Files
AutistMask/build.js
clawbot bd0a626e7b
All checks were successful
check / check (push) Successful in 33s
e2e / e2e-chrome (push) Successful in 1m45s
e2e / e2e-firefox (push) Successful in 31s
harden: stop the background reading the shared state singleton, and enforce it at build time (closes #324)
Five defects, one of which destroyed every wallet, came from src/background reading and writing the module-level state singleton the MV3 worker never populates, which silently served DEFAULT_STATE. Each point fix created the next defect. The background now has its own per-call getState() and a queued read-modify-write updateState(); the singleton is unreachable from it, and an unpopulated read throws instead of serving defaults.

The prohibition is enforced by the build, not by review: build.js asserts over esbuild's own metafile that no forbidden module is an input of a background bundle, so every specifier syntax esbuild resolves is covered, and both halves of the table are checked for rot -- a stale key, a stale module, an empty list, or an unlisted entry point under src/background/ all fail the build. The ESLint rule remains as fast local feedback and reads the same shared table. Known bounds are documented where the table lives.

Also closes #320: getProvider() now requires a validated network id, so a cold worker no longer prepares a non-mainnet dApp transaction for mainnet and gets refused by the wallet's own verifier. backgroundRefresh() no longer mutates address objects across a network round trip, the broadcast path takes its endpoint and chain id from one snapshot, and eight test storage stubs now structured-clone on get as the real chrome.storage.local does.

closes #320
2026-08-23 17:57:30 +02:00

575 lines
23 KiB
JavaScript

const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const { execSync } = require("child_process");
const esbuild = require("esbuild");
const { resolveVersion } = require("./script/lib/version");
const {
BACKGROUND_ENTRY_PREFIX,
FORBIDDEN_INPUTS,
assertTableWellFormed,
} = require("./script/lib/forbiddenBundleInputs");
const DIST = path.join(__dirname, "dist");
const DIST_CHROME = path.join(DIST, "chrome");
const DIST_FIREFOX = path.join(DIST, "firefox");
const SRC = path.join(__dirname, "src");
// The module whose compiled DEBUG state script/verify-build asserts. Which
// bundles contain it is derived from esbuild's own dependency graph rather
// than from a hardcoded list, so it tracks the bundle layout instead of
// rotting with it.
const AUDITED_MODULE = "src/shared/constants.js";
// FORBIDDEN_INPUTS — what each entry point's bundle may not contain, and what
// that covers — lives in script/lib/forbiddenBundleInputs.js, because the
// ESLint rule reads the same table and two literal copies of a path drift.
//
// This is the authoritative check, and it is here rather than in the linter
// because it consults the resolution esbuild actually performed. Any specifier
// syntax, any hop, any resolution rule that puts the module in the bundle fails
// the build, whether or not a text matcher would have recognized it. A
// background entry point the table does not name fails as well, so a second
// worker is protected by default rather than by someone remembering this file.
// Dockerfile:42 runs `make build`, so it is enforced in CI.
// The build receipt: every file this build emits, with its sha256 and whether
// it is one of the audited bundles. script/verify-build is handed this and
// checks dist/ against it, so the file list comes from the build that just ran
// rather than being read back out of the tree it is supposed to vouch for.
//
// The path is supplied by the caller, not chosen here, and the Makefile makes
// a fresh one per invocation outside the repo: that is what ties a receipt to
// one build rather than leaving a standing file anyone can write.
const RECEIPT_HEADER = "autistmask-build-receipt v1";
const RECEIPT_ENV = "AUTISTMASK_BUILD_RECEIPT";
// Every emitted path must be plainly nameable, because the receipt is a
// line-oriented text file consumed by a POSIX shell script and a path with a
// space or a newline in it could not be read back unambiguously. Nothing this
// build emits looks like that; if that ever changes, the build fails here
// rather than writing a receipt that cannot be checked.
const SAFE_EMITTED_PATH = /^dist\/[A-Za-z0-9._][A-Za-z0-9._/-]*$/;
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}
// Repo-relative, forward-slashed, so the manifest reads the same on every
// platform and can be consumed by a POSIX shell script without further work.
function repoRelative(p) {
return path.relative(__dirname, p).split(path.sep).join("/");
}
// Collect the outputs of one esbuild run that bundle AUDITED_MODULE. esbuild
// reports every input that contributed to an output in the metafile, which is
// the authoritative answer to "is constants.js in this bundle" — unlike
// searching the minified text, it does not depend on what survived minification.
//
// The ".js" filter below is the only place that assumption lives:
// script/verify-build reads every file the receipt names, whatever its
// extension, and fails on any that carries a debug marker without being
// recorded as an audited bundle — so a bundle emitted under some other
// extension fails there rather than escaping both checks at once.
function outputsContainingAuditedModule(metafile) {
return Object.entries(metafile.outputs)
.filter(([outFile, info]) => {
if (!outFile.endsWith(".js")) return false;
return Object.keys(info.inputs).some(
(input) => repoRelative(input) === AUDITED_MODULE,
);
})
.map(([outFile]) => repoRelative(outFile));
}
// Shortest import chain from `entryInput` to `target` through the metafile's
// own input graph, or null when there is none. The message this feeds is the
// point of the check: "state.js is in the worker bundle" is not actionable on
// its own, "index.js -> chainSwitchFields.js -> state.js" is.
function importChain(metafile, entryInput, target) {
const graph = new Map(
Object.entries(metafile.inputs).map(([input, info]) => [
repoRelative(input),
(info.imports || []).map((i) => repoRelative(i.path)),
]),
);
const start = repoRelative(entryInput);
const seen = new Set([start]);
const queue = [[start]];
while (queue.length > 0) {
const chain = queue.shift();
for (const next of graph.get(chain[chain.length - 1]) || []) {
if (next === target) return chain.concat([next]);
if (seen.has(next)) continue;
seen.add(next);
queue.push(chain.concat([next]));
}
}
return null;
}
// What the forbidden-input checks accumulate over a whole build: which
// FORBIDDEN_INPUTS keys were actually bundled, and every input of every output
// this build emitted. Both are read by assertForbiddenTableCovered() at the
// end — a table entry naming something that is not there any more enforces
// nothing, and must fail rather than pass quietly.
function newForbiddenRecord() {
return { entriesChecked: new Set(), bundledInputs: new Set() };
}
// Note every input of every output of one esbuild run. Deliberately not
// restricted to the entry points named in FORBIDDEN_INPUTS: it is the POPUP
// that legitimately bundles src/shared/state.js, and that is what makes
// "the forbidden module still exists at this path" checkable at all.
function recordBundledInputs(metafile, record) {
for (const info of Object.values(metafile.outputs)) {
for (const input of Object.keys(info.inputs)) {
record.bundledInputs.add(repoRelative(input));
}
}
}
// Fail the build when an entry point's bundle contains a module it is
// prohibited from reaching. The inputs come from esbuild's metafile, so this is
// the resolution the shipped bundle was built from and not a guess at it.
//
// A background entry point with no line in the table fails here too. The five
// defects this exists to prevent were accidents, and so is adding a second
// worker entry point without knowing that a table somewhere needs a line: the
// protection has to be the default for that directory rather than something
// the next author must opt into.
function assertNoForbiddenInputs(
entryPoint,
outfile,
metafile,
record,
table = FORBIDDEN_INPUTS,
) {
const entry = repoRelative(entryPoint);
const forbidden = table[entry];
if (!forbidden) {
if (!entry.startsWith(BACKGROUND_ENTRY_PREFIX)) return;
throw new Error(
`${entry} is a background entry point with no line in ` +
`FORBIDDEN_INPUTS, so nothing stops its bundle from ` +
`containing the shared state singleton. Add it to ` +
`script/lib/forbiddenBundleInputs.js. The MV3 worker never ` +
`populates that singleton, so reading it serves ` +
`DEFAULT_STATE; use getState()/updateState() from ` +
`src/background/state.js instead.`,
);
}
const out = repoRelative(outfile);
const entryOutput = Object.entries(metafile.outputs).find(
([outFile]) => repoRelative(outFile) === out,
);
if (!entryOutput) {
throw new Error(`esbuild reported no metafile output for ${out}`);
}
const inputs = new Set(
Object.keys(entryOutput[1].inputs).map(repoRelative),
);
// Recorded only once the bundle's inputs are actually in hand. Marking the
// entry checked any earlier — as this did — means an early return above
// satisfies assertForbiddenTableCovered() with a bundle nobody examined,
// and the coverage half cannot tell that from a real check. The lookup
// above is the fragile step: repoRelative() resolves against process.cwd()
// while esbuild's output keys are cwd-relative, so a change to where the
// build runs from could miss.
record.entriesChecked.add(entry);
for (const module of forbidden) {
if (!inputs.has(module)) continue;
const chain = importChain(metafile, entryPoint, module);
throw new Error(
`${out} bundles ${module}, which ${entry} must not reach` +
`${chain ? `: ${chain.join(" -> ")}` : ""}. The MV3 worker ` +
`never populates the shared state singleton, so reading it ` +
`serves DEFAULT_STATE. Use getState()/updateState() from ` +
`src/background/state.js instead.`,
);
}
}
// Fail the build when the table has rotted away from the tree it describes.
// Both halves of an entry rot independently, and either one turns the whole
// prohibition into a pass that checks nothing:
//
// - the KEY, when no bundled entry point matches it: the entry point was
// renamed or is no longer built, and no bundle was ever tested against the
// list;
// - the MODULE, when this build bundled it nowhere: the module was renamed,
// moved or deleted, so "is it an input of the background bundle" is asked
// about a path nothing resolves to and is answered no forever. The popup
// legitimately bundles src/shared/state.js, which is what makes this
// checkable — and it is stronger than an existsSync(), because it also
// fails when the file is still there but has dropped out of every bundle.
//
// This matters concretely: https://git.eeqj.de/sneak/AutistMask/issues/311
// rewrites this persistence layer, and a rename that quietly disarmed the
// guarantee would put the singleton back within reach of the worker with every
// check in the repo still green.
//
// The third way — an entry that lists no modules at all — is refused where the
// table is defined, at require time, because that one also empties the ESLint
// rule's forbidden set and so has to fail before either layer runs. It is
// re-checked here so the build's own half does not depend on the table having
// been loaded from that file.
function assertForbiddenTableCovered(record, table = FORBIDDEN_INPUTS) {
assertTableWellFormed(table);
for (const [entry, modules] of Object.entries(table)) {
if (!record.entriesChecked.has(entry)) {
throw new Error(
`${entry} is listed in FORBIDDEN_INPUTS but was not bundled, ` +
`so nothing checked it`,
);
}
for (const module of modules) {
if (record.bundledInputs.has(module)) continue;
throw new Error(
`${module} is listed in FORBIDDEN_INPUTS for ${entry}, but ` +
`this build bundled it nowhere, so the prohibition names ` +
`a module that is not in this tree at that path and ` +
`nothing enforces it. If the module moved, move it in ` +
`script/lib/forbiddenBundleInputs.js too, which both ` +
`this check and the ESLint rule read.`,
);
}
}
}
// Every file this build writes under dist/, recorded as it is written. This is
// the build's own account of what it emitted; it is never recovered by
// listing dist/, because a file that is in dist/ without this build having put
// it there is exactly what the receipt exists to expose.
const emittedFiles = [];
function recordEmitted(absPath) {
emittedFiles.push(absPath);
}
// Copying is the only other way a file reaches dist/; esbuild and the Tailwind
// CLI record their outputs where they are invoked.
function copyEmitted(src, dest) {
fs.copyFileSync(src, dest);
recordEmitted(dest);
}
function sha256File(absPath) {
return crypto
.createHash("sha256")
.update(fs.readFileSync(absPath))
.digest("hex");
}
// Write the receipt for the files this build emitted. Deliberately records no
// build mode: which mode was asked for is script/verify-build's argument, so
// build.js cannot vouch for build.js. All the receipt says is "these bytes,
// under these names, are what I wrote, and these ones bundle constants.js".
function writeReceipt(receiptPath, auditedBundles) {
const audited = new Set(auditedBundles);
const paths = [...new Set(emittedFiles.map(repoRelative))].sort();
for (const p of paths) {
if (!SAFE_EMITTED_PATH.test(p)) {
throw new Error(
`emitted path cannot be written to a build receipt: ${JSON.stringify(p)}`,
);
}
}
// A bundle esbuild reported but that nothing recorded as emitted means the
// two halves have drifted apart, and the receipt would then leave an
// audited bundle out. Fail rather than emit a short receipt.
for (const bundle of audited) {
if (!paths.includes(bundle)) {
throw new Error(
`${bundle} contains ${AUDITED_MODULE} but was not recorded as emitted`,
);
}
}
if (audited.size === 0) {
throw new Error(
`no emitted bundle contains ${AUDITED_MODULE}, which is never correct`,
);
}
const lines = [RECEIPT_HEADER, `root ${fs.realpathSync(__dirname)}`];
for (const p of paths) {
const flag = audited.has(p) ? "A" : "P";
lines.push(`file ${sha256File(path.join(__dirname, p))} ${flag} ${p}`);
}
fs.writeFileSync(receiptPath, lines.map((l) => `${l}\n`).join(""));
console.log(
`Build receipt: ${paths.length} emitted file(s), ${audited.size} ` +
`containing ${AUDITED_MODULE} (${receiptPath})`,
);
}
// Where the receipt goes, decided before anything is emitted so a build that
// cannot produce a checkable receipt fails before it writes any artifacts.
// Inside dist/ is refused: a receipt that lives in the tree it describes can
// be rewritten by whoever rewrites the tree, which is the hole this replaces.
function receiptTarget() {
const requested = process.env[RECEIPT_ENV];
if (!requested) {
return null;
}
const resolved = path.resolve(requested);
if (resolved === DIST || resolved.startsWith(DIST + path.sep)) {
throw new Error(
`${RECEIPT_ENV} points inside dist/ (${resolved}). The receipt ` +
`describes dist/ and must not live in it.`,
);
}
return resolved;
}
// DEBUG is a build-time flag, off unless explicitly requested. It is the only
// thing that makes the hardcoded test mnemonic reachable, so the opt-in must be
// exact: anything other than the literal "1" (unset, empty, "true", a typo)
// produces a release build. Failing towards the safe mode is deliberate.
function isDebugBuild() {
return process.env.AUTISTMASK_DEBUG === "1";
}
// A short git output, or null when git cannot answer. Distinguishing "git said
// nothing" from "git could not be asked" matters below: a working tree whose
// state is unknown must not be stamped as clean.
function git(args) {
try {
return execSync(`git ${args}`, {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
} catch {
// not a git repo, or git not available
return null;
}
}
// The working-tree state, as a suffix for the displayed commit: "" when the
// tree matches HEAD, "-dirty" when it does not, "-unknown" when git answered
// the hash but not the status. Without this a build from a modified tree
// stamped a clean hash, so the About screen named a commit whose contents were
// not what was running — the one thing that stamp exists to establish.
//
// git status --porcelain honours .gitignore, so dist/ and node_modules/ do not
// make every build dirty; an untracked file that is NOT ignored does, and
// correctly: it may well be in the bundle.
function worktreeSuffix() {
const status = git("status --porcelain");
if (status === null) return "-unknown";
return status === "" ? "" : "-dirty";
}
function getBuildInfo() {
const pkg = JSON.parse(
fs.readFileSync(path.join(__dirname, "package.json"), "utf8"),
);
const commitHashFull = git("rev-parse HEAD") || "unknown";
const shortHash = git("rev-parse --short HEAD") || "unknown";
// The full hash is left clean because it is the href of the commit link in
// the About screen, and "abc123-dirty" is not a commit anyone can fetch.
// The displayed short hash carries the marker, so the screen says the tree
// was modified while still linking somewhere real.
const commitHash =
shortHash === "unknown" ? shortHash : shortHash + worktreeSuffix();
return {
// Fails the build when package.json and the two manifests disagree;
// see script/lib/version.js. Called before anything is emitted, so a
// tree with no single version never reaches dist/.
version: resolveVersion(__dirname),
license: pkg.license,
author: pkg.author,
commitHash,
commitHashFull,
buildDate: new Date().toISOString().slice(0, 10),
};
}
async function build() {
console.log("Building AutistMask extension...");
const receiptPath = receiptTarget();
if (!receiptPath) {
console.warn(
`WARNING: ${RECEIPT_ENV} is unset, so this build writes no ` +
`receipt and script/verify-build cannot verify what it ` +
`emitted. Build through make build / make build-debug.`,
);
}
const buildInfo = getBuildInfo();
console.log("Build info:", buildInfo);
const debugBuild = isDebugBuild();
console.log(
debugBuild
? "Build mode: DEBUG (INSECURE - hardcoded test mnemonic, do not ship)"
: "Build mode: release (DEBUG off)",
);
const define = {
__BUILD_DEBUG__: JSON.stringify(debugBuild),
__BUILD_VERSION__: JSON.stringify(buildInfo.version),
__BUILD_LICENSE__: JSON.stringify(buildInfo.license),
__BUILD_AUTHOR__: JSON.stringify(buildInfo.author),
__BUILD_COMMIT__: JSON.stringify(buildInfo.commitHash),
__BUILD_COMMIT_FULL__: JSON.stringify(buildInfo.commitHashFull),
__BUILD_DATE__: JSON.stringify(buildInfo.buildDate),
};
// Emitted bundles that contain constants.js, accumulated across every
// esbuild run below and recorded in the receipt for script/verify-build.
const auditedBundles = [];
// What the forbidden-input checks accumulate across those same runs.
const forbiddenRecord = newForbiddenRecord();
// compile tailwind CSS
console.log("Compiling Tailwind CSS...");
const tailwindInput = path.join(SRC, "popup", "styles", "main.css");
const tailwindOutput = path.join(DIST, "styles.css");
// Start from an empty dist/, so what is there afterwards is what this
// build put there and nothing else. Leftovers from an earlier build are
// not covered by this build's receipt, and script/verify-build rejects
// any file it did not emit rather than ignoring it.
fs.rmSync(DIST, { recursive: true, force: true });
ensureDir(DIST);
// The locally installed binary, not `npx` — npx silently fetches from the
// registry when the binary is absent, which is an unpinned network fetch
// in the middle of a build.
const tailwindBin = path.join(
__dirname,
"node_modules",
".bin",
"tailwindcss",
);
execSync(
`"${tailwindBin}" -i "${tailwindInput}" -o "${tailwindOutput}" --minify`,
{ stdio: "inherit" },
);
recordEmitted(tailwindOutput);
// Every bundle goes through here, so metafile collection cannot be
// forgotten when a new entry point is added.
async function bundle(entryPoint, outfile) {
const result = await esbuild.build({
entryPoints: [entryPoint],
bundle: true,
format: "iife",
outfile,
platform: "browser",
target: ["chrome110", "firefox110"],
minify: true,
metafile: true,
define,
});
// Before the output is recorded as emitted: a bundle that violates a
// prohibition must abort the build, not be written into a receipt.
recordBundledInputs(result.metafile, forbiddenRecord);
assertNoForbiddenInputs(
entryPoint,
outfile,
result.metafile,
forbiddenRecord,
);
recordEmitted(outfile);
auditedBundles.push(...outputsContainingAuditedModule(result.metafile));
}
for (const distDir of [DIST_CHROME, DIST_FIREFOX]) {
ensureDir(path.join(distDir, "src", "popup"));
ensureDir(path.join(distDir, "src", "background"));
ensureDir(path.join(distDir, "src", "content"));
// bundle popup JS with esbuild (inlines ethers, libsodium, etc.)
await bundle(
path.join(SRC, "popup", "index.js"),
path.join(distDir, "src", "popup", "index.js"),
);
// bundle background script
await bundle(
path.join(SRC, "background", "index.js"),
path.join(distDir, "src", "background", "index.js"),
);
// bundle content script
await bundle(
path.join(SRC, "content", "index.js"),
path.join(distDir, "src", "content", "index.js"),
);
// bundle inpage script (injected into page context, separate file)
await bundle(
path.join(SRC, "content", "inpage.js"),
path.join(distDir, "src", "content", "inpage.js"),
);
// copy popup HTML
copyEmitted(
path.join(SRC, "popup", "index.html"),
path.join(distDir, "src", "popup", "index.html"),
);
// place compiled CSS next to popup HTML
copyEmitted(
tailwindOutput,
path.join(distDir, "src", "popup", "styles.css"),
);
}
// copy manifests
copyEmitted(
path.join(__dirname, "manifest", "chrome.json"),
path.join(DIST_CHROME, "manifest.json"),
);
copyEmitted(
path.join(__dirname, "manifest", "firefox.json"),
path.join(DIST_FIREFOX, "manifest.json"),
);
assertForbiddenTableCovered(forbiddenRecord);
// Written last so a build that died partway through leaves no receipt at
// all, which script/verify-build treats as a hard failure rather than as
// "nothing to check".
if (receiptPath) {
writeReceipt(receiptPath, auditedBundles);
}
console.log("Build complete: dist/chrome/ and dist/firefox/");
}
// Run only as a program. Required as a module — which is how
// tests/buildForbiddenInputs.test.js reaches the checks below — this file
// builds nothing and writes nothing.
if (require.main === module) {
build().catch((err) => {
console.error(
`Build failed: ${err && err.message ? err.message : err}`,
);
process.exit(1);
});
}
// Exported for tests/buildForbiddenInputs.test.js only. The prohibition these
// three functions enforce is the guarantee behind
// https://git.eeqj.de/sneak/AutistMask/issues/324, and `make check` does not
// run `make build` — so they are unit tested against synthetic metafiles
// rather than being exercised only by CI, where "it ran" is not "it works".
module.exports = {
importChain,
newForbiddenRecord,
recordBundledInputs,
assertNoForbiddenInputs,
assertForbiddenTableCovered,
};