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 on a field one of those boots corrupts and a restorable
view dereferences on its render, at EITHER POLARITY — a value nothing in src/
writes is a wrong-typed one and therefore truthy, so every such field is also
driven falsy, or proven unable to be falsy after the floor. Without that, 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.
Two things it does not drive, and the artifacts say so rather than claiming
total coverage: 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 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 it.
That is forty-four boots instead of several hundred; the suite runs in 12.7s
against a 30s cap.
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.
826 lines
34 KiB
JavaScript
826 lines
34 KiB
JavaScript
// What the floor under each persisted field actually guarantees — as a table
|
||
// that RUNS, one row per field.
|
||
//
|
||
// This file replaces a hand-written per-field justification in the header of
|
||
// src/shared/stateSchema.js. That comment shipped a false claim in three
|
||
// consecutive changes: every author wrote plausible prose about thirty fields,
|
||
// every reviewer re-derived it by hand, and it kept being wrong in a different
|
||
// place each time. The artifact was the problem. A claim nobody can execute is
|
||
// worse than no claim, because it is believed.
|
||
//
|
||
// So the claim is a row here instead:
|
||
//
|
||
// KIND.REFUSED assertStateUsable() refuses the record outright. Proven by
|
||
// stateProblem() naming a problem for every hostile value.
|
||
// KIND.ENTRIES normalizePersisted() floors the container AND its entries.
|
||
// Proven by holds() over the normalized value.
|
||
// KIND.SCALAR normalizePersisted() floors it to one scalar type, or to a
|
||
// fixed fallback. Proven the same way.
|
||
// KIND.LOOSE `saved.x || default`, no type check at all. The claim is
|
||
// that no structural dereference of it is reachable from a
|
||
// stored record — which cannot be argued, only driven, so the
|
||
// proof is a boot of the REAL popup entry point over a stored
|
||
// record carrying the hostile value, ONTO EVERY RESTORABLE
|
||
// VIEW. Home is not where this class of defect lives.
|
||
//
|
||
// Every row is driven through a boot regardless of kind, but only a LOOSE row
|
||
// (or a row that sets `alsoSweep`) is swept across the restore path: that is
|
||
// what declaring LOOSE costs. ENTRIES and SCALAR rows are proven by their
|
||
// holds() instead, because a floored value is not hostile by the time a
|
||
// renderer sees it. A LOOSE row must additionally prove it is loose: if
|
||
// someone floors the field — even partially — and leaves the row saying LOOSE,
|
||
// the "survives verbatim" assertion fails. A field added to PERSISTED_FIELDS
|
||
// with no row fails the first test in the file.
|
||
//
|
||
// The sweep is what makes a LOOSE row falsifiable, so read how it is driven
|
||
// before trusting it. A row the ROUTER reads (`routes`) gets its own boot per
|
||
// view, because a hostile value in it legitimately changes which view renders.
|
||
// Every other swept field is corrupted on the SAME boot, one boot per view per
|
||
// slot, and that boot has to land on the view it stored — so a field that does
|
||
// move the routing cannot hide in the crowd. Every swept field is driven at
|
||
// BOTH POLARITIES: a value nothing in src/ writes is a wrong-typed one and so
|
||
// always truthy, which leaves `if (!state.x) { state.y.deref() }` unentered on
|
||
// the very boot that corrupts x. The last slot is the falsy one for that
|
||
// 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
|
||
// 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:
|
||
// rpcUrl reaching `new JsonRpcProvider()` (a synchronous throw, not a caught
|
||
// request); viewData's ENTRIES being dereferenced by four restore branches
|
||
// that gate on one truthy field each; and selectedWallet, where a stale
|
||
// integer index is the SAFE case and `wallets["map"]` is the throwing one.
|
||
|
||
const {
|
||
PERSISTED_FIELDS,
|
||
normalizePersisted,
|
||
} = require("../src/shared/persistedState");
|
||
const { stateProblem } = require("../src/shared/stateSchema");
|
||
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
|
||
const {
|
||
bootPopup,
|
||
cleanupPopup,
|
||
unversionedValidProfile,
|
||
ADDRESS,
|
||
TOKEN_ADDRESS,
|
||
} = require("./support/popupBoot");
|
||
|
||
const KIND = {
|
||
REFUSED: "refused by the gate",
|
||
ENTRIES: "container and entries type-checked",
|
||
SCALAR: "scalar type-checked",
|
||
LOOSE: "loosely floored; safety proven by driving the popup",
|
||
};
|
||
|
||
const isText = (v) => typeof v === "string";
|
||
const isRecord = (v) =>
|
||
typeof v === "object" && v !== null && !Array.isArray(v);
|
||
const isIndexOrNull = (v) => v === null || (Number.isInteger(v) && v >= 0);
|
||
const isTextOrNull = (v) => v === null || (isText(v) && v !== "");
|
||
const everyEntry = (v, fn) => Array.isArray(v) && v.every(fn);
|
||
|
||
// A row is SWEPT — driven onto every restorable view rather than only onto
|
||
// Home — when its claim is that no restore path dereferences the field. That
|
||
// is what LOOSE means. The two index rows opt in with `alsoSweep` although
|
||
// they are floored, because the restore path is precisely why they gained a
|
||
// floor and the sweep is the regression guard on it.
|
||
const swept = (row) => row.kind === KIND.LOOSE || Boolean(row.alsoSweep);
|
||
|
||
// Every value a swept row drives through a boot: the hostile set, plus the
|
||
// falsy slot that gives the field its other polarity. `hostile` values are all
|
||
// TRUTHY by nature — a value nothing in src/ writes is a wrong-typed one, and
|
||
// wrong-typed values are objects, non-empty strings and non-zero numbers. A
|
||
// field that is only ever truthy on the boot that corrupts it cannot falsify
|
||
// `if (!state.x) { state.y.deref() }`, so the falsy slot is not optional.
|
||
const sweptValues = (row) => [...row.hostile, ...(row.falsy || [])];
|
||
|
||
// ------------------------------------------------------------------ the table
|
||
//
|
||
// `hostile` is values a stored record can carry that nothing in src/ ever
|
||
// writes. Each one is driven through the floor AND through a real popup boot —
|
||
// and, for a swept row, through one boot per restorable view — so keep the
|
||
// list short and pointed. `floorOnly` is extra values checked against the
|
||
// floor alone, which is pure and free. `hostileRestore` is extra values driven
|
||
// through the restore path only, for a value that means nothing until a
|
||
// particular branch's gate has let it past.
|
||
//
|
||
// `falsy` is the other POLARITY of a swept field, driven for the same reason.
|
||
// It is not a value src/ never writes — for three of these fields it is the
|
||
// DEFAULT_STATE default, which is the branch every ordinary install takes —
|
||
// and that is the point: without it, a dereference behind `if (!state.x)` is
|
||
// unreachable on the one boot that corrupts x. A swept row that cannot supply
|
||
// one says `neverFalsy` instead, which is proven rather than asserted: every
|
||
// falsy value stored under that field comes back TRUTHY from the floor, so no
|
||
// `!state.x` branch is reachable from a stored record at all.
|
||
|
||
const CONTRACT = [
|
||
{
|
||
field: "wallets",
|
||
kind: KIND.REFUSED,
|
||
hostile: [42, "notastructure", { a: 1 }, [null], [{ addresses: 1 }]],
|
||
},
|
||
{
|
||
field: "networkId",
|
||
kind: KIND.REFUSED,
|
||
hostile: [42, "notanetwork", { a: 1 }, "__proto__"],
|
||
},
|
||
{
|
||
field: "trackedTokens",
|
||
kind: KIND.ENTRIES,
|
||
hostile: [42, "notalist", { a: 1 }],
|
||
floorOnly: [[1, 2], [null], [{}], [[TOKEN_ADDRESS]]],
|
||
holds: (v) => everyEntry(v, (t) => isRecord(t) && isText(t.address)),
|
||
},
|
||
{
|
||
field: "allowedSites",
|
||
kind: KIND.ENTRIES,
|
||
hostile: [42, "notarecord", { [ADDRESS]: "notalist" }],
|
||
floorOnly: [
|
||
[ADDRESS],
|
||
{ [ADDRESS]: 42 },
|
||
{ [ADDRESS]: [42, null, {}] },
|
||
JSON.parse('{"__proto__":["evil.invalid"]}'),
|
||
],
|
||
holds: siteMapHolds,
|
||
},
|
||
{
|
||
field: "deniedSites",
|
||
kind: KIND.ENTRIES,
|
||
hostile: [42, "notarecord", { [ADDRESS]: "notalist" }],
|
||
floorOnly: [
|
||
[ADDRESS],
|
||
{ [ADDRESS]: 42 },
|
||
{ [ADDRESS]: [42, null, {}] },
|
||
JSON.parse('{"__proto__":["evil.invalid"]}'),
|
||
],
|
||
holds: siteMapHolds,
|
||
},
|
||
{
|
||
field: "fraudContracts",
|
||
kind: KIND.ENTRIES,
|
||
hostile: [42, "notalist", { a: 1 }],
|
||
floorOnly: [[42], [null], [{}], [[TOKEN_ADDRESS]]],
|
||
holds: (v) => everyEntry(v, isText),
|
||
},
|
||
{
|
||
field: "viewStack",
|
||
kind: KIND.ENTRIES,
|
||
hostile: [42, "notalist", ["main", "show-phrase", "settings"]],
|
||
floorOnly: [[1, 2], [null], [{}], ["export-privkey"]],
|
||
// Truncated at the first entry the popup will not reopen onto, rather
|
||
// than filtered: every surviving entry's Back target has to stay the
|
||
// one it had. restorableStack() may also substitute ["main"] under a
|
||
// view restored below the root, so this is the one ENTRIES field whose
|
||
// result is not always a subset of what was stored.
|
||
holds: (v) => everyEntry(v, (e) => RESTORABLE_VIEWS.has(e)),
|
||
},
|
||
{
|
||
field: "networkEndpoints",
|
||
kind: KIND.ENTRIES,
|
||
hostile: [42, "notarecord", { mainnet: "notapair" }],
|
||
floorOnly: [
|
||
[1, 2],
|
||
{ mainnet: { rpcUrl: 42, blockscoutUrl: {} } },
|
||
{ mainnet: { rpcUrl: "", blockscoutUrl: [] } },
|
||
{ sepolia: 42 },
|
||
],
|
||
// Entries are coerced rather than dropped: an unknown network id is
|
||
// KEPT, so a profile that has been on a build with more networks does
|
||
// not lose their endpoints here. What is floored is the two URL fields
|
||
// inside the pair, which applyChainSwitchFields() assigns straight onto
|
||
// s.rpcUrl / s.blockscoutUrl on the next switch.
|
||
holds: (v) =>
|
||
isRecord(v) &&
|
||
Object.keys(v).every((id) => {
|
||
const pair = v[id];
|
||
return (
|
||
isRecord(pair) &&
|
||
(pair.rpcUrl === undefined ||
|
||
(isText(pair.rpcUrl) && pair.rpcUrl !== "")) &&
|
||
(pair.blockscoutUrl === undefined ||
|
||
(isText(pair.blockscoutUrl) &&
|
||
pair.blockscoutUrl !== ""))
|
||
);
|
||
}),
|
||
},
|
||
{
|
||
field: "rpcUrl",
|
||
kind: KIND.SCALAR,
|
||
hostile: [42, true, { a: 1 }],
|
||
floorOnly: [[], "", null],
|
||
holds: (v) => isText(v) && v !== "",
|
||
// The claim this row replaced said a bad value "fails the request on a
|
||
// path that already catches". It does not: getProvider() hands rpcUrl
|
||
// to `new JsonRpcProvider()`, which throws SYNCHRONOUSLY, from two call
|
||
// sites outside any try — and a stored `currentView: "wait-tx"` reaches
|
||
// one of them through restoreView(). So the row proves the claim
|
||
// against the real constructor rather than describing it.
|
||
alsoProven: (normalized, hostile) => {
|
||
// requireActual: bootPopup() mocks this module out for the boots
|
||
// above, and a mocked getProvider() would prove nothing at all
|
||
// about the constructor this row is a claim about.
|
||
const { getProvider } = jest.requireActual(
|
||
"../src/shared/balances",
|
||
);
|
||
expect(() => getProvider(hostile, "mainnet")).toThrow();
|
||
const provider = getProvider(normalized, "mainnet");
|
||
expect(provider).toBeTruthy();
|
||
provider.destroy();
|
||
},
|
||
},
|
||
{
|
||
field: "blockscoutUrl",
|
||
kind: KIND.SCALAR,
|
||
hostile: [42, true, { a: 1 }],
|
||
floorOnly: [[], "", null],
|
||
holds: (v) => isText(v) && v !== "",
|
||
},
|
||
{
|
||
field: "activeAddress",
|
||
kind: KIND.SCALAR,
|
||
hostile: [42, true, { a: 1 }],
|
||
floorOnly: [[ADDRESS], ""],
|
||
holds: isTextOrNull,
|
||
},
|
||
{
|
||
field: "selectedToken",
|
||
kind: KIND.SCALAR,
|
||
hostile: [42, true, { a: 1 }],
|
||
floorOnly: [[TOKEN_ADDRESS], ""],
|
||
holds: isTextOrNull,
|
||
},
|
||
{
|
||
field: "selectedWallet",
|
||
kind: KIND.SCALAR,
|
||
// The prototype members are the whole point: `wallets["map"]` is
|
||
// TRUTHY, so hasValidAddress()'s `&&` does not short-circuit and
|
||
// `.addresses[…]` throws. A stale INTEGER is the safe case.
|
||
hostile: ["map", "__proto__", { a: 1 }],
|
||
floorOnly: ["length", "constructor", "toString", "0", -1, 1.5, true],
|
||
holds: isIndexOrNull,
|
||
// SCALAR, and swept anyway: the restore path is precisely why this
|
||
// field gained a floor, so the sweep is the regression guard on it.
|
||
alsoSweep: true,
|
||
routes: true,
|
||
// A stale INTEGER index, which reaches the restore path by a different
|
||
// route from the prototype members above — falsy or out of range
|
||
// rather than truthy — and has to keep being the safe case.
|
||
hostileRestore: [{ value: "length" }, { value: 5 }],
|
||
},
|
||
{
|
||
field: "selectedAddress",
|
||
kind: KIND.SCALAR,
|
||
hostile: ["map", "__proto__", { a: 1 }],
|
||
floorOnly: ["length", "constructor", "toString", "0", -1, 1.5, true],
|
||
holds: isIndexOrNull,
|
||
alsoSweep: true,
|
||
routes: true,
|
||
hostileRestore: [{ value: 5 }],
|
||
},
|
||
{
|
||
field: "currentView",
|
||
kind: KIND.LOOSE,
|
||
routes: true,
|
||
// Compared, and concatenated into the debug banner's textContent
|
||
// (src/popup/views/helpers.js) with no gate in front of it, which
|
||
// coerces. Nothing renders FROM it without RESTORABLE_VIEWS.has()
|
||
// first, and Set.has() answers false for any value.
|
||
hostile: [42, "no-such-view", { a: 1 }],
|
||
// `saved.currentView || null`: the falsy polarity is the popup landing
|
||
// on Home, which every boot in "booting onto Home" below also drives.
|
||
falsy: [""],
|
||
},
|
||
{
|
||
field: "viewData",
|
||
kind: KIND.LOOSE,
|
||
routes: true,
|
||
// The container is taken verbatim; what makes its ENTRIES safe is the
|
||
// per-branch guard in src/popup/viewRouter.js. The sweep drives the
|
||
// container shapes below onto every restorable view; hostileRestore
|
||
// adds the records that PASS a branch's gate and then hand its
|
||
// renderer something it dereferences, which is where the entries are
|
||
// actually decided.
|
||
hostile: [42, "notarecord", { a: 1 }, [1, 2]],
|
||
// `structuredClone(saved.viewData || {})`: the container is never falsy
|
||
// in state whatever was stored, so no `!state.viewData` branch exists to
|
||
// drive.
|
||
neverFalsy: true,
|
||
hostileRestore: [
|
||
// success-tx passes on `data.hash`, and renderSuccess() then calls
|
||
// toAddressHtml(d.to) -> addressTitle() -> address.toLowerCase().
|
||
{ value: { hash: "0x1" }, views: ["success-tx"] },
|
||
{ value: { hash: "0x1", to: 42 }, views: ["success-tx"] },
|
||
{
|
||
value: { hash: "0x1", to: ADDRESS, decoded: { details: 7 } },
|
||
views: ["success-tx"],
|
||
},
|
||
{
|
||
value: {
|
||
hash: "0x1",
|
||
to: ADDRESS,
|
||
decoded: { details: [{ address: 42 }] },
|
||
},
|
||
views: ["success-tx"],
|
||
},
|
||
// error-tx passes on `data.message`, same dereference.
|
||
{ value: { message: "boom" }, views: ["error-tx"] },
|
||
{ value: { message: "boom", to: 42 }, views: ["error-tx"] },
|
||
// transaction passes on `data.tx`.
|
||
{ value: { tx: { hash: "0x1" } }, views: ["transaction"] },
|
||
{
|
||
value: {
|
||
tx: {
|
||
hash: "0x1",
|
||
from: ADDRESS,
|
||
to: ADDRESS,
|
||
contractAddress: 42,
|
||
},
|
||
},
|
||
views: ["transaction"],
|
||
},
|
||
// confirm-tx passes on `data.pendingTx`.
|
||
{ value: { pendingTx: { amount: "1" } }, views: ["confirm-tx"] },
|
||
{
|
||
value: {
|
||
pendingTx: {
|
||
token: 42,
|
||
from: ADDRESS,
|
||
to: ADDRESS,
|
||
amount: "1",
|
||
},
|
||
},
|
||
views: ["confirm-tx"],
|
||
},
|
||
// wait-tx passes on `pendingWait.hash`; restoreWait() has checked
|
||
// the fields below it since it was written, and this is the
|
||
// regression guard.
|
||
{
|
||
value: {
|
||
pendingWait: {
|
||
hash: "0x1",
|
||
txInfo: { to: 42, amount: "1" },
|
||
},
|
||
},
|
||
views: ["wait-tx"],
|
||
},
|
||
// A record that passes EVERY branch's gate at once, driven onto
|
||
// every restorable view: a branch a view does not read must stay
|
||
// one it does not read, and each renderer must survive the fields
|
||
// another branch left behind.
|
||
{
|
||
value: {
|
||
hash: "0x1",
|
||
message: "boom",
|
||
tx: { hash: "0x1" },
|
||
pendingTx: { amount: "1" },
|
||
pendingWait: { hash: "0x1" },
|
||
},
|
||
},
|
||
],
|
||
},
|
||
{
|
||
field: "lastBalanceRefresh",
|
||
kind: KIND.LOOSE,
|
||
// Arithmetic only: `now - (s.lastBalanceRefresh || 0)` compares false
|
||
// for a non-number and forces a refresh.
|
||
hostile: [true, "notatime", { a: 1 }],
|
||
// `|| 0` collapses every falsy stored value to 0, so 0 IS the whole
|
||
// falsy polarity of this field — and it is the DEFAULT_STATE default,
|
||
// the value a profile carries until its first refresh lands.
|
||
falsy: [0],
|
||
},
|
||
{
|
||
field: "tokenHolderCache",
|
||
kind: KIND.LOOSE,
|
||
// Nothing DEREFERENCES it structurally. It is read by the
|
||
// field-agnostic snapshotPersisted()/deepEqual() in
|
||
// src/shared/state.js, which are safe for any value, and otherwise
|
||
// only reset wholesale in src/shared/chainSwitchFields.js.
|
||
hostile: [42, "notarecord", [1, 2]],
|
||
// `structuredClone(saved.tokenHolderCache || {})`.
|
||
neverFalsy: true,
|
||
},
|
||
{
|
||
field: "theme",
|
||
kind: KIND.LOOSE,
|
||
// Compared against "dark"/"light" in applyTheme() and otherwise falls
|
||
// to the system branch; assigned into an input .value, which coerces.
|
||
hostile: [42, "chartreuse", { a: 1 }],
|
||
// `saved.theme || "system"`.
|
||
neverFalsy: true,
|
||
},
|
||
{
|
||
field: "dustThresholdGwei",
|
||
kind: KIND.LOOSE,
|
||
hostile: ["notanumber", true, { a: 1 }],
|
||
// Survives verbatim, so the falsy slot is also wrong-typed: "" reaches
|
||
// filterTransactions() as a comparand and a settings input .value.
|
||
falsy: [""],
|
||
},
|
||
...[
|
||
"rememberSiteChoice",
|
||
"showZeroBalanceTokens",
|
||
"hideSpoofedSymbols",
|
||
"hideLowHolderTokens",
|
||
"hideFraudContracts",
|
||
"hideDustTransactions",
|
||
"utcTimestamps",
|
||
"debugMode",
|
||
].map((field) => ({
|
||
field,
|
||
kind: KIND.LOOSE,
|
||
// A flag: only ever tested for truthiness, and written back verbatim.
|
||
hostile: [42, "notabool", { a: 1 }],
|
||
// Both answers to that truthiness test have to be driven, and 0 is a
|
||
// value src/ never writes for a flag. For utcTimestamps and debugMode
|
||
// the falsy answer is also the DEFAULT_STATE default.
|
||
falsy: [0],
|
||
})),
|
||
];
|
||
|
||
function siteMapHolds(v) {
|
||
return (
|
||
isRecord(v) &&
|
||
Object.getPrototypeOf(v) === Object.prototype &&
|
||
!Object.prototype.hasOwnProperty.call(v, "__proto__") &&
|
||
Object.keys(v).every((key) => everyEntry(v[key], isText))
|
||
);
|
||
}
|
||
|
||
afterEach(() => {
|
||
cleanupPopup();
|
||
});
|
||
|
||
// -------------------------------------------------------------- exhaustive
|
||
|
||
describe("the contract covers the record", () => {
|
||
test("every persisted field has exactly one row, and no row invents one", () => {
|
||
const rows = CONTRACT.map((row) => row.field);
|
||
|
||
expect([...rows].sort()).toEqual([...PERSISTED_FIELDS].sort());
|
||
});
|
||
|
||
test("every row declares a kind this file knows how to prove", () => {
|
||
const kinds = Object.values(KIND);
|
||
for (const row of CONTRACT) {
|
||
expect(kinds).toContain(row.kind);
|
||
expect(row.hostile.length).toBeGreaterThan(0);
|
||
}
|
||
});
|
||
});
|
||
|
||
// ------------------------------------------------------------- the floors
|
||
|
||
function profileWith(field, value) {
|
||
return unversionedValidProfile({ [field]: value });
|
||
}
|
||
|
||
describe("the floor each row claims", () => {
|
||
// The falsy slot is deliberately NOT in here. `saved.x || default` is a
|
||
// floor on falsy values and on nothing else, so a falsy value is the one
|
||
// thing a LOOSE field need not carry through verbatim; what it has to carry
|
||
// through is being falsy, which "both polarities" below asserts.
|
||
for (const row of CONTRACT) {
|
||
const values = [...row.hostile, ...(row.floorOnly || [])];
|
||
|
||
if (row.kind === KIND.REFUSED) {
|
||
test(`${row.field}: the gate refuses it`, () => {
|
||
for (const value of values) {
|
||
expect(
|
||
typeof stateProblem(profileWith(row.field, value)),
|
||
).toBe("string");
|
||
}
|
||
});
|
||
continue;
|
||
}
|
||
|
||
test(`${row.field}: ${row.kind}`, () => {
|
||
for (const value of values) {
|
||
const out = normalizePersisted(profileWith(row.field, value));
|
||
if (row.kind === KIND.LOOSE) {
|
||
// The claim IS that there is no floor. A field that grows
|
||
// one has to move to another kind rather than keep a row
|
||
// saying its readers are what make it safe.
|
||
continue;
|
||
}
|
||
expect({
|
||
value: value,
|
||
holds: row.holds(out[row.field]),
|
||
}).toEqual({ value: value, holds: true });
|
||
}
|
||
});
|
||
|
||
if (row.kind === KIND.LOOSE) {
|
||
test(`${row.field}: is genuinely unfloored`, () => {
|
||
// EVERY value, not some: a PARTIAL floor is still a floor, and
|
||
// a row that keeps saying LOOSE because one hostile value out
|
||
// of three still survives is exactly the stale claim this file
|
||
// exists to stop.
|
||
for (const value of values) {
|
||
const out = normalizePersisted(
|
||
profileWith(row.field, value),
|
||
);
|
||
expect({
|
||
value: value,
|
||
survived: JSON.stringify(out[row.field]),
|
||
}).toEqual({
|
||
value: value,
|
||
survived: JSON.stringify(value),
|
||
});
|
||
}
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
// --------------------------------------------- driving the real popup boot
|
||
|
||
// A booted popup is healthy when nothing threw out of init() and something is
|
||
// on screen. A throw out of restoreView() is neither: init() does not guard it,
|
||
// so the rest of popup init never runs and the user gets a popup with no view,
|
||
// no message and no control on it.
|
||
async function bootHealth(profile) {
|
||
const env = await bootPopup(profile);
|
||
return {
|
||
errors: env.pageErrors,
|
||
blank: env.visibleViews().length === 0,
|
||
};
|
||
}
|
||
|
||
const HEALTHY = { errors: [], blank: false };
|
||
|
||
// unversionedValidProfile() stores no currentView, so every boot in here lands
|
||
// on Home. That is the cheap half of the proof; the restore path below is the
|
||
// half that matters.
|
||
// Both polarities of every swept field are driven, or the field is proven
|
||
// unable to take one of them. This is the guard on the sweep itself: a hostile
|
||
// set is all-truthy by construction, so without a falsy slot a dereference
|
||
// behind `if (!state.x)` is never reached on the boot that corrupts x — the
|
||
// same falsy-collapse blind spot the fields below were floored for.
|
||
describe("both polarities of every swept field are driven", () => {
|
||
const FALSY_STORED = [0, "", false, null];
|
||
const floored = (field, value) =>
|
||
normalizePersisted(profileWith(field, value))[field];
|
||
|
||
for (const row of CONTRACT) {
|
||
if (!swept(row)) continue;
|
||
|
||
if (row.neverFalsy) {
|
||
test(`${row.field}: cannot be falsy in state at all`, () => {
|
||
for (const value of FALSY_STORED) {
|
||
expect({
|
||
stored: value,
|
||
truthy: Boolean(floored(row.field, value)),
|
||
}).toEqual({ stored: value, truthy: true });
|
||
}
|
||
});
|
||
continue;
|
||
}
|
||
|
||
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));
|
||
|
||
expect({
|
||
truthy: driven.some((value) => Boolean(value)),
|
||
falsy: driven.some((value) => !value),
|
||
}).toEqual({ truthy: true, falsy: true });
|
||
});
|
||
}
|
||
});
|
||
|
||
describe("a hostile value for one field, booting onto Home", () => {
|
||
for (const row of CONTRACT) {
|
||
for (const value of sweptValues(row)) {
|
||
test(`${row.field} = ${JSON.stringify(value)}`, async () => {
|
||
await expect(
|
||
bootHealth(profileWith(row.field, value)),
|
||
).resolves.toEqual(HEALTHY);
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
describe("a row's extra proof against the real reader", () => {
|
||
for (const row of CONTRACT) {
|
||
if (!row.alsoProven) continue;
|
||
test(row.field, () => {
|
||
for (const value of row.hostile) {
|
||
const out = normalizePersisted(profileWith(row.field, value));
|
||
row.alsoProven(out[row.field], value);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
// ------------------------------------------------ driving the restore path
|
||
|
||
// Everything above lands on Home. Home is not where this class of defect
|
||
// lives: all three of the false claims this file replaced were falsified by a
|
||
// RESTORE, through the unguarded restoreView() in src/popup/index.js. So a
|
||
// swept row's hostile values are driven onto EVERY restorable view, one boot
|
||
// each.
|
||
//
|
||
// This is what makes a LOOSE row falsifiable. A field that gains a structural
|
||
// dereference on any restorable view — `state.theme.toLowerCase()` in a view's
|
||
// show(), say — turns the row red here, instead of waiting for a reviewer to
|
||
// re-derive the claim by hand.
|
||
|
||
// restoreWait() resumes from this, so it has to be a finite number and recent
|
||
// enough that the resumed deadline has not already passed — a wait that has
|
||
// outlived its deadline resolves on the first poll instead of staying on
|
||
// screen. Read once at module load, so every boot in one run shares it.
|
||
const BROADCAST_TIME = Date.now();
|
||
|
||
// A viewData well formed for every restorable branch at once, so the only
|
||
// thing a swept boot can fail on is the field the row corrupts. "the base
|
||
// profile the sweep corrupts" below proves this really does render each view
|
||
// rather than falling back — without that, a sweep could pass by never
|
||
// reaching a renderer at all.
|
||
const WELL_FORMED_DATA = {
|
||
hash: "0x1",
|
||
message: "boom",
|
||
to: ADDRESS,
|
||
decoded: { details: [{ address: TOKEN_ADDRESS }] },
|
||
tx: { hash: "0x1", from: ADDRESS, to: ADDRESS, contractAddress: null },
|
||
pendingTx: {
|
||
token: "ETH",
|
||
from: ADDRESS,
|
||
to: ADDRESS,
|
||
amount: "1",
|
||
balance: "2",
|
||
},
|
||
pendingWait: {
|
||
hash: "0x1",
|
||
txInfo: { to: ADDRESS, amount: "1" },
|
||
broadcastTime: BROADCAST_TIME,
|
||
},
|
||
};
|
||
|
||
function restoringOnto(view, extra) {
|
||
return unversionedValidProfile({
|
||
currentView: view,
|
||
selectedWallet: 0,
|
||
selectedAddress: 0,
|
||
selectedToken: TOKEN_ADDRESS,
|
||
viewStack: ["main"],
|
||
viewData: WELL_FORMED_DATA,
|
||
...extra,
|
||
});
|
||
}
|
||
|
||
// A boot that RESTORED is healthy and landed on the view it stored, rather
|
||
// than falling back to Home — which a healthy boot also does, and which would
|
||
// let a sweep pass by never running the renderer it is aimed at.
|
||
async function restoredHealth(profile, view) {
|
||
const env = await bootPopup(profile);
|
||
return {
|
||
errors: env.pageErrors,
|
||
restored: env.visibleViews().includes(view),
|
||
};
|
||
}
|
||
|
||
const RESTORED = { errors: [], restored: true };
|
||
|
||
describe("the base profile the sweep corrupts", () => {
|
||
for (const view of RESTORABLE_VIEWS) {
|
||
test(`renders ${view} rather than falling back`, async () => {
|
||
await expect(
|
||
restoredHealth(restoringOnto(view), view),
|
||
).resolves.toEqual(RESTORED);
|
||
});
|
||
}
|
||
});
|
||
|
||
// A field the ROUTER itself reads — the two it gates on and the two
|
||
// hasValidAddress() indexes with. A hostile value in one of these legitimately
|
||
// changes which view renders, so each gets its own boot per view and is held
|
||
// only to "healthy", not to "restored onto the view it stored".
|
||
const routes = (row) => Boolean(row.routes);
|
||
|
||
// Every routing row × every hostile value × every restorable view. Profiles
|
||
// are deduplicated because a hostile `currentView` REPLACES the view being
|
||
// restored onto, which would otherwise be the same boot eleven times.
|
||
describe("a hostile routing value restoring onto", () => {
|
||
for (const row of CONTRACT) {
|
||
if (!swept(row) || !routes(row)) continue;
|
||
const seen = new Set();
|
||
for (const value of sweptValues(row)) {
|
||
for (const view of RESTORABLE_VIEWS) {
|
||
const profile = restoringOnto(view, { [row.field]: value });
|
||
const key = JSON.stringify(profile);
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
test(`${view}: ${row.field} = ${JSON.stringify(
|
||
value,
|
||
)}`, async () => {
|
||
await expect(bootHealth(profile)).resolves.toEqual(HEALTHY);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// Every OTHER swept field, corrupted at once, one boot per view per hostile
|
||
// slot: twelve fields on one boot rather than twelve boots. A field is only in
|
||
// here because it is not one the router reads — and that is ASSERTED, not
|
||
// argued, because the boot has to land on `view`. A field that does move the
|
||
// 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
|
||
// 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.
|
||
const UNROUTED = CONTRACT.filter((row) => swept(row) && !routes(row));
|
||
const HOSTILE_SLOTS = Math.max(
|
||
...UNROUTED.map((row) => sweptValues(row).length),
|
||
);
|
||
|
||
function unroutedValues(slot) {
|
||
const fields = {};
|
||
for (const row of UNROUTED) {
|
||
const values = sweptValues(row);
|
||
fields[row.field] = values[slot % values.length];
|
||
}
|
||
return fields;
|
||
}
|
||
|
||
describe("every field the router does not read, corrupted at once, onto", () => {
|
||
for (const view of RESTORABLE_VIEWS) {
|
||
for (let slot = 0; slot < HOSTILE_SLOTS; slot++) {
|
||
test(`${view}: hostile value ${slot + 1} in all ${
|
||
UNROUTED.length
|
||
} of them`, async () => {
|
||
const fields = unroutedValues(slot);
|
||
const together = await restoredHealth(
|
||
restoringOnto(view, fields),
|
||
view,
|
||
);
|
||
if (together.errors.length === 0 && together.restored) {
|
||
expect(together).toEqual(RESTORED);
|
||
return;
|
||
}
|
||
|
||
const named = [];
|
||
for (const row of UNROUTED) {
|
||
const one = await restoredHealth(
|
||
restoringOnto(view, {
|
||
[row.field]: fields[row.field],
|
||
}),
|
||
view,
|
||
);
|
||
if (one.errors.length === 0 && one.restored) continue;
|
||
named.push(
|
||
`${row.field}=${JSON.stringify(fields[row.field])}: ` +
|
||
(one.errors.join("; ") || `fell off ${view}`),
|
||
);
|
||
}
|
||
expect({ view: view, fields: named }).toEqual({
|
||
view: view,
|
||
fields: [],
|
||
});
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
// The values that only mean something on the restore path: a viewData that
|
||
// PASSES a branch's gate and then hands its renderer something dereferenced,
|
||
// and the index values whose route through hasValidAddress() differs from the
|
||
// row's own hostile set.
|
||
describe("a restore-only hostile value onto", () => {
|
||
for (const row of CONTRACT) {
|
||
for (const entry of row.hostileRestore || []) {
|
||
for (const view of entry.views || RESTORABLE_VIEWS) {
|
||
test(`${view}: ${row.field} = ${JSON.stringify(
|
||
entry.value,
|
||
)}`, async () => {
|
||
await expect(
|
||
bootHealth(
|
||
restoringOnto(view, { [row.field]: entry.value }),
|
||
),
|
||
).resolves.toEqual(HEALTHY);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
});
|