Neither manifest declared any icons, so both browsers showed a generic puzzle-piece -- the first thing seen on every browser start, and how a user tells a real extension from a look-alike. Both manifests now declare 16/32/48/128, and the PNGs ship inside each browser archive rather than being left at dist/ root, which is the trap that made a naive zip incomplete before. build.js reads which icons to copy from each manifest's own icons block, so the manifest is the single source of truth and a declared-but-absent size fails the build rather than shipping a dangling reference; the packager's reference-resolver covers them independently. Manifest values are constrained before being joined into a path. The artwork is original, generated from geometry rather than traced or fetched.
211 lines
8.4 KiB
JavaScript
211 lines
8.4 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,
|
|
} = 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"]);
|
|
});
|
|
});
|
|
|
|
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/);
|
|
});
|
|
});
|