Compare commits

..

1 Commits

Author SHA1 Message Date
ee9bf03403 fix: floor the persisted fields a restore dereferences, and make each field's floor an executable claim (closes #362)
All checks were successful
check / check (push) Successful in 43s
e2e / e2e-chrome (push) Successful in 1m47s
e2e / e2e-firefox (push) Successful in 38s
A persisted container was checked while its ENTRIES were dereferenced
unchecked. A stored `{"0x…": "notalist"}` in allowedSites passes the state
gate, renders a working popup, and then throws inside saveState()'s per-
hostname merge, so every save from that moment on fails while the UI looks
entirely healthy. deniedSites has the identical shape; fraudContracts is the
same class with a milder consequence.

The sweep for that class found four more:

- selectedToken, dereferenced as text behind a truthiness-only restore gate.
- rpcUrl, handed whole to `new JsonRpcProvider()` by getProvider(), which
  throws SYNCHRONOUSLY for a non-string — from txStatus.js and addWallet.js,
  neither inside a try, and the first reachable from a stored
  `currentView: "wait-tx"` through the unguarded restoreView().
- The ENTRIES of viewData. Four restore branches gate on one truthy field and
  hand the rest to a renderer that calls address.toLowerCase(): a stored
  `{"currentView":"success-tx","viewData":{"hash":"0x1"}}` throws out of
  restoreView(), skipping the rest of popup init.
- selectedWallet / selectedAddress. `wallets` is a real Array, so a stored
  "map", "length", "constructor" or "__proto__" is TRUTHY: hasValidAddress()'s
  `&&` does not short-circuit and `.addresses[…]` throws. A stale INTEGER index
  is the safe case.

Floors, in src/shared/persistedState.js: allowedSites/deniedSites through
siteMap(), fraudContracts and each hostname list through textList(),
selectedToken and activeAddress as text-or-null, rpcUrl and blockscoutUrl as
non-empty text, selectedWallet and selectedAddress as a non-negative integer
or null, and each networkEndpoints pair's two URL fields — which
applyChainSwitchFields() assigns straight onto s.rpcUrl on the next switch.

Guards, in src/popup/viewRouter.js: the four restore branches that gate on one
truthy field now check the entries their renderer dereferences, as
txStatus.restoreWait() has always done for wait-tx. "confirm-tx" joins
ADDRESS_VIEWS, because its Sign button dereferences
state.wallets[state.selectedWallet] behind no guard of its own.

A stored own "__proto__" key is dropped by siteMap(): it can never be a wallet
address, so it grants nothing, and keeping it only keeps a value the next save
would hand to the prototype setter. networkEndpoints keeps unknown keys by
design, so mergeMapByKey() in src/shared/state.js now writes with
defineProperty as well — the guard in the floor was being undone one layer
downstream.

A save that fails is also told, not merely repaired: onSaveFailure() reports
every failed save, awaited or not (the save queue's own rejection handler is
what made a failure vanish), and the popup raises a persistent "NOT SAVED"
banner naming the reason. doRefreshAndRender() no longer rejects, since every
one of its call sites fires it and walks away.

The per-field justification in the header of src/shared/stateSchema.js is
replaced by tests/persistedFieldContract.test.js. That comment shipped a false
claim in three consecutive changes; the artifact was the problem. The test is
one row per persisted field, declaring the property that field's floor is
claimed to have and PROVING it by driving the real code with hostile values —
the gate for a field the gate refuses, normalizePersisted() for a field it
floors, the real JsonRpcProvider constructor for rpcUrl, and — for every field
whose only defence is that nothing dereferences it structurally — a boot of the
real popup entry point over that value onto EVERY view the popup can reopen
onto.

That last part is what makes the claim falsifiable, and it is why this defect
class is worth a harness at all: it lives on the RESTORE path and not on Home.
So the suite goes red whenever one of those boots reaches a structural
dereference on the view it restored onto — including one that takes TWO
corrupted fields at once, because the verdict is the combined boot itself and
the per-field re-boot that names a culprit can only decorate the message.
Every swept field is driven at both polarities, or proven unable to be falsy
after the floor: a value nothing in src/ writes is a wrong-typed one and
therefore truthy, so without a falsy slot a dereference behind `if (!state.x)`
is never reached on the very boot that corrupts x, and for three of these
fields the falsy answer is the DEFAULT_STATE default — the branch every
ordinary install takes. It goes red too on a field that gains a floor while its
row still claims it has none, and on a field added to PERSISTED_FIELDS with no
row.

What it does NOT drive, stated accurately rather than claiming total coverage:
every combination. Four value combinations per view are driven, not the product
of the twelve swept fields. The last of the four is itself a MIX rather than a
uniform polarity — every falsy-capable field is falsy on it while the ones that
cannot be falsy stay hostile-truthy — so many two-field interactions are driven
and fatal; one needing a pairing none of the four produces is not driven at
all. Nor is anything no stored record reaches by itself: a view only forward
navigation opens, and anything behind a click. The header and the README mirror
now point at the test instead of restating it.

The boots are cheap enough to keep by construction rather than by sampling.
Every field the router itself reads is driven onto each view individually,
since a hostile value in one of those legitimately changes which view renders;
every other unfloored field is corrupted on the SAME boot, and that boot has to
land on the view it stored — so a field that does move the routing cannot hide
in the crowd, and the failure path re-boots one field at a time to name a
culprit without ever being able to clear the failure. That is forty-four boots
instead of several hundred; the suite runs in 12.8s against a 30s cap.

The polarity guard counts only values driven onto EVERY restorable view. A
hostileRestore entry may carry `views: [...]`, and counting one would let a
future row satisfy the guard with a polarity that reaches a single renderer.
No current row does; this keeps it that way.

The DOM stub in tests/support/popupBoot.js gained one thing to make any of that
possible: an element's parentElement. Without it success-tx and transaction
threw on the first line that hides a field's wrapper, so neither renderer could
be booted onto at all — every boot aimed at them fell back to Home instead, and
the base profile the sweep starts from is now asserted to render each view
rather than fall back, so that cannot go unnoticed again.
2026-08-23 20:42:26 +00:00
4 changed files with 121 additions and 59 deletions

View File

@@ -1052,19 +1052,24 @@ driving the real code with hostile values — and, for every 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. That last part is
what makes the claim falsifiable, because this defect class lives on the restore
path rather than on the home screen: a field one of those boots corrupts and a
restorable view dereferences on its render fails `make check`, at either
polarity — a value nothing writes is a wrong-typed one and therefore truthy, so
each such field is also driven falsy, or proven unable to be falsy after the
floor. So does a field that gains a floor while its row still claims it has
none, and so does a field added to `PERSISTED_FIELDS` with no row at all. Two
things the boots do not drive: a MIX of polarities, since one boot puts every
corrupted field on the same slot, so a branch reached only when one is truthy
and another falsy is not entered; and whatever no stored record reaches by
itself — a view only forward navigation opens, and anything behind a click. The
per-field justification that used to live in the header of
`src/shared/stateSchema.js` shipped a false claim in three consecutive changes,
each caught only by a reviewer re-deriving thirty fields by hand.
path rather than on the home screen: whenever one of those boots reaches a
structural dereference on the view it restored onto, `make check` fails —
including a dereference that takes two corrupted fields at once, because the
verdict is the combined boot and the per-field re-boot that names a culprit can
only decorate the message. Each swept field is driven at both polarities, or
proven unable to be falsy after the floor, since a value nothing writes is
wrong-typed and therefore truthy and would otherwise leave every `if (!state.x)`
branch unentered. So does a field that gains a floor while its row still claims
it has none, and so does a field added to `PERSISTED_FIELDS` with no row at all.
What the boots do not drive is every combination: four value combinations per
view, not the product of the twelve swept fields. The last of the four is itself
a mix — every falsy-capable field falsy against the ones that cannot be falsy —
so many two-field interactions are driven; one needing a pairing none of the
four produces is not. Nor is anything no stored record reaches by itself — a
view only forward navigation opens, and anything behind a click. The per-field
justification that used to live in the header of `src/shared/stateSchema.js`
shipped a false claim in three consecutive changes, each caught only by a
reviewer re-deriving thirty fields by hand.
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

10
TODO.md
View File

@@ -86,8 +86,14 @@ but the review is broader than any of them.
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. A field with no row, a field that gains a floor while its row still
claims it has none, and a field one of those boots corrupts and a restorable
view dereferences on its render now all fail `make check`.
claims it has none, and any structural dereference one of those boots reaches
on the view it restored onto now all fail `make check` — including a
dereference that takes two corrupted fields at once, because the verdict is
the combined boot and the per-field re-boot that names a culprit can only
decorate the message. What is not driven is every combination: four value
combinations per view rather than the product of the twelve swept fields, so
an interaction needing a pairing none of the four produces goes unseen, as
does anything no stored record reaches by itself.
- 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

View File

@@ -38,15 +38,24 @@
// real popup entry point onto EVERY view the popup can reopen onto.
//
// That last part is the whole point, because this defect class lives on the
// RESTORE path and not on Home: a field one of those boots corrupts and a
// restorable view dereferences on its render turns that suite red, at either
// polarity — a value nothing writes is wrong-typed and so truthy, so each such
// field is also driven falsy, or proven unable to be falsy after the floor. So
// does a field that gains a floor while its row still claims it has none. Two
// things the boots do not drive: a MIX of polarities, since one boot puts every
// corrupted field on the same slot; and whatever no stored record reaches by
// itself — a view only forward navigation opens, and anything behind a click. A
// field added to PERSISTED_FIELDS with no row fails the suite too.
// RESTORE path and not on Home: whenever one of those boots reaches a
// structural dereference on the view it restored onto, that suite goes red —
// including a dereference that takes two corrupted fields at once, since the
// verdict is the combined boot and the per-field re-boot that names a culprit
// can only decorate the message. Each swept field is driven at both
// polarities, or proven unable to be falsy after the floor: a value nothing
// writes is wrong-typed and so truthy, which would otherwise leave every
// `if (!state.x)` branch unentered. So does a field that gains a floor while
// its row still claims it has none, and a field added to PERSISTED_FIELDS with
// no row at all.
//
// What the boots do NOT drive is every combination: four value combinations
// per view, not the product of the twelve swept fields. The last of the four
// is itself a mix — every falsy-capable field falsy against the ones that
// cannot be falsy — so many two-field interactions are driven; one needing a
// pairing none of the four produces is not. Nor is anything no stored record
// reaches by itself: a view only forward navigation opens, and anything behind
// a click.
//
// That test exists because this comment did not work. It carried a
// hand-written justification per field, and it shipped a false one in three

View File

@@ -44,11 +44,22 @@
// reason, and a field that cannot be falsy after the floor says so in its row
// and is proven so.
//
// What that buys: a field any restorable view dereferences on that view's
// render turns this file red, at either polarity. What it does not buy is a
// MIX of polarities — one boot puts every swept field on the same slot, so a
// branch reached only when one swept field is truthy and another falsy is not
// entered. Booting every field separately at every value would be several
// What that buys: whenever one of those boots reaches a structural
// dereference on the view it restored onto, this file goes red — INCLUDING a
// dereference that takes two corrupted fields at once, because the verdict is
// the combined boot itself and the per-field re-boot below can only decorate
// the message. That is the one thing an earlier version of this file got
// wrong: it asserted on the per-field list, so an observed dead popup that no
// single field reproduced was reported green.
//
// What it does NOT buy is every combination — four value combinations per
// view are driven, not the product of twelve fields. Note that the last slot
// is itself a MIX rather than a uniform polarity: every falsy-capable field is
// falsy on it while the neverFalsy ones stay hostile-truthy, so many two-field
// interactions are driven and fatal. One that needs a pairing none of the four
// slots produces is not driven at all. Nor is anything no stored record
// reaches by itself: a view only forward navigation opens, and anything behind
// a click. Booting every field separately at every value would be several
// hundred boots and most of the suite's budget; this is forty-four.
//
// The three claims this replaced, all false, all caught here by construction:
@@ -100,6 +111,19 @@ const swept = (row) => row.kind === KIND.LOOSE || Boolean(row.alsoSweep);
// `if (!state.x) { state.y.deref() }`, so the falsy slot is not optional.
const sweptValues = (row) => [...row.hostile, ...(row.falsy || [])];
// What may count toward a row's POLARITY: the swept values, plus only those
// `hostileRestore` entries driven onto every restorable view. An entry that
// carries `views: [...]` reaches only those renderers, so counting it would
// let a future row claim a polarity that one view sees and the other ten do
// not. No current row does that — the restricted entries are all viewData's,
// and viewData is neverFalsy — and this is what keeps it so.
const polarityValues = (row) => [
...sweptValues(row),
...(row.hostileRestore || [])
.filter((entry) => !entry.views)
.map((entry) => entry.value),
];
// ------------------------------------------------------------------ the table
//
// `hostile` is values a stored record can carry that nothing in src/ ever
@@ -586,10 +610,9 @@ describe("both polarities of every swept field are driven", () => {
test(`${row.field}: truthy and falsy`, () => {
// What the boots below actually drive, floored the way a renderer
// sees it — not what the row says it drives.
const driven = [
...sweptValues(row),
...(row.hostileRestore || []).map((entry) => entry.value),
].map((value) => floored(row.field, value));
const driven = polarityValues(row).map((value) =>
floored(row.field, value),
);
expect({
truthy: driven.some((value) => Boolean(value)),
@@ -738,17 +761,23 @@ describe("a hostile routing value restoring onto", () => {
// routing turns this red and has to declare `routes` and take the individual
// sweep above.
//
// Combining does hide one thing, and the last slot is what stops it. A hostile
// Combining hides one thing, and the last slot is what stops it. A hostile
// value is wrong-typed and therefore TRUTHY, so on a boot where every swept
// field is hostile, no `if (!state.x)` branch is entered — and a dereference
// inside such a branch would go unseen however loudly it throws. The last slot
// is the falsy one: every swept field that CAN be falsy is falsy on it, which
// is also the state an ordinary install boots in for three of them, while the
// fields that cannot be falsy stay hostile. Beyond that, a throw fails the boot
// whichever field threw, and a renderer that never ran is what `restored`
// forbids. When it does go red, the same view is re-booted one field at a time
// so the failure names the fields rather than leaving a reader to bisect twelve
// of them.
// fields that cannot be falsy stay hostile-truthy. That makes it a MIX, and a
// deliberate one — the interaction between a falsy flag and a still-hostile
// theme is a shape a stored record really produces.
//
// The verdict is the combined boot, always. When it goes red the same view is
// re-booted one field at a time, so the failure NAMES a culprit instead of
// leaving a reader to bisect twelve fields — but that loop only decorates the
// message. It cannot clear the failure. A dereference that needs two corrupted
// fields at once is reproduced by neither field alone, and a version of this
// file that asserted on the named list reported exactly that case green while
// watching the popup die.
const UNROUTED = CONTRACT.filter((row) => swept(row) && !routes(row));
const HOSTILE_SLOTS = Math.max(
...UNROUTED.map((row) => sweptValues(row).length),
@@ -774,12 +803,14 @@ describe("every field the router does not read, corrupted at once, onto", () =>
restoringOnto(view, fields),
view,
);
if (together.errors.length === 0 && together.restored) {
expect(together).toEqual(RESTORED);
return;
}
// The per-field re-boot only DECORATES the message. The
// verdict is `together`, unconditionally: a dereference that
// needs two corrupted fields at once is reproduced by NEITHER
// field alone, so an assertion on the named list would report
// an observed dead popup as green.
const named = [];
if (together.errors.length > 0 || !together.restored) {
for (const row of UNROUTED) {
const one = await restoredHealth(
restoringOnto(view, {
@@ -789,14 +820,25 @@ describe("every field the router does not read, corrupted at once, onto", () =>
);
if (one.errors.length === 0 && one.restored) continue;
named.push(
`${row.field}=${JSON.stringify(fields[row.field])}: ` +
`${row.field}=${JSON.stringify(
fields[row.field],
)}: ` +
(one.errors.join("; ") || `fell off ${view}`),
);
}
expect({ view: view, fields: named }).toEqual({
if (named.length === 0) {
named.push(
"no single field reproduces it; it takes two or " +
`more of ${JSON.stringify(fields)}`,
);
}
}
expect({
view: view,
fields: [],
});
together: together,
fields: named,
}).toEqual({ view: view, together: RESTORED, fields: [] });
});
}
}