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
124 lines
6.4 KiB
JavaScript
124 lines
6.4 KiB
JavaScript
// The modules a given entry point's bundle may not contain, keyed by the
|
|
// repo-relative entry point.
|
|
//
|
|
// ONE table, read by both layers that act on it: build.js asserts it against
|
|
// esbuild's own metafile (the guarantee), and
|
|
// script/lib/eslint/noStateSingletonInBackground.js reports the same
|
|
// prohibition in the editor (fast feedback). It lives here because a second
|
|
// literal copy of the path is exactly how a rename disarms one layer while the
|
|
// other still looks enforced.
|
|
//
|
|
// src/shared/state.js holds a module-level `state` object, loaded once by
|
|
// loadState() and mutated in place from then on. That is the popup's model:
|
|
// one page, one load at boot, one lifetime. The MV3 service worker has no
|
|
// "once" — it is killed when idle and revived by the next message, nothing
|
|
// loads state at module scope, and an unpopulated read was answered out of
|
|
// DEFAULT_STATE in silence. 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.
|
|
//
|
|
// What build.js's assertion covers, measured rather than assumed:
|
|
//
|
|
// - Any import of a listed module, at any hop, in any specifier syntax,
|
|
// however esbuild resolved it. The check reads the input list esbuild
|
|
// reported for the emitted bundle, so it is the resolution the shipped
|
|
// file was built from and not a model of it. Measured on a computed
|
|
// specifier that esbuild constant-folds (`require("../shared/" +
|
|
// "state")`), on a computed specifier it resolves as a glob
|
|
// (`import("../shared/" + variable)`), and on a symlink to the module
|
|
// (esbuild reports the real path): each is `make build` exit 2.
|
|
//
|
|
// - Every background entry point, whether or not anyone remembered to list
|
|
// it. A bundled entry point under BACKGROUND_ENTRY_PREFIX with no line in
|
|
// this table fails the build (assertNoForbiddenInputs()), so adding a
|
|
// second worker entry point is protected by default rather than protected
|
|
// only if the person adding it knew about this file. Measured: bundling
|
|
// src/background/worker2.js with no line here is `make build` exit 2.
|
|
//
|
|
// - NOT covered: a COPY of a listed module at another path. The table is
|
|
// keyed by path, so `cp src/shared/state.js src/shared/stateCopy.js` plus
|
|
// a background require of the copy is `make build` exit 0 and `make lint`
|
|
// exit 0 (measured). The copy carries the singleton's own guard, so
|
|
// defects 1-3 of https://git.eeqj.de/sneak/AutistMask/issues/324 — a read
|
|
// of a field nothing loaded — become a loud StateNotLoadedError instead of
|
|
// a silent DEFAULT_STATE. Defects 4 and 5 do NOT: a copy also carries
|
|
// loadState(), and a stale read several awaits after a load, or a load
|
|
// detaching the objects an in-flight handler is mutating, are silent over
|
|
// a LOADED singleton whether it is the original or a copy. So the residual
|
|
// is wider than "it fails loudly". A newly WRITTEN singleton has no
|
|
// backstop at all.
|
|
//
|
|
// - NOT covered: a background-behaving entry point outside
|
|
// BACKGROUND_ENTRY_PREFIX. The default protection above is keyed on that
|
|
// directory, which is also what eslint.config.js scopes the rule to, so a
|
|
// worker entry point placed somewhere else is covered by neither layer and
|
|
// needs its own line here.
|
|
//
|
|
// The ESLint rule's bounds are its own and are narrower: it matches specifiers
|
|
// textually, so a computed specifier and a symlink to a listed module are
|
|
// reported by the build and not by the rule. Both are pinned as non-reports in
|
|
// tests/backgroundStateLintRule.test.js and are `make build` exit 2 (measured).
|
|
// A second background entry point reached by one of those two shapes is
|
|
// therefore caught by the build and not by the rule — which is the same
|
|
// division of labour as everywhere else here, not an extra hole.
|
|
//
|
|
// Every way the table itself can rot is a failure rather than a quiet pass:
|
|
//
|
|
// - a KEY no bundled entry point matched, and a listed MODULE this build
|
|
// bundled nowhere: assertForbiddenTableCovered(), at the end of a build;
|
|
// - an entry that lists NO modules, and a table with no entries at all:
|
|
// assertTableWellFormed() below, at require time — so it fails the build
|
|
// and the lint run alike, because the rule reads the same values and an
|
|
// empty list leaves it with nothing to look for.
|
|
//
|
|
// All of it is pinned by tests/buildForbiddenInputs.test.js.
|
|
|
|
// What counts as a background entry point, and therefore must be listed above.
|
|
// The build has no other notion of one: entry points are the paths handed to
|
|
// bundle(), and this prefix is the narrowest rule that names the worker's
|
|
// directory. eslint.config.js scopes the lint rule with the same prefix, from
|
|
// this constant, so the two layers cannot disagree about what "background"
|
|
// means.
|
|
const BACKGROUND_ENTRY_PREFIX = "src/background/";
|
|
|
|
const FORBIDDEN_INPUTS = {
|
|
"src/background/index.js": ["src/shared/state.js"],
|
|
};
|
|
|
|
// Refuse a table that cannot prohibit anything. An entry whose module list is
|
|
// empty passes every check in both layers while enforcing nothing: the build
|
|
// finds no module to look for and records the entry as checked, and the rule's
|
|
// forbidden set — Object.values(...).flat() — comes back empty, so a plain
|
|
// `require("../shared/state")` in the worker is green everywhere. That is a
|
|
// one-character edit, so it fails here, where the table is defined and both
|
|
// layers must load it, rather than in either layer's own checks.
|
|
function assertTableWellFormed(table) {
|
|
const entries = Object.entries(table);
|
|
if (entries.length === 0) {
|
|
throw new Error(
|
|
"FORBIDDEN_INPUTS is empty, so nothing is prohibited anywhere. " +
|
|
"Removing the last entry disables the guarantee behind " +
|
|
"https://git.eeqj.de/sneak/AutistMask/issues/324.",
|
|
);
|
|
}
|
|
for (const [entry, modules] of entries) {
|
|
if (!Array.isArray(modules) || modules.length === 0) {
|
|
throw new Error(
|
|
`FORBIDDEN_INPUTS["${entry}"] lists no modules, so it ` +
|
|
`prohibits nothing while still looking enforced. Give it ` +
|
|
`the modules that entry point may not reach, or remove ` +
|
|
`the entry.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
assertTableWellFormed(FORBIDDEN_INPUTS);
|
|
|
|
module.exports = {
|
|
BACKGROUND_ENTRY_PREFIX,
|
|
FORBIDDEN_INPUTS,
|
|
assertTableWellFormed,
|
|
};
|