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
This commit was merged in pull request #344.
This commit is contained in:
366
tests/buildForbiddenInputs.test.js
Normal file
366
tests/buildForbiddenInputs.test.js
Normal file
@@ -0,0 +1,366 @@
|
||||
// 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user