fix: floor malformed allowedSites, fraudContracts and selectedToken entries (closes #362)
All checks were successful
check / check (push) Successful in 42s
e2e / e2e-chrome (push) Successful in 1m45s
e2e / e2e-firefox (push) Successful in 31s

A stored allowedSites whose value was not a list rendered a working popup and then made every subsequent save fail silently, so the user operated a wallet that persisted nothing -- worse than a blank popup, which is at least visibly broken. fraudContracts and selectedToken had the same shape: a container floored by truthiness or not at all, while its entries were dereferenced. Entries are now floored as well as containers, following the idiom #311 established, and a failed save raises a persistent banner instead of vanishing into a swallowed rejection.

The per-field justifications that used to live in a hand-written header are replaced by a contract test that drives each field's hostile and falsy values through a real popup boot, so a claim about a field answers to the code rather than to prose. Its guarantee is stated narrowly and deliberately: no structural dereference on the code paths a wholly-corrupted profile takes, which is not every path a stored record takes. The paths it does not drive are named where the claim is made, and are tracked in #379.
This commit was merged in pull request #366.
This commit is contained in:
2026-08-23 23:06:17 +02:00
parent 45500e66cf
commit a098bb0c32
14 changed files with 2100 additions and 354 deletions

37
TODO.md
View File

@@ -57,6 +57,43 @@ but the review is broader than any of them.
reached a manifest but not the archive fails packaging. The artwork is
original: a flat dark-navy rounded field with a teal triangular "A", drawn
from geometry and rasterised into PNG, nothing traced or downloaded.
- 2026-08-23: A persisted container whose ENTRIES were dereferenced unchecked no
longer reaches a `.map()` or a `.toLowerCase()`
([#362](https://git.eeqj.de/sneak/AutistMask/issues/362)). `allowedSites` was
the worst shape available: a stored `{"0x…": "notalist"}` passed the gate,
rendered a completely healthy popup, and then threw inside `saveState()`'s
per-hostname merge, so every save from that moment on failed silently and the
user went on operating a wallet that was persisting nothing — measured as
`chrome.storage.local.set` never being called at all. `deniedSites` has the
same shape, `fraudContracts` the same class with a milder consequence, and the
sweep for the class turned up `selectedToken`, `rpcUrl` (handed whole to
`new JsonRpcProvider()`, which throws synchronously outside any `try`), the
entries of `viewData` (four restore branches gate on one truthy field and then
dereference an address), and `selectedWallet`/`selectedAddress` (a stored
`"map"` is TRUTHY against a real Array, so the restore guard does not
short-circuit). All of them are now floored in `src/shared/persistedState.js`
or refused by the per-branch guards in `src/popup/viewRouter.js`. Separately,
a save that fails is no longer swallowed: `onSaveFailure()` in
`src/shared/state.js` reports every failed save, awaited or not, and the popup
raises a persistent "NOT SAVED" banner. The hand-written per-field
justification in the header of `src/shared/stateSchema.js` — which had shipped
a false claim in three consecutive changes — is replaced by
`tests/persistedFieldContract.test.js`, one row per persisted field, each
proven by driving the real code with hostile values — and, for a field whose
only defence is that nothing dereferences it, by booting the real popup entry
point over that value onto every view the popup can reopen onto, since that is
the path this whole class of defect lives on. Each such field is driven at
both polarities — a value nothing writes is wrong-typed and so truthy, so a
falsy slot is driven too, or the field is proven unable to be falsy after the
floor. The claim is narrow and stated as such: no structural dereference on
the code paths a wholly-corrupted profile takes, which is not every path a
stored record takes — a pairing of values the four slots do not produce, a
view only forward navigation opens, anything behind a click, and everything a
healthy profile reaches are all undriven. Within that boundary the verdict is
unconditional, including a dereference that takes two corrupted fields at
once, since the assertion is on the combined boot and the per-field re-boot
can only decorate the message. A field with no row and a field that gains a
floor while its row still claims it has none also fail `make check`.
- 2026-08-23: A swap amount and the token it is counted in now always come from
the same hop, on both sides of the approval screen
([#359](https://git.eeqj.de/sneak/AutistMask/issues/359) and