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
207 lines
8.5 KiB
JavaScript
207 lines
8.5 KiB
JavaScript
// ESLint rule: the background bundle may not reach the shared state singleton.
|
|
//
|
|
// 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. In
|
|
// the MV3 service worker there is no "once": the worker is terminated when
|
|
// idle and revived by the next message, nothing loads state at module scope,
|
|
// and an unpopulated read used to be served DEFAULT_STATE without complaint —
|
|
// five defects, one cause
|
|
// (https://git.eeqj.de/sneak/AutistMask/issues/324). The background has its
|
|
// own per-call storage layer in src/background/state.js instead.
|
|
//
|
|
// THIS RULE IS NOT THE GUARANTEE, and must not be described as one. The
|
|
// guarantee is in build.js: FORBIDDEN_INPUTS / assertNoForbiddenInputs() fails
|
|
// the build when esbuild's own metafile reports src/shared/state.js as an input
|
|
// of a background bundle. That consults the resolution esbuild actually
|
|
// performed, so no specifier syntax and no resolution rule can slip past it,
|
|
// and Dockerfile:42 runs `make build` in CI.
|
|
//
|
|
// What this rule is: fast local feedback, in the editor and in `make lint`,
|
|
// before a full bundle. It reads sources from disk and matches import
|
|
// specifiers TEXTUALLY, so it is a best-effort approximation of module
|
|
// resolution — a hand-rolled matcher will diverge from a real bundler, and two
|
|
// earlier revisions of this file proved it by shipping holes (a template
|
|
// literal, a dynamic `import()`, a comment inside the call, a directory
|
|
// resolved through `package.json` `main`). Those are all covered now, and the
|
|
// next divergence is caught by the build rather than by widening this again.
|
|
//
|
|
// It checks REACHABILITY, not just the direct require: the singleton is one
|
|
// `require()` away from any shared module the background pulls in, and a
|
|
// re-export would put it back in the bundle without any background file naming
|
|
// it. So each background file is the root of a walk over the CommonJS require
|
|
// graph, and the error names the whole chain that brought the singleton in.
|
|
//
|
|
// Matching textually over-approximates — a specifier inside a comment or a
|
|
// string counts — which is the safe direction here: the failure mode is a
|
|
// spurious error naming an exact file and line, not a silent hole.
|
|
//
|
|
// Two shapes this rule does NOT report, both of which the build does fail on
|
|
// (each measured with `make lint` and `make build` on the branch that added
|
|
// this note):
|
|
//
|
|
// - a computed specifier, `require("../shared/" + "state")` — esbuild
|
|
// constant-folds it, so it is in the bundle and `make build` is exit 2
|
|
// naming src/shared/state.js, while `make lint` is exit 0. Same for
|
|
// `import("../shared/" + variable)`, which esbuild resolves as a glob.
|
|
// - a symlink to the module — esbuild reports the real path and fails the
|
|
// build; this rule resolves the link's own path and sees a different file.
|
|
//
|
|
// Both are pinned as non-reports in tests/backgroundStateLintRule.test.js, so
|
|
// this list is a measured description of the rule rather than a claim about
|
|
// it. They are known divergences, not things that cannot happen. A
|
|
// matcher will keep diverging from a bundler; that is why the guarantee is the
|
|
// build's and this rule is not widened again to chase them.
|
|
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
|
|
const { FORBIDDEN_INPUTS } = require("../forbiddenBundleInputs");
|
|
|
|
// The modules to keep out, repo-relative, taken from the same table build.js
|
|
// asserts against so that the two layers cannot name different paths. A second
|
|
// literal copy here is how a rename disarms one of them while the other still
|
|
// looks enforced.
|
|
const FORBIDDEN = [...new Set(Object.values(FORBIDDEN_INPUTS).flat())];
|
|
|
|
// Whatever may sit between a keyword, a paren and a specifier: whitespace and
|
|
// comments. `import(/* webpackChunkName: "x" */ "./x")` is a standard bundler
|
|
// idiom, and an inline `/* eslint-… */` is just as ordinary, so a matcher that
|
|
// allows only \s there is not strict, it is broken. Each alternative starts
|
|
// with a distinct character, so this cannot backtrack quadratically.
|
|
const GAP = "(?:\\s|/\\*[^]*?\\*/|//[^\\n]*)";
|
|
const SPECIFIER = "[\"'`]([^\"'`]+)[\"'`]";
|
|
|
|
// Both alternatives capture the specifier: call form first
|
|
// (`require(...)`/`import(...)`), then clause form (`from "x"`, and the bare
|
|
// side-effect `import "x"`). Nothing after the specifier is matched, so a
|
|
// trailing comment or a trailing comma cannot break the match either.
|
|
const SPECIFIER_RE = new RegExp(
|
|
`\\b(?:require|import)${GAP}*\\(${GAP}*${SPECIFIER}` +
|
|
`|\\b(?:from|import)${GAP}+${SPECIFIER}`,
|
|
"g",
|
|
);
|
|
|
|
// The `main` of a directory's package.json, as a specifier relative to that
|
|
// directory, or null. esbuild resolves a directory through it, so a walk that
|
|
// stops at `<dir>/index.js` reports a specifier it matched perfectly well as
|
|
// unresolvable.
|
|
function packageMain(dir) {
|
|
try {
|
|
const pkg = JSON.parse(
|
|
fs.readFileSync(path.join(dir, "package.json"), "utf8"),
|
|
);
|
|
return typeof pkg.main === "string" && pkg.main ? pkg.main : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Resolve a relative require to a file path, trying what node and esbuild would
|
|
// in the order they would: the path itself, then extensions, then the directory
|
|
// (its package.json `main`, then its index.js).
|
|
function resolveRelative(fromFile, spec) {
|
|
if (!spec.startsWith(".")) return null; // a package, not our tree
|
|
const base = path.resolve(path.dirname(fromFile), spec);
|
|
const main = packageMain(base);
|
|
for (const candidate of [
|
|
base,
|
|
base + ".js",
|
|
base + ".json",
|
|
...(main
|
|
? [path.resolve(base, main), path.resolve(base, main) + ".js"]
|
|
: []),
|
|
path.join(base, "index.js"),
|
|
]) {
|
|
try {
|
|
if (fs.statSync(candidate).isFile()) return candidate;
|
|
} catch {
|
|
// Not this candidate.
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function requiresOf(file) {
|
|
let source;
|
|
try {
|
|
source = fs.readFileSync(file, "utf8");
|
|
} catch {
|
|
return [];
|
|
}
|
|
const out = [];
|
|
for (const match of source.matchAll(SPECIFIER_RE)) {
|
|
const resolved = resolveRelative(file, match[1] ?? match[2]);
|
|
if (resolved) out.push(resolved);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Breadth-first from `entry`, returning the shortest chain of files that ends
|
|
// at one of the forbidden modules, or null when none is reachable.
|
|
function chainToForbidden(entry, forbidden) {
|
|
const seen = new Set([entry]);
|
|
const queue = [[entry]];
|
|
while (queue.length > 0) {
|
|
const chain = queue.shift();
|
|
for (const next of requiresOf(chain[chain.length - 1])) {
|
|
if (forbidden.has(next)) return chain.concat([next]);
|
|
if (seen.has(next)) continue;
|
|
seen.add(next);
|
|
queue.push(chain.concat([next]));
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const rule = {
|
|
meta: {
|
|
type: "problem",
|
|
docs: {
|
|
description:
|
|
"the background bundle must not be able to reach the" +
|
|
" module-level state singleton in src/shared/state.js",
|
|
},
|
|
schema: [],
|
|
messages: {
|
|
reachable:
|
|
"The background must not reach the shared state singleton:" +
|
|
" {{chain}}. The MV3 worker never populates it, so reading it" +
|
|
" serves DEFAULT_STATE. Use getState()/updateState() from" +
|
|
" src/background/state.js instead.",
|
|
},
|
|
},
|
|
|
|
create(context) {
|
|
return {
|
|
"Program:exit"(node) {
|
|
const filename = context.filename;
|
|
// ESLint lints from the repo root, which is also where the
|
|
// forbidden paths are anchored.
|
|
const forbidden = new Set(
|
|
FORBIDDEN.map((module) =>
|
|
path.resolve(context.cwd, module),
|
|
),
|
|
);
|
|
const chain = chainToForbidden(
|
|
path.resolve(filename),
|
|
forbidden,
|
|
);
|
|
if (!chain) return;
|
|
context.report({
|
|
node,
|
|
messageId: "reachable",
|
|
data: {
|
|
chain: chain
|
|
.map((file) => path.relative(context.cwd, file))
|
|
.join(" -> "),
|
|
},
|
|
});
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
module.exports = {
|
|
rules: { "no-state-singleton-in-background": rule },
|
|
};
|