Five defects traced to one fact: src/background/index.js read and wrote the
module-level `state` singleton in src/shared/state.js, which the MV3 service
worker never populates and which answered an unpopulated read out of
DEFAULT_STATE in silence. Every previous fix added a loadState() before the
access, and that is what produced the fifth: a load detaches the objects an
in-flight handler is holding.
So the reachability goes rather than a sixth call site.
The background now has its own storage layer, src/background/state.js:
getState() is a detached, normalized per-call read, and updateState() is a
queued read-modify-write whose read is one storage round trip ahead of its
write. Nothing in the background holds an in-memory copy of the profile.
- Every handler takes one snapshot and answers from it, including the address
it names: activeAddressOf(s) replaced a second, later storage read that
could disagree with the first.
- wallet_switchEthereumChain applies applyChainSwitchFields() (split out of
chainSwitch.js, which keeps the singleton path for the popup) inside
updateState() instead of calling onChainSwitch() on the singleton.
- The remembered site decision is a read-modify-write, not a load-mutate-save
around a prompt the user takes seconds to answer.
- backgroundRefresh() refreshes a private copy of the wallets and applies the
balances that came back by address, so it never publishes an object other
in-flight work holds, and a wallet added or deleted during the round trip
survives its write.
- The transaction attempt takes its chain id and its endpoint from the same
snapshot. They used to come from different moments, so a chain switch
committed in between moved the endpoint under an artifact already verified
against the old chain.
getProvider(rpcUrl, networkId) now REQUIRES the network id and validates it
against networks.js. That closes the cold-worker wrong-chain send at its shape
rather than at one call site: the hint used to default to currentNetwork() off
the unpopulated singleton, so the endpoint was the user's chain and ethers
fixed chainId at 0x1, and the wallet's own verifySignedTx then refused every
non-mainnet dApp send. refreshBalances(), lookupTokenInfo(), scanForAddresses()
and resolveEnsName() carry the id through; balances.js no longer requires
state.js at all.
The prohibition is enforced mechanically, not by review, and it is enforced by
the bundler rather than by a guess at what the bundler does. build.js keeps a
FORBIDDEN_INPUTS table of modules an entry point's bundle may not contain, and
assertNoForbiddenInputs() fails the build when esbuild's metafile reports
src/shared/state.js as an input of a background bundle, naming the import chain
from the metafile's own graph. That is the resolution the shipped bundle was
built from, so no specifier syntax, no hop and no resolution rule can slip past
it; Dockerfile:42 runs make build, so it holds in CI. A FORBIDDEN_INPUTS key
that matches no bundled entry point also fails, so the table cannot rot into a
vacuous pass.
A custom ESLint rule walks the CommonJS require graph from every src/background/
file and reports the same thing in the editor, before a full bundle. It matches
specifiers textually, so it is best-effort fast feedback and not the guarantee —
two earlier revisions of it shipped holes (a template literal, a dynamic
import(), a comment inside the call, a directory resolved through package.json
main). Those are covered now and pinned by
tests/backgroundStateLintRule.test.js, and the next divergence between a
hand-rolled matcher and a real bundler is caught by the build instead. A
computed specifier (require("../shared/" + "state")) is deliberately not
matched: esbuild cannot resolve it either, so it never reaches the bundle.
Reading a persisted field of the singleton before any load now throws
StateNotLoadedError instead of serving DEFAULT_STATE.
Test stubs: chrome.storage.local is a serialization boundary, and eight files
stubbed it with an aliasing get, so the object a module held and the object
"storage" held were one object — an assertion could pass on a build that never
wrote anything. Every test that drives real persistence now goes through
tests/support/storageStub.js, which structured-clones in both directions.
closes #320
236 lines
9.7 KiB
JavaScript
236 lines
9.7 KiB
JavaScript
// The lint rule that keeps src/shared/state.js out of the background bundle
|
|
// (script/lib/eslint/noStateSingletonInBackground.js).
|
|
//
|
|
// Five defects, one of which destroyed a wallet, came from background code
|
|
// reaching that singleton, and each point fix created the next site
|
|
// (https://git.eeqj.de/sneak/AutistMask/issues/324).
|
|
//
|
|
// What this file does NOT do is establish that the singleton cannot reach the
|
|
// background bundle. That is build.js's FORBIDDEN_INPUTS assertion, which reads
|
|
// esbuild's metafile and so cannot be evaded by a syntax a matcher does not
|
|
// know. The rule under test here is fast local feedback in front of that, and
|
|
// these cases pin the shapes it is known to catch, so a regression in the
|
|
// matcher is a failing test rather than a quietly narrower rule.
|
|
//
|
|
// Every shape below was measured against a real `make build`: each one puts
|
|
// state.js in the shipped background bundles, and each one was invisible to
|
|
// some earlier revision of the matcher — the quoted-only regex missed the
|
|
// backtick, the dynamic import and the `from` clause; its successor missed a
|
|
// comment inside the call and a directory resolved through package.json `main`.
|
|
//
|
|
// NOT covered, deliberately: a computed specifier such as
|
|
// `require("../shared/" + "state")`. esbuild cannot resolve that statically
|
|
// either, so it never reaches the bundle — there is nothing to block.
|
|
|
|
const fs = require("fs");
|
|
const os = require("os");
|
|
const path = require("path");
|
|
const { Linter } = require("eslint");
|
|
|
|
const plugin = require("../script/lib/eslint/noStateSingletonInBackground");
|
|
|
|
const RULE = "background/no-state-singleton-in-background";
|
|
|
|
// The three files a fixture tree always has. `src/background/index.js` is
|
|
// supplied per case; the other two stand in for the real modules.
|
|
const SHARED_STATE = "const state = {};\nmodule.exports = { state };\n";
|
|
const SHARED_HOP =
|
|
"// A shared module the background legitimately imports.\n" +
|
|
"module.exports = { applyChainSwitchFields() {} };\n";
|
|
|
|
let roots = [];
|
|
|
|
function fixture(files) {
|
|
const root = fs.realpathSync(
|
|
fs.mkdtempSync(path.join(os.tmpdir(), "autistmask-state-rule-")),
|
|
);
|
|
roots.push(root);
|
|
const tree = {
|
|
"src/shared/state.js": SHARED_STATE,
|
|
"src/shared/chainSwitchFields.js": SHARED_HOP,
|
|
...files,
|
|
};
|
|
for (const [rel, source] of Object.entries(tree)) {
|
|
const abs = path.join(root, rel);
|
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
fs.writeFileSync(abs, source);
|
|
}
|
|
return root;
|
|
}
|
|
|
|
// Run the rule exactly as eslint.config.js runs it, over a real tree: the walk
|
|
// reads its sources from disk, so a virtual RuleTester would not exercise it.
|
|
// `sourceType` is the fixture's own, not the rule's business: the walk is
|
|
// textual and never parses the files it follows. The two ESM cases below pass
|
|
// "module" only so espree can parse the fixture at all — in this repo those
|
|
// shapes are also a parse error under the commonjs config, but the rule must
|
|
// not be left depending on that.
|
|
function lintBackground(root, { sourceType = "commonjs" } = {}) {
|
|
const file = path.join(root, "src/background/index.js");
|
|
const linter = new Linter({ cwd: root });
|
|
return linter.verify(
|
|
fs.readFileSync(file, "utf8"),
|
|
{
|
|
plugins: { background: plugin },
|
|
languageOptions: { ecmaVersion: 2024, sourceType },
|
|
rules: { [RULE]: "error" },
|
|
},
|
|
file,
|
|
);
|
|
}
|
|
|
|
function chainOf(messages) {
|
|
expect(messages).toHaveLength(1);
|
|
expect(messages[0].ruleId).toBe(RULE);
|
|
// "...singleton: <chain>. The MV3 worker..." — the chain is what the
|
|
// message exists to hand the reader, so assert on it rather than on the
|
|
// fact that something was reported.
|
|
return messages[0].message.split("singleton: ")[1].split(". The MV3")[0];
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const root of roots) fs.rmSync(root, { recursive: true, force: true });
|
|
roots = [];
|
|
});
|
|
|
|
describe("the specifier syntaxes the matcher is known to catch", () => {
|
|
test("a quoted require", () => {
|
|
const root = fixture({
|
|
"src/background/index.js":
|
|
'const { state } = require("../shared/state");\n' +
|
|
"module.exports = { state };\n",
|
|
});
|
|
expect(chainOf(lintBackground(root))).toBe(
|
|
"src/background/index.js -> src/shared/state.js",
|
|
);
|
|
});
|
|
|
|
test("a backtick require", () => {
|
|
const root = fixture({
|
|
"src/background/index.js":
|
|
"const { state } = require(`../shared/state`);\n" +
|
|
"module.exports = { state };\n",
|
|
});
|
|
expect(chainOf(lintBackground(root))).toBe(
|
|
"src/background/index.js -> src/shared/state.js",
|
|
);
|
|
});
|
|
|
|
test("a dynamic import inside an async function", () => {
|
|
const root = fixture({
|
|
"src/background/index.js":
|
|
"async function readState() {\n" +
|
|
' const m = await import("../shared/state");\n' +
|
|
" return m.state;\n" +
|
|
"}\n" +
|
|
"module.exports = { readState };\n",
|
|
});
|
|
expect(chainOf(lintBackground(root))).toBe(
|
|
"src/background/index.js -> src/shared/state.js",
|
|
);
|
|
});
|
|
|
|
test("a static import from-clause", () => {
|
|
const root = fixture({
|
|
"src/background/index.js":
|
|
'import { state } from "../shared/state";\n' +
|
|
"export { state };\n",
|
|
});
|
|
expect(chainOf(lintBackground(root, { sourceType: "module" }))).toBe(
|
|
"src/background/index.js -> src/shared/state.js",
|
|
);
|
|
});
|
|
|
|
// `import(/* webpackChunkName: "x" */ "./x")` is the standard bundler
|
|
// annotation idiom, and prettier leaves both of these exactly as written,
|
|
// so nothing else in the repo would object to them either.
|
|
test("a comment between the paren and the specifier", () => {
|
|
const root = fixture({
|
|
"src/background/index.js":
|
|
'globalThis.__probe = require(/* probe */ "../shared/state").state;\n',
|
|
});
|
|
expect(chainOf(lintBackground(root))).toBe(
|
|
"src/background/index.js -> src/shared/state.js",
|
|
);
|
|
});
|
|
|
|
test("a comment between the specifier and the closing paren", () => {
|
|
const root = fixture({
|
|
"src/background/index.js":
|
|
'globalThis.__probe = require("../shared/state" /* probe */).state;\n',
|
|
});
|
|
expect(chainOf(lintBackground(root))).toBe(
|
|
"src/background/index.js -> src/shared/state.js",
|
|
);
|
|
});
|
|
|
|
test("a bare side-effect import", () => {
|
|
const root = fixture({
|
|
"src/background/index.js": 'import "../shared/state";\n',
|
|
});
|
|
expect(chainOf(lintBackground(root, { sourceType: "module" }))).toBe(
|
|
"src/background/index.js -> src/shared/state.js",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("reachability, not just the direct specifier", () => {
|
|
// The shape a no-restricted-imports could never see: no background file
|
|
// names state.js, and the singleton is in the bundle anyway. In a backtick
|
|
// require, so this fails on the specifier widening as well as on the walk.
|
|
test("a two-hop re-export through a shared module", () => {
|
|
const root = fixture({
|
|
"src/background/index.js":
|
|
'const { applyChainSwitchFields } = require("../shared/chainSwitchFields");\n' +
|
|
"module.exports = { applyChainSwitchFields };\n",
|
|
"src/shared/chainSwitchFields.js":
|
|
SHARED_HOP +
|
|
"module.exports.state = require(`./state`).state;\n",
|
|
});
|
|
expect(chainOf(lintBackground(root))).toBe(
|
|
"src/background/index.js -> src/shared/chainSwitchFields.js" +
|
|
" -> src/shared/state.js",
|
|
);
|
|
});
|
|
|
|
// Resolution, not syntax: the specifier names a directory, and the file it
|
|
// resolves to is chosen by that directory's package.json `main`. A walk
|
|
// that only tries `<dir>/index.js` stops on a specifier it matched.
|
|
test("a directory resolved through its package.json main", () => {
|
|
const root = fixture({
|
|
"src/background/index.js":
|
|
'globalThis.__probe = require("../shared/probepkg").state;\n',
|
|
"src/shared/probepkg/package.json": '{"main": "./bridge.js"}\n',
|
|
"src/shared/probepkg/bridge.js":
|
|
'const { state } = require("../state");\n' +
|
|
"module.exports = { state };\n",
|
|
});
|
|
expect(chainOf(lintBackground(root))).toBe(
|
|
"src/background/index.js -> src/shared/probepkg/bridge.js" +
|
|
" -> src/shared/state.js",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("what the rule must not report", () => {
|
|
test("a background file that reaches only its own state layer", () => {
|
|
const root = fixture({
|
|
"src/background/index.js":
|
|
'const { getState } = require("./state");\n' +
|
|
'const { applyChainSwitchFields } = require("../shared/chainSwitchFields");\n' +
|
|
"module.exports = { getState, applyChainSwitchFields };\n",
|
|
"src/background/state.js":
|
|
"async function getState() {}\nmodule.exports = { getState };\n",
|
|
});
|
|
expect(lintBackground(root)).toEqual([]);
|
|
});
|
|
|
|
// The tree as it actually stands. This is the assertion that would catch a
|
|
// widened matcher that resolves something it should not: it runs the rule
|
|
// over the real background entrypoint, from the real repo root.
|
|
test("the repository's own background entrypoint", () => {
|
|
const root = path.resolve(__dirname, "..");
|
|
expect(lintBackground(root)).toEqual([]);
|
|
});
|
|
});
|