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 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"; // Modules a given entry point's bundle may not contain, keyed by the // repo-relative entry point. // // src/shared/state.js is a module-level object loaded once by loadState() and // mutated in place from then on. That is the popup's model. The MV3 service // worker has no "once" — it is killed when idle and revived by the next // message — so a background read of it is answered out of DEFAULT_STATE. Five // defects came from that, one of which destroyed a wallet // (https://git.eeqj.de/sneak/AutistMask/issues/324); the background has its own // per-call storage layer in src/background/state.js instead. // // 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. // Dockerfile:42 runs `make build`, so it is enforced in CI. const FORBIDDEN_INPUTS = { "src/background/index.js": ["src/shared/state.js"], }; // 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; } // Entry points from FORBIDDEN_INPUTS that this build actually bundled. A key // that matches nothing means the table has rotted away from the entry point // list — the prohibition would then be silently unenforced, so the build fails // on it rather than passing vacuously. const forbiddenEntriesSeen = new Set(); // 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. function assertNoForbiddenInputs(entryPoint, outfile, metafile) { const entry = repoRelative(entryPoint); const forbidden = FORBIDDEN_INPUTS[entry]; if (!forbidden) return; forbiddenEntriesSeen.add(entry); 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), ); 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.`, ); } } // 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 = []; // 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. assertNoForbiddenInputs(entryPoint, outfile, result.metafile); 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"), ); for (const entry of Object.keys(FORBIDDEN_INPUTS)) { if (!forbiddenEntriesSeen.has(entry)) { throw new Error( `${entry} is listed in FORBIDDEN_INPUTS but was not bundled, ` + `so nothing checked it`, ); } } // 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/"); } build().catch((err) => { console.error(`Build failed: ${err && err.message ? err.message : err}`); process.exit(1); });