Files
2026-07-22 10:09:47 +00:00

433 lines
21 KiB
JavaScript

"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 ownershipUnsafe(stat) {
return process.platform !== "win32"
&& typeof process.getuid === "function"
&& stat.uid !== process.getuid();
}
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, cacheRoot) {
const boundary = path.resolve(cacheRoot);
const resolved = path.resolve(directoryPath);
if (resolved !== boundary && !resolved.startsWith(`${boundary}${path.sep}`)) {
throw cacheError("AAS_RUNTIME_DIRECTORY_UNSAFE", "runtime cache path escaped the configured cache root");
}
try {
const stat = await fsp.lstat(resolved);
if (!stat.isDirectory() || stat.isSymbolicLink() || privateModeUnsafe(stat) || ownershipUnsafe(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(resolved);
if (resolved === boundary) {
await assertSafeCacheAncestorChain(parent);
} else {
await ensureRealDirectory(parent, created, boundary);
}
try {
await fsp.mkdir(resolved, { mode: 0o700 });
created.push(resolved);
} catch (mkdirError) {
if (mkdirError.code !== "EEXIST") throw mkdirError;
const stat = await fsp.lstat(resolved);
if (!stat.isDirectory() || stat.isSymbolicLink() || privateModeUnsafe(stat) || ownershipUnsafe(stat)) {
throw cacheError("AAS_RUNTIME_DIRECTORY_UNSAFE", "runtime cache path is not a private real directory");
}
}
}
}
async function assertSafeCacheAncestorChain(directoryPath) {
const logical = path.resolve(directoryPath);
let logicalCursor = path.parse(logical).root;
for (const component of path.relative(logicalCursor, logical).split(path.sep).filter(Boolean)) {
logicalCursor = path.join(logicalCursor, component);
const stat = await fsp.lstat(logicalCursor);
if (stat.isSymbolicLink()) {
const stableSystemAlias = process.platform !== "win32"
&& stat.uid === 0
&& (stat.mode & 0o022) === 0;
if (!stableSystemAlias) {
throw cacheError("AAS_RUNTIME_DIRECTORY_UNSAFE", "runtime cache ancestor contains an untrusted symlink");
}
}
}
let current = await fsp.realpath(logical);
let childStat = null;
while (true) {
const stat = await fsp.lstat(current);
if (!stat.isDirectory() || stat.isSymbolicLink()) {
throw cacheError("AAS_RUNTIME_DIRECTORY_UNSAFE", "runtime cache ancestor is not a real directory");
}
if (process.platform !== "win32" && (stat.mode & 0o022) !== 0) {
const sticky = (stat.mode & 0o1000) !== 0;
const childOwnedByCurrentUser = childStat !== null && !ownershipUnsafe(childStat);
if (!sticky || !childOwnedByCurrentUser) {
throw cacheError("AAS_RUNTIME_DIRECTORY_UNSAFE", "runtime cache ancestor is replaceable by another user");
}
}
const parent = path.dirname(current);
if (parent === current) return;
childStat = stat;
current = parent;
}
}
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, cacheRoot);
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,
};