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.
366 lines
14 KiB
JavaScript
366 lines
14 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 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";
|
|
}
|
|
|
|
// 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,
|
|
});
|
|
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);
|
|
});
|