diff --git a/packages/safe-bash/tests/integration/s3-http-exports/archive-controls.test.mjs b/packages/safe-bash/tests/integration/s3-http-exports/archive-controls.test.mjs index 8ba07012c..6df60cd07 100644 --- a/packages/safe-bash/tests/integration/s3-http-exports/archive-controls.test.mjs +++ b/packages/safe-bash/tests/integration/s3-http-exports/archive-controls.test.mjs @@ -1018,6 +1018,7 @@ test("maintained outer launcher rejects inherited startup settings before the ve writeFileSync(join(directory, "exports.test.mjs"), launcher); assert.deepEqual(readFileSync(join(directory, "exports.test.mjs")), launcher); writeFileSync(join(directory, "archive-controls.test.mjs"), "export {};\n"); + writeFileSync(join(directory, "archive-parser.test.mjs"), "export {};\n"); writeFileSync(join(directory, "committed-archive.mjs"), `export { cleanEnvironment } from ${JSON.stringify(new URL("./committed-archive.mjs", import.meta.url).href)};\n`); const startupMarker = join(directory, "startup-ran"); const verifierMarker = join(directory, "synthetic-verifier-ran"); diff --git a/packages/safe-bash/tests/integration/s3-http-exports/archive-parser.test.mjs b/packages/safe-bash/tests/integration/s3-http-exports/archive-parser.test.mjs new file mode 100644 index 000000000..e8d5bce16 --- /dev/null +++ b/packages/safe-bash/tests/integration/s3-http-exports/archive-parser.test.mjs @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { digest, readArchive, resolveTools } from "./committed-archive.mjs"; + +// Exercise both public constructor names with the actual npm-bundled parser. +// In-memory archive bytes keep these controls independent of package staging. +for (const exportName of ["Parse", "Parser"]) { + test(`archive admission preserves safety with the ${exportName} tar API`, async () => { + const { tar } = resolveTools(); + const Parser = tar.Parser ?? tar.Parse; + const parserApi = { [exportName]: Parser }; + const archive = (path, type = "File") => { + const payload = Buffer.from("admitted bytes"); + const header = new tar.Header({ path, type, size: type === "File" ? payload.length : 0, linkpath: type === "SymbolicLink" ? "allowed.txt" : "", mode: 0o644 }); + const bytes = Buffer.alloc(2048); + header.encode(bytes); + if (type === "File") payload.copy(bytes, 512); + return bytes; + }; + const read = (bytes, admit = () => {}, expectedHash = digest(bytes)) => readArchive(parserApi, "/control.tar", expectedHash, admit, { + lstatSync: () => ({ isFile: () => true, size: bytes.length }), + readFileSync: () => bytes, + }); + const bytes = archive("allowed.txt"); + const admitted = []; + const files = await read(bytes, path => admitted.push(path)); + assert.deepEqual(admitted, ["allowed.txt"]); + assert.equal(files.get("allowed.txt").toString(), "admitted bytes"); + await assert.rejects(read(bytes, () => assert.fail("must authenticate first"), "0".repeat(64)), /identity changed/); + await assert.rejects(read(bytes, () => assert.fail("unbound entry")), /unbound entry/); + await assert.rejects(read(archive("../escape")), /nonliteral input path/); + await assert.rejects(read(archive("link.txt", "SymbolicLink")), /nonregular archive entry/); + const duplicate = Buffer.concat([bytes.subarray(0, 1024), bytes]); + await assert.rejects(read(duplicate), /duplicate or excessive archive entries/); + }); +} diff --git a/packages/safe-bash/tests/integration/s3-http-exports/committed-archive.mjs b/packages/safe-bash/tests/integration/s3-http-exports/committed-archive.mjs index 492935191..5e7dcde4b 100644 --- a/packages/safe-bash/tests/integration/s3-http-exports/committed-archive.mjs +++ b/packages/safe-bash/tests/integration/s3-http-exports/committed-archive.mjs @@ -551,7 +551,8 @@ export async function readArchive(tar, filename, expectedHash, admit, fileSystem const files = new Map(); let expanded = 0; await new Promise((resolvePromise, reject) => { - const parser = new tar.Parser({ strict: true, maxMetaEntrySize: 1024 * 1024 }); + const Parser = tar.Parser ?? tar.Parse; + const parser = new Parser({ strict: true, maxMetaEntrySize: 1024 * 1024 }); parser.on("error", reject); parser.on("end", resolvePromise); parser.on("entry", entry => { diff --git a/packages/safe-bash/tests/integration/s3-http-exports/exports.test.ts b/packages/safe-bash/tests/integration/s3-http-exports/exports.test.ts index 51fecdd39..924a36799 100644 --- a/packages/safe-bash/tests/integration/s3-http-exports/exports.test.ts +++ b/packages/safe-bash/tests/integration/s3-http-exports/exports.test.ts @@ -7,6 +7,7 @@ import { test } from "node:test"; import { fileURLToPath } from "node:url"; await import(new URL("./archive-controls.test.mjs", import.meta.url).href); +await import(new URL("./archive-parser.test.mjs", import.meta.url).href); const { cleanEnvironment } = await import(new URL("./committed-archive.mjs", import.meta.url).href); test("S3 HTTP root/subpath exports work from a clean packed revision without source fallback", { timeout: 300_000 }, () => {