241 lines
12 KiB
JavaScript
241 lines
12 KiB
JavaScript
"use strict";
|
|
|
|
const assert = require("node:assert/strict");
|
|
const fs = require("node:fs");
|
|
const fsp = require("node:fs/promises");
|
|
const net = require("node:net");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const test = require("node:test");
|
|
const {
|
|
CATALOG_IDENTITY_FILE,
|
|
catalogCachePath,
|
|
catalogStatus,
|
|
filesystemSafeIntegrityKey,
|
|
promoteCatalogDirectory,
|
|
runtimeCachePath,
|
|
scanDataDirectory,
|
|
validateCatalogIdentity,
|
|
} = require("../../lib/aas-v1/cache");
|
|
|
|
const INTEGRITY = `sha512-${Buffer.alloc(64, 0xab).toString("base64")}`;
|
|
const ALLOWLIST = ["data/catalog.json", "data/plugin-compatibility.json"];
|
|
|
|
async function makeDirectory(prefix) {
|
|
return fsp.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
}
|
|
|
|
async function writeAsset(root, relativePath, content = "{}\n", mode = 0o644) {
|
|
const target = path.join(root, ...relativePath.split("/"));
|
|
await fsp.mkdir(path.dirname(target), { recursive: true, mode: 0o755 });
|
|
await fsp.writeFile(target, content, { mode });
|
|
await fsp.chmod(target, mode);
|
|
}
|
|
|
|
async function makeValidSource(prefix = "aas-cache-source-") {
|
|
const sourceDir = await makeDirectory(prefix);
|
|
await writeAsset(sourceDir, ALLOWLIST[0], '[{"id":"alpha"}]\n');
|
|
await writeAsset(sourceDir, ALLOWLIST[1], '{"alpha":{"codex":"supported"}}\n');
|
|
return sourceDir;
|
|
}
|
|
|
|
async function identityFor(sourceDir, allowlist = ALLOWLIST, extra = {}) {
|
|
const scan = await scanDataDirectory({ sourceDir, allowlist });
|
|
return {
|
|
schemaVersion: 1,
|
|
package: "agentic-awesome-skills",
|
|
version: "14.6.0",
|
|
integrity: INTEGRITY,
|
|
catalogDigest: scan.catalogDigest,
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
async function snapshotTree(root) {
|
|
try {
|
|
await fsp.lstat(root);
|
|
} catch (error) {
|
|
if (error.code === "ENOENT") return "missing";
|
|
throw error;
|
|
}
|
|
const records = [];
|
|
async function walk(relativeDirectory) {
|
|
const directory = relativeDirectory ? path.join(root, relativeDirectory) : root;
|
|
const entries = await fsp.readdir(directory);
|
|
entries.sort();
|
|
for (const name of entries) {
|
|
const relativePath = relativeDirectory ? `${relativeDirectory}/${name}` : name;
|
|
const absolutePath = path.join(root, relativePath);
|
|
const stat = await fsp.lstat(absolutePath);
|
|
if (stat.isDirectory()) {
|
|
records.push([relativePath, "directory", stat.mode & 0o7777]);
|
|
await walk(relativePath);
|
|
} else if (stat.isFile()) {
|
|
records.push([relativePath, "file", stat.mode & 0o7777, (await fsp.readFile(absolutePath)).toString("base64")]);
|
|
} else if (stat.isSymbolicLink()) {
|
|
records.push([relativePath, "symlink", await fsp.readlink(absolutePath)]);
|
|
} else {
|
|
records.push([relativePath, "special", stat.mode & 0o170000]);
|
|
}
|
|
}
|
|
}
|
|
await walk("");
|
|
return JSON.stringify(records);
|
|
}
|
|
|
|
async function assertHostileLeavesCacheUnchanged({ cacheRoot, sourceDir, allowlist, limits, code }) {
|
|
const before = await snapshotTree(cacheRoot);
|
|
await assert.rejects(
|
|
promoteCatalogDirectory({
|
|
cacheRoot,
|
|
sourceDir,
|
|
allowlist,
|
|
limits,
|
|
identity: {
|
|
schemaVersion: 1,
|
|
package: "agentic-awesome-skills",
|
|
version: "14.6.0",
|
|
integrity: INTEGRITY,
|
|
catalogDigest: `sha256-${"0".repeat(64)}`,
|
|
},
|
|
}),
|
|
(error) => !code || error.code === code,
|
|
);
|
|
assert.equal(await snapshotTree(cacheRoot), before);
|
|
}
|
|
|
|
test("npm SRI has one canonical filesystem-safe key and layouts are content-addressed", () => {
|
|
const key = filesystemSafeIntegrityKey(INTEGRITY);
|
|
assert.equal(key, `sha512-${Buffer.alloc(64, 0xab).toString("base64url")}`);
|
|
assert.equal(runtimeCachePath({ cacheRoot: "/cache", packageVersion: "14.6.0", integrity: INTEGRITY }), `/cache/runtimes/14.6.0/${key}`);
|
|
const digest = `sha256-${"1".repeat(64)}`;
|
|
assert.equal(catalogCachePath({ cacheRoot: "/cache", packageVersion: "14.6.0", catalogDigest: digest }), `/cache/catalogs/14.6.0/${digest}`);
|
|
assert.throws(() => filesystemSafeIntegrityKey(`sha512-${Buffer.alloc(63).toString("base64")}`), (error) => error.code === "AAS_CACHE_INTEGRITY_INVALID");
|
|
assert.throws(() => validateCatalogIdentity({ schemaVersion: 1, package: "evil", version: "14.6.0", integrity: INTEGRITY, catalogDigest: digest }), (error) => error.code === "AAS_CACHE_IDENTITY_INVALID");
|
|
});
|
|
|
|
test("catalog status is read-only and does not create a missing cache root", async (t) => {
|
|
const parent = await makeDirectory("aas-cache-status-");
|
|
t.after(() => fsp.rm(parent, { recursive: true, force: true }));
|
|
const cacheRoot = path.join(parent, "not-created");
|
|
const result = await catalogStatus({ cacheRoot, packageVersion: "14.6.0", catalogDigest: `sha256-${"2".repeat(64)}` });
|
|
assert.equal(result.status, "missing");
|
|
await assert.rejects(fsp.lstat(cacheRoot), (error) => error.code === "ENOENT");
|
|
});
|
|
|
|
test("a verified data-only catalog is staged, atomically promoted, and idempotent", async (t) => {
|
|
const parent = await makeDirectory("aas-cache-valid-");
|
|
const sourceDir = await makeValidSource();
|
|
const cacheRoot = path.join(parent, "cache");
|
|
t.after(() => Promise.all([fsp.rm(parent, { recursive: true, force: true }), fsp.rm(sourceDir, { recursive: true, force: true })]));
|
|
const identity = await identityFor(sourceDir);
|
|
const promoted = await promoteCatalogDirectory({ cacheRoot, sourceDir, allowlist: ALLOWLIST, identity });
|
|
assert.equal(promoted.status, "promoted");
|
|
assert.ok(promoted.targetPath.startsWith(path.join(cacheRoot, "catalogs", "14.6.0")));
|
|
assert.equal((await catalogStatus({ cacheRoot, packageVersion: "14.6.0", catalogDigest: identity.catalogDigest, integrity: INTEGRITY })).status, "verified");
|
|
const before = await snapshotTree(cacheRoot);
|
|
const repeated = await promoteCatalogDirectory({ cacheRoot, sourceDir, allowlist: [...ALLOWLIST].reverse(), identity });
|
|
assert.equal(repeated.status, "alreadyPresent");
|
|
assert.equal(await snapshotTree(cacheRoot), before);
|
|
});
|
|
|
|
test("tampering invalidates catalog identity without status writes", async (t) => {
|
|
const parent = await makeDirectory("aas-cache-tamper-");
|
|
const sourceDir = await makeValidSource();
|
|
const cacheRoot = path.join(parent, "cache");
|
|
t.after(() => Promise.all([fsp.rm(parent, { recursive: true, force: true }), fsp.rm(sourceDir, { recursive: true, force: true })]));
|
|
const identity = await identityFor(sourceDir);
|
|
const promoted = await promoteCatalogDirectory({ cacheRoot, sourceDir, allowlist: ALLOWLIST, identity });
|
|
await fsp.chmod(path.join(promoted.targetPath, ALLOWLIST[0]), 0o644);
|
|
await fsp.writeFile(path.join(promoted.targetPath, ALLOWLIST[0]), "tampered\n");
|
|
const before = await snapshotTree(cacheRoot);
|
|
const result = await catalogStatus({ cacheRoot, packageVersion: identity.version, catalogDigest: identity.catalogDigest, integrity: INTEGRITY });
|
|
assert.equal(result.status, "invalid");
|
|
assert.equal(await snapshotTree(cacheRoot), before);
|
|
assert.equal(path.basename(path.join(promoted.targetPath, CATALOG_IDENTITY_FILE)), CATALOG_IDENTITY_FILE);
|
|
});
|
|
|
|
test("traversal, absolute paths, extra files, permissions, and expansion limits leave cache unchanged", async (t) => {
|
|
const parent = await makeDirectory("aas-cache-hostile-basic-");
|
|
const cacheRoot = path.join(parent, "cache");
|
|
await fsp.mkdir(cacheRoot, { mode: 0o700 });
|
|
await fsp.writeFile(path.join(cacheRoot, "sentinel"), "unchanged");
|
|
const sources = [];
|
|
t.after(() => Promise.all([fsp.rm(parent, { recursive: true, force: true }), ...sources.map((entry) => fsp.rm(entry, { recursive: true, force: true }))]));
|
|
|
|
const valid = await makeValidSource(); sources.push(valid);
|
|
await assertHostileLeavesCacheUnchanged({ cacheRoot, sourceDir: valid, allowlist: ["../escape"], code: "AAS_CACHE_ASSET_PATH_INVALID" });
|
|
await assertHostileLeavesCacheUnchanged({ cacheRoot, sourceDir: valid, allowlist: [path.resolve(valid, "data/catalog.json")], code: "AAS_CACHE_ASSET_PATH_INVALID" });
|
|
|
|
const extra = await makeValidSource(); sources.push(extra);
|
|
await writeAsset(extra, "data/not-allowed.json");
|
|
await assertHostileLeavesCacheUnchanged({ cacheRoot, sourceDir: extra, allowlist: ALLOWLIST, code: "AAS_CACHE_ASSET_NOT_ALLOWED" });
|
|
|
|
const executable = await makeValidSource(); sources.push(executable);
|
|
await fsp.chmod(path.join(executable, ALLOWLIST[0]), 0o755);
|
|
await assertHostileLeavesCacheUnchanged({ cacheRoot, sourceDir: executable, allowlist: ALLOWLIST, code: "AAS_CACHE_MODE_UNSAFE" });
|
|
|
|
const oversized = await makeValidSource(); sources.push(oversized);
|
|
await assertHostileLeavesCacheUnchanged({ cacheRoot, sourceDir: oversized, allowlist: ALLOWLIST, limits: { maxFileBytes: 4 }, code: "AAS_CACHE_FILE_LIMIT" });
|
|
await assertHostileLeavesCacheUnchanged({ cacheRoot, sourceDir: oversized, allowlist: ALLOWLIST, limits: { maxEntries: 1 }, code: "AAS_CACHE_ENTRY_LIMIT" });
|
|
});
|
|
|
|
test("symlinks, hardlinks, special files, and Unicode/case collisions leave cache unchanged", async (t) => {
|
|
const parent = await makeDirectory("aas-cache-hostile-types-");
|
|
const cacheRoot = path.join(parent, "cache");
|
|
await fsp.mkdir(cacheRoot, { mode: 0o700 });
|
|
await fsp.writeFile(path.join(cacheRoot, "sentinel"), "unchanged");
|
|
const sources = [];
|
|
t.after(() => Promise.all([fsp.rm(parent, { recursive: true, force: true }), ...sources.map((entry) => fsp.rm(entry, { recursive: true, force: true }))]));
|
|
|
|
const symlinkSource = await makeValidSource(); sources.push(symlinkSource);
|
|
await fsp.rm(path.join(symlinkSource, ALLOWLIST[0]));
|
|
await fsp.symlink(path.join(symlinkSource, ALLOWLIST[1]), path.join(symlinkSource, ALLOWLIST[0]));
|
|
await assertHostileLeavesCacheUnchanged({ cacheRoot, sourceDir: symlinkSource, allowlist: ALLOWLIST, code: "AAS_CACHE_LINK_FORBIDDEN" });
|
|
|
|
const hardlinkSource = await makeValidSource(); sources.push(hardlinkSource);
|
|
await fsp.rm(path.join(hardlinkSource, ALLOWLIST[0]));
|
|
await fsp.link(path.join(hardlinkSource, ALLOWLIST[1]), path.join(hardlinkSource, ALLOWLIST[0]));
|
|
await assertHostileLeavesCacheUnchanged({ cacheRoot, sourceDir: hardlinkSource, allowlist: ALLOWLIST, code: "AAS_CACHE_HARDLINK_FORBIDDEN" });
|
|
|
|
const socketSource = await makeDirectory("aas-cache-socket-"); sources.push(socketSource);
|
|
await fsp.mkdir(path.join(socketSource, "data"), { mode: 0o755 });
|
|
const socketPath = path.join(socketSource, "data", "catalog.sock");
|
|
const server = net.createServer();
|
|
await new Promise((resolve, reject) => server.once("error", reject).listen(socketPath, resolve));
|
|
try {
|
|
await assertHostileLeavesCacheUnchanged({ cacheRoot, sourceDir: socketSource, allowlist: ["data/catalog.sock"], code: "AAS_CACHE_SPECIAL_FILE_FORBIDDEN" });
|
|
} finally {
|
|
await new Promise((resolve) => server.close(resolve));
|
|
}
|
|
|
|
const collisionSource = await makeValidSource(); sources.push(collisionSource);
|
|
await assertHostileLeavesCacheUnchanged({
|
|
cacheRoot,
|
|
sourceDir: collisionSource,
|
|
allowlist: ["data/catalog.json", "data/catalog.json"],
|
|
code: "AAS_CACHE_PATH_COLLISION",
|
|
});
|
|
await assertHostileLeavesCacheUnchanged({
|
|
cacheRoot,
|
|
sourceDir: collisionSource,
|
|
allowlist: ["data/CATALOG.json", "data/catalog.json"],
|
|
code: "AAS_CACHE_PATH_COLLISION",
|
|
});
|
|
await assertHostileLeavesCacheUnchanged({
|
|
cacheRoot,
|
|
sourceDir: collisionSource,
|
|
allowlist: ["data/caf\u00e9.json", "data/cafe\u0301.json"],
|
|
code: "AAS_CACHE_PATH_COLLISION",
|
|
});
|
|
});
|
|
|
|
test("a digest mismatch is rejected before creating a cache root", async (t) => {
|
|
const parent = await makeDirectory("aas-cache-digest-mismatch-");
|
|
const sourceDir = await makeValidSource();
|
|
const cacheRoot = path.join(parent, "cache");
|
|
t.after(() => Promise.all([fsp.rm(parent, { recursive: true, force: true }), fsp.rm(sourceDir, { recursive: true, force: true })]));
|
|
await assertHostileLeavesCacheUnchanged({ cacheRoot, sourceDir, allowlist: ALLOWLIST, code: "AAS_CACHE_CATALOG_DIGEST_MISMATCH" });
|
|
assert.equal(fs.existsSync(cacheRoot), false);
|
|
});
|