📦 deps(thirdparty): update snapshots
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
"use strict";
|
||||
|
||||
const zlib = require("node:zlib");
|
||||
const { cacheError } = require("./identity");
|
||||
const { collisionKey, validateRelativeAssetPath } = require("./scan");
|
||||
|
||||
const DEFAULT_ARCHIVE_LIMITS = Object.freeze({
|
||||
maxEntries: 10000,
|
||||
maxSingleFileBytes: 32 * 1024 * 1024,
|
||||
maxExpandedTotalBytes: 160 * 1024 * 1024,
|
||||
maxCompressionRatio: 128,
|
||||
});
|
||||
|
||||
function parseOctal(buffer, field) {
|
||||
const text = buffer.toString("ascii").replace(/\0.*$/, "").trim();
|
||||
if (!/^[0-7]+$/.test(text)) throw cacheError("AAS_ARCHIVE_HEADER_INVALID", `invalid ${field}`);
|
||||
const value = Number.parseInt(text, 8);
|
||||
if (!Number.isSafeInteger(value) || value < 0) throw cacheError("AAS_ARCHIVE_HEADER_INVALID", `unsafe ${field}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function decodeField(buffer) {
|
||||
const end = buffer.indexOf(0);
|
||||
const bytes = end === -1 ? buffer : buffer.subarray(0, end);
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
} catch {
|
||||
throw cacheError("AAS_ARCHIVE_HEADER_INVALID", "archive path is not valid UTF-8");
|
||||
}
|
||||
}
|
||||
|
||||
function safeArchivePath(value) {
|
||||
if (typeof value !== "string" || value.includes("\\") || value.startsWith("/")
|
||||
|| /^[A-Za-z]:/.test(value) || value.startsWith("//")) {
|
||||
throw cacheError("AAS_ARCHIVE_PATH_INVALID", "archive path is absolute or platform-ambiguous");
|
||||
}
|
||||
const withoutDirectorySlash = value.replace(/\/$/, "");
|
||||
for (const segment of withoutDirectorySlash.split("/")) {
|
||||
const deviceBase = segment.split(".")[0].toUpperCase();
|
||||
if (!segment || /[\u0000-\u001f<>:"|?*]/u.test(segment) || /[ .]$/.test(segment)
|
||||
|| /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9]|CONIN\$|CONOUT\$)$/.test(deviceBase)) {
|
||||
throw cacheError("AAS_ARCHIVE_PATH_INVALID", "archive path is unsafe on a supported filesystem");
|
||||
}
|
||||
}
|
||||
try {
|
||||
return validateRelativeAssetPath(withoutDirectorySlash);
|
||||
} catch {
|
||||
throw cacheError("AAS_ARCHIVE_PATH_INVALID", "archive path contains traversal or invalid segments");
|
||||
}
|
||||
}
|
||||
|
||||
function parsePax(bytes) {
|
||||
const values = {};
|
||||
let offset = 0;
|
||||
while (offset < bytes.length) {
|
||||
const space = bytes.indexOf(0x20, offset);
|
||||
if (space < 0) throw cacheError("AAS_ARCHIVE_PAX_INVALID", "PAX record length is missing");
|
||||
const lengthText = bytes.subarray(offset, space).toString("ascii");
|
||||
if (!/^[1-9][0-9]*$/.test(lengthText)) throw cacheError("AAS_ARCHIVE_PAX_INVALID", "PAX record length is invalid");
|
||||
const length = Number(lengthText);
|
||||
if (!Number.isSafeInteger(length) || length < 5 || offset + length > bytes.length || bytes[offset + length - 1] !== 0x0a) {
|
||||
throw cacheError("AAS_ARCHIVE_PAX_INVALID", "PAX record is truncated");
|
||||
}
|
||||
const record = bytes.subarray(space + 1, offset + length - 1).toString("utf8");
|
||||
const equals = record.indexOf("=");
|
||||
if (equals < 1) throw cacheError("AAS_ARCHIVE_PAX_INVALID", "PAX record has no key");
|
||||
const key = record.slice(0, equals);
|
||||
const value = record.slice(equals + 1);
|
||||
if (Object.hasOwn(values, key)) throw cacheError("AAS_ARCHIVE_PAX_INVALID", "duplicate PAX key");
|
||||
values[key] = value;
|
||||
offset += length;
|
||||
}
|
||||
if (values.linkpath !== undefined) throw cacheError("AAS_ARCHIVE_LINK_FORBIDDEN", "PAX linkpath is forbidden");
|
||||
return values;
|
||||
}
|
||||
|
||||
function assertArchiveMode(mode) {
|
||||
if ((mode & 0o7000) !== 0 || (mode & 0o022) !== 0) {
|
||||
throw cacheError("AAS_ARCHIVE_MODE_UNSAFE", "archive entry has anomalous permissions");
|
||||
}
|
||||
}
|
||||
|
||||
function parseTar(tarBytes, options = {}) {
|
||||
const limits = { ...DEFAULT_ARCHIVE_LIMITS, ...(options.limits || {}) };
|
||||
const selected = options.selectPaths ? new Set(options.selectPaths.map(safeArchivePath)) : null;
|
||||
const entries = [];
|
||||
const seen = new Map();
|
||||
const collisionKeys = new Map();
|
||||
let offset = 0;
|
||||
let fileCount = 0;
|
||||
let expandedBytes = 0;
|
||||
let pendingPath = null;
|
||||
let zeroBlocks = 0;
|
||||
while (offset + 512 <= tarBytes.length) {
|
||||
const header = tarBytes.subarray(offset, offset + 512);
|
||||
offset += 512;
|
||||
if (header.every((byte) => byte === 0)) {
|
||||
zeroBlocks += 1;
|
||||
if (zeroBlocks === 2) break;
|
||||
continue;
|
||||
}
|
||||
zeroBlocks = 0;
|
||||
const expectedChecksum = parseOctal(header.subarray(148, 156), "checksum");
|
||||
let checksum = 0;
|
||||
for (let index = 0; index < 512; index += 1) checksum += index >= 148 && index < 156 ? 0x20 : header[index];
|
||||
if (checksum !== expectedChecksum) throw cacheError("AAS_ARCHIVE_CHECKSUM_INVALID", "archive header checksum mismatch");
|
||||
const size = parseOctal(header.subarray(124, 136), "size");
|
||||
const mode = parseOctal(header.subarray(100, 108), "mode");
|
||||
assertArchiveMode(mode);
|
||||
const type = String.fromCharCode(header[156] || 0);
|
||||
const name = decodeField(header.subarray(0, 100));
|
||||
const prefix = decodeField(header.subarray(345, 500));
|
||||
let archivePath = pendingPath || (prefix ? `${prefix}/${name}` : name);
|
||||
pendingPath = null;
|
||||
const paddedSize = Math.ceil(size / 512) * 512;
|
||||
if (offset + paddedSize > tarBytes.length) throw cacheError("AAS_ARCHIVE_TRUNCATED", "archive entry is truncated");
|
||||
const body = tarBytes.subarray(offset, offset + size);
|
||||
offset += paddedSize;
|
||||
if (type === "x" || type === "g") {
|
||||
if (size > 64 * 1024) throw cacheError("AAS_ARCHIVE_PAX_INVALID", "PAX metadata is too large");
|
||||
const pax = parsePax(body);
|
||||
if (pax.path !== undefined) pendingPath = pax.path;
|
||||
continue;
|
||||
}
|
||||
if (type === "L") {
|
||||
if (size > 4096) throw cacheError("AAS_ARCHIVE_PATH_INVALID", "GNU long path is too large");
|
||||
pendingPath = decodeField(body).replace(/\0$/, "");
|
||||
continue;
|
||||
}
|
||||
archivePath = safeArchivePath(archivePath);
|
||||
const directory = type === "5";
|
||||
const regular = type === "0" || type === "\0";
|
||||
if (!directory && !regular) throw cacheError("AAS_ARCHIVE_SPECIAL_FILE_FORBIDDEN", "archive links and special entries are forbidden");
|
||||
if (directory && size !== 0) throw cacheError("AAS_ARCHIVE_HEADER_INVALID", "directory entry has data");
|
||||
fileCount += regular ? 1 : 0;
|
||||
expandedBytes += regular ? size : 0;
|
||||
if (fileCount > limits.maxEntries) throw cacheError("AAS_ARCHIVE_ENTRY_LIMIT", "archive exceeds the file-count limit");
|
||||
if (regular && size > limits.maxSingleFileBytes) throw cacheError("AAS_ARCHIVE_FILE_LIMIT", "archive file exceeds the size limit");
|
||||
if (expandedBytes > limits.maxExpandedTotalBytes) throw cacheError("AAS_ARCHIVE_TOTAL_LIMIT", "archive exceeds the expanded-byte limit");
|
||||
const kind = directory ? "directory" : "file";
|
||||
if (seen.has(archivePath)) throw cacheError("AAS_ARCHIVE_DUPLICATE_PATH", "archive contains a duplicate path");
|
||||
const key = collisionKey(archivePath);
|
||||
if (collisionKeys.has(key)) throw cacheError("AAS_ARCHIVE_PATH_COLLISION", "archive paths collide by case or Unicode normalization");
|
||||
for (const [existingPath, existingKind] of seen) {
|
||||
if ((archivePath.startsWith(`${existingPath}/`) && existingKind === "file")
|
||||
|| (existingPath.startsWith(`${archivePath}/`) && kind === "file")) {
|
||||
throw cacheError("AAS_ARCHIVE_FILE_DIRECTORY_COLLISION", "archive file and directory paths collide");
|
||||
}
|
||||
}
|
||||
seen.set(archivePath, kind);
|
||||
collisionKeys.set(key, archivePath);
|
||||
if (regular && (!selected || selected.has(archivePath))) entries.push({ path: archivePath, mode, bytes: Buffer.from(body) });
|
||||
}
|
||||
if (zeroBlocks < 2) throw cacheError("AAS_ARCHIVE_TRUNCATED", "archive end markers are missing");
|
||||
if (pendingPath !== null) throw cacheError("AAS_ARCHIVE_TRUNCATED", "archive ended after path metadata");
|
||||
if (selected) {
|
||||
const found = new Set(entries.map((entry) => entry.path));
|
||||
const missing = [...selected].filter((entry) => !found.has(entry));
|
||||
if (missing.length) throw cacheError("AAS_ARCHIVE_ASSET_MISSING", `archive asset is missing: ${missing[0]}`);
|
||||
}
|
||||
return { entries, fileCount, expandedBytes };
|
||||
}
|
||||
|
||||
function parsePackageArchive(archiveBytes, options = {}) {
|
||||
const limits = { ...DEFAULT_ARCHIVE_LIMITS, ...(options.limits || {}) };
|
||||
const gzip = archiveBytes[0] === 0x1f && archiveBytes[1] === 0x8b;
|
||||
let tarBytes = archiveBytes;
|
||||
if (gzip) {
|
||||
try {
|
||||
tarBytes = zlib.gunzipSync(archiveBytes, { maxOutputLength: limits.maxExpandedTotalBytes + 1024 * 1024 });
|
||||
} catch (cause) {
|
||||
throw cacheError("AAS_ARCHIVE_DECOMPRESSION_FAILED", "archive decompression failed", cause);
|
||||
}
|
||||
if (tarBytes.length > archiveBytes.length * limits.maxCompressionRatio) {
|
||||
throw cacheError("AAS_ARCHIVE_COMPRESSION_RATIO", "archive exceeds the compression-ratio limit");
|
||||
}
|
||||
}
|
||||
return parseTar(tarBytes, { ...options, limits });
|
||||
}
|
||||
|
||||
module.exports = { DEFAULT_ARCHIVE_LIMITS, parsePackageArchive, parsePax, parseTar, safeArchivePath };
|
||||
@@ -0,0 +1,118 @@
|
||||
"use strict";
|
||||
|
||||
const path = require("node:path");
|
||||
|
||||
const CATALOG_PACKAGE = "agentic-awesome-skills";
|
||||
const CATALOG_IDENTITY_FILE = ".aas-catalog-identity.json";
|
||||
const RUNTIME_IDENTITY_FILE = ".aas-runtime-identity.json";
|
||||
const DIGEST_VERSION = 1;
|
||||
const SRI_LENGTHS = Object.freeze({ sha256: 32, sha384: 48, sha512: 64 });
|
||||
|
||||
function cacheError(code, message) {
|
||||
const error = new Error(message);
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
function parseNpmIntegrity(integrity) {
|
||||
if (typeof integrity !== "string" || integrity.trim() !== integrity || /\s/.test(integrity)) {
|
||||
throw cacheError("AAS_CACHE_INTEGRITY_INVALID", "npm integrity must be one canonical SRI token");
|
||||
}
|
||||
const match = /^(sha256|sha384|sha512)-([A-Za-z0-9+/]+={0,2})$/.exec(integrity);
|
||||
if (!match) throw cacheError("AAS_CACHE_INTEGRITY_INVALID", "npm integrity is not a supported SRI token");
|
||||
const [, algorithm, encoded] = match;
|
||||
const bytes = Buffer.from(encoded, "base64");
|
||||
if (bytes.length !== SRI_LENGTHS[algorithm]) {
|
||||
throw cacheError("AAS_CACHE_INTEGRITY_INVALID", `npm ${algorithm} integrity has the wrong digest length`);
|
||||
}
|
||||
const canonical = bytes.toString("base64");
|
||||
if (canonical !== encoded) {
|
||||
throw cacheError("AAS_CACHE_INTEGRITY_INVALID", "npm integrity digest is not canonical base64");
|
||||
}
|
||||
return { algorithm, bytes, integrity };
|
||||
}
|
||||
|
||||
function filesystemSafeIntegrityKey(integrity) {
|
||||
const parsed = parseNpmIntegrity(integrity);
|
||||
return `${parsed.algorithm}-${parsed.bytes.toString("base64url")}`;
|
||||
}
|
||||
|
||||
function validatePackageVersion(version) {
|
||||
if (typeof version !== "string" || version.length > 128 || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.test(version)) {
|
||||
throw cacheError("AAS_CACHE_VERSION_INVALID", "package version must be a canonical SemVer value");
|
||||
}
|
||||
return version;
|
||||
}
|
||||
|
||||
function validateCatalogDigest(digest) {
|
||||
if (typeof digest !== "string" || !/^sha256-[0-9a-f]{64}$/.test(digest)) {
|
||||
throw cacheError("AAS_CACHE_CATALOG_DIGEST_INVALID", "catalog digest must be canonical sha256 hex");
|
||||
}
|
||||
return digest;
|
||||
}
|
||||
|
||||
function validateCacheRoot(cacheRoot) {
|
||||
if (typeof cacheRoot !== "string" || cacheRoot.length === 0 || cacheRoot.includes("\0")) {
|
||||
throw cacheError("AAS_CACHE_ROOT_INVALID", "cache root must be a non-empty filesystem path");
|
||||
}
|
||||
return path.resolve(cacheRoot);
|
||||
}
|
||||
|
||||
function runtimeCachePath({ cacheRoot, packageVersion, integrity }) {
|
||||
return path.join(
|
||||
validateCacheRoot(cacheRoot),
|
||||
"runtimes",
|
||||
validatePackageVersion(packageVersion),
|
||||
filesystemSafeIntegrityKey(integrity),
|
||||
);
|
||||
}
|
||||
|
||||
function catalogCachePath({ cacheRoot, packageVersion, catalogDigest }) {
|
||||
return path.join(
|
||||
validateCacheRoot(cacheRoot),
|
||||
"catalogs",
|
||||
validatePackageVersion(packageVersion),
|
||||
validateCatalogDigest(catalogDigest),
|
||||
);
|
||||
}
|
||||
|
||||
function validateCatalogIdentity(identity, expected = {}) {
|
||||
if (!identity || typeof identity !== "object" || Array.isArray(identity)) {
|
||||
throw cacheError("AAS_CACHE_IDENTITY_INVALID", "catalog identity must be an object");
|
||||
}
|
||||
const allowedKeys = new Set(["schemaVersion", "package", "version", "integrity", "catalogDigest"]);
|
||||
for (const key of Object.keys(identity)) {
|
||||
if (!allowedKeys.has(key)) throw cacheError("AAS_CACHE_IDENTITY_INVALID", `unknown catalog identity field: ${key}`);
|
||||
}
|
||||
if (identity.schemaVersion !== 1) throw cacheError("AAS_CACHE_IDENTITY_INVALID", "catalog identity schemaVersion must be 1");
|
||||
if (identity.package !== CATALOG_PACKAGE) throw cacheError("AAS_CACHE_IDENTITY_INVALID", `catalog package must be ${CATALOG_PACKAGE}`);
|
||||
const normalized = {
|
||||
schemaVersion: 1,
|
||||
package: CATALOG_PACKAGE,
|
||||
version: validatePackageVersion(identity.version),
|
||||
integrity: parseNpmIntegrity(identity.integrity).integrity,
|
||||
catalogDigest: validateCatalogDigest(identity.catalogDigest),
|
||||
};
|
||||
for (const key of ["package", "version", "integrity", "catalogDigest"]) {
|
||||
if (expected[key] !== undefined && normalized[key] !== expected[key]) {
|
||||
throw cacheError("AAS_CACHE_IDENTITY_MISMATCH", `catalog identity ${key} does not match the expected value`);
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CATALOG_IDENTITY_FILE,
|
||||
CATALOG_PACKAGE,
|
||||
RUNTIME_IDENTITY_FILE,
|
||||
DIGEST_VERSION,
|
||||
cacheError,
|
||||
catalogCachePath,
|
||||
filesystemSafeIntegrityKey,
|
||||
parseNpmIntegrity,
|
||||
runtimeCachePath,
|
||||
validateCacheRoot,
|
||||
validateCatalogDigest,
|
||||
validateCatalogIdentity,
|
||||
validatePackageVersion,
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
...require("./archive"),
|
||||
...require("./identity"),
|
||||
...require("./promote"),
|
||||
...require("./resolver"),
|
||||
...require("./runtime"),
|
||||
...require("./scan"),
|
||||
...require("./status"),
|
||||
...require("./update"),
|
||||
};
|
||||
@@ -0,0 +1,139 @@
|
||||
"use strict";
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
const fsp = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { fsyncDirectoryAsync } = require("../durability");
|
||||
const { canonicalJson } = require("../canonical-json");
|
||||
const {
|
||||
CATALOG_IDENTITY_FILE,
|
||||
DIGEST_VERSION,
|
||||
cacheError,
|
||||
catalogCachePath,
|
||||
validateCacheRoot,
|
||||
validateCatalogIdentity,
|
||||
} = require("./identity");
|
||||
const { scanDataDirectory } = require("./scan");
|
||||
const { catalogStatus } = require("./status");
|
||||
|
||||
async function fsyncDirectory(directoryPath) {
|
||||
await fsyncDirectoryAsync(directoryPath);
|
||||
}
|
||||
|
||||
async function writeFileCrashSafe(filePath, bytes) {
|
||||
const handle = await fsp.open(filePath, "wx", 0o600);
|
||||
try {
|
||||
await handle.writeFile(bytes);
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureRealDirectory(directoryPath, created) {
|
||||
try {
|
||||
const stat = await fsp.lstat(directoryPath);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) throw cacheError("AAS_CACHE_DIRECTORY_UNSAFE", `cache path is not a real directory: ${directoryPath}`);
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
const parent = path.dirname(directoryPath);
|
||||
if (parent !== directoryPath) await ensureRealDirectory(parent, created);
|
||||
try {
|
||||
await fsp.mkdir(directoryPath, { mode: 0o700 });
|
||||
created.push(directoryPath);
|
||||
} catch (mkdirError) {
|
||||
if (mkdirError.code !== "EEXIST") throw mkdirError;
|
||||
const stat = await fsp.lstat(directoryPath);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) throw cacheError("AAS_CACHE_DIRECTORY_UNSAFE", `cache path is not a real directory: ${directoryPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function removeCreatedEmptyDirectories(created) {
|
||||
for (const directoryPath of [...created].reverse()) {
|
||||
try {
|
||||
await fsp.rmdir(directoryPath);
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT" && error.code !== "ENOTEMPTY") throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function promoteCatalogDirectory({ cacheRoot, sourceDir, allowlist, controlPaths = [], identity, limits }) {
|
||||
// Validate every untrusted input byte before the cache filesystem is mutated.
|
||||
const scan = await scanDataDirectory({ sourceDir, allowlist, ignoredPaths: controlPaths, limits });
|
||||
const normalizedIdentity = validateCatalogIdentity(identity);
|
||||
if (normalizedIdentity.catalogDigest !== scan.catalogDigest) {
|
||||
throw cacheError("AAS_CACHE_CATALOG_DIGEST_MISMATCH", "catalog content does not match the approved catalog digest");
|
||||
}
|
||||
|
||||
const existing = await catalogStatus({
|
||||
cacheRoot,
|
||||
packageVersion: normalizedIdentity.version,
|
||||
catalogDigest: normalizedIdentity.catalogDigest,
|
||||
integrity: normalizedIdentity.integrity,
|
||||
});
|
||||
if (existing.status === "verified") return { status: "alreadyPresent", identity: existing.identity, targetPath: existing.targetPath };
|
||||
if (existing.status === "invalid") throw cacheError("AAS_CACHE_EXISTING_INVALID", "an invalid object already occupies the immutable catalog cache key");
|
||||
|
||||
const root = validateCacheRoot(cacheRoot);
|
||||
const targetPath = catalogCachePath({
|
||||
cacheRoot: root,
|
||||
packageVersion: normalizedIdentity.version,
|
||||
catalogDigest: normalizedIdentity.catalogDigest,
|
||||
});
|
||||
const versionDirectory = path.dirname(targetPath);
|
||||
const created = [];
|
||||
let stagePath;
|
||||
let promoted = false;
|
||||
try {
|
||||
await ensureRealDirectory(versionDirectory, created);
|
||||
stagePath = path.join(versionDirectory, `.stage-${process.pid}-${crypto.randomBytes(12).toString("hex")}`);
|
||||
await fsp.mkdir(stagePath, { mode: 0o700 });
|
||||
|
||||
const madeDirectories = new Set([stagePath]);
|
||||
for (const record of [...scan.records, ...scan.ignoredRecords]) {
|
||||
const segments = record.path.split("/");
|
||||
const outputPath = path.join(stagePath, ...segments);
|
||||
let cursor = stagePath;
|
||||
for (const segment of segments.slice(0, -1)) {
|
||||
cursor = path.join(cursor, segment);
|
||||
if (!madeDirectories.has(cursor)) {
|
||||
await fsp.mkdir(cursor, { mode: 0o700 });
|
||||
madeDirectories.add(cursor);
|
||||
}
|
||||
}
|
||||
await writeFileCrashSafe(outputPath, record.bytes);
|
||||
}
|
||||
const storedIdentity = {
|
||||
...normalizedIdentity,
|
||||
digestVersion: DIGEST_VERSION,
|
||||
assets: scan.publicRecords,
|
||||
controls: scan.publicIgnoredRecords,
|
||||
};
|
||||
await writeFileCrashSafe(path.join(stagePath, CATALOG_IDENTITY_FILE), Buffer.from(`${canonicalJson(storedIdentity)}\n`));
|
||||
for (const directoryPath of [...madeDirectories].sort((left, right) => right.length - left.length)) await fsyncDirectory(directoryPath);
|
||||
|
||||
try {
|
||||
await fsp.rename(stagePath, targetPath);
|
||||
promoted = true;
|
||||
} catch (error) {
|
||||
if (error.code !== "EEXIST" && error.code !== "ENOTEMPTY") throw error;
|
||||
const raced = await catalogStatus({
|
||||
cacheRoot: root,
|
||||
packageVersion: normalizedIdentity.version,
|
||||
catalogDigest: normalizedIdentity.catalogDigest,
|
||||
integrity: normalizedIdentity.integrity,
|
||||
});
|
||||
if (raced.status !== "verified") throw cacheError("AAS_CACHE_PROMOTION_CONFLICT", "catalog cache key was occupied during promotion");
|
||||
return { status: "alreadyPresent", identity: raced.identity, targetPath: raced.targetPath };
|
||||
}
|
||||
await fsyncDirectory(versionDirectory);
|
||||
return { status: "promoted", identity: storedIdentity, targetPath };
|
||||
} finally {
|
||||
if (stagePath && !promoted) await fsp.rm(stagePath, { recursive: true, force: true });
|
||||
if (!promoted) await removeCreatedEmptyDirectories(created);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { promoteCatalogDirectory };
|
||||
@@ -0,0 +1,59 @@
|
||||
"use strict";
|
||||
|
||||
const fsp = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { loadBundledCatalog } = require("../catalog");
|
||||
const { catalogStatus } = require("./status");
|
||||
const { validateCacheRoot, validateCatalogDigest } = require("./identity");
|
||||
|
||||
function compareStrings(left, right) {
|
||||
return left < right ? -1 : (left > right ? 1 : 0);
|
||||
}
|
||||
|
||||
async function realDirectories(directory) {
|
||||
let entries;
|
||||
try {
|
||||
entries = await fsp.readdir(directory, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return [];
|
||||
throw error;
|
||||
}
|
||||
return entries.filter((entry) => entry.isDirectory() && !entry.isSymbolicLink())
|
||||
.map((entry) => entry.name).sort(compareStrings);
|
||||
}
|
||||
|
||||
function createVerifiedCatalogResolver({ cacheRoot, bundledRoot, maximumVersions = 64 } = {}) {
|
||||
const bundled = loadBundledCatalog({ root: bundledRoot });
|
||||
const root = cacheRoot ? validateCacheRoot(cacheRoot) : null;
|
||||
return async function resolveCatalog(digest) {
|
||||
validateCatalogDigest(digest);
|
||||
if (digest === bundled.digest) return bundled;
|
||||
if (!root) return null;
|
||||
const versions = await realDirectories(path.join(root, "catalogs"));
|
||||
if (versions.length > maximumVersions) {
|
||||
const error = new Error("verified catalog cache exceeds the resolver limit");
|
||||
error.code = "AAS_CACHE_RESOLVER_LIMIT";
|
||||
throw error;
|
||||
}
|
||||
const matches = [];
|
||||
for (const version of versions) {
|
||||
const status = await catalogStatus({ cacheRoot: root, packageVersion: version, catalogDigest: digest });
|
||||
if (status.status === "verified") matches.push(status);
|
||||
}
|
||||
if (matches.length === 0) return null;
|
||||
if (matches.length > 1) {
|
||||
const error = new Error("catalog digest resolves to multiple verified cache identities");
|
||||
error.code = "AAS_CACHE_RESOLVER_AMBIGUOUS";
|
||||
throw error;
|
||||
}
|
||||
const catalog = loadBundledCatalog({ root: matches[0].targetPath });
|
||||
if (catalog.digest !== digest) {
|
||||
const error = new Error("resolved catalog digest changed after verification");
|
||||
error.code = "AAS_CACHE_RESOLVER_DRIFT";
|
||||
throw error;
|
||||
}
|
||||
return catalog;
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createVerifiedCatalogResolver, realDirectories };
|
||||
@@ -0,0 +1,381 @@
|
||||
"use strict";
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const fsp = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { fsyncDirectoryAsync } = require("../durability");
|
||||
const { canonicalJson, sha256 } = require("../canonical-json");
|
||||
const { parsePackageArchive } = require("./archive");
|
||||
const {
|
||||
CATALOG_PACKAGE,
|
||||
DIGEST_VERSION,
|
||||
RUNTIME_IDENTITY_FILE,
|
||||
cacheError,
|
||||
parseNpmIntegrity,
|
||||
runtimeCachePath,
|
||||
validateCacheRoot,
|
||||
validatePackageVersion,
|
||||
} = require("./identity");
|
||||
const { REGISTRY_ORIGIN, fetchBytes, verifySri } = require("./update");
|
||||
|
||||
const MAX_RUNTIME_IDENTITY_BYTES = 4 * 1024 * 1024;
|
||||
const RUNTIME_ARCHIVE_LIMITS = Object.freeze({
|
||||
maxEntries: 10000,
|
||||
maxSingleFileBytes: 32 * 1024 * 1024,
|
||||
maxExpandedTotalBytes: 160 * 1024 * 1024,
|
||||
maxCompressionRatio: 128,
|
||||
});
|
||||
const REQUIRED_RUNTIME_FILES = Object.freeze([
|
||||
"package.json",
|
||||
"tools/bin/aas-mcp.js",
|
||||
"tools/lib/aas-v1/index.js",
|
||||
"data/aas-v1/catalog-manifest.v1.json",
|
||||
"data/catalog.json",
|
||||
"data/plugin-compatibility.json",
|
||||
"skills_index.json",
|
||||
]);
|
||||
const REQUIRED_BUNDLED_DEPENDENCIES = Object.freeze(["ajv", "sanitize-filename", "yaml"]);
|
||||
|
||||
function allowedRuntimeAsset(relativePath) {
|
||||
return relativePath === "package.json"
|
||||
|| relativePath === "tools/bin/aas-mcp.js"
|
||||
|| relativePath.startsWith("tools/lib/aas-v1/")
|
||||
|| relativePath.startsWith("data/aas-v1/")
|
||||
|| relativePath === "data/catalog.json"
|
||||
|| relativePath === "data/plugin-compatibility.json"
|
||||
|| relativePath === "skills_index.json"
|
||||
|| relativePath.startsWith("skills/")
|
||||
|| relativePath.startsWith("schemas/aas-v1/")
|
||||
|| relativePath.startsWith("node_modules/");
|
||||
}
|
||||
|
||||
function privateModeUnsafe(stat) {
|
||||
return process.platform !== "win32" && (stat.mode & 0o077) !== 0;
|
||||
}
|
||||
|
||||
function publicRuntimeIdentity(identity) {
|
||||
return Object.freeze({
|
||||
package: identity.package,
|
||||
version: identity.version,
|
||||
integrity: identity.integrity,
|
||||
closureDigest: identity.closureDigest,
|
||||
});
|
||||
}
|
||||
|
||||
function validateRuntimeIdentity(identity, expected = {}) {
|
||||
if (!identity || typeof identity !== "object" || Array.isArray(identity)) {
|
||||
throw cacheError("AAS_RUNTIME_IDENTITY_INVALID", "runtime identity must be an object");
|
||||
}
|
||||
const allowed = new Set(["schemaVersion", "package", "version", "integrity", "closureDigest", "digestVersion", "assets", "provenance"]);
|
||||
for (const key of Object.keys(identity)) {
|
||||
if (!allowed.has(key)) throw cacheError("AAS_RUNTIME_IDENTITY_INVALID", `unknown runtime identity field: ${key}`);
|
||||
}
|
||||
if (identity.schemaVersion !== 1 || identity.package !== CATALOG_PACKAGE || identity.digestVersion !== DIGEST_VERSION) {
|
||||
throw cacheError("AAS_RUNTIME_IDENTITY_INVALID", "runtime identity is incompatible");
|
||||
}
|
||||
const version = validatePackageVersion(identity.version);
|
||||
const integrity = parseNpmIntegrity(identity.integrity).integrity;
|
||||
if (typeof identity.closureDigest !== "string" || !/^sha256-[0-9a-f]{64}$/.test(identity.closureDigest)) {
|
||||
throw cacheError("AAS_RUNTIME_IDENTITY_INVALID", "runtime closure digest is invalid");
|
||||
}
|
||||
if (!Array.isArray(identity.assets) || identity.assets.length === 0 || identity.assets.length > RUNTIME_ARCHIVE_LIMITS.maxEntries) {
|
||||
throw cacheError("AAS_RUNTIME_IDENTITY_INVALID", "runtime asset records are invalid");
|
||||
}
|
||||
const seen = new Set();
|
||||
const assets = identity.assets.map((record) => {
|
||||
if (!record || typeof record !== "object" || Array.isArray(record)
|
||||
|| Object.keys(record).sort().join(",") !== "path,sha256,size"
|
||||
|| typeof record.path !== "string" || !record.path.startsWith("package/") || record.path.includes("\\")
|
||||
|| record.path.split("/").some((part) => !part || part === "." || part === "..")
|
||||
|| !Number.isSafeInteger(record.size) || record.size < 0
|
||||
|| !/^sha256-[0-9a-f]{64}$/.test(record.sha256) || seen.has(record.path)) {
|
||||
throw cacheError("AAS_RUNTIME_IDENTITY_INVALID", "runtime asset record is invalid");
|
||||
}
|
||||
seen.add(record.path);
|
||||
return { path: record.path, size: record.size, sha256: record.sha256 };
|
||||
}).sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
|
||||
const provenance = identity.provenance;
|
||||
if (!provenance || typeof provenance !== "object" || Array.isArray(provenance)
|
||||
|| Object.keys(provenance).sort().join(",") !== "attestationsPresent,registryOrigin,signaturesPresent"
|
||||
|| provenance.registryOrigin !== REGISTRY_ORIGIN || typeof provenance.signaturesPresent !== "boolean"
|
||||
|| typeof provenance.attestationsPresent !== "boolean") {
|
||||
throw cacheError("AAS_RUNTIME_IDENTITY_INVALID", "runtime provenance is invalid");
|
||||
}
|
||||
const normalized = {
|
||||
schemaVersion: 1,
|
||||
package: CATALOG_PACKAGE,
|
||||
version,
|
||||
integrity,
|
||||
closureDigest: identity.closureDigest,
|
||||
digestVersion: DIGEST_VERSION,
|
||||
assets,
|
||||
provenance: { ...provenance },
|
||||
};
|
||||
if (sha256(canonicalJson({ digestVersion: DIGEST_VERSION, assets })) !== normalized.closureDigest) {
|
||||
throw cacheError("AAS_RUNTIME_IDENTITY_INVALID", "runtime asset records do not match the closure digest");
|
||||
}
|
||||
for (const key of ["package", "version", "integrity", "closureDigest"]) {
|
||||
if (expected[key] !== undefined && normalized[key] !== expected[key]) {
|
||||
throw cacheError("AAS_RUNTIME_IDENTITY_MISMATCH", `runtime identity ${key} does not match the expected value`);
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function releaseMetadataUrl(version) {
|
||||
return `${REGISTRY_ORIGIN}/${CATALOG_PACKAGE}/${validatePackageVersion(version)}`;
|
||||
}
|
||||
|
||||
async function inspectRuntimeRelease({ version, fetcher = fetchBytes }) {
|
||||
const packageVersion = validatePackageVersion(version);
|
||||
const bytes = await fetcher(releaseMetadataUrl(packageVersion), 2 * 1024 * 1024);
|
||||
let metadata;
|
||||
try { metadata = JSON.parse(bytes.toString("utf8")); } catch {
|
||||
throw cacheError("AAS_RUNTIME_PACKUMENT_INVALID", "registry metadata is invalid JSON");
|
||||
}
|
||||
const dist = metadata?.dist;
|
||||
if (metadata?.name !== CATALOG_PACKAGE || metadata?.version !== packageVersion
|
||||
|| !dist || typeof dist.integrity !== "string" || typeof dist.tarball !== "string") {
|
||||
throw cacheError("AAS_RUNTIME_PACKUMENT_INVALID", "registry metadata lacks the exact runtime identity");
|
||||
}
|
||||
const integrity = parseNpmIntegrity(dist.integrity).integrity;
|
||||
const tarball = new URL(dist.tarball);
|
||||
if (tarball.origin !== REGISTRY_ORIGIN || tarball.protocol !== "https:") {
|
||||
throw cacheError("AAS_RUNTIME_TARBALL_URL_INVALID", "runtime tarball URL is outside the pinned npm origin");
|
||||
}
|
||||
return {
|
||||
package: CATALOG_PACKAGE,
|
||||
version: packageVersion,
|
||||
integrity,
|
||||
tarballUrl: tarball.href,
|
||||
provenance: {
|
||||
registryOrigin: REGISTRY_ORIGIN,
|
||||
distIntegrity: integrity,
|
||||
signaturesPresent: Array.isArray(dist.signatures) && dist.signatures.length > 0,
|
||||
attestationsPresent: Boolean(dist.attestations),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeRecords(entries, version) {
|
||||
if (entries.some((entry) => !entry.path.startsWith("package/"))) {
|
||||
throw cacheError("AAS_RUNTIME_ARCHIVE_ROOT_INVALID", "runtime archive contains a file outside the npm package root");
|
||||
}
|
||||
const selectedEntries = entries.filter((entry) => allowedRuntimeAsset(entry.path.slice("package/".length)));
|
||||
const byPath = new Map(selectedEntries.map((entry) => [entry.path.slice("package/".length), entry]));
|
||||
for (const required of REQUIRED_RUNTIME_FILES) {
|
||||
if (!byPath.has(required)) throw cacheError("AAS_RUNTIME_FILE_MISSING", `runtime archive is missing ${required}`);
|
||||
}
|
||||
let metadata;
|
||||
try { metadata = JSON.parse(byPath.get("package.json").bytes.toString("utf8")); } catch {
|
||||
throw cacheError("AAS_RUNTIME_PACKAGE_INVALID", "runtime package metadata is invalid");
|
||||
}
|
||||
const bin = typeof metadata.bin === "string" ? { [CATALOG_PACKAGE]: metadata.bin } : metadata.bin;
|
||||
if (metadata.name !== CATALOG_PACKAGE || metadata.version !== version || bin?.["aas-mcp"] !== "tools/bin/aas-mcp.js") {
|
||||
throw cacheError("AAS_RUNTIME_PACKAGE_INVALID", "runtime package metadata does not expose the expected MCP binary");
|
||||
}
|
||||
const bundled = metadata.bundledDependencies || metadata.bundleDependencies;
|
||||
if (!Array.isArray(bundled)
|
||||
|| REQUIRED_BUNDLED_DEPENDENCIES.some((dependency) => !bundled.includes(dependency))
|
||||
|| REQUIRED_BUNDLED_DEPENDENCIES.some((dependency) => !byPath.has(`node_modules/${dependency}/package.json`))) {
|
||||
throw cacheError("AAS_RUNTIME_DEPENDENCY_CLOSURE_MISSING", "runtime package lacks its declared verified dependency closure");
|
||||
}
|
||||
const records = selectedEntries.map((entry) => ({ path: entry.path, size: entry.bytes.length, sha256: sha256(entry.bytes), bytes: entry.bytes }))
|
||||
.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
|
||||
const assets = records.map(({ path: assetPath, size, sha256: digest }) => ({ path: assetPath, size, sha256: digest }));
|
||||
return { records, assets, closureDigest: sha256(canonicalJson({ digestVersion: DIGEST_VERSION, assets })) };
|
||||
}
|
||||
|
||||
async function ensureRealDirectory(directoryPath, created) {
|
||||
try {
|
||||
const stat = await fsp.lstat(directoryPath);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink() || privateModeUnsafe(stat)) {
|
||||
throw cacheError("AAS_RUNTIME_DIRECTORY_UNSAFE", "runtime cache path is not a private real directory");
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
const parent = path.dirname(directoryPath);
|
||||
if (parent !== directoryPath) await ensureRealDirectory(parent, created);
|
||||
try {
|
||||
await fsp.mkdir(directoryPath, { mode: 0o700 });
|
||||
created.push(directoryPath);
|
||||
} catch (mkdirError) {
|
||||
if (mkdirError.code !== "EEXIST") throw mkdirError;
|
||||
const stat = await fsp.lstat(directoryPath);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink() || privateModeUnsafe(stat)) {
|
||||
throw cacheError("AAS_RUNTIME_DIRECTORY_UNSAFE", "runtime cache path is not a private real directory");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fsyncDirectory(directoryPath) {
|
||||
await fsyncDirectoryAsync(directoryPath);
|
||||
}
|
||||
|
||||
async function writeExclusive(filePath, bytes, { sync = true } = {}) {
|
||||
const handle = await fsp.open(filePath, "wx", 0o600);
|
||||
try {
|
||||
await handle.writeFile(bytes);
|
||||
await handle.chmod(0o600);
|
||||
if (sync) await handle.sync();
|
||||
} finally { await handle.close(); }
|
||||
}
|
||||
|
||||
async function readRuntimeAsset(targetPath, record) {
|
||||
const absolute = path.join(targetPath, ...record.path.split("/"));
|
||||
const stat = await fsp.lstat(absolute);
|
||||
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || privateModeUnsafe(stat) || stat.size !== record.size) {
|
||||
throw cacheError("AAS_RUNTIME_CONTENT_MISMATCH", "cached runtime contains an unsafe or changed asset");
|
||||
}
|
||||
const handle = await fsp.open(absolute, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
||||
try {
|
||||
const before = await handle.stat();
|
||||
const bytes = await handle.readFile();
|
||||
const after = await handle.stat();
|
||||
if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || sha256(bytes) !== record.sha256) {
|
||||
throw cacheError("AAS_RUNTIME_CONTENT_MISMATCH", "cached runtime changed during verification");
|
||||
}
|
||||
} finally { await handle.close(); }
|
||||
}
|
||||
|
||||
async function listRuntimeFiles(root, relative = "") {
|
||||
const directory = relative ? path.join(root, ...relative.split("/")) : root;
|
||||
const stat = await fsp.lstat(directory);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink() || privateModeUnsafe(stat)) {
|
||||
throw cacheError("AAS_RUNTIME_CONTENT_MISMATCH", "cached runtime contains an unsafe directory");
|
||||
}
|
||||
const found = [];
|
||||
const entries = await fsp.readdir(directory, { withFileTypes: true });
|
||||
entries.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
|
||||
for (const entry of entries) {
|
||||
const next = relative ? `${relative}/${entry.name}` : entry.name;
|
||||
const absolute = path.join(root, ...next.split("/"));
|
||||
const entryStat = await fsp.lstat(absolute);
|
||||
if (entryStat.isSymbolicLink()) throw cacheError("AAS_RUNTIME_CONTENT_MISMATCH", "cached runtime contains a link");
|
||||
if (entryStat.isDirectory()) found.push(...await listRuntimeFiles(root, next));
|
||||
else if (entryStat.isFile()) found.push(next);
|
||||
else throw cacheError("AAS_RUNTIME_CONTENT_MISMATCH", "cached runtime contains a special file");
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
async function runtimeStatus({ cacheRoot, packageVersion, integrity, closureDigest }) {
|
||||
const targetPath = runtimeCachePath({ cacheRoot, packageVersion, integrity });
|
||||
try {
|
||||
const stat = await fsp.lstat(targetPath);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) throw cacheError("AAS_RUNTIME_TARGET_INVALID", "runtime cache target is not a real directory");
|
||||
const identityPath = path.join(targetPath, RUNTIME_IDENTITY_FILE);
|
||||
const identityStat = await fsp.lstat(identityPath);
|
||||
if (!identityStat.isFile() || identityStat.isSymbolicLink() || identityStat.nlink !== 1 || identityStat.size > MAX_RUNTIME_IDENTITY_BYTES) {
|
||||
throw cacheError("AAS_RUNTIME_IDENTITY_INVALID", "stored runtime identity is not a bounded regular file");
|
||||
}
|
||||
const text = await fsp.readFile(identityPath, "utf8");
|
||||
const parsed = JSON.parse(text);
|
||||
if (`${canonicalJson(parsed)}\n` !== text) throw cacheError("AAS_RUNTIME_IDENTITY_INVALID", "stored runtime identity is not canonical JSON");
|
||||
const identity = validateRuntimeIdentity(parsed, { version: packageVersion, integrity, ...(closureDigest ? { closureDigest } : {}) });
|
||||
const files = (await listRuntimeFiles(targetPath)).sort();
|
||||
const expected = [RUNTIME_IDENTITY_FILE, ...identity.assets.map((asset) => asset.path)].sort();
|
||||
if (canonicalJson(files) !== canonicalJson(expected)) throw cacheError("AAS_RUNTIME_CONTENT_MISMATCH", "cached runtime has missing or unexpected files");
|
||||
for (let index = 0; index < identity.assets.length; index += 32) {
|
||||
await Promise.all(identity.assets.slice(index, index + 32).map((record) => readRuntimeAsset(targetPath, record)));
|
||||
}
|
||||
return { status: "verified", present: true, identity, runtimeIdentity: publicRuntimeIdentity(identity), targetPath };
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return { status: "missing", present: false, targetPath };
|
||||
return { status: "invalid", present: true, targetPath, error: { code: error.code || "AAS_RUNTIME_STATUS_FAILED", message: error.message } };
|
||||
}
|
||||
}
|
||||
|
||||
async function promoteRuntime({ cacheRoot, release, parsed }) {
|
||||
const scanned = runtimeRecords(parsed.entries, release.version);
|
||||
const targetPath = runtimeCachePath({ cacheRoot, packageVersion: release.version, integrity: release.integrity });
|
||||
const existing = await runtimeStatus({ cacheRoot, packageVersion: release.version, integrity: release.integrity, closureDigest: scanned.closureDigest });
|
||||
if (existing.status === "verified") return { ...existing, status: "alreadyPresent" };
|
||||
if (existing.status === "invalid") throw cacheError("AAS_RUNTIME_EXISTING_INVALID", "an invalid object occupies the immutable runtime cache key");
|
||||
const versionDirectory = path.dirname(targetPath);
|
||||
const created = [];
|
||||
let stagePath;
|
||||
let promoted = false;
|
||||
try {
|
||||
await ensureRealDirectory(versionDirectory, created);
|
||||
stagePath = path.join(versionDirectory, `.stage-${process.pid}-${crypto.randomBytes(12).toString("hex")}`);
|
||||
await fsp.mkdir(stagePath, { mode: 0o700 });
|
||||
const directories = new Set([stagePath]);
|
||||
for (const record of scanned.records) {
|
||||
const segments = record.path.split("/");
|
||||
let cursor = stagePath;
|
||||
for (const segment of segments.slice(0, -1)) {
|
||||
cursor = path.join(cursor, segment);
|
||||
if (!directories.has(cursor)) { await fsp.mkdir(cursor, { mode: 0o700 }); directories.add(cursor); }
|
||||
}
|
||||
await writeExclusive(path.join(stagePath, ...segments), record.bytes);
|
||||
}
|
||||
const identity = validateRuntimeIdentity({
|
||||
schemaVersion: 1,
|
||||
package: CATALOG_PACKAGE,
|
||||
version: release.version,
|
||||
integrity: release.integrity,
|
||||
closureDigest: scanned.closureDigest,
|
||||
digestVersion: DIGEST_VERSION,
|
||||
assets: scanned.assets,
|
||||
provenance: {
|
||||
registryOrigin: release.provenance.registryOrigin,
|
||||
signaturesPresent: release.provenance.signaturesPresent,
|
||||
attestationsPresent: release.provenance.attestationsPresent,
|
||||
},
|
||||
});
|
||||
await writeExclusive(path.join(stagePath, RUNTIME_IDENTITY_FILE), Buffer.from(`${canonicalJson(identity)}\n`));
|
||||
// Persist every file and nested directory before making the immutable
|
||||
// content-addressed object visible through the final rename.
|
||||
for (const directory of [...directories].sort((left, right) => right.split(path.sep).length - left.split(path.sep).length)) {
|
||||
await fsyncDirectory(directory);
|
||||
}
|
||||
try { await fsp.rename(stagePath, targetPath); promoted = true; } catch (error) {
|
||||
if (error.code !== "EEXIST" && error.code !== "ENOTEMPTY") throw error;
|
||||
const raced = await runtimeStatus({ cacheRoot, packageVersion: release.version, integrity: release.integrity, closureDigest: scanned.closureDigest });
|
||||
if (raced.status !== "verified") throw cacheError("AAS_RUNTIME_PROMOTION_CONFLICT", "runtime cache key was occupied during promotion");
|
||||
return { ...raced, status: "alreadyPresent" };
|
||||
}
|
||||
await fsyncDirectory(versionDirectory);
|
||||
return { status: "promoted", present: true, identity, runtimeIdentity: publicRuntimeIdentity(identity), targetPath };
|
||||
} finally {
|
||||
if (stagePath && !promoted) await fsp.rm(stagePath, { recursive: true, force: true });
|
||||
if (!promoted) {
|
||||
for (const directory of [...created].reverse()) await fsp.rmdir(directory).catch((error) => { if (error.code !== "ENOENT" && error.code !== "ENOTEMPTY") throw error; });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function installRuntimeFromRegistry({ cacheRoot, version, expectedIntegrity, fetcher = fetchBytes }) {
|
||||
validateCacheRoot(cacheRoot);
|
||||
const release = await inspectRuntimeRelease({ version, fetcher });
|
||||
if (expectedIntegrity !== undefined && release.integrity !== parseNpmIntegrity(expectedIntegrity).integrity) {
|
||||
throw cacheError("AAS_RUNTIME_RELEASE_CHANGED", "runtime release integrity differs from the approved preview");
|
||||
}
|
||||
const archive = await fetcher(release.tarballUrl, 64 * 1024 * 1024);
|
||||
verifySri(archive, release.integrity);
|
||||
const parsed = parsePackageArchive(archive, { limits: RUNTIME_ARCHIVE_LIMITS });
|
||||
const promoted = await promoteRuntime({ cacheRoot, release, parsed });
|
||||
return { ok: true, status: promoted.status, runtimeIdentity: promoted.runtimeIdentity, targetPath: promoted.targetPath, provenance: release.provenance };
|
||||
}
|
||||
|
||||
function runtimeMcpPath({ cacheRoot, packageVersion, integrity }) {
|
||||
return path.join(runtimeCachePath({ cacheRoot, packageVersion, integrity }), "package", "tools", "bin", "aas-mcp.js");
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_RUNTIME_IDENTITY_BYTES,
|
||||
REQUIRED_RUNTIME_FILES,
|
||||
REQUIRED_BUNDLED_DEPENDENCIES,
|
||||
RUNTIME_ARCHIVE_LIMITS,
|
||||
inspectRuntimeRelease,
|
||||
installRuntimeFromRegistry,
|
||||
promoteRuntime,
|
||||
publicRuntimeIdentity,
|
||||
runtimeMcpPath,
|
||||
runtimeRecords,
|
||||
runtimeStatus,
|
||||
validateRuntimeIdentity,
|
||||
};
|
||||
@@ -0,0 +1,178 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const fsp = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { canonicalJson, sha256 } = require("../canonical-json");
|
||||
const { DIGEST_VERSION, cacheError } = require("./identity");
|
||||
|
||||
const DEFAULT_LIMITS = Object.freeze({
|
||||
maxFiles: 128,
|
||||
maxEntries: 256,
|
||||
maxFileBytes: 32 * 1024 * 1024,
|
||||
maxTotalBytes: 128 * 1024 * 1024,
|
||||
maxDepth: 8,
|
||||
});
|
||||
|
||||
function collisionKey(relativePath) {
|
||||
return relativePath.normalize("NFKC").toLowerCase();
|
||||
}
|
||||
|
||||
function validateRelativeAssetPath(value) {
|
||||
if (typeof value !== "string" || value.length === 0 || value.length > 512 || value.includes("\0") || value.includes("\\")) {
|
||||
throw cacheError("AAS_CACHE_ASSET_PATH_INVALID", "catalog asset path is invalid");
|
||||
}
|
||||
if (path.posix.isAbsolute(value) || path.win32.isAbsolute(value)) {
|
||||
throw cacheError("AAS_CACHE_ASSET_PATH_INVALID", `absolute catalog asset path is forbidden: ${value}`);
|
||||
}
|
||||
const segments = value.split("/");
|
||||
if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
|
||||
throw cacheError("AAS_CACHE_ASSET_PATH_INVALID", `catalog asset traversal is forbidden: ${value}`);
|
||||
}
|
||||
if (path.posix.normalize(value) !== value) {
|
||||
throw cacheError("AAS_CACHE_ASSET_PATH_INVALID", `catalog asset path is not normalized: ${value}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeLimits(limits = {}) {
|
||||
const merged = { ...DEFAULT_LIMITS, ...limits };
|
||||
for (const [key, value] of Object.entries(merged)) {
|
||||
if (!Number.isSafeInteger(value) || value < 1) throw cacheError("AAS_CACHE_LIMIT_INVALID", `${key} must be a positive safe integer`);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function normalizeAllowlist(allowlist) {
|
||||
if (!Array.isArray(allowlist) || allowlist.length === 0) {
|
||||
throw cacheError("AAS_CACHE_ALLOWLIST_INVALID", "a non-empty explicit catalog asset allowlist is required");
|
||||
}
|
||||
const seen = new Map();
|
||||
return allowlist.map(validateRelativeAssetPath).sort().map((assetPath) => {
|
||||
const key = collisionKey(assetPath);
|
||||
if (seen.has(key)) {
|
||||
throw cacheError("AAS_CACHE_PATH_COLLISION", `allowlist paths collide: ${seen.get(key)} and ${assetPath}`);
|
||||
}
|
||||
seen.set(key, assetPath);
|
||||
return assetPath;
|
||||
});
|
||||
}
|
||||
|
||||
function assertSafeMode(stat, relativePath, isDirectory) {
|
||||
if ((stat.mode & 0o7000) !== 0 || (stat.mode & 0o022) !== 0 || (!isDirectory && (stat.mode & 0o111) !== 0)) {
|
||||
throw cacheError("AAS_CACHE_MODE_UNSAFE", `anomalous permissions on catalog input: ${relativePath || "."}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function readRegularFileNoFollow(absolutePath, relativePath, priorStat, limits) {
|
||||
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
|
||||
let handle;
|
||||
try {
|
||||
handle = await fsp.open(absolutePath, flags);
|
||||
const before = await handle.stat();
|
||||
if (!before.isFile() || before.nlink !== 1 || before.dev !== priorStat.dev || before.ino !== priorStat.ino) {
|
||||
throw cacheError("AAS_CACHE_INPUT_CHANGED", `catalog input changed during validation: ${relativePath}`);
|
||||
}
|
||||
if (before.size > limits.maxFileBytes) throw cacheError("AAS_CACHE_FILE_LIMIT", `catalog asset exceeds the file-size limit: ${relativePath}`);
|
||||
const bytes = await handle.readFile();
|
||||
const after = await handle.stat();
|
||||
if (bytes.length !== before.size || after.size !== before.size || after.dev !== before.dev || after.ino !== before.ino) {
|
||||
throw cacheError("AAS_CACHE_INPUT_CHANGED", `catalog input changed while being read: ${relativePath}`);
|
||||
}
|
||||
return bytes;
|
||||
} finally {
|
||||
if (handle) await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function scanDataDirectory({ sourceDir, allowlist, ignoredPaths = [], limits }) {
|
||||
if (typeof sourceDir !== "string" || sourceDir.length === 0 || sourceDir.includes("\0")) {
|
||||
throw cacheError("AAS_CACHE_SOURCE_INVALID", "catalog source directory is invalid");
|
||||
}
|
||||
const normalizedAllowlist = normalizeAllowlist(allowlist);
|
||||
const ignored = new Set(ignoredPaths.map(validateRelativeAssetPath));
|
||||
for (const ignoredPath of ignored) {
|
||||
if (normalizedAllowlist.includes(ignoredPath)) throw cacheError("AAS_CACHE_ALLOWLIST_INVALID", `ignored path is also allowlisted: ${ignoredPath}`);
|
||||
}
|
||||
const bounded = normalizeLimits(limits);
|
||||
const root = path.resolve(sourceDir);
|
||||
const rootStat = await fsp.lstat(root).catch((error) => {
|
||||
if (error.code === "ENOENT") throw cacheError("AAS_CACHE_SOURCE_MISSING", "catalog source directory does not exist");
|
||||
throw error;
|
||||
});
|
||||
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw cacheError("AAS_CACHE_SOURCE_INVALID", "catalog source must be a real directory");
|
||||
assertSafeMode(rootStat, "", true);
|
||||
|
||||
const allowed = new Set(normalizedAllowlist);
|
||||
const allowedDirectories = new Set();
|
||||
for (const assetPath of [...normalizedAllowlist, ...ignored]) {
|
||||
const segments = assetPath.split("/");
|
||||
for (let index = 1; index < segments.length; index += 1) allowedDirectories.add(segments.slice(0, index).join("/"));
|
||||
}
|
||||
const collisionPaths = new Map();
|
||||
const records = [];
|
||||
const ignoredRecords = [];
|
||||
let entryCount = 0;
|
||||
let totalBytes = 0;
|
||||
|
||||
async function walk(relativeDirectory) {
|
||||
const directory = relativeDirectory ? path.join(root, ...relativeDirectory.split("/")) : root;
|
||||
const entries = await fsp.readdir(directory, { withFileTypes: true });
|
||||
entries.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
|
||||
for (const entry of entries) {
|
||||
entryCount += 1;
|
||||
if (entryCount > bounded.maxEntries) throw cacheError("AAS_CACHE_ENTRY_LIMIT", "catalog input exceeds the entry-count limit");
|
||||
if (entry.name.includes("/") || entry.name.includes("\\") || entry.name === "." || entry.name === ".." || entry.name.includes("\0")) {
|
||||
throw cacheError("AAS_CACHE_ASSET_PATH_INVALID", "catalog input contains an unsafe directory entry");
|
||||
}
|
||||
const relativePath = relativeDirectory ? `${relativeDirectory}/${entry.name}` : entry.name;
|
||||
validateRelativeAssetPath(relativePath);
|
||||
if (relativePath.split("/").length > bounded.maxDepth) throw cacheError("AAS_CACHE_DEPTH_LIMIT", `catalog input exceeds maximum depth: ${relativePath}`);
|
||||
const key = collisionKey(relativePath);
|
||||
if (collisionPaths.has(key)) throw cacheError("AAS_CACHE_PATH_COLLISION", `catalog paths collide: ${collisionPaths.get(key)} and ${relativePath}`);
|
||||
collisionPaths.set(key, relativePath);
|
||||
|
||||
const absolutePath = path.join(root, ...relativePath.split("/"));
|
||||
const stat = await fsp.lstat(absolutePath);
|
||||
if (stat.isSymbolicLink()) throw cacheError("AAS_CACHE_LINK_FORBIDDEN", `symbolic links are forbidden: ${relativePath}`);
|
||||
if (stat.isDirectory()) {
|
||||
if (!allowedDirectories.has(relativePath)) throw cacheError("AAS_CACHE_ASSET_NOT_ALLOWED", `directory is not allowlisted: ${relativePath}`);
|
||||
assertSafeMode(stat, relativePath, true);
|
||||
await walk(relativePath);
|
||||
continue;
|
||||
}
|
||||
if (!stat.isFile()) throw cacheError("AAS_CACHE_SPECIAL_FILE_FORBIDDEN", `special files are forbidden: ${relativePath}`);
|
||||
if (stat.nlink !== 1) throw cacheError("AAS_CACHE_HARDLINK_FORBIDDEN", `hard-linked files are forbidden: ${relativePath}`);
|
||||
assertSafeMode(stat, relativePath, false);
|
||||
if (!allowed.has(relativePath) && !ignored.has(relativePath)) {
|
||||
throw cacheError("AAS_CACHE_ASSET_NOT_ALLOWED", `file is not allowlisted: ${relativePath}`);
|
||||
}
|
||||
const bytes = await readRegularFileNoFollow(absolutePath, relativePath, stat, bounded);
|
||||
totalBytes += bytes.length;
|
||||
if (totalBytes > bounded.maxTotalBytes) throw cacheError("AAS_CACHE_TOTAL_LIMIT", "catalog input exceeds the expanded-byte limit");
|
||||
const record = { path: relativePath, size: bytes.length, sha256: sha256(bytes), bytes };
|
||||
if (ignored.has(relativePath)) ignoredRecords.push(record);
|
||||
else records.push(record);
|
||||
}
|
||||
}
|
||||
|
||||
await walk("");
|
||||
if (records.length > bounded.maxFiles) throw cacheError("AAS_CACHE_FILE_COUNT_LIMIT", "catalog input exceeds the file-count limit");
|
||||
const found = new Set(records.map((record) => record.path));
|
||||
const missing = normalizedAllowlist.filter((assetPath) => !found.has(assetPath));
|
||||
if (missing.length > 0) throw cacheError("AAS_CACHE_ASSET_MISSING", `required catalog asset is missing: ${missing[0]}`);
|
||||
records.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
|
||||
const publicRecords = records.map(({ path: assetPath, size, sha256: digest }) => ({ path: assetPath, size, sha256: digest }));
|
||||
const publicIgnoredRecords = ignoredRecords.map(({ path: assetPath, size, sha256: digest }) => ({ path: assetPath, size, sha256: digest }));
|
||||
const catalogDigest = sha256(canonicalJson({ digestVersion: DIGEST_VERSION, assets: publicRecords }));
|
||||
return { catalogDigest, records, ignoredRecords, publicRecords, publicIgnoredRecords, totalBytes };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_LIMITS,
|
||||
collisionKey,
|
||||
normalizeAllowlist,
|
||||
normalizeLimits,
|
||||
scanDataDirectory,
|
||||
validateRelativeAssetPath,
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
"use strict";
|
||||
|
||||
const fsp = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { canonicalJson } = require("../canonical-json");
|
||||
const {
|
||||
CATALOG_IDENTITY_FILE,
|
||||
DIGEST_VERSION,
|
||||
cacheError,
|
||||
catalogCachePath,
|
||||
validateCatalogIdentity,
|
||||
} = require("./identity");
|
||||
const { scanDataDirectory } = require("./scan");
|
||||
|
||||
const MAX_IDENTITY_BYTES = 128 * 1024;
|
||||
|
||||
function validateStoredIdentity(value, expected) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw cacheError("AAS_CACHE_IDENTITY_INVALID", "stored catalog identity must be an object");
|
||||
const allowed = new Set(["schemaVersion", "package", "version", "integrity", "catalogDigest", "digestVersion", "assets", "controls"]);
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!allowed.has(key)) throw cacheError("AAS_CACHE_IDENTITY_INVALID", `unknown stored identity field: ${key}`);
|
||||
}
|
||||
const identity = validateCatalogIdentity({
|
||||
schemaVersion: value.schemaVersion,
|
||||
package: value.package,
|
||||
version: value.version,
|
||||
integrity: value.integrity,
|
||||
catalogDigest: value.catalogDigest,
|
||||
}, expected);
|
||||
if (value.digestVersion !== DIGEST_VERSION) throw cacheError("AAS_CACHE_IDENTITY_INVALID", "unsupported catalog digest version");
|
||||
if (!Array.isArray(value.assets) || value.assets.length === 0) throw cacheError("AAS_CACHE_IDENTITY_INVALID", "stored catalog identity needs asset records");
|
||||
const assets = value.assets.map((record) => {
|
||||
if (!record || typeof record !== "object" || Array.isArray(record) || Object.keys(record).sort().join(",") !== "path,sha256,size") {
|
||||
throw cacheError("AAS_CACHE_IDENTITY_INVALID", "stored catalog asset record is invalid");
|
||||
}
|
||||
if (!Number.isSafeInteger(record.size) || record.size < 0 || !/^sha256-[0-9a-f]{64}$/.test(record.sha256)) {
|
||||
throw cacheError("AAS_CACHE_IDENTITY_INVALID", "stored catalog asset size or digest is invalid");
|
||||
}
|
||||
return { path: record.path, size: record.size, sha256: record.sha256 };
|
||||
});
|
||||
const controls = (value.controls || []).map((record) => {
|
||||
if (!record || typeof record !== "object" || Array.isArray(record) || Object.keys(record).sort().join(",") !== "path,sha256,size") {
|
||||
throw cacheError("AAS_CACHE_IDENTITY_INVALID", "stored catalog control record is invalid");
|
||||
}
|
||||
if (!Number.isSafeInteger(record.size) || record.size < 0 || !/^sha256-[0-9a-f]{64}$/.test(record.sha256)) {
|
||||
throw cacheError("AAS_CACHE_IDENTITY_INVALID", "stored catalog control size or digest is invalid");
|
||||
}
|
||||
return { path: record.path, size: record.size, sha256: record.sha256 };
|
||||
});
|
||||
return { ...identity, digestVersion: DIGEST_VERSION, assets, controls };
|
||||
}
|
||||
|
||||
async function catalogStatus({ cacheRoot, packageVersion, catalogDigest, integrity }) {
|
||||
const targetPath = catalogCachePath({ cacheRoot, packageVersion, catalogDigest });
|
||||
try {
|
||||
const targetStat = await fsp.lstat(targetPath);
|
||||
if (!targetStat.isDirectory() || targetStat.isSymbolicLink()) throw cacheError("AAS_CACHE_TARGET_INVALID", "catalog cache target is not a real directory");
|
||||
const identityPath = path.join(targetPath, CATALOG_IDENTITY_FILE);
|
||||
const identityStat = await fsp.lstat(identityPath);
|
||||
if (!identityStat.isFile() || identityStat.isSymbolicLink() || identityStat.nlink !== 1 || identityStat.size > MAX_IDENTITY_BYTES) {
|
||||
throw cacheError("AAS_CACHE_IDENTITY_INVALID", "stored catalog identity is not a bounded regular file");
|
||||
}
|
||||
const text = await fsp.readFile(identityPath, "utf8");
|
||||
const parsed = JSON.parse(text);
|
||||
if (`${canonicalJson(parsed)}\n` !== text) throw cacheError("AAS_CACHE_IDENTITY_INVALID", "stored catalog identity is not canonical JSON");
|
||||
const identity = validateStoredIdentity(parsed, {
|
||||
version: packageVersion,
|
||||
catalogDigest,
|
||||
...(integrity === undefined ? {} : { integrity }),
|
||||
});
|
||||
const scan = await scanDataDirectory({
|
||||
sourceDir: targetPath,
|
||||
allowlist: identity.assets.map((asset) => asset.path),
|
||||
ignoredPaths: [CATALOG_IDENTITY_FILE, ...identity.controls.map((asset) => asset.path)],
|
||||
});
|
||||
const observedControls = scan.publicIgnoredRecords.filter((record) => record.path !== CATALOG_IDENTITY_FILE);
|
||||
if (scan.catalogDigest !== identity.catalogDigest || canonicalJson(scan.publicRecords) !== canonicalJson(identity.assets)
|
||||
|| canonicalJson(observedControls) !== canonicalJson(identity.controls)) {
|
||||
throw cacheError("AAS_CACHE_CONTENT_MISMATCH", "cached catalog bytes do not match their identity");
|
||||
}
|
||||
return { status: "verified", present: true, identity, targetPath };
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return { status: "missing", present: false, targetPath };
|
||||
return {
|
||||
status: "invalid",
|
||||
present: true,
|
||||
targetPath,
|
||||
error: { code: error.code || "AAS_CACHE_STATUS_FAILED", message: error.message },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { MAX_IDENTITY_BYTES, catalogStatus, validateStoredIdentity };
|
||||
@@ -0,0 +1,161 @@
|
||||
"use strict";
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const https = require("node:https");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { canonicalJson } = require("../canonical-json");
|
||||
const { parsePackageArchive } = require("./archive");
|
||||
const { cacheError, parseNpmIntegrity, validatePackageVersion } = require("./identity");
|
||||
const { promoteCatalogDirectory } = require("./promote");
|
||||
|
||||
const PACKAGE_NAME = "agentic-awesome-skills";
|
||||
const REGISTRY_ORIGIN = "https://registry.npmjs.org";
|
||||
const CATALOG_MANIFEST_PATH = "data/aas-v1/catalog-manifest.v1.json";
|
||||
const CATALOG_ASSET_PATHS = Object.freeze([
|
||||
"data/aas-v1/skill-content-index.v1.json",
|
||||
"data/aas-v1/skill-content.v1.ndjson",
|
||||
"data/catalog.json",
|
||||
"data/plugin-compatibility.json",
|
||||
"tools/lib/aas-v1/metadata-reviews.v1.json",
|
||||
"tools/lib/aas-v1/metadata-overrides.v1.json",
|
||||
"tools/lib/aas-v1/ontology.v1.json",
|
||||
]);
|
||||
|
||||
function fetchBytes(url, maximumBytes, request = https.get) {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.origin !== REGISTRY_ORIGIN || parsed.protocol !== "https:") {
|
||||
return Promise.reject(cacheError("AAS_UPDATE_REGISTRY_URL_INVALID", "registry URL is outside the pinned npm origin"));
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const call = request(parsed, {
|
||||
headers: { accept: "application/json", "user-agent": "agentic-awesome-skills/catalog-updater-v1" },
|
||||
timeout: 15000,
|
||||
}, (response) => {
|
||||
if (response.statusCode !== 200) {
|
||||
response.resume();
|
||||
reject(cacheError("AAS_UPDATE_HTTP_STATUS", `registry returned ${response.statusCode}`));
|
||||
return;
|
||||
}
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
response.on("data", (chunk) => {
|
||||
total += chunk.length;
|
||||
if (total > maximumBytes) {
|
||||
response.destroy(cacheError("AAS_UPDATE_DOWNLOAD_LIMIT", "registry response exceeded its byte limit"));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
response.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
response.on("error", reject);
|
||||
});
|
||||
call.on("timeout", () => call.destroy(cacheError("AAS_UPDATE_TIMEOUT", "registry request timed out")));
|
||||
call.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function verifySri(bytes, integrity) {
|
||||
const parsed = parseNpmIntegrity(integrity);
|
||||
const actual = crypto.createHash(parsed.algorithm).update(bytes).digest();
|
||||
if (actual.length !== parsed.bytes.length || !crypto.timingSafeEqual(actual, parsed.bytes)) {
|
||||
throw cacheError("AAS_UPDATE_DIST_INTEGRITY_MISMATCH", "downloaded tarball does not match npm dist.integrity");
|
||||
}
|
||||
}
|
||||
|
||||
function validateCatalogManifest(bytes, version) {
|
||||
const text = bytes.toString("utf8");
|
||||
const manifest = JSON.parse(text);
|
||||
if (`${canonicalJson(manifest)}\n` !== text || manifest.schemaVersion !== 1 || manifest.digestVersion !== 1
|
||||
|| manifest.package !== PACKAGE_NAME || manifest.packageVersion !== version || manifest.skillCount !== 1965
|
||||
|| !/^sha256-[a-f0-9]{64}$/.test(manifest.catalogDigest) || !Array.isArray(manifest.assets)) {
|
||||
throw cacheError("AAS_UPDATE_CATALOG_MANIFEST_INVALID", "catalog manifest is invalid or incompatible");
|
||||
}
|
||||
const paths = manifest.assets.map((asset) => asset.path).sort();
|
||||
if (canonicalJson(paths) !== canonicalJson([...CATALOG_ASSET_PATHS].sort())) {
|
||||
throw cacheError("AAS_UPDATE_CATALOG_ALLOWLIST_MISMATCH", "release catalog assets do not match the v1 allowlist");
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
function materializeSelected(entries) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "aas-catalog-update-"));
|
||||
try {
|
||||
for (const entry of entries) {
|
||||
const relative = entry.path.replace(/^package\//, "");
|
||||
const output = path.join(directory, ...relative.split("/"));
|
||||
fs.mkdirSync(path.dirname(output), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(output, entry.bytes, { mode: 0o600, flag: "wx" });
|
||||
}
|
||||
return directory;
|
||||
} catch (error) {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateCatalogFromRegistry({ cacheRoot, version, fetcher = fetchBytes }) {
|
||||
const packageVersion = validatePackageVersion(version);
|
||||
const metadataUrl = `${REGISTRY_ORIGIN}/${PACKAGE_NAME}/${packageVersion}`;
|
||||
const metadataBytes = await fetcher(metadataUrl, 2 * 1024 * 1024);
|
||||
let metadata;
|
||||
try { metadata = JSON.parse(metadataBytes.toString("utf8")); } catch {
|
||||
throw cacheError("AAS_UPDATE_PACKUMENT_INVALID", "registry metadata is invalid JSON");
|
||||
}
|
||||
const dist = metadata?.dist;
|
||||
if (!dist || typeof dist.integrity !== "string" || typeof dist.tarball !== "string") {
|
||||
throw cacheError("AAS_UPDATE_PACKUMENT_INVALID", "registry metadata lacks dist identity");
|
||||
}
|
||||
const tarballUrl = new URL(dist.tarball);
|
||||
if (tarballUrl.origin !== REGISTRY_ORIGIN || tarballUrl.protocol !== "https:") {
|
||||
throw cacheError("AAS_UPDATE_TARBALL_URL_INVALID", "registry tarball URL is outside the pinned origin");
|
||||
}
|
||||
const archiveBytes = await fetcher(tarballUrl.href, 64 * 1024 * 1024);
|
||||
verifySri(archiveBytes, dist.integrity);
|
||||
const selectedPaths = [CATALOG_MANIFEST_PATH, ...CATALOG_ASSET_PATHS].map((asset) => `package/${asset}`);
|
||||
const parsed = parsePackageArchive(archiveBytes, { selectPaths: selectedPaths });
|
||||
const byPath = new Map(parsed.entries.map((entry) => [entry.path.replace(/^package\//, ""), entry]));
|
||||
const manifest = validateCatalogManifest(byPath.get(CATALOG_MANIFEST_PATH).bytes, packageVersion);
|
||||
const sourceDir = materializeSelected(parsed.entries);
|
||||
try {
|
||||
const promoted = await promoteCatalogDirectory({
|
||||
cacheRoot,
|
||||
sourceDir,
|
||||
allowlist: CATALOG_ASSET_PATHS,
|
||||
controlPaths: [CATALOG_MANIFEST_PATH],
|
||||
identity: {
|
||||
schemaVersion: 1,
|
||||
package: PACKAGE_NAME,
|
||||
version: packageVersion,
|
||||
integrity: dist.integrity,
|
||||
catalogDigest: manifest.catalogDigest,
|
||||
},
|
||||
limits: { maxFiles: 16, maxEntries: 32, maxFileBytes: 32 * 1024 * 1024, maxTotalBytes: 64 * 1024 * 1024, maxDepth: 8 },
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
status: promoted.status,
|
||||
identity: promoted.identity,
|
||||
provenance: {
|
||||
registryOrigin: REGISTRY_ORIGIN,
|
||||
distIntegrity: dist.integrity,
|
||||
signaturesPresent: Array.isArray(dist.signatures) && dist.signatures.length > 0,
|
||||
attestationsPresent: Boolean(dist.attestations),
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
fs.rmSync(sourceDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CATALOG_ASSET_PATHS,
|
||||
CATALOG_MANIFEST_PATH,
|
||||
PACKAGE_NAME,
|
||||
REGISTRY_ORIGIN,
|
||||
fetchBytes,
|
||||
updateCatalogFromRegistry,
|
||||
validateCatalogManifest,
|
||||
verifySri,
|
||||
};
|
||||
Reference in New Issue
Block a user