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.
279 lines
11 KiB
JavaScript
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,
|
|
};
|