Files
AutistMask/tests/packaging.test.js
T
sneak ded29b3a9c
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
fix: build hashes and recovery-phrase entropy come from ethers (closes #758)
build.js and script/lib/package.js hash with sha256 from ethers, with the
0x prefix stripped so the build receipt and SHA256SUMS keep bare lowercase
hex. generateMnemonic() takes its 16 bytes of entropy from randomBytes in
ethers. The README Crypto policy no longer lists these as exceptions.

Model: opus-5-5
2026-11-10 21:40:47 +00:00

220 lines
8.8 KiB
JavaScript

// The release archives: the zip container, and the self-containment rule.
//
// script/package produces one archive per browser and script/lib/package.js
// decides what goes in it. The trap that rule exists for is real and specific:
// build.js writes the compiled stylesheet to dist/styles.css at the dist/
// ROOT, outside both browser directories, and copies it into each of them as
// src/popup/styles.css. A `zip -r dist/chrome` is correct only because of that
// copy, and would silently start shipping a popup with no stylesheet the
// moment a reference pointed up and out of the directory.
//
// So the packager resolves every reference in the manifest and in every HTML
// document, and fails on any that leaves the extension root. These are the
// cases for that, plus the archive format itself — new code, and the thing the
// artifact is made of.
const {
checkSelfContained,
htmlReferences,
manifestReferences,
sha256,
} = require("../script/lib/package");
const { readZip, writeZip } = require("../script/lib/zip");
function archiveOf(files) {
return {
members: Object.keys(files).sort(),
read: (name) => Buffer.from(files[name] ?? "", "utf8"),
};
}
const MINIMAL_MANIFEST = {
manifest_version: 3,
name: "AutistMask",
version: "0.1.0",
action: { default_popup: "src/popup/index.html" },
background: { service_worker: "src/background/index.js" },
};
describe("archive self-containment", () => {
test("a complete tree passes", () => {
const { members, read } = archiveOf({
"manifest.json": JSON.stringify(MINIMAL_MANIFEST),
"src/popup/index.html":
'<link rel="stylesheet" href="styles.css" />' +
'<script src="index.js"></script>',
"src/popup/styles.css": "body{}",
"src/popup/index.js": "//",
"src/background/index.js": "//",
});
expect(() => checkSelfContained("chrome", members, read)).not.toThrow();
});
test("a manifest naming a file that is not in the archive fails", () => {
const { members, read } = archiveOf({
"manifest.json": JSON.stringify(MINIMAL_MANIFEST),
"src/popup/index.html": "<html></html>",
"src/popup/index.js": "//",
});
expect(() => checkSelfContained("chrome", members, read)).toThrow(
/would not be self-contained.*src\/background\/index\.js/s,
);
});
// The dist/styles.css case, exactly: a popup that reached up out of its
// own browser directory for the stylesheet the build leaves at the dist/
// root. Nothing would be missing from disk, and the zip would still be
// built — the archive would just have no stylesheet in it.
test("an HTML reference that escapes the extension root fails", () => {
const { members, read } = archiveOf({
"manifest.json": JSON.stringify(MINIMAL_MANIFEST),
"src/popup/index.html":
'<link rel="stylesheet" href="../../../styles.css" />',
"src/popup/index.js": "//",
"src/background/index.js": "//",
});
expect(() => checkSelfContained("chrome", members, read)).toThrow(
/resolves outside the extension root/,
);
});
test("a manifest reference that escapes the extension root fails", () => {
const { members, read } = archiveOf({
"manifest.json": JSON.stringify({
...MINIMAL_MANIFEST,
background: { service_worker: "../shared/index.js" },
}),
"src/popup/index.html": "<html></html>",
});
expect(() => checkSelfContained("chrome", members, read)).toThrow(
/points outside the extension root/,
);
});
// Toolbar icons are the other file the manifest names and no bundler
// emits, so an archive built without them would carry a manifest whose
// "icons" resolve to nothing and a browser would fall back to a generic
// placeholder without saying so.
test("a manifest naming an icon that is not in the archive fails", () => {
const { members, read } = archiveOf({
"manifest.json": JSON.stringify({
...MINIMAL_MANIFEST,
icons: { 16: "icons/icon16.png", 128: "icons/icon128.png" },
}),
"src/popup/index.html": "<html></html>",
"src/popup/index.js": "//",
"src/background/index.js": "//",
"icons/icon16.png": "PNG",
});
expect(() => checkSelfContained("chrome", members, read)).toThrow(
/would not be self-contained.*icons\/icon128\.png/s,
);
});
test("an archive with no manifest.json at its root fails", () => {
const { members, read } = archiveOf({ "src/popup/index.js": "//" });
expect(() => checkSelfContained("chrome", members, read)).toThrow(
/no manifest\.json at its root/,
);
});
test("manifest strings that are not paths are not treated as files", () => {
const found = manifestReferences({
name: "AutistMask",
version: "0.1.0",
permissions: ["storage", "<all_urls>"],
content_security_policy: {
extension_pages: "default-src 'self'; script-src 'self'",
},
key: "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzy",
});
expect([...found]).toEqual([]);
});
test("absolute, data: and anchor HTML references are not files", () => {
const refs = htmlReferences(
"src/popup/index.html",
'<a href="https://example.com/x.js">a</a>' +
'<img src="data:image/png;base64,AAAA" />' +
'<a href="#view-main">b</a>' +
'<script src="index.js"></script>',
);
expect([...refs]).toEqual(["src/popup/index.js"]);
});
});
// release/SHA256SUMS is checked with `sha256sum -c`, which needs the digest
// as bare lowercase hex. The expected value is the FIPS 180-2 digest of "abc".
test("sha256 prints the digest as sha256sum does", () => {
expect(sha256(Buffer.from("abc"))).toBe(
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
);
});
describe("the zip container", () => {
const files = [
{ name: "manifest.json", data: Buffer.from('{"a":1}') },
// Long enough that deflate wins, so both paths through compress() are
// exercised by one archive.
{ name: "src/popup/index.js", data: Buffer.from("x".repeat(5000)) },
{ name: "empty.txt", data: Buffer.alloc(0) },
];
test("round-trips every member byte for byte", () => {
const entries = readZip(writeZip(files));
expect(entries.map((e) => e.name)).toEqual([
"empty.txt",
"manifest.json",
"src/popup/index.js",
]);
for (const original of files) {
const found = entries.find((e) => e.name === original.name);
expect(found.data.equals(original.data)).toBe(true);
}
});
// The sha256 in release/SHA256SUMS has to be a property of the input. Two
// builds of one commit that produce different archives cannot be compared
// to each other, which is most of what publishing a digest is for.
test("is byte-identical across runs and independent of input order", () => {
const a = writeZip(files);
const b = writeZip([...files].reverse());
expect(a.equals(b)).toBe(true);
});
// Reading an archive back is how script/lib/package.js establishes that
// the artifact holds what dist/ holds, so a member whose bytes changed
// after it was written has to fail rather than be handed back.
test("a corrupted member fails its CRC on read", () => {
// One member, incompressible at this size, so it is stored verbatim
// and its bytes begin at a known offset: local header (30) + name.
const name = "a.js";
const archive = writeZip([{ name, data: Buffer.from("hello") }]);
expect(readZip(archive)[0].data.toString()).toBe("hello");
archive[30 + name.length] ^= 0xff;
expect(() => readZip(archive)).toThrow(/fails its recorded CRC32/);
});
test.each([["/abs.js"], ["../up.js"], ["a/../b.js"], ["a\\b.js"], [""]])(
"refuses the member name %p",
(name) => {
expect(() => writeZip([{ name, data: Buffer.from("x") }])).toThrow(
/member name/,
);
},
);
test("refuses an archive with no members", () => {
expect(() => writeZip([])).toThrow(/no members/);
});
test("refuses duplicate members", () => {
expect(() =>
writeZip([
{ name: "a.js", data: Buffer.from("1") },
{ name: "a.js", data: Buffer.from("2") },
]),
).toThrow(/duplicate member/);
});
});