168 lines
7.2 KiB
JavaScript
168 lines
7.2 KiB
JavaScript
"use strict";
|
|
|
|
const assert = require("node:assert/strict");
|
|
const crypto = require("node:crypto");
|
|
const fs = require("node:fs");
|
|
const fsp = require("node:fs/promises");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const { execFileSync } = require("node:child_process");
|
|
const { after, before, test } = require("node:test");
|
|
const { canonicalJson, sha256 } = require("../../lib/aas-v1/canonical-json");
|
|
const {
|
|
CATALOG_MANIFEST_PATH,
|
|
CATALOG_ASSET_PATHS,
|
|
REGISTRY_ORIGIN,
|
|
catalogStatus,
|
|
createVerifiedCatalogResolver,
|
|
updateCatalogFromRegistry,
|
|
verifySri,
|
|
} = require("../../lib/aas-v1/cache");
|
|
|
|
const ROOT = path.resolve(__dirname, "../../..");
|
|
const packageMetadata = require(path.join(ROOT, "package.json"));
|
|
const expectedSkillCount = require(path.join(ROOT, "skills_index.json")).length;
|
|
const TARBALL_URL = `${REGISTRY_ORIGIN}/${packageMetadata.name}/-/${packageMetadata.name}-${packageMetadata.version}.tgz`;
|
|
|
|
let fixtureRoot;
|
|
let archiveBytes;
|
|
let archiveIntegrity;
|
|
|
|
function npmIntegrity(bytes) {
|
|
return `sha512-${crypto.createHash("sha512").update(bytes).digest("base64")}`;
|
|
}
|
|
|
|
async function makeAlternateBundledCatalog(sourceRoot, destinationRoot) {
|
|
await fsp.cp(sourceRoot, destinationRoot, { recursive: true });
|
|
const manifestPath = path.join(destinationRoot, ...CATALOG_MANIFEST_PATH.split("/"));
|
|
const manifest = JSON.parse(await fsp.readFile(manifestPath, "utf8"));
|
|
const changedPath = "data/catalog.json";
|
|
const changedFile = path.join(destinationRoot, ...changedPath.split("/"));
|
|
await fsp.appendFile(changedFile, "\n");
|
|
const bytes = await fsp.readFile(changedFile);
|
|
const asset = manifest.assets.find((entry) => entry.path === changedPath);
|
|
asset.size = bytes.length;
|
|
asset.sha256 = sha256(bytes);
|
|
const records = manifest.assets
|
|
.map(({ path: assetPath, size, sha256: digest }) => ({ path: assetPath, size, sha256: digest }))
|
|
.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
|
|
manifest.catalogDigest = sha256(canonicalJson({ digestVersion: manifest.digestVersion, assets: records }));
|
|
await fsp.writeFile(manifestPath, `${canonicalJson(manifest)}\n`);
|
|
return manifest.catalogDigest;
|
|
}
|
|
|
|
before(async () => {
|
|
fixtureRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "aas-v1-update-test-"));
|
|
const packDirectory = path.join(fixtureRoot, "pack");
|
|
await fsp.mkdir(packDirectory, { mode: 0o700 });
|
|
execFileSync("npm", ["pack", "--ignore-scripts", "--pack-destination", packDirectory, "--silent"], {
|
|
cwd: ROOT,
|
|
env: { ...process.env, npm_config_cache: path.join(fixtureRoot, "npm-cache") },
|
|
stdio: "ignore",
|
|
});
|
|
const archivePath = path.join(packDirectory, `${packageMetadata.name}-${packageMetadata.version}.tgz`);
|
|
archiveBytes = await fsp.readFile(archivePath);
|
|
archiveIntegrity = npmIntegrity(archiveBytes);
|
|
});
|
|
|
|
after(async () => {
|
|
if (fixtureRoot) await fsp.rm(fixtureRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
test("npm dist.integrity accepts the exact package and rejects changed bytes", () => {
|
|
assert.doesNotThrow(() => verifySri(archiveBytes, archiveIntegrity));
|
|
const changed = Buffer.from(archiveBytes);
|
|
changed[changed.length - 1] ^= 1;
|
|
assert.throws(
|
|
() => verifySri(changed, archiveIntegrity),
|
|
(error) => error.code === "AAS_UPDATE_DIST_INTEGRITY_MISMATCH",
|
|
);
|
|
});
|
|
|
|
test("an SRI failure leaves the cache absent", async (t) => {
|
|
const testRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "aas-v1-update-sri-fail-"));
|
|
t.after(() => fsp.rm(testRoot, { recursive: true, force: true }));
|
|
const cacheRoot = path.join(testRoot, "cache");
|
|
const wrongIntegrity = `sha512-${Buffer.alloc(64, 0).toString("base64")}`;
|
|
const fetcher = async (url) => {
|
|
if (url === `${REGISTRY_ORIGIN}/${packageMetadata.name}/${packageMetadata.version}`) {
|
|
return Buffer.from(JSON.stringify({ dist: { integrity: wrongIntegrity, tarball: TARBALL_URL } }));
|
|
}
|
|
if (url === TARBALL_URL) return archiveBytes;
|
|
throw new Error(`unexpected mocked registry URL: ${url}`);
|
|
};
|
|
|
|
await assert.rejects(
|
|
updateCatalogFromRegistry({ cacheRoot, version: packageMetadata.version, fetcher }),
|
|
(error) => error.code === "AAS_UPDATE_DIST_INTEGRITY_MISMATCH",
|
|
);
|
|
assert.equal(fs.existsSync(cacheRoot), false);
|
|
});
|
|
|
|
test("the mocked npm registry promotes, verifies, resolves, and reuses the real package catalog", async (t) => {
|
|
const testRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "aas-v1-update-success-"));
|
|
t.after(() => fsp.rm(testRoot, { recursive: true, force: true }));
|
|
const cacheRoot = path.join(testRoot, "cache");
|
|
const calls = [];
|
|
const fetcher = async (url, maximumBytes) => {
|
|
calls.push({ url, maximumBytes });
|
|
if (url === `${REGISTRY_ORIGIN}/${packageMetadata.name}/${packageMetadata.version}`) {
|
|
return Buffer.from(JSON.stringify({
|
|
dist: {
|
|
integrity: archiveIntegrity,
|
|
tarball: TARBALL_URL,
|
|
signatures: [{ keyid: "fixture", sig: "fixture" }],
|
|
attestations: { url: "fixture" },
|
|
},
|
|
}));
|
|
}
|
|
if (url === TARBALL_URL) return archiveBytes;
|
|
throw new Error(`unexpected mocked registry URL: ${url}`);
|
|
};
|
|
|
|
const result = await updateCatalogFromRegistry({ cacheRoot, version: packageMetadata.version, fetcher });
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.status, "promoted");
|
|
assert.equal(result.identity.integrity, archiveIntegrity);
|
|
assert.equal(result.provenance.registryOrigin, REGISTRY_ORIGIN);
|
|
assert.equal(result.provenance.signaturesPresent, true);
|
|
assert.equal(result.provenance.attestationsPresent, true);
|
|
assert.deepEqual(calls, [
|
|
{ url: `${REGISTRY_ORIGIN}/${packageMetadata.name}/${packageMetadata.version}`, maximumBytes: 2 * 1024 * 1024 },
|
|
{ url: TARBALL_URL, maximumBytes: 64 * 1024 * 1024 },
|
|
]);
|
|
|
|
const status = await catalogStatus({
|
|
cacheRoot,
|
|
packageVersion: packageMetadata.version,
|
|
catalogDigest: result.identity.catalogDigest,
|
|
integrity: archiveIntegrity,
|
|
});
|
|
assert.equal(status.status, "verified");
|
|
assert.equal(status.identity.controls.length, 1);
|
|
assert.equal(status.identity.controls[0].path, CATALOG_MANIFEST_PATH);
|
|
assert.equal(status.identity.assets.length, CATALOG_ASSET_PATHS.length);
|
|
|
|
const alternateRoot = path.join(testRoot, "alternate-bundled");
|
|
const alternateDigest = await makeAlternateBundledCatalog(status.targetPath, alternateRoot);
|
|
assert.notEqual(alternateDigest, result.identity.catalogDigest);
|
|
const resolveCatalog = createVerifiedCatalogResolver({ cacheRoot, bundledRoot: alternateRoot });
|
|
const resolved = await resolveCatalog(result.identity.catalogDigest);
|
|
assert.ok(resolved);
|
|
assert.equal(resolved.digest, result.identity.catalogDigest);
|
|
assert.equal(resolved.skills.length, expectedSkillCount);
|
|
|
|
const beforeRepeat = JSON.stringify(status.identity);
|
|
calls.length = 0;
|
|
const repeated = await updateCatalogFromRegistry({ cacheRoot, version: packageMetadata.version, fetcher });
|
|
assert.equal(repeated.status, "alreadyPresent");
|
|
const repeatedStatus = await catalogStatus({
|
|
cacheRoot,
|
|
packageVersion: packageMetadata.version,
|
|
catalogDigest: result.identity.catalogDigest,
|
|
integrity: archiveIntegrity,
|
|
});
|
|
assert.equal(repeatedStatus.status, "verified");
|
|
assert.equal(JSON.stringify(repeatedStatus.identity), beforeRepeat);
|
|
});
|