fix: floor the entries of the site maps and the fraud list, and stop a failed save from failing silently (closes #362)
All checks were successful
check / check (push) Successful in 32s
e2e / e2e-chrome (push) Successful in 1m45s
e2e / e2e-firefox (push) Successful in 33s

allowedSites was checked as a container while its entries were dereferenced unchecked. A stored {"0x...": "notalist"} passed the state gate and rendered a completely healthy popup, then threw "base.map is not a function" inside saveState()'s per-hostname merge, so every save from that moment on failed and the user went on operating a wallet that was persisting nothing. Measured against the previous head: the popup showed the main view with no page errors, and chrome.storage.local.set was never called at all. deniedSites has the identical shape; fraudContracts the same class with a milder consequence, throwing "(state.fraudContracts || []).map is not a function" on the send screen; and the sweep for the class turned up selectedToken, which is truthiness-gated on restore and then dereferenced as text, blanking the popup outright with "tokenId.toLowerCase is not a function".

All four now get the floor issue 311 settled -- the container AND its entries, with a malformed entry dropped -- through textList() and siteMap() beside the existing tokenRefs() in persistedState.js, rather than a third mechanism. Site-map keys are written with defineProperty for the same reason networkEndpoints' keys are: a stored own "__proto__" key would otherwise be handed to the prototype setter. The background's allowed.includes(hostname) gate is covered by the same floor, where a stored string would have answered a substring match rather than merely throwing.

A save that fails is no longer swallowed. onSaveFailure() in state.js reports every failed save, awaited or not -- the save queue has to attach a rejection handler to keep advancing, which is what made a failure disappear entirely -- and the popup raises a persistent "NOT SAVED" banner naming the reason. The popup's background refresh loop no longer turns a save failure into an unhandled rejection instead of a report. Both halves are needed: the floor only covers the causes it knows about, and storage can still fail for a quota or a revoked permission.

The field-by-field categorisation in the header of stateSchema.js, and its mirror in README.md, were re-verified against the code and moved with the change; the fields left on a loose floor now carry the reason each one is still safe. The popup boot harness moved to tests/support/popupBoot.js so the new tests drive the real entry point rather than duplicating it.
This commit is contained in:
2026-08-23 18:22:01 +00:00
parent ad6aa7b20d
commit 44b0a153f0
11 changed files with 954 additions and 304 deletions

View File

@@ -1016,17 +1016,33 @@ generic `-32603` every request used to answer.
Every other field of the record is floored in `normalizePersisted()` rather than
gated. That floor is a type check for the fields something dereferences
structurally — `trackedTokens`, each address's `tokenBalances`, `networkId`,
`networkEndpoints`, `activeAddress`, `viewStack` — and it checks the ENTRIES as
well as the container, because `[1, 2]` is a list and `t.address` is one level
below an `Array.isArray()`. The remaining fields get a `saved.x || default` or a
present-or-default passthrough that takes the stored value verbatim, with no
type check at all; which field is in which category is listed in the header of
structurally — `trackedTokens`, each address's `tokenBalances`, `allowedSites`,
`deniedSites`, `fraudContracts`, `viewStack`, `networkEndpoints`, plus the
scalars `networkId`, `activeAddress` and `selectedToken` — and it checks the
ENTRIES as well as the container, because `[1, 2]` is a list,
`{"0x…": "notalist"}` is an object, and the dereference is one level below the
container check. A malformed entry is dropped. The remaining fields get a
`saved.x || default` or a present-or-default passthrough that takes the stored
value verbatim, with no type check at all; which field is in which category, and
why the loose floor is still enough for each of them, is listed in the header of
`src/shared/stateSchema.js`. A truthy value of the wrong type in a field that IS
dereferenced walks through truthiness and throws on the first read, which is the
blank popup again by a longer route — so adding a field means choosing between
the two by what reads it.
The `allowedSites` case is why the entry check is not optional. A stored
`{"0x…": "notalist"}` is a well-formed object holding a malformed entry: it
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 and
the user went on operating a wallet that was persisting nothing
([#362](https://git.eeqj.de/sneak/AutistMask/issues/362)). A save that fails is
now also reported rather than swallowed: `onSaveFailure()` in
`src/shared/state.js` is called for every failed save, awaited or not, and the
popup puts up a persistent "NOT SAVED" banner (`showSaveFailureBanner()` in
`src/popup/views/helpers.js`). Storage can still fail for reasons no floor
covers — a quota, a revoked permission, a record a newer build wrote — and the
wallet must never look healthy while that is true.
The `networkId` check is not cosmetic: that value is an object KEY into
`state.networkEndpoints`, so an unvalidated `"__proto__"` would set the map's
prototype instead of an own key and the user's endpoint would silently not be