// 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, } = 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": '' + '', "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": "", "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": '', "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": "", }); expect(() => checkSelfContained("chrome", members, read)).toThrow( /points outside the extension root/, ); }); 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", ""], 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' + '' + 'b' + '', ); expect([...refs]).toEqual(["src/popup/index.js"]); }); }); 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/); }); });