Files
AutistMask/src/shared/stateSchema.js
T
clawbot a098bb0c32
check / check (push) Successful in 42s
e2e / e2e-chrome (push) Successful in 1m45s
e2e / e2e-firefox (push) Successful in 31s
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.
2026-08-23 23:06:17 +02:00

279 lines
11 KiB
JavaScript

// 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 is NOT
// uniform across the record.
//
// 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.
//
// 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");
// 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,
};