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
367 lines
14 KiB
JavaScript
367 lines
14 KiB
JavaScript
// build.js's FORBIDDEN_INPUTS assertion — the mechanical guarantee that the
|
|
// MV3 background bundle cannot contain src/shared/state.js
|
|
// (https://git.eeqj.de/sneak/AutistMask/issues/324).
|
|
//
|
|
// Why this file exists: `make check` does not run `make build`. CI executes
|
|
// the assertion (Dockerfile runs `make build`), but executing is not testing —
|
|
// invert its condition, or make the table lookup always come back undefined,
|
|
// and every check in this repo stays green while the singleton walks back into
|
|
// the worker. Five defects, one destroyed wallet, and the whole argument for
|
|
// the scoped loud-read guard rest on this assertion, so it is pinned here.
|
|
//
|
|
// The subject is build.js's exported helpers plus the table's own
|
|
// well-formedness check, driven against SYNTHETIC metafiles in esbuild's
|
|
// shape. Nothing here shells out to a build or writes dist/: the assertion's
|
|
// job is to read a metafile correctly, and a metafile is data. That the real
|
|
// shapes reach it is the build's own business and is measured in the PR that
|
|
// introduced it.
|
|
//
|
|
// Every vacuous pass this guarantee has been found to have is pinned below,
|
|
// because each one was a way for `make check`, `make lint` and `make build` to
|
|
// be green over a background bundle containing the singleton: a stale key, a
|
|
// stale module, an entry that lists no modules, an entry recorded as checked
|
|
// before its bundle was in hand, and a background entry point nobody added to
|
|
// the table.
|
|
//
|
|
// Paths are absolute on the way in, because the helpers normalize whatever
|
|
// esbuild gave them to repo-relative and this file should not depend on the
|
|
// working directory jest was started from.
|
|
|
|
const path = require("path");
|
|
|
|
const {
|
|
importChain,
|
|
newForbiddenRecord,
|
|
recordBundledInputs,
|
|
assertNoForbiddenInputs,
|
|
assertForbiddenTableCovered,
|
|
} = require("../build");
|
|
const {
|
|
BACKGROUND_ENTRY_PREFIX,
|
|
FORBIDDEN_INPUTS,
|
|
assertTableWellFormed,
|
|
} = require("../script/lib/forbiddenBundleInputs");
|
|
|
|
const ROOT = path.resolve(__dirname, "..");
|
|
const abs = (p) => path.join(ROOT, p);
|
|
|
|
const ENTRY = "src/background/index.js";
|
|
const OUT = "dist/chrome/src/background/index.js";
|
|
const STATE = "src/shared/state.js";
|
|
const HOP = "src/shared/chainSwitchFields.js";
|
|
const SECOND = "src/background/worker2.js";
|
|
const SECOND_OUT = "dist/chrome/src/background/worker2.js";
|
|
const POPUP = "src/popup/index.js";
|
|
const POPUP_OUT = "dist/chrome/src/popup/index.js";
|
|
|
|
// The real table's shape: entry point -> modules its bundle may not contain.
|
|
const TABLE = { [ENTRY]: [STATE] };
|
|
|
|
// A metafile as esbuild emits one: `outputs[out].inputs` is the flat list of
|
|
// every input that contributed to that output, and `inputs[file].imports` is
|
|
// the edge list, which is what the chain walk follows.
|
|
function metafile({ outputs = {}, imports = {} } = {}) {
|
|
return {
|
|
outputs: Object.fromEntries(
|
|
Object.entries(outputs).map(([out, inputs]) => [
|
|
abs(out),
|
|
{
|
|
inputs: Object.fromEntries(
|
|
inputs.map((input) => [
|
|
abs(input),
|
|
{ bytesInOutput: 1 },
|
|
]),
|
|
),
|
|
},
|
|
]),
|
|
),
|
|
inputs: Object.fromEntries(
|
|
Object.entries(imports).map(([file, targets]) => [
|
|
abs(file),
|
|
{ imports: targets.map((target) => ({ path: abs(target) })) },
|
|
]),
|
|
),
|
|
};
|
|
}
|
|
|
|
function check(mf, table = TABLE, record = newForbiddenRecord()) {
|
|
recordBundledInputs(mf, record);
|
|
assertNoForbiddenInputs(abs(ENTRY), abs(OUT), mf, record, table);
|
|
return record;
|
|
}
|
|
|
|
describe("assertNoForbiddenInputs()", () => {
|
|
test("a forbidden module in the bundle fails, naming the import chain", () => {
|
|
const mf = metafile({
|
|
outputs: { [OUT]: [ENTRY, HOP, STATE] },
|
|
imports: {
|
|
[ENTRY]: [HOP],
|
|
[HOP]: [STATE],
|
|
},
|
|
});
|
|
|
|
expect(() => check(mf)).toThrow(
|
|
`${OUT} bundles ${STATE}, which ${ENTRY} must not reach: ` +
|
|
`${ENTRY} -> ${HOP} -> ${STATE}.`,
|
|
);
|
|
});
|
|
|
|
test("a bundle without the forbidden module passes, and is recorded as checked", () => {
|
|
const mf = metafile({
|
|
outputs: { [OUT]: [ENTRY, HOP, "src/background/state.js"] },
|
|
imports: { [ENTRY]: [HOP, "src/background/state.js"] },
|
|
});
|
|
|
|
const record = check(mf);
|
|
expect([...record.entriesChecked]).toEqual([ENTRY]);
|
|
expect(record.bundledInputs.has(STATE)).toBe(false);
|
|
});
|
|
|
|
test("the failure still names the bundle when no import chain can be shown", () => {
|
|
// esbuild resolves `import("../shared/" + variable)` as a glob: the
|
|
// module is an input of the output, but no single edge leads to it.
|
|
// The message must degrade to no chain rather than crash.
|
|
const mf = metafile({
|
|
outputs: { [OUT]: [ENTRY, STATE] },
|
|
imports: { [ENTRY]: [] },
|
|
});
|
|
|
|
expect(() => check(mf)).toThrow(
|
|
`${OUT} bundles ${STATE}, which ${ENTRY} must not reach.`,
|
|
);
|
|
});
|
|
|
|
// The lookup this covers is the fragile step in the whole assertion:
|
|
// repoRelative() resolves against process.cwd() and esbuild's output keys
|
|
// are cwd-relative, so a change to where the build runs from, or to
|
|
// outfile vs outdir, makes it miss. It must be loud, and the entry must
|
|
// NOT already be marked checked when it does — otherwise a later edit
|
|
// turning this throw into an early return leaves both halves of the
|
|
// guarantee satisfied by a bundle nothing looked at.
|
|
test("an output esbuild did not report fails, and records nothing as checked", () => {
|
|
const mf = metafile({
|
|
outputs: { "dist/chrome/src/background/renamed.js": [ENTRY] },
|
|
imports: { [ENTRY]: [] },
|
|
});
|
|
const record = newForbiddenRecord();
|
|
recordBundledInputs(mf, record);
|
|
|
|
expect(() =>
|
|
assertNoForbiddenInputs(abs(ENTRY), abs(OUT), mf, record, TABLE),
|
|
).toThrow(`esbuild reported no metafile output for ${OUT}`);
|
|
expect([...record.entriesChecked]).toEqual([]);
|
|
|
|
// So even if that throw became `return`, the coverage half catches it.
|
|
record.bundledInputs.add(STATE);
|
|
expect(() => assertForbiddenTableCovered(record, TABLE)).toThrow(
|
|
`${ENTRY} is listed in FORBIDDEN_INPUTS but was not bundled`,
|
|
);
|
|
});
|
|
});
|
|
|
|
// Finding from the fifth review of this change: adding a second worker entry
|
|
// point is exactly the accident this guarantee exists for, and the person
|
|
// adding one has no reason to know a table elsewhere needs a line. So the
|
|
// default for the background directory is protected, not unprotected.
|
|
describe("background entry points are protected by default", () => {
|
|
test("a bundled background entry point with no line in the table fails", () => {
|
|
const mf = metafile({
|
|
outputs: { [SECOND_OUT]: [SECOND, STATE] },
|
|
imports: { [SECOND]: [STATE] },
|
|
});
|
|
const record = newForbiddenRecord();
|
|
recordBundledInputs(mf, record);
|
|
|
|
expect(() =>
|
|
assertNoForbiddenInputs(
|
|
abs(SECOND),
|
|
abs(SECOND_OUT),
|
|
mf,
|
|
record,
|
|
TABLE,
|
|
),
|
|
).toThrow(
|
|
`${SECOND} is a background entry point with no line in ` +
|
|
`FORBIDDEN_INPUTS`,
|
|
);
|
|
});
|
|
|
|
test("an entry point outside the background directory needs no line", () => {
|
|
// The popup legitimately bundles the singleton; that is its model.
|
|
const mf = metafile({
|
|
outputs: { [POPUP_OUT]: [POPUP, STATE] },
|
|
imports: { [POPUP]: [STATE] },
|
|
});
|
|
const record = newForbiddenRecord();
|
|
recordBundledInputs(mf, record);
|
|
|
|
expect(() =>
|
|
assertNoForbiddenInputs(
|
|
abs(POPUP),
|
|
abs(POPUP_OUT),
|
|
mf,
|
|
record,
|
|
TABLE,
|
|
),
|
|
).not.toThrow();
|
|
expect([...record.entriesChecked]).toEqual([]);
|
|
});
|
|
|
|
test("the prefix is the one the lint rule is scoped to", () => {
|
|
expect(BACKGROUND_ENTRY_PREFIX).toBe("src/background/");
|
|
expect(SECOND.startsWith(BACKGROUND_ENTRY_PREFIX)).toBe(true);
|
|
expect(POPUP.startsWith(BACKGROUND_ENTRY_PREFIX)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("recordBundledInputs()", () => {
|
|
// The sole data source for the module half of the anti-rot check, and
|
|
// every other case here hand-seeds what it produces. Driven for real,
|
|
// across two outputs, and then handed straight to the check that reads it.
|
|
test("records every input of every output, satisfying the module half", () => {
|
|
const mf = metafile({
|
|
outputs: {
|
|
[OUT]: [ENTRY, HOP],
|
|
[POPUP_OUT]: [POPUP, STATE],
|
|
},
|
|
imports: { [ENTRY]: [HOP], [POPUP]: [STATE] },
|
|
});
|
|
const record = newForbiddenRecord();
|
|
recordBundledInputs(mf, record);
|
|
|
|
expect([...record.bundledInputs].sort()).toEqual(
|
|
[ENTRY, HOP, POPUP, STATE].sort(),
|
|
);
|
|
|
|
// Nothing hand-seeded: the covered check passes on what the recorder
|
|
// actually collected, so a recorder that collects nothing fails here.
|
|
assertNoForbiddenInputs(abs(ENTRY), abs(OUT), mf, record, TABLE);
|
|
expect(() => assertForbiddenTableCovered(record, TABLE)).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("importChain()", () => {
|
|
test("terminates on a cyclic input graph, and still finds the module", () => {
|
|
const mf = metafile({
|
|
imports: {
|
|
[ENTRY]: [HOP],
|
|
[HOP]: ["src/shared/log.js"],
|
|
// The cycle: log <-> hop, with the target one hop past it.
|
|
"src/shared/log.js": [HOP, STATE],
|
|
},
|
|
});
|
|
|
|
expect(importChain(mf, abs(ENTRY), STATE)).toEqual([
|
|
ENTRY,
|
|
HOP,
|
|
"src/shared/log.js",
|
|
STATE,
|
|
]);
|
|
});
|
|
|
|
test("terminates and returns null when a cycle cannot reach the module", () => {
|
|
const mf = metafile({
|
|
imports: {
|
|
[ENTRY]: [HOP],
|
|
[HOP]: ["src/shared/log.js"],
|
|
"src/shared/log.js": [HOP, ENTRY],
|
|
},
|
|
});
|
|
|
|
expect(importChain(mf, abs(ENTRY), STATE)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("assertForbiddenTableCovered()", () => {
|
|
test("the shipped table is satisfied by a build that checked it", () => {
|
|
const record = newForbiddenRecord();
|
|
record.entriesChecked.add(ENTRY);
|
|
// The popup bundle is what legitimately contains the singleton.
|
|
record.bundledInputs.add(STATE);
|
|
|
|
expect(() => assertForbiddenTableCovered(record, TABLE)).not.toThrow();
|
|
});
|
|
|
|
// The build this drives bundles the popup and no background entry point,
|
|
// because a stale key AND a bundled background entry point is now the
|
|
// stronger failure above — the entry point that is there but unlisted is
|
|
// reported by name, before the end of the build. This case is the rot that
|
|
// is left after that one: a key naming something this build never bundled.
|
|
test("a key no bundled entry point matched fails", () => {
|
|
const mf = metafile({
|
|
outputs: { [POPUP_OUT]: [POPUP] },
|
|
imports: { [POPUP]: [] },
|
|
});
|
|
const stale = { "src/background/renamed.js": [STATE] };
|
|
const record = newForbiddenRecord();
|
|
recordBundledInputs(mf, record);
|
|
assertNoForbiddenInputs(abs(POPUP), abs(POPUP_OUT), mf, record, stale);
|
|
record.bundledInputs.add(STATE);
|
|
|
|
expect(() => assertForbiddenTableCovered(record, stale)).toThrow(
|
|
"src/background/renamed.js is listed in FORBIDDEN_INPUTS but was" +
|
|
" not bundled, so nothing checked it",
|
|
);
|
|
});
|
|
|
|
test("a forbidden module this build bundled nowhere fails", () => {
|
|
// The other half of the same rot: renaming or moving the singleton
|
|
// leaves a table that names a path nothing resolves to any more, and
|
|
// every bundle then passes it vacuously.
|
|
const mf = metafile({
|
|
outputs: { [OUT]: [ENTRY] },
|
|
imports: { [ENTRY]: [] },
|
|
});
|
|
const stale = { [ENTRY]: ["src/shared/stateRenamed.js"] };
|
|
const record = check(mf, stale);
|
|
record.bundledInputs.add(STATE);
|
|
|
|
expect(() => assertForbiddenTableCovered(record, stale)).toThrow(
|
|
"src/shared/stateRenamed.js is listed in FORBIDDEN_INPUTS for" +
|
|
` ${ENTRY}, but this build bundled it nowhere`,
|
|
);
|
|
});
|
|
|
|
// The third rot, and the worst of the three: an entry with an empty list
|
|
// is checked, is bundled, names no module that could be missing, and
|
|
// prohibits nothing — in BOTH layers at once, since the rule's forbidden
|
|
// set is Object.values(table).flat().
|
|
test("an entry that lists no modules fails", () => {
|
|
const mf = metafile({
|
|
outputs: { [OUT]: [ENTRY, STATE] },
|
|
imports: { [ENTRY]: [STATE] },
|
|
});
|
|
const empty = { [ENTRY]: [] };
|
|
const record = newForbiddenRecord();
|
|
recordBundledInputs(mf, record);
|
|
assertNoForbiddenInputs(abs(ENTRY), abs(OUT), mf, record, empty);
|
|
|
|
expect(() => assertForbiddenTableCovered(record, empty)).toThrow(
|
|
`FORBIDDEN_INPUTS["${ENTRY}"] lists no modules`,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("assertTableWellFormed()", () => {
|
|
// Runs at require time on the shipped table, in script/lib/
|
|
// forbiddenBundleInputs.js, so an empty list fails the lint run as well as
|
|
// the build — the build's own re-check cannot help a layer that never
|
|
// reaches the build.
|
|
test("the shipped table is well formed", () => {
|
|
expect(() => assertTableWellFormed(FORBIDDEN_INPUTS)).not.toThrow();
|
|
expect(Object.entries(FORBIDDEN_INPUTS).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("an entry that lists no modules fails, naming the entry", () => {
|
|
expect(() => assertTableWellFormed({ [ENTRY]: [] })).toThrow(
|
|
`FORBIDDEN_INPUTS["${ENTRY}"] lists no modules`,
|
|
);
|
|
});
|
|
|
|
test("a table with no entries at all fails", () => {
|
|
expect(() => assertTableWellFormed({})).toThrow(
|
|
"FORBIDDEN_INPUTS is empty",
|
|
);
|
|
});
|
|
});
|