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:
+22
-2
@@ -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) {
|
||||
|
||||
+78
-3
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
+144
-29
@@ -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;
|
||||
|
||||
+58
-3
@@ -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,
|
||||
|
||||
+34
-27
@@ -26,35 +26,42 @@
|
||||
//
|
||||
// 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()'d — where 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 values — the 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. Take the claim NARROWLY, exactly as that file
|
||||
// states it: what those boots prove is no structural dereference on the code
|
||||
// paths a WHOLLY-CORRUPTED PROFILE takes — which is not every path a stored
|
||||
// record takes. Not driven: any pairing of values the four slots do not
|
||||
// produce, a view only forward navigation opens, anything behind a click, and
|
||||
// everything a healthy profile reaches. Within that boundary the verdict is
|
||||
// unconditional, including a dereference that takes two corrupted fields at
|
||||
// once. That suite also goes red 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 at all.
|
||||
//
|
||||
// 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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user