Neither manifest declared an `icons` block, so Chrome and Firefox both drew the generic puzzle piece for this extension. That is the first thing the owner sees on every launch, and an unbranded placeholder is also how a user fails to tell a real extension from a look-alike. Both manifests now declare 16/32/48/128 as `icons/icon<size>.png`, and the four PNGs live at `icons/` in the tree. build.js copies them into each browser directory relative to it, so nothing points up and out the way `dist/styles.css` does, and the receipt records them like every other emitted file. The sizes copied come from the manifest that will ship next to them, not from a second list in build.js: a size a manifest declares and `icons/` does not hold fails the build with the path that is missing, rather than emitting a directory whose manifest references nothing. script/lib/package.js already resolved `.png` strings, so an icon that reached a manifest but not the archive fails self-containment; tests/packaging.test.js now pins that case, since it was covered only incidentally before. tests/manifest.test.js asserts the declaration in both manifests, that both declare the same set, and that each referenced file is a PNG whose IHDR states the size the entry claims — a declaration alone would still permit a reference to a file that is not there or is not an image. The artwork is original, drawn from geometry rather than traced or downloaded: a flat dark-navy rounded square (#101A2E) with a teal (#35E0C2) triangular "A" — one outer triangle minus a triangular counter — rasterised with 8x8 supersampling and encoded as RGBA PNG. One shape, two flat colours, which is what a 16px toolbar slot can carry. Verified: make check 56 suites / 1023 tests, make build and make package green, both archives unpacked and the four icons confirmed inside each with bytes identical to the tree, make test-e2e 55/55 and 5/5, make test-e2e-firefox 8/8 and 7/7 (the latter installs the packaged XPI). Removing icons/icon48.png fails make build; making build.js skip one copy fails make package with "the chrome archive would not be self-contained: it is told to load icons/icon48.png, which is not in it".
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/);
|
|
});
|
|
});
|