// The version stamped on the stored profile, and the shape check every read // of one goes through. // // Storage is the one input to this extension that nobody validated. A profile // carried no version at all, so there was no way to tell a record this build // understands from one a later build wrote, and loadState() coerced scalars // while trusting the structure — so a `wallets` that was a string, or an array // of nulls, or a later schema's wallet records, reached the popup and threw on // the first dereference. The popup rendered NOTHING: no view, no message, no // control, and no way out from inside the product // (https://git.eeqj.de/sneak/AutistMask/issues/311). // // Two separate jobs, deliberately not merged: // // stateProblem() / assertStateUsable() refuse a record this build cannot // safely reason about, loudly, naming the problem in a // sentence that goes on screen. This is the gate. // normalizePersisted() (persistedState.js) self-heal a record that IS // usable: absent fields, legacy shapes, out-of-range flags. // // The gate runs FIRST, on the raw stored bytes, before normalization has a // chance to paper over a record whose meaning nobody can vouch for. A blob // that fails it is left in storage untouched — it is the user's only copy of // whatever it holds, and the recovery screen exports it before offering to // erase it. // // 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: // // 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. // // 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. const { isKnownNetworkId } = require("./networks"); // Bump this when the MEANING of a stored field changes, and add the migration // that carries the older version forward. Adding a field with a defaulted // absent value is not a bump: normalizePersisted() already handles that, and // bumping for it would send every older install to the recovery screen for no // reason. // // Version 1 is the shape that shipped unversioned. An unversioned record is // therefore version 1, not a defect — see migrationNeeded() below. const STATE_SCHEMA_VERSION = 1; // Thrown by every read path that finds a record it cannot use. `problem` is // the sentence shown to the user; `message` carries the same text so a log // line or a rethrow is not empty. class StateUnusableError extends Error { constructor(problem) { super(problem); this.name = "StateUnusableError"; this.problem = problem; } } function isPlainObject(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } // Own properties only, everywhere in this file. `saved` comes from storage as // parsed JSON, so `saved.constructor` and `saved.__proto__` answer from the // prototype chain for a record that carries neither — a check written as a // plain truthiness test can be satisfied by Object.prototype rather than by // anything the user's profile actually contains. function has(obj, key) { return Object.prototype.hasOwnProperty.call(obj, key); } function ordinal(index) { return String(index + 1); } function describeType(value) { if (value === null) return "null"; if (Array.isArray(value)) return "a list"; return "a " + typeof value; } // One address record, as every screen dereferences it. function addressProblem(addr, walletIndex, addrIndex) { const where = "address " + ordinal(addrIndex) + " of wallet " + ordinal(walletIndex) + " in the saved data"; if (!isPlainObject(addr)) { return "The " + where + " is " + describeType(addr) + ", not a record."; } if (typeof addr.address !== "string" || addr.address === "") { return "The " + where + " has no address."; } return null; } function walletProblem(wallet, index) { const where = "Wallet " + ordinal(index) + " in the saved data"; if (!isPlainObject(wallet)) { return where + " is " + describeType(wallet) + ", not a wallet record."; } if (!Array.isArray(wallet.addresses)) { return where + " has no list of addresses."; } if (has(wallet, "name") && typeof wallet.name !== "string") { return where + " has a name that is not text."; } for (let i = 0; i < wallet.addresses.length; i++) { const problem = addressProblem(wallet.addresses[i], index, i); if (problem) return problem; } return null; } function versionProblem(saved) { // No version field at all is the shape every install in the field has: // no build ever wrote one. It is version 1, and it is migrated in place. if (!has(saved, "schemaVersion")) return null; const version = saved.schemaVersion; if ( typeof version !== "number" || !Number.isInteger(version) || version < 1 ) { return ( "The saved data carries a schema version AutistMask does not" + " recognize (" + JSON.stringify(version) + ")." ); } if (version > STATE_SCHEMA_VERSION) { return ( "The saved data was written by a newer version of AutistMask" + " (schema version " + version + "; this build understands version " + STATE_SCHEMA_VERSION + ")." ); } return null; } /** * The reason this build cannot use `saved`, as a sentence for the user, or * null when it can. * * @param {*} saved the raw record from storage, or undefined for a fresh * install. * @returns {string|null} */ function stateProblem(saved) { // Nothing stored is a first run, not a defect. if (saved === undefined || saved === null) return null; if (!isPlainObject(saved)) { return ( "The saved data is " + describeType(saved) + ", not the record AutistMask stores." ); } const version = versionProblem(saved); if (version) return version; // Read once, from an OWN property or not at all, so that a polluted // prototype cannot decide whether a profile is refused. Note that // normalizePersisted() reads the same field plainly, and so WOULD consult // the prototype chain: the two halves agree only because a record arriving // from storage has been through structuredClone and always carries // Object.prototype. Nothing reachable from storage can put them at odds, // but a caller that hands either one a hand-built object with an unusual // prototype is not covered by that. const wallets = has(saved, "wallets") && saved.wallets !== undefined ? saved.wallets : []; if (!Array.isArray(wallets)) { return ( "The list of wallets in the saved data is " + describeType(wallets) + ", not a list." ); } for (let i = 0; i < wallets.length; i++) { const problem = walletProblem(wallets[i], i); if (problem) return problem; } // networkId is not merely displayed: it is an object KEY into // state.networkEndpoints. A corrupt "__proto__" would set that map's // prototype instead of an own key, so the user's endpoint would silently // not be recorded and a switch away and back would return the public // default. isKnownNetworkId() is an own-property test against the network // table for exactly that reason. if ( has(saved, "networkId") && saved.networkId !== undefined && !isKnownNetworkId(saved.networkId) ) { return ( "The saved data selects a network AutistMask does not know (" + JSON.stringify(saved.networkId) + ")." ); } return null; } /** * Refuse a record this build cannot use. * * @param {*} saved the raw record from storage. * @throws {StateUnusableError} */ function assertStateUsable(saved) { const problem = stateProblem(saved); if (problem) throw new StateUnusableError(problem); } /** * Whether `saved` is a usable record written before versions existed, and so * gets the current version stamped on it the next time anything writes. Purely * informational — the migration itself is that stamp, since version 1 IS the * unversioned shape. * * @param {*} saved * @returns {boolean} */ function migrationNeeded(saved) { return ( isPlainObject(saved) && !has(saved, "schemaVersion") && stateProblem(saved) === null ); } module.exports = { STATE_SCHEMA_VERSION, StateUnusableError, assertStateUsable, migrationNeeded, stateProblem, };