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 40s
e2e / e2e-chrome (push) Successful in 1m45s
e2e / e2e-firefox (push) Successful in 32s

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 any restorable view dereferences on render, 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 reach is what 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 it
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 thirty-three boots instead of six hundred; the suite runs in about 13s
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.
This commit is contained in:
2026-08-23 18:22:01 +00:00
committed by sneak
parent 45500e66cf
commit 6a01688106
14 changed files with 1940 additions and 354 deletions

View File

@@ -0,0 +1,723 @@
// 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
// hostile 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, and a field that is
// dereferenced by any renderer reachable from a stored record turns this file
// red. Booting each of them separately would be about six hundred boots and
// half a minute; this is thirty-three.
//
// 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);
// ------------------------------------------------------------------ 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.
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 }],
},
{
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]],
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 }],
},
{
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]],
},
{
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 }],
},
{
field: "dustThresholdGwei",
kind: KIND.LOOSE,
hostile: ["notanumber", true, { a: 1 }],
},
...[
"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 }],
})),
];
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", () => {
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.
describe("a hostile value for one field, booting onto Home", () => {
for (const row of CONTRACT) {
for (const value of row.hostile) {
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 row.hostile) {
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.
//
// Nothing is masked by combining: a throw fails the boot whichever field threw,
// and the only other way a dereference could go unseen is the renderer not
// running at all, which is exactly 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) => row.hostile.length));
function unroutedValues(slot) {
const fields = {};
for (const row of UNROUTED) {
fields[row.field] = row.hostile[slot % row.hostile.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);
});
}
}
}
});