fix: floor malformed allowedSites, fraudContracts and selectedToken entries (closes #362)
A stored allowedSites whose value was not a list rendered a working popup and then made every subsequent save fail silently, so the user operated a wallet that persisted nothing -- worse than a blank popup, which is at least visibly broken. fraudContracts and selectedToken had the same shape: a container floored by truthiness or not at all, while its entries were dereferenced. Entries are now floored as well as containers, following the idiom #311 established, and a failed save raises a persistent banner instead of vanishing into a swallowed rejection. The per-field justifications that used to live in a hand-written header are replaced by a contract test that drives each field's hostile and falsy values through a real popup boot, so a claim about a field answers to the code rather than to prose. Its guarantee is stated narrowly and deliberately: no structural dereference on the code paths a wholly-corrupted profile takes, which is not every path a stored record takes. The paths it does not drive are named where the claim is made, and are tracked in #379.
This commit was merged in pull request #366.
This commit is contained in:
864
tests/persistedFieldContract.test.js
Normal file
864
tests/persistedFieldContract.test.js
Normal file
@@ -0,0 +1,864 @@
|
||||
// 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.
|
||||
//
|
||||
// READ THE CLAIM NARROWLY. What this file proves is: NO STRUCTURAL
|
||||
// DEREFERENCE ON THE CODE PATHS A WHOLLY-CORRUPTED PROFILE TAKES. That is not
|
||||
// every path a stored record takes, and the difference is the whole of what
|
||||
// this file does not cover:
|
||||
//
|
||||
// - Only the values in the table, in the SLOT arrangement below: four value
|
||||
// combinations per view, not the product of twelve fields. A dereference
|
||||
// reached only under a pairing no slot produces is not driven at all.
|
||||
// - Only what a stored record reaches by ITSELF. A view only forward
|
||||
// navigation opens, and anything behind a click, is not driven.
|
||||
// - Nothing about the paths a HEALTHY profile takes, which is most of the
|
||||
// popup. This file is a floor under one defect class, not a proof about
|
||||
// the renderers.
|
||||
//
|
||||
// Within that boundary it is unconditional: if one of these boots leaves the
|
||||
// popup unhealthy or off the view it stored, this file goes red — including
|
||||
// when it 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 last part is the one thing an earlier version got wrong: it
|
||||
// asserted on the per-field list, so an observed dead popup that no single
|
||||
// field reproduced was reported green.
|
||||
//
|
||||
// Booting every field separately at every value would be several hundred boots
|
||||
// and most of the suite's budget; this is forty-four. Widening it further is
|
||||
// out of scope — proving no field is dereferenced on any reachable render path
|
||||
// is exhaustive verification of the popup, not a floor under a stored record.
|
||||
//
|
||||
// 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 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-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),
|
||||
);
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
// 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, {
|
||||
[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}`),
|
||||
);
|
||||
}
|
||||
if (named.length === 0) {
|
||||
named.push(
|
||||
"no single field reproduces it; it takes two or " +
|
||||
`more of ${JSON.stringify(fields)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
expect({
|
||||
view: view,
|
||||
together: together,
|
||||
fields: named,
|
||||
}).toEqual({ view: view, together: RESTORED, 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user