fix: floor the persisted fields a restore dereferences, and make each field's floor an executable claim (closes #362)
All checks were successful
check / check (push) Successful in 43s
e2e / e2e-chrome (push) Successful in 1m47s
e2e / e2e-firefox (push) Successful in 38s

A persisted container was checked while its ENTRIES were dereferenced
unchecked. A stored `{"0x…": "notalist"}` in allowedSites passes the state
gate, renders a working popup, and then throws inside saveState()'s per-
hostname merge, so every save from that moment on fails while the UI looks
entirely healthy. deniedSites has the identical shape; fraudContracts is the
same class with a milder consequence.

The sweep for that class found four more:

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -1,9 +1,14 @@
// AutistMask popup entry point.
// Loads state, initializes views, triggers first render.
const { state, saveState, loadState } = require("../shared/state");
const {
state,
saveState,
onSaveFailure,
loadState,
} = require("../shared/state");
const { StateUnusableError } = require("../shared/stateSchema");
const { setRuntimeDebug } = require("../shared/log");
const { log, setRuntimeDebug } = require("../shared/log");
const { refreshPrices } = require("../shared/prices");
const { refreshBalances } = require("../shared/balances");
const {
@@ -11,6 +16,7 @@ const {
showView,
updateDebugBanner,
setBackRenderer,
showSaveFailureBanner,
pushCurrentView,
goBack,
} = require("./views/helpers");
@@ -61,6 +67,14 @@ async function doRefreshAndRender() {
state.lastBalanceRefresh = Date.now();
await saveState();
renderWalletList();
} catch (e) {
// Every call site fires this and walks away — the boot below, the ten
// second interval, and eight views through ctx — so it must never
// reject: an unhandled rejection is not a report of anything. The save
// inside it reports its own failure through onSaveFailure() (see
// src/shared/state.js); what is left here is a failed network round
// trip, which the next tick retries.
log.errorf("popup: background refresh failed:", e);
} finally {
refreshInFlight = false;
}
@@ -136,6 +150,12 @@ function fallbackView() {
}
async function init() {
// First, before anything can save: showView() saves on every navigation
// without awaiting, so a save that fails from here on has somewhere to be
// reported rather than being swallowed by the save queue
// (https://git.eeqj.de/sneak/AutistMask/issues/362). Registered ahead of
// the approval-window branch below too, since that window saves as well.
onSaveFailure(showSaveFailureBanner);
try {
await loadState();
} catch (e) {

View File

@@ -53,12 +53,17 @@ function resetRenderedViews() {
const ALWAYS_RENDER_ON_BACK = new Set(["main"]);
// Views that render an address the user picked and cannot be rendered
// without one.
// without one. "confirm-tx" is here because its Sign button dereferences
// `state.wallets[state.selectedWallet].encryptedSecret`
// (src/popup/views/confirmTx.js) behind no guard of its own — a screen that
// can only throw when the user presses its one button must not be restored
// onto.
const ADDRESS_VIEWS = new Set([
"address",
"address-token",
"receive",
"transaction",
"confirm-tx",
]);
function needsAddress(view) {
@@ -74,6 +79,73 @@ function hasValidAddress(state) {
);
}
// The stored viewData ENTRIES each branch below dereferences, as opposed to
// the one field it gates on.
//
// A gate on a single truthy field checks the container, not the entries, and
// the dereference is one level below it: a stored `{"currentView":
// "success-tx","viewData":{"hash":"0x1"}}` passes `data.hash` and then throws
// on `address.toLowerCase()` inside addressTitle() (src/popup/views/
// helpers.js), out of restoreView(), which src/popup/index.js does not guard —
// so the rest of popup init never runs. txStatus.restoreWait() has checked its
// own branch's fields since it was written; these are the other four.
//
// Only what actually throws is required. Fields that are compared,
// concatenated or escaped coerce (escapeHtml() and displaySymbol() both
// String() their argument), so requiring them would refuse a restorable screen
// over a cosmetic value.
function isText(value) {
return typeof value === "string";
}
function isRecord(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
// An address handed to renderAddressHtml()/addressTitle(): both reach
// `address.slice()` and `address.toLowerCase()` with no guard.
function isAddressText(value) {
return isText(value);
}
// Decoded calldata, as decodedDetailsHtml() (src/popup/views/txStatus.js)
// walks it: `for (const d of decoded.details)` needs an iterable, and each
// entry's `address` reaches toAddressHtml(). Absent or falsy is the ordinary
// case and short-circuits before either.
function isRenderableDecoded(value) {
if (!value) return true;
if (!isRecord(value)) return false;
if (!value.details) return true;
if (!Array.isArray(value.details)) return false;
return value.details.every(
(entry) =>
isRecord(entry) && (!entry.address || isAddressText(entry.address)),
);
}
// The pending transaction confirmTx.show() renders: `token` reaches
// renderAddressHtml() when it is not "ETH", and `from`/`to` reach
// addressTitle(), makeBlockie() and getLocalWarnings().
function isRenderablePendingTx(value) {
return (
isRecord(value) &&
isText(value.token) &&
isAddressText(value.from) &&
isAddressText(value.to)
);
}
// The stored transaction transactionDetail.render() shows. contractAddress is
// optional on an ETH transfer, and reaches addressDotHtml() when it is there.
function isRenderableTx(value) {
return (
isRecord(value) &&
isAddressText(value.from) &&
isAddressText(value.to) &&
(!value.contractAddress || isAddressText(value.contractAddress))
);
}
// Render `view` from persisted state. Each view module shows itself, so a
// true return means the view is both rendered and on screen.
//
@@ -107,11 +179,11 @@ function renderView(view, state, views) {
views.settingsAddToken.show();
return true;
case "confirm-tx":
if (!data.pendingTx) return false;
if (!isRenderablePendingTx(data.pendingTx)) return false;
views.confirmTx.restore();
return true;
case "transaction":
if (!data.tx) return false;
if (!isRenderableTx(data.tx)) return false;
views.transactionDetail.render();
return true;
case "wait-tx":
@@ -120,10 +192,13 @@ function renderView(view, state, views) {
return Boolean(views.txStatus.restoreWait());
case "success-tx":
if (!data.hash) return false;
if (!isAddressText(data.to)) return false;
if (!isRenderableDecoded(data.decoded)) return false;
views.txStatus.renderSuccess();
return true;
case "error-tx":
if (!data.message) return false;
if (!isAddressText(data.to)) return false;
views.txStatus.renderError();
return true;
default:

View File

@@ -132,6 +132,38 @@ function updateDebugBanner(viewName) {
}
}
// The banner shown when a save has failed, registered as the save-failure
// reporter by src/popup/index.js.
//
// Persistent and not dismissable, unlike showFlash(): what it says is true
// until the popup is closed, and a message that clears itself after two seconds
// is how the user goes on operating a wallet that is persisting nothing
// (https://git.eeqj.de/sneak/AutistMask/issues/362). It survives navigation
// because it hangs off document.body rather than off a view.
//
// Created on demand rather than authored in index.html, the same way
// updateDebugBanner() creates its own: it is absent from a popup where nothing
// has failed, which is the state that must not need markup to be in.
//
// textContent, never innerHTML: `detail` carries an error message, which may
// come from the browser's storage layer.
function showSaveFailureBanner(detail) {
let banner = document.getElementById("save-failure-banner");
if (!banner) {
banner = document.createElement("div");
banner.id = "save-failure-banner";
banner.style.cssText =
"background:#c00;color:#fff;text-align:center;font-size:10px;padding:2px 4px;font-family:monospace;position:sticky;top:0;z-index:10000;";
document.body.prepend(banner);
}
const message = (detail && (detail.message || detail.problem)) || detail;
banner.textContent =
"NOT SAVED — AutistMask could not write to storage, so recent" +
" changes are not stored. Close and reopen the popup; if this keeps" +
" happening, do not rely on anything you change now." +
(message ? " (" + String(message) + ")" : "");
}
// Callback that renders a view being navigated BACK onto. Set once by
// index.js via setBackRenderer(), which routes the view through the same
// per-view render and data guards restoreView() uses.
@@ -544,6 +576,7 @@ module.exports = {
showView,
onViewLeave,
updateDebugBanner,
showSaveFailureBanner,
setBackRenderer,
pushCurrentView,
goBack,

View File

@@ -100,6 +100,107 @@ function tokenRefs(value) {
);
}
// A list of strings, for the fields whose entries are dereferenced as text:
// fraudContracts (`a.toLowerCase()` in src/popup/views/send.js and
// src/shared/transactions.js) and each address's hostname list in the site maps
// below (`h !== host` filters, `list.includes(hostname)` in the background).
//
// Same rule as tokenRefs(), for the same reason: the container AND the entries,
// with a malformed entry DROPPED rather than repaired. A number in a hostname
// list names no site and a number in fraudContracts names no contract, so there
// is nothing to repair either to, and the empty list is a legitimate value that
// survives. The result is a fresh array of primitives, so it shares no
// structure with `saved`.
function textList(value) {
if (!Array.isArray(value)) return [];
return value.filter((entry) => typeof entry === "string");
}
// allowedSites / deniedSites: { [address]: [hostname, ...] }.
//
// The container check these had (truthy and not an array) is not the floor:
// `{"0xabc…": "notalist"}` IS a non-array object, and the dereference is one
// level below it. saveState() merges these maps per key and then per hostname
// WITHIN each key, so a stored value that is not a list reaches `base.map()` in
// mergeListByIdentity() (src/shared/state.js) and throws — after the popup has
// rendered, which is why every save from then on failed while the UI looked
// healthy (https://git.eeqj.de/sneak/AutistMask/issues/362). The Settings
// revoke button (`list.filter()`), and the background's
// `allowed.includes(hostname)` gate, dereference it the same way; on that last
// one a stored string would also answer a SUBSTRING match, so a corrupt map
// could widen a site permission rather than merely throw.
//
// An address key whose value is not a list of hostnames is dropped entirely: it
// grants and denies nothing, and dropping it fails closed. A stored own
// "__proto__" key — which JSON can carry — is dropped for the same reason: it
// can never be a wallet address, so it grants nothing either, and keeping it
// only keeps a value that saveState()'s merge would hand to the prototype
// setter on the next write. Keys are written with defineProperty so that no key
// reaching this function can consult a setter at all, whatever the rule above
// it becomes; mergeMapByKey() in src/shared/state.js writes the same way.
function siteMap(value) {
const out = {};
if (!isRecord(value)) return out;
for (const address of Object.keys(value)) {
if (address === "__proto__") continue;
const hostnames = textList(value[address]);
if (hostnames.length === 0) continue;
defineOwn(out, address, hostnames);
}
return out;
}
// An endpoint URL: non-empty text, or the fallback.
function url(value, fallback) {
return typeof value === "string" && value !== "" ? value : fallback;
}
// One remembered endpoint pair out of networkEndpoints, floored on the two
// fields applyChainSwitchFields() (src/shared/chainSwitchFields.js) assigns
// STRAIGHT ONTO s.rpcUrl / s.blockscoutUrl on the next chain switch: flooring
// the live fields alone would leave a non-string sitting one switch away from
// them. A field that is not text is deleted rather than replaced, so the
// switch falls through its own `|| net.defaultRpcUrl`. Anything else the pair
// carries is kept: a profile that has been on a build storing more per-network
// fields must not lose them by passing through this one.
function endpointPair(value) {
const pair = { ...(isRecord(value) ? value : {}) };
for (const field of ["rpcUrl", "blockscoutUrl"]) {
if (typeof pair[field] !== "string" || pair[field] === "") {
delete pair[field];
}
}
return pair;
}
// A list index into wallets / a wallet's addresses: a non-negative integer, or
// null for "nothing selected".
//
// hasValidAddress() (src/popup/viewRouter.js) guards the restore path with
// `state.wallets[state.selectedWallet] && …addresses[state.selectedAddress]`,
// which is safe for a stale INTEGER — out of range is undefined, and the `&&`
// short-circuits — and NOT safe for a string naming an Array.prototype member.
// `wallets["map"]` is truthy, so the guard does not short-circuit and
// `.addresses[…]` throws out of restoreView(): the dead popup. "length",
// "constructor" and "__proto__" answer the same way, and
// src/popup/views/confirmTx.js dereferences selectedWallet behind no guard at
// all.
function listIndex(value) {
return Number.isInteger(value) && value >= 0 ? value : null;
}
// Write `key` as an own data property, never through a setter. Plain
// assignment of "__proto__" replaces the object's prototype and records no
// entry; every map built from stored keys goes through this.
function defineOwn(obj, key, value) {
Object.defineProperty(obj, key, {
value: value,
writable: true,
enumerable: true,
configurable: true,
});
}
// Keep only the leading run of stored views the popup is willing to render.
//
// restoreView() refuses to reopen ONTO a non-restorable view, but the stack
@@ -195,8 +296,15 @@ function normalizePersisted(saved) {
out.networkId = isKnownNetworkId(saved.networkId)
? saved.networkId
: DEFAULT_STATE.networkId;
out.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl;
out.blockscoutUrl = saved.blockscoutUrl || DEFAULT_STATE.blockscoutUrl;
// Non-empty text or the default, never anything else. getProvider()
// (src/shared/balances.js) hands rpcUrl straight to `new
// JsonRpcProvider()`, which throws SYNCHRONOUSLY for a value that is not a
// string — out of src/popup/views/txStatus.js and src/popup/views/
// addWallet.js, neither of which is inside a try, and the first of which a
// stored `currentView: "wait-tx"` reaches through restoreView(). It is a
// scalar, so the type check is the whole fix.
out.rpcUrl = url(saved.rpcUrl, DEFAULT_STATE.rpcUrl);
out.blockscoutUrl = url(saved.blockscoutUrl, DEFAULT_STATE.blockscoutUrl);
// An actual object is required, not merely a truthy non-array: the code
// below and applyChainSwitchFields() index and ASSIGN INTO this value, and
// assigning a property to a string or a number is a silent no-op in
@@ -210,19 +318,16 @@ function normalizePersisted(saved) {
: {};
out.networkEndpoints = {};
for (const netId of Object.keys(rawEndpoints)) {
// defineProperty, not assignment: a stored map with an own
// "__proto__" key — which JSON can carry and assignment treats as the
// prototype setter — would otherwise replace this object's prototype
// and record no entry at all. Keys other than the known network ids
// are kept rather than dropped, so a profile that has been on a build
// with more networks does not lose their endpoints by passing through
// this one.
Object.defineProperty(out.networkEndpoints, netId, {
value: { ...rawEndpoints[netId] },
writable: true,
enumerable: true,
configurable: true,
});
// Keys other than the known network ids are kept rather than dropped,
// so a profile that has been on a build with more networks does not
// lose their endpoints by passing through this one. That is why an own
// "__proto__" key survives here where siteMap() drops it, and why the
// write has to go through defineOwn().
defineOwn(
out.networkEndpoints,
netId,
endpointPair(rawEndpoints[netId]),
);
}
// A profile written before this map existed carries exactly one pair of
// endpoints, belonging to whatever network it was last on. Adopt it as
@@ -247,14 +352,8 @@ function normalizePersisted(saved) {
typeof saved.activeAddress === "string" && saved.activeAddress !== ""
? saved.activeAddress
: null;
out.allowedSites =
saved.allowedSites && !Array.isArray(saved.allowedSites)
? structuredClone(saved.allowedSites)
: {};
out.deniedSites =
saved.deniedSites && !Array.isArray(saved.deniedSites)
? structuredClone(saved.deniedSites)
: {};
out.allowedSites = siteMap(saved.allowedSites);
out.deniedSites = siteMap(saved.deniedSites);
out.rememberSiteChoice =
saved.rememberSiteChoice !== undefined
? saved.rememberSiteChoice
@@ -287,16 +386,32 @@ function normalizePersisted(saved) {
: 100000;
out.utcTimestamps =
saved.utcTimestamps !== undefined ? saved.utcTimestamps : false;
out.fraudContracts = structuredClone(saved.fraudContracts || []);
// A list of contract addresses, floored the same way: send.js builds its
// fraud set as `(state.fraudContracts || []).map((a) => a.toLowerCase())`
// and filterTransactions() maps the same list through normalizeAddress(),
// so a stored string walks through the `|| []` and a stored number walks
// through an Array.isArray().
out.fraudContracts = textList(saved.fraudContracts);
out.tokenHolderCache = structuredClone(saved.tokenHolderCache || {});
out.theme = saved.theme || "system";
out.debugMode = saved.debugMode !== undefined ? saved.debugMode : false;
out.currentView = saved.currentView || null;
out.selectedWallet =
saved.selectedWallet !== undefined ? saved.selectedWallet : null;
out.selectedAddress =
saved.selectedAddress !== undefined ? saved.selectedAddress : null;
out.selectedToken = saved.selectedToken || null;
out.selectedWallet = listIndex(saved.selectedWallet);
out.selectedAddress = listIndex(saved.selectedAddress);
// "ETH", or a contract address, or null — never anything else. The popup
// restores onto "address-token" behind a truthiness check on this field and
// then dereferences it as text (`tokenId.toLowerCase()` in
// src/popup/views/addressToken.js, `state.selectedToken.toLowerCase()` in
// src/popup/views/receive.js), so a stored number is truthy, passes the
// restore gate, and throws on the screen it restores onto. Found by the
// sweep for this same defect class in
// https://git.eeqj.de/sneak/AutistMask/issues/362; floored to null, which
// is what the restore gate already treats as "nothing selected". The empty
// string was already falsy here and stays null.
out.selectedToken =
typeof saved.selectedToken === "string" && saved.selectedToken !== ""
? saved.selectedToken
: null;
out.viewData = structuredClone(saved.viewData || {});
out.viewStack = restorableStack(saved.viewStack, out.currentView);
return out;

View File

@@ -310,12 +310,26 @@ function mergeAddress(base, ours, theirs) {
// a key another page edited. Unlike an array's identity function, an object
// key can't collide with a different logical entry (Object.keys() is
// already deduplicated), so this needs no collision floor of its own.
//
// Every write goes through defineProperty rather than assignment. The keys are
// whatever the stored record carries, and plain assignment of "__proto__" —
// which JSON can carry and normalizePersisted() keeps for networkEndpoints —
// replaces this object's prototype and records no entry. That would undo one
// layer downstream exactly what defineOwn() does in
// src/shared/persistedState.js.
function mergeMapByKey(base, ours, theirs, mergeLeaf) {
base = base || {};
ours = ours || {};
theirs = theirs || {};
const result = {};
const seen = new Set();
const put = (key, value) =>
Object.defineProperty(result, key, {
value: value,
writable: true,
enumerable: true,
configurable: true,
});
for (const key of Object.keys(theirs)) {
seen.add(key);
@@ -323,16 +337,16 @@ function mergeMapByKey(base, ours, theirs, mergeLeaf) {
const inOurs = Object.prototype.hasOwnProperty.call(ours, key);
if (inBase && !inOurs) continue; // this page deleted the whole entry
if (inOurs) {
result[key] = mergeLeaf(base[key], ours[key], theirs[key]);
put(key, mergeLeaf(base[key], ours[key], theirs[key]));
} else {
result[key] = theirs[key];
put(key, theirs[key]);
}
}
for (const key of Object.keys(ours)) {
if (seen.has(key)) continue;
if (!Object.prototype.hasOwnProperty.call(base, key)) {
result[key] = ours[key];
put(key, ours[key]);
}
}
@@ -522,11 +536,51 @@ async function saveStateOnce() {
// begins, so each one only ever sees the true live state at its turn.
let saveQueue = Promise.resolve();
// Where a failed save is REPORTED, set once by the context that has a screen
// to say it on (src/popup/index.js).
//
// A save that fails must not fail silently. showView() fires saveState() on
// every navigation without awaiting it, and the queue below has to attach a
// rejection handler to keep advancing — so a failing save was swallowed
// entirely: no throw, no message, nothing on screen. The wallet kept running
// against storage that was rejecting every write, which is the data-loss half
// of https://git.eeqj.de/sneak/AutistMask/issues/362. The awaited callers were
// no better off: `await saveState()` inside an unguarded event handler surfaces
// in the console and nowhere the user looks.
//
// This is the "tell the user" half; the other half is the floor in
// normalizePersisted(), which stops the malformed-record cause from arising in
// the first place. Both, because a floor only covers the causes it knows about
// and storage can still fail for reasons of its own (quota, a revoked
// permission, a record a newer build wrote).
let saveFailureHandler = null;
function onSaveFailure(fn) {
saveFailureHandler = fn;
}
function reportSaveFailure(err) {
log.errorf("state: saving failed, changes were NOT persisted:", err);
if (!saveFailureHandler) return;
try {
saveFailureHandler(err);
} catch (e) {
// The reporter is the last thing standing between a failed save and
// silence; a reporter that throws must not become an unhandled
// rejection of its own on top of it.
log.errorf("state: the save-failure reporter itself failed:", e);
}
}
function saveState() {
const turn = saveQueue.then(saveStateOnce);
// The queue must advance even when a save rejects, or every save after
// it queues behind a promise that never settles.
saveQueue = turn.catch(() => {});
// Every failed save is reported, whether or not the caller awaited this
// one. The returned promise still rejects, so a caller that DOES await
// keeps its own error handling.
turn.catch(reportSaveFailure);
return turn;
}
@@ -574,6 +628,7 @@ function currentAddress() {
module.exports = {
state,
saveState,
onSaveFailure,
loadState,
currentAddress,
currentNetwork,

View File

@@ -26,35 +26,50 @@
//
// What is checked HERE is what nothing downstream can floor: the wallet list,
// the version, and the network id that keys an object. Every other field is
// normalizePersisted()'s to make safe, and what that function does today is
// NOT uniform. The four kinds of floor it applies, listed so a reader can tell
// which one a given field has without reading it off:
// normalizePersisted()'s to make safe, and what that function does is NOT
// uniform across the record.
//
// Type-checked, container AND entries: trackedTokens, each address's
// tokenBalances, networkId, networkEndpoints, activeAddress, viewStack.
// These are the fields something dereferences structurally — iterated,
// indexed, assigned into, or .toLowerCase()'dwhere a truthy value of
// the wrong type throws on the first read. The entries matter as much as
// the container: [1, 2] IS a list, and `t.address` is one level below the
// Array.isArray(). What is checked on an ENTRY is the field the check
// exists for and no more — for trackedTokens and tokenBalances that is
// `address` alone; the rest of an entry is taken verbatim. So an entry's
// `decimals` and `balance` may be null, which is how balances.js records
// that nothing knows the token's scale
// (https://git.eeqj.de/sneak/AutistMask/issues/349), and every reader
// handles that null rather than being defended from it here.
// Container shape only: allowedSites, deniedSites. A falsy value or a list
// becomes {}; anything else is taken as stored and the entries are not
// checked.
// `saved.x || default`, no type check: rpcUrl, blockscoutUrl,
// lastBalanceRefresh, fraudContracts, tokenHolderCache, theme,
// currentView, selectedToken, viewData.
// Present-or-default, value taken verbatim: every boolean flag,
// dustThresholdGwei, selectedWallet, selectedAddress.
// WHICH FLOOR A GIVEN FIELD HAS IS NOT WRITTEN HERE. It is
// tests/persistedFieldContract.test.js: one row per persisted field, naming
// the property that field's floor is claimed to have, and PROVING it by
// driving the real code with hostile valuesthe gate for a field the gate
// refuses, normalizePersisted() for a field it floors, and, for a field whose
// only defence is that nothing dereferences it structurally, a boot of the
// real popup entry point onto EVERY view the popup can reopen onto.
//
// A field added to the record needs a check here or a floor there, chosen by
// what reads it: anything dereferenced structurally needs the type check, and
// neither of the last two kinds is one.
// That last part is the whole point, because this defect class lives on the
// RESTORE path and not on Home: whenever one of those boots reaches a
// structural dereference on the view it restored onto, that suite goes red —
// including a dereference that takes two corrupted fields at once, since the
// verdict is the combined boot and the per-field re-boot that names a culprit
// can only decorate the message. Each swept field is driven at both
// polarities, or proven unable to be falsy after the floor: a value nothing
// writes is wrong-typed and so truthy, which would otherwise leave every
// `if (!state.x)` branch unentered. So does a field that gains a floor while
// its row still claims it has none, and a field added to PERSISTED_FIELDS with
// no row at all.
//
// What the boots do NOT drive is every combination: four value combinations
// per view, not the product of the twelve swept fields. The last of the four
// is itself a mix — every falsy-capable field falsy against the ones that
// cannot be falsy — so many two-field interactions are driven; one needing a
// pairing none of the four produces is not. Nor is anything no stored record
// reaches by itself: a view only forward navigation opens, and anything behind
// a click.
//
// That test exists because this comment did not work. It carried a
// hand-written justification per field, and it shipped a false one in three
// consecutive changes — a different field each time, each caught only by a
// reviewer re-deriving thirty fields by hand. A claim nobody can execute is
// worse than no claim, because it is believed.
//
// The trap is worth stating here, since it is what all three got wrong: a
// check on a CONTAINER is not a check on its ENTRIES, and the dereference is
// one level below the container. `[1, 2]` is a list, `{"0x…": "notalist"}` is
// a record, and `{"currentView":"success-tx","viewData":{"hash":"0x1"}}`
// passes the restore gate and throws on the address the renderer below it
// reads. A field added to the record needs a decision about its entries as
// well as its shape — and then a row in that test.
const { isKnownNetworkId } = require("./networks");