release: package the extension, pin the Chrome extension id, and prove the wallet survives a reinstall (closes #310)
All checks were successful
check / check (push) Successful in 52s
e2e / e2e-chrome (push) Successful in 2m0s
e2e / e2e-firefox (push) Successful in 50s

There was no packaging target anywhere, no artifact, and no `key` in
`manifest/chrome.json` — so an unpacked Chrome load derived its extension
id, and therefore its `chrome.storage.local` partition, from the absolute
checkout path. Moving or re-cloning the checkout presented an empty
wallet, with no error and nothing in the UI to say so.

`manifest/chrome.json` now carries a fixed `key`: the public half of an
RSA keypair, which pins the extension id to
`gipbhkogfopeahplcjhipkgpcimdpkip`. The private half is a credential and
is not in this repo; no target generates one into the working tree, and
`tests/extensionId.test.js` fails if a `.pem` is ever committed. Changing
`key` changes the id and orphans every wallet stored under the old one.

`make package` (script/package) runs `make build` — the only audited path
to a release build — and writes one self-contained, versioned archive per
browser into `release/`, plus `SHA256SUMS`. The archives are deterministic:
entries sorted, timestamps fixed, compression level fixed, so two builds
of one commit are byte-identical. Self-containment is checked rather than
assumed: every path the manifests and the popup HTML reference is resolved
and required to be inside the archive, a reference that climbs out of the
extension root is a hard failure, and files left at the `dist/` root —
`dist/styles.css`, which build.js copies into each browser directory — are
reported as deliberately not shipped rather than dropped by a glob. The
archive is then read back off disk and compared member by member against
the directory it was built from. The zip writer and reader are stdlib zlib
in `script/lib/zip.js`; no new dependency, and nothing unpinned.

One version, enforced rather than generated. `script/lib/version.js`
requires `package.json`, `manifest/chrome.json` and `manifest/firefox.json`
to agree and fails the build naming each file and what it said, instead of
reading from one of the three. `BUILD_COMMIT` now carries `-dirty` when the
working tree does not match `HEAD`, and `-unknown` when git cannot say;
the full hash behind the About screen's commit link stays clean so the link
still resolves.

Two real-browser observations, both run through the pinned harnesses:

- `tests/e2e/storagePartition.js` loads the build from two different paths
  in one Chrome profile. With `key`: same id, and the second load reads the
  first load's storage. Without `key`: different ids, and the second load
  sees an empty partition. Loading both keyed copies at once yields one id,
  not two.
- `tests/e2e/firefox/reinstall.js` installs the packaged XPI in a real
  Firefox, creates a wallet, quits the browser, restarts on the same
  profile, adds the add-on again, and decrypts the vault back to the
  original recovery phrase. It then observes that an explicit uninstall
  DESTROYS that storage — correct browser behaviour, but for a wallet it
  means Remove is irreversible except from the recovery phrase, so
  README.md says so.

Firefox ships an UNSIGNED XPI. README.md states plainly that release
Firefox and ESR will refuse it, that Developer Edition, Nightly or an
Unbranded build is required, and that a temporary add-on does not survive
a browser restart. AMO signing, CRX packing, tagging and any upload are
deliberately out of scope.
This commit is contained in:
2026-08-23 13:52:29 +00:00
parent 669c443bf9
commit a874299412
21 changed files with 2142 additions and 42 deletions

294
script/lib/package.js Normal file
View File

@@ -0,0 +1,294 @@
// Turn a verified dist/ into the two distributable archives.
//
// Invoked by script/package, which runs `make build` first so that dist/ has
// already been checked against the build's own receipt (see the Build Receipts
// section of README.md). This program does not build anything and does not
// write into dist/: it reads the emitted tree and writes release/.
//
// release/autistmask-chrome-<version>.zip loaded via chrome://extensions
// release/autistmask-firefox-<version>.xpi an UNSIGNED add-on, see README
// release/SHA256SUMS
//
// Self-containment is checked rather than assumed, because the layout invites
// exactly one mistake: build.js emits dist/styles.css at the dist/ ROOT,
// outside both browser directories, and copies it into each of them as
// src/popup/styles.css. A naive `zip -r dist/chrome` is therefore correct only
// by accident, and would stop being correct the moment a reference pointed up
// and out. So every path the manifest and the popup HTML reference is resolved
// and required to be inside the archive, a reference that escapes the browser
// directory is a hard failure, and anything sitting at the dist/ root is
// listed as deliberately not shipped rather than silently dropped.
//
// The archive is then read back and compared byte for byte against the
// directory it was built from. An archive nobody opened is a claim, not an
// artifact.
"use strict";
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
const { readZip, writeZip } = require("./zip");
const { resolveVersion } = require("./version");
const ROOT = path.resolve(__dirname, "..", "..");
const DIST = path.join(ROOT, "dist");
const RELEASE = path.join(ROOT, "release");
const TARGETS = [
{ dir: "chrome", ext: "zip" },
// .xpi rather than .zip: it is the same container, but Firefox's install
// flow keys off the extension.
{ dir: "firefox", ext: "xpi" },
];
// Strings in a manifest that name a file the extension loads. Matched by
// shape, not by a list of manifest keys, so a key added in a later manifest
// version is covered the day it appears rather than the day someone remembers
// to extend a list here. Nothing else in either manifest looks like this: the
// CSP strings, "<all_urls>", the version and the base64 key all fail it.
const MANIFEST_PATH_RE =
/^[A-Za-z0-9._][A-Za-z0-9._/-]*\.(?:js|css|html|json|png|svg|woff2?)$/;
// Local references out of an HTML document. Enough for what this repo emits —
// one stylesheet link and one script tag — and anything it does not understand
// is reported rather than passed over, see htmlReferences().
const HTML_REF_RE = /(?:src|href)\s*=\s*["']([^"']+)["']/gi;
function fail(message) {
throw new Error(message);
}
function sha256(buf) {
return crypto.createHash("sha256").update(buf).digest("hex");
}
// Every regular file under dir, as archive-root-relative forward-slashed
// paths. A symlink is refused rather than followed: build.js emits regular
// files only, so a link under dist/ is not something the build produced, and
// dereferencing one would put bytes from outside dist/ into the artifact.
function listFiles(dir, prefix = "") {
const out = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isSymbolicLink()) {
fail(
`${dir}/${entry.name} is a symlink. The build emits regular ` +
`files only, so this is not something it produced and it ` +
`will not be archived.`,
);
} else if (entry.isDirectory()) {
out.push(...listFiles(path.join(dir, entry.name), rel));
} else if (entry.isFile()) {
out.push(rel);
} else {
fail(
`${dir}/${entry.name} is neither a regular file nor a ` +
`directory, so it is not something the build emitted`,
);
}
}
return out.sort();
}
// Collect every string anywhere in the manifest that looks like a file it
// loads, plus every string that tries to reach outside the extension root.
// The second half is the point: "../styles.css" never matches
// MANIFEST_PATH_RE, so without an explicit check an escaping reference would
// read as "not a path" and the missing file would be found only by a user
// whose popup rendered unstyled.
function manifestReferences(value, found = new Set()) {
if (typeof value === "string") {
if (value.split("/").includes("..")) {
fail(
`the manifest references ${JSON.stringify(value)}, which ` +
`points outside the extension root. Everything the ` +
`browser loads has to be inside the archive; nothing ` +
`above it is shipped.`,
);
}
if (MANIFEST_PATH_RE.test(value)) found.add(value);
} else if (Array.isArray(value)) {
for (const v of value) manifestReferences(v, found);
} else if (value && typeof value === "object") {
for (const v of Object.values(value)) manifestReferences(v, found);
}
return found;
}
// Local references out of one HTML member, resolved against that member's own
// directory and returned archive-relative. Absolute URLs, data: URIs and
// in-page anchors are not files and are skipped; a relative reference that
// climbs out of the archive root is a failure for the same reason as above.
function htmlReferences(member, text) {
const base = path.posix.dirname(member);
const out = new Set();
for (const match of text.matchAll(HTML_REF_RE)) {
const ref = match[1].trim();
if (ref === "" || ref.startsWith("#") || ref.startsWith("//")) continue;
if (/^[a-z][a-z0-9+.-]*:/i.test(ref)) continue;
if (ref.startsWith("/")) {
fail(
`${member} references ${JSON.stringify(ref)} from the ` +
`extension root. Nothing here emits root-absolute ` +
`references and this packager does not resolve them.`,
);
}
const resolved = path.posix.normalize(path.posix.join(base, ref));
if (resolved.startsWith("..")) {
fail(
`${member} references ${JSON.stringify(ref)}, which resolves ` +
`outside the extension root. build.js copies the ` +
`compiled stylesheet into each browser directory for ` +
`exactly this reason: dist/styles.css lives at the dist/ ` +
`root and is not part of either archive.`,
);
}
out.add(resolved);
}
return out;
}
// Everything the browser is told to load, and the assertion that all of it is
// in the archive.
function checkSelfContained(target, members, read) {
if (!members.includes("manifest.json")) {
fail(`dist/${target} has no manifest.json at its root`);
}
const manifest = JSON.parse(read("manifest.json").toString("utf8"));
const referenced = new Set(manifestReferences(manifest));
for (const member of members) {
if (!member.endsWith(".html")) continue;
for (const ref of htmlReferences(
member,
read(member).toString("utf8"),
)) {
referenced.add(ref);
}
}
const missing = [...referenced].filter((r) => !members.includes(r));
if (missing.length > 0) {
fail(
`the ${target} archive would not be self-contained: it is told ` +
`to load ${missing.join(", ")}, which ${
missing.length === 1 ? "is" : "are"
} not in it`,
);
}
return { manifest, referenced };
}
function main() {
const version = resolveVersion(ROOT);
if (!fs.existsSync(DIST)) {
fail(
"there is no dist/ to package. script/package runs make build " +
"first; run it rather than this program.",
);
}
fs.rmSync(RELEASE, { recursive: true, force: true });
fs.mkdirSync(RELEASE, { recursive: true });
// Files the build emits at the dist/ root, outside both browser
// directories. Printed rather than ignored: dist/styles.css is the
// Tailwind output that build.js then copies into each browser directory,
// so leaving it out is correct — but "correct and stated" and "dropped by
// a glob" are different things, and only one of them survives the next
// change to the build.
const rootOnly = fs
.readdirSync(DIST, { withFileTypes: true })
.filter((e) => !e.isDirectory())
.map((e) => e.name)
.sort();
if (rootOnly.length > 0) {
console.log(
`Not shipped (dist/ root, outside every browser directory, and ` +
`referenced by nothing inside one): ${rootOnly.join(", ")}`,
);
}
const sums = [];
for (const { dir, ext } of TARGETS) {
const targetDir = path.join(DIST, dir);
if (!fs.existsSync(targetDir)) {
fail(`dist/${dir} does not exist; run make build`);
}
const members = listFiles(targetDir);
const readFromDir = (member) =>
fs.readFileSync(path.join(targetDir, member));
const { manifest } = checkSelfContained(dir, members, readFromDir);
if (manifest.version !== version) {
fail(
`dist/${dir}/manifest.json says version ${manifest.version} ` +
`but this tree is ${version}. dist/ is stale: run make ` +
`build.`,
);
}
const archive = writeZip(
members.map((name) => ({ name, data: readFromDir(name) })),
);
const name = `autistmask-${dir}-${version}.${ext}`;
const outPath = path.join(RELEASE, name);
fs.writeFileSync(outPath, archive);
// Read the artifact back off disk, not the buffer that was just
// written: what ships is the file.
const written = fs.readFileSync(outPath);
const entries = readZip(written);
const inArchive = entries.map((e) => e.name).sort();
if (inArchive.join("\n") !== members.join("\n")) {
fail(
`${name} does not hold the same members as dist/${dir}: ` +
`archive has ${inArchive.length}, directory has ` +
`${members.length}`,
);
}
for (const entry of entries) {
const onDisk = readFromDir(entry.name);
if (sha256(entry.data) !== sha256(onDisk)) {
fail(`${name} member ${entry.name} differs from dist/${dir}`);
}
}
// Re-run the self-containment check against the ARCHIVE's own
// contents. The directory passing it is not the claim being made.
const byName = new Map(entries.map((e) => [e.name, e.data]));
checkSelfContained(dir, inArchive, (m) => byName.get(m));
const digest = sha256(written);
sums.push(`${digest} ${name}`);
console.log(
`${name}: ${entries.length} member(s), ${written.length} bytes, ` +
`sha256 ${digest}`,
);
}
fs.writeFileSync(
path.join(RELEASE, "SHA256SUMS"),
sums.map((l) => `${l}\n`).join(""),
);
console.log(`Wrote release/ for version ${version}`);
}
// Only when run as a program. The reference-resolving helpers are what decide
// whether an archive is self-contained, so tests/packaging.test.js exercises
// them directly and must be able to require this file without packaging
// anything.
if (require.main === module) {
try {
main();
} catch (err) {
console.error(`package: ${err && err.message ? err.message : err}`);
process.exit(1);
}
}
module.exports = { checkSelfContained, htmlReferences, manifestReferences };

74
script/lib/version.js Normal file
View File

@@ -0,0 +1,74 @@
// The version, and the rule that there is only one of it.
//
// Three files declare a version and none of them can be derived from another:
// Chrome and Firefox each need their own manifest, both are copied to dist/
// verbatim (tests/manifest.test.js asserts that what is in manifest/ is what
// ships), and package.json's copy is what build.js compiles into the About
// screen. So the single source of truth is enforced rather than generated —
// they must all agree or there is no version and no build.
//
// Reading one of the three and ignoring the rest is what this replaces. That
// shape cannot fail: it silently ships an extension whose About screen and
// whose browser-reported version disagree, and whose release artifact is named
// after whichever file the packager happened to read.
//
// Required by build.js, script/lib/package.js and tests/version.test.js, so
// the build, the release artifacts and make check all apply the same rule to
// the same files.
"use strict";
const fs = require("fs");
const path = require("path");
const VERSION_SOURCES = [
"package.json",
"manifest/chrome.json",
"manifest/firefox.json",
];
// Every declared version, in VERSION_SOURCES order, as { source, version }.
// A file that declares nothing usable fails here rather than being skipped:
// a missing version is not agreement.
function declaredVersions(root) {
return VERSION_SOURCES.map((source) => {
const file = path.join(root, source);
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(file, "utf8"));
} catch (e) {
throw new Error(
`${source} could not be read as JSON: ${e.message}`,
);
}
const version = parsed.version;
if (typeof version !== "string" || version.trim() === "") {
throw new Error(
`${source} declares no usable "version" (found ` +
`${JSON.stringify(version)}). Every artifact is named and ` +
`stamped with it, so there is nothing to build without it.`,
);
}
return { source, version };
});
}
// The one version all three declare, or a failure naming every disagreeing
// file and what it said.
function resolveVersion(root) {
const declared = declaredVersions(root);
const distinct = [...new Set(declared.map((d) => d.version))];
if (distinct.length !== 1) {
throw new Error(
"the declared versions disagree, so this tree has no version: " +
declared.map((d) => `${d.source}=${d.version}`).join(", ") +
". Set all of them to the same value: the manifests are what " +
"the browser reports and package.json is what the About " +
"screen shows, and a build that picked one of them would " +
"ship the disagreement.",
);
}
return distinct[0];
}
module.exports = { VERSION_SOURCES, declaredVersions, resolveVersion };

274
script/lib/zip.js Normal file
View File

@@ -0,0 +1,274 @@
// A minimal, deterministic ZIP writer and reader.
//
// Used by script/lib/package.js to build the distributable archives: a Chrome
// zip and a Firefox XPI are both ordinary zip files with manifest.json at the
// root, so one implementation covers both.
//
// Why this rather than a package or the zip(1) binary. A dependency would have
// to be hash-pinned like everything else in REPO_POLICIES.md, and this is
// about a hundred lines of stdlib zlib for a format the archives use two
// features of. The binary is worse: the release artifact would then depend on
// whichever Info-ZIP the machine happens to have, which is the same objection
// that keeps linting inside a container.
//
// Deterministic on purpose. Entries are sorted by name, every timestamp is the
// same fixed 1980-01-01 the format's epoch starts at, and the compression
// level is fixed, so building the same dist/ twice produces byte-identical
// archives and the sha256 in SHA256SUMS is a property of the input rather than
// of the clock. Two builds of the same commit that disagree are then visible
// instead of expected.
//
// Deliberately NOT implemented: zip64, encryption, data descriptors,
// directory entries (browsers infer directories from member paths), and
// anything to do with symlinks. writeZip refuses input it cannot represent
// rather than emitting an archive that is quietly wrong.
"use strict";
const zlib = require("zlib");
const LOCAL_SIG = 0x04034b50;
const CENTRAL_SIG = 0x02014b50;
const EOCD_SIG = 0x06054b50;
const METHOD_STORE = 0;
const METHOD_DEFLATE = 8;
// 1980-01-01 00:00:00, the earliest the MS-DOS timestamp fields can express.
const DOS_DATE = (0 << 9) | (1 << 5) | 1;
const DOS_TIME = 0;
// Unix regular file, mode 0644, in the high 16 bits, which is where the "made
// by unix" convention puts it.
// >>> 0 because JS shifts are signed 32-bit and this one sets the top bit.
const EXTERNAL_ATTRS = (0o100644 << 16) >>> 0;
const VERSION_MADE_BY = (3 << 8) | 20; // unix, needs zip 2.0
const VERSION_NEEDED = 20;
// Without zip64 every size and offset is a u32.
const MAX_U32 = 0xffffffff;
const CRC_TABLE = (() => {
const table = new Int32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let k = 0; k < 8; k++) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
}
table[i] = c;
}
return table;
})();
// Written out rather than taken from zlib.crc32, which only exists from node
// 22.2: this runs from script/ on whatever node the host has as well as inside
// the pinned image, and a checksum that silently is not there is worse than
// twelve lines.
function crc32(buf) {
let c = -1;
for (let i = 0; i < buf.length; i++) {
c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
}
return (c ^ -1) >>> 0;
}
// Member names are stored as raw bytes. Anything outside ASCII would need the
// UTF-8 flag and interoperability care that nothing this repo emits requires,
// so it is refused instead of guessed at.
function encodeName(name) {
if (typeof name !== "string" || name === "") {
throw new Error(`zip: unusable member name ${JSON.stringify(name)}`);
}
const segments = name.split("/");
if (
name.startsWith("/") ||
name.includes("\\") ||
segments.some((s) => s === "" || s === "." || s === "..")
) {
throw new Error(
`zip: refusing member name ${JSON.stringify(name)}: archive ` +
`members must be relative paths under the archive root`,
);
}
if (/[^\x20-\x7e]/.test(name)) {
throw new Error(
`zip: refusing non-ASCII member name ${JSON.stringify(name)}`,
);
}
return Buffer.from(name, "ascii");
}
function compress(data) {
if (data.length === 0) {
return { method: METHOD_STORE, body: data };
}
const deflated = zlib.deflateRawSync(data, { level: 9 });
if (deflated.length >= data.length) {
return { method: METHOD_STORE, body: data };
}
return { method: METHOD_DEFLATE, body: deflated };
}
// entries: [{ name, data }]. Returns the archive as a Buffer.
function writeZip(entries) {
if (!Array.isArray(entries) || entries.length === 0) {
throw new Error("zip: refusing to write an archive with no members");
}
const sorted = [...entries].sort((a, b) => (a.name < b.name ? -1 : 1));
const seen = new Set();
const locals = [];
const centrals = [];
let offset = 0;
for (const entry of sorted) {
const name = encodeName(entry.name);
if (seen.has(entry.name)) {
throw new Error(`zip: duplicate member ${entry.name}`);
}
seen.add(entry.name);
const data = Buffer.from(entry.data);
const { method, body } = compress(data);
if (data.length > MAX_U32 || body.length > MAX_U32) {
throw new Error(
`zip: ${entry.name} is too large for a non-zip64 archive`,
);
}
const crc = crc32(data);
const local = Buffer.alloc(30 + name.length);
local.writeUInt32LE(LOCAL_SIG, 0);
local.writeUInt16LE(VERSION_NEEDED, 4);
local.writeUInt16LE(0, 6);
local.writeUInt16LE(method, 8);
local.writeUInt16LE(DOS_TIME, 10);
local.writeUInt16LE(DOS_DATE, 12);
local.writeUInt32LE(crc, 14);
local.writeUInt32LE(body.length, 18);
local.writeUInt32LE(data.length, 22);
local.writeUInt16LE(name.length, 26);
local.writeUInt16LE(0, 28);
name.copy(local, 30);
const central = Buffer.alloc(46 + name.length);
central.writeUInt32LE(CENTRAL_SIG, 0);
central.writeUInt16LE(VERSION_MADE_BY, 4);
central.writeUInt16LE(VERSION_NEEDED, 6);
central.writeUInt16LE(0, 8);
central.writeUInt16LE(method, 10);
central.writeUInt16LE(DOS_TIME, 12);
central.writeUInt16LE(DOS_DATE, 14);
central.writeUInt32LE(crc, 16);
central.writeUInt32LE(body.length, 20);
central.writeUInt32LE(data.length, 24);
central.writeUInt16LE(name.length, 28);
central.writeUInt16LE(0, 30);
central.writeUInt16LE(0, 32);
central.writeUInt16LE(0, 34);
central.writeUInt16LE(0, 36);
central.writeUInt32LE(EXTERNAL_ATTRS, 38);
if (offset > MAX_U32) {
throw new Error("zip: archive too large for a non-zip64 archive");
}
central.writeUInt32LE(offset, 42);
name.copy(central, 46);
locals.push(local, body);
centrals.push(central);
offset += local.length + body.length;
}
const centralBuf = Buffer.concat(centrals);
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(EOCD_SIG, 0);
eocd.writeUInt16LE(0, 4);
eocd.writeUInt16LE(0, 6);
eocd.writeUInt16LE(sorted.length, 8);
eocd.writeUInt16LE(sorted.length, 10);
eocd.writeUInt32LE(centralBuf.length, 12);
eocd.writeUInt32LE(offset, 16);
eocd.writeUInt16LE(0, 20);
return Buffer.concat([...locals, centralBuf, eocd]);
}
// Read an archive back into [{ name, data }], from the central directory
// rather than by scanning for local headers: the central directory is the
// authoritative index, and a member reachable only by scanning is one a real
// unzipper would not extract.
//
// Every member's CRC is checked. The point of reading an archive back is to
// establish that it holds what it was meant to hold, so a member that does not
// decompress to its recorded checksum is a failure and never a warning.
function readZip(buf) {
if (buf.length < 22) {
throw new Error("zip: too short to be an archive");
}
// No archive this writes has a trailing comment, so the EOCD is the last
// 22 bytes. Anything else is not an archive this produced.
const eocdAt = buf.length - 22;
if (buf.readUInt32LE(eocdAt) !== EOCD_SIG) {
throw new Error(
"zip: no end-of-central-directory record at the end of the " +
"archive (a trailing comment, or not a zip at all)",
);
}
const count = buf.readUInt16LE(eocdAt + 10);
const centralSize = buf.readUInt32LE(eocdAt + 12);
let at = buf.readUInt32LE(eocdAt + 16);
if (at + centralSize > eocdAt) {
throw new Error("zip: central directory runs past the archive");
}
const out = [];
for (let i = 0; i < count; i++) {
if (buf.readUInt32LE(at) !== CENTRAL_SIG) {
throw new Error(`zip: bad central directory entry ${i}`);
}
const method = buf.readUInt16LE(at + 10);
const crc = buf.readUInt32LE(at + 16);
const compSize = buf.readUInt32LE(at + 20);
const rawSize = buf.readUInt32LE(at + 24);
const nameLen = buf.readUInt16LE(at + 28);
const extraLen = buf.readUInt16LE(at + 30);
const commentLen = buf.readUInt16LE(at + 32);
const localAt = buf.readUInt32LE(at + 42);
const name = buf.toString("ascii", at + 46, at + 46 + nameLen);
at += 46 + nameLen + extraLen + commentLen;
if (buf.readUInt32LE(localAt) !== LOCAL_SIG) {
throw new Error(`zip: ${name} has no local header`);
}
// The local header's own name and extra lengths, not the central
// directory's: the two are allowed to differ and the data starts after
// the local ones.
const localNameLen = buf.readUInt16LE(localAt + 26);
const localExtraLen = buf.readUInt16LE(localAt + 28);
const dataAt = localAt + 30 + localNameLen + localExtraLen;
const body = buf.subarray(dataAt, dataAt + compSize);
let data;
if (method === METHOD_STORE) {
data = Buffer.from(body);
} else if (method === METHOD_DEFLATE) {
data = zlib.inflateRawSync(body);
} else {
throw new Error(`zip: ${name} uses compression method ${method}`);
}
if (data.length !== rawSize) {
throw new Error(
`zip: ${name} decompressed to ${data.length} bytes, not the ` +
`recorded ${rawSize}`,
);
}
if (crc32(data) !== crc) {
throw new Error(`zip: ${name} fails its recorded CRC32`);
}
out.push({ name, data });
}
return out;
}
module.exports = { crc32, readZip, writeZip };

38
script/package Executable file
View File

@@ -0,0 +1,38 @@
#!/bin/sh
# script/package: produce the release artifacts — one self-contained,
# versioned archive per browser — into release/. Our own extension to
# scripts-to-rule-them-all.
#
# It builds first, through `make build` rather than by calling build.js
# itself. That target is the only audited path to a release build: it creates
# the build receipt outside the repo, scrubs AUTISTMASK_DEBUG from the
# verifier's environment, tells script/verify-build in so many words to expect
# a RELEASE build, and re-runs script/check-censored against dist/.
# script/test-verify-build asserts that wiring by reading the recipe back out
# of `make -n`. Re-implementing that sequence here would give the release
# artifacts a second, unaudited path to dist/ — and it is the release
# artifacts, above everything else, that must never be built from a debug
# compile.
#
# This packages, it does not publish. Tagging, CRX packing and any upload are
# outward-facing acts and are nobody's job but the owner's.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
if ! command -v make >/dev/null 2>&1; then
echo "package: make is required (the release build runs through" \
"make build)" >&2
exit 1
fi
make build
echo "Packaging release artifacts..."
node script/lib/package.js
}
main "$@"

View File

@@ -78,6 +78,22 @@ main() {
-e "E2E_TRACE_NETWORK=${E2E_TRACE_NETWORK:-0}" \
"$(cat "$IIDFILE")" \
node tests/e2e/run.js
# Where chrome.storage.local lives, and what moves it: two unpacked loads
# from two different paths in one profile, with the shipped manifest and
# again with `key` stripped out. Its own browser sessions — four of them —
# because the whole subject is what happens ACROSS loads, which the suite
# above cannot express with one.
#
# No PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS here: this drives no
# RPC and installs no route handlers, and the browser is started with
# --host-resolver-rules=MAP * ~NOTFOUND so nothing it does can leave.
echo "Running the extension-id and storage-partition observations..."
docker run --rm \
--ipc=host \
-e HOME=/tmp \
"$(cat "$IIDFILE")" \
node tests/e2e/storagePartition.js
}
main "$@"

View File

@@ -72,6 +72,19 @@ main() {
-e HOME=/tmp \
"$(cat "$IIDFILE")" \
node tests/e2e/firefox/run.js dist/firefox
# The install/uninstall/re-install property, against the packaged XPI
# rather than the unpacked directory: it is the artifact a user would be
# handed, and this is the only place a real Firefox is asked to load it.
# Its own browser session, because it takes the add-on away in the middle
# and the suite above shares one session throughout.
echo "Running the Firefox re-install suite against the packaged XPI..."
docker run --rm \
--shm-size=1g \
--network none \
-e HOME=/tmp \
"$(cat "$IIDFILE")" \
node tests/e2e/firefox/reinstall.js
}
main "$@"