script/verify-build computed its expectation from AUTISTMASK_DEBUG in its own environment, and the Makefile invoked it bare, so an operator with that flag exported who ran the release target got a debug bundle -- every wallet it creates carrying the publicly committed test recovery phrase -- verified green at exit 0. The mode is now the required argument --expect release|debug, with no default and nothing read from the environment; make build passes --expect release on an env -u AUTISTMASK_DEBUG environment and make build-debug passes --expect debug. The flag is deliberately still allowed to reach the compiler, so a shell that has it exported fails make build loudly rather than quietly receiving something other than the release build it asked for. The other half was provenance. The check was a marker grep over a file list read back out of dist/, so a 26-byte file containing only autistmask-build-debug=off verified ok, manifest.json and the content script that runs on every page were never read at all, and an entire hand-written dist/ passed as "1 bundle(s) verified". build.js now records every file it emits and writes a receipt of them -- path, sha256, and whether the file is one of the bundles containing constants.js -- to a path the Makefile creates with mktemp per invocation, outside the repo, and deletes afterwards; a receipt path inside dist/ is refused. dist/ is cleared before a build, so it holds only what that build wrote. dist/constants-bundles.txt is gone, and with it the standalone make verify-build target: re-verifying a dist/ out of the dist/ itself is the thing that was broken. verify-build now checks the receipt's shape, then that dist/ contains nothing the build did not emit and no symlinks, then each recorded file's bytes against its digest and each audited bundle's marker against --expect. The guarantee is narrow and README.md states it as such: dist/ is byte for byte the output of the build.js run that just finished. It proves nothing about the honesty of the source tree or of build.js, and offers nothing to a third party holding a dist/. That is signing: #310 script/test-verify-build goes from 18 cases to 39, extended in place: one per demonstrated bypass, the missing/invalid argument cases, an AUTISTMASK_DEBUG=1 environment that the verifier must ignore, debug bundles that must fail --expect release, and four checks that read the make build and make build-debug recipes back out of make -n. The existing failure modes (grep exit-2, find's status, newline and trailing-space paths, symlinked dist/, and the root probe that refuses to count permission cases vacuously) are kept. Verified: make check green (39 suites / 811 tests, 39 verify-build cases, permission cases enabled), and green again inside the pinned image via script/cibuild with --no-cache-filter=check, where the harness runs as root and reports the setpriv runner rather than skipping. Non-vacuity proved by mutation: disabling the digest comparison fails exactly the four bypass cases, removing the dist/ walk fails the eight extra-file and symlink cases, restoring the ambient AUTISTMASK_DEBUG fallback fails the no---expect case, breaking the Makefile recipe fails the wiring cases, and dropping manifest.json from the recorded emissions fails a real make build.
340 lines
12 KiB
JavaScript
340 lines
12 KiB
JavaScript
const fs = require("fs");
|
|
const path = require("path");
|
|
const crypto = require("crypto");
|
|
const { execSync } = require("child_process");
|
|
const esbuild = require("esbuild");
|
|
|
|
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";
|
|
|
|
// 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));
|
|
}
|
|
|
|
// 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";
|
|
}
|
|
|
|
function getBuildInfo() {
|
|
const pkg = JSON.parse(
|
|
fs.readFileSync(path.join(__dirname, "package.json"), "utf8"),
|
|
);
|
|
let commitHash = "unknown";
|
|
try {
|
|
commitHash = execSync("git rev-parse --short HEAD", {
|
|
encoding: "utf8",
|
|
}).trim();
|
|
} catch {
|
|
// not a git repo or git not available
|
|
}
|
|
let commitHashFull = "unknown";
|
|
try {
|
|
commitHashFull = execSync("git rev-parse HEAD", {
|
|
encoding: "utf8",
|
|
}).trim();
|
|
} catch {
|
|
// not a git repo or git not available
|
|
}
|
|
return {
|
|
version: pkg.version,
|
|
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,
|
|
});
|
|
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"),
|
|
);
|
|
|
|
// 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);
|
|
});
|