Files
AutistMask/src/background/state.js
sneak 18ad93be45
All checks were successful
check / check (push) Successful in 54s
e2e / e2e-chrome (push) Successful in 2m0s
e2e / e2e-firefox (push) Successful in 54s
harden: make the background physically unable to read the shared state singleton (closes #324)
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. The table of
modules an entry point's bundle may not contain lives in
script/lib/forbiddenBundleInputs.js — one copy, read by both layers that act on
it — and build.js's 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.

Both halves of the table are checked for rot, because either one turns the
prohibition into a pass that checks nothing: a key no bundled entry point
matched fails, and so does a listed module this build bundled nowhere. The
second is what makes a rename of src/shared/state.js loud instead of silently
disarming the check, and it is stronger than an existsSync() because it also
fails when the module is still there but has dropped out of every bundle. What
the assertion does NOT cover is a COPY of the singleton at another path: it is
keyed by path, so a copy builds and lints clean. That is stated where the table
lives, with the reason it is accepted — a copy carries the singleton's own
guard, so a background read of it throws rather than being served DEFAULT_STATE.

make check does not run make build, so the assertion is unit tested against
synthetic metafiles in tests/buildForbiddenInputs.test.js: build.js runs its
build() only as a program now and exports the checks. Executing a check in CI
is not testing it — without that file, inverting the condition leaves every
check in this repo green with the singleton back in the worker.

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 reads
the same table, and 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. Two shapes it does not report are
recorded in its header as known divergences the build catches, both measured: a
computed specifier (require("../shared/" + "state"), which esbuild
constant-folds into the bundle) and a symlink to the module (esbuild reports the
real path). Each is make lint exit 0 and make build exit 2.

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
2026-08-23 15:04:59 +00:00

77 lines
3.7 KiB
JavaScript

// The background's access to the persisted profile.
//
// There is no in-memory copy here, and that is the whole design. The MV3
// service worker is terminated when idle and revived by the next message, so
// anything held at module scope is either absent or arbitrarily stale, and
// src/shared/state.js's module-level `state` singleton — which nothing in the
// worker ever populates — silently served DEFAULT_STATE to whoever read it.
// Five defects came out of that (https://git.eeqj.de/sneak/AutistMask/issues/324),
// and every point fix for one of them added a loadState() that created the
// next: loading detaches the objects an in-flight handler is holding.
//
// So the background reads per call and writes read-modify-write:
//
// getState() one storage read, normalized, detached. Nothing else
// holds the object it returns, so a handler may keep it
// across any number of awaits and no concurrent work can
// move it.
// updateState(fn) read fresh, apply fn to that fresh record, write it
// back — all inside a queue, so two background writes
// never interleave, and the read is one storage round trip
// ahead of the write rather than a page lifetime ahead of
// it (which is what made the popup's saveState() need a
// per-field merge against a baseline at all).
//
// A handler that must both read and write therefore does its network work
// against a snapshot it owns, and applies the RESULT inside updateState().
// It never publishes an object other in-flight work is holding.
const { storageGet, storageSet } = require("../shared/browserApi");
const { normalizePersisted } = require("../shared/persistedState");
// A fresh, fully-normalized, detached copy of the persisted profile.
//
// Normalized rather than raw: a legacy or malformed record is self-healed the
// same way loadState() heals it for the popup, so the background is never the
// one context reasoning about a shape the rest of the extension repairs.
async function getState() {
const result = await storageGet("autistmask");
return normalizePersisted(result.autistmask);
}
// Serializes the read-modify-write turns below. Two of them interleaved would
// each read before the other wrote, and the second write would carry the first
// one's fields back to their pre-turn values.
let updateQueue = Promise.resolve();
async function updateStateOnce(mutate) {
const s = await getState();
await mutate(s);
s.hasWallet = Boolean(s.wallets && s.wallets.length > 0);
await storageSet({ autistmask: s });
return s;
}
// Apply `mutate` to a record read fresh from storage and write the result
// back. `mutate` receives a detached, normalized profile and mutates it in
// place; it may be async, but it must not do anything slow — the window
// between the read and the write is the window in which another context's
// write is lost, and keeping it to one storage round trip is what makes a
// whole-record write safe here.
//
// `mutate` must also not call updateState() itself, directly or through
// anything it awaits: the queue is strictly serial, so the inner turn waits on
// the outer one, which is waiting on the inner one. That deadlocks the whole
// background, not just the caller. Mutate the record you were handed.
//
// Resolves with the record that was written.
function updateState(mutate) {
const turn = updateQueue.then(() => updateStateOnce(mutate));
// The queue must advance even when a turn rejects, or every update after
// it queues behind a promise that never settles.
updateQueue = turn.catch(() => {});
return turn;
}
module.exports = { getState, updateState };