Compare commits

..

1 Commits

Author SHA1 Message Date
clawbot
61e0cad31f fix: let a user who lost the password delete the wallet, and warn before they can (closes #312)
All checks were successful
check / check (push) Successful in 31s
e2e / e2e-chrome (push) Successful in 1m11s
e2e / e2e-firefox (push) Successful in 24s
Deleting a wallet was password-gated and importing its recovery phrase
again was refused as a duplicate xpub by findWalletByXpub(), so a user who
held the phrase but had forgotten the password could neither leave the
wallet nor come back to it. The only escape was clearing extension storage
through browser internals, which takes every other wallet with it, and
nothing in the product ever warned that this was possible.

DeleteWallet now offers "I have lost my password", a screen that destroys
the wallet after the user types its name back. No password: requiring one
to discard a secret protects nobody, because an attacker at the popup who
wants the wallet gone can uninstall the extension, so the only person such
a gate stops is the owner who forgot it. The typed name is a check that
the user knows which wallet they are on, so it is matched with letter
case, surrounding spaces and repeated inner spaces ignored. The last of
those is not a nicety: HTML collapses a doubled inner space when it
renders the name, so comparing raw would leave a wallet named "My  Wallet"
with a confirmation no typing could ever satisfy.

This is the deletion route rather than the re-import route, and only one
of the two. Re-import would have had to be built three times over (hd and
xprv by xpub, key by address), would make the user retype the recovery
phrase into a live popup in order to change a password, and reaches no end
state that delete-then-import does not already reach through the existing
import path and scanForAddresses().

Both routes share one finishDelete(), so the selection repair, the site
permission cleanup and the AUTISTMASK_ACTIVE_CHANGED broadcast cannot
diverge between them. The new screen is not in RESTORABLE_VIEWS, alongside
delete-wallet-confirm: a popup reopened by accident must not land on a
button that erases key material. It registers an onViewLeave() cleanup as
well, not because a wallet name is a secret but because a typed
confirmation left standing in a hidden view leaves a wallet one click from
deletion. The two delete screens are siblings, so nothing is pushed on the
way in and Back re-enters DeleteWallet through show(), which hands it back
its wallet selection.

AddWallet's password hint now states, per import mode, that the password
cannot be recovered or reset and names what the only backup is. The hint
line reserves the 48px all three wordings measure in the popup, so
switching tabs cannot move the password fields under the pointer and the
reserve costs no height the screen needs elsewhere.

The test drives the real view against a chrome.storage.local stub that
structured-clones on both set and get, and asserts against what comes back
out of storage rather than against the live state object, so it fails on
the deletion of saveState() and not only on an in-memory splice.
2026-08-20 13:01:34 +00:00
70 changed files with 733 additions and 6967 deletions

View File

@@ -4,4 +4,3 @@
node_modules
.DS_Store
dist
release

3
.gitignore vendored
View File

@@ -23,9 +23,6 @@ node_modules/
# Build output
dist/
# Release artifacts (make package). Derived from dist/, never committed.
release/
# Yarn
.yarn-integrity
package-lock.json

View File

@@ -1,5 +1,4 @@
node_modules/
yarn.lock
dist/
release/
.claude/

View File

@@ -1,4 +1,4 @@
.PHONY: bootstrap setup install test test-e2e test-e2e-firefox lint fmt fmt-check check check-censored docker hooks build build-debug package vendor-blocklist clean dev
.PHONY: bootstrap setup install test test-e2e test-e2e-firefox lint fmt fmt-check check check-censored docker hooks build build-debug vendor-blocklist clean dev
# Standard targets are thin shims; the implementations live in script/
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
@@ -41,9 +41,6 @@ check:
check-censored:
@script/check-censored
package:
@script/package
docker:
@script/docker
@@ -63,31 +60,19 @@ hooks:
# scrubbed from the build itself: with AUTISTMASK_DEBUG=1 exported, this target
# compiles a debug bundle and then fails on it, loudly, rather than quietly
# handing back something other than the release build that was asked for.
#
# Every step of this target is wrapped in script/discard-dist-on-failure, so a
# release build that fails removes dist/ instead of leaving a complete, loadable
# debug bundle there for whoever runs the build, sees it fail, and loads
# dist/chrome/ anyway. A step that succeeds removes nothing, and build-debug is
# deliberately not wrapped.
build:
@echo "Building extension..."
@set -eu; \
receipt="$$(mktemp "$${TMPDIR:-/tmp}/autistmask-build-receipt.XXXXXX")"; \
trap 'rm -f "$$receipt"' EXIT INT TERM; \
script/discard-dist-on-failure \
env AUTISTMASK_BUILD_RECEIPT="$$receipt" yarn run build 2>&1; \
script/discard-dist-on-failure \
env -u AUTISTMASK_DEBUG script/verify-build --expect release \
AUTISTMASK_BUILD_RECEIPT="$$receipt" yarn run build 2>&1; \
env -u AUTISTMASK_DEBUG script/verify-build --expect release \
--receipt "$$receipt"
@script/discard-dist-on-failure script/check-censored --require-dist
@script/check-censored --require-dist
# Development-only build: enables the red DEBUG / INSECURE banner and makes
# the hardcoded test recovery phrase the output of wallet creation. Never
# distribute the artifacts this produces.
#
# No discard-dist-on-failure here, on purpose: a debug build that fails is not
# producing an artifact anyone could mistake for a release one, and its dist/ is
# the evidence of what went wrong.
build-debug:
@echo "Building extension (DEBUG)..."
@set -eu; \
@@ -105,7 +90,7 @@ vendor-blocklist:
@script/vendor-blocklist
clean:
@rm -rf dist/ release/
@rm -rf dist/
dev:
@echo "Building in watch mode..."

267
README.md
View File

@@ -44,107 +44,7 @@ Load the extension:
- **Chrome**: Navigate to `chrome://extensions/`, enable "Developer mode", click
"Load unpacked", and select the `dist/chrome/` directory.
- **Firefox**: Navigate to `about:debugging#/runtime/this-firefox`, click "Load
Temporary Add-on", and select `dist/firefox/manifest.json`. Read
[Installing on Firefox](#installing-on-firefox) before relying on this: a
temporary add-on does not survive closing the browser.
### Release Artifacts
`make package` runs `make build` and then writes one self-contained, versioned
archive per browser into `release/`, plus a `SHA256SUMS` for them:
```bash
make package
```
```
release/autistmask-chrome-<version>.zip
release/autistmask-firefox-<version>.xpi
release/SHA256SUMS
```
Nothing is published by this. Tagging, CRX packing and any upload are
outward-facing acts and are the owner's alone.
The archives are deterministic — entries sorted, timestamps fixed, compression
level fixed — so two builds of one commit produce byte-identical files and the
recorded digest is a property of the input rather than of the clock.
**Self-containment is checked, not assumed.** `build.js` writes the compiled
Tailwind output to `dist/styles.css` at the `dist/` ROOT, outside both browser
directories, and copies it into each of them as `src/popup/styles.css`; a naive
`zip -r dist/chrome` is therefore correct only by accident. So the packager
resolves every path referenced by the manifest and by every HTML document in the
archive, requires each to be inside the archive, and fails on any reference that
climbs out of the extension root. Files left at the `dist/` root are printed as
deliberately not shipped rather than dropped by a glob. The archive is then read
back off disk and compared member by member against the directory it was built
from: an archive nobody opened is a claim, not an artifact.
There is one version, and the build enforces it. `package.json`,
`manifest/chrome.json` and `manifest/firefox.json` each declare one and none is
derived from another — the manifests are copied to `dist/` verbatim, which is
what `tests/manifest.test.js` asserts — so `script/lib/version.js` requires all
three to agree and **fails the build when they do not**, naming each file and
what it said. `tests/version.test.js` covers that rule in `make check`.
### Installing on Chrome
`manifest/chrome.json` carries a fixed `key`: the public half of an RSA keypair,
base64-encoded DER. It exists for one reason. An unpacked Chrome extension with
no `key` gets an extension id derived from the **absolute path it was loaded
from**, and `chrome.storage.local` — which is where the wallet lives — is
partitioned by that id. Move the checkout, re-clone it, or load a second copy
from anywhere else, and the extension comes up on a fresh, empty storage
partition: the wallet is simply gone, with no error and nothing in the UI to say
so. With the `key` in place the id is derived from the key instead, and follows
the extension wherever it is loaded from. That id is
`gipbhkogfopeahplcjhipkgpcimdpkip`, pinned in `tests/extensionId.test.js` and
observed against a real Chrome in `tests/e2e/storagePartition.js`.
**Changing `key` changes the extension id, and orphans every wallet stored under
the old one.** It is a migration, not an edit.
The **private** half is a credential. It is not in this repository, no target
generates one into the working tree, `*.pem` and `*.key` are gitignored, and
`tests/extensionId.test.js` fails if such a file is ever committed. It is not
needed to build, load or test anything here — it signs a CRX, and this repo does
not pack one. A packer would take it from outside the repo, e.g.
`chrome --pack-extension=dist/chrome --pack-extension-key=<path to the .pem>`.
### Installing on Firefox
**The XPI this repo produces is UNSIGNED, and release Firefox and Firefox ESR
will refuse to install it.** Those builds enforce add-on signing with no working
override — `xpinstall.signatures.required` does nothing on them — so a permanent
install needs Firefox Developer Edition, Nightly, or an Unbranded build, with
`xpinstall.signatures.required` set to `false` in `about:config`.
Signing means submitting to AMO (self-distribution is enough, and does not
require listing), which needs credentials this repository does not have and is
the owner's decision.
The other route is `about:debugging#/runtime/this-firefox` -> "Load Temporary
Add-on", which works on every Firefox including release. **A temporary add-on is
unloaded when Firefox exits**, so daily use means re-adding it by hand on every
browser start.
**The wallet survives the restart.** `manifest/firefox.json` declares a fixed
`browser_specific_settings.gecko.id`, and Firefox keys the extension's storage
area on that id rather than on the install, so adding the temporary add-on again
in the same profile finds the vault where it left it. That is asserted, not
assumed: `tests/e2e/firefox/reinstall.js` installs the packaged XPI in a real
Firefox, creates a wallet through the UI, quits the browser, starts it again on
the same profile, adds the add-on again, and decrypts the vault with the
original password back to the original recovery phrase. The `moz-extension://`
origin the popup is served from is _not_ stable across installs and does not
need to be — nothing durable is keyed on it.
**Removing the add-on does not.** An explicit uninstall — about:addons "Remove"
— destroys the extension's storage, and the vault with it. That is ordinary,
correct browser behaviour and it is observed in the same suite, but for a wallet
it is worth saying out loud: **on Firefox, Remove is irreversible, and the
recovery phrase is the only way back.**
Temporary Add-on", and select `dist/firefox/manifest.json`.
### Debug Builds
@@ -163,12 +63,8 @@ that runs `make build`, that target compiles a debug bundle and then **fails**,
because it tells `script/verify-build` in so many words that it was supposed to
produce a release build. It used to be that the verifier read the same variable
out of its own environment, agreed with itself, and reported a debug artifact as
verified. The build prints which mode it used. A release build that fails also
**removes `dist/`**, and says so: the bundle it had already written is loadable,
and a loud failure is no protection against someone loading `dist/chrome/`
anyway. `make build-debug` keeps its `dist/` on failure — that output is not
mistakable for a release build, and it is the evidence of what went wrong. See
the [DEBUG Mode Policy](#debug-mode-policy) for what the flag changes. **Never
verified. The build prints which mode it used. See the
[DEBUG Mode Policy](#debug-mode-policy) for what the flag changes. **Never
distribute a debug build** — every wallet it creates gets the same publicly
known test recovery phrase.
@@ -185,21 +81,17 @@ lives.
one of the bundles containing `src/shared/constants.js` — into a build receipt,
and `script/verify-build` checks `dist/` against that receipt: every recorded
file present with exactly the recorded bytes, every audited bundle carrying the
requested `DEBUG` marker, and no regular file or symlink under `dist/` that the
build did not write. The `Makefile` creates the receipt path with `mktemp` per
invocation, outside the repo, and deletes it afterwards.
requested `DEBUG` marker, and nothing under `dist/` that the build did not
write. The `Makefile` creates the receipt path with `mktemp` per invocation,
outside the repo, and deletes it afterwards.
That is what ties the check to a build rather than to a directory. What it
establishes is narrow and worth stating exactly: `dist/` is byte for byte the
output of the `build.js` run that just finished, with no regular file or symlink
added, removed or altered in between. Regular files and symlinks are the whole
of what the tree walk covers; fifos, sockets, device nodes and empty directories
under `dist/` are not checked, because a build emits none of them, none can
carry a shippable payload, and `grep` on a fifo would hang rather than fail. It
establishes nothing about whether the source tree or `build.js` were honest, and
it offers nothing to someone handed a `dist/` from elsewhere — without the
receipt from its own build there is no input to the check. Verifiable provenance
for a third party is signing, which this is not.
output of the `build.js` run that just finished, with nothing added, removed or
altered in between. It establishes nothing about whether the source tree or
`build.js` were honest, and it offers nothing to someone handed a `dist/` from
elsewhere — without the receipt from its own build there is no input to the
check. Verifiable provenance for a third party is signing, which this is not.
There is deliberately no target that re-verifies an existing `dist/` on its own.
The list of files to check has to come from the build that produced them; read
@@ -246,45 +138,31 @@ provide:
fails anywhere else. Part of `make check`, which inspects `dist/` when there
is one and says loudly when there is not; `make build` re-runs it with
`--require-dist`, so a build artifact is always covered
- `script/package` — produce the release artifacts: `make build` first, so the
archives can only ever be made from a `dist/` that has been verified against
that build's own receipt as a RELEASE build, then one self-contained,
versioned archive per browser into `release/` (see
[Release Artifacts](#release-artifacts)). It packages and does not publish
- `script/vendor-blocklist` — refresh `src/shared/phishingBlocklist.json` from
its upstream, pinned to a commit and to the sha256 of the bytes that commit
serves. Run deliberately, never as part of a build: the output is committed
and there is no runtime fetch, so the shipped list is as fresh as the last
vendoring run that was released
- `script/verify-build --expect release|debug --receipt PATH` — assert that the
regular files and symlinks under `dist/` are exactly what the build that just
ran emitted (other file types are out of scope), and that the compiled `DEBUG`
state of the bundles in it is the one that was asked for. Both arguments are
required and neither has a default: the expected mode is stated by the caller
rather than read from `AUTISTMASK_DEBUG`, and the file list comes from the
build's receipt rather than from `dist/` (see
- `script/verify-build --expect release|debug --receipt PATH` — assert that
`dist/` is exactly what the build that just ran emitted, and that the compiled
`DEBUG` state of the bundles in it is the one that was asked for. Both
arguments are required and neither has a default: the expected mode is stated
by the caller rather than read from `AUTISTMASK_DEBUG`, and the file list
comes from the build's receipt rather than from `dist/` (see
[Build Receipts](#build-receipts)). Run automatically at the end of
`make build` and `make build-debug`; fails loudly rather than passing whenever
it cannot determine something. Not part of `make check`, which does not depend
on build artifacts existing.
- `script/discard-dist-on-failure COMMAND [ARG...]` — run one step of the
**release** build and, if it fails, remove `dist/` before returning that
step's exit status, saying on stderr that it did and why. Every step of
`make build` runs through it; `make build-debug` runs none of them through it.
A step that succeeds removes nothing, and a removal that cannot be completed
is reported as loudly as one that was
- `script/test-verify-build` — exercise every failure mode of
`script/verify-build` against a fixture tree in a temp dir, asserting the exit
status and the message of each, assert the state of `dist/` on disk after a
failing and a succeeding release build step, and read the `make build` and
status and the message of each, and read the `make build` and
`make build-debug` recipes back out of `make -n` to check that they pass the
mode as an argument on a scrubbed environment and wrap only the release path.
Part of `make check`; it reads no build artifacts and writes nothing under
`dist/`. The cases that depend on file permissions cannot mean anything for a
process that is not subject to them, so the harness proves its runner against
a mode-000 file before counting them, dropping to an unprivileged user when
run as root; if it cannot, it skips those cases and says so in a banner rather
than passing them.
mode as an argument on a scrubbed environment. Part of `make check`; it reads
no build artifacts and writes nothing under `dist/`. The cases that depend on
file permissions cannot mean anything for a process that is not subject to
them, so the harness proves its runner against a mode-000 file before counting
them, dropping to an unprivileged user when run as root; if it cannot, it
skips those cases and says so in a banner rather than passing them.
- `script/docker` — build the Docker image tagged via `script/projectname`
- `script/cibuild` — CI entrypoint: plain `docker build .`
- `script/precommit` — run by the git pre-commit hook; runs `script/check`
@@ -298,12 +176,10 @@ The Makefile shims to those. It also carries a few targets that have no
silently rewritten. Use `make setup` for a fresh clone.
- `make hooks` — shims to `script/install-precommit`
- `make build` — build the extension into `dist/chrome/` and `dist/firefox/`,
then verify the result against the build's receipt as a release build. A
failure at any step removes `dist/`
then verify the result against the build's receipt as a release build
- `make build-debug` — the same build with `AUTISTMASK_DEBUG=1`, verified as a
debug build, and keeping its `dist/` on failure (see
[Debug Builds](#debug-builds))
- `make clean` — remove `dist/` and `release/`
debug build (see [Debug Builds](#debug-builds))
- `make clean` — remove `dist/`
- `make dev` — build in watch mode
## End-to-End Tests
@@ -439,23 +315,6 @@ class before a browser is involved, so this suite is no longer the only thing
standing between it and a release — but a static rule only sees identifiers, and
the runtime errors this suite catches are broader than one rule.
`make test-e2e` then runs a second program in the same image,
`tests/e2e/storagePartition.js`, which answers where `chrome.storage.local`
lives. It loads the built extension from one temporary directory in a fresh
profile, writes a sentinel key the extension itself never touches, closes the
browser, and loads a **copy at a different path into the same profile** — then
does it again with `key` stripped out of the manifest, and reports what each
pair actually did rather than what it expected. It needs its own browser
sessions, four of them, because the whole subject is what happens ACROSS loads.
The observed behaviour, on the pinned Chromium: **with `key`, both paths get the
same extension id and the second load reads the first load's storage. Without
`key`, the two paths get different ids and the second load sees an empty
partition** — the wallet, silently gone. Loading both keyed copies at once in
one profile yields a single extension id, not two: Chrome does not load a second
copy of an id it already has. The assertions are annotated as observations, so a
Chrome that ever changes this fails the run instead of passing it.
### Firefox (`make test-e2e-firefox`)
`make test-e2e-firefox` builds `dist/firefox/` and drives the **real popup in a
@@ -473,33 +332,6 @@ runner rather than believed from the extension, and the stub node has to answer
`eth_sendRawTransaction` with the hash `ethers` computes for the artifact it
sent, or `provider.broadcastTransaction()` refuses the answer.
`make test-e2e-firefox` then runs a second program in the same image,
`tests/e2e/firefox/reinstall.js`, and it installs the **packaged XPI** rather
than the unpacked directory — the only place a real Firefox is asked to load the
artifact that would actually be handed to someone. It runs two browsers over one
profile, because it asks two questions that have different answers:
- **Restart.** Install, create a wallet through the UI, record the vault, quit
the browser, start it again on the same profile, add the add-on again. The
extension must not come up as a fresh install; the vault, xpub and first
address must be unchanged; and the vault must still **decrypt** with the
original password to the original recovery phrase, through the real Show
Recovery Phrase screen. "The ciphertext is still in storage" and "the wallet
still works" are different claims and only the second one is worth anything.
This is what a user does every day, because a temporary add-on is unloaded
when Firefox exits.
- **Removal.** Then, in the same browser, an explicit uninstall and a fresh
install. Observed: **Firefox destroys the extension's storage on uninstall**,
so the vault is gone. Recorded as an assertion, so a Firefox that ever changes
it fails the run, and stated in
[Installing on Firefox](#installing-on-firefox) because for a wallet it means
Remove is irreversible except from the recovery phrase.
It also pins down something worth knowing about the harness: the
`moz-extension://` uuid the popup is served from is not stable across installs,
and navigating to a stale one does not fail — it hangs. The program reads the
live uuid out of `extensions.webextensions.uuids` after every install.
Both suites build their own image, each with the repo and a fresh extension
build baked in; what differs is the base. The Chrome image layers those on top
of a published Playwright image, whereas this one is assembled from a `node`
@@ -857,51 +689,6 @@ Both are click-copyable. Truncating to 4 decimals in summary views is acceptable
for scannability, but the detail view must never discard precision — it is the
one place the user can always use to verify exact details.
**Specific Exception — nonzero floor on the approval screens:** A nonzero amount
must never render as zero. Truncating to 4 decimals does exactly that to an
amount below 0.0001 — 1 base unit of an 18-decimal token, 500 base units of an
8-decimal one — and on the dApp approval screen and the wait/success/error
screens that carry its amount forward, a real transfer or allowance then reads
as "nothing is being moved". A swap's `Min. received` is the sharper case: a
slippage floor shown as `0.0000` states that the swap may return nothing.
On those screens, when the truncated string would contain no digit from 1 to 9
and the value does, the amount is extended to its first significant digit
instead: `0.000000000000000001 DAI`, not `0.0000 DAI`. The test is on the whole
truncated string, integer part included, so `1.00005` still shows as `1.0000`
the exception only fires where the entire displayed figure would read as zero. A
genuine zero still renders `0.0000`, and truncation stays truncation: `0.99999`
shows as `0.9999`, never rounded up.
The rule and its exception live in `src/shared/amountDisplay.js` as
`truncateAmount()` and `truncateAmountNeverZero()`. Everything the approval and
confirmation screens display goes through the floored one — the ERC-20 amount,
the ETH value and max fee (`src/popup/views/approval.js`), and the swap's
`Amount` and `Min. received` lines (`src/shared/uniswap.js`). The history and
balance lists (`src/shared/transactions.js`) use the unfloored one: the
transaction detail view is the authoritative record and already shows exact
precision. The 4-decimal rule is unchanged everywhere else, including for
amounts at or above the floor on the approval screens.
The floor applies only where the token's scale is known. Where it is not, the
approval screen states base units instead of a quantity — see Unknown token
scale below — and no truncation happens at all.
**Specific Exception — unknown token scale:** Calldata carries base units and no
scale, so every amount on the dApp approval screen needs the token's `decimals`.
It is resolved from the bundled token list, then from the tokens the user
tracks, then from what the block explorer reported for the contract
(`resolveTokenDecimals()` in `src/shared/approvalAmount.js`). Where none of them
answers, the amount is not formatted: the line reads
`5000000000 base units (decimals unknown)`. A guessed scale is not an
approximation but a different number — 1,000 units of a 6-decimal token
formatted at 18 decimals reads `0.000000001` — on the screen whose only job is
to state what is being authorized. Both amount paths of that screen take this
rule: the ERC-20 `transfer`/`approve` line (`src/popup/views/approval.js`) and
the swap's `Amount` and `Min. received` lines (`src/shared/uniswap.js`). An
unbounded allowance or permit needs no scale to describe and is still shown as
`Unlimited`.
#### Partial USD totals
Prices are fetched for the top 25 tokens only, so an address can hold assets the

171
TODO.md
View File

@@ -26,8 +26,7 @@ milestone is in flight on `next`; its `next` -> `main` PR is
[#190](https://git.eeqj.de/sneak/AutistMask/pulls/190). `make check` verified
green on `next` at `e9fa8be` on 2026-08-10, and `make build` produces
`dist/chrome/` and `dist/firefox/`, verified against the build's own receipt to
hold exactly the regular files and symlinks that build emitted, with `DEBUG`
compiled off.
be exactly what that build emitted with `DEBUG` compiled off.
The backlog lives on the
[Gitea tracker](https://git.eeqj.de/sneak/AutistMask/issues), which is
@@ -45,174 +44,6 @@ but the review is broader than any of them.
# Completed Steps
- 2026-08-23: The background no longer reads or writes the shared `state`
singleton ([#324](https://git.eeqj.de/sneak/AutistMask/issues/324)), which
also closes the cold-worker wrong-chain send
([#320](https://git.eeqj.de/sneak/AutistMask/issues/320)). One in-memory copy
loaded once is the popup's lifetime, not the MV3 worker's: the worker is
killed when idle, nothing loaded state at module scope, and an unpopulated
read was answered out of `DEFAULT_STATE` in silence. Five defects traced to
that, and every point fix added a `loadState()` that created the next one — a
load detaches the objects an in-flight handler is holding. The background now
has its own storage layer (`src/background/state.js`): `getState()` for a
detached per-call read, `updateState()` for a queued read-modify-write.
`backgroundRefresh()` refreshes a private copy and applies the balances that
came back by address, so a wallet added, renamed or deleted during the round
trip survives. The transaction attempt takes its chain id and its endpoint
from one snapshot, so a committed chain switch can no longer move the endpoint
under an artifact already verified against the old chain. `getProvider()` now
REQUIRES the network id, which is what closes
[#320](https://git.eeqj.de/sneak/AutistMask/issues/320) at the shape rather
than at the call site. The prohibition is enforced by `build.js`, which fails
the build when esbuild's own metafile reports `src/shared/state.js` as an
input of either background bundle — the resolution the shipped bundle was
actually built from, so no specifier syntax and no resolution rule can slip
past it, and `make build` runs in CI. A bundled entry point under
`src/background/` with no line in the table fails the build too, so a second
worker entry point is protected by default rather than only if whoever adds it
knows the table exists. The assertion itself is pinned by
`tests/buildForbiddenInputs.test.js`, including every way its table can rot: a
key no bundled entry point matched, a forbidden module this build bundled
nowhere, and an entry that lists no modules (which would otherwise empty the
lint rule's forbidden set as well, and is refused at require time). Its bound
is that it is keyed by path, so a COPY of the singleton at another path is
outside it — loud for three of the five defects and silent for the other two;
the bounds are recorded in full where the table lives
(`script/lib/forbiddenBundleInputs.js`). An ESLint rule that walks the require
graph textually gives the same answer in the editor, before a full bundle; it
reads the same table, and it is fast feedback rather than the guarantee. The
shapes it catches are pinned by `tests/backgroundStateLintRule.test.js`, and
so are the two it misses — a computed specifier and a symlink — as asserted
non-reports, which the build fails on. Reading an unloaded singleton now
throws `StateNotLoadedError` instead of serving defaults. The
`chrome.storage.local` stubs in eight test files aliased instead of
structured-cloning, which could let an assertion pass on a build that never
wrote anything; every test that drives real persistence now goes through
`tests/support/storageStub.js`.
- 2026-08-23: A swap always names its output token
([#346](https://git.eeqj.de/sneak/AutistMask/issues/346)). The `Token Out`
detail line in `src/shared/uniswap.js` was pushed only when a symbol was
known, so a swap whose output token is absent from the bundled list — every
newly listed token — showed a `Min. received` figure with nothing saying what
was being received. The line is now keyed on the token's address and falls
back to it when there is no symbol, exactly as the `Token In` line already
did. It composes with the unknown-scale refusal from
[#340](https://git.eeqj.de/sneak/AutistMask/issues/340): the address says
which token, the base-unit figure says how much and states that the scale is
unknown.
- 2026-08-23: The swap approval screen no longer guesses 18 decimals for a token
outside the bundled list
([#340](https://git.eeqj.de/sneak/AutistMask/issues/340)). `tokenInfo()` in
`src/shared/uniswap.js` returned `decimals: 18` for any such token — the same
assumption [#306](https://git.eeqj.de/sneak/AutistMask/issues/306) removed
from the ERC-20 amount line — so a 1,000-unit swap of a 6-decimal token was
stated as `0.000000001`, and every newly listed token reached it. The swap's
`Amount` and `Min. received` lines now resolve the scale through
`resolveTokenDecimals()`, the same bundled-list-then-tracked-then-explorer
order the ERC-20 line uses, and where nothing knows it they render
`unknownDecimalsAmount()` — base units with the scale stated — instead of a
number. No new data source and no network call: the scale comes only from what
the wallet already holds. An unbounded permit is still shown as `Unlimited`.
`README.md` records the rule as a Display Consistency exception.
- 2026-08-23: A failed release build no longer leaves a loadable debug bundle in
`dist/` ([#333](https://git.eeqj.de/sneak/AutistMask/issues/333)). With
`AUTISTMASK_DEBUG=1` exported, `make build` compiled a debug bundle and failed
on it in `script/verify-build` — but the bundle stayed on disk, loadable, with
every wallet it creates using the publicly committed test recovery phrase.
Every step of `make build` now runs through `script/discard-dist-on-failure`,
which removes `dist/` when a step fails and says on stderr that it did and
why; a removal it cannot complete is reported just as loudly.
`make build-debug` is deliberately not wrapped: its output is not mistakable
for a release build and is the evidence of the failure.
`script/test-verify-build` asserts the state of `dist/` on disk after a
failing and a succeeding step, not just the exit status, and reads `make -n`
to check the wrapper is on the release path and only there.
- 2026-08-23: `README.md` and `script/verify-build`'s own comments now state the
emitted-tree guarantee at the width the code actually enforces
([#331](https://git.eeqj.de/sneak/AutistMask/issues/331)). The tree walk is
`-type f -o -type l`, so the guarantee covers regular files and symlinks under
`dist/`; fifos, sockets, device nodes and empty directories are not checked,
because a build emits none of them, none can carry a shippable payload, and
`grep` on a fifo would hang rather than fail. The exclusion is deliberate and
unchanged — the README said "nothing under `dist/` that the build did not
write", which was broader than that. Documentation only; no executable line
changed.
- 2026-08-23: An amount below the 4-decimal display floor no longer reads as
zero on the approval screens
([#322](https://git.eeqj.de/sneak/AutistMask/issues/322)). With the token's
true scale resolved, the 4-decimal truncation still printed a small amount as
`0.0000` — 1 base unit of an 18-decimal token, 500 of an 8-decimal one — so a
real transfer, allowance or swap was stated as nothing on the one screen whose
job is to say what is being authorized, and a swap's `Min. received` claimed
the user might receive nothing. Three copies of that truncation existed; they
now share `src/shared/amountDisplay.js`. Everything the approval and
confirmation screens render (`src/popup/views/approval.js`,
`src/shared/uniswap.js`) extends to the first significant digit when the
truncated figure would otherwise read as zero, keeping the amount in token
units rather than switching to base units mid-line. The history and balance
lists (`src/shared/transactions.js`) keep the unfloored rule, which is out of
scope by the issue's definition of done. `README.md`'s Display Consistency
section records the exception.
- 2026-08-23: The extension can be installed once and kept
([#310](https://git.eeqj.de/sneak/AutistMask/issues/310)). There was no
packaging target anywhere, no artifact, and `manifest/chrome.json` carried no
`key` — so an unpacked Chrome load derived its extension id, and therefore its
`chrome.storage.local` partition, from the absolute checkout path: moving or
re-cloning the checkout presented an empty wallet with no error. The manifest
now carries a fixed `key` (public half only; the private half is a credential
and is not in this repo, and no target generates one into the tree), pinning
the id to `gipbhkogfopeahplcjhipkgpcimdpkip`. `make package` produces
`release/autistmask-chrome-<version>.zip` and
`release/autistmask-firefox-<version>.xpi` plus `SHA256SUMS`,
deterministically and via `make build` so the archives can only be made from a
`dist/` already verified against that build's receipt as a release build;
every path the manifests and the popup HTML reference is resolved and required
to be inside the archive, and the archive is read back off disk and compared
member by member — `dist/styles.css` sits at the `dist/` root outside both
browser directories and is reported as deliberately not shipped rather than
dropped by a glob. One version: `script/lib/version.js` fails the build when
`package.json` and the two manifests disagree, instead of reading from one of
them. `BUILD_COMMIT` now carries `-dirty` when the working tree does not match
`HEAD` (and `-unknown` when git cannot say), while the full hash behind the
About screen's commit link stays clean so the link still resolves. Two real
browser observations back it: `tests/e2e/storagePartition.js` loads the
extension from two different paths in one Chrome profile with and without
`key` and records what each does, and `tests/e2e/firefox/reinstall.js`
installs the packaged XPI in a real Firefox, creates a wallet, restarts the
browser on the same profile, adds the add-on again and decrypts the vault back
to the original recovery phrase — and then observes that an explicit uninstall
DESTROYS that storage, which is correct browser behaviour but means Remove is
irreversible for a wallet, now stated in README.md. Deliberately not done: AMO
signing, CRX packing, tagging and any upload — the Firefox artifact is
UNSIGNED and README.md now states that release Firefox and ESR refuse it, that
Developer Edition or an Unbranded build is required, and that a temporary
add-on does not survive a browser restart.
- 2026-08-20: A second extension page can no longer silently delete a wallet
([#304](https://git.eeqj.de/sneak/AutistMask/issues/304)). `saveState()` wrote
the entire state blob, and every extension page — the toolbar popup, a dApp
approval window, `backgroundRefresh()` — holds its own in-memory `state`,
loaded once, with `showView()` saving on every navigation; a second page that
saved after a first had written something new overwrote it, no attacker or
unusual input required. `saveState()` is now a read-modify-write: it re-reads
storage, diffs the persisted fields against a deep-cloned `baseline` snapshot
taken at the last `loadState()`/`saveState()` on that page, and writes only
the fields that actually changed — everything else is carried forward from
storage in its loaded-and-normalized shape (`normalizePersisted()`, shared
with `loadState()`), so a legacy or malformed record a load has always
self-healed in memory keeps getting written back even on a save that touched
something else entirely. `showView()` fires `saveState()` on every navigation
without awaiting it, so two saves from the same page can be in flight at once;
a FIFO queue serializes them rather than letting a slow one finish after a
later one and re-derive a stale answer. Deliberately not done: the live
`state` of a field this page does not own is not rehydrated from what another
page wrote, only the persisted record is — adopting a concurrently-written
value into `state` reintroduced the same clobber one page later, caught by
`tests/txStatus.test.js` red. Two writers of the same field still resolve
last-writer-wins, documented at the merge point. `tests/stateMerge.test.js`
covers the two-page save and the approval-window reproduction from the issue —
add a wallet in one page, force a save from a second page loaded before it,
both wallets survive — each demonstrated failing against the unfixed full-blob
write.
- 2026-08-20: A forgotten password no longer wedges the wallet
([#312](https://git.eeqj.de/sneak/AutistMask/issues/312)). Deleting a wallet
was password-gated and importing its recovery phrase again was refused as a

277
build.js
View File

@@ -3,12 +3,6 @@ const path = require("path");
const crypto = require("crypto");
const { execSync } = require("child_process");
const esbuild = require("esbuild");
const { resolveVersion } = require("./script/lib/version");
const {
BACKGROUND_ENTRY_PREFIX,
FORBIDDEN_INPUTS,
assertTableWellFormed,
} = require("./script/lib/forbiddenBundleInputs");
const DIST = path.join(__dirname, "dist");
const DIST_CHROME = path.join(DIST, "chrome");
@@ -21,18 +15,6 @@ const SRC = path.join(__dirname, "src");
// rotting with it.
const AUDITED_MODULE = "src/shared/constants.js";
// FORBIDDEN_INPUTS — what each entry point's bundle may not contain, and what
// that covers — lives in script/lib/forbiddenBundleInputs.js, because the
// ESLint rule reads the same table and two literal copies of a path drift.
//
// This is the authoritative check, and it is here rather than in the linter
// because it consults the resolution esbuild actually performed. Any specifier
// syntax, any hop, any resolution rule that puts the module in the bundle fails
// the build, whether or not a text matcher would have recognized it. A
// background entry point the table does not name fails as well, so a second
// worker is protected by default rather than by someone remembering this file.
// Dockerfile:42 runs `make build`, so it is enforced in CI.
// The build receipt: every file this build emits, with its sha256 and whether
// it is one of the audited bundles. script/verify-build is handed this and
// checks dist/ against it, so the file list comes from the build that just ran
@@ -82,164 +64,6 @@ function outputsContainingAuditedModule(metafile) {
.map(([outFile]) => repoRelative(outFile));
}
// Shortest import chain from `entryInput` to `target` through the metafile's
// own input graph, or null when there is none. The message this feeds is the
// point of the check: "state.js is in the worker bundle" is not actionable on
// its own, "index.js -> chainSwitchFields.js -> state.js" is.
function importChain(metafile, entryInput, target) {
const graph = new Map(
Object.entries(metafile.inputs).map(([input, info]) => [
repoRelative(input),
(info.imports || []).map((i) => repoRelative(i.path)),
]),
);
const start = repoRelative(entryInput);
const seen = new Set([start]);
const queue = [[start]];
while (queue.length > 0) {
const chain = queue.shift();
for (const next of graph.get(chain[chain.length - 1]) || []) {
if (next === target) return chain.concat([next]);
if (seen.has(next)) continue;
seen.add(next);
queue.push(chain.concat([next]));
}
}
return null;
}
// What the forbidden-input checks accumulate over a whole build: which
// FORBIDDEN_INPUTS keys were actually bundled, and every input of every output
// this build emitted. Both are read by assertForbiddenTableCovered() at the
// end — a table entry naming something that is not there any more enforces
// nothing, and must fail rather than pass quietly.
function newForbiddenRecord() {
return { entriesChecked: new Set(), bundledInputs: new Set() };
}
// Note every input of every output of one esbuild run. Deliberately not
// restricted to the entry points named in FORBIDDEN_INPUTS: it is the POPUP
// that legitimately bundles src/shared/state.js, and that is what makes
// "the forbidden module still exists at this path" checkable at all.
function recordBundledInputs(metafile, record) {
for (const info of Object.values(metafile.outputs)) {
for (const input of Object.keys(info.inputs)) {
record.bundledInputs.add(repoRelative(input));
}
}
}
// Fail the build when an entry point's bundle contains a module it is
// prohibited from reaching. The inputs come from esbuild's metafile, so this is
// the resolution the shipped bundle was built from and not a guess at it.
//
// A background entry point with no line in the table fails here too. The five
// defects this exists to prevent were accidents, and so is adding a second
// worker entry point without knowing that a table somewhere needs a line: the
// protection has to be the default for that directory rather than something
// the next author must opt into.
function assertNoForbiddenInputs(
entryPoint,
outfile,
metafile,
record,
table = FORBIDDEN_INPUTS,
) {
const entry = repoRelative(entryPoint);
const forbidden = table[entry];
if (!forbidden) {
if (!entry.startsWith(BACKGROUND_ENTRY_PREFIX)) return;
throw new Error(
`${entry} is a background entry point with no line in ` +
`FORBIDDEN_INPUTS, so nothing stops its bundle from ` +
`containing the shared state singleton. Add it to ` +
`script/lib/forbiddenBundleInputs.js. The MV3 worker never ` +
`populates that singleton, so reading it serves ` +
`DEFAULT_STATE; use getState()/updateState() from ` +
`src/background/state.js instead.`,
);
}
const out = repoRelative(outfile);
const entryOutput = Object.entries(metafile.outputs).find(
([outFile]) => repoRelative(outFile) === out,
);
if (!entryOutput) {
throw new Error(`esbuild reported no metafile output for ${out}`);
}
const inputs = new Set(
Object.keys(entryOutput[1].inputs).map(repoRelative),
);
// Recorded only once the bundle's inputs are actually in hand. Marking the
// entry checked any earlier — as this did — means an early return above
// satisfies assertForbiddenTableCovered() with a bundle nobody examined,
// and the coverage half cannot tell that from a real check. The lookup
// above is the fragile step: repoRelative() resolves against process.cwd()
// while esbuild's output keys are cwd-relative, so a change to where the
// build runs from could miss.
record.entriesChecked.add(entry);
for (const module of forbidden) {
if (!inputs.has(module)) continue;
const chain = importChain(metafile, entryPoint, module);
throw new Error(
`${out} bundles ${module}, which ${entry} must not reach` +
`${chain ? `: ${chain.join(" -> ")}` : ""}. The MV3 worker ` +
`never populates the shared state singleton, so reading it ` +
`serves DEFAULT_STATE. Use getState()/updateState() from ` +
`src/background/state.js instead.`,
);
}
}
// Fail the build when the table has rotted away from the tree it describes.
// Both halves of an entry rot independently, and either one turns the whole
// prohibition into a pass that checks nothing:
//
// - the KEY, when no bundled entry point matches it: the entry point was
// renamed or is no longer built, and no bundle was ever tested against the
// list;
// - the MODULE, when this build bundled it nowhere: the module was renamed,
// moved or deleted, so "is it an input of the background bundle" is asked
// about a path nothing resolves to and is answered no forever. The popup
// legitimately bundles src/shared/state.js, which is what makes this
// checkable — and it is stronger than an existsSync(), because it also
// fails when the file is still there but has dropped out of every bundle.
//
// This matters concretely: https://git.eeqj.de/sneak/AutistMask/issues/311
// rewrites this persistence layer, and a rename that quietly disarmed the
// guarantee would put the singleton back within reach of the worker with every
// check in the repo still green.
//
// The third way — an entry that lists no modules at all — is refused where the
// table is defined, at require time, because that one also empties the ESLint
// rule's forbidden set and so has to fail before either layer runs. It is
// re-checked here so the build's own half does not depend on the table having
// been loaded from that file.
function assertForbiddenTableCovered(record, table = FORBIDDEN_INPUTS) {
assertTableWellFormed(table);
for (const [entry, modules] of Object.entries(table)) {
if (!record.entriesChecked.has(entry)) {
throw new Error(
`${entry} is listed in FORBIDDEN_INPUTS but was not bundled, ` +
`so nothing checked it`,
);
}
for (const module of modules) {
if (record.bundledInputs.has(module)) continue;
throw new Error(
`${module} is listed in FORBIDDEN_INPUTS for ${entry}, but ` +
`this build bundled it nowhere, so the prohibition names ` +
`a module that is not in this tree at that path and ` +
`nothing enforces it. If the module moved, move it in ` +
`script/lib/forbiddenBundleInputs.js too, which both ` +
`this check and the ESLint rule read.`,
);
}
}
}
// Every file this build writes under dist/, recorded as it is written. This is
// the build's own account of what it emitted; it is never recovered by
// listing dist/, because a file that is in dist/ without this build having put
@@ -336,53 +160,28 @@ function isDebugBuild() {
return process.env.AUTISTMASK_DEBUG === "1";
}
// A short git output, or null when git cannot answer. Distinguishing "git said
// nothing" from "git could not be asked" matters below: a working tree whose
// state is unknown must not be stamped as clean.
function git(args) {
try {
return execSync(`git ${args}`, {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
} catch {
// not a git repo, or git not available
return null;
}
}
// The working-tree state, as a suffix for the displayed commit: "" when the
// tree matches HEAD, "-dirty" when it does not, "-unknown" when git answered
// the hash but not the status. Without this a build from a modified tree
// stamped a clean hash, so the About screen named a commit whose contents were
// not what was running — the one thing that stamp exists to establish.
//
// git status --porcelain honours .gitignore, so dist/ and node_modules/ do not
// make every build dirty; an untracked file that is NOT ignored does, and
// correctly: it may well be in the bundle.
function worktreeSuffix() {
const status = git("status --porcelain");
if (status === null) return "-unknown";
return status === "" ? "" : "-dirty";
}
function getBuildInfo() {
const pkg = JSON.parse(
fs.readFileSync(path.join(__dirname, "package.json"), "utf8"),
);
const commitHashFull = git("rev-parse HEAD") || "unknown";
const shortHash = git("rev-parse --short HEAD") || "unknown";
// The full hash is left clean because it is the href of the commit link in
// the About screen, and "abc123-dirty" is not a commit anyone can fetch.
// The displayed short hash carries the marker, so the screen says the tree
// was modified while still linking somewhere real.
const commitHash =
shortHash === "unknown" ? shortHash : shortHash + worktreeSuffix();
let commitHash = "unknown";
try {
commitHash = execSync("git rev-parse --short HEAD", {
encoding: "utf8",
}).trim();
} catch {
// not a git repo or git not available
}
let commitHashFull = "unknown";
try {
commitHashFull = execSync("git rev-parse HEAD", {
encoding: "utf8",
}).trim();
} catch {
// not a git repo or git not available
}
return {
// Fails the build when package.json and the two manifests disagree;
// see script/lib/version.js. Called before anything is emitted, so a
// tree with no single version never reaches dist/.
version: resolveVersion(__dirname),
version: pkg.version,
license: pkg.license,
author: pkg.author,
commitHash,
@@ -427,9 +226,6 @@ async function build() {
// esbuild run below and recorded in the receipt for script/verify-build.
const auditedBundles = [];
// What the forbidden-input checks accumulate across those same runs.
const forbiddenRecord = newForbiddenRecord();
// compile tailwind CSS
console.log("Compiling Tailwind CSS...");
const tailwindInput = path.join(SRC, "popup", "styles", "main.css");
@@ -471,15 +267,6 @@ async function build() {
metafile: true,
define,
});
// Before the output is recorded as emitted: a bundle that violates a
// prohibition must abort the build, not be written into a receipt.
recordBundledInputs(result.metafile, forbiddenRecord);
assertNoForbiddenInputs(
entryPoint,
outfile,
result.metafile,
forbiddenRecord,
);
recordEmitted(outfile);
auditedBundles.push(...outputsContainingAuditedModule(result.metafile));
}
@@ -536,8 +323,6 @@ async function build() {
path.join(DIST_FIREFOX, "manifest.json"),
);
assertForbiddenTableCovered(forbiddenRecord);
// Written last so a build that died partway through leaves no receipt at
// all, which script/verify-build treats as a hard failure rather than as
// "nothing to check".
@@ -548,27 +333,7 @@ async function build() {
console.log("Build complete: dist/chrome/ and dist/firefox/");
}
// Run only as a program. Required as a module — which is how
// tests/buildForbiddenInputs.test.js reaches the checks below — this file
// builds nothing and writes nothing.
if (require.main === module) {
build().catch((err) => {
console.error(
`Build failed: ${err && err.message ? err.message : err}`,
);
process.exit(1);
});
}
// Exported for tests/buildForbiddenInputs.test.js only. The prohibition these
// three functions enforce is the guarantee behind
// https://git.eeqj.de/sneak/AutistMask/issues/324, and `make check` does not
// run `make build` — so they are unit tested against synthetic metafiles
// rather than being exercised only by CI, where "it ran" is not "it works".
module.exports = {
importChain,
newForbiddenRecord,
recordBundledInputs,
assertNoForbiddenInputs,
assertForbiddenTableCovered,
};
build().catch((err) => {
console.error(`Build failed: ${err && err.message ? err.message : err}`);
process.exit(1);
});

View File

@@ -8,10 +8,6 @@
const js = require("@eslint/js");
const globals = require("globals");
const backgroundState = require("./script/lib/eslint/noStateSingletonInBackground");
const {
BACKGROUND_ENTRY_PREFIX,
} = require("./script/lib/forbiddenBundleInputs");
// The extension APIs. MV3 Chrome exposes `chrome`; Firefox exposes both, and
// the code feature-detects between them.
@@ -82,36 +78,12 @@ module.exports = [
},
// MV3 background: a service worker, with no window and no document.
//
// It also may not reach src/shared/state.js. That module's `state` export
// is a per-bundle singleton loaded once and mutated in place, which is the
// popup's lifetime and not the worker's: the worker is killed when idle,
// nothing loads state at module scope, and an unpopulated read used to be
// served DEFAULT_STATE silently. Five defects came from background code
// reading or writing it (https://git.eeqj.de/sneak/AutistMask/issues/324),
// and each point fix added a loadState() that created the next one. The
// rule below checks reachability through the whole require graph, not just
// the direct require, because a re-export from any shared module the
// background already pulls in would put the singleton back in the bundle
// with no background file naming it.
//
// It is not the guarantee: build.js asserts the same prohibition against
// esbuild's own metafile, from the shared table in
// script/lib/forbiddenBundleInputs.js. This is the early report.
//
// The glob comes from that same file, because build.js uses the prefix to
// decide which entry points must be listed in the table at all: the two
// layers must not disagree about which files are "the background".
{
files: [`${BACKGROUND_ENTRY_PREFIX}**/*.js`],
plugins: { background: backgroundState },
files: ["src/background/**/*.js"],
languageOptions: {
...commonjs,
globals: { ...globals.serviceworker, ...extensionGlobals },
},
rules: {
"background/no-state-singleton-in-background": "error",
},
},
// src/shared is bundled into both, so it may only use what both provide:
@@ -135,9 +107,9 @@ module.exports = [
},
},
// Unit tests, and the helpers they require: jest on node.
// Unit tests: jest on node.
{
files: ["tests/**/*.test.js", "tests/support/**/*.js"],
files: ["tests/**/*.test.js"],
languageOptions: {
...commonjs,
globals: { ...globals.node, ...globals.jest },

View File

@@ -3,7 +3,6 @@
"name": "AutistMask",
"version": "0.1.0",
"description": "Minimal Ethereum wallet for Chrome",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzy/G9gT4Z3Ci0HCmthUPEiCjENg+5meZpjdogyT7SiMfxENtHdrpDL6wGhAg1Dk0f1C67Ft8OYpMrMH3kiP2Wnt0UpHo45PY0YUUYzdJgbsp8u0kaykd5FFiY6FycIIFaTniMuh7wRKuNNdJWly+H3aG7qZ6nGu5PIMdb1GXUk35hY+yl7dz5dqFFYUCyxvWCT9XGBSYiI+XRBB/rVZjMWfWpaTmRPdOZ4+GO/Lx0OdMxKlPA/kLWoPot5vMlLn2FDPu6sASphiu7dKZnrINW+h/27jlHMJQS0jncB1EgqOHW0vbXrZnTveFX6UW+Qp86FfSkikhKtQgTW2A4mtWawIDAQAB",
"permissions": ["storage", "activeTab", "alarms"],
"host_permissions": ["<all_urls>"],
"content_security_policy": {

View File

@@ -1,78 +0,0 @@
#!/bin/sh
# script/discard-dist-on-failure: run one step of the RELEASE build, and if that
# step fails, remove dist/ before returning its exit status. Our own extension
# to scripts-to-rule-them-all, wrapped around every step of make build.
#
# Why: with AUTISTMASK_DEBUG=1 exported in the calling shell, make build
# compiles a debug bundle and then fails on it in script/verify-build — but the
# bundle is already written. It is loadable, and every wallet it creates gets
# the publicly committed test recovery phrase from src/shared/constants.js. A
# failed release build that leaves that behind is a smaller version of the trap
# the verifier exists to close, and "the failure was loud" only works on an
# operator who does not load dist/chrome/ anyway. Removing the artifact does not
# depend on that.
#
# Two things this deliberately does not do. It does not wrap make build-debug: a
# debug build that failed is not a mistakable artifact, and its output is the
# evidence of what went wrong. And it never removes anything on a step that
# SUCCEEDS, including the final check-censored --require-dist pass.
#
# The removal is never silent: it says dist/ is gone and why, on stderr, above
# the build's own failure.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
DIST="$ROOT/dist"
usage() {
echo "usage: discard-dist-on-failure COMMAND [ARG...]" >&2
}
# Remove dist/, and say so. A removal that could not be completed is reported as
# loudly as one that was: the artifact is still on disk, and reporting nothing
# would leave the operator believing it is not.
discard_dist() {
if [ ! -e "$DIST" ] && [ ! -h "$DIST" ]; then
echo "discard-dist-on-failure: the release build failed. There was no" \
"dist/ to remove." >&2
return 0
fi
rm -rf "$DIST" || true
if [ -e "$DIST" ] || [ -h "$DIST" ]; then
echo "discard-dist-on-failure: the release build failed and dist/" \
"COULD NOT BE REMOVED, so it is still on disk. Do not load it:" \
"a release build that failed may hold a complete debug bundle," \
"whose wallets all use the publicly committed test recovery" \
"phrase. Remove it by hand (make clean)." >&2
return 0
fi
echo "discard-dist-on-failure: the release build failed, so dist/ WAS" \
"REMOVED and no longer exists. A release build that fails has often" \
"already emitted a complete, loadable debug bundle — every wallet it" \
"creates gets the publicly committed test recovery phrase — so the" \
"failed build is not left behind to be loaded. Fix the failure and" \
"re-run make build, or run make build-debug if a debug build is what" \
"was wanted; that target keeps its output." >&2
}
main() {
[ "$#" -ge 1 ] || {
usage
echo "discard-dist-on-failure: no command given, so no build step ran" \
"and nothing was removed." >&2
exit 1
}
_status=0
"$@" || _status=$?
[ "$_status" -ne 0 ] || return 0
discard_dist
exit "$_status"
}
main "$@"

View File

@@ -1,206 +0,0 @@
// ESLint rule: the background bundle may not reach the shared state singleton.
//
// src/shared/state.js holds a module-level `state` object, loaded once by
// loadState() and mutated in place from then on. That is the popup's model. In
// the MV3 service worker there is no "once": the worker is terminated when
// idle and revived by the next message, nothing loads state at module scope,
// and an unpopulated read used to be served DEFAULT_STATE without complaint —
// five defects, one cause
// (https://git.eeqj.de/sneak/AutistMask/issues/324). The background has its
// own per-call storage layer in src/background/state.js instead.
//
// THIS RULE IS NOT THE GUARANTEE, and must not be described as one. The
// guarantee is in build.js: FORBIDDEN_INPUTS / assertNoForbiddenInputs() fails
// the build when esbuild's own metafile reports src/shared/state.js as an input
// of a background bundle. That consults the resolution esbuild actually
// performed, so no specifier syntax and no resolution rule can slip past it,
// and Dockerfile:42 runs `make build` in CI.
//
// What this rule is: fast local feedback, in the editor and in `make lint`,
// before a full bundle. It reads sources from disk and matches import
// specifiers TEXTUALLY, so it is a best-effort approximation of module
// resolution — a hand-rolled matcher will diverge from a real bundler, and two
// earlier revisions of this file proved it by shipping holes (a template
// literal, a dynamic `import()`, a comment inside the call, a directory
// resolved through `package.json` `main`). Those are all covered now, and the
// next divergence is caught by the build rather than by widening this again.
//
// It checks REACHABILITY, not just the direct require: the singleton is one
// `require()` away from any shared module the background pulls in, and a
// re-export would put it back in the bundle without any background file naming
// it. So each background file is the root of a walk over the CommonJS require
// graph, and the error names the whole chain that brought the singleton in.
//
// Matching textually over-approximates — a specifier inside a comment or a
// string counts — which is the safe direction here: the failure mode is a
// spurious error naming an exact file and line, not a silent hole.
//
// Two shapes this rule does NOT report, both of which the build does fail on
// (each measured with `make lint` and `make build` on the branch that added
// this note):
//
// - a computed specifier, `require("../shared/" + "state")` — esbuild
// constant-folds it, so it is in the bundle and `make build` is exit 2
// naming src/shared/state.js, while `make lint` is exit 0. Same for
// `import("../shared/" + variable)`, which esbuild resolves as a glob.
// - a symlink to the module — esbuild reports the real path and fails the
// build; this rule resolves the link's own path and sees a different file.
//
// Both are pinned as non-reports in tests/backgroundStateLintRule.test.js, so
// this list is a measured description of the rule rather than a claim about
// it. They are known divergences, not things that cannot happen. A
// matcher will keep diverging from a bundler; that is why the guarantee is the
// build's and this rule is not widened again to chase them.
const fs = require("fs");
const path = require("path");
const { FORBIDDEN_INPUTS } = require("../forbiddenBundleInputs");
// The modules to keep out, repo-relative, taken from the same table build.js
// asserts against so that the two layers cannot name different paths. A second
// literal copy here is how a rename disarms one of them while the other still
// looks enforced.
const FORBIDDEN = [...new Set(Object.values(FORBIDDEN_INPUTS).flat())];
// Whatever may sit between a keyword, a paren and a specifier: whitespace and
// comments. `import(/* webpackChunkName: "x" */ "./x")` is a standard bundler
// idiom, and an inline `/* eslint-… */` is just as ordinary, so a matcher that
// allows only \s there is not strict, it is broken. Each alternative starts
// with a distinct character, so this cannot backtrack quadratically.
const GAP = "(?:\\s|/\\*[^]*?\\*/|//[^\\n]*)";
const SPECIFIER = "[\"'`]([^\"'`]+)[\"'`]";
// Both alternatives capture the specifier: call form first
// (`require(...)`/`import(...)`), then clause form (`from "x"`, and the bare
// side-effect `import "x"`). Nothing after the specifier is matched, so a
// trailing comment or a trailing comma cannot break the match either.
const SPECIFIER_RE = new RegExp(
`\\b(?:require|import)${GAP}*\\(${GAP}*${SPECIFIER}` +
`|\\b(?:from|import)${GAP}+${SPECIFIER}`,
"g",
);
// The `main` of a directory's package.json, as a specifier relative to that
// directory, or null. esbuild resolves a directory through it, so a walk that
// stops at `<dir>/index.js` reports a specifier it matched perfectly well as
// unresolvable.
function packageMain(dir) {
try {
const pkg = JSON.parse(
fs.readFileSync(path.join(dir, "package.json"), "utf8"),
);
return typeof pkg.main === "string" && pkg.main ? pkg.main : null;
} catch {
return null;
}
}
// Resolve a relative require to a file path, trying what node and esbuild would
// in the order they would: the path itself, then extensions, then the directory
// (its package.json `main`, then its index.js).
function resolveRelative(fromFile, spec) {
if (!spec.startsWith(".")) return null; // a package, not our tree
const base = path.resolve(path.dirname(fromFile), spec);
const main = packageMain(base);
for (const candidate of [
base,
base + ".js",
base + ".json",
...(main
? [path.resolve(base, main), path.resolve(base, main) + ".js"]
: []),
path.join(base, "index.js"),
]) {
try {
if (fs.statSync(candidate).isFile()) return candidate;
} catch {
// Not this candidate.
}
}
return null;
}
function requiresOf(file) {
let source;
try {
source = fs.readFileSync(file, "utf8");
} catch {
return [];
}
const out = [];
for (const match of source.matchAll(SPECIFIER_RE)) {
const resolved = resolveRelative(file, match[1] ?? match[2]);
if (resolved) out.push(resolved);
}
return out;
}
// Breadth-first from `entry`, returning the shortest chain of files that ends
// at one of the forbidden modules, or null when none is reachable.
function chainToForbidden(entry, forbidden) {
const seen = new Set([entry]);
const queue = [[entry]];
while (queue.length > 0) {
const chain = queue.shift();
for (const next of requiresOf(chain[chain.length - 1])) {
if (forbidden.has(next)) return chain.concat([next]);
if (seen.has(next)) continue;
seen.add(next);
queue.push(chain.concat([next]));
}
}
return null;
}
const rule = {
meta: {
type: "problem",
docs: {
description:
"the background bundle must not be able to reach the" +
" module-level state singleton in src/shared/state.js",
},
schema: [],
messages: {
reachable:
"The background must not reach the shared state singleton:" +
" {{chain}}. The MV3 worker never populates it, so reading it" +
" serves DEFAULT_STATE. Use getState()/updateState() from" +
" src/background/state.js instead.",
},
},
create(context) {
return {
"Program:exit"(node) {
const filename = context.filename;
// ESLint lints from the repo root, which is also where the
// forbidden paths are anchored.
const forbidden = new Set(
FORBIDDEN.map((module) =>
path.resolve(context.cwd, module),
),
);
const chain = chainToForbidden(
path.resolve(filename),
forbidden,
);
if (!chain) return;
context.report({
node,
messageId: "reachable",
data: {
chain: chain
.map((file) => path.relative(context.cwd, file))
.join(" -> "),
},
});
},
};
},
};
module.exports = {
rules: { "no-state-singleton-in-background": rule },
};

View File

@@ -1,123 +0,0 @@
// The modules a given entry point's bundle may not contain, keyed by the
// repo-relative entry point.
//
// ONE table, read by both layers that act on it: build.js asserts it against
// esbuild's own metafile (the guarantee), and
// script/lib/eslint/noStateSingletonInBackground.js reports the same
// prohibition in the editor (fast feedback). It lives here because a second
// literal copy of the path is exactly how a rename disarms one layer while the
// other still looks enforced.
//
// src/shared/state.js holds a module-level `state` object, loaded once by
// loadState() and mutated in place from then on. That is the popup's model:
// one page, one load at boot, one lifetime. The MV3 service worker has no
// "once" — it is killed when idle and revived by the next message, nothing
// loads state at module scope, and an unpopulated read was answered out of
// DEFAULT_STATE in silence. Five defects came from that, one of which
// destroyed a wallet (https://git.eeqj.de/sneak/AutistMask/issues/324). The
// background has its own per-call storage layer in src/background/state.js
// instead.
//
// What build.js's assertion covers, measured rather than assumed:
//
// - Any import of a listed module, at any hop, in any specifier syntax,
// however esbuild resolved it. The check reads the input list esbuild
// reported for the emitted bundle, so it is the resolution the shipped
// file was built from and not a model of it. Measured on a computed
// specifier that esbuild constant-folds (`require("../shared/" +
// "state")`), on a computed specifier it resolves as a glob
// (`import("../shared/" + variable)`), and on a symlink to the module
// (esbuild reports the real path): each is `make build` exit 2.
//
// - Every background entry point, whether or not anyone remembered to list
// it. A bundled entry point under BACKGROUND_ENTRY_PREFIX with no line in
// this table fails the build (assertNoForbiddenInputs()), so adding a
// second worker entry point is protected by default rather than protected
// only if the person adding it knew about this file. Measured: bundling
// src/background/worker2.js with no line here is `make build` exit 2.
//
// - NOT covered: a COPY of a listed module at another path. The table is
// keyed by path, so `cp src/shared/state.js src/shared/stateCopy.js` plus
// a background require of the copy is `make build` exit 0 and `make lint`
// exit 0 (measured). The copy carries the singleton's own guard, so
// defects 1-3 of https://git.eeqj.de/sneak/AutistMask/issues/324 — a read
// of a field nothing loaded — become a loud StateNotLoadedError instead of
// a silent DEFAULT_STATE. Defects 4 and 5 do NOT: a copy also carries
// loadState(), and a stale read several awaits after a load, or a load
// detaching the objects an in-flight handler is mutating, are silent over
// a LOADED singleton whether it is the original or a copy. So the residual
// is wider than "it fails loudly". A newly WRITTEN singleton has no
// backstop at all.
//
// - NOT covered: a background-behaving entry point outside
// BACKGROUND_ENTRY_PREFIX. The default protection above is keyed on that
// directory, which is also what eslint.config.js scopes the rule to, so a
// worker entry point placed somewhere else is covered by neither layer and
// needs its own line here.
//
// The ESLint rule's bounds are its own and are narrower: it matches specifiers
// textually, so a computed specifier and a symlink to a listed module are
// reported by the build and not by the rule. Both are pinned as non-reports in
// tests/backgroundStateLintRule.test.js and are `make build` exit 2 (measured).
// A second background entry point reached by one of those two shapes is
// therefore caught by the build and not by the rule — which is the same
// division of labour as everywhere else here, not an extra hole.
//
// Every way the table itself can rot is a failure rather than a quiet pass:
//
// - a KEY no bundled entry point matched, and a listed MODULE this build
// bundled nowhere: assertForbiddenTableCovered(), at the end of a build;
// - an entry that lists NO modules, and a table with no entries at all:
// assertTableWellFormed() below, at require time — so it fails the build
// and the lint run alike, because the rule reads the same values and an
// empty list leaves it with nothing to look for.
//
// All of it is pinned by tests/buildForbiddenInputs.test.js.
// What counts as a background entry point, and therefore must be listed above.
// The build has no other notion of one: entry points are the paths handed to
// bundle(), and this prefix is the narrowest rule that names the worker's
// directory. eslint.config.js scopes the lint rule with the same prefix, from
// this constant, so the two layers cannot disagree about what "background"
// means.
const BACKGROUND_ENTRY_PREFIX = "src/background/";
const FORBIDDEN_INPUTS = {
"src/background/index.js": ["src/shared/state.js"],
};
// Refuse a table that cannot prohibit anything. An entry whose module list is
// empty passes every check in both layers while enforcing nothing: the build
// finds no module to look for and records the entry as checked, and the rule's
// forbidden set — Object.values(...).flat() — comes back empty, so a plain
// `require("../shared/state")` in the worker is green everywhere. That is a
// one-character edit, so it fails here, where the table is defined and both
// layers must load it, rather than in either layer's own checks.
function assertTableWellFormed(table) {
const entries = Object.entries(table);
if (entries.length === 0) {
throw new Error(
"FORBIDDEN_INPUTS is empty, so nothing is prohibited anywhere. " +
"Removing the last entry disables the guarantee behind " +
"https://git.eeqj.de/sneak/AutistMask/issues/324.",
);
}
for (const [entry, modules] of entries) {
if (!Array.isArray(modules) || modules.length === 0) {
throw new Error(
`FORBIDDEN_INPUTS["${entry}"] lists no modules, so it ` +
`prohibits nothing while still looking enforced. Give it ` +
`the modules that entry point may not reach, or remove ` +
`the entry.`,
);
}
}
}
assertTableWellFormed(FORBIDDEN_INPUTS);
module.exports = {
BACKGROUND_ENTRY_PREFIX,
FORBIDDEN_INPUTS,
assertTableWellFormed,
};

View File

@@ -1,294 +0,0 @@
// Turn a verified dist/ into the two distributable archives.
//
// Invoked by script/package, which runs `make build` first so that dist/ has
// already been checked against the build's own receipt (see the Build Receipts
// section of README.md). This program does not build anything and does not
// write into dist/: it reads the emitted tree and writes release/.
//
// release/autistmask-chrome-<version>.zip loaded via chrome://extensions
// release/autistmask-firefox-<version>.xpi an UNSIGNED add-on, see README
// release/SHA256SUMS
//
// Self-containment is checked rather than assumed, because the layout invites
// exactly one mistake: build.js emits dist/styles.css at the dist/ ROOT,
// outside both browser directories, and copies it into each of them as
// src/popup/styles.css. A naive `zip -r dist/chrome` is therefore correct only
// by accident, and would stop being correct the moment a reference pointed up
// and out. So every path the manifest and the popup HTML reference is resolved
// and required to be inside the archive, a reference that escapes the browser
// directory is a hard failure, and anything sitting at the dist/ root is
// listed as deliberately not shipped rather than silently dropped.
//
// The archive is then read back and compared byte for byte against the
// directory it was built from. An archive nobody opened is a claim, not an
// artifact.
"use strict";
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
const { readZip, writeZip } = require("./zip");
const { resolveVersion } = require("./version");
const ROOT = path.resolve(__dirname, "..", "..");
const DIST = path.join(ROOT, "dist");
const RELEASE = path.join(ROOT, "release");
const TARGETS = [
{ dir: "chrome", ext: "zip" },
// .xpi rather than .zip: it is the same container, but Firefox's install
// flow keys off the extension.
{ dir: "firefox", ext: "xpi" },
];
// Strings in a manifest that name a file the extension loads. Matched by
// shape, not by a list of manifest keys, so a key added in a later manifest
// version is covered the day it appears rather than the day someone remembers
// to extend a list here. Nothing else in either manifest looks like this: the
// CSP strings, "<all_urls>", the version and the base64 key all fail it.
const MANIFEST_PATH_RE =
/^[A-Za-z0-9._][A-Za-z0-9._/-]*\.(?:js|css|html|json|png|svg|woff2?)$/;
// Local references out of an HTML document. Enough for what this repo emits —
// one stylesheet link and one script tag — and anything it does not understand
// is reported rather than passed over, see htmlReferences().
const HTML_REF_RE = /(?:src|href)\s*=\s*["']([^"']+)["']/gi;
function fail(message) {
throw new Error(message);
}
function sha256(buf) {
return crypto.createHash("sha256").update(buf).digest("hex");
}
// Every regular file under dir, as archive-root-relative forward-slashed
// paths. A symlink is refused rather than followed: build.js emits regular
// files only, so a link under dist/ is not something the build produced, and
// dereferencing one would put bytes from outside dist/ into the artifact.
function listFiles(dir, prefix = "") {
const out = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isSymbolicLink()) {
fail(
`${dir}/${entry.name} is a symlink. The build emits regular ` +
`files only, so this is not something it produced and it ` +
`will not be archived.`,
);
} else if (entry.isDirectory()) {
out.push(...listFiles(path.join(dir, entry.name), rel));
} else if (entry.isFile()) {
out.push(rel);
} else {
fail(
`${dir}/${entry.name} is neither a regular file nor a ` +
`directory, so it is not something the build emitted`,
);
}
}
return out.sort();
}
// Collect every string anywhere in the manifest that looks like a file it
// loads, plus every string that tries to reach outside the extension root.
// The second half is the point: "../styles.css" never matches
// MANIFEST_PATH_RE, so without an explicit check an escaping reference would
// read as "not a path" and the missing file would be found only by a user
// whose popup rendered unstyled.
function manifestReferences(value, found = new Set()) {
if (typeof value === "string") {
if (value.split("/").includes("..")) {
fail(
`the manifest references ${JSON.stringify(value)}, which ` +
`points outside the extension root. Everything the ` +
`browser loads has to be inside the archive; nothing ` +
`above it is shipped.`,
);
}
if (MANIFEST_PATH_RE.test(value)) found.add(value);
} else if (Array.isArray(value)) {
for (const v of value) manifestReferences(v, found);
} else if (value && typeof value === "object") {
for (const v of Object.values(value)) manifestReferences(v, found);
}
return found;
}
// Local references out of one HTML member, resolved against that member's own
// directory and returned archive-relative. Absolute URLs, data: URIs and
// in-page anchors are not files and are skipped; a relative reference that
// climbs out of the archive root is a failure for the same reason as above.
function htmlReferences(member, text) {
const base = path.posix.dirname(member);
const out = new Set();
for (const match of text.matchAll(HTML_REF_RE)) {
const ref = match[1].trim();
if (ref === "" || ref.startsWith("#") || ref.startsWith("//")) continue;
if (/^[a-z][a-z0-9+.-]*:/i.test(ref)) continue;
if (ref.startsWith("/")) {
fail(
`${member} references ${JSON.stringify(ref)} from the ` +
`extension root. Nothing here emits root-absolute ` +
`references and this packager does not resolve them.`,
);
}
const resolved = path.posix.normalize(path.posix.join(base, ref));
if (resolved.startsWith("..")) {
fail(
`${member} references ${JSON.stringify(ref)}, which resolves ` +
`outside the extension root. build.js copies the ` +
`compiled stylesheet into each browser directory for ` +
`exactly this reason: dist/styles.css lives at the dist/ ` +
`root and is not part of either archive.`,
);
}
out.add(resolved);
}
return out;
}
// Everything the browser is told to load, and the assertion that all of it is
// in the archive.
function checkSelfContained(target, members, read) {
if (!members.includes("manifest.json")) {
fail(`dist/${target} has no manifest.json at its root`);
}
const manifest = JSON.parse(read("manifest.json").toString("utf8"));
const referenced = new Set(manifestReferences(manifest));
for (const member of members) {
if (!member.endsWith(".html")) continue;
for (const ref of htmlReferences(
member,
read(member).toString("utf8"),
)) {
referenced.add(ref);
}
}
const missing = [...referenced].filter((r) => !members.includes(r));
if (missing.length > 0) {
fail(
`the ${target} archive would not be self-contained: it is told ` +
`to load ${missing.join(", ")}, which ${
missing.length === 1 ? "is" : "are"
} not in it`,
);
}
return { manifest, referenced };
}
function main() {
const version = resolveVersion(ROOT);
if (!fs.existsSync(DIST)) {
fail(
"there is no dist/ to package. script/package runs make build " +
"first; run it rather than this program.",
);
}
fs.rmSync(RELEASE, { recursive: true, force: true });
fs.mkdirSync(RELEASE, { recursive: true });
// Files the build emits at the dist/ root, outside both browser
// directories. Printed rather than ignored: dist/styles.css is the
// Tailwind output that build.js then copies into each browser directory,
// so leaving it out is correct — but "correct and stated" and "dropped by
// a glob" are different things, and only one of them survives the next
// change to the build.
const rootOnly = fs
.readdirSync(DIST, { withFileTypes: true })
.filter((e) => !e.isDirectory())
.map((e) => e.name)
.sort();
if (rootOnly.length > 0) {
console.log(
`Not shipped (dist/ root, outside every browser directory, and ` +
`referenced by nothing inside one): ${rootOnly.join(", ")}`,
);
}
const sums = [];
for (const { dir, ext } of TARGETS) {
const targetDir = path.join(DIST, dir);
if (!fs.existsSync(targetDir)) {
fail(`dist/${dir} does not exist; run make build`);
}
const members = listFiles(targetDir);
const readFromDir = (member) =>
fs.readFileSync(path.join(targetDir, member));
const { manifest } = checkSelfContained(dir, members, readFromDir);
if (manifest.version !== version) {
fail(
`dist/${dir}/manifest.json says version ${manifest.version} ` +
`but this tree is ${version}. dist/ is stale: run make ` +
`build.`,
);
}
const archive = writeZip(
members.map((name) => ({ name, data: readFromDir(name) })),
);
const name = `autistmask-${dir}-${version}.${ext}`;
const outPath = path.join(RELEASE, name);
fs.writeFileSync(outPath, archive);
// Read the artifact back off disk, not the buffer that was just
// written: what ships is the file.
const written = fs.readFileSync(outPath);
const entries = readZip(written);
const inArchive = entries.map((e) => e.name).sort();
if (inArchive.join("\n") !== members.join("\n")) {
fail(
`${name} does not hold the same members as dist/${dir}: ` +
`archive has ${inArchive.length}, directory has ` +
`${members.length}`,
);
}
for (const entry of entries) {
const onDisk = readFromDir(entry.name);
if (sha256(entry.data) !== sha256(onDisk)) {
fail(`${name} member ${entry.name} differs from dist/${dir}`);
}
}
// Re-run the self-containment check against the ARCHIVE's own
// contents. The directory passing it is not the claim being made.
const byName = new Map(entries.map((e) => [e.name, e.data]));
checkSelfContained(dir, inArchive, (m) => byName.get(m));
const digest = sha256(written);
sums.push(`${digest} ${name}`);
console.log(
`${name}: ${entries.length} member(s), ${written.length} bytes, ` +
`sha256 ${digest}`,
);
}
fs.writeFileSync(
path.join(RELEASE, "SHA256SUMS"),
sums.map((l) => `${l}\n`).join(""),
);
console.log(`Wrote release/ for version ${version}`);
}
// Only when run as a program. The reference-resolving helpers are what decide
// whether an archive is self-contained, so tests/packaging.test.js exercises
// them directly and must be able to require this file without packaging
// anything.
if (require.main === module) {
try {
main();
} catch (err) {
console.error(`package: ${err && err.message ? err.message : err}`);
process.exit(1);
}
}
module.exports = { checkSelfContained, htmlReferences, manifestReferences };

View File

@@ -1,74 +0,0 @@
// The version, and the rule that there is only one of it.
//
// Three files declare a version and none of them can be derived from another:
// Chrome and Firefox each need their own manifest, both are copied to dist/
// verbatim (tests/manifest.test.js asserts that what is in manifest/ is what
// ships), and package.json's copy is what build.js compiles into the About
// screen. So the single source of truth is enforced rather than generated —
// they must all agree or there is no version and no build.
//
// Reading one of the three and ignoring the rest is what this replaces. That
// shape cannot fail: it silently ships an extension whose About screen and
// whose browser-reported version disagree, and whose release artifact is named
// after whichever file the packager happened to read.
//
// Required by build.js, script/lib/package.js and tests/version.test.js, so
// the build, the release artifacts and make check all apply the same rule to
// the same files.
"use strict";
const fs = require("fs");
const path = require("path");
const VERSION_SOURCES = [
"package.json",
"manifest/chrome.json",
"manifest/firefox.json",
];
// Every declared version, in VERSION_SOURCES order, as { source, version }.
// A file that declares nothing usable fails here rather than being skipped:
// a missing version is not agreement.
function declaredVersions(root) {
return VERSION_SOURCES.map((source) => {
const file = path.join(root, source);
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(file, "utf8"));
} catch (e) {
throw new Error(
`${source} could not be read as JSON: ${e.message}`,
);
}
const version = parsed.version;
if (typeof version !== "string" || version.trim() === "") {
throw new Error(
`${source} declares no usable "version" (found ` +
`${JSON.stringify(version)}). Every artifact is named and ` +
`stamped with it, so there is nothing to build without it.`,
);
}
return { source, version };
});
}
// The one version all three declare, or a failure naming every disagreeing
// file and what it said.
function resolveVersion(root) {
const declared = declaredVersions(root);
const distinct = [...new Set(declared.map((d) => d.version))];
if (distinct.length !== 1) {
throw new Error(
"the declared versions disagree, so this tree has no version: " +
declared.map((d) => `${d.source}=${d.version}`).join(", ") +
". Set all of them to the same value: the manifests are what " +
"the browser reports and package.json is what the About " +
"screen shows, and a build that picked one of them would " +
"ship the disagreement.",
);
}
return distinct[0];
}
module.exports = { VERSION_SOURCES, declaredVersions, resolveVersion };

View File

@@ -1,274 +0,0 @@
// A minimal, deterministic ZIP writer and reader.
//
// Used by script/lib/package.js to build the distributable archives: a Chrome
// zip and a Firefox XPI are both ordinary zip files with manifest.json at the
// root, so one implementation covers both.
//
// Why this rather than a package or the zip(1) binary. A dependency would have
// to be hash-pinned like everything else in REPO_POLICIES.md, and this is
// about a hundred lines of stdlib zlib for a format the archives use two
// features of. The binary is worse: the release artifact would then depend on
// whichever Info-ZIP the machine happens to have, which is the same objection
// that keeps linting inside a container.
//
// Deterministic on purpose. Entries are sorted by name, every timestamp is the
// same fixed 1980-01-01 the format's epoch starts at, and the compression
// level is fixed, so building the same dist/ twice produces byte-identical
// archives and the sha256 in SHA256SUMS is a property of the input rather than
// of the clock. Two builds of the same commit that disagree are then visible
// instead of expected.
//
// Deliberately NOT implemented: zip64, encryption, data descriptors,
// directory entries (browsers infer directories from member paths), and
// anything to do with symlinks. writeZip refuses input it cannot represent
// rather than emitting an archive that is quietly wrong.
"use strict";
const zlib = require("zlib");
const LOCAL_SIG = 0x04034b50;
const CENTRAL_SIG = 0x02014b50;
const EOCD_SIG = 0x06054b50;
const METHOD_STORE = 0;
const METHOD_DEFLATE = 8;
// 1980-01-01 00:00:00, the earliest the MS-DOS timestamp fields can express.
const DOS_DATE = (0 << 9) | (1 << 5) | 1;
const DOS_TIME = 0;
// Unix regular file, mode 0644, in the high 16 bits, which is where the "made
// by unix" convention puts it.
// >>> 0 because JS shifts are signed 32-bit and this one sets the top bit.
const EXTERNAL_ATTRS = (0o100644 << 16) >>> 0;
const VERSION_MADE_BY = (3 << 8) | 20; // unix, needs zip 2.0
const VERSION_NEEDED = 20;
// Without zip64 every size and offset is a u32.
const MAX_U32 = 0xffffffff;
const CRC_TABLE = (() => {
const table = new Int32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let k = 0; k < 8; k++) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
}
table[i] = c;
}
return table;
})();
// Written out rather than taken from zlib.crc32, which only exists from node
// 22.2: this runs from script/ on whatever node the host has as well as inside
// the pinned image, and a checksum that silently is not there is worse than
// twelve lines.
function crc32(buf) {
let c = -1;
for (let i = 0; i < buf.length; i++) {
c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
}
return (c ^ -1) >>> 0;
}
// Member names are stored as raw bytes. Anything outside ASCII would need the
// UTF-8 flag and interoperability care that nothing this repo emits requires,
// so it is refused instead of guessed at.
function encodeName(name) {
if (typeof name !== "string" || name === "") {
throw new Error(`zip: unusable member name ${JSON.stringify(name)}`);
}
const segments = name.split("/");
if (
name.startsWith("/") ||
name.includes("\\") ||
segments.some((s) => s === "" || s === "." || s === "..")
) {
throw new Error(
`zip: refusing member name ${JSON.stringify(name)}: archive ` +
`members must be relative paths under the archive root`,
);
}
if (/[^\x20-\x7e]/.test(name)) {
throw new Error(
`zip: refusing non-ASCII member name ${JSON.stringify(name)}`,
);
}
return Buffer.from(name, "ascii");
}
function compress(data) {
if (data.length === 0) {
return { method: METHOD_STORE, body: data };
}
const deflated = zlib.deflateRawSync(data, { level: 9 });
if (deflated.length >= data.length) {
return { method: METHOD_STORE, body: data };
}
return { method: METHOD_DEFLATE, body: deflated };
}
// entries: [{ name, data }]. Returns the archive as a Buffer.
function writeZip(entries) {
if (!Array.isArray(entries) || entries.length === 0) {
throw new Error("zip: refusing to write an archive with no members");
}
const sorted = [...entries].sort((a, b) => (a.name < b.name ? -1 : 1));
const seen = new Set();
const locals = [];
const centrals = [];
let offset = 0;
for (const entry of sorted) {
const name = encodeName(entry.name);
if (seen.has(entry.name)) {
throw new Error(`zip: duplicate member ${entry.name}`);
}
seen.add(entry.name);
const data = Buffer.from(entry.data);
const { method, body } = compress(data);
if (data.length > MAX_U32 || body.length > MAX_U32) {
throw new Error(
`zip: ${entry.name} is too large for a non-zip64 archive`,
);
}
const crc = crc32(data);
const local = Buffer.alloc(30 + name.length);
local.writeUInt32LE(LOCAL_SIG, 0);
local.writeUInt16LE(VERSION_NEEDED, 4);
local.writeUInt16LE(0, 6);
local.writeUInt16LE(method, 8);
local.writeUInt16LE(DOS_TIME, 10);
local.writeUInt16LE(DOS_DATE, 12);
local.writeUInt32LE(crc, 14);
local.writeUInt32LE(body.length, 18);
local.writeUInt32LE(data.length, 22);
local.writeUInt16LE(name.length, 26);
local.writeUInt16LE(0, 28);
name.copy(local, 30);
const central = Buffer.alloc(46 + name.length);
central.writeUInt32LE(CENTRAL_SIG, 0);
central.writeUInt16LE(VERSION_MADE_BY, 4);
central.writeUInt16LE(VERSION_NEEDED, 6);
central.writeUInt16LE(0, 8);
central.writeUInt16LE(method, 10);
central.writeUInt16LE(DOS_TIME, 12);
central.writeUInt16LE(DOS_DATE, 14);
central.writeUInt32LE(crc, 16);
central.writeUInt32LE(body.length, 20);
central.writeUInt32LE(data.length, 24);
central.writeUInt16LE(name.length, 28);
central.writeUInt16LE(0, 30);
central.writeUInt16LE(0, 32);
central.writeUInt16LE(0, 34);
central.writeUInt16LE(0, 36);
central.writeUInt32LE(EXTERNAL_ATTRS, 38);
if (offset > MAX_U32) {
throw new Error("zip: archive too large for a non-zip64 archive");
}
central.writeUInt32LE(offset, 42);
name.copy(central, 46);
locals.push(local, body);
centrals.push(central);
offset += local.length + body.length;
}
const centralBuf = Buffer.concat(centrals);
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(EOCD_SIG, 0);
eocd.writeUInt16LE(0, 4);
eocd.writeUInt16LE(0, 6);
eocd.writeUInt16LE(sorted.length, 8);
eocd.writeUInt16LE(sorted.length, 10);
eocd.writeUInt32LE(centralBuf.length, 12);
eocd.writeUInt32LE(offset, 16);
eocd.writeUInt16LE(0, 20);
return Buffer.concat([...locals, centralBuf, eocd]);
}
// Read an archive back into [{ name, data }], from the central directory
// rather than by scanning for local headers: the central directory is the
// authoritative index, and a member reachable only by scanning is one a real
// unzipper would not extract.
//
// Every member's CRC is checked. The point of reading an archive back is to
// establish that it holds what it was meant to hold, so a member that does not
// decompress to its recorded checksum is a failure and never a warning.
function readZip(buf) {
if (buf.length < 22) {
throw new Error("zip: too short to be an archive");
}
// No archive this writes has a trailing comment, so the EOCD is the last
// 22 bytes. Anything else is not an archive this produced.
const eocdAt = buf.length - 22;
if (buf.readUInt32LE(eocdAt) !== EOCD_SIG) {
throw new Error(
"zip: no end-of-central-directory record at the end of the " +
"archive (a trailing comment, or not a zip at all)",
);
}
const count = buf.readUInt16LE(eocdAt + 10);
const centralSize = buf.readUInt32LE(eocdAt + 12);
let at = buf.readUInt32LE(eocdAt + 16);
if (at + centralSize > eocdAt) {
throw new Error("zip: central directory runs past the archive");
}
const out = [];
for (let i = 0; i < count; i++) {
if (buf.readUInt32LE(at) !== CENTRAL_SIG) {
throw new Error(`zip: bad central directory entry ${i}`);
}
const method = buf.readUInt16LE(at + 10);
const crc = buf.readUInt32LE(at + 16);
const compSize = buf.readUInt32LE(at + 20);
const rawSize = buf.readUInt32LE(at + 24);
const nameLen = buf.readUInt16LE(at + 28);
const extraLen = buf.readUInt16LE(at + 30);
const commentLen = buf.readUInt16LE(at + 32);
const localAt = buf.readUInt32LE(at + 42);
const name = buf.toString("ascii", at + 46, at + 46 + nameLen);
at += 46 + nameLen + extraLen + commentLen;
if (buf.readUInt32LE(localAt) !== LOCAL_SIG) {
throw new Error(`zip: ${name} has no local header`);
}
// The local header's own name and extra lengths, not the central
// directory's: the two are allowed to differ and the data starts after
// the local ones.
const localNameLen = buf.readUInt16LE(localAt + 26);
const localExtraLen = buf.readUInt16LE(localAt + 28);
const dataAt = localAt + 30 + localNameLen + localExtraLen;
const body = buf.subarray(dataAt, dataAt + compSize);
let data;
if (method === METHOD_STORE) {
data = Buffer.from(body);
} else if (method === METHOD_DEFLATE) {
data = zlib.inflateRawSync(body);
} else {
throw new Error(`zip: ${name} uses compression method ${method}`);
}
if (data.length !== rawSize) {
throw new Error(
`zip: ${name} decompressed to ${data.length} bytes, not the ` +
`recorded ${rawSize}`,
);
}
if (crc32(data) !== crc) {
throw new Error(`zip: ${name} fails its recorded CRC32`);
}
out.push({ name, data });
}
return out;
}
module.exports = { crc32, readZip, writeZip };

View File

@@ -1,38 +0,0 @@
#!/bin/sh
# script/package: produce the release artifacts — one self-contained,
# versioned archive per browser — into release/. Our own extension to
# scripts-to-rule-them-all.
#
# It builds first, through `make build` rather than by calling build.js
# itself. That target is the only audited path to a release build: it creates
# the build receipt outside the repo, scrubs AUTISTMASK_DEBUG from the
# verifier's environment, tells script/verify-build in so many words to expect
# a RELEASE build, and re-runs script/check-censored against dist/.
# script/test-verify-build asserts that wiring by reading the recipe back out
# of `make -n`. Re-implementing that sequence here would give the release
# artifacts a second, unaudited path to dist/ — and it is the release
# artifacts, above everything else, that must never be built from a debug
# compile.
#
# This packages, it does not publish. Tagging, CRX packing and any upload are
# outward-facing acts and are nobody's job but the owner's.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
if ! command -v make >/dev/null 2>&1; then
echo "package: make is required (the release build runs through" \
"make build)" >&2
exit 1
fi
make build
echo "Packaging release artifacts..."
node script/lib/package.js
}
main "$@"

View File

@@ -78,22 +78,6 @@ main() {
-e "E2E_TRACE_NETWORK=${E2E_TRACE_NETWORK:-0}" \
"$(cat "$IIDFILE")" \
node tests/e2e/run.js
# Where chrome.storage.local lives, and what moves it: two unpacked loads
# from two different paths in one profile, with the shipped manifest and
# again with `key` stripped out. Its own browser sessions — four of them —
# because the whole subject is what happens ACROSS loads, which the suite
# above cannot express with one.
#
# No PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS here: this drives no
# RPC and installs no route handlers, and the browser is started with
# --host-resolver-rules=MAP * ~NOTFOUND so nothing it does can leave.
echo "Running the extension-id and storage-partition observations..."
docker run --rm \
--ipc=host \
-e HOME=/tmp \
"$(cat "$IIDFILE")" \
node tests/e2e/storagePartition.js
}
main "$@"

View File

@@ -72,19 +72,6 @@ main() {
-e HOME=/tmp \
"$(cat "$IIDFILE")" \
node tests/e2e/firefox/run.js dist/firefox
# The install/uninstall/re-install property, against the packaged XPI
# rather than the unpacked directory: it is the artifact a user would be
# handed, and this is the only place a real Firefox is asked to load it.
# Its own browser session, because it takes the add-on away in the middle
# and the suite above shares one session throughout.
echo "Running the Firefox re-install suite against the packaged XPI..."
docker run --rm \
--shm-size=1g \
--network none \
-e HOME=/tmp \
"$(cat "$IIDFILE")" \
node tests/e2e/firefox/reinstall.js
}
main "$@"

View File

@@ -1,8 +1,7 @@
#!/bin/sh
# script/test-verify-build: exercise every failure mode of
# script/verify-build, and what make build does with dist/ after one of them
# (script/discard-dist-on-failure). Our own extension to
# scripts-to-rule-them-all, run from script/check so make check covers it.
# script/verify-build. Our own extension to scripts-to-rule-them-all, run
# from script/check so make check covers it.
#
# Why this exists: verify-build is the build-integrity guard, and four separate
# reviews of it each found a fresh vacuous pass — the grep exit-2 conflation,
@@ -32,7 +31,6 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
VERIFY_BUILD="$ROOT/script/verify-build"
DISCARD_DIST="$ROOT/script/discard-dist-on-failure"
MARKER_ON="autistmask-build-debug=on"
MARKER_OFF="autistmask-build-debug=off"
@@ -152,7 +150,6 @@ build_fixture() {
mkdir -p "$FIXTURE/script"
ln -s "$VERIFY_BUILD" "$FIXTURE/script/verify-build"
ln -s "$DISCARD_DIST" "$FIXTURE/script/discard-dist-on-failure"
mkdir -p "$FIXTURE/dist/chrome/src/popup" \
"$FIXTURE/dist/chrome/src/content" \
@@ -516,125 +513,12 @@ c_debug_build() {
write_receipt
}
c_no_dist() { rm -rf dist; }
# --- dist discard -----------------------------------------------------------
#
# make build wraps every step of the release path in
# script/discard-dist-on-failure, so a release build that fails removes dist/:
# with AUTISTMASK_DEBUG=1 exported it has already emitted a complete, loadable
# debug bundle whose every wallet uses the publicly committed test recovery
# phrase, and a loud failure alone does not stop someone loading dist/chrome/
# anyway. make build-debug is deliberately not wrapped.
#
# Both directions are asserted against the state of dist/ ON DISK after the run,
# not against the exit status: a case reading only the status would keep passing
# if the removal quietly stopped happening, which is the flip this exists to
# catch. The wrapper runs against the fixture — its ROOT is the fixture, via the
# symlink in the fixture's script/ — with trivial commands standing in for the
# build steps, because what is under test is what happens after a step says no,
# not the step.
# discard_case <name> <setup> <status> <gone|kept> <want> <unwanted> [cmd...]
discard_case() {
_dc_name="$1"
_dc_setup="$2"
_dc_want_status="$3"
_dc_want_dist="$4"
_dc_want="$5"
_dc_unwanted="$6"
shift 6
build_fixture
if ! (cd "$FIXTURE" && "$_dc_setup") >/dev/null 2>&1; then
FAILED=$((FAILED + 1))
echo " FAIL: $_dc_name"
echo " the case's own setup failed, so nothing was tested."
return 0
fi
_dc_status=0
_dc_out="$(cd "$FIXTURE" &&
"$FIXTURE/script/discard-dist-on-failure" "$@" 2>&1)" || _dc_status=$?
_ok=yes
_why=""
if [ "$_dc_status" -ne "$_dc_want_status" ]; then
_ok=no
_why="exit status $_dc_status, wanted $_dc_want_status"
fi
# The assertion this case exists for: what is on disk now.
if [ -e "$FIXTURE/dist" ] || [ -h "$FIXTURE/dist" ]; then
_dc_dist=kept
else
_dc_dist=gone
fi
if [ "$_dc_dist" != "$_dc_want_dist" ]; then
_ok=no
_why="${_why:+$_why; }dist/ is $_dc_dist after the run, wanted"
_why="$_why $_dc_want_dist"
elif [ "$_dc_want_dist" = kept ] &&
[ ! -f "$FIXTURE/dist/chrome/src/popup/index.js" ]; then
# Kept has to mean intact: a dist/ emptied out is not one left alone.
_ok=no
_why="${_why:+$_why; }dist/ survived but its emitted bundle did not"
fi
_dc_check_message "$_dc_want" want
_dc_check_message "$_dc_unwanted" unwanted
if [ "$_ok" = yes ]; then
PASSED=$((PASSED + 1))
echo " ok: $_dc_name"
return 0
fi
FAILED=$((FAILED + 1))
echo " FAIL: $_dc_name"
echo " $_why"
echo " --- discard-dist-on-failure output ---"
printf '%s\n' "$_dc_out" | sed 's/^/ /'
echo " --- end output ---"
}
# Require ($2 = want) or forbid ($2 = unwanted) a substring in the wrapper's
# output, updating _ok and _why. An empty substring asserts nothing. Same grep
# discipline as everywhere else here: 0 and 1 are answers, anything else means
# the message was never checked.
_dc_check_message() {
[ -n "$1" ] || return 0
_dcm_g=0
printf '%s\n' "$_dc_out" | grep -q -F -e "$1" || _dcm_g=$?
case "$_dcm_g" in
0)
[ "$2" = unwanted ] || return 0
_ok=no
_why="${_why:+$_why; }message contained: $1"
;;
1)
[ "$2" = want ] || return 0
_ok=no
_why="${_why:+$_why; }message did not contain: $1"
;;
*)
_ok=no
_why="${_why:+$_why; }grep exited $_dcm_g matching the message, so the
message was never checked"
;;
esac
}
# --- Makefile wiring --------------------------------------------------------
# The verifier cases above prove what verify-build does when it is told what to
# expect, and the discard cases prove what the wrapper does with dist/. This
# proves the Makefile wires both up — the mode as an argument, on a scrubbed
# environment, identically whether or not AUTISTMASK_DEBUG is exported in the
# shell that ran make, and the wrapper on the release path only. Read off
# `make -n`, so no build runs.
# expect. This proves the Makefile tells it — with the mode as an argument, on
# a scrubbed environment, and identically whether or not AUTISTMASK_DEBUG is
# exported in the shell that ran make. Read off `make -n`, so no build runs.
check_makefile_wiring() {
if ! command -v make >/dev/null 2>&1; then
SKIPPED=$((SKIPPED + 1))
@@ -653,34 +537,6 @@ check_makefile_wiring() {
build-debug "verify-build --expect debug"
_wiring_case "make build-debug scrubs AUTISTMASK_DEBUG for the verifier" \
build-debug "env -u AUTISTMASK_DEBUG"
# The release path runs its steps through the wrapper, including the final
# check-censored pass; the debug path runs none of them through it, which is
# what keeps a failed debug build's dist/ on disk.
_wiring_case "make build wraps its steps in discard-dist-on-failure" \
build "script/discard-dist-on-failure"
_wiring_case "make build wraps check-censored --require-dist too" \
build "script/discard-dist-on-failure script/check-censored"
_wiring_case_absent "make build-debug never discards its dist/" \
build-debug "discard-dist-on-failure"
}
# Run `make -n TARGET` with AUTISTMASK_DEBUG=1 exported, into _wc_out. Returns
# non-zero, having already reported the failure, when make itself failed: a
# recipe that could not be printed was never checked.
_wiring_make_n() {
AUTISTMASK_DEBUG=1
export AUTISTMASK_DEBUG
_wc_status=0
_wc_out="$(cd "$ROOT" && make -n "$_wc_target" 2>&1)" || _wc_status=$?
unset AUTISTMASK_DEBUG
[ "$_wc_status" -ne 0 ] || return 0
FAILED=$((FAILED + 1))
echo " FAIL: $_wc_name"
echo " make -n $_wc_target exited $_wc_status"
return 1
}
_wiring_case() {
@@ -688,7 +544,18 @@ _wiring_case() {
_wc_target="$2"
_wc_want="$3"
_wiring_make_n || return 0
AUTISTMASK_DEBUG=1
export AUTISTMASK_DEBUG
_wc_status=0
_wc_out="$(cd "$ROOT" && make -n "$_wc_target" 2>&1)" || _wc_status=$?
unset AUTISTMASK_DEBUG
if [ "$_wc_status" -ne 0 ]; then
FAILED=$((FAILED + 1))
echo " FAIL: $_wc_name"
echo " make -n $_wc_target exited $_wc_status"
return 0
fi
_wc_g=0
printf '%s\n' "$_wc_out" | grep -q -F -e "$_wc_want" || _wc_g=$?
@@ -710,34 +577,6 @@ _wiring_case() {
esac
}
# The inverse: the recipe must NOT run something.
_wiring_case_absent() {
_wc_name="$1"
_wc_target="$2"
_wc_want="$3"
_wiring_make_n || return 0
_wc_g=0
printf '%s\n' "$_wc_out" | grep -q -F -e "$_wc_want" || _wc_g=$?
case "$_wc_g" in
1)
PASSED=$((PASSED + 1))
echo " ok: $_wc_name"
;;
0)
FAILED=$((FAILED + 1))
echo " FAIL: $_wc_name"
echo " make -n $_wc_target runs: $_wc_want"
;;
*)
FAILED=$((FAILED + 1))
echo " FAIL: $_wc_name"
echo " grep exited $_wc_g, so the recipe was never checked"
;;
esac
}
run_cases() {
check_case "control: untouched dist passes" \
no release 0 "2 bundle(s) $MARKER_OFF" c_control
@@ -873,18 +712,6 @@ run_cases() {
no release 1 "carries a debug marker but the build did not" \
c_marker_on_plain_file
discard_case "a failed release build step removes dist/" \
c_control 3 gone "dist/ WAS REMOVED" "" sh -c 'exit 3'
discard_case "a successful release build step leaves dist/ alone" \
c_control 0 kept "" "REMOVED" true
discard_case "a failed release build step with no dist/ says there was none" \
c_no_dist 3 gone "There was no dist/ to remove" "" sh -c 'exit 3'
discard_case "the wrapper given no command removes nothing" \
c_control 1 kept "no command given" ""
check_makefile_wiring
}
@@ -913,10 +740,6 @@ main() {
echo "test-verify-build: $VERIFY_BUILD is missing or not executable" >&2
exit 1
}
[ -x "$DISCARD_DIST" ] || {
echo "test-verify-build: $DISCARD_DIST is missing or not executable" >&2
exit 1
}
echo "Testing script/verify-build failure modes..."
pick_sha256_tool

View File

@@ -1,10 +1,8 @@
#!/bin/sh
# script/verify-build: assert that the regular files and symlinks under dist/
# are exactly what the build that just ran emitted (other file types are out of
# scope; see "What that does and does not establish" below), and that the
# compiled DEBUG state of that output is the one the caller asked for. Our own
# extension to scripts-to-rule-them-all, run at the end of make build /
# make build-debug.
# script/verify-build: assert that dist/ holds exactly what the build that just
# ran emitted, and that the compiled DEBUG state of that output is the one the
# caller asked for. Our own extension to scripts-to-rule-them-all, run at the
# end of make build / make build-debug.
#
# Why the DEBUG half exists: DEBUG makes the publicly committed test recovery
# phrase the output of wallet creation, so a release artifact built with it live
@@ -31,16 +29,12 @@
# path fresh per invocation, outside the repo, and deletes it afterwards.
#
# What that does and does not establish. It establishes that dist/ is byte for
# byte the output of the build.js run that just finished, with no regular file
# or symlink added, missing or altered in between, and that the audited bundles
# in it compiled to the requested mode. Regular files and symlinks are the whole
# of what the tree walk covers; fifos, sockets, device nodes and empty
# directories under dist/ are not checked, because a build emits none of them,
# none can carry a shippable payload, and grep on a fifo would hang rather than
# fail. It does NOT establish that the source tree or build.js were honest, and
# it says nothing at all to someone handed a dist/ from elsewhere: without the
# receipt from its own build they have no input to this check. That is signing,
# and it is not this control.
# byte the output of the build.js run that just finished, with nothing added,
# nothing missing and nothing altered in between, and that the audited bundles
# in it compiled to the requested mode. It does NOT establish that the source
# tree or build.js were honest, and it says nothing at all to someone handed a
# dist/ from elsewhere: without the receipt from its own build they have no
# input to this check. That is signing, and it is not this control.
#
# It fails rather than passes whenever it cannot determine something. Minified
# output is not a stable contract, so "matched neither marker" is not evidence
@@ -398,10 +392,8 @@ check_receipt_entries() {
# its own command line, so a linked dist/ collapses this walk to one entry
# and cross-checks nothing.
#
# Types other than regular files and symlinks — fifos, sockets, device nodes and
# empty directories — are left out on purpose, and the guarantee is bounded to
# what is walked: a build emits none of them, none can carry a shippable
# payload, and grep on a fifo would hang rather than fail.
# Types other than regular files and symlinks are left out on purpose: a build
# emits none of them, and grep on a fifo would hang rather than fail.
check_dist_tree() {
LISTING="$(mktemp "${TMPDIR:-/tmp}/verify-build-dist.XXXXXX")" ||
fail "could not create a temporary file for the dist/ listing, so the

View File

@@ -2,20 +2,19 @@
// Handles EIP-1193 RPC requests from content scripts and proxies
// non-sensitive calls to the configured Ethereum JSON-RPC endpoint.
const { DEFAULT_RPC_URL } = require("../shared/constants");
const {
SUPPORTED_CHAIN_IDS,
networkById,
networkByChainId,
} = require("../shared/networks");
const { applyChainSwitchFields } = require("../shared/chainSwitchFields");
// The background's own storage layer. src/shared/state.js — the module-level
// `state` singleton, loadState() and saveState() — is deliberately NOT
// imported here and must never be: see the header of src/background/state.js.
// The build enforces it, not review: build.js fails when esbuild's metafile
// reports that module as an input of this bundle (FORBIDDEN_INPUTS in
// script/lib/forbiddenBundleInputs.js). The ESLint rule of the same name is
// the same prohibition reported early, not the guarantee.
const { getState, updateState } = require("./state");
const { onChainSwitch } = require("../shared/chainSwitch");
const {
state,
loadState,
saveState,
currentNetwork,
} = require("../shared/state");
const { refreshBalances, getProvider } = require("../shared/balances");
const { debugFetch, log } = require("../shared/log");
const {
@@ -43,6 +42,7 @@ const {
const {
actionApi,
runtimeApi,
storageGet,
tabsQuery,
tabsSendMessage,
windowsApi,
@@ -179,12 +179,21 @@ const INTERNAL_ERROR_CODE = -32603;
const INTERNAL_ERROR_MESSAGE =
"AutistMask could not complete this request because of an internal error.";
// The active address of a profile snapshot. Pure, and taking the snapshot as
// an argument rather than reading storage itself: a handler that has already
// read state must not answer "which account is this" from a SECOND, later read
// — the two can disagree, and the checks that compare them would then be
// comparing two different moments.
function activeAddressOf(s) {
async function getState() {
const result = await storageGet("autistmask");
return (
result.autistmask || {
wallets: [],
rpcUrl: DEFAULT_RPC_URL,
activeAddress: null,
allowedSites: {},
deniedSites: {},
}
);
}
async function getActiveAddress() {
const s = await getState();
if (s.activeAddress) return s.activeAddress;
// Fall back to first address
if (s.wallets.length > 0 && s.wallets[0].addresses.length > 0) {
@@ -193,11 +202,6 @@ function activeAddressOf(s) {
return null;
}
// For the few call sites that need only the address and hold no snapshot.
async function getActiveAddress() {
return activeAddressOf(await getState());
}
// Whether a request names a signing address other than the active one. Such a
// request is refused rather than quietly signed as whichever address happens
// to be active: the page asked for account A and would otherwise be handed
@@ -206,14 +210,9 @@ function namesAnotherAddress(requested, activeAddress) {
return !!requested && !sameAddress(requested, activeAddress);
}
// The endpoint alone, for the one caller that needs nothing else. Anything
// that also needs the network the endpoint belongs to must take both from ONE
// snapshot — see handleSendTransaction() — because a chain switch moves them
// together and a provider built from two different reads can end up pointed at
// one chain and told it is on another
// (https://git.eeqj.de/sneak/AutistMask/issues/320).
async function getRpcUrl() {
return (await getState()).rpcUrl;
const s = await getState();
return s.rpcUrl || DEFAULT_RPC_URL;
}
function extractHostname(origin) {
@@ -554,26 +553,10 @@ runtime.onConnect.addListener((port) => {
}
});
// Record a remembered site decision under one address.
//
// A read-modify-write against storage, not a load-mutate-save of a shared
// singleton: the user takes seconds to answer the prompt, and everything else
// in the worker — a balance refresh in flight, another site's approval — has
// gone on running the whole time. Loading here used to replace the very
// objects that work was holding.
async function rememberSiteChoice(field, address, hostname) {
await updateState((s) => {
if (!s[field][address]) s[field][address] = [];
if (!s[field][address].includes(hostname)) {
s[field][address].push(hostname);
}
});
}
// Handle connection requests (eth_requestAccounts, wallet_requestPermissions)
async function handleConnectionRequest(origin) {
const s = await getState();
const activeAddress = activeAddressOf(s);
const activeAddress = await getActiveAddress();
if (!activeAddress) {
return { error: { message: "No accounts available" } };
}
@@ -605,14 +588,29 @@ async function handleConnectionRequest(origin) {
if (decision.approved) {
if (decision.remember) {
await rememberSiteChoice("allowedSites", activeAddress, hostname);
// Reload state to get latest, add to allowed, persist
await loadState();
if (!state.allowedSites[activeAddress]) {
state.allowedSites[activeAddress] = [];
}
if (!state.allowedSites[activeAddress].includes(hostname)) {
state.allowedSites[activeAddress].push(hostname);
}
await saveState();
} else {
connectedSites[origin + ":" + activeAddress] = true;
}
return { result: [activeAddress] };
} else {
if (decision.remember) {
await rememberSiteChoice("deniedSites", activeAddress, hostname);
await loadState();
if (!state.deniedSites[activeAddress]) {
state.deniedSites[activeAddress] = [];
}
if (!state.deniedSites[activeAddress].includes(hostname)) {
state.deniedSites[activeAddress].push(hostname);
}
await saveState();
}
return {
error: {
@@ -656,7 +654,7 @@ async function handleRpc(method, params, origin) {
if (method === "eth_accounts") {
const s = await getState();
const activeAddress = activeAddressOf(s);
const activeAddress = await getActiveAddress();
if (!activeAddress) return { result: [] };
const hostname = extractHostname(origin);
const allowed = s.allowedSites[activeAddress] || [];
@@ -669,11 +667,22 @@ async function handleRpc(method, params, origin) {
return { result: [] };
}
// Both used to be answered from currentNetwork(), which reads the
// module-level state singleton, and nothing populates that at module
// scope. A worker revived by the page's own message therefore held
// DEFAULT_STATE and told a page it was on mainnet while the user was on
// Sepolia (https://git.eeqj.de/sneak/AutistMask/issues/317).
// Both answered from currentNetwork(), which reads the module-level state
// singleton, and nothing populates that at module scope. A worker revived
// by the page's own message therefore held DEFAULT_STATE and told a page
// it was on mainnet while the user was on Sepolia
// (https://git.eeqj.de/sneak/AutistMask/issues/317).
//
// Answered from getState() rather than by loading the singleton. Any page
// reaches these two — neither is gated on a connection, and the injected
// provider sends eth_chainId on every page load — and loadState() replaces
// state.wallets wholesale, which would detach the address objects an
// in-flight backgroundRefresh() is mutating across its network round trip,
// so its saveState() would persist the pre-refresh balances while still
// stamping lastBalanceRefresh. getState() is the detached per-call storage
// read the other read handlers here already use.
// networkById(undefined) falls back to mainnet, matching the default for a
// profile with no stored networkId.
if (method === "eth_chainId" || method === "net_version") {
const s = await getState();
const net = networkById(s.networkId);
@@ -690,7 +699,7 @@ async function handleRpc(method, params, origin) {
// not be able to do it. Ungated, any page could clear the
// [TESTNET] banner under a user who believed they were on Sepolia.
const s = await getState();
const activeAddress = activeAddressOf(s);
const activeAddress = await getActiveAddress();
const hostname = extractHostname(origin);
const allowed = s.allowedSites[activeAddress] || [];
if (
@@ -700,25 +709,24 @@ async function handleRpc(method, params, origin) {
return { error: { code: 4100, message: "Unauthorized" } };
}
// The chain in force is read from the snapshot above, not from the
// singleton: this worker may have been started by this very message,
// and the singleton would then be DEFAULT_STATE, so the same-chain
// check compared against mainnet whatever the user was on
// (https://git.eeqj.de/sneak/AutistMask/issues/316).
// onChainSwitch() mutates the module-level state singleton and then
// saves every field of it, and currentNetwork() reads the same
// singleton. This worker may have been started by this very message:
// nothing loads state at module scope, so without this the singleton
// is DEFAULT_STATE, the same-chain check compares against the wrong
// network, and the save writes empty wallets, empty allowedSites and
// the default endpoints over the user's stored profile
// (https://git.eeqj.de/sneak/AutistMask/issues/316). Same precedent
// as the transaction path below.
await loadState();
const chainId = params?.[0]?.chainId;
if (chainId === networkById(s.networkId).chainId) {
if (chainId === currentNetwork().chainId) {
return { result: null };
}
if (SUPPORTED_CHAIN_IDS.has(chainId)) {
const target = networkByChainId(chainId);
// Read-modify-write against storage. The old path went through
// onChainSwitch(), which mutates the singleton and then persists
// every field of it — on an unloaded worker that wrote empty
// wallets, empty allowedSites and the default endpoints over the
// user's stored profile, encrypted secrets included.
await updateState((fresh) =>
applyChainSwitchFields(fresh, target.id),
);
await onChainSwitch(target.id);
broadcastChainChanged(target.chainId);
return { result: null };
}
@@ -765,7 +773,7 @@ async function handleRpc(method, params, origin) {
if (method === "wallet_getPermissions") {
const s = await getState();
const activeAddress = activeAddressOf(s);
const activeAddress = await getActiveAddress();
const hostname = extractHostname(origin);
const allowed = s.allowedSites[activeAddress] || [];
const isConnected =
@@ -791,7 +799,7 @@ async function handleRpc(method, params, origin) {
if (method === "personal_sign" || method === "eth_sign") {
const s = await getState();
const activeAddress = activeAddressOf(s);
const activeAddress = await getActiveAddress();
if (!activeAddress)
return { error: { message: "No accounts available" } };
@@ -840,7 +848,7 @@ async function handleRpc(method, params, origin) {
if (method === "eth_signTypedData_v4" || method === "eth_signTypedData") {
const s = await getState();
const activeAddress = activeAddressOf(s);
const activeAddress = await getActiveAddress();
if (!activeAddress)
return { error: { message: "No accounts available" } };
@@ -896,7 +904,7 @@ async function handleRpc(method, params, origin) {
// page has its answer.
async function handleSendTransaction(params, origin) {
const s = await getState();
const activeAddress = activeAddressOf(s);
const activeAddress = await getActiveAddress();
if (!activeAddress) return { error: { message: "No accounts available" } };
const hostname = extractHostname(origin);
@@ -940,19 +948,10 @@ async function handleSendTransaction(params, origin) {
// user is shown is a complete one and is the same object the signed
// artifact is checked against. A failure raises no approval at all and
// is reported to the requesting page; see approvalTx.js.
//
// The provider is built from ONE snapshot — the endpoint and the
// network name both come from `s`. It used to be
// getProvider(await getRpcUrl()) with no network name at all, so
// getProvider fell back to the unpopulated singleton's mainnet: the
// endpoint was the user's chain and the static hint was 0x1, ethers
// fixed chainId at 0x1, and the wallet's own verifySignedTx then
// refused every non-mainnet dApp send
// (https://git.eeqj.de/sneak/AutistMask/issues/320).
let approvedTx;
try {
approvedTx = await prepareApprovalTx(
getProvider(s.rpcUrl, s.networkId),
getProvider(await getRpcUrl()),
activeAddress,
txParams,
);
@@ -1040,7 +1039,7 @@ async function broadcastAccountsChanged() {
}
resetPopupUrl();
const s = await getState();
const activeAddress = activeAddressOf(s);
const activeAddress = await getActiveAddress();
const allowed = activeAddress ? s.allowedSites[activeAddress] || [] : [];
let tabs;
try {
@@ -1080,60 +1079,20 @@ async function broadcastAccountsChanged() {
const BALANCE_REFRESH_PERIOD_MS = BALANCE_REFRESH_PERIOD_MINUTES * 60 * 1000;
const RECENT_BALANCE_REFRESH_MS = Math.floor(BALANCE_REFRESH_PERIOD_MS / 2);
// The wallets this refresh works on are its OWN, and nothing else in the
// worker can reach them.
//
// refreshBalances() mutates address objects in place across a multi-second
// network round trip. It used to be handed the module-level singleton's
// wallets, which meant any concurrent handler that called loadState() replaced
// state.wallets underneath it: the refreshed balances landed on detached
// objects, and the save that followed persisted the PRE-refresh values while
// still stamping lastBalanceRefresh, suppressing the redo. Every point fix for
// the singleton added such a loadState(), so the next one would have done it
// again (https://git.eeqj.de/sneak/AutistMask/issues/324).
//
// So: read a snapshot, refresh a private copy of its wallets, then apply the
// balances that came back — by address, onto whatever storage holds NOW.
// Applying by address rather than writing the array back is what keeps a
// wallet or address added, renamed or deleted during the round trip.
async function backgroundRefresh() {
const s = await getState();
await loadState();
const now = Date.now();
if (now - (s.lastBalanceRefresh || 0) < RECENT_BALANCE_REFRESH_MS) return;
if (s.wallets.length === 0) return;
const wallets = s.wallets;
if (now - (state.lastBalanceRefresh || 0) < RECENT_BALANCE_REFRESH_MS)
return;
if (state.wallets.length === 0) return;
await refreshBalances(
wallets,
s.rpcUrl,
s.blockscoutUrl,
s.trackedTokens,
s.networkId,
state.wallets,
state.rpcUrl,
state.blockscoutUrl,
state.trackedTokens,
);
const refreshed = new Map();
for (const wallet of wallets) {
for (const addr of wallet.addresses || []) {
refreshed.set(String(addr.address).toLowerCase(), addr);
}
}
await updateState((fresh) => {
for (const wallet of fresh.wallets) {
for (const addr of wallet.addresses || []) {
const got = refreshed.get(String(addr.address).toLowerCase());
if (!got) continue;
// Only fields the refresh actually produced. refreshBalances()
// leaves a field untouched when its lookup failed, so an
// undefined here means "no answer", not "the answer is empty",
// and must not overwrite what is stored.
for (const key of ["balance", "ensName", "tokenBalances"]) {
if (got[key] !== undefined) addr[key] = got[key];
}
}
}
fresh.lastBalanceRefresh = now;
});
state.lastBalanceRefresh = now;
await saveState();
}
// The recurring job runs off an alarm, not a timer. On Chrome MV3 this file is
@@ -1348,29 +1307,16 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
// so an escape from there must not tell the user it might have.
let lastResortStage = TX_STAGE_VERIFY;
(async () => {
// The chain this attempt is on, read once — and the endpoint it
// will be broadcast to comes from the SAME read.
//
// Verification below refuses an artifact signed for any other
// chain, and the nonce record is both consulted and written under
// this one, so a network switch part-way through cannot make the
// check and the record disagree about which chain the nonce was
// spent on. The endpoint used to be read separately, several
// awaits later (`state.rpcUrl` off the singleton), so a chain
// switch committed in that window moved the endpoint out from
// under a transaction already verified against the old chain: the
// artifact would be sent to the new chain's node, which is
// precisely the "signed for a different network" case the
// verification exists to prevent.
// The chain this attempt is on, read once. Verification below
// refuses an artifact signed for any other chain, and the nonce
// record is both consulted and written under this one, so a
// network switch part-way through cannot make the check and the
// record disagree about which chain the nonce was spent on.
let chainId;
let rpcUrl;
let networkId;
try {
const s = await getState();
networkId = s.networkId;
chainId = networkById(networkId).chainId;
rpcUrl = s.rpcUrl;
const activeAddress = activeAddressOf(s);
await loadState();
chainId = currentNetwork().chainId;
const activeAddress = await getActiveAddress();
// An address switch between approval and signing refuses. The
// approval named one account; signing from whichever account
// is active now would send funds from an account this screen
@@ -1442,7 +1388,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
}
try {
const provider = getProvider(rpcUrl, networkId);
const provider = getProvider(state.rpcUrl);
lastResortStage = TX_STAGE_BROADCAST;
const tx = await provider.broadcastTransaction(msg.rawSignedTx);
if (nonce !== null) spent.add(nonce);

View File

@@ -1,80 +0,0 @@
// The background's access to the persisted profile.
//
// There is no in-memory copy here, and that is the whole design. The MV3
// service worker is terminated when idle and revived by the next message, so
// anything held at module scope is either absent or arbitrarily stale, and
// src/shared/state.js's module-level `state` singleton — which nothing in the
// worker ever populates — silently served DEFAULT_STATE to whoever read it.
// Five defects came out of that (https://git.eeqj.de/sneak/AutistMask/issues/324),
// and every point fix for one of them added a loadState() that created the
// next: loading detaches the objects an in-flight handler is holding.
//
// So the background reads per call and writes read-modify-write:
//
// getState() one storage read, normalized, detached. Nothing else
// holds the object it returns, so a handler may keep it
// across any number of awaits and no concurrent work can
// move it.
// updateState(fn) read fresh, apply fn to that fresh record, write it
// back — all inside a queue, so two background writes
// never interleave, and the read is one storage round trip
// ahead of the write rather than a page lifetime ahead of
// it (which is what made the popup's saveState() need a
// per-field merge against a baseline at all).
//
// A handler that must both read and write therefore does its network work
// against a snapshot it owns, and applies the RESULT inside updateState().
// It never publishes an object other in-flight work is holding.
const { storageGet, storageSet } = require("../shared/browserApi");
const { normalizePersisted } = require("../shared/persistedState");
// A fresh, fully-normalized, detached copy of the persisted profile.
//
// Normalized rather than raw: a legacy or malformed record is self-healed the
// same way loadState() heals it for the popup, so the background is never the
// one context reasoning about a shape the rest of the extension repairs.
async function getState() {
const result = await storageGet("autistmask");
return normalizePersisted(result.autistmask);
}
// Serializes the read-modify-write turns below. Two of them interleaved would
// each read before the other wrote, and the second write would carry the first
// one's fields back to their pre-turn values.
let updateQueue = Promise.resolve();
async function updateStateOnce(mutate) {
const s = await getState();
await mutate(s);
s.hasWallet = Boolean(s.wallets && s.wallets.length > 0);
await storageSet({ autistmask: s });
return s;
}
// Apply `mutate` to a record read fresh from storage and write the result
// back. `mutate` receives a detached, normalized profile and mutates it in
// place; it may be async, but it must not do anything slow — the window
// between the read and the write is the window in which another context's
// write is lost, and keeping it to one storage round trip is what makes a
// whole-record write safe here. Concretely: the write is the WHOLE record, so
// a popup write that lands inside that window is reverted, in every field, by
// the record this turn read before it. That is accepted because the window is
// one round trip long and the popup is not writing while the worker is;
// widening it is what would make it a real hazard.
//
// `mutate` must also not call updateState() itself, directly or through
// anything it awaits: the queue is strictly serial, so the inner turn waits on
// the outer one, which is waiting on the inner one. That deadlocks the whole
// background, not just the caller. Mutate the record you were handed.
//
// Resolves with the record that was written.
function updateState(mutate) {
const turn = updateQueue.then(() => updateStateOnce(mutate));
// The queue must advance even when a turn rejects, or every update after
// it queues behind a promise that never settles.
updateQueue = turn.catch(() => {});
return turn;
}
module.exports = { getState, updateState };

View File

@@ -53,7 +53,6 @@ async function doRefreshAndRender() {
state.rpcUrl,
state.blockscoutUrl,
state.trackedTokens,
state.networkId,
),
]);
state.lastBalanceRefresh = Date.now();

View File

@@ -49,11 +49,7 @@ function init(ctx) {
infoEl.style.visibility = "visible";
log.debugf("Looking up token contract", contractAddr);
try {
const info = await lookupTokenInfo(
contractAddr,
state.rpcUrl,
state.networkId,
);
const info = await lookupTokenInfo(contractAddr, state.rpcUrl);
log.infof("Adding token", info.symbol, contractAddr);
state.trackedTokens.push({
address: contractAddr,

View File

@@ -179,7 +179,7 @@ async function importMnemonic(ctx) {
// Scan for used HD addresses beyond index 0.
showFlash("Scanning for addresses...", 30000);
const scan = await scanForAddresses(xpub, state.rpcUrl, state.networkId);
const scan = await scanForAddresses(xpub, state.rpcUrl);
if (scan.addresses.length > 1) {
wallet.addresses = scan.addresses.map((a) => ({
address: a.address,
@@ -298,7 +298,7 @@ async function importXprvKey(ctx) {
// Scan for used HD addresses beyond index 0.
showFlash("Scanning for addresses...", 30000);
const scan = await scanForAddresses(xpub, state.rpcUrl, state.networkId);
const scan = await scanForAddresses(xpub, state.rpcUrl);
if (scan.addresses.length > 1) {
wallet.addresses = scan.addresses.map((a) => ({
address: a.address,

View File

@@ -188,7 +188,6 @@ async function loadTransactions(address) {
ensNameMap = await resolveEnsNames(
counterparties,
state.rpcUrl,
state.networkId,
);
} catch {
ensNameMap = new Map();

View File

@@ -268,7 +268,6 @@ async function loadTransactions(address, tokenId) {
ensNameMap = await resolveEnsNames(
counterparties,
state.rpcUrl,
state.networkId,
);
} catch {
ensNameMap = new Map();

View File

@@ -25,12 +25,6 @@ const {
resolveTokenDecimals,
unknownDecimalsAmount,
} = require("../../shared/approvalAmount");
// Four decimals, with the nonzero floor these screens hold: every amount this
// view renders — the ERC-20 line, the ETH value, the max fee — and every one
// it carries forward to the wait/success/error screens goes through it.
const {
truncateAmountNeverZero: formatTxValue,
} = require("../../shared/amountDisplay");
const { decryptWithPassword } = require("../../shared/vault");
const { getSignerForAddress } = require("../../shared/wallet");
const { walletDefect } = require("../../shared/walletDefects");
@@ -46,6 +40,13 @@ function approvalAddressHtml(address) {
return renderAddressHtml(address, { title });
}
function formatTxValue(val) {
const parts = val.split(".");
if (parts.length === 1) return val + ".0000";
const dec = (parts[1] + "0000").slice(0, 4);
return parts[0] + "." + dec;
}
// The amount line for a decoded ERC-20 call. With a known scale it is the
// token quantity; with `decimals` null it is the base-unit integer with the
// unknown scale stated, because formatting it with an assumed scale is what
@@ -73,14 +74,6 @@ function tokenLabel(address) {
function decodeCalldata(data, toAddress) {
if (!data || data === "0x" || data.length < 10) return null;
// Where a token's scale is looked for, for every decoder below: the ERC-20
// amount line and the swap's Amount and Min. received lines resolve it the
// same way, and refuse to format the same way when it is nowhere.
const decimalsSources = {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
};
// Try ERC-20 (approve / transfer)
try {
const parsed = erc20Iface.parseTransaction({ data });
@@ -92,10 +85,10 @@ function decodeCalldata(data, toAddress) {
// the wrong number, and for a token with fewer decimals than the
// guess it is the wrong number in the direction that reads as
// zero. See tokenAmountText().
const tokenDecimals = resolveTokenDecimals(
toAddress,
decimalsSources,
);
const tokenDecimals = resolveTokenDecimals(toAddress, {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
});
const contractLabel = tokenSymbol
? tokenSymbol + " (" + toAddress + ")"
: toAddress;
@@ -175,7 +168,7 @@ function decodeCalldata(data, toAddress) {
}
// Try Uniswap Universal Router
const routerResult = uniswap.decode(data, toAddress, decimalsSources);
const routerResult = uniswap.decode(data, toAddress);
if (routerResult) return routerResult;
return null;

View File

@@ -304,7 +304,7 @@ function formatFeeEth(wei) {
async function estimateGas(txInfo) {
try {
const provider = getProvider(state.rpcUrl, state.networkId);
const provider = getProvider(state.rpcUrl);
const feeData = await provider.getFeeData();
let gasLimit;
@@ -386,7 +386,7 @@ async function estimateGas(txInfo) {
async function checkRecipientHistory(txInfo) {
try {
const provider = getProvider(state.rpcUrl, state.networkId);
const provider = getProvider(state.rpcUrl);
const asyncWarnings = await getFullWarnings(txInfo.to, provider, {
fromAddress: txInfo.from,
});
@@ -454,7 +454,7 @@ function init(_ctx) {
state.selectedAddress,
decryptedSecret,
);
const provider = getProvider(state.rpcUrl, state.networkId);
const provider = getProvider(state.rpcUrl);
const connectedSigner = signer.connect(provider);
if (pendingTx.token === "ETH") {

View File

@@ -202,7 +202,7 @@ function init(_ctx) {
let ensName = null;
if (to.includes(".") && !to.startsWith("0x")) {
try {
const provider = getProvider(state.rpcUrl, state.networkId);
const provider = getProvider(state.rpcUrl);
const resolved = await provider.resolveName(to);
if (!resolved) {
showFlash("Could not resolve " + to);

View File

@@ -133,11 +133,7 @@ function init(_ctx) {
infoEl.style.visibility = "visible";
log.debugf("Looking up token contract", addr);
try {
const info = await lookupTokenInfo(
addr,
state.rpcUrl,
state.networkId,
);
const info = await lookupTokenInfo(addr, state.rpcUrl);
log.infof("Adding token", info.symbol, addr);
state.trackedTokens.push({
address: addr,

View File

@@ -113,7 +113,7 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
renderElapsed();
}, 1000);
const provider = getProvider(state.rpcUrl, state.networkId);
const provider = getProvider(state.rpcUrl);
let consecutiveFailures = 0;
async function poll() {

View File

@@ -1,46 +0,0 @@
// The 4-decimal amount rule from README.md's Display Consistency section, and
// the one exception to it, in one place. Three call sites had grown their own
// copy of the truncation — the history and balance lists
// (`src/shared/transactions.js`), the approval screen's ERC-20 amount line
// (`src/popup/views/approval.js`) and its Uniswap swap detail lines
// (`src/shared/uniswap.js`) — and a fix applied to one of them left the other
// two showing a different number for the same value.
//
// The two functions below are the two policies, not two implementations of
// one: summary lists truncate, and the screens that state what is being
// authorized truncate with a floor. Keeping them adjacent is the point, so a
// change to the rule cannot reach one screen and miss another.
// Truncate to exactly four decimal places. Truncation, never rounding: an
// amount must never be displayed as larger than it is, so 0.99999 stays
// 0.9999.
function truncateAmount(val) {
const parts = val.split(".");
if (parts.length === 1) return val + ".0000";
return parts[0] + "." + (parts[1] + "0000").slice(0, 4);
}
// The same rule, plus the invariant the approval and confirmation screens
// hold: a nonzero amount never renders as zero. Truncating to four decimals
// does exactly that to an amount below 0.0001 — one base unit of an 18-decimal
// token, 500 of an 8-decimal one — and a real transfer or allowance then reads
// as "nothing is being moved" on the screen whose whole job is to say what is
// being authorized.
//
// When the truncated string carries no significant digit and the value does,
// the amount is extended to its first significant digit instead. It stays in
// token units, the same unit as the symbol printed beside it. A genuine zero
// still renders 0.0000, and anything at or above the floor is untouched.
function truncateAmountNeverZero(val) {
const truncated = truncateAmount(val);
// Tests the whole truncated string, integer part included: 1.00005 has a
// significant digit already and stays 1.0000.
if (/[1-9]/.test(truncated)) return truncated;
const parts = val.split(".");
if (parts.length === 1) return truncated;
const sig = parts[1].search(/[1-9]/);
if (sig === -1) return truncated;
return parts[0] + "." + parts[1].slice(0, sig + 1);
}
module.exports = { truncateAmount, truncateAmountNeverZero };

View File

@@ -14,10 +14,6 @@
// them answers, unknownDecimalsAmount() renders the base-unit integer with the
// unknown scale stated, and no formatUnits() call is reached at all.
//
// The Uniswap decoder's Amount and Min. received lines land on this same
// screen and use these same two functions, so there is one way of resolving a
// scale and one way of saying there is none.
//
// This is the display counterpart to transferAmount.js, which takes the same
// stance on the wallet's own send path: an amount whose scale is unknown or
// disputed is refused rather than guessed at.

View File

@@ -9,7 +9,6 @@ const {
formatUnits,
} = require("ethers");
const { ERC20_ABI } = require("./constants");
const { NETWORKS } = require("./networks");
const { log, debugFetch } = require("./log");
const { deriveAddressFromXpub } = require("./wallet");
const { TOKEN_BY_ADDRESS } = require("./tokenList");
@@ -18,38 +17,17 @@ const { isSpoofedSymbol } = require("./symbolSpoof");
// Use a static network to skip auto-detection (which can fail and cause
// "could not coalesce error" on some RPC endpoints like Cloudflare).
//
// `networkId` is REQUIRED, and is one of the ids in networks.js. It used to be
// optional, falling back to currentNetwork() — the module-level `state`
// singleton, which the MV3 service worker never populates. The endpoint then
// came out right and the static hint came out mainnet, so ethers fixed
// `chainId` at 0x1 and every non-mainnet dApp send was prepared for the wrong
// chain and then refused by the wallet's own verifier
// (https://git.eeqj.de/sneak/AutistMask/issues/320). Requiring it is what
// stops that from coming back: a caller that has no network to name has no
// business constructing a provider, and there is no longer a default for it
// to get silently wrong.
//
// Validated against NETWORKS rather than passed straight to Network.from():
// ethers knows chains this wallet does not, so an id that is not one of ours
// is a caller bug and must not resolve to a working provider for some other
// chain.
function getProvider(rpcUrl, networkId) {
const net = Network.from(requireNetworkId(networkId).id);
// Accepts an optional networkName ("mainnet" or "sepolia") for the static
// network hint so ethers picks the right chain parameters. When omitted,
// reads the currently selected network from extension state.
function getProvider(rpcUrl, networkName) {
// Lazy require to avoid circular dependency issues at module scope.
const { currentNetwork } = require("./state");
const name = networkName || currentNetwork().id;
const net = Network.from(name);
return new JsonRpcProvider(rpcUrl, net, { staticNetwork: net });
}
function requireNetworkId(networkId) {
const net = NETWORKS[networkId];
if (!net) {
throw new Error(
"getProvider requires the id of a supported network; got " +
JSON.stringify(networkId),
);
}
return net;
}
function formatBalance(wei) {
const eth = formatEther(wei);
const parts = eth.split(".");
@@ -140,15 +118,9 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
}
// Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses.
async function refreshBalances(
wallets,
rpcUrl,
blockscoutUrl,
trackedTokens,
networkId,
) {
async function refreshBalances(wallets, rpcUrl, blockscoutUrl, trackedTokens) {
log.debugf("refreshBalances start, rpc:", rpcUrl);
const provider = getProvider(rpcUrl, networkId);
const provider = getProvider(rpcUrl);
const updates = [];
for (const wallet of wallets) {
@@ -221,9 +193,9 @@ async function refreshBalances(
// Look up token metadata from its contract.
// Calls symbol() and decimals() to verify it implements ERC-20.
async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
async function lookupTokenInfo(contractAddress, rpcUrl) {
log.debugf("lookupTokenInfo", contractAddress, "rpc:", rpcUrl);
const provider = getProvider(rpcUrl, networkId);
const provider = getProvider(rpcUrl);
const contract = new Contract(contractAddress, ERC20_ABI, provider);
let name, symbol, decimals;
@@ -263,9 +235,9 @@ async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
// Checks gapLimit addresses in parallel per batch. Stops when an entire
// batch has no used addresses (i.e. gapLimit consecutive empty addresses).
// Returns { addresses: [{ address, index }], nextIndex }.
async function scanForAddresses(xpub, rpcUrl, networkId, gapLimit = 5) {
async function scanForAddresses(xpub, rpcUrl, gapLimit = 5) {
log.debugf("scanForAddresses start, gapLimit:", gapLimit);
const provider = getProvider(rpcUrl, networkId);
const provider = getProvider(rpcUrl);
const used = [];
let checked = 0;
let checkUpTo = gapLimit;

View File

@@ -1,23 +1,14 @@
// Consolidated chain-switch handler for the popup.
// Consolidated chain-switch handler.
//
// Every state change required when the active network changes is
// performed here so that callers (settings UI, future chain additions) all go
// performed here so that callers (settings UI, background
// wallet_switchEthereumChain, future chain additions) all go
// through a single code path.
//
// Adding a new chain (e.g. ETC) requires only a new entry in
// networks.js — no per-caller wiring is needed.
//
// The background does NOT come through here: this function mutates the
// module-level `state` singleton, which the MV3 service worker never
// populates, and a background switch performed on it wrote DEFAULT_STATE over
// the user's whole profile
// (https://git.eeqj.de/sneak/AutistMask/issues/316). The field mutations
// themselves live in chainSwitchFields.js, which takes the record to mutate as
// an argument; src/background/state.js applies them inside a read-modify-write
// against storage, and the singleton is not reachable from the background
// bundle at all (enforced by the ESLint rule in eslint.config.js).
const { applyChainSwitchFields } = require("./chainSwitchFields");
const { networkById } = require("./networks");
const { clearPrices } = require("./prices");
// Switch the active chain and reset all chain-specific cached state.
@@ -25,14 +16,56 @@ const { clearPrices } = require("./prices");
async function onChainSwitch(newNetworkId) {
const { state, saveState } = require("./state");
const net = applyChainSwitchFields(state, newNetworkId);
const net = networkById(newNetworkId);
// --- core identity ---
// Endpoints are remembered per network rather than reset to the
// defaults, because a user who points the wallet at their own node has
// no way to get that URL back once it is gone: overwriting it moved
// every address and every transaction onto a third-party endpoint
// silently and permanently.
//
// state.rpcUrl / state.blockscoutUrl stay the live endpoints of the
// active network, so nothing that reads them changes. The invariant is
// that for the ACTIVE network those two fields are authoritative and
// the map entry may be stale (Settings writes the fields directly);
// for every other network the map is authoritative. Snapshotting the
// outgoing network here, before the switch, is what reconciles them.
state.networkEndpoints[state.networkId] = {
rpcUrl: state.rpcUrl,
blockscoutUrl: state.blockscoutUrl,
};
const remembered = state.networkEndpoints[net.id] || {};
state.networkId = net.id;
state.rpcUrl = remembered.rpcUrl || net.defaultRpcUrl;
state.blockscoutUrl = remembered.blockscoutUrl || net.defaultBlockscoutUrl;
// --- price cache ---
// Prices are chain-specific (testnet tokens are worthless,
// ETC has different pricing, etc.). In-memory and per bundle, so this is
// the popup's own cache — the only context that ever fills it.
// ETC has different pricing, etc.).
clearPrices();
// --- balance / refresh state ---
// Reset last-refresh timestamp so the next polling cycle
// triggers an immediate balance refresh on the new chain.
state.lastBalanceRefresh = 0;
// Clear per-address balances and token balances so stale data
// from the previous chain is never displayed while the first
// refresh on the new chain is in flight.
for (const wallet of state.wallets) {
for (const addr of wallet.addresses) {
addr.balance = "0";
addr.tokenBalances = [];
}
}
// --- chain-specific caches ---
// Token holder counts and fraud contract lists are
// chain-specific and must not carry over.
state.tokenHolderCache = {};
state.fraudContracts = [];
await saveState();
return net;

View File

@@ -1,69 +0,0 @@
// The field mutations a chain switch performs, applied to a state record
// handed in rather than to the module-level `state` singleton.
//
// Split out of chainSwitch.js so the background can perform a chain switch
// without the singleton being reachable from its bundle at all. The popup
// still goes through onChainSwitch() (chainSwitch.js), which applies this to
// the singleton and saves; the background applies it to the detached record of
// its own read-modify-write (src/background/state.js).
//
// Everything here is synchronous and touches nothing but the object it is
// given: no storage, no caches, no imports beyond the network table. That is
// what makes it usable on a record that has been read fresh from storage
// microseconds earlier and is about to be written back.
const { networkById } = require("./networks");
// Switch `s` to `newNetworkId` and reset every piece of chain-specific state
// it carries. Returns the network configuration object for the new chain.
function applyChainSwitchFields(s, newNetworkId) {
const net = networkById(newNetworkId);
// --- core identity ---
// Endpoints are remembered per network rather than reset to the
// defaults, because a user who points the wallet at their own node has
// no way to get that URL back once it is gone: overwriting it moved
// every address and every transaction onto a third-party endpoint
// silently and permanently.
//
// s.rpcUrl / s.blockscoutUrl stay the live endpoints of the active
// network, so nothing that reads them changes. The invariant is that for
// the ACTIVE network those two fields are authoritative and the map entry
// may be stale (Settings writes the fields directly); for every other
// network the map is authoritative. Snapshotting the outgoing network
// here, before the switch, is what reconciles them.
if (!s.networkEndpoints) s.networkEndpoints = {};
s.networkEndpoints[s.networkId] = {
rpcUrl: s.rpcUrl,
blockscoutUrl: s.blockscoutUrl,
};
const remembered = s.networkEndpoints[net.id] || {};
s.networkId = net.id;
s.rpcUrl = remembered.rpcUrl || net.defaultRpcUrl;
s.blockscoutUrl = remembered.blockscoutUrl || net.defaultBlockscoutUrl;
// --- balance / refresh state ---
// Reset last-refresh timestamp so the next polling cycle
// triggers an immediate balance refresh on the new chain.
s.lastBalanceRefresh = 0;
// Clear per-address balances and token balances so stale data
// from the previous chain is never displayed while the first
// refresh on the new chain is in flight.
for (const wallet of s.wallets || []) {
for (const addr of wallet.addresses || []) {
addr.balance = "0";
addr.tokenBalances = [];
}
}
// --- chain-specific caches ---
// Token holder counts and fraud contract lists are
// chain-specific and must not carry over.
s.tokenHolderCache = {};
s.fraudContracts = [];
return net;
}
module.exports = { applyChainSwitchFields };

View File

@@ -32,11 +32,11 @@ function setCache(address, name) {
localStorage.setItem(key, JSON.stringify({ name, ts: Date.now() }));
}
async function resolveEnsName(address, rpcUrl, networkId) {
async function resolveEnsName(address, rpcUrl) {
const cached = getCached(address);
if (cached !== undefined) return cached;
const provider = getProvider(rpcUrl, networkId);
const provider = getProvider(rpcUrl);
try {
const name = (await provider.lookupAddress(address)) || null;
setCache(address, name);
@@ -48,11 +48,11 @@ async function resolveEnsName(address, rpcUrl, networkId) {
}
}
async function resolveEnsNames(addresses, rpcUrl, networkId) {
async function resolveEnsNames(addresses, rpcUrl) {
const results = new Map();
await Promise.all(
addresses.map(async (addr) => {
results.set(addr, await resolveEnsName(addr, rpcUrl, networkId));
results.set(addr, await resolveEnsName(addr, rpcUrl));
}),
);
return results;

View File

@@ -1,204 +0,0 @@
// The shape of the persisted profile, and the normalization every read of it
// goes through. No singleton, no storage access, no browser API: just the
// record definition and pure functions over it.
//
// Split out of state.js so that a context which must never touch the
// module-level `state` singleton can still speak the same record format.
// src/background/state.js is that context — the MV3 service worker never
// populates the singleton, and every defect in
// https://git.eeqj.de/sneak/AutistMask/issues/324 came from background code
// reaching it anyway and being served DEFAULT_STATE.
const { DEFAULT_RPC_URL, DEFAULT_BLOCKSCOUT_URL } = require("./constants");
// Dependency-free constant module; safe to pull into a background bundle.
const { RESTORABLE_VIEWS } = require("../popup/restorableViews");
const DEFAULT_STATE = {
hasWallet: false,
wallets: [],
trackedTokens: [],
networkId: "mainnet",
rpcUrl: DEFAULT_RPC_URL,
blockscoutUrl: DEFAULT_BLOCKSCOUT_URL,
// Endpoints remembered per network: { [networkId]: { rpcUrl,
// blockscoutUrl } }. rpcUrl/blockscoutUrl above are the live endpoints
// of the active network; this is what the others are restored from
// when the active network changes. See applyChainSwitchFields().
networkEndpoints: {},
lastBalanceRefresh: 0,
activeAddress: null,
allowedSites: {},
deniedSites: {},
rememberSiteChoice: true,
showZeroBalanceTokens: true,
hideSpoofedSymbols: true,
hideLowHolderTokens: true,
hideFraudContracts: true,
hideDustTransactions: true,
dustThresholdGwei: 100000,
utcTimestamps: false,
fraudContracts: [],
tokenHolderCache: {},
theme: "system",
debugMode: false,
};
// Every field written to and read from the single "autistmask" storage key.
// hasWallet is deliberately excluded from the diffing/merge logic in
// state.js — like loadState() does, it is always derived from `wallets`,
// never carried as an independent value.
const PERSISTED_FIELDS = Object.keys(DEFAULT_STATE)
.filter((key) => key !== "hasWallet")
.concat([
"currentView",
"selectedWallet",
"selectedAddress",
"selectedToken",
"viewData",
"viewStack",
]);
// 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
// behind it used to be restored verbatim, so Back could walk onto a screen
// whose content is deliberately never re-rendered — and "show-phrase" has no
// Back control to leave by. Truncating at the first such entry instead of
// splicing it out keeps the result a prefix of the stored stack, so every
// surviving entry's Back target is exactly the one it had; splicing would
// silently re-point the entry above the hole at a different screen.
//
// Filtering happens here on load rather than in saveState(): the live
// in-session stack is legitimate (the screen really is rendered while the
// popup is open), and only a load-side filter also repairs the stacks
// already in storage, including ones written before a view left the set.
function restorableStack(stored, currentView) {
// A stored stack that is missing or not an array keeps nothing, but it
// still goes through the never-empty rule below rather than returning
// early: otherwise a corrupt stack would depend on exactly the goBack()
// fallback that the explicit ["main"] exists in order not to depend on.
const source = Array.isArray(stored) ? stored : [];
const cut = source.findIndex((view) => !RESTORABLE_VIEWS.has(view));
const kept = cut === -1 ? source.slice() : source.slice(0, cut);
// A view restored below the root still needs somewhere for Back to go.
if (
kept.length === 0 &&
currentView !== "main" &&
RESTORABLE_VIEWS.has(currentView)
) {
return ["main"];
}
return kept;
}
// Turn a raw stored (or missing) record into the full, defaulted shape
// loadState() used to assign directly onto `state`. A pure function so that
// saveState() can apply it too: the fields THIS page did not change still have
// to come from storage in their loaded-and-normalized form, not as the raw
// bytes another page (or an old release) left there — otherwise a legacy shape
// a load has always self-healed in memory (a missing networkEndpoints map, an
// out-of-range flag) is dropped right back into storage unfixed every time the
// page that DID normalize it saves something unrelated, because that field's
// value never "changed" for that page to notice.
//
// The result never shares structure with `saved`, so a caller may mutate it
// freely: it is the detached record every per-call read in the background is
// built on.
function normalizePersisted(saved) {
saved = saved || {};
const out = {};
out.wallets = structuredClone(saved.wallets || []);
// Derived, never trusted verbatim off storage — see loadState().
out.hasWallet = out.wallets.length > 0;
out.trackedTokens = structuredClone(saved.trackedTokens || []);
out.networkId = saved.networkId || DEFAULT_STATE.networkId;
out.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl;
out.blockscoutUrl = 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
// sloppy mode. Copied rather than referenced, nested pairs included, so
// normalizing never mutates the object a caller handed in.
const rawEndpoints =
typeof saved.networkEndpoints === "object" &&
saved.networkEndpoints !== null &&
!Array.isArray(saved.networkEndpoints)
? saved.networkEndpoints
: {};
out.networkEndpoints = {};
for (const netId of Object.keys(rawEndpoints)) {
out.networkEndpoints[netId] = { ...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
// that network's remembered pair, so a custom endpoint set on the old
// build is not lost by the first switch away and back.
if (!out.networkEndpoints[out.networkId]) {
out.networkEndpoints[out.networkId] = {
rpcUrl: out.rpcUrl,
blockscoutUrl: out.blockscoutUrl,
};
}
out.lastBalanceRefresh = saved.lastBalanceRefresh || 0;
out.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.rememberSiteChoice =
saved.rememberSiteChoice !== undefined
? saved.rememberSiteChoice
: true;
out.showZeroBalanceTokens =
saved.showZeroBalanceTokens !== undefined
? saved.showZeroBalanceTokens
: true;
// A profile written before this setting existed has no key for it. It
// is a safety filter, so absent must load as on, not as undefined.
out.hideSpoofedSymbols =
saved.hideSpoofedSymbols !== undefined
? saved.hideSpoofedSymbols
: true;
out.hideLowHolderTokens =
saved.hideLowHolderTokens !== undefined
? saved.hideLowHolderTokens
: true;
out.hideFraudContracts =
saved.hideFraudContracts !== undefined
? saved.hideFraudContracts
: true;
out.hideDustTransactions =
saved.hideDustTransactions !== undefined
? saved.hideDustTransactions
: true;
out.dustThresholdGwei =
saved.dustThresholdGwei !== undefined
? saved.dustThresholdGwei
: 100000;
out.utcTimestamps =
saved.utcTimestamps !== undefined ? saved.utcTimestamps : false;
out.fraudContracts = structuredClone(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.viewData = structuredClone(saved.viewData || {});
out.viewStack = restorableStack(saved.viewStack, out.currentView);
return out;
}
module.exports = {
DEFAULT_STATE,
PERSISTED_FIELDS,
normalizePersisted,
restorableStack,
};

View File

@@ -1,42 +1,45 @@
// State management and extension storage persistence.
//
// The `state` export is a module-level singleton: ONE in-memory copy of the
// profile per bundle, loaded once by loadState() and mutated in place from
// then on. That is the popup's model — one page, one load at boot, one
// lifetime.
//
// It is NOT the background's model, and the background must not reach it. The
// MV3 service worker is torn down when idle and revived by the next message,
// nothing loads state at module scope, and an unpopulated read used to hand
// back DEFAULT_STATE with no complaint — five defects came out of that one
// fact (https://git.eeqj.de/sneak/AutistMask/issues/324). Two things close it:
// this module is unreachable from the background bundle, and reading a
// persisted field of the singleton before a load now THROWS instead of quietly
// serving a default.
//
// The unreachability is enforced by the BUILD. build.js fails when esbuild's
// own metafile reports this module as an input of a background bundle — the
// resolution the shipped file was built from, so no specifier syntax gets past
// it — from the table in script/lib/forbiddenBundleInputs.js, which also
// records what that does and does not cover. The ESLint rule that reports the
// same thing in the editor is fast feedback in front of the build, not the
// guarantee.
const { DEFAULT_RPC_URL, DEFAULT_BLOCKSCOUT_URL } = require("./constants");
const { networkById } = require("./networks");
const {
DEFAULT_STATE,
PERSISTED_FIELDS,
normalizePersisted,
} = require("./persistedState");
// Dependency-free constant module; safe to pull into a background bundle.
const { RESTORABLE_VIEWS } = require("../popup/restorableViews");
const { storageGet, storageSet } = require("./browserApi");
const { log } = require("./log");
// The live record the proxy below guards. Everything inside this module reads
// and writes THIS object, never the proxy: the guard is for callers.
const rawState = {
const DEFAULT_STATE = {
hasWallet: false,
wallets: [],
trackedTokens: [],
networkId: "mainnet",
rpcUrl: DEFAULT_RPC_URL,
blockscoutUrl: DEFAULT_BLOCKSCOUT_URL,
// Endpoints remembered per network: { [networkId]: { rpcUrl,
// blockscoutUrl } }. rpcUrl/blockscoutUrl above are the live endpoints
// of the active network; this is what the others are restored from
// when the active network changes. See onChainSwitch().
networkEndpoints: {},
lastBalanceRefresh: 0,
activeAddress: null,
allowedSites: {},
deniedSites: {},
rememberSiteChoice: true,
showZeroBalanceTokens: true,
hideSpoofedSymbols: true,
hideLowHolderTokens: true,
hideFraudContracts: true,
hideDustTransactions: true,
dustThresholdGwei: 100000,
utcTimestamps: false,
fraudContracts: [],
tokenHolderCache: {},
theme: "system",
debugMode: false,
};
const state = {
...DEFAULT_STATE,
// Its own object, not the one DEFAULT_STATE holds: applyChainSwitchFields()
// Its own object, not the one DEFAULT_STATE holds: onChainSwitch()
// mutates this map in place, and a spread copies the reference.
networkEndpoints: {},
currentView: null,
@@ -47,489 +50,175 @@ const rawState = {
viewStack: [],
};
// False until loadState() has completed in this bundle. Until then, a
// persisted field that has not been assigned in this context cannot be READ:
// see StateNotLoadedError.
let loaded = false;
// True once this context has assigned anything into the singleton.
// Keep only the leading run of stored views the popup is willing to render.
//
// What the guard is for is a context that READS a profile nobody put there —
// every one of the five defects was a pure read of an untouched singleton,
// answered out of DEFAULT_STATE. A context that has written into it is
// managing it deliberately (the popup does, via loadState() at boot and by
// hand thereafter), and reading back what you yourself put there is not the
// mistake being caught.
// restoreView() refuses to reopen ONTO a non-restorable view, but the stack
// behind it used to be restored verbatim, so Back could walk onto a screen
// whose content is deliberately never re-rendered — and "show-phrase" has no
// Back control to leave by. Truncating at the first such entry instead of
// splicing it out keeps the result a prefix of the stored stack, so every
// surviving entry's Back target is exactly the one it had; splicing would
// silently re-point the entry above the hole at a different screen.
//
// The cost of that is honest and worth naming: a context that writes one field
// and then reads a different, untouched one is still served that field's
// default. Nothing closes that here — what closes it for the background is
// that the background cannot reach this module at all, which build.js asserts
// against esbuild's metafile on every build (FORBIDDEN_INPUTS in
// script/lib/forbiddenBundleInputs.js, pinned by
// tests/buildForbiddenInputs.test.js).
let adopted = false;
// Every field whose pre-load value would be a plausible-looking default rather
// than the user's data. The view scratch fields are guarded too: currentView
// and viewStack are persisted, and a save that carried their pre-load values
// would overwrite a real stored stack with an empty one.
const GUARDED_FIELDS = new Set(PERSISTED_FIELDS.concat(["hasWallet"]));
class StateNotLoadedError extends Error {
constructor(field) {
super(
"state." +
field +
" was read before loadState(); this context has no profile" +
" loaded and must not be served DEFAULT_STATE",
);
this.name = "StateNotLoadedError";
// Filtering happens here on load rather than in saveState(): the live
// in-session stack is legitimate (the screen really is rendered while the
// popup is open), and only a load-side filter also repairs the stacks
// already in storage, including ones written before a view left the set.
function restorableStack(stored, currentView) {
// A stored stack that is missing or not an array keeps nothing, but it
// still goes through the never-empty rule below rather than returning
// early: otherwise a corrupt stack would depend on exactly the goBack()
// fallback that the explicit ["main"] exists in order not to depend on.
const source = Array.isArray(stored) ? stored : [];
const cut = source.findIndex((view) => !RESTORABLE_VIEWS.has(view));
const kept = cut === -1 ? source.slice() : source.slice(0, cut);
// A view restored below the root still needs somewhere for Back to go.
if (
kept.length === 0 &&
currentView !== "main" &&
RESTORABLE_VIEWS.has(currentView)
) {
return ["main"];
}
return kept;
}
// Loud, not defaulted. The whole defect class this guard closes looks exactly
// like working code at the call site: the read succeeds, the value is
// well-formed, and it describes a wallet that is not the user's.
const state = new Proxy(rawState, {
get(target, prop, receiver) {
if (
!loaded &&
!adopted &&
typeof prop === "string" &&
GUARDED_FIELDS.has(prop)
) {
throw new StateNotLoadedError(prop);
}
return Reflect.get(target, prop, receiver);
},
set(target, prop, value, receiver) {
if (typeof prop === "string" && GUARDED_FIELDS.has(prop)) {
adopted = true;
}
return Reflect.set(target, prop, value, receiver);
},
});
// Return the network configuration for the currently selected network.
function currentNetwork() {
return networkById(state.networkId);
}
// The persisted fields as they stood at the end of this page's last
// loadState() or saveState(). saveState() diffs the live state against this
// to find only the fields THIS page actually changed.
//
// Deep-cloned, not a reference: callers mutate persisted objects and arrays
// in place (state.wallets.push(...)), and a reference baseline would mutate
// right along with `state`, so the diff would always come out empty.
let baseline = null;
function snapshotPersisted() {
const out = {};
for (const key of PERSISTED_FIELDS) out[key] = rawState[key];
return out;
}
function deepEqual(a, b) {
if (a === b) return true;
if (typeof a !== "object" || typeof b !== "object") return false;
if (a === null || b === null) return false;
if (Array.isArray(a) !== Array.isArray(b)) return false;
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) return false;
for (const key of aKeys) {
if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
if (!deepEqual(a[key], b[key])) return false;
}
return true;
}
// Stable identity for a wallet, independent of its position in the array
// (which shifts under a concurrent add/delete elsewhere) and independent of
// its mutable fields (name is user-editable; addresses gains/loses entries
// via scanning and deleteAddress.js). An "hd"/"xprv" wallet's xpub never
// changes for its lifetime and is already enforced unique
// (findWalletByXpub() in addWallet.js). A "key" wallet has no xpub, exactly
// one address for its whole lifetime (nothing ever adds to or removes from
// a key wallet's address list), and that address is already enforced
// unique (findWalletByAddress()) — so it stands in for identity there.
// Neither invariant is enforced by this function or by
// mergeListByIdentity() below — they hold only because every wallet-
// creation path in addWallet.js happens to populate one or the other before
// the wallet ever reaches state.wallets, and because canRemoveAddress() in
// walletDelete.js never lets a wallet's address list go to zero. A wallet
// with neither (an empty/legacy/corrupt record) falls back to the same
// "addr:" identity as every other such record, which is a genuine
// collision, not a proxy for one — see the collision handling in
// mergeListByIdentity().
function walletIdentity(wallet) {
if (wallet.xpub) return "xpub:" + wallet.xpub;
const first = wallet.addresses && wallet.addresses[0];
return "addr:" + (first ? String(first.address).toLowerCase() : "");
}
// Stable identity for an address within one wallet's address list. An
// address is unique within its wallet and, once derived or imported, never
// changes — only whether it is present.
function addressIdentity(addr) {
return String(addr.address).toLowerCase();
}
// Merge one array of identity-bearing objects (wallets, or the addresses
// inside one wallet) by identity rather than by array index — an index
// shifts under a concurrent insert/delete elsewhere, which would merge the
// wrong pair of objects entirely.
//
// `theirs` (fresh storage) sets the membership baseline and the order:
// - An item this page never had baseline knowledge of, but that is in
// `theirs`, was added by someone else — kept as-is.
// - An item `base` had and `ours` no longer has was deleted by THIS page
// — dropped even though `theirs` still has it (this page's own delete
// must win over a background save that only touched leaf fields).
// - An item present in both `ours` and `theirs` is merged leaf-by-leaf via
// `mergeItem`, so a leaf this page changed (e.g. a renamed wallet) lands
// on top of `theirs`' otherwise-current copy (e.g. a refreshed balance).
// Anything left in `ours` that `base` never had and `theirs` does not have
// yet is this page's own new addition — appended.
//
// `identityOf` is not guaranteed collision-free (walletIdentity() falls
// back to one shared "addr:" value for any wallet with neither an xpub nor
// a populated first address). Two records that collide under it must never
// silently collapse into one — that is exactly how this function used to
// drop a wallet, encryptedSecret included, with no error and no log. Two
// defenses:
// - `ours` is indexed into GROUPS, not a single item per identity, so two
// colliding live items on this page can't overwrite each other in the
// index before the merge below even runs.
// - A matched pair with no shared `base` entry (neither page ever agreed
// on this identity) is only merged leaf-by-leaf when the two sides are
// already equal. If they differ, that is not "the same record edited
// twice", it is two different records that happen to share an identity
// — both are kept, unmerged, rather than guessing which one is real.
function mergeListByIdentity(base, ours, theirs, identityOf, mergeItem) {
base = base || [];
ours = ours || [];
theirs = theirs || [];
const baseIndex = new Map(base.map((item) => [identityOf(item), item]));
const oursIndex = new Map();
for (const item of ours) {
const id = identityOf(item);
if (!oursIndex.has(id)) oursIndex.set(id, []);
oursIndex.get(id).push(item);
}
const result = [];
const seen = new Set();
for (const theirItem of theirs) {
const id = identityOf(theirItem);
seen.add(id);
const oursGroup = oursIndex.get(id);
if (baseIndex.has(id) && !oursGroup) continue;
if (oursGroup) {
const baseItem = baseIndex.get(id);
if (!baseItem && !deepEqual(oursGroup[0], theirItem)) {
log.errorf(
"state: identity collision merging",
JSON.stringify(id),
"- keeping both records instead of dropping one",
);
result.push(theirItem, ...oursGroup);
} else {
result.push(mergeItem(baseItem, oursGroup[0], theirItem));
for (let i = 1; i < oursGroup.length; i++) {
result.push(oursGroup[i]);
}
}
} else {
result.push(theirItem);
}
}
for (const item of ours) {
const id = identityOf(item);
if (seen.has(id)) continue;
if (!baseIndex.has(id)) result.push(item);
}
return result;
}
// Merge one wallet's scalar/leaf fields (name, encryptedSecret, nextIndex,
// ...) against base, then recurse into its address list by identity. `base`
// is null when this page created the wallet itself and no other page has
// (yet) produced a same-identity record — nothing to merge in that case,
// this page's own copy wins outright. mergeListByIdentity() only ever calls
// this with `!base` when `ours` and `theirs` are already equal (a genuine
// collision between two DIFFERENT same-identity records is caught and kept
// as two separate entries before this function is reached), so returning
// `ours` here can't discard a different wallet's data.
function mergeWallet(base, ours, theirs) {
if (!base) return ours;
const merged = { ...theirs };
for (const key of Object.keys(ours)) {
if (key === "addresses") continue;
if (!deepEqual(ours[key], base[key])) merged[key] = ours[key];
}
merged.addresses = mergeListByIdentity(
base.addresses,
ours.addresses,
theirs.addresses,
addressIdentity,
mergeAddress,
);
return merged;
}
// Merge one address's leaf fields (balance, ensName, tokenBalances, ...).
// tokenBalances is itself an array, but only a balance refresh ever writes
// it and always wholesale (refreshBalances() in src/shared/balances.js), so
// there is no membership to reconcile within it — it is a leaf like balance
// or ensName, not a list with its own identity.
function mergeAddress(base, ours, theirs) {
if (!base) return ours;
const merged = { ...theirs };
for (const key of Object.keys(ours)) {
if (!deepEqual(ours[key], base[key])) merged[key] = ours[key];
}
return merged;
}
// Merge a plain object keyed by string (allowedSites/deniedSites: address ->
// hostname list; networkEndpoints: networkId -> {rpcUrl, blockscoutUrl}) the
// same way mergeListByIdentity() merges an array — by key, not by whole-
// object diff — so a key one page added or removed applies independently of
// 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.
function mergeMapByKey(base, ours, theirs, mergeLeaf) {
base = base || {};
ours = ours || {};
theirs = theirs || {};
const result = {};
const seen = new Set();
for (const key of Object.keys(theirs)) {
seen.add(key);
const inBase = Object.prototype.hasOwnProperty.call(base, key);
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]);
} else {
result[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];
}
}
return result;
}
// allowedSites/deniedSites: { [address]: [hostname, ...] }. The hostname
// list is itself membership, not a leaf — the background appends a newly
// approved/denied hostname to it, and the Settings "revoke" button
// (src/popup/views/settings.js) filters a hostname out of it in place, from a
// different page. Merge it the same way wallets are merged: identity is the
// hostname itself, so a merged pair is always equal and mergeItem is a no-op
// pick.
function mergeHostnameList(base, ours, theirs) {
return mergeListByIdentity(
base,
ours,
theirs,
(hostname) => hostname,
(b, o, t) => t,
);
}
function mergeSiteMap(base, ours, theirs) {
return mergeMapByKey(base, ours, theirs, mergeHostnameList);
}
// networkEndpoints: { [networkId]: {rpcUrl, blockscoutUrl} }.
// applyChainSwitchFields() (src/shared/chainSwitchFields.js) writes
// networkEndpoints[networkId] in place before saving. No code path ever
// removes a key from this map, so the membership collision that matters for
// allowedSites/wallets (an add on one page racing a delete on another) can't
// happen here — but two pages switching to two different networks
// concurrently still race a whole-field diff the same way, so it gets the same
// per-key merge for the leaf edit case (e.g. Settings saving a custom RPC URL
// for the active network).
function mergeEndpointEntry(base, ours, theirs) {
if (!base) return ours;
const merged = { ...theirs };
for (const key of Object.keys(ours)) {
if (!deepEqual(ours[key], base[key])) merged[key] = ours[key];
}
return merged;
}
function mergeNetworkEndpoints(base, ours, theirs) {
return mergeMapByKey(base, ours, theirs, mergeEndpointEntry);
}
// Read-modify-write, merged per field, rather than one full-blob write.
//
// Every extension page (the toolbar popup, a dApp approval window) holds its
// own in-memory `state`, loaded once, and showView() saves on every
// navigation. A full-blob write here clobbers whatever a second page had
// written since — including, in the worst case, an entire wallet and its
// encrypted secret with no attacker and no unusual input (see the issue this
// fixes).
//
// Only the fields this page actually changed — those that differ from
// `baseline`, captured at the last loadState()/saveState() on this page —
// are written; every other field is carried forward from whatever is in
// storage right now, which may already be a value another page wrote.
//
// `wallets` is merged structurally (mergeListByIdentity(), by wallet
// identity and then by address identity within each wallet), not as one
// whole field: a balance refresh mutates wallets IN PLACE (addr.balance /
// ensName / tokenBalances, via refreshBalances()), so a whole-field diff
// would mark all of `wallets` "changed" the moment any balance moved and
// write back that page's own copy — loaded before its multi-second network
// round trip — clobbering a wallet another page added, or resurrecting one
// another page deleted, in that window. Merging by identity lets the leaf
// changes and another page's membership changes (add/delete a wallet or an
// address) apply independently instead of colliding as the same field.
//
// `allowedSites` and `deniedSites` get the same treatment (mergeSiteMap(),
// by address key and then by hostname within each address's list), for the
// identical reason: the background appends a newly approved/denied hostname
// to them, and the Settings "revoke" button (src/popup/views/settings.js)
// filters one out in place, from a different page. A whole-field diff here
// doesn't just lose data, it is a security defect — a stale page's save can
// resurrect a just-revoked site permission, or silently wipe a permission just
// granted elsewhere.
//
// `networkEndpoints` gets the same treatment too (mergeNetworkEndpoints(),
// by network id), since applyChainSwitchFields() writes into it in place; the
// value per key is a small leaf object with no membership of its own; see the
// comment at mergeEndpointEntry() for why the collision this closes is
// milder than the other two.
//
// Every other persisted field stays a whole-field diff:
// `trackedTokens`/`fraudContracts`/`viewStack` are arrays of scalars with no
// per-element identity to merge by; `tokenHolderCache` is a map shaped like
// the ones above, but nothing in src/ ever writes an entry into it — it is
// only ever reset wholesale to `{}` (applyChainSwitchFields()) — so there is
// no in-place mutation for a whole-field diff to collide with; `viewData` is
// this page's own UI scratch space, not data another page has any reason to
// share membership of.
//
// This does not make two pages that both change the SAME leaf concurrently
// safe: last write wins on that one leaf, same as before. What it removes
// is the cross-field (and now cross-membership-vs-leaf) clobber — a page
// that only navigated, or only refreshed a balance, overwriting a wallet or
// address list it never touched the membership of.
//
// This page's own live `state` is deliberately NOT rehydrated from a field
// another page changed — only the record written to storage is merged.
// showView() fires saveState() on every navigation without awaiting it,
// which is what makes the queue above necessary in the first place, and a
// save that is slow to come back has no way to tell whether the field it
// is about to hand back is still the current answer or has since been
// overtaken by something this very page did in the meantime; writing it
// into `state` regardless reintroduced exactly the clobber this function
// exists to remove, just delayed and confined to one page instead of two
// (caught by tests/txStatus.test.js). A page's live picture of a field it
// does not own goes on being whatever its last loadState() saw, same as
// before this fix; only the persisted record is guaranteed current.
async function saveStateOnce() {
const current = snapshotPersisted();
const result = await storageGet("autistmask");
// Normalized, not raw: a field this page did not change still has to
// come from storage in its loaded (self-healed) shape. See
// normalizePersisted() in persistedState.js.
const fresh = normalizePersisted(result.autistmask);
const merged = { ...fresh };
for (const key of PERSISTED_FIELDS) {
if (key === "wallets") {
merged.wallets = mergeListByIdentity(
baseline ? baseline.wallets : [],
current.wallets,
fresh.wallets,
walletIdentity,
mergeWallet,
);
} else if (key === "allowedSites" || key === "deniedSites") {
merged[key] = mergeSiteMap(
baseline ? baseline[key] : {},
current[key],
fresh[key],
);
} else if (key === "networkEndpoints") {
merged.networkEndpoints = mergeNetworkEndpoints(
baseline ? baseline.networkEndpoints : {},
current.networkEndpoints,
fresh.networkEndpoints,
);
} else if (
baseline === null ||
!deepEqual(current[key], baseline[key])
) {
merged[key] = current[key];
}
}
merged.hasWallet = Boolean(merged.wallets && merged.wallets.length > 0);
await storageSet({ autistmask: merged });
// Derived from this page's own wallets, never adopted off the wire —
// see loadState(). Everything else this page did not change is left
// exactly as it stood; see the note above.
rawState.hasWallet = rawState.wallets.length > 0;
baseline = structuredClone(snapshotPersisted());
}
// showView() calls saveState() on every navigation without awaiting it, so
// two saves from the SAME page can be in flight at once — e.g. a screen
// shown, then immediately replaced before the first save's storageGet()
// round trip has come back. Left concurrent, the first save's turn would
// finish after the second's live-state mutation and then re-hydrate `state`
// from what IT read, stomping the second, later change back to a stale
// value — the same clobber this function exists to prevent, just between
// two saves on one page instead of two pages. Queuing makes every save's
// snapshot-diff-write-rehydrate run start to finish before the next one
// begins, so each one only ever sees the true live state at its turn.
let saveQueue = Promise.resolve();
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(() => {});
return turn;
async function saveState() {
const persisted = {
hasWallet: state.hasWallet,
wallets: state.wallets,
trackedTokens: state.trackedTokens,
networkId: state.networkId,
rpcUrl: state.rpcUrl,
blockscoutUrl: state.blockscoutUrl,
networkEndpoints: state.networkEndpoints,
lastBalanceRefresh: state.lastBalanceRefresh,
activeAddress: state.activeAddress,
allowedSites: state.allowedSites,
deniedSites: state.deniedSites,
rememberSiteChoice: state.rememberSiteChoice,
showZeroBalanceTokens: state.showZeroBalanceTokens,
hideSpoofedSymbols: state.hideSpoofedSymbols,
hideLowHolderTokens: state.hideLowHolderTokens,
hideFraudContracts: state.hideFraudContracts,
hideDustTransactions: state.hideDustTransactions,
dustThresholdGwei: state.dustThresholdGwei,
utcTimestamps: state.utcTimestamps,
fraudContracts: state.fraudContracts,
tokenHolderCache: state.tokenHolderCache,
theme: state.theme,
debugMode: state.debugMode,
currentView: state.currentView,
selectedWallet: state.selectedWallet,
selectedAddress: state.selectedAddress,
selectedToken: state.selectedToken,
viewData: state.viewData,
viewStack: state.viewStack,
};
await storageSet({ autistmask: persisted });
}
async function loadState() {
const result = await storageGet("autistmask");
if (result.autistmask) {
Object.assign(rawState, normalizePersisted(result.autistmask));
const saved = result.autistmask;
state.wallets = saved.wallets || [];
// Derived, never read from storage: a profile persisted with the flag
// out of step with the wallet list would otherwise stay broken on
// every load. Nothing depends on the two disagreeing.
state.hasWallet = state.wallets.length > 0;
state.trackedTokens = saved.trackedTokens || [];
state.networkId = saved.networkId || DEFAULT_STATE.networkId;
state.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl;
state.blockscoutUrl =
saved.blockscoutUrl || DEFAULT_STATE.blockscoutUrl;
// An actual object is required, not merely a truthy non-array: the
// code below and onChainSwitch() index and ASSIGN INTO this value,
// and assigning a property to a string or a number is a silent no-op
// in sloppy mode. A stored primitive would therefore be re-persisted
// unchanged forever, and every switch would fall back to the network
// default — the endpoint loss this map exists to prevent, with no
// self-healing. The allowedSites/deniedSites guards below are only
// read from, which is why they can be looser.
state.networkEndpoints =
typeof saved.networkEndpoints === "object" &&
saved.networkEndpoints !== null &&
!Array.isArray(saved.networkEndpoints)
? saved.networkEndpoints
: {};
// A profile written before this map existed carries exactly one pair
// of endpoints, belonging to whatever network it was last on. Adopt
// it as that network's remembered pair, so a custom endpoint set on
// the old build is not lost by the first switch away and back.
if (!state.networkEndpoints[state.networkId]) {
state.networkEndpoints[state.networkId] = {
rpcUrl: state.rpcUrl,
blockscoutUrl: state.blockscoutUrl,
};
}
state.lastBalanceRefresh = saved.lastBalanceRefresh || 0;
state.activeAddress = saved.activeAddress || null;
state.allowedSites =
saved.allowedSites && !Array.isArray(saved.allowedSites)
? saved.allowedSites
: {};
state.deniedSites =
saved.deniedSites && !Array.isArray(saved.deniedSites)
? saved.deniedSites
: {};
state.rememberSiteChoice =
saved.rememberSiteChoice !== undefined
? saved.rememberSiteChoice
: true;
state.showZeroBalanceTokens =
saved.showZeroBalanceTokens !== undefined
? saved.showZeroBalanceTokens
: true;
// A profile written before this setting existed has no key for it.
// It is a safety filter, so absent must load as on, not as undefined.
state.hideSpoofedSymbols =
saved.hideSpoofedSymbols !== undefined
? saved.hideSpoofedSymbols
: true;
state.hideLowHolderTokens =
saved.hideLowHolderTokens !== undefined
? saved.hideLowHolderTokens
: true;
state.hideFraudContracts =
saved.hideFraudContracts !== undefined
? saved.hideFraudContracts
: true;
state.hideDustTransactions =
saved.hideDustTransactions !== undefined
? saved.hideDustTransactions
: true;
state.dustThresholdGwei =
saved.dustThresholdGwei !== undefined
? saved.dustThresholdGwei
: 100000;
state.utcTimestamps =
saved.utcTimestamps !== undefined ? saved.utcTimestamps : false;
state.fraudContracts = saved.fraudContracts || [];
state.tokenHolderCache = saved.tokenHolderCache || {};
state.theme = saved.theme || "system";
state.debugMode =
saved.debugMode !== undefined ? saved.debugMode : false;
state.currentView = saved.currentView || null;
state.selectedWallet =
saved.selectedWallet !== undefined ? saved.selectedWallet : null;
state.selectedAddress =
saved.selectedAddress !== undefined ? saved.selectedAddress : null;
state.selectedToken = saved.selectedToken || null;
state.viewData = saved.viewData || {};
state.viewStack = restorableStack(saved.viewStack, state.currentView);
}
// Whether storage had a profile or was empty, this context has now read
// it, and the defaults standing in for an empty profile are the right
// answer rather than a stand-in for one nobody looked for.
loaded = true;
// The point of comparison every saveState() on this page diffs against.
// See PERSISTED_FIELDS in persistedState.js for why a reference here
// would be wrong.
baseline = structuredClone(snapshotPersisted());
}
// Through the guarded proxy, not rawState: a caller asking which address is
// selected before anything was loaded gets the same loud failure it would get
// reading the fields itself.
function currentAddress() {
if (state.selectedWallet === null || state.selectedAddress === null) {
return null;
@@ -543,5 +232,4 @@ module.exports = {
loadState,
currentAddress,
currentNetwork,
StateNotLoadedError,
};

View File

@@ -11,10 +11,6 @@ const { log, debugFetch } = require("./log");
const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { parseHoldersCount, isLowHolderCount } = require("./holders");
const { isSpoofedSymbol } = require("./symbolSpoof");
// The plain 4-decimal rule. The history and balance lists deliberately keep
// truncation without the approval screens' nonzero floor: the transaction
// detail view is the authoritative record and already shows exact precision.
const { truncateAmount: formatTxValue } = require("./amountDisplay");
// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum
// over the address, not part of its identity. Every address comparison in
@@ -24,6 +20,13 @@ function normalizeAddress(addr) {
return (addr || "").toLowerCase();
}
function formatTxValue(val) {
const parts = val.split(".");
if (parts.length === 1) return val + ".0000";
const dec = (parts[1] + "0000").slice(0, 4);
return parts[0] + "." + dec;
}
function parseTx(tx, addrLower) {
const from = tx.from?.hash || "";
const to = tx.to?.hash || "";

View File

@@ -3,11 +3,6 @@
const { Interface, AbiCoder, getBytes, formatUnits } = require("ethers");
const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { truncateAmountNeverZero } = require("./amountDisplay");
const {
resolveTokenDecimals,
unknownDecimalsAmount,
} = require("./approvalAmount");
const coder = AbiCoder.defaultAbiCoder();
@@ -39,46 +34,20 @@ const COMMAND_NAMES = {
0x21: "Execute Sub-Plan",
};
// The swap's Amount and Min. received lines land on the same approval screen,
// and Amount is carried to the wait/success/error screens as the ERC-20 line
// is, so they take the same nonzero floor: a swap of an amount below 0.0001 is
// not "0.0000", and a slippage floor of one base unit does not read as "you may
// receive nothing".
function formatAmount(raw, decimals) {
return truncateAmountNeverZero(formatUnits(raw, decimals));
const parts = formatUnits(raw, decimals).split(".");
if (parts.length === 1) return parts[0] + ".0000";
const dec = (parts[1] + "0000").slice(0, 4);
return parts[0] + "." + dec;
}
// `decimals` is null when nothing knows this token's scale. It is not
// defaulted to 18: the swap lines land on the same approval screen as the
// ERC-20 line, and a scale guessed there is what showed a 1,000 USDT swap as
// 0.000000000001. `sources` is { trackedTokens, wallets }, shaped as they are
// on `state`; resolveTokenDecimals() reads the bundled list, then those.
function tokenInfo(address, sources) {
function tokenInfo(address) {
if (!address || address === "0x0000000000000000000000000000000000000000") {
return { symbol: "ETH", decimals: 18, address: null };
}
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
return {
symbol: t ? t.symbol : null,
decimals: resolveTokenDecimals(address, sources),
address,
};
}
// A swap amount line. With a scale it is the token quantity; with none it is
// the base-unit integer with the unknown scale stated, the same refusal the
// ERC-20 amount line makes, so the screen has one way of saying it. `display`
// is the line on the screen, `raw` is what the status screens carry.
function amountText(raw, info) {
if (info.decimals === null) {
const unknown = unknownDecimalsAmount(raw);
return { raw: unknown, display: unknown };
}
const formatted = formatAmount(raw, info.decimals);
return {
raw: formatted,
display: formatted + (info.symbol ? " " + info.symbol : ""),
};
if (t) return { symbol: t.symbol, decimals: t.decimals, address };
return { symbol: null, decimals: 18, address };
}
// Decode PERMIT2_PERMIT (command 0x0a) input bytes.
@@ -351,7 +320,7 @@ function decodeV4Swap(input) {
// Try to decode a Universal Router execute() call.
// Returns { name, description, details } matching the format used by
// the approval UI, or null if the calldata is not a recognised execute().
function decode(data, toAddress, sources) {
function decode(data, toAddress) {
try {
const parsed = ROUTER_IFACE.parseTransaction({ data });
if (!parsed) return null;
@@ -444,10 +413,10 @@ function decode(data, toAddress, sources) {
}
// Resolve token info
const inInfo = tokenInfo(inputToken, sources);
const inInfo = tokenInfo(inputToken);
const outInfo = hasUnwrapWeth
? { symbol: "ETH", decimals: 18, address: null }
: tokenInfo(outputToken, sources);
: tokenInfo(outputToken);
const inSymbol = inInfo.symbol;
const outSymbol = outInfo.symbol;
@@ -484,42 +453,40 @@ function decode(data, toAddress, sources) {
"0xffffffffffffffffffffffffffffffffffffffff",
);
const isUnlimited = inputAmount >= maxUint160;
// An unbounded permit needs no scale to describe, so it is still
// named rather than refused.
const amount = isUnlimited
? { raw: "Unlimited", display: "Unlimited" }
: amountText(inputAmount, inInfo);
const amountRaw = isUnlimited
? "Unlimited"
: formatAmount(inputAmount, inInfo.decimals);
const amountStr = isUnlimited
? "Unlimited"
: amountRaw + (inSymbol ? " " + inSymbol : "");
details.push({
label: "Amount",
value: amount.display,
rawValue: amount.raw,
value: amountStr,
rawValue: amountRaw,
});
}
// Keyed on the address, not the symbol: a token absent from the
// bundled list has no symbol, and gating the line on one dropped it
// entirely, leaving a Min. received figure with nothing saying what is
// being received. The Token In line above already falls back to the
// address; this does the same.
if (outInfo.address) {
const label = outSymbol
? outSymbol + " (" + outInfo.address + ")"
: outInfo.address;
details.push({
label: "Token Out",
value: label,
address: outInfo.address,
isToken: true,
});
} else if (outSymbol) {
details.push({ label: "Token Out", value: outSymbol });
if (outSymbol) {
if (outInfo.address) {
const label = outSymbol
? outSymbol + " (" + outputToken + ")"
: outputToken;
details.push({
label: "Token Out",
value: label,
address: outputToken,
isToken: true,
});
} else {
details.push({ label: "Token Out", value: outSymbol });
}
}
if (minOutput !== null && minOutput !== undefined) {
details.push({
label: "Min. received",
value: amountText(minOutput, outInfo).display,
});
const minStr =
formatAmount(minOutput, outInfo.decimals) +
(outSymbol ? " " + outSymbol : "");
details.push({ label: "Min. received", value: minStr });
}
details.push({ label: "Steps", value: commandNames.join(" \u2192 ") });

View File

@@ -8,8 +8,6 @@
// A controllable clock plus a stubbed balance refresh, so a cadence test can
// measure the interval between refreshes that actually happened rather than
// asserting the interval someone intended.
const { makeStorageStub } = require("./support/storageStub");
let mockNow = 0;
const mockBalanceRefreshAt = [];
@@ -249,17 +247,32 @@ describe("alarms module", () => {
// Loads the background worker against stubbed browser APIs. The returned
// store is the extension storage the worker sees, so a test can seed wallet
// state and read back what the worker persisted.
// The stub clones in both directions, as the real chrome.storage.local does,
// and carries the latency simulation above on every operation. It used to
// alias, which for this file meant the worker's in-memory wallets and the
// "stored" ones were one object — see tests/support/storageStub.js.
function loadBackground(initialStore = {}) {
const storage = makeStorageStub(initialStore, mockStorageTick);
const storageStore = initialStore;
const alarmsStub = makeAlarmsStub();
const listeners = { onInstalled: [], onStartup: [] };
global.chrome = {
alarms: alarmsStub,
storage,
storage: {
local: {
get: async (key) => {
mockStorageTick();
return Object.prototype.hasOwnProperty.call(
storageStore,
key,
)
? { [key]: storageStore[key] }
: {};
},
set: async (items) => {
mockStorageTick();
Object.assign(storageStore, items);
},
remove: async (key) => {
delete storageStore[key];
},
},
},
runtime: {
onMessage: { addListener: jest.fn() },
onConnect: { addListener: jest.fn() },
@@ -288,7 +301,7 @@ function loadBackground(initialStore = {}) {
}));
jest.resetModules();
require("../src/background/index");
return { alarmsStub, listeners, storage };
return { alarmsStub, listeners, store: storageStore };
}
// Flush the promise chains the startup path and the alarm handlers run on.
@@ -463,16 +476,12 @@ describe("balance refresh steady-state cadence", () => {
// The guard's actual job, and the reason it is shortened rather than
// removed: while the popup is open it refreshes every 10 seconds and
// stamps the same field, and the background job has nothing to add.
const { alarmsStub, storage } = loadBackground(seededStore());
const store = seededStore();
const { alarmsStub } = loadBackground(store);
await settle();
mockNow += PERIOD_MS;
// As the open popup's own refresh would leave it: written to storage,
// not poked into an object the worker happens to share.
storage.write("autistmask", {
...storage.read("autistmask"),
lastBalanceRefresh: mockNow - 10 * 1000,
});
store.autistmask.lastBalanceRefresh = mockNow - 10 * 1000;
alarmsStub.fire(BALANCE_REFRESH_ALARM);
await settle();

View File

@@ -1,190 +0,0 @@
// The floor of the approval screen's amount line.
//
// Amounts are truncated to four decimal places for scannability (README.md,
// Display Consistency). With the token's true scale resolved, that truncation
// can still take a real amount below the floor and print it as `0.0000`: one
// base unit of an 18-decimal token, or a few hundred of an 8-decimal one. On
// the one screen whose job is to state what is being authorized, a nonzero
// transfer or allowance then reads as nothing.
//
// The invariant asserted here is narrow: a nonzero amount never renders as
// zero. The four-decimal rule itself is unchanged, and the string the
// confirmation screens carry as `txInfo.amount` is the same one, so it is
// asserted on `rawValue` alongside the displayed line.
//
// Both amount paths of that screen are covered: the ERC-20 line decoded by
// `src/popup/views/approval.js`, and the swap's `Amount` and `Min. received`
// lines decoded by `src/shared/uniswap.js`.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { AbiCoder, Interface } = require("ethers");
const { ERC20_ABI } = require("../src/shared/constants");
const { state } = require("../src/shared/state");
const { decodeCalldata } = require("../src/popup/views/approval");
const uniswap = require("../src/shared/uniswap");
const {
truncateAmount,
truncateAmountNeverZero,
} = require("../src/shared/amountDisplay");
const iface = new Interface(ERC20_ABI);
// Bundled tokens, so the scale and the symbol both come from the list.
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; // 6 decimals
const WBT = "0x925206b8a707096Ed26ae47C84747fE0bb734F59"; // 8 decimals
const DAI = "0x6B175474E89094C44Da98b954EedeAC495271d0F"; // 18 decimals
// Outside the list, so the scale comes from what the user tracks and the line
// carries no symbol.
const NOVEL = "0xE2E0000000000000000000000000000000000E2e";
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const SPENDER = "0x1111111111111111111111111111111111111111";
// The Uniswap swap lines land on this same approval screen.
const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
const USDT = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; // 6 decimals
const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; // 18 decimals
const coder = AbiCoder.defaultAbiCoder();
const routerIface = new Interface([
"function execute(bytes commands, bytes[] inputs, uint256 deadline)",
]);
// A V2_SWAP_EXACT_IN (command 0x08) execute() call: `amountIn` of USDT for at
// least `amountOutMin` of WETH.
function swapData(amountIn, amountOutMin) {
const input = coder.encode(
["address", "uint256", "uint256", "address[]", "bool"],
[RECIPIENT, amountIn, amountOutMin, [USDT, WETH], true],
);
return routerIface.encodeFunctionData("execute", [
"0x08",
[input],
9999999999n,
]);
}
function swapDetail(amountIn, amountOutMin, label) {
const decoded = uniswap.decode(swapData(amountIn, amountOutMin), ROUTER);
return decoded.details.find((d) => d.label === label);
}
function transferData(amount) {
return iface.encodeFunctionData("transfer", [RECIPIENT, amount]);
}
function approveData(amount) {
return iface.encodeFunctionData("approve", [SPENDER, amount]);
}
// The Amount detail as the approval screen renders it: `value` is the line on
// the screen, `rawValue` is what is carried to the wait/success/error screens.
function amount(data, token) {
const decoded = decodeCalldata(data, token);
return decoded.details.find((d) => d.label === "Amount");
}
beforeEach(() => {
state.trackedTokens = [];
state.wallets = [];
});
describe("a nonzero amount never renders as zero", () => {
test("500 base units of a 6-decimal token", () => {
const detail = amount(transferData(500n), USDC);
expect(detail.value).toBe("0.0005 USDC");
expect(detail.rawValue).toBe("0.0005");
});
test("1 base unit of an 18-decimal token", () => {
const detail = amount(transferData(1n), DAI);
expect(detail.value).toBe("0.000000000000000001 DAI");
expect(detail.rawValue).toBe("0.000000000000000001");
});
test("500 base units of an 8-decimal token", () => {
expect(amount(transferData(500n), WBT).rawValue).toBe("0.000005");
});
test("an allowance below the floor is not rendered as zero either", () => {
expect(amount(approveData(1n), DAI).value).toBe(
"0.000000000000000001 DAI",
);
});
// The floor holds at any scale, not only the three above: for every
// decimals a token can declare, one base unit has to show a digit.
test("one base unit shows a significant digit at every scale", () => {
for (let decimals = 0; decimals <= 30; decimals++) {
state.trackedTokens = [{ address: NOVEL, decimals }];
expect(amount(transferData(1n), NOVEL).rawValue).toMatch(/[1-9]/);
}
});
});
// The swap decoder formats its own amounts, so the same floor has to hold on
// the swap lines of the same screen. `Min. received` is the sharper of the
// two: the slippage floor rendered as `0.0000` states that the swap may return
// nothing.
describe("a swap's amounts never render as zero either", () => {
test("a swap input below the floor keeps a significant digit", () => {
// 50 base units of a 6-decimal token is 0.00005.
const detail = swapDetail(50n, 10n ** 15n, "Amount");
expect(detail.value).toBe("0.00005 USDT");
expect(detail.rawValue).toBe("0.00005");
});
test("a min-received below the floor keeps a significant digit", () => {
// 1 wei of an 18-decimal token.
expect(swapDetail(10n ** 6n, 1n, "Min. received").value).toBe(
"0.000000000000000001 WETH",
);
});
test("swap amounts at or above the floor are still truncated", () => {
expect(swapDetail(1000000n, 10n ** 15n, "Amount").rawValue).toBe(
"1.0000",
);
expect(
swapDetail(1000000n, 999999999999999999n, "Min. received").value,
).toBe("0.9999 WETH");
});
});
describe("the four-decimal rule is otherwise unchanged", () => {
test("a whole amount keeps exactly four decimals", () => {
expect(amount(transferData(5000000000n), USDC).rawValue).toBe(
"5000.0000",
);
});
test("precision beyond four decimals is still truncated", () => {
expect(amount(transferData(1234567890123456789n), DAI).rawValue).toBe(
"1.2345",
);
});
test("an amount at the floor is not extended", () => {
expect(amount(transferData(100000000000000n), DAI).rawValue).toBe(
"0.0001",
);
});
test("a genuine zero still renders as zero", () => {
expect(amount(transferData(0n), DAI).rawValue).toBe("0.0000");
});
// The three truncators now share one module. The floor is a policy of the
// approval and confirmation screens only: the history and balance lists
// keep plain truncation, because the transaction detail view is the
// authoritative record and already shows exact precision.
test("the list rule stays unfloored", () => {
expect(truncateAmount("0.000000000000000001")).toBe("0.0000");
expect(truncateAmountNeverZero("0.000000000000000001")).toBe(
"0.000000000000000001",
);
});
});

View File

@@ -24,7 +24,6 @@ const { Network, Wallet } = require("ethers");
// before any jest.doMock() of the module, so the copy assertions below check
// what the user is actually shown.
const { describeSigningFailure } = require("../src/shared/approvalVerify");
const { makeStorageStub } = require("./support/storageStub");
const SIGNER_KEY =
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
@@ -138,22 +137,22 @@ function loadBackground(options) {
jest.resetModules();
const broadcastTransaction = jest.fn();
const loadState = jest.fn(opts.loadState || (async () => {}));
// The node the transaction is populated against is on whatever chain the
// stored profile says, as it would be: switching networks switches the RPC
// endpoint too. The background takes the network from storage per call —
// it holds no in-memory copy — so this reads the record rather than a
// variable the test keeps alongside it.
const chainOf = (networkId) =>
networkId === "sepolia" ? SEPOLIA : MAINNET;
// The network the wallet is on, which the tests switch under a pending
// approval. The node the transaction is populated against is on the same
// one, as it would be: switching networks switches the RPC endpoint too.
let chain = MAINNET;
jest.doMock("../src/shared/state", () => ({
state: { rpcUrl: "https://rpc.invalid", wallets: [] },
loadState,
saveState: jest.fn(async () => {}),
currentNetwork: () => ({ chainId: chain.hex }),
}));
jest.doMock("../src/shared/balances", () => ({
getProvider: (rpcUrl, networkId) =>
fakeProvider(
broadcastTransaction,
opts.provider,
chainOf(networkId).num,
),
getProvider: () =>
fakeProvider(broadcastTransaction, opts.provider, chain.num),
refreshBalances: jest.fn(async () => {}),
}));
jest.doMock("../src/shared/phishingDomains", () => ({
@@ -178,31 +177,12 @@ function loadBackground(options) {
wallets: [
{ name: "Wallet 1", type: "hd", addresses: [signer.address] },
],
networkId: "mainnet",
rpcUrl: "https://rpc.invalid",
activeAddress: signer.address,
allowedSites: { [signer.address]: [HOSTNAME] },
deniedSites: {},
};
// The one wallet state there is. The background reads it per call and
// writes it read-modify-write; it holds no in-memory copy and cannot reach
// the shared singleton. Clones in both directions, as the real API does —
// the stub here used to hand back the live record and drop every write on
// the floor, so a test could neither see what was persisted nor be sure
// what it read had crossed the boundary
// (https://git.eeqj.de/sneak/AutistMask/issues/324).
const storage = makeStorageStub({ autistmask: persisted });
// A test that needs the state read itself to misbehave installs a hook —
// a stall, a throw — in place of the next reads. Armed after setup so
// that raising the approval is not what fails.
let storageGetHook = opts.storageGet || null;
const realGet = storage.local.get;
storage.local.get = jest.fn(async (key) =>
storageGetHook ? storageGetHook(key) : realGet(key),
);
let messageListener = null;
let windowRemovedListener = null;
let connectListener = null;
@@ -214,7 +194,15 @@ function loadBackground(options) {
const actionPopups = [];
global.chrome = {
storage,
storage: {
local: {
get: jest.fn(
opts.storageGet ||
(async () => ({ autistmask: persisted })),
),
set: jest.fn(async () => {}),
},
},
runtime: {
getURL: (path) => EXT_URL + path,
onMessage: {
@@ -413,32 +401,18 @@ function loadBackground(options) {
connectApproval,
closeWindow,
broadcastTransaction,
loadState,
created,
removed,
storage,
// The user switching account in the toolbar popup, as the background
// sees it: the persisted active address changes underneath a pending
// approval.
setActiveAddress: (address) => {
storage.write("autistmask", {
...storage.read("autistmask"),
activeAddress: address,
});
persisted.activeAddress = address;
},
// The user switching network in the toolbar popup. It moves the stored
// network and the endpoint together, as a real switch does.
// The user switching network in the toolbar popup.
setNetwork: (network) => {
const networkId = network === SEPOLIA ? "sepolia" : "mainnet";
storage.write("autistmask", {
...storage.read("autistmask"),
networkId,
rpcUrl: "https://rpc-" + networkId + ".invalid",
});
},
// Make the next state reads misbehave — stall, throw — without
// touching the reads that raised the approval. Pass null to restore.
setStateReadHook: (hook) => {
storageGetHook = hook;
chain = network;
},
fromPopup: { url: EXT_URL + "src/popup/index.html" },
};
@@ -703,16 +677,15 @@ describe("one transaction approval at a time", () => {
// page never — and holds the slot for the life of the worker with it.
test("an approval whose window closed under a failed attempt is answered, and frees the next request", async () => {
const stalled = deferred();
const bg = loadBackground();
const bg = loadBackground({
loadState: async () => {
await stalled.promise;
throw new Error("The wallet data could not be read.");
},
});
const first = bg.requestTx();
await settle();
// Armed only now: the approval was raised against a working state
// read, and it is the ATTEMPT's read that hangs and then fails.
bg.setStateReadHook(async () => {
await stalled.promise;
throw new Error("The wallet data could not be read.");
});
bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
@@ -736,7 +709,6 @@ describe("one transaction approval at a time", () => {
error: { code: 4001, message: "User rejected the request." },
});
bg.setStateReadHook(null);
const second = bg.requestTx();
await settle();
expect(second.result()).toBeNull();
@@ -1254,18 +1226,19 @@ describe("what the approval is verified against", () => {
// The interlock must not cost the retry the approval exists to allow.
describe("the interlock releases a failed attempt", () => {
test("a retryable failure before the broadcast leaves the approval usable", async () => {
const bg = loadBackground();
let failNext = true;
const bg = loadBackground({
loadState: async () => {
if (failNext) {
failNext = false;
throw new Error("storage unavailable");
}
},
});
const pending = bg.requestTx();
await settle();
const id = pending.id();
// The attempt's state read fails once, then works: nothing was
// broadcast, so the approval must survive for the retry.
bg.setStateReadHook(() => {
bg.setStateReadHook(null);
throw new Error("storage unavailable");
});
const first = bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",

View File

@@ -1,398 +0,0 @@
// What one background handler's state can do to another's while both are in
// flight.
//
// The background used to read and write the module-level `state` singleton in
// src/shared/state.js — one object, shared by every handler in the worker,
// replaced wholesale by any loadState(). Two consequences, both covered here
// and both from https://git.eeqj.de/sneak/AutistMask/issues/324:
//
// - A transaction attempt captured the chain id at its loadState() and then
// read the ENDPOINT off the singleton several awaits later. A chain switch
// committed in that window moved the endpoint under an artifact already
// verified against the old chain, so it would have gone to the new chain's
// node — the very thing the verification exists to prevent.
//
// - backgroundRefresh() handed the singleton's wallets to refreshBalances(),
// which mutates address objects in place across a multi-second network
// round trip. Any concurrent handler that loaded state replaced those
// objects, so the refreshed balances landed on detached ones and the save
// that followed persisted the pre-refresh values — while still stamping
// lastBalanceRefresh, suppressing the redo.
//
// Both use the real persistence path over a cloning storage stub. Nothing here
// asserts the absence of a loadState() call; each asserts the OUTCOME, so it
// holds against any implementation that gets the outcome right.
const { Wallet } = require("ethers");
const { networkById } = require("../src/shared/networks");
const { makeStorageStub } = require("./support/storageStub");
const SIGNER_KEY =
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
const signer = new Wallet(SIGNER_KEY);
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const CONNECTED_ORIGIN = "https://dapp.example";
const CONNECTED_HOSTNAME = "dapp.example";
const EXT_URL = "chrome-extension://autistmask/";
const MAINNET = networkById("mainnet");
const SEPOLIA = networkById("sepolia");
const NONCE = 7;
const REFRESHED_BALANCE = "1.5";
// The transaction the background populates, and the artifact signed from it.
// Its chain is a parameter because the whole subject here is a chain moving
// under work already committed to one.
function populated(chainId) {
return {
type: 2,
chainId,
nonce: NONCE,
gasLimit: 100000n,
maxFeePerGas: 2000000000n,
maxPriorityFeePerGas: 1000000000n,
to: RECIPIENT,
value: 10000000000000000n,
data: "0x",
};
}
function storedProfile(networkId) {
const net = networkById(networkId);
return {
hasWallet: true,
wallets: [
{
name: "Wallet 1",
type: "hd",
xpub: "xpub-1",
addresses: [
{
address: signer.address,
balance: "0.0",
tokenBalances: [],
},
],
},
],
activeAddress: signer.address,
networkId,
rpcUrl: net.defaultRpcUrl,
blockscoutUrl: net.defaultBlockscoutUrl,
allowedSites: { [signer.address]: [CONNECTED_HOSTNAME] },
deniedSites: {},
trackedTokens: [],
lastBalanceRefresh: 0,
};
}
async function settle() {
for (let i = 0; i < 60; i++) await Promise.resolve();
}
function deferred() {
let resolve;
const promise = new Promise((res) => {
resolve = res;
});
return { promise, resolve };
}
afterEach(() => {
delete global.chrome;
});
// The background worker over a cloning storage stub, with the network and the
// clock stubbed out. `opts.refreshBalances` replaces the balance refresh so a
// test can hold one open across another handler's whole turn.
function loadWorker(networkId, opts) {
const options = opts || {};
jest.resetModules();
// Every provider this worker constructs, in order, with the endpoint and
// the network id it was given. The subject of the first test is which pair
// reaches the broadcast.
const providers = [];
const broadcastTransaction = jest.fn(async () => ({ hash: "0xfeed" }));
jest.doMock("../src/shared/balances", () => ({
getProvider: (rpcUrl, networkId2) => {
const provider = {
rpcUrl,
networkId: networkId2,
broadcastTransaction,
getNetwork: async () => ({
chainId: BigInt(networkById(networkId2).networkVersion),
}),
getTransactionCount: async () => NONCE,
estimateGas: async () => 100000n,
getFeeData: async () => ({
gasPrice: 2000000000n,
maxFeePerGas: 2000000000n,
maxPriorityFeePerGas: 1000000000n,
}),
};
providers.push(provider);
return provider;
},
refreshBalances:
options.refreshBalances || jest.fn(async () => undefined),
}));
jest.doMock("../src/shared/phishingDomains", () => ({
isPhishingDomain: () => false,
}));
let alarmHandlers = {};
jest.doMock("../src/shared/alarms", () => ({
BALANCE_REFRESH_ALARM: "balance",
BALANCE_REFRESH_PERIOD_MINUTES: 1,
ensureRecurringAlarms: jest.fn(async () => {}),
registerAlarmHandlers: jest.fn((handlers) => {
alarmHandlers = handlers;
}),
}));
const storage = makeStorageStub({ autistmask: storedProfile(networkId) });
// A hook the tests use to suspend one handler mid-flight, so the other one
// runs entirely inside its window.
let getHook = null;
const realGet = storage.local.get;
storage.local.get = jest.fn(async (key) => {
if (getHook) await getHook();
return realGet(key);
});
let messageListener = null;
// Every popup URL the background opened. The approval id is in it, and
// that is how the popup learns which approval it is answering.
const createdUrls = [];
global.chrome = {
storage,
runtime: {
getURL: (path) => EXT_URL + path,
onMessage: {
addListener: (fn) => {
messageListener = fn;
},
},
onConnect: { addListener: () => {} },
lastError: null,
},
windows: {
getLastFocused: (cb) => cb(null),
create: (createOpts, cb) => {
createdUrls.push(createOpts.url);
cb({ id: createdUrls.length });
},
remove: (id, cb) => {
if (cb) cb();
},
onRemoved: { addListener: () => {} },
},
tabs: {
query: (queryInfo, cb) => cb([{ id: 1 }]),
sendMessage: (tabId, message, cb) => {
if (cb) cb();
},
},
action: { setPopup: () => {} },
};
require("../src/background/index");
function send(msg, sender) {
let result = null;
const kept = messageListener(msg, sender, (r) => {
result = r;
});
return { kept, result: () => result };
}
function rpc(method, params, origin) {
return send(
{ type: "AUTISTMASK_RPC", method, params },
{ origin: origin || CONNECTED_ORIGIN },
);
}
return {
rpc,
send,
providers,
broadcastTransaction,
persisted: () => storage.read("autistmask"),
setGetHook: (hook) => {
getHook = hook;
},
fromPopup: { url: EXT_URL + "src/popup/index.html" },
fireBalanceAlarm: () => alarmHandlers.balance(),
lastApprovalId: () => {
const url = createdUrls[createdUrls.length - 1];
if (!url) return null;
return new URL(url, EXT_URL).searchParams.get("approval");
},
};
}
describe("a chain switch under a transaction already committed to a chain", () => {
// Item 4 of https://git.eeqj.de/sneak/AutistMask/issues/324.
//
// The artifact is verified against the chain read at the top of the
// attempt. Whatever endpoint it is then broadcast to has to be that same
// chain's — otherwise the wallet checks a transaction against Sepolia and
// sends it to a mainnet node. A connected site can switch the chain at any
// moment, including this one.
test("the artifact is broadcast to the endpoint of the chain it was verified against", async () => {
const bg = loadWorker("sepolia");
// Raise the approval, then find its id from the popup's own fetch.
bg.rpc("eth_sendTransaction", [
{
from: signer.address,
to: RECIPIENT,
value: "0x2386f26fc10000",
data: "0x",
},
]);
await settle();
const id = bg.lastApprovalId();
expect(id).toBeTruthy();
// The popup signs what it was shown: Sepolia.
const rawSignedTx = await signer.signTransaction(
populated(Number(SEPOLIA.networkVersion)),
);
// A connected site switches the chain while the attempt is running,
// and the switch is committed to storage in full before the attempt
// goes any further.
//
// It is fired from inside the attempt's SECOND state read, because
// that is where the window used to be: the chain id was captured at
// the first read and the endpoint was taken off the singleton several
// awaits later, so a switch landing between them moved the endpoint
// under an artifact already verified against the old chain. An
// implementation that takes both from one read has no second read for
// this to fire on, and the switch below runs after the attempt is
// done instead — which is the point.
let reads = 0;
let switched = null;
const doSwitch = async () => {
switched = bg.rpc("wallet_switchEthereumChain", [
{ chainId: MAINNET.chainId },
]);
await settle();
};
bg.setGetHook(async () => {
reads++;
if (reads !== 2) return;
bg.setGetHook(null);
await doSwitch();
});
const attempt = bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id,
approved: true,
rawSignedTx,
},
{ url: bg.fromPopup.url },
);
await settle();
bg.setGetHook(null);
if (!switched) await doSwitch();
expect(switched.result()).toEqual({ result: null });
expect(bg.persisted().networkId).toBe("mainnet");
await settle();
// It went out, and it went out to Sepolia's node — the chain the
// artifact was verified against. Reading the endpoint separately from
// the chain id put mainnet's here.
expect(attempt.result()).toEqual({ txHash: "0xfeed" });
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
const used = bg.providers[bg.providers.length - 1];
expect(used.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
expect(used.networkId).toBe("sepolia");
});
});
describe("a balance refresh under another handler's state read", () => {
// Item 5 of https://git.eeqj.de/sneak/AutistMask/issues/324.
//
// The trigger is a same-chain wallet_switchEthereumChain from a connected
// site: it answers { result: null } and changes nothing, so the ONLY thing
// it can do to the refresh is what its state read does. On the singleton
// that read replaced state.wallets, detaching the objects the refresh was
// mutating.
test("a chain read arriving mid-refresh does not discard the refresh", async () => {
const roundTrip = deferred();
const reachedNetwork = deferred();
const bg = loadWorker("sepolia", {
refreshBalances: async (wallets) => {
reachedNetwork.resolve();
await roundTrip.promise;
// In place, on the objects handed in — as balances.js does.
wallets[0].addresses[0].balance = REFRESHED_BALANCE;
},
});
const refresh = bg.fireBalanceAlarm();
await reachedNetwork.promise;
const answered = bg.rpc("wallet_switchEthereumChain", [
{ chainId: SEPOLIA.chainId },
]);
await settle();
expect(answered.result()).toEqual({ result: null });
roundTrip.resolve();
await refresh;
expect(bg.persisted().wallets[0].addresses[0].balance).toBe(
REFRESHED_BALANCE,
);
expect(bg.persisted().lastBalanceRefresh).toBeGreaterThan(0);
});
// The other half of "does not publish a shared object": a wallet added
// while the refresh was in flight must survive the refresh's own write.
test("a wallet added mid-refresh survives the refresh's write", async () => {
const roundTrip = deferred();
const reachedNetwork = deferred();
const bg = loadWorker("sepolia", {
refreshBalances: async (wallets) => {
reachedNetwork.resolve();
await roundTrip.promise;
wallets[0].addresses[0].balance = REFRESHED_BALANCE;
},
});
const refresh = bg.fireBalanceAlarm();
await reachedNetwork.promise;
// Another extension page adds a wallet while the round trip is out.
const during = bg.persisted();
during.wallets.push({
name: "Wallet 2",
type: "hd",
xpub: "xpub-2",
addresses: [
{ address: RECIPIENT, balance: "0.0", tokenBalances: [] },
],
});
global.chrome.storage.write("autistmask", during);
roundTrip.resolve();
await refresh;
const after = bg.persisted();
expect(after.wallets).toHaveLength(2);
expect(after.wallets[0].addresses[0].balance).toBe(REFRESHED_BALANCE);
});
});

View File

@@ -1,270 +0,0 @@
// The lint rule that keeps src/shared/state.js out of the background bundle
// (script/lib/eslint/noStateSingletonInBackground.js).
//
// Five defects, one of which destroyed a wallet, came from background code
// reaching that singleton, and each point fix created the next site
// (https://git.eeqj.de/sneak/AutistMask/issues/324).
//
// What this file does NOT do is establish that the singleton cannot reach the
// background bundle. That is build.js's FORBIDDEN_INPUTS assertion, which reads
// esbuild's metafile and so cannot be evaded by a syntax a matcher does not
// know; it is pinned by tests/buildForbiddenInputs.test.js. The rule under test
// here is fast local feedback in front of that, and these cases pin the shapes
// it is known to catch, so a regression in the matcher is a failing test rather
// than a quietly narrower rule.
//
// Every shape below was measured against a real `make build`: each one puts
// state.js in the shipped background bundles, and each one was invisible to
// some earlier revision of the matcher — the quoted-only regex missed the
// backtick, the dynamic import and the `from` clause; its successor missed a
// comment inside the call and a directory resolved through package.json `main`.
//
// Two shapes the rule does NOT report are pinned below as non-reports, in
// "the divergences from the build's answer": a computed specifier such as
// `require("../shared/" + "state")`, which esbuild constant-folds, and a
// symlink to the module, whose real path esbuild reports. Both put state.js in
// the shipped background bundle and both are `make build` exit 2 with
// `make lint` exit 0 (measured). Pinning them as non-reports is what makes the
// rule's stated bounds a measured description rather than a claim: if either
// starts being reported, or the matcher is widened until one is, a test says
// so. Their catch is the build's, and is pinned in
// tests/buildForbiddenInputs.test.js against the metafile that catches it.
const fs = require("fs");
const os = require("os");
const path = require("path");
const { Linter } = require("eslint");
const plugin = require("../script/lib/eslint/noStateSingletonInBackground");
const RULE = "background/no-state-singleton-in-background";
// The three files a fixture tree always has. `src/background/index.js` is
// supplied per case; the other two stand in for the real modules.
const SHARED_STATE = "const state = {};\nmodule.exports = { state };\n";
const SHARED_HOP =
"// A shared module the background legitimately imports.\n" +
"module.exports = { applyChainSwitchFields() {} };\n";
let roots = [];
function fixture(files) {
const root = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), "autistmask-state-rule-")),
);
roots.push(root);
const tree = {
"src/shared/state.js": SHARED_STATE,
"src/shared/chainSwitchFields.js": SHARED_HOP,
...files,
};
for (const [rel, source] of Object.entries(tree)) {
const abs = path.join(root, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, source);
}
return root;
}
// Run the rule exactly as eslint.config.js runs it, over a real tree: the walk
// reads its sources from disk, so a virtual RuleTester would not exercise it.
// `sourceType` is the fixture's own, not the rule's business: the walk is
// textual and never parses the files it follows. The two ESM cases below pass
// "module" only so espree can parse the fixture at all — in this repo those
// shapes are also a parse error under the commonjs config, but the rule must
// not be left depending on that.
function lintBackground(root, { sourceType = "commonjs" } = {}) {
const file = path.join(root, "src/background/index.js");
const linter = new Linter({ cwd: root });
return linter.verify(
fs.readFileSync(file, "utf8"),
{
plugins: { background: plugin },
languageOptions: { ecmaVersion: 2024, sourceType },
rules: { [RULE]: "error" },
},
file,
);
}
function chainOf(messages) {
expect(messages).toHaveLength(1);
expect(messages[0].ruleId).toBe(RULE);
// "...singleton: <chain>. The MV3 worker..." — the chain is what the
// message exists to hand the reader, so assert on it rather than on the
// fact that something was reported.
return messages[0].message.split("singleton: ")[1].split(". The MV3")[0];
}
afterEach(() => {
for (const root of roots) fs.rmSync(root, { recursive: true, force: true });
roots = [];
});
describe("the specifier syntaxes the matcher is known to catch", () => {
test("a quoted require", () => {
const root = fixture({
"src/background/index.js":
'const { state } = require("../shared/state");\n' +
"module.exports = { state };\n",
});
expect(chainOf(lintBackground(root))).toBe(
"src/background/index.js -> src/shared/state.js",
);
});
test("a backtick require", () => {
const root = fixture({
"src/background/index.js":
"const { state } = require(`../shared/state`);\n" +
"module.exports = { state };\n",
});
expect(chainOf(lintBackground(root))).toBe(
"src/background/index.js -> src/shared/state.js",
);
});
test("a dynamic import inside an async function", () => {
const root = fixture({
"src/background/index.js":
"async function readState() {\n" +
' const m = await import("../shared/state");\n' +
" return m.state;\n" +
"}\n" +
"module.exports = { readState };\n",
});
expect(chainOf(lintBackground(root))).toBe(
"src/background/index.js -> src/shared/state.js",
);
});
test("a static import from-clause", () => {
const root = fixture({
"src/background/index.js":
'import { state } from "../shared/state";\n' +
"export { state };\n",
});
expect(chainOf(lintBackground(root, { sourceType: "module" }))).toBe(
"src/background/index.js -> src/shared/state.js",
);
});
// `import(/* webpackChunkName: "x" */ "./x")` is the standard bundler
// annotation idiom, and prettier leaves both of these exactly as written,
// so nothing else in the repo would object to them either.
test("a comment between the paren and the specifier", () => {
const root = fixture({
"src/background/index.js":
'globalThis.__probe = require(/* probe */ "../shared/state").state;\n',
});
expect(chainOf(lintBackground(root))).toBe(
"src/background/index.js -> src/shared/state.js",
);
});
test("a comment between the specifier and the closing paren", () => {
const root = fixture({
"src/background/index.js":
'globalThis.__probe = require("../shared/state" /* probe */).state;\n',
});
expect(chainOf(lintBackground(root))).toBe(
"src/background/index.js -> src/shared/state.js",
);
});
test("a bare side-effect import", () => {
const root = fixture({
"src/background/index.js": 'import "../shared/state";\n',
});
expect(chainOf(lintBackground(root, { sourceType: "module" }))).toBe(
"src/background/index.js -> src/shared/state.js",
);
});
});
describe("reachability, not just the direct specifier", () => {
// The shape a no-restricted-imports could never see: no background file
// names state.js, and the singleton is in the bundle anyway. In a backtick
// require, so this fails on the specifier widening as well as on the walk.
test("a two-hop re-export through a shared module", () => {
const root = fixture({
"src/background/index.js":
'const { applyChainSwitchFields } = require("../shared/chainSwitchFields");\n' +
"module.exports = { applyChainSwitchFields };\n",
"src/shared/chainSwitchFields.js":
SHARED_HOP +
"module.exports.state = require(`./state`).state;\n",
});
expect(chainOf(lintBackground(root))).toBe(
"src/background/index.js -> src/shared/chainSwitchFields.js" +
" -> src/shared/state.js",
);
});
// Resolution, not syntax: the specifier names a directory, and the file it
// resolves to is chosen by that directory's package.json `main`. A walk
// that only tries `<dir>/index.js` stops on a specifier it matched.
test("a directory resolved through its package.json main", () => {
const root = fixture({
"src/background/index.js":
'globalThis.__probe = require("../shared/probepkg").state;\n',
"src/shared/probepkg/package.json": '{"main": "./bridge.js"}\n',
"src/shared/probepkg/bridge.js":
'const { state } = require("../state");\n' +
"module.exports = { state };\n",
});
expect(chainOf(lintBackground(root))).toBe(
"src/background/index.js -> src/shared/probepkg/bridge.js" +
" -> src/shared/state.js",
);
});
});
// These two are holes in the rule, and they are pinned as holes on purpose:
// the build catches both, the rule is fast feedback in front of it, and a
// written-down bound that nothing measures is how the previous three rounds of
// this change ended up with claims that were false.
describe("the divergences from the build's answer", () => {
test("a computed specifier is not reported (esbuild folds it; the build fails)", () => {
const root = fixture({
"src/background/index.js":
'globalThis.__probe = require("../shared/" + "state").state;\n',
});
expect(lintBackground(root)).toEqual([]);
});
test("a symlink to the module is not reported (esbuild reports the real path)", () => {
const root = fixture({
"src/background/index.js":
'const { state } = require("../shared/stateLink");\n' +
"module.exports = { state };\n",
});
fs.symlinkSync(
path.join(root, "src/shared/state.js"),
path.join(root, "src/shared/stateLink.js"),
);
expect(lintBackground(root)).toEqual([]);
});
});
describe("what the rule must not report", () => {
test("a background file that reaches only its own state layer", () => {
const root = fixture({
"src/background/index.js":
'const { getState } = require("./state");\n' +
'const { applyChainSwitchFields } = require("../shared/chainSwitchFields");\n' +
"module.exports = { getState, applyChainSwitchFields };\n",
"src/background/state.js":
"async function getState() {}\nmodule.exports = { getState };\n",
});
expect(lintBackground(root)).toEqual([]);
});
// The tree as it actually stands. This is the assertion that would catch a
// widened matcher that resolves something it should not: it runs the rule
// over the real background entrypoint, from the real repo root.
test("the repository's own background entrypoint", () => {
const root = path.resolve(__dirname, "..");
expect(lintBackground(root)).toEqual([]);
});
});

View File

@@ -1,366 +0,0 @@
// build.js's FORBIDDEN_INPUTS assertion — the mechanical guarantee that the
// MV3 background bundle cannot contain src/shared/state.js
// (https://git.eeqj.de/sneak/AutistMask/issues/324).
//
// Why this file exists: `make check` does not run `make build`. CI executes
// the assertion (Dockerfile runs `make build`), but executing is not testing —
// invert its condition, or make the table lookup always come back undefined,
// and every check in this repo stays green while the singleton walks back into
// the worker. Five defects, one destroyed wallet, and the whole argument for
// the scoped loud-read guard rest on this assertion, so it is pinned here.
//
// The subject is build.js's exported helpers plus the table's own
// well-formedness check, driven against SYNTHETIC metafiles in esbuild's
// shape. Nothing here shells out to a build or writes dist/: the assertion's
// job is to read a metafile correctly, and a metafile is data. That the real
// shapes reach it is the build's own business and is measured in the PR that
// introduced it.
//
// Every vacuous pass this guarantee has been found to have is pinned below,
// because each one was a way for `make check`, `make lint` and `make build` to
// be green over a background bundle containing the singleton: a stale key, a
// stale module, an entry that lists no modules, an entry recorded as checked
// before its bundle was in hand, and a background entry point nobody added to
// the table.
//
// Paths are absolute on the way in, because the helpers normalize whatever
// esbuild gave them to repo-relative and this file should not depend on the
// working directory jest was started from.
const path = require("path");
const {
importChain,
newForbiddenRecord,
recordBundledInputs,
assertNoForbiddenInputs,
assertForbiddenTableCovered,
} = require("../build");
const {
BACKGROUND_ENTRY_PREFIX,
FORBIDDEN_INPUTS,
assertTableWellFormed,
} = require("../script/lib/forbiddenBundleInputs");
const ROOT = path.resolve(__dirname, "..");
const abs = (p) => path.join(ROOT, p);
const ENTRY = "src/background/index.js";
const OUT = "dist/chrome/src/background/index.js";
const STATE = "src/shared/state.js";
const HOP = "src/shared/chainSwitchFields.js";
const SECOND = "src/background/worker2.js";
const SECOND_OUT = "dist/chrome/src/background/worker2.js";
const POPUP = "src/popup/index.js";
const POPUP_OUT = "dist/chrome/src/popup/index.js";
// The real table's shape: entry point -> modules its bundle may not contain.
const TABLE = { [ENTRY]: [STATE] };
// A metafile as esbuild emits one: `outputs[out].inputs` is the flat list of
// every input that contributed to that output, and `inputs[file].imports` is
// the edge list, which is what the chain walk follows.
function metafile({ outputs = {}, imports = {} } = {}) {
return {
outputs: Object.fromEntries(
Object.entries(outputs).map(([out, inputs]) => [
abs(out),
{
inputs: Object.fromEntries(
inputs.map((input) => [
abs(input),
{ bytesInOutput: 1 },
]),
),
},
]),
),
inputs: Object.fromEntries(
Object.entries(imports).map(([file, targets]) => [
abs(file),
{ imports: targets.map((target) => ({ path: abs(target) })) },
]),
),
};
}
function check(mf, table = TABLE, record = newForbiddenRecord()) {
recordBundledInputs(mf, record);
assertNoForbiddenInputs(abs(ENTRY), abs(OUT), mf, record, table);
return record;
}
describe("assertNoForbiddenInputs()", () => {
test("a forbidden module in the bundle fails, naming the import chain", () => {
const mf = metafile({
outputs: { [OUT]: [ENTRY, HOP, STATE] },
imports: {
[ENTRY]: [HOP],
[HOP]: [STATE],
},
});
expect(() => check(mf)).toThrow(
`${OUT} bundles ${STATE}, which ${ENTRY} must not reach: ` +
`${ENTRY} -> ${HOP} -> ${STATE}.`,
);
});
test("a bundle without the forbidden module passes, and is recorded as checked", () => {
const mf = metafile({
outputs: { [OUT]: [ENTRY, HOP, "src/background/state.js"] },
imports: { [ENTRY]: [HOP, "src/background/state.js"] },
});
const record = check(mf);
expect([...record.entriesChecked]).toEqual([ENTRY]);
expect(record.bundledInputs.has(STATE)).toBe(false);
});
test("the failure still names the bundle when no import chain can be shown", () => {
// esbuild resolves `import("../shared/" + variable)` as a glob: the
// module is an input of the output, but no single edge leads to it.
// The message must degrade to no chain rather than crash.
const mf = metafile({
outputs: { [OUT]: [ENTRY, STATE] },
imports: { [ENTRY]: [] },
});
expect(() => check(mf)).toThrow(
`${OUT} bundles ${STATE}, which ${ENTRY} must not reach.`,
);
});
// The lookup this covers is the fragile step in the whole assertion:
// repoRelative() resolves against process.cwd() and esbuild's output keys
// are cwd-relative, so a change to where the build runs from, or to
// outfile vs outdir, makes it miss. It must be loud, and the entry must
// NOT already be marked checked when it does — otherwise a later edit
// turning this throw into an early return leaves both halves of the
// guarantee satisfied by a bundle nothing looked at.
test("an output esbuild did not report fails, and records nothing as checked", () => {
const mf = metafile({
outputs: { "dist/chrome/src/background/renamed.js": [ENTRY] },
imports: { [ENTRY]: [] },
});
const record = newForbiddenRecord();
recordBundledInputs(mf, record);
expect(() =>
assertNoForbiddenInputs(abs(ENTRY), abs(OUT), mf, record, TABLE),
).toThrow(`esbuild reported no metafile output for ${OUT}`);
expect([...record.entriesChecked]).toEqual([]);
// So even if that throw became `return`, the coverage half catches it.
record.bundledInputs.add(STATE);
expect(() => assertForbiddenTableCovered(record, TABLE)).toThrow(
`${ENTRY} is listed in FORBIDDEN_INPUTS but was not bundled`,
);
});
});
// Finding from the fifth review of this change: adding a second worker entry
// point is exactly the accident this guarantee exists for, and the person
// adding one has no reason to know a table elsewhere needs a line. So the
// default for the background directory is protected, not unprotected.
describe("background entry points are protected by default", () => {
test("a bundled background entry point with no line in the table fails", () => {
const mf = metafile({
outputs: { [SECOND_OUT]: [SECOND, STATE] },
imports: { [SECOND]: [STATE] },
});
const record = newForbiddenRecord();
recordBundledInputs(mf, record);
expect(() =>
assertNoForbiddenInputs(
abs(SECOND),
abs(SECOND_OUT),
mf,
record,
TABLE,
),
).toThrow(
`${SECOND} is a background entry point with no line in ` +
`FORBIDDEN_INPUTS`,
);
});
test("an entry point outside the background directory needs no line", () => {
// The popup legitimately bundles the singleton; that is its model.
const mf = metafile({
outputs: { [POPUP_OUT]: [POPUP, STATE] },
imports: { [POPUP]: [STATE] },
});
const record = newForbiddenRecord();
recordBundledInputs(mf, record);
expect(() =>
assertNoForbiddenInputs(
abs(POPUP),
abs(POPUP_OUT),
mf,
record,
TABLE,
),
).not.toThrow();
expect([...record.entriesChecked]).toEqual([]);
});
test("the prefix is the one the lint rule is scoped to", () => {
expect(BACKGROUND_ENTRY_PREFIX).toBe("src/background/");
expect(SECOND.startsWith(BACKGROUND_ENTRY_PREFIX)).toBe(true);
expect(POPUP.startsWith(BACKGROUND_ENTRY_PREFIX)).toBe(false);
});
});
describe("recordBundledInputs()", () => {
// The sole data source for the module half of the anti-rot check, and
// every other case here hand-seeds what it produces. Driven for real,
// across two outputs, and then handed straight to the check that reads it.
test("records every input of every output, satisfying the module half", () => {
const mf = metafile({
outputs: {
[OUT]: [ENTRY, HOP],
[POPUP_OUT]: [POPUP, STATE],
},
imports: { [ENTRY]: [HOP], [POPUP]: [STATE] },
});
const record = newForbiddenRecord();
recordBundledInputs(mf, record);
expect([...record.bundledInputs].sort()).toEqual(
[ENTRY, HOP, POPUP, STATE].sort(),
);
// Nothing hand-seeded: the covered check passes on what the recorder
// actually collected, so a recorder that collects nothing fails here.
assertNoForbiddenInputs(abs(ENTRY), abs(OUT), mf, record, TABLE);
expect(() => assertForbiddenTableCovered(record, TABLE)).not.toThrow();
});
});
describe("importChain()", () => {
test("terminates on a cyclic input graph, and still finds the module", () => {
const mf = metafile({
imports: {
[ENTRY]: [HOP],
[HOP]: ["src/shared/log.js"],
// The cycle: log <-> hop, with the target one hop past it.
"src/shared/log.js": [HOP, STATE],
},
});
expect(importChain(mf, abs(ENTRY), STATE)).toEqual([
ENTRY,
HOP,
"src/shared/log.js",
STATE,
]);
});
test("terminates and returns null when a cycle cannot reach the module", () => {
const mf = metafile({
imports: {
[ENTRY]: [HOP],
[HOP]: ["src/shared/log.js"],
"src/shared/log.js": [HOP, ENTRY],
},
});
expect(importChain(mf, abs(ENTRY), STATE)).toBeNull();
});
});
describe("assertForbiddenTableCovered()", () => {
test("the shipped table is satisfied by a build that checked it", () => {
const record = newForbiddenRecord();
record.entriesChecked.add(ENTRY);
// The popup bundle is what legitimately contains the singleton.
record.bundledInputs.add(STATE);
expect(() => assertForbiddenTableCovered(record, TABLE)).not.toThrow();
});
// The build this drives bundles the popup and no background entry point,
// because a stale key AND a bundled background entry point is now the
// stronger failure above — the entry point that is there but unlisted is
// reported by name, before the end of the build. This case is the rot that
// is left after that one: a key naming something this build never bundled.
test("a key no bundled entry point matched fails", () => {
const mf = metafile({
outputs: { [POPUP_OUT]: [POPUP] },
imports: { [POPUP]: [] },
});
const stale = { "src/background/renamed.js": [STATE] };
const record = newForbiddenRecord();
recordBundledInputs(mf, record);
assertNoForbiddenInputs(abs(POPUP), abs(POPUP_OUT), mf, record, stale);
record.bundledInputs.add(STATE);
expect(() => assertForbiddenTableCovered(record, stale)).toThrow(
"src/background/renamed.js is listed in FORBIDDEN_INPUTS but was" +
" not bundled, so nothing checked it",
);
});
test("a forbidden module this build bundled nowhere fails", () => {
// The other half of the same rot: renaming or moving the singleton
// leaves a table that names a path nothing resolves to any more, and
// every bundle then passes it vacuously.
const mf = metafile({
outputs: { [OUT]: [ENTRY] },
imports: { [ENTRY]: [] },
});
const stale = { [ENTRY]: ["src/shared/stateRenamed.js"] };
const record = check(mf, stale);
record.bundledInputs.add(STATE);
expect(() => assertForbiddenTableCovered(record, stale)).toThrow(
"src/shared/stateRenamed.js is listed in FORBIDDEN_INPUTS for" +
` ${ENTRY}, but this build bundled it nowhere`,
);
});
// The third rot, and the worst of the three: an entry with an empty list
// is checked, is bundled, names no module that could be missing, and
// prohibits nothing — in BOTH layers at once, since the rule's forbidden
// set is Object.values(table).flat().
test("an entry that lists no modules fails", () => {
const mf = metafile({
outputs: { [OUT]: [ENTRY, STATE] },
imports: { [ENTRY]: [STATE] },
});
const empty = { [ENTRY]: [] };
const record = newForbiddenRecord();
recordBundledInputs(mf, record);
assertNoForbiddenInputs(abs(ENTRY), abs(OUT), mf, record, empty);
expect(() => assertForbiddenTableCovered(record, empty)).toThrow(
`FORBIDDEN_INPUTS["${ENTRY}"] lists no modules`,
);
});
});
describe("assertTableWellFormed()", () => {
// Runs at require time on the shipped table, in script/lib/
// forbiddenBundleInputs.js, so an empty list fails the lint run as well as
// the build — the build's own re-check cannot help a layer that never
// reaches the build.
test("the shipped table is well formed", () => {
expect(() => assertTableWellFormed(FORBIDDEN_INPUTS)).not.toThrow();
expect(Object.entries(FORBIDDEN_INPUTS).length).toBeGreaterThan(0);
});
test("an entry that lists no modules fails, naming the entry", () => {
expect(() => assertTableWellFormed({ [ENTRY]: [] })).toThrow(
`FORBIDDEN_INPUTS["${ENTRY}"] lists no modules`,
);
});
test("a table with no entries at all fails", () => {
expect(() => assertTableWellFormed({})).toThrow(
"FORBIDDEN_INPUTS is empty",
);
});
});

View File

@@ -8,12 +8,10 @@
// broadcast — because an error code alone would not distinguish a gate from
// a switch that happened and then reported a failure.
//
// The endpoint half of that issue lives in tests/networkEndpoints.test.js,
// which covers the popup's chain switch; this file covers the background's,
// which goes through storage rather than the shared state singleton.
// The endpoint half of that issue lives in tests/networkEndpoints.test.js;
// this file mocks the state module, which that one exercises for real.
const { networkById } = require("../src/shared/networks");
const { makeStorageStub } = require("./support/storageStub");
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
@@ -53,11 +51,29 @@ afterEach(() => {
// ---------------------------------------------------------------------------
// Load the background worker against stubbed browser APIs, with the real
// chain-switch and persistence modules behind it, and return the handles to
// drive it.
// chain-switch module behind it, and return the handles to drive it. The
// wallet state is a plain object so that a switch that DID happen is visible
// as a mutation of it, and one that did not is visible as its absence.
function loadBackground() {
jest.resetModules();
const walletState = {
networkId: "mainnet",
rpcUrl: CUSTOM_RPC,
blockscoutUrl: MAINNET.defaultBlockscoutUrl,
networkEndpoints: {},
wallets: walletFixture(),
lastBalanceRefresh: 1,
tokenHolderCache: {},
fraudContracts: [],
};
jest.doMock("../src/shared/state", () => ({
state: walletState,
loadState: jest.fn(async () => {}),
saveState: jest.fn(async () => {}),
currentNetwork: () => networkById(walletState.networkId),
}));
jest.doMock("../src/shared/balances", () => ({
getProvider: () => ({}),
refreshBalances: jest.fn(async () => {}),
@@ -72,24 +88,12 @@ function loadBackground() {
registerAlarmHandlers: jest.fn(),
}));
// Storage is the only wallet state there is. The background reads and
// writes it per call — it holds no in-memory copy and cannot reach the
// shared singleton — so a switch that happened is visible here as a
// written record, and one that did not is visible as its absence.
const persisted = {
networkId: "mainnet",
rpcUrl: CUSTOM_RPC,
blockscoutUrl: MAINNET.defaultBlockscoutUrl,
networkEndpoints: {},
wallets: walletFixture(),
lastBalanceRefresh: 1,
tokenHolderCache: {},
fraudContracts: [],
activeAddress: ADDRESS,
allowedSites: { [ADDRESS]: [CONNECTED_HOSTNAME] },
deniedSites: {},
};
const storage = makeStorageStub({ autistmask: persisted });
let messageListener = null;
// Every message the background pushed at a content script. chainChanged
@@ -98,7 +102,12 @@ function loadBackground() {
const toTabs = [];
global.chrome = {
storage,
storage: {
local: {
get: jest.fn(async () => ({ autistmask: persisted })),
set: jest.fn(async () => {}),
},
},
runtime: {
getURL: (path) => "chrome-extension://autistmask/" + path,
onMessage: {
@@ -148,7 +157,7 @@ function loadBackground() {
return {
switchChain,
walletState: () => storage.read("autistmask"),
walletState,
chainChangedEvents: () =>
toTabs.filter((m) => m.eventName === "chainChanged"),
};
@@ -165,8 +174,8 @@ describe("wallet_switchEthereumChain is gated on the connection", () => {
// The refusal has to be a refusal to ACT, not just an error string:
// the wallet is still on mainnet, still on the user's own node, and
// no page was told the chain moved.
expect(bg.walletState().networkId).toBe("mainnet");
expect(bg.walletState().rpcUrl).toBe(CUSTOM_RPC);
expect(bg.walletState.networkId).toBe("mainnet");
expect(bg.walletState.rpcUrl).toBe(CUSTOM_RPC);
expect(bg.chainChangedEvents()).toEqual([]);
});
@@ -192,7 +201,7 @@ describe("wallet_switchEthereumChain is gated on the connection", () => {
const result = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
expect(result).toEqual({ result: null });
expect(bg.walletState().networkId).toBe("sepolia");
expect(bg.walletState.networkId).toBe("sepolia");
expect(bg.chainChangedEvents()).toEqual([
{
type: "AUTISTMASK_EVENT",
@@ -208,16 +217,16 @@ describe("wallet_switchEthereumChain is gated on the connection", () => {
const result = await bg.switchChain("0x89", CONNECTED_ORIGIN);
expect(result.error.code).toBe(4902);
expect(bg.walletState().networkId).toBe("mainnet");
expect(bg.walletState.networkId).toBe("mainnet");
});
test("a switch by a connected origin keeps the user's endpoint", async () => {
const bg = loadBackground();
await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
expect(bg.walletState().rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
expect(bg.walletState.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
await bg.switchChain(MAINNET.chainId, CONNECTED_ORIGIN);
expect(bg.walletState().rpcUrl).toBe(CUSTOM_RPC);
expect(bg.walletState.rpcUrl).toBe(CUSTOM_RPC);
});
});

View File

@@ -16,7 +16,6 @@
// first, so neither can see this.
const { networkById } = require("../src/shared/networks");
const { makeStorageStub } = require("./support/storageStub");
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
@@ -65,12 +64,9 @@ afterEach(() => {
delete global.chrome;
});
// Load the background worker with the real chain-switch and persistence
// modules behind it, over a storage stub that actually keeps what is written —
// a wipe is only observable against storage that remembers — and that clones
// in both directions, as the real API does. It used to alias, so the record
// the worker held and the "stored" one were a single object; see
// tests/support/storageStub.js.
// Load the background worker with the real state and chain-switch modules
// behind it, over a storage stub that actually keeps what is written — a
// wipe is only observable against storage that remembers.
function loadColdWorker(networkId) {
jest.resetModules();
@@ -88,13 +84,20 @@ function loadColdWorker(networkId) {
registerAlarmHandlers: jest.fn(),
}));
const storage = makeStorageStub({ autistmask: storedProfile(networkId) });
const store = { autistmask: storedProfile(networkId) };
let messageListener = null;
const toTabs = [];
global.chrome = {
storage,
storage: {
local: {
get: jest.fn(async () => ({ autistmask: store.autistmask })),
set: jest.fn(async (items) => {
store.autistmask = items.autistmask;
}),
},
},
runtime: {
getURL: (path) => "chrome-extension://autistmask/" + path,
onMessage: {
@@ -144,7 +147,7 @@ function loadColdWorker(networkId) {
return {
switchChain,
persisted: () => storage.read("autistmask"),
persisted: () => store.autistmask,
chainChangedEvents: () =>
toTabs.filter((m) => m.eventName === "chainChanged"),
};

View File

@@ -1,279 +0,0 @@
// Which chain a dApp transaction is PREPARED for on a worker that has not
// loaded state.
//
// The MV3 service worker is terminated when idle — roughly 30 seconds, which
// is its normal condition — and revived by the page's own message. Nothing
// loads state at module scope, so handleSendTransaction() used to build its
// provider with `getProvider(await getRpcUrl())`: the endpoint came from
// storage and was right, and the static network hint was omitted, so
// src/shared/balances.js fell back to currentNetwork() — the unpopulated
// singleton — and answered mainnet. ethers then fixed `chainId` at 0x1.
//
// The transaction was not sent on the wrong chain: verifySignedTx() compares
// the artifact against the selected chain and refused it. So the guard held
// and the feature did not — a user on any non-mainnet network could not send
// from a dApp at all, and the error described the symptom
// (https://git.eeqj.de/sneak/AutistMask/issues/320).
//
// This drives the real balances module and the real approval preparation and
// verification. Only ethers' JsonRpcProvider is replaced, so the static
// network hint getProvider() computes is the hint the population sees.
const { Network, Wallet, Transaction } = require("ethers");
const { networkById } = require("../src/shared/networks");
const { makeStorageStub } = require("./support/storageStub");
const SIGNER_KEY =
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
const signer = new Wallet(SIGNER_KEY);
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const CONNECTED_ORIGIN = "https://dapp.example";
const CONNECTED_HOSTNAME = "dapp.example";
const EXT_URL = "chrome-extension://autistmask/";
const SEPOLIA = networkById("sepolia");
const MAINNET = networkById("mainnet");
const NONCE = 7;
const TX_HASH = "0xfeed";
const TX_PARAMS = {
from: signer.address,
to: RECIPIENT,
value: "0x2386f26fc10000",
data: "0x",
};
function storedProfile(networkId) {
const net = networkById(networkId);
return {
hasWallet: true,
wallets: [
{
name: "Wallet 1",
type: "hd",
xpub: "xpub-1",
addresses: [
{
address: signer.address,
balance: "0.0",
tokenBalances: [],
},
],
},
],
activeAddress: signer.address,
networkId,
rpcUrl: net.defaultRpcUrl,
blockscoutUrl: net.defaultBlockscoutUrl,
allowedSites: { [signer.address]: [CONNECTED_HOSTNAME] },
deniedSites: {},
trackedTokens: [],
};
}
async function settle() {
for (let i = 0; i < 60; i++) await Promise.resolve();
}
afterEach(() => {
delete global.chrome;
});
// A worker whose only wallet state is what is in storage, with ethers'
// JsonRpcProvider replaced by a stub that answers out of the static network it
// was constructed with — which is exactly what a real staticNetwork provider
// does, and what makes the chain id on the approval screen observable here.
function loadColdWorker(networkId) {
jest.resetModules();
const constructed = [];
const broadcast = [];
jest.doMock("ethers", () => {
const actual = jest.requireActual("ethers");
class StubJsonRpcProvider {
constructor(url, network) {
this._network = network;
constructed.push({ url, network });
}
async getNetwork() {
return this._network;
}
async getTransactionCount() {
return NONCE;
}
async estimateGas() {
return 100000n;
}
async getFeeData() {
return {
gasPrice: 2000000000n,
maxFeePerGas: 2000000000n,
maxPriorityFeePerGas: 1000000000n,
};
}
async broadcastTransaction(raw) {
broadcast.push(raw);
return { hash: TX_HASH };
}
}
return { ...actual, JsonRpcProvider: StubJsonRpcProvider };
});
jest.doMock("../src/shared/phishingDomains", () => ({
isPhishingDomain: () => false,
}));
jest.doMock("../src/shared/alarms", () => ({
BALANCE_REFRESH_ALARM: "balance",
BALANCE_REFRESH_PERIOD_MINUTES: 1,
ensureRecurringAlarms: jest.fn(async () => {}),
registerAlarmHandlers: jest.fn(),
}));
const storage = makeStorageStub({ autistmask: storedProfile(networkId) });
let messageListener = null;
const createdUrls = [];
global.chrome = {
storage,
runtime: {
getURL: (path) => EXT_URL + path,
onMessage: {
addListener: (fn) => {
messageListener = fn;
},
},
onConnect: { addListener: () => {} },
lastError: null,
},
windows: {
getLastFocused: (cb) => cb(null),
create: (opts, cb) => {
createdUrls.push(opts.url);
cb({ id: createdUrls.length });
},
remove: (id, cb) => {
if (cb) cb();
},
onRemoved: { addListener: () => {} },
},
tabs: {
query: (queryInfo, cb) => cb([{ id: 1 }]),
sendMessage: (tabId, message, cb) => {
if (cb) cb();
},
},
action: { setPopup: () => {} },
};
require("../src/background/index");
function send(msg, sender) {
let result = null;
messageListener(msg, sender, (r) => {
result = r;
});
return () => result;
}
return {
send,
constructed,
broadcast,
fromPopup: { url: EXT_URL + "src/popup/index.html" },
// The first message this worker ever sees, as the injected provider
// sends it.
sendTransaction: () =>
send(
{
type: "AUTISTMASK_RPC",
method: "eth_sendTransaction",
params: [TX_PARAMS],
},
{ origin: CONNECTED_ORIGIN },
),
approvalId: () => {
const url = createdUrls[createdUrls.length - 1];
return url
? new URL(url, EXT_URL).searchParams.get("approval")
: null;
},
};
}
// What the approval window does: fetch the approval and sign the transaction
// it was handed, exactly as given.
function signApproved(approvedTx) {
const tx = {};
for (const [key, value] of Object.entries(approvedTx)) {
if (key === "from") continue;
tx[key] = value;
}
return signer.signTransaction(tx);
}
describe("a dApp transaction prepared by a worker that never loaded state", () => {
test("a cold send on Sepolia reaches the approval screen and goes out", async () => {
const bg = loadColdWorker("sepolia");
const answer = bg.sendTransaction();
await settle();
// The provider was built for Sepolia, endpoint and static hint
// together. Omitting the hint made this mainnet.
expect(bg.constructed).toHaveLength(1);
expect(bg.constructed[0].url).toBe(SEPOLIA.defaultRpcUrl);
expect(bg.constructed[0].network.chainId).toBe(
Network.from("sepolia").chainId,
);
// So the approval the user is shown is a Sepolia transaction.
const id = bg.approvalId();
expect(id).toBeTruthy();
const approval = bg.send(
{ type: "AUTISTMASK_GET_APPROVAL", id },
{ url: bg.fromPopup.url },
)();
expect(approval.type).toBe("tx");
expect(approval.approvedTx.chainId).toBe(SEPOLIA.chainId);
// And it survives the wallet's own verification, which is where a
// 0x1-stamped artifact was refused as "for a different network".
const rawSignedTx = await signApproved(approval.approvedTx);
const response = bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id,
approved: true,
rawSignedTx,
},
{ url: bg.fromPopup.url },
);
await settle();
expect(response()).toEqual({ txHash: TX_HASH });
expect(bg.broadcast).toEqual([rawSignedTx]);
expect(Number(Transaction.from(rawSignedTx).chainId)).toBe(
Number(SEPOLIA.networkVersion),
);
expect(answer()).toEqual({ result: TX_HASH });
});
test("a cold send on mainnet is prepared for mainnet", async () => {
// The stored value and the old fallback agree here, so this case
// cannot catch the defect; it is what keeps the fix from being a swap.
const bg = loadColdWorker("mainnet");
bg.sendTransaction();
await settle();
expect(bg.constructed[0].url).toBe(MAINNET.defaultRpcUrl);
const approval = bg.send(
{ type: "AUTISTMASK_GET_APPROVAL", id: bg.approvalId() },
{ url: bg.fromPopup.url },
)();
expect(approval.approvedTx.chainId).toBe(MAINNET.chainId);
});
});

View File

@@ -19,14 +19,6 @@ const {
balanceWarningHtml,
} = require("../src/popup/views/deleteAddress");
const { prices, clearPrices } = require("../src/shared/prices");
const { state } = require("../src/shared/state");
// The screen prices holdings, and pricing asks which chain it is on. Reading
// the singleton's network before anything loaded it now throws rather than
// answering mainnet by default
// (https://git.eeqj.de/sneak/AutistMask/issues/324), so the network this
// fixture is on is stated instead of assumed.
state.networkId = "mainnet";
const USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";

View File

@@ -13,15 +13,13 @@
// never persisting it looks identical from `state`, and a build that never
// wrote at all would pass a check that only reads `state` back.
//
// That makes the storage stub load-bearing, so it is the shared one from
// tests/support/storageStub.js, a real store that structured-clones on both
// `set` and `get`. A stub whose `get` hands back the same object its `set`
// was given aliases the caller's own array: the test then reads its own
// in-memory mutation and calls it persistence, and passes against a build
// that persists nothing
// (https://git.eeqj.de/sneak/AutistMask/issues/324). The aliasing is closed
// off explicitly by the first test below rather than left as an assumption
// about `structuredClone`.
// That makes the storage stub load-bearing, so it is a real store that
// structured-clones on both `set` and `get`. A stub whose `get` hands back
// the same object its `set` was given aliases the caller's own array: the
// test then reads its own in-memory mutation and calls it persistence, and
// passes against a build that persists nothing (see issue #324). The
// aliasing is closed off explicitly by the first test below rather than
// left as an assumption about `structuredClone`.
//
// The view is driven against a minimal DOM stub, in the same shape as
// tests/exportPrivkey.test.js: the module reads and writes named nodes and
@@ -36,7 +34,6 @@ jest.mock("../src/shared/vault", () => ({
}));
const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
const { makeStorageStub } = require("./support/storageStub");
const VIEW = "delete-wallet-lost-password";
@@ -97,6 +94,35 @@ function makeDocument() {
};
}
// --------------------------------------------------------- storage stub
// A store that behaves the way `chrome.storage.local` does: what goes in is
// serialized, so the caller keeps no handle on what came to rest there, and
// what comes out is a fresh object the caller may mutate freely.
function makeStorage() {
let store = {};
return {
get: async (keys) => {
const wanted =
keys === undefined || keys === null
? Object.keys(store)
: [].concat(keys);
const out = {};
for (const key of wanted) {
if (key in store) out[key] = structuredClone(store[key]);
}
return out;
},
set: async (items) => {
for (const [key, value] of Object.entries(items)) {
store[key] = structuredClone(value);
}
},
// Test-only: what the extension would find on a cold start.
_raw: () => structuredClone(store),
};
}
// ------------------------------------------------------------ harness
function wallet(name, secret, addresses) {
@@ -118,10 +144,10 @@ function load() {
jest.resetModules();
mockSettingsShow.mockClear();
const storage = makeStorageStub();
const storage = makeStorage();
const sent = [];
globalThis.chrome = {
storage: { local: storage.local },
storage: { local: storage },
runtime: { sendMessage: (msg) => sent.push(msg) },
};
globalThis.document = makeDocument();
@@ -179,7 +205,7 @@ async function openLostPassword(deleteWallet, walletIdx) {
// every other test in this file against a build that never writes.
describe("the storage stub", () => {
test("does not hand back the object it was given", async () => {
const storage = makeStorageStub();
const storage = makeStorage();
const written = { wallets: [{ name: "Wallet 1" }] };
await storage.set({ autistmask: written });
@@ -367,8 +393,8 @@ describe("deleting without the password", () => {
// The deleted wallet's secret is gone from storage entirely, not
// merely unreferenced by the wallet list.
expect(JSON.stringify(storage.read())).not.toContain("secret-two");
expect(JSON.stringify(storage.read())).not.toContain("xpub-Wallet 2");
expect(JSON.stringify(storage._raw())).not.toContain("secret-two");
expect(JSON.stringify(storage._raw())).not.toContain("xpub-Wallet 2");
});
test("only the deleted wallet's site permissions are dropped", async () => {
@@ -436,7 +462,7 @@ describe("deleting without the password", () => {
expect(saved.activeAddress).toBeNull();
expect(saved.allowedSites).toEqual({});
expect(state.currentView).toBe("welcome");
expect(JSON.stringify(storage.read())).not.toContain("secret-one");
expect(JSON.stringify(storage._raw())).not.toContain("secret-one");
});
});

View File

@@ -72,11 +72,6 @@ RUN script/bootstrap
COPY . .
# make package builds (and verifies) dist/ and then writes the release
# artifacts, so the image carries both: run.js installs the unpacked
# dist/firefox and reinstall.js installs the packaged .xpi, which is how the
# artifact that would actually be handed to someone gets exercised in a real
# Firefox rather than only being produced.
RUN make package
RUN make build
CMD ["node", "tests/e2e/firefox/run.js", "dist/firefox"]

View File

@@ -103,13 +103,7 @@ class Driver {
// ------------------------------------------------------------ setup
// `profileDir` reuses an existing profile directory in place instead of
// letting geckodriver make a throwaway one. That is the only way to ask
// what survives a browser RESTART, which for a Firefox add-on that can
// only be installed temporarily is the question that decides whether the
// extension is usable at all: a temporary add-on is unloaded when Firefox
// exits, so every session begins by adding it again.
async newSession(profileDir) {
async newSession() {
const prefs = {
// See EXTENSION_UUID above. The pref is a string pref whose
// value is itself JSON.
@@ -138,27 +132,26 @@ class Driver {
"extensions.openPopupWithoutUserGesture.enabled": false,
};
const args = [
"-headless",
// Mandatory on Firefox 153: without it, navigating to
// moz-extension:// and running chrome-context script both
// fail with "unsupported operation".
//
// It grants the driver FULL CHROME PRIVILEGES over this
// browser. Acceptable only because the browser is a
// throwaway in a CI container; never point a session with
// this flag at anything you care about.
"-remote-allow-system-access",
];
if (profileDir) args.push("-profile", profileDir);
const value = await this.send("POST", "/session", {
capabilities: {
alwaysMatch: {
browserName: "firefox",
"moz:firefoxOptions": {
binary: FIREFOX_BIN,
args,
args: [
"-headless",
// Mandatory on Firefox 153: without it,
// navigating to moz-extension:// and running
// chrome-context script both fail with
// "unsupported operation".
//
// It grants the driver FULL CHROME PRIVILEGES
// over this browser. Acceptable only because
// the browser is a throwaway in a CI
// container; never point a session with this
// flag at anything you care about.
"-remote-allow-system-access",
],
prefs,
},
},
@@ -169,30 +162,16 @@ class Driver {
return value;
}
// Installs the MV2 build, either from an unpacked directory or from an
// XPI file. temporary:true bypasses signature checks — which is the only
// way an UNSIGNED xpi installs at all, and the reason README.md says
// release Firefox will refuse the artifact this repo produces — and the
// add-on dies with the profile.
//
// Returns the add-on id Firefox assigned, which is
// browser_specific_settings.gecko.id from the manifest and is what
// uninstallAddon() takes.
async installAddon(pathToAddon) {
// Installs the unpacked MV2 build straight from a directory.
// temporary:true bypasses signature checks, so no XPI and no signing
// are involved, and the add-on dies with the profile.
async installAddon(dir) {
return this.session("POST", "/moz/addon/install", {
path: pathToAddon,
path: dir,
temporary: true,
});
}
// Removes an installed add-on, the way clicking Remove in about:addons
// does. tests/e2e/firefox/reinstall.js uses it to ask the one question
// that decides whether this extension can be used at all on Firefox: does
// the vault survive being removed and added again.
async uninstallAddon(id) {
return this.session("POST", "/moz/addon/uninstall", { id });
}
// Classic navigation on purpose. BiDi's browsingContext.navigate
// refuses moz-extension:// URLs outright.
async navigate(url) {

View File

@@ -1,438 +0,0 @@
// Does the wallet survive being installed again on Firefox?
//
// This is the question behind
// https://git.eeqj.de/sneak/AutistMask/issues/310, and it is the one property
// that has to hold before real money goes into this extension. The only route
// that works on release Firefox is a TEMPORARY add-on, which is unloaded when
// the browser exits: daily use means adding it again from about:debugging on
// every browser start. If extension storage did not survive that, every start
// would present an empty wallet and the recovery phrase would be the only copy
// of the money.
//
// manifest/firefox.json declares a fixed browser_specific_settings.gecko.id,
// which is the right SHAPE for storage to survive — Firefox keys the storage
// area by add-on id — but shape is not observation, and nothing asserted it.
//
// Two different things are asked here, because they have different answers and
// conflating them would be the whole mistake:
//
// RESTART one profile, two browser runs, the add-on added temporarily
// in each. This is what a user does every day, and the vault
// has to survive it.
// REMOVAL an explicit uninstall, the way about:addons "Remove" works,
// inside one browser run. Firefox destroys an add-on's storage
// when it is uninstalled, and the observed result is recorded
// here rather than wished away — for a wallet it means Remove
// is irreversible except from the recovery phrase.
//
// The vault is not merely compared as bytes in the restart case. It is
// DECRYPTED with the original password through the real Show Recovery Phrase
// screen and the phrase is compared against the one wallet creation produced,
// because "the ciphertext is still in storage" and "the wallet still works"
// are different claims and only the second one is worth anything.
//
// Run through script/test-e2e-firefox, which builds the artifact and the
// pinned container. The one argument is what to install — an unpacked
// directory or an .xpi. With none, the packaged XPI in release/ is used, so
// this doubles as the check that the release artifact installs in a real
// Firefox.
//
// node tests/e2e/firefox/reinstall.js release/autistmask-firefox-0.1.0.xpi
//
// A separate program from run.js rather than another step in it: every step
// there shares one browser session with one installed add-on, and this needs
// two browsers and three installs.
"use strict";
const fs = require("fs");
const os = require("os");
const path = require("path");
const { EXTENSION_ID, EXTENSION_UUID, start } = require("./driver");
const REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
const PASSWORD = "e2e-harness-password";
const STEP_TIMEOUT_MS = 120000;
const checks = [];
let failed = 0;
function check(name, cond, detail) {
checks.push(name);
if (cond) {
console.log("ok " + checks.length + " - " + name);
} else {
failed += 1;
console.log("not ok " + checks.length + " - " + name);
if (detail) console.log(" " + detail);
}
}
// The moz-extension:// uuid Firefox currently serves this add-on from, read
// out of the pref that holds the mapping. Privileged scope, because that pref
// is not reachable from content.
//
// It has to be read after every install and never assumed. driver.js pins a
// uuid through extensions.webextensions.uuids at each session start, but an
// uninstall inside a running session drops that mapping and the next install
// mints a fresh one — and navigating to the stale origin does not fail, it
// HANGS until the session times out, which is how this was found.
async function extensionUuid(d) {
const raw = await d.executeChrome(
`return Services.prefs.getStringPref(
"extensions.webextensions.uuids", "{}");`,
);
let map;
try {
map = JSON.parse(raw);
} catch (e) {
throw new Error(
"extensions.webextensions.uuids is not JSON (" +
e.message +
"): " +
raw,
);
}
return map[EXTENSION_ID] || null;
}
async function openPopup(d) {
const uuid = await extensionUuid(d);
if (!uuid) {
throw new Error(
"no uuid for " +
EXTENSION_ID +
" in extensions.webextensions.uuids, so the popup has no " +
"origin to be served from",
);
}
await d.navigate("moz-extension://" + uuid + "/src/popup/index.html");
// Whichever screen it lands on, wait for one of the two it can land on.
// Waiting for #view-main directly would time out rather than say what
// happened, and an empty storage partition is exactly the case where it
// lands on the other one.
await d.waitFor(
"the popup to finish restoring",
`const w = document.getElementById("view-welcome");
const m = document.getElementById("view-main");
if (!w || !m) return false;
const shown = (el) => {
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0;
};
return shown(w) || shown(m);`,
[],
STEP_TIMEOUT_MS,
);
return uuid;
}
// The persisted record, straight out of extension storage, read from the popup
// page — the one moz-extension:// document this program opens and therefore
// the only place the storage API is reachable from.
async function readVault(d) {
const outcome = await d.executeAsync(
`const done = arguments[arguments.length - 1];
const api = typeof browser !== "undefined" ? browser : chrome;
Promise.resolve(api.storage.local.get("autistmask"))
.then((r) => {
const s = r.autistmask;
if (!s) return done({ present: false });
const w = (s.wallets || [])[0];
if (!w) return done({ present: false });
done({
present: true,
wallets: s.wallets.length,
name: w.name,
xpub: w.xpub,
address: (w.addresses || []).map((a) => a.address)[0],
vault: JSON.stringify(w.encryptedSecret),
});
})
.catch((e) => done({ error: String((e && e.message) || e) }));`,
);
if (outcome && outcome.error) {
throw new Error("could not read extension storage: " + outcome.error);
}
return outcome;
}
async function createWallet(d) {
await d.click("#btn-welcome-add");
await d.waitVisible("#view-add-wallet");
await d.click("#btn-generate-phrase");
await d.waitFor(
"a generated recovery phrase of at least 12 words",
`const el = document.getElementById("wallet-mnemonic");
return !!el && el.value.trim().split(/\\s+/).length >= 12;`,
);
const phrase = (await d.value("#wallet-mnemonic")).trim();
await d.fill("#add-wallet-password", PASSWORD);
await d.fill("#add-wallet-password-confirm", PASSWORD);
await d.click("#btn-add-wallet-confirm");
// Argon2id under libsodium, for real.
await d.waitVisible("#view-main", STEP_TIMEOUT_MS);
return phrase;
}
// The phrase the vault decrypts to, obtained the way a user would: Settings,
// Show Recovery Phrase, the original password.
async function revealPhrase(d) {
if (!(await d.isVisible("#view-settings"))) {
await d.click("#btn-settings");
}
await d.waitVisible("#view-settings");
await d.click("#settings-wallet-list .btn-show-phrase");
await d.waitVisible("#view-show-phrase");
await d.fill("#show-phrase-password", PASSWORD);
await d.click("#btn-show-phrase-reveal");
await d.waitVisible("#show-phrase-result", STEP_TIMEOUT_MS);
return (await d.text("#show-phrase-value")).trim();
}
// What to install. An explicit argument wins; with none, the packaged XPI in
// release/ is used, and there is deliberately no fallback to dist/firefox: the
// point of running this against the artifact is that the artifact is what gets
// installed, and quietly testing something else instead would leave the XPI
// unexercised while the run stayed green.
function resolveArtifact() {
if (process.argv[2]) return path.resolve(REPO_ROOT, process.argv[2]);
const releaseDir = path.join(REPO_ROOT, "release");
const xpis = fs.existsSync(releaseDir)
? fs.readdirSync(releaseDir).filter((f) => f.endsWith(".xpi"))
: [];
if (xpis.length !== 1) {
throw new Error(
"expected exactly one .xpi in release/, found " +
xpis.length +
" (" +
xpis.join(", ") +
"). Run make package, or name the artifact as the argument.",
);
}
return path.join(releaseDir, xpis[0]);
}
async function main() {
let artifact;
try {
artifact = resolveArtifact();
} catch (e) {
console.error("e2e-firefox-reinstall: " + e.message);
process.exitCode = 1;
return;
}
if (!fs.existsSync(artifact)) {
console.error(
"e2e-firefox-reinstall: nothing to install at " + artifact,
);
process.exitCode = 1;
return;
}
console.log("# installing: " + artifact);
// One profile, reused by both browser runs below. geckodriver would
// otherwise make a throwaway one per session, and "survives a restart"
// cannot be asked of a profile that does not.
const profile = fs.mkdtempSync(
path.join(os.tmpdir(), "autistmask-reinstall-profile-"),
);
let phrase = null;
let before = null;
let first;
// --- run one: install, create a wallet, close the browser --------------
try {
first = await start();
await first.newSession(profile);
} catch (e) {
// A browser we cannot start is a failure of this program, never an
// absent one.
console.error("e2e-firefox-reinstall: cannot run: " + e.message);
if (first) await first.quit().catch(() => {});
process.exitCode = 1;
return;
}
try {
const firstId = await first.installAddon(artifact);
check(
"the artifact installs and reports the manifest's gecko id",
firstId === EXTENSION_ID,
"installed add-on id is " +
JSON.stringify(firstId) +
", expected " +
JSON.stringify(EXTENSION_ID) +
". Without a stable id Firefox has no key to hang the " +
"storage area on, and nothing below can hold.",
);
const firstUuid = await openPopup(first);
await first.waitVisible("#view-welcome", STEP_TIMEOUT_MS);
phrase = await createWallet(first);
before = await readVault(first);
check(
"a wallet created through the UI is in extension storage",
before.present && before.wallets === 1 && !!before.vault,
JSON.stringify(before),
);
console.log(
"# run 1 moz-extension uuid: " +
firstUuid +
(firstUuid === EXTENSION_UUID ? " (the pinned one)" : ""),
);
} catch (e) {
failed += 1;
console.log("# ERROR in run 1: " + (e && e.stack ? e.stack : e));
} finally {
await first.quit().catch(() => {});
}
// --- run two: same profile, add-on added again -------------------------
//
// This is the restart. The temporary add-on died with the previous
// browser; the profile, and whatever Firefox kept in it, did not.
let second;
try {
second = await start();
await second.newSession(profile);
} catch (e) {
console.error(
"e2e-firefox-reinstall: cannot restart the browser: " + e.message,
);
if (second) await second.quit().catch(() => {});
process.exitCode = 1;
return;
}
try {
const secondId = await second.installAddon(artifact);
check(
"the add-on installs again after a browser restart with the " +
"same id",
secondId === EXTENSION_ID,
"re-installed id is " + JSON.stringify(secondId),
);
const secondUuid = await openPopup(second);
// The origin the popup is served from is not the thing that carries
// the wallet, and the uuid is read live for exactly that reason. In
// this run it comes back as the pinned one, because driver.js writes
// extensions.webextensions.uuids into the profile at every session
// start; an uninstall inside a running session drops the mapping and
// the next install mints a fresh uuid instead. Either way the storage
// area is keyed on the add-on id, never on this.
console.log(
"# run 2 moz-extension uuid: " +
secondUuid +
(secondUuid === EXTENSION_UUID
? " (the pinned one, re-applied at session start)"
: " (freshly minted)"),
);
const onWelcome = await second.isVisible("#view-welcome");
check(
"after a restart and re-add, the extension does not come up as " +
"a fresh install",
!onWelcome,
"the popup shows the welcome screen, which is what an empty " +
"storage partition looks like: the wallet is gone and only " +
"the recovery phrase would get it back.",
);
const after = await readVault(second);
check(
"the vault, xpub and first address survive the restart unchanged",
after.present &&
after.wallets === before.wallets &&
after.vault === before.vault &&
after.xpub === before.xpub &&
after.address === before.address,
"before: " +
JSON.stringify(before) +
" after: " +
JSON.stringify(after),
);
if (after.present) {
const revealed = await revealPhrase(second);
check(
"the vault still decrypts with the original password to the " +
"original recovery phrase",
revealed === phrase,
"the recovery phrase read back after the restart is not the " +
"one the wallet was created with",
);
} else {
check(
"the vault still decrypts with the original password to the " +
"original recovery phrase",
false,
"there was no vault left to decrypt",
);
}
// --- an explicit removal, in the same browser run ------------------
//
// Leave the popup FIRST. Removing the add-on destroys every document
// it serves, and the popup is this session's only window: uninstalling
// while it is on screen discards the browsing context, and every
// subsequent WebDriver command fails with "no such window" rather than
// with anything about the add-on. Observed, not anticipated.
await second.navigate("about:blank");
await second.uninstallAddon(EXTENSION_ID);
await second.installAddon(artifact);
await openPopup(second);
const afterRemoval = await readVault(second);
console.log(
"# after an explicit uninstall: " + JSON.stringify(afterRemoval),
);
// Recorded as observed behaviour, not as something this repo wants.
// Firefox destroys an add-on's storage when it is uninstalled, and
// that is correct of a browser — it is stated here, and in README.md,
// because for a WALLET it means about:addons "Remove" is irreversible
// except from the recovery phrase. If a Firefox ever stops doing it
// this fails and the claim gets rewritten from a new observation.
check(
"an explicit uninstall DESTROYS the vault (observed Firefox " +
"behaviour: Remove is irreversible, unlike a restart)",
afterRemoval.present === false,
"the vault survived an explicit uninstall: " +
JSON.stringify(afterRemoval),
);
} catch (e) {
failed += 1;
console.log("# ERROR in run 2: " + (e && e.stack ? e.stack : e));
} finally {
await second.quit().catch(() => {});
fs.rmSync(profile, { recursive: true, force: true });
}
console.log("1.." + checks.length);
if (checks.length === 0) {
console.log("# FAILED: this program asserted nothing");
process.exitCode = 1;
return;
}
console.log(
"# " +
(checks.length - failed) +
"/" +
checks.length +
" checks passed",
);
if (failed > 0) {
console.log("# FAILED");
process.exitCode = 1;
}
}
main().catch((e) => {
console.error("e2e-firefox-reinstall: " + (e && e.stack ? e.stack : e));
process.exitCode = 1;
});

View File

@@ -1,303 +0,0 @@
// Where does chrome.storage.local live, and what moves it?
//
// The finding behind https://git.eeqj.de/sneak/AutistMask/issues/310: an
// unpacked Chrome extension with no `key` in its manifest gets an extension id
// derived from the ABSOLUTE PATH it was loaded from, and chrome.storage.local
// is partitioned by that id. README.md documents Load unpacked from
// dist/chrome/ as the install route, so moving the checkout, re-cloning it, or
// loading a second copy from anywhere else means a different id, a different
// storage partition, and a wallet that reads as empty — with no error, no
// prompt and nothing in the UI to say what happened.
//
// So this OBSERVES the behaviour rather than restating it. Two unpacked loads
// from two different directories, in one profile, with the shipped manifest
// and again with `key` stripped out, and it reports what each pair actually
// does. The assertions are on what was seen when this was written and are
// annotated as such; if Chrome's derivation ever changes, this says so instead
// of passing.
//
// Run through script/test-e2e, which builds the extension and the pinned
// container.
//
// node tests/e2e/storagePartition.js
//
// A separate program from run.js because every test there shares one browser
// and one extension load, and the whole subject here is what happens across
// two of each.
//
// The extension's own UI is deliberately not driven. What is under test is the
// storage partition, so a sentinel key the extension never reads or writes is
// written and read back directly: a wallet would prove the same thing more
// slowly, and would confuse an empty partition with a UI that failed to
// render.
"use strict";
const fs = require("fs");
const os = require("os");
const path = require("path");
const { chromium } = require("playwright-core");
const REPO_ROOT = path.resolve(__dirname, "..", "..");
const DIST_CHROME = path.join(REPO_ROOT, "dist", "chrome");
// Never touched by the extension: src/shared/state.js reads and writes the
// single key "autistmask" and nothing else.
const SENTINEL_KEY = "e2e-storage-partition-sentinel";
const SENTINEL_VALUE = "written-by-the-first-load";
const checks = [];
let failed = 0;
const scratch = [];
function check(name, cond, detail) {
checks.push(name);
if (cond) {
console.log("ok " + checks.length + " - " + name);
} else {
failed += 1;
console.log("not ok " + checks.length + " - " + name);
if (detail) console.log(" " + detail);
}
}
function tmpdir(tag) {
const dir = fs.mkdtempSync(
path.join(os.tmpdir(), "autistmask-" + tag + "-"),
);
scratch.push(dir);
return dir;
}
// A copy of the built extension at a fresh absolute path. `withKey: false`
// strips the manifest's `key`, which is the pre-fix state of this repo and the
// control the whole program is built around.
function extensionCopy(tag, withKey) {
const dir = path.join(tmpdir(tag), "chrome");
fs.cpSync(DIST_CHROME, dir, { recursive: true });
const manifestPath = path.join(dir, "manifest.json");
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
if (withKey) {
if (!manifest.key) {
throw new Error(
"dist/chrome/manifest.json has no `key`, so this program has " +
"nothing to observe. That field is what pins the " +
"extension id; see tests/extensionId.test.js.",
);
}
} else {
delete manifest.key;
}
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 4));
return dir;
}
async function launch(profileDir, extensionDirs) {
const ctx = await chromium.launchPersistentContext(profileDir, {
// See the note in harness.js: the default headless shell silently
// refuses to load extensions.
channel: "chromium",
headless: true,
args: [
"--disable-extensions-except=" + extensionDirs.join(","),
"--load-extension=" + extensionDirs.join(","),
"--no-sandbox",
// Nothing here should reach the network; this makes sure it
// cannot.
"--host-resolver-rules=MAP * ~NOTFOUND",
],
});
return ctx;
}
// Every extension id Chrome ended up with in this context, from the service
// worker urls. MV3 registers one worker per loaded extension.
async function extensionIds(ctx, expected) {
const deadline = Date.now() + 30000;
for (;;) {
const ids = [
...new Set(ctx.serviceWorkers().map((w) => new URL(w.url()).host)),
].sort();
if (ids.length >= expected) return ids;
if (Date.now() > deadline) return ids;
await new Promise((r) => setTimeout(r, 200));
}
}
// Open one extension's popup and run `fn` in it. The popup page is used rather
// than the service worker because Chrome stops an idle MV3 worker, and a
// handle to a stopped worker cannot be evaluated in.
async function inExtension(ctx, id, fn, arg) {
const page = await ctx.newPage();
try {
await page.goto("chrome-extension://" + id + "/src/popup/index.html");
return await page.evaluate(fn, arg);
} finally {
await page.close();
}
}
const writeSentinel = ([key, value]) =>
new Promise((resolve) => {
chrome.storage.local.set({ [key]: value }, () => resolve(true));
});
const readSentinel = (key) =>
new Promise((resolve) => {
chrome.storage.local.get(key, (r) => resolve(r[key] ?? null));
});
// Load `firstDir` in a fresh profile, write the sentinel, close; load
// `secondDir` in the SAME profile, read it back. Returns both ids and what the
// second load saw.
async function acrossTwoPaths(tag, firstDir, secondDir) {
const profile = tmpdir(tag + "-profile");
const first = await launch(profile, [firstDir]);
let firstId;
try {
[firstId] = await extensionIds(first, 1);
if (!firstId) throw new Error("no extension loaded from " + firstDir);
await inExtension(first, firstId, writeSentinel, [
SENTINEL_KEY,
SENTINEL_VALUE,
]);
} finally {
await first.close();
}
const second = await launch(profile, [secondDir]);
let secondId;
let seen;
try {
[secondId] = await extensionIds(second, 1);
if (!secondId) throw new Error("no extension loaded from " + secondDir);
seen = await inExtension(second, secondId, readSentinel, SENTINEL_KEY);
} finally {
await second.close();
}
return { firstId, secondId, seen };
}
async function main() {
if (!fs.existsSync(path.join(DIST_CHROME, "manifest.json"))) {
console.error(
"storagePartition: no unpacked build at " +
DIST_CHROME +
" — run make build first",
);
process.exitCode = 1;
return;
}
try {
// --- the shipped manifest, which carries `key` ----------------------
const keyedA = extensionCopy("keyed-a", true);
const keyedB = extensionCopy("keyed-b", true);
const keyed = await acrossTwoPaths("keyed", keyedA, keyedB);
console.log("# with `key`: " + keyedA + " -> " + keyed.firstId);
console.log("# with `key`: " + keyedB + " -> " + keyed.secondId);
console.log("# with `key`: sentinel read back: " + keyed.seen);
check(
"with `key`, two different paths produce the SAME extension id",
keyed.firstId === keyed.secondId,
keyed.firstId + " != " + keyed.secondId,
);
check(
"with `key`, the second path reads the first path's storage",
keyed.seen === SENTINEL_VALUE,
"the second load read " +
JSON.stringify(keyed.seen) +
" instead of the value the first load wrote. The storage " +
"partition did not follow the extension across the move, " +
"which is the wallet silently reading as empty.",
);
// --- the same build with `key` removed: the control -----------------
const barePath = extensionCopy("bare-a", false);
const bareB = extensionCopy("bare-b", false);
const bare = await acrossTwoPaths("bare", barePath, bareB);
console.log("# without `key`: " + barePath + " -> " + bare.firstId);
console.log("# without `key`: " + bareB + " -> " + bare.secondId);
console.log("# without `key`: sentinel read back: " + bare.seen);
// Observed, not assumed. If Chrome ever stops deriving the id from
// the load path these two fail, and the right response is to record
// what it does now — not to delete them.
check(
"without `key`, two different paths produce DIFFERENT ids " +
"(observed Chrome behaviour, the defect this fixes)",
bare.firstId !== bare.secondId,
"both loads got " +
bare.firstId +
", so the id no longer follows the load path on this Chrome",
);
check(
"without `key`, the second path sees an EMPTY partition " +
"(observed Chrome behaviour, the defect this fixes)",
bare.seen === null,
"the second load read " +
JSON.stringify(bare.seen) +
" from a different id's partition",
);
// --- both copies loaded at once, in one profile ---------------------
// The literal shape of the question, kept because "two unpacked loads
// in one profile" is what a user does when they forget to remove the
// old one. With `key`, both copies claim the same id.
const profile = tmpdir("simultaneous-profile");
const ctx = await launch(profile, [keyedA, keyedB]);
let ids = [];
try {
ids = await extensionIds(ctx, 2);
} finally {
await ctx.close();
}
console.log(
"# both keyed copies loaded at once: " +
ids.length +
" extension id(s): " +
ids.join(", "),
);
check(
"loading both keyed copies at once yields one id, not two " +
"(observed: Chrome does not load a second copy of an id it " +
"already has)",
ids.length === 1 && ids[0] === keyed.firstId,
"saw " + JSON.stringify(ids) + ", expected exactly one id",
);
} catch (e) {
failed += 1;
console.log("# ERROR: " + (e && e.stack ? e.stack : e));
} finally {
for (const dir of scratch) {
fs.rmSync(dir, { recursive: true, force: true });
}
}
console.log("1.." + checks.length);
if (checks.length === 0) {
console.log("# FAILED: this program asserted nothing");
process.exitCode = 1;
return;
}
console.log(
"# " +
(checks.length - failed) +
"/" +
checks.length +
" checks passed",
);
if (failed > 0) {
console.log("# FAILED");
process.exitCode = 1;
}
}
main().catch((e) => {
console.error("storagePartition: " + (e && e.stack ? e.stack : e));
process.exitCode = 1;
});

View File

@@ -1,119 +0,0 @@
// The extension identity on both browsers, pinned.
//
// This is the anti-regression check for
// https://git.eeqj.de/sneak/AutistMask/issues/310. An unpacked Chrome
// extension with no `key` in its manifest gets an id derived from the
// ABSOLUTE PATH it was loaded from, and chrome.storage.local is partitioned by
// that id. Move the checkout, re-clone it, or load it from a second directory,
// and the wallet is silently gone: the extension comes up on a fresh, empty
// storage partition with no error anywhere. `key` pins the id to the public
// key instead of to the path, which is what makes the storage survive.
//
// So the id is asserted as a literal. A test that merely recomputed the id
// from whatever `key` happened to be in the manifest would pass after someone
// replaced the key — and replacing the key is exactly the change that orphans
// every existing wallet. The value below is the promise; changing it is a
// migration, not an edit.
//
// Firefox needs no key: browser_specific_settings.gecko.id declares the id
// directly, and it is pinned here for the same reason. The Firefox e2e suite
// depends on it too (tests/e2e/firefox/driver.js maps it to a fixed uuid), and
// tests/e2e/firefox/reinstall.js is the empirical half — it removes the add-on
// and installs it again and reads the vault back out.
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
const MANIFEST_DIR = path.join(__dirname, "..", "manifest");
// The public half of an RSA keypair, DER-encoded SubjectPublicKeyInfo, base64.
// The PRIVATE half is not in this repo and is not needed to build, load or
// test anything here: it is only ever used to sign a CRX, which this repo does
// not do.
const CHROME_KEY =
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzy/G9gT4Z3Ci0HCmthUPEiCjENg+" +
"5meZpjdogyT7SiMfxENtHdrpDL6wGhAg1Dk0f1C67Ft8OYpMrMH3kiP2Wnt0UpHo45PY0YUU" +
"YzdJgbsp8u0kaykd5FFiY6FycIIFaTniMuh7wRKuNNdJWly+H3aG7qZ6nGu5PIMdb1GXUk35" +
"hY+yl7dz5dqFFYUCyxvWCT9XGBSYiI+XRBB/rVZjMWfWpaTmRPdOZ4+GO/Lx0OdMxKlPA/kL" +
"WoPot5vMlLn2FDPu6sASphiu7dKZnrINW+h/27jlHMJQS0jncB1EgqOHW0vbXrZnTveFX6UW" +
"+Qp86FfSkikhKtQgTW2A4mtWawIDAQAB";
// chrome.storage.local for this extension lives under this id, and nowhere
// else.
const CHROME_EXTENSION_ID = "gipbhkogfopeahplcjhipkgpcimdpkip";
const FIREFOX_EXTENSION_ID = "autistmask@sneak.berlin";
// Chrome's id derivation: sha256 of the DER public key, first 16 bytes, each
// hex digit mapped 0-f onto a-p. Written out here rather than taken on trust,
// because the whole claim of this file is that the committed key produces that
// id.
function chromeExtensionId(keyBase64) {
const der = Buffer.from(keyBase64, "base64");
const digest = crypto.createHash("sha256").update(der).digest("hex");
return [...digest.slice(0, 32)]
.map((c) => String.fromCharCode(97 + parseInt(c, 16)))
.join("");
}
function readManifest(name) {
return JSON.parse(
fs.readFileSync(path.join(MANIFEST_DIR, name + ".json"), "utf8"),
);
}
describe("chrome extension identity", () => {
test("the manifest carries the pinned key", () => {
expect(readManifest("chrome").key).toBe(CHROME_KEY);
});
test("the key is a well-formed RSA public key", () => {
const der = Buffer.from(CHROME_KEY, "base64");
// Round-trips: a truncated or re-wrapped base64 blob would still
// decode to bytes, and Chrome would then derive an id from garbage.
expect(der.toString("base64")).toBe(CHROME_KEY);
const key = crypto.createPublicKey({
key: der,
format: "der",
type: "spki",
});
expect(key.asymmetricKeyType).toBe("rsa");
expect(key.asymmetricKeyDetails.modulusLength).toBe(2048);
});
test("the key derives the pinned extension id", () => {
expect(chromeExtensionId(CHROME_KEY)).toBe(CHROME_EXTENSION_ID);
expect(CHROME_EXTENSION_ID).toMatch(/^[a-p]{32}$/);
});
// The private half is a credential. It has never been in this repo and no
// target generates one into the working tree; this fails loudly if that
// ever changes, because a committed .pem is a key anyone can sign a CRX
// with under this extension's id.
test("no private key is committed anywhere in the tree", () => {
const tracked = require("child_process")
.execSync("git ls-files", {
cwd: path.join(__dirname, ".."),
encoding: "utf8",
})
.split("\n")
.filter(Boolean);
expect(tracked.filter((f) => /\.(pem|key|p12|pfx)$/i.test(f))).toEqual(
[],
);
});
});
describe("firefox extension identity", () => {
test("the manifest declares the pinned gecko id", () => {
const gecko = readManifest("firefox").browser_specific_settings.gecko;
expect(gecko.id).toBe(FIREFOX_EXTENSION_ID);
});
// Firefox derives nothing from the path, so no key field belongs here; one
// would be ignored and would only suggest the id came from somewhere else.
test("the firefox manifest carries no chrome key field", () => {
expect(readManifest("firefox").key).toBeUndefined();
});
});

View File

@@ -10,7 +10,6 @@
// happened.
const { networkById } = require("../src/shared/networks");
const { makeStorageStub } = require("./support/storageStub");
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
@@ -35,19 +34,25 @@ function walletFixture() {
// saveState() wrote — so a case can reload a fresh module from the bytes an
// earlier one persisted, which is what an extension restart does. `state` is
// a module-level singleton, so the registry has to be reset per load.
// The stub clones in both directions, as the real chrome.storage.local does.
// It used to alias, and written() then handed the NEXT module load the live
// in-memory object of the previous one as its "persisted bytes" — an extension
// restart that never crossed a serialization boundary. See
// tests/support/storageStub.js.
function loadModuleWith(persisted) {
jest.resetModules();
const storage = makeStorageStub(persisted ? { autistmask: persisted } : {});
global.chrome = { storage };
let written = null;
global.chrome = {
storage: {
local: {
get: jest.fn(async () =>
persisted ? { autistmask: persisted } : {},
),
set: jest.fn(async (items) => {
written = items.autistmask;
}),
},
},
};
return {
mod: require("../src/shared/state"),
chainSwitch: require("../src/shared/chainSwitch"),
written: () => storage.read("autistmask"),
written: () => written,
};
}

View File

@@ -1,190 +0,0 @@
// The release archives: the zip container, and the self-containment rule.
//
// script/package produces one archive per browser and script/lib/package.js
// decides what goes in it. The trap that rule exists for is real and specific:
// build.js writes the compiled stylesheet to dist/styles.css at the dist/
// ROOT, outside both browser directories, and copies it into each of them as
// src/popup/styles.css. A `zip -r dist/chrome` is correct only because of that
// copy, and would silently start shipping a popup with no stylesheet the
// moment a reference pointed up and out of the directory.
//
// So the packager resolves every reference in the manifest and in every HTML
// document, and fails on any that leaves the extension root. These are the
// cases for that, plus the archive format itself — new code, and the thing the
// artifact is made of.
const {
checkSelfContained,
htmlReferences,
manifestReferences,
} = require("../script/lib/package");
const { readZip, writeZip } = require("../script/lib/zip");
function archiveOf(files) {
return {
members: Object.keys(files).sort(),
read: (name) => Buffer.from(files[name] ?? "", "utf8"),
};
}
const MINIMAL_MANIFEST = {
manifest_version: 3,
name: "AutistMask",
version: "0.1.0",
action: { default_popup: "src/popup/index.html" },
background: { service_worker: "src/background/index.js" },
};
describe("archive self-containment", () => {
test("a complete tree passes", () => {
const { members, read } = archiveOf({
"manifest.json": JSON.stringify(MINIMAL_MANIFEST),
"src/popup/index.html":
'<link rel="stylesheet" href="styles.css" />' +
'<script src="index.js"></script>',
"src/popup/styles.css": "body{}",
"src/popup/index.js": "//",
"src/background/index.js": "//",
});
expect(() => checkSelfContained("chrome", members, read)).not.toThrow();
});
test("a manifest naming a file that is not in the archive fails", () => {
const { members, read } = archiveOf({
"manifest.json": JSON.stringify(MINIMAL_MANIFEST),
"src/popup/index.html": "<html></html>",
"src/popup/index.js": "//",
});
expect(() => checkSelfContained("chrome", members, read)).toThrow(
/would not be self-contained.*src\/background\/index\.js/s,
);
});
// The dist/styles.css case, exactly: a popup that reached up out of its
// own browser directory for the stylesheet the build leaves at the dist/
// root. Nothing would be missing from disk, and the zip would still be
// built — the archive would just have no stylesheet in it.
test("an HTML reference that escapes the extension root fails", () => {
const { members, read } = archiveOf({
"manifest.json": JSON.stringify(MINIMAL_MANIFEST),
"src/popup/index.html":
'<link rel="stylesheet" href="../../../styles.css" />',
"src/popup/index.js": "//",
"src/background/index.js": "//",
});
expect(() => checkSelfContained("chrome", members, read)).toThrow(
/resolves outside the extension root/,
);
});
test("a manifest reference that escapes the extension root fails", () => {
const { members, read } = archiveOf({
"manifest.json": JSON.stringify({
...MINIMAL_MANIFEST,
background: { service_worker: "../shared/index.js" },
}),
"src/popup/index.html": "<html></html>",
});
expect(() => checkSelfContained("chrome", members, read)).toThrow(
/points outside the extension root/,
);
});
test("an archive with no manifest.json at its root fails", () => {
const { members, read } = archiveOf({ "src/popup/index.js": "//" });
expect(() => checkSelfContained("chrome", members, read)).toThrow(
/no manifest\.json at its root/,
);
});
test("manifest strings that are not paths are not treated as files", () => {
const found = manifestReferences({
name: "AutistMask",
version: "0.1.0",
permissions: ["storage", "<all_urls>"],
content_security_policy: {
extension_pages: "default-src 'self'; script-src 'self'",
},
key: "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzy",
});
expect([...found]).toEqual([]);
});
test("absolute, data: and anchor HTML references are not files", () => {
const refs = htmlReferences(
"src/popup/index.html",
'<a href="https://example.com/x.js">a</a>' +
'<img src="data:image/png;base64,AAAA" />' +
'<a href="#view-main">b</a>' +
'<script src="index.js"></script>',
);
expect([...refs]).toEqual(["src/popup/index.js"]);
});
});
describe("the zip container", () => {
const files = [
{ name: "manifest.json", data: Buffer.from('{"a":1}') },
// Long enough that deflate wins, so both paths through compress() are
// exercised by one archive.
{ name: "src/popup/index.js", data: Buffer.from("x".repeat(5000)) },
{ name: "empty.txt", data: Buffer.alloc(0) },
];
test("round-trips every member byte for byte", () => {
const entries = readZip(writeZip(files));
expect(entries.map((e) => e.name)).toEqual([
"empty.txt",
"manifest.json",
"src/popup/index.js",
]);
for (const original of files) {
const found = entries.find((e) => e.name === original.name);
expect(found.data.equals(original.data)).toBe(true);
}
});
// The sha256 in release/SHA256SUMS has to be a property of the input. Two
// builds of one commit that produce different archives cannot be compared
// to each other, which is most of what publishing a digest is for.
test("is byte-identical across runs and independent of input order", () => {
const a = writeZip(files);
const b = writeZip([...files].reverse());
expect(a.equals(b)).toBe(true);
});
// Reading an archive back is how script/lib/package.js establishes that
// the artifact holds what dist/ holds, so a member whose bytes changed
// after it was written has to fail rather than be handed back.
test("a corrupted member fails its CRC on read", () => {
// One member, incompressible at this size, so it is stored verbatim
// and its bytes begin at a known offset: local header (30) + name.
const name = "a.js";
const archive = writeZip([{ name, data: Buffer.from("hello") }]);
expect(readZip(archive)[0].data.toString()).toBe("hello");
archive[30 + name.length] ^= 0xff;
expect(() => readZip(archive)).toThrow(/fails its recorded CRC32/);
});
test.each([["/abs.js"], ["../up.js"], ["a/../b.js"], ["a\\b.js"], [""]])(
"refuses the member name %p",
(name) => {
expect(() => writeZip([{ name, data: Buffer.from("x") }])).toThrow(
/member name/,
);
},
);
test("refuses an archive with no members", () => {
expect(() => writeZip([])).toThrow(/no members/);
});
test("refuses duplicate members", () => {
expect(() =>
writeZip([
{ name: "a.js", data: Buffer.from("1") },
{ name: "a.js", data: Buffer.from("2") },
]),
).toThrow(/duplicate member/);
});
});

View File

@@ -9,8 +9,6 @@
const fs = require("fs");
const path = require("path");
const { makeStorageStub } = require("./support/storageStub");
const POPUP_HTML = fs.readFileSync(
path.join(__dirname, "..", "src", "popup", "index.html"),
"utf8",
@@ -53,17 +51,19 @@ describe("the UTC Timestamps checkbox placement", () => {
});
describe("the UTC Timestamps setting round-trips through storage", () => {
let storage;
let store;
// The stub clones in both directions, as the real chrome.storage.local
// does. It used to alias, which is fatal to a round-trip test in
// particular: the object the module holds and the object "storage" holds
// are then the same object, so the setting appears to have been persisted
// and read back on a build where neither happened. See
// tests/support/storageStub.js.
function loadStateModule() {
storage = makeStorageStub();
global.chrome = { storage };
store = {};
global.chrome = {
storage: {
local: {
get: async (key) =>
key in store ? { [key]: store[key] } : {},
set: async (obj) => Object.assign(store, obj),
},
},
};
jest.resetModules();
return require("../src/shared/state");
}
@@ -86,18 +86,12 @@ describe("the UTC Timestamps setting round-trips through storage", () => {
// What the change handler in views/settings.js does.
first.state.utcTimestamps = true;
await first.saveState();
expect(storage.read("autistmask").utcTimestamps).toBe(true);
expect(store.autistmask.utcTimestamps).toBe(true);
// A fresh popup load sees it — and, before that load, refuses to
// answer at all rather than reporting the default. That refusal is
// what makes the assertion below evidence of a read from storage
// instead of a value that was already sitting in memory
// (https://git.eeqj.de/sneak/AutistMask/issues/324).
// A fresh popup load sees it.
jest.resetModules();
const second = require("../src/shared/state");
expect(() => second.state.utcTimestamps).toThrow(
second.StateNotLoadedError,
);
expect(second.state.utcTimestamps).toBe(false);
await second.loadState();
expect(second.state.utcTimestamps).toBe(true);
});

View File

@@ -4,24 +4,23 @@ function oneWallet() {
return [{ name: "Wallet 1", type: "hd", addresses: [ADDRESS] }];
}
const { makeStorageStub } = require("./support/storageStub");
// state.js resolves the storage API at require time, so the stub has to exist
// before the module is loaded, and the module registry has to be reset between
// cases because `state` is a module-level singleton.
//
// The stub clones in both directions, as the real chrome.storage.local does —
// see tests/support/storageStub.js for why an aliasing one made this file
// assert less than it appears to.
function loadModuleWith(persisted) {
jest.resetModules();
const storage = makeStorageStub(persisted ? { autistmask: persisted } : {});
global.chrome = { storage };
return {
mod: require("../src/shared/state"),
set: storage.set,
stored: () => storage.read("autistmask"),
const set = jest.fn(async () => {});
global.chrome = {
storage: {
local: {
get: jest.fn(async () =>
persisted ? { autistmask: persisted } : {},
),
set,
},
},
};
return { mod: require("../src/shared/state"), set };
}
afterEach(() => {

View File

@@ -1,423 +0,0 @@
// saveState() used to write the entire state blob every time
// (src/shared/state.js). Every extension page — the toolbar popup, a dApp
// approval window opened by the background, backgroundRefresh() in
// src/background/index.js — holds its own in-memory `state`, loaded once,
// and src/popup/views/helpers.js showView() saves on EVERY navigation. So
// any second page that saved after a first page had written something new
// overwrote it, with no attacker and no unusual input: a whole wallet, name,
// addresses and encrypted secret included, silently gone
// (https://git.eeqj.de/sneak/AutistMask/issues/304).
//
// Both cases below drive the real state.js module through two independent
// module registries sharing one storage backend, the way two real extension
// pages share one chrome.storage.local. The shared stub structured-clones on
// both get and set — a stub that hands back the object it was given aliases
// the caller's own mutation and would make this entire defect class invisible
// (see https://git.eeqj.de/sneak/AutistMask/issues/324).
const { makeStorageStub } = require("./support/storageStub");
// One extension page: a fresh module registry over the shared storage.
// state.js resolves the storage API at require time, so the stub has to be
// installed before the module is loaded, and `state` is a module-level
// singleton, so each page needs its own registry to hold its own copy.
function loadPage(storage) {
jest.resetModules();
globalThis.chrome = { storage: { local: storage.local } };
return {
state: require("../src/shared/state"),
helpers: require("../src/popup/views/helpers"),
};
}
function wallet(name, secret, address) {
return {
type: "hd",
name,
xpub: "xpub-" + name,
encryptedSecret: secret,
nextIndex: 1,
addresses: [{ address, balance: "0", tokenBalances: [] }],
};
}
const W1 = wallet(
"Wallet 1",
"secret-one",
"0x66133E8ea0f5D1d612D2502a968757D1048c214a",
);
const W2 = wallet(
"Wallet 2",
"secret-two",
"0xdAC17F958D2ee523a2206206994597C13D831ec7",
);
// Minimal DOM: showView() toggles view elements, clears the flash line and
// creates/removes the debug banner. Nothing here is asserted; it only has to
// answer without throwing, the way the popup's own index.html would.
function makeElement(id) {
const classes = new Set();
return {
id,
textContent: "",
style: {},
classList: {
add: (...n) => n.forEach((c) => classes.add(c)),
remove: (...n) => n.forEach((c) => classes.delete(c)),
toggle: (c, force) => {
const on = force === undefined ? !classes.has(c) : force;
if (on) classes.add(c);
else classes.delete(c);
return on;
},
},
remove: () => {},
};
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
body: { prepend: () => {} },
};
}
afterEach(() => {
delete globalThis.chrome;
delete globalThis.document;
});
describe("a save from a page that never saw a wallet another page added", () => {
// The first DoD case on the issue: add a wallet in one page, then force
// a save from a second page loaded before that wallet existed. Both
// wallets must survive.
test("both wallets are in storage afterwards", async () => {
const storage = makeStorageStub();
await storage.set({ autistmask: { wallets: [W1] } });
// Loaded while storage held only Wallet 1, and never reloads —
// the approval window in the reproduction, or a second popup that
// has been open for a while.
const stale = loadPage(storage);
await stale.state.loadState();
expect(stale.state.state.wallets).toHaveLength(1);
// A second page, loaded after, adds a wallet — the exact sequence
// src/popup/views/addWallet.js uses.
const fresh = loadPage(storage);
await fresh.state.loadState();
fresh.state.state.wallets.push(W2);
fresh.state.state.hasWallet = true;
await fresh.state.saveState();
expect(
(await storage.get("autistmask")).autistmask.wallets,
).toHaveLength(2);
// The stale page saves something that has nothing to do with
// wallets — exactly what showView() does on every navigation, and
// what backgroundRefresh() does after a balance poll.
stale.state.state.currentView = "settings";
await stale.state.saveState();
const persisted = (await storage.get("autistmask")).autistmask;
expect(persisted.wallets.map((w) => w.name)).toEqual([
"Wallet 1",
"Wallet 2",
]);
expect(persisted.wallets.map((w) => w.encryptedSecret)).toEqual([
"secret-one",
"secret-two",
]);
});
});
describe("the approval-window reproduction", () => {
// approval window open, add a wallet in the popup, confirm the approval
// — the exact sequence from the issue. The approval window and the
// popup are the same popup code with a different starting view, so
// showView() is the real save path in both: src/popup/views/approval.js
// showTxApproval() calls showView("approve-tx") when the window opens,
// and a successful confirm calls
// src/popup/views/txStatus.js showWait() -> startWait(), which calls
// showView("wait-tx") — the save that clobbered the second wallet in
// the reproduction on the issue.
test("the wallet added in the popup survives confirming the approval", async () => {
globalThis.document = makeDocument();
const storage = makeStorageStub();
await storage.set({ autistmask: { wallets: [W1] } });
// The background opens the approval window on the approve-tx
// screen; nothing else has happened yet.
const approvalWindow = loadPage(storage);
await approvalWindow.state.loadState();
approvalWindow.helpers.showView("approve-tx");
// showView() does not await its own saveState(); an extra save
// joins the same queue and only resolves once that one has too,
// which is the black-box way to know it landed.
await approvalWindow.state.saveState();
// The user adds a wallet in the popup — a separate page, loaded
// after the approval window.
const popup = loadPage(storage);
await popup.state.loadState();
popup.state.state.wallets.push(W2);
popup.state.state.hasWallet = true;
await popup.state.saveState();
expect(
(await storage.get("autistmask")).autistmask.wallets,
).toHaveLength(2);
// The user confirms the approval. The approval window navigates
// approve-tx -> wait-tx, saving again from state it loaded before
// Wallet 2 ever existed.
approvalWindow.helpers.showView("wait-tx");
await approvalWindow.state.saveState();
const persisted = (await storage.get("autistmask")).autistmask;
expect(persisted.wallets.map((w) => w.name)).toEqual([
"Wallet 1",
"Wallet 2",
]);
expect(persisted.wallets.map((w) => w.encryptedSecret)).toEqual([
"secret-one",
"secret-two",
]);
});
});
// backgroundRefresh() (src/background/index.js) loads state, spends seconds
// on network I/O in refreshBalances() (src/shared/balances.js) mutating
// addr.balance/ensName/tokenBalances IN PLACE on the wallets it already
// knew about, then saves. Precondition 2 on the issue: that refresh window
// overlapping a membership change (add or delete) on another page must not
// clobber or resurrect a wallet — a whole-field diff on `wallets` failed
// this, because "background changed a balance" and "another page changed
// membership" collided as the same field.
describe("background refresh racing a wallet added on another page", () => {
test("the wallet added elsewhere survives background's stale balance save", async () => {
const storage = makeStorageStub();
await storage.set({ autistmask: { wallets: [W1] } });
// "background": loads first, and its save is the one that lands
// last, modeling the multi-second network round trip in between.
const background = loadPage(storage);
await background.state.loadState();
background.state.state.wallets[0].addresses[0].balance = "1.2345";
// A second page, loaded after, adds a wallet while background's
// refresh is still in flight.
const popup = loadPage(storage);
await popup.state.loadState();
popup.state.state.wallets.push(W2);
popup.state.state.hasWallet = true;
await popup.state.saveState();
expect(
(await storage.get("autistmask")).autistmask.wallets,
).toHaveLength(2);
// background's save lands last, carrying only its balance update.
await background.state.saveState();
const persisted = (await storage.get("autistmask")).autistmask;
expect(persisted.wallets.map((w) => w.name)).toEqual([
"Wallet 1",
"Wallet 2",
]);
expect(persisted.wallets.map((w) => w.encryptedSecret)).toEqual([
"secret-one",
"secret-two",
]);
// The balance update itself must not be lost either — this is a
// merge, not deletion-always-wins.
expect(persisted.wallets[0].addresses[0].balance).toBe("1.2345");
});
});
describe("background refresh racing a wallet deleted on another page", () => {
test("the wallet deleted elsewhere stays deleted after background's stale balance save", async () => {
const storage = makeStorageStub();
await storage.set({ autistmask: { wallets: [W1, W2] } });
const background = loadPage(storage);
await background.state.loadState();
background.state.state.wallets[0].addresses[0].balance = "1.2345";
// A second page deletes Wallet 2 while background's refresh is in
// flight — the same splice deleteWallet.js's removeWalletFromState()
// does.
const popup = loadPage(storage);
await popup.state.loadState();
popup.state.state.wallets.splice(1, 1);
popup.state.state.hasWallet = popup.state.state.wallets.length > 0;
await popup.state.saveState();
expect(
(await storage.get("autistmask")).autistmask.wallets,
).toHaveLength(1);
await background.state.saveState();
const persisted = (await storage.get("autistmask")).autistmask;
expect(persisted.wallets.map((w) => w.name)).toEqual(["Wallet 1"]);
expect(persisted.wallets[0].addresses[0].balance).toBe("1.2345");
});
});
// allowedSites/deniedSites: { [address]: [hostname, ...] }. Mutated in place
// from two different contexts — src/background/index.js:592-599 pushes a
// newly approved hostname onto state.allowedSites[activeAddress], and the
// Settings "revoke" button (src/popup/views/settings.js:55-68) filters a
// hostname out of state[key][addr] in place, deleting the address key
// entirely once its list is empty — the exact membership-vs-whole-field
// pattern that made the whole-field `wallets` diff unsafe, on a
// security-relevant field: a stale whole-field save here can resurrect a
// revoked permission or wipe a freshly granted one.
const ADDR1 = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const ADDR2 = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
function approveSite(pageState, address, hostname) {
if (!pageState.allowedSites[address]) {
pageState.allowedSites[address] = [];
}
if (!pageState.allowedSites[address].includes(hostname)) {
pageState.allowedSites[address].push(hostname);
}
}
function revokeSite(pageState, hostname) {
for (const addr of Object.keys(pageState.allowedSites)) {
pageState.allowedSites[addr] = pageState.allowedSites[addr].filter(
(h) => h !== hostname,
);
if (pageState.allowedSites[addr].length === 0) {
delete pageState.allowedSites[addr];
}
}
}
describe("a dApp approval racing a stale Settings page's later save", () => {
test("the fresh approval survives Settings revoking an unrelated site", async () => {
const storage = makeStorageStub();
await storage.set({
autistmask: {
wallets: [W1],
allowedSites: { [ADDR2]: ["other.example"] },
},
});
// Settings loads first, and its save lands last — before either has
// any idea a dApp approval happened elsewhere in between.
const settings = loadPage(storage);
await settings.state.loadState();
// A dApp approval window, opened later, approves a new site for a
// different address and saves — the real sequence at
// src/background/index.js:592-599.
const approval = loadPage(storage);
await approval.state.loadState();
approveSite(approval.state.state, ADDR1, "dapp.example");
await approval.state.saveState();
expect(
(await storage.get("autistmask")).autistmask.allowedSites[ADDR1],
).toEqual(["dapp.example"]);
// Settings revokes its own, unrelated site — the real sequence at
// src/popup/views/settings.js:55-68 — and saves from state loaded
// before the dApp approval ever happened.
revokeSite(settings.state.state, "other.example");
await settings.state.saveState();
const persisted = (await storage.get("autistmask")).autistmask;
expect(persisted.allowedSites[ADDR1]).toEqual(["dapp.example"]);
expect(persisted.allowedSites[ADDR2]).toBeUndefined();
});
});
describe("a revoked site permission against a stale page's later save", () => {
test("the revocation holds even when the stale page approves something else", async () => {
const storage = makeStorageStub();
await storage.set({
autistmask: {
wallets: [W1],
allowedSites: { [ADDR1]: ["evil.example"] },
},
});
// A stale page loads while the permission still stands.
const stale = loadPage(storage);
await stale.state.loadState();
// Settings revokes it — src/popup/views/settings.js:55-68 — from a
// second page.
const settings = loadPage(storage);
await settings.state.loadState();
revokeSite(settings.state.state, "evil.example");
await settings.state.saveState();
expect(
(await storage.get("autistmask")).autistmask.allowedSites[ADDR1],
).toBeUndefined();
// The stale page, unaware of the revoke, approves an unrelated site
// for a different address and saves — src/background/index.js:592-599.
approveSite(stale.state.state, ADDR2, "good.example");
await stale.state.saveState();
const persisted = (await storage.get("autistmask")).autistmask;
expect(persisted.allowedSites[ADDR2]).toEqual(["good.example"]);
expect(persisted.allowedSites[ADDR1]).toBeUndefined();
});
});
// mergeListByIdentity()'s identity function is not guaranteed collision-free
// — walletIdentity() falls back to one shared "addr:" value for any wallet
// with neither an xpub nor a populated first address (a legacy or corrupt
// record). Two such records created independently on two different pages
// must not silently collapse into one, dropping the loser's
// encryptedSecret with no error and no log.
function legacyWallet(name, secret) {
return {
type: "legacy",
name,
encryptedSecret: secret,
nextIndex: 0,
addresses: [],
};
}
describe("two wallets independently created with a colliding identity", () => {
test("both survive, encryptedSecret included, instead of one silently replacing the other", async () => {
const storage = makeStorageStub();
await storage.set({ autistmask: { wallets: [W1] } });
// Both pages load before either has created their malformed wallet,
// so neither has baseline knowledge of the other's.
const pageA = loadPage(storage);
await pageA.state.loadState();
const pageB = loadPage(storage);
await pageB.state.loadState();
pageA.state.state.wallets.push(legacyWallet("Legacy A", "secret-a"));
pageA.state.state.hasWallet = true;
await pageA.state.saveState();
expect(
(await storage.get("autistmask")).autistmask.wallets,
).toHaveLength(2);
pageB.state.state.wallets.push(legacyWallet("Legacy B", "secret-b"));
pageB.state.state.hasWallet = true;
await pageB.state.saveState();
const persisted = (await storage.get("autistmask")).autistmask;
const secrets = persisted.wallets.map((w) => w.encryptedSecret);
expect(secrets).toContain("secret-one");
expect(secrets).toContain("secret-a");
expect(secrets).toContain("secret-b");
});
});

View File

@@ -1,73 +0,0 @@
// A chrome.storage.local stub that behaves like the real one.
//
// The real extension storage API is a serialization boundary: `set` writes a
// structured clone of what it is given, and `get` hands back a structured
// clone of what is stored. Nothing an extension page holds is ever the object
// storage holds.
//
// A stub that skips the clone aliases them together, and that hides an entire
// class of defect rather than merely being imprecise. loadState() assigns
// nested references straight out of the get result, so over an aliasing stub a
// test can assert "the endpoint was persisted" and pass on a build that never
// called saveState() at all: the in-memory mutation IS the stored record.
// Measured, not theorised — with an aliasing `get` restored over the handler
// fixed in https://git.eeqj.de/sneak/AutistMask/pulls/319, the whole suite
// passed 794/794 (https://git.eeqj.de/sneak/AutistMask/issues/324).
//
// So every test that drives real persistence uses this, and nothing rebuilds
// a storage stub by hand.
// `initial` is the starting contents, keyed as storage is: { autistmask: {...} }.
// `onOp` runs before each operation, for a test that needs to advance a clock
// or count round trips.
function makeStorageStub(initial, onOp) {
const store = initial ? structuredClone(initial) : {};
const tick = onOp || (() => {});
const get = jest.fn(async (key) => {
tick();
if (key === undefined || key === null) return structuredClone(store);
const keys = Array.isArray(key) ? key : [key];
const out = {};
for (const k of keys) {
if (Object.prototype.hasOwnProperty.call(store, k)) {
out[k] = structuredClone(store[k]);
}
}
return out;
});
const set = jest.fn(async (items) => {
tick();
for (const k of Object.keys(items)) {
store[k] = structuredClone(items[k]);
}
});
const remove = jest.fn(async (key) => {
tick();
for (const k of Array.isArray(key) ? key : [key]) delete store[k];
});
return {
// Drop this straight in as chrome.storage.
local: { get, set, remove },
get,
set,
remove,
// What is stored, cloned on the way out: a test can neither observe a
// later write through an object it read nor reach into the store by
// mutating one.
read: (key) =>
key === undefined
? structuredClone(store)
: structuredClone(store[key]),
// Seed or replace a record without going through the module under
// test — for standing in as "another page wrote this".
write: (key, value) => {
store[key] = structuredClone(value);
},
};
}
module.exports = { makeStorageStub };

View File

@@ -682,7 +682,6 @@ describe("surface 3: the balance list", () => {
"https://rpc.example.invalid",
BLOCKSCOUT,
[],
"mainnet",
);
expect(addr.balance).toBe("1.2345");
expect(addr.tokenBalances).toEqual([]);

View File

@@ -30,11 +30,8 @@ global.fetch = jest.fn(() => {
});
// state.js reads chrome.storage.local at module load; stub it so the
// default settings can be asserted against what the README promises. Empty
// storage, so a load produces exactly the defaults.
const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub() };
// default settings can be asserted against what the README promises.
global.chrome = { storage: { local: {} } };
const {
fetchRecentTransactions,
@@ -748,16 +745,6 @@ describe("dust threshold filtering", () => {
});
describe("filter defaults promised by the README and Settings", () => {
// The defaults are what a load of empty storage produces, so the load is
// part of the assertion rather than an incantation before it: reading the
// singleton before any load now throws (StateNotLoadedError), because a
// context served DEFAULT_STATE without asking for it is the whole subject
// of https://git.eeqj.de/sneak/AutistMask/issues/324. The stub at the top
// of this file has storage empty.
beforeAll(async () => {
await require("../src/shared/state").loadState();
});
test("all four toggles default to on and the threshold to 100,000 gwei", () => {
expect(state.hideSpoofedSymbols).toBe(true);
expect(state.hideLowHolderTokens).toBe(true);

View File

@@ -96,14 +96,18 @@ global.document = {
global.window = { location: { search: "" } };
// Clones in both directions, as the real chrome.storage.local does; the stub
// here used to hand back the live stored object, so an in-memory mutation
// looked like a write that had reached storage. See
// tests/support/storageStub.js.
const { makeStorageStub } = require("./support/storageStub");
const storage = makeStorageStub();
global.chrome = { storage };
const stored = {};
global.chrome = {
storage: {
local: {
set: (obj) => {
Object.assign(stored, obj);
return Promise.resolve();
},
get: () => Promise.resolve(stored),
},
},
};
const txStatus = require("../src/popup/views/txStatus");
const { state } = require("../src/shared/state");

View File

@@ -1,107 +0,0 @@
// The output token of a swap, as the dApp approval screen names it.
//
// Issue #346: the `Token Out` detail line in `src/shared/uniswap.js` was
// pushed only `if (outSymbol)`, and a token absent from the bundled list has
// no symbol. The line was therefore dropped entirely for exactly that
// population — which is every newly listed token — leaving a `Min. received`
// figure with nothing on the screen saying what is being received. The
// `Token In` line already falls back to the address; the rule asserted here is
// that the output side does too, always.
const { AbiCoder, Interface, getAddress } = require("ethers");
const uniswap = require("../src/shared/uniswap");
const { unknownDecimalsAmount } = require("../src/shared/approvalAmount");
const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
// In the bundled list, at 18 decimals.
const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
// Outside it, as every newly listed token is. Checksummed, because that is
// the form the decoder gets back from the ABI decode and puts on the line.
const NOVEL_OUT = getAddress("0xd0d0000000000000000000000000000000000d0d");
const HALF_WETH = 500000000000000000n;
// 1,000.00 of a 6-decimal token.
const THOUSAND_AT_SIX = 1000000000n;
const coder = AbiCoder.defaultAbiCoder();
const routerIface = new Interface([
"function execute(bytes commands, bytes[] inputs, uint256 deadline)",
]);
// A V2_SWAP_EXACT_IN (command 0x08) execute() call.
function swapData(tokenIn, amountIn, tokenOut, amountOutMin) {
const input = coder.encode(
["address", "uint256", "uint256", "address[]", "bool"],
[RECIPIENT, amountIn, amountOutMin, [tokenIn, tokenOut], true],
);
return routerIface.encodeFunctionData("execute", [
"0x08",
[input],
9999999999n,
]);
}
// An UNWRAP_WETH (command 0x0c) execute() call: the output side is native ETH,
// which has no contract address to name.
function unwrapData() {
return routerIface.encodeFunctionData("execute", [
"0x0c",
[coder.encode(["address", "uint256"], [RECIPIENT, HALF_WETH])],
9999999999n,
]);
}
function detail(data, label, sources) {
const decoded = uniswap.decode(data, ROUTER, sources || {});
return decoded.details.find((d) => d.label === label);
}
describe("a swap to a token absent from the bundled list", () => {
const data = () => swapData(WETH, HALF_WETH, NOVEL_OUT, THOUSAND_AT_SIX);
test("still renders a Token Out line, naming the address", () => {
const out = detail(data(), "Token Out");
expect(out).toBeDefined();
expect(out.value).toBe(NOVEL_OUT);
expect(out.address).toBe(NOVEL_OUT);
expect(out.isToken).toBe(true);
});
test("names the address alongside the unknown-scale refusal", () => {
// The same population hits both: no symbol, and no scale either. The
// address says which token, the base units say how much and admit the
// scale is unknown — neither line silently means something else.
expect(detail(data(), "Token Out").value).toBe(NOVEL_OUT);
expect(detail(data(), "Min. received").value).toBe(
unknownDecimalsAmount(THOUSAND_AT_SIX),
);
});
test("names the address when the scale is known but the symbol is not", () => {
const sources = {
trackedTokens: [
{ address: NOVEL_OUT, symbol: "NOVEL", decimals: 6 },
],
};
expect(detail(data(), "Token Out", sources).value).toBe(NOVEL_OUT);
expect(detail(data(), "Min. received", sources).value).toBe(
"1000.0000",
);
});
});
describe("the output tokens that already had a line keep it unchanged", () => {
test("a bundled token is named by symbol and address", () => {
const data = swapData(NOVEL_OUT, THOUSAND_AT_SIX, WETH, HALF_WETH);
const out = detail(data, "Token Out");
expect(out.value).toBe("WETH (" + WETH + ")");
expect(out.address).toBe(WETH);
});
test("an unwrap to native ETH is named ETH, with no address", () => {
const out = detail(unwrapData(), "Token Out");
expect(out.value).toBe("ETH");
expect(out.address).toBeUndefined();
});
});

View File

@@ -1,176 +0,0 @@
// The scale the swap lines of the dApp approval screen are displayed with.
//
// Issue #340: `tokenInfo()` in `src/shared/uniswap.js` returned `decimals: 18`
// for any token absent from the bundled list — the same guess
// https://git.eeqj.de/sneak/AutistMask/issues/306 removed from the ERC-20
// amount line, still live on the swap path. A 1,000-token swap of a 6-decimal
// token then rendered as `0.000000001` on the one screen whose job is to state
// what is being authorized, and every newly listed token reaches it.
//
// What is asserted here is the rule #306 established: resolve the real scale
// wherever the wallet already has it, and where nothing has it refuse to
// format — base units with the scale stated, never a quantity.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { AbiCoder, Interface } = require("ethers");
const { state } = require("../src/shared/state");
const { unknownDecimalsAmount } = require("../src/shared/approvalAmount");
const { decodeCalldata } = require("../src/popup/views/approval");
const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
// Outside the bundled list, as every newly listed token is.
const NOVEL = "0xE2E0000000000000000000000000000000000E2e";
// Also outside it, standing in for the swap's output side.
const NOVEL_OUT = "0xd0d0000000000000000000000000000000000d0d";
// In the bundled list, at 18 decimals.
const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
// 1,000.00 of a 6-decimal token — the amount from the issue, which the 18
// guess rendered as 0.000000001.
const THOUSAND_AT_SIX = 1000000000n;
// 0.5 WETH out, so the Min. received line has a real number of its own.
const HALF_WETH = 500000000000000000n;
const coder = AbiCoder.defaultAbiCoder();
const routerIface = new Interface([
"function execute(bytes commands, bytes[] inputs, uint256 deadline)",
]);
// A V2_SWAP_EXACT_IN (command 0x08) execute() call: `amountIn` of `tokenIn`
// for at least `amountOutMin` of `tokenOut`.
function swapData(tokenIn, amountIn, tokenOut, amountOutMin) {
const input = coder.encode(
["address", "uint256", "uint256", "address[]", "bool"],
[RECIPIENT, amountIn, amountOutMin, [tokenIn, tokenOut], true],
);
return routerIface.encodeFunctionData("execute", [
"0x08",
[input],
9999999999n,
]);
}
// A wallet holding `token` with the decimals the block explorer reported,
// shaped as balances.js writes it onto state.
function walletsHolding(token, decimals) {
return [
{
name: "Wallet 1",
addresses: [
{
address: "0x" + "a".repeat(40),
balance: "1.0",
tokenBalances: [
{
address: token,
symbol: "NOVEL",
decimals,
balance: "1000.0",
},
],
},
],
},
];
}
// The swap detail line as the approval screen renders it. It goes through
// decodeCalldata() rather than uniswap.decode() directly, because the scale
// sources the screen supplies are part of what is under test.
function swapDetail(data, label) {
const decoded = decodeCalldata(data, ROUTER);
return decoded.details.find((d) => d.label === label);
}
beforeEach(() => {
state.trackedTokens = [];
state.wallets = [];
});
describe("a swap of a token outside the bundled list", () => {
const data = () => swapData(NOVEL, THOUSAND_AT_SIX, WETH, HALF_WETH);
test("shows the true quantity when the user tracks the token", () => {
state.trackedTokens = [
{ address: NOVEL, symbol: "NOVEL", decimals: 6 },
];
expect(swapDetail(data(), "Amount").value).toBe("1000.0000");
});
test("shows the true quantity from the explorer's decimals", () => {
state.wallets = walletsHolding(NOVEL, "6");
expect(swapDetail(data(), "Amount").value).toBe("1000.0000");
});
test("refuses to format when nothing knows the scale", () => {
const detail = swapDetail(data(), "Amount");
expect(detail.value).toBe("1000000000 base units (decimals unknown)");
expect(detail.value).toBe(unknownDecimalsAmount(THOUSAND_AT_SIX));
// The defect: an 18-decimal guess renders this swap as 0.000000001, a
// quantity, and a wrong one.
expect(detail.value).not.toMatch(/^0\./);
expect(detail.value).not.toMatch(/[0-9]\.[0-9]/);
});
test("the amount carried to the status screens is the same refusal", () => {
expect(swapDetail(data(), "Amount").rawValue).toBe(
"1000000000 base units (decimals unknown)",
);
});
test("a bundled token on the other side still formats", () => {
expect(swapDetail(data(), "Min. received").value).toBe("0.5000 WETH");
});
});
describe("the Min. received line takes the same rule", () => {
test("refuses to format an output token of unknown scale", () => {
const data = swapData(WETH, HALF_WETH, NOVEL_OUT, THOUSAND_AT_SIX);
const detail = swapDetail(data, "Min. received");
expect(detail.value).toBe("1000000000 base units (decimals unknown)");
expect(detail.value).not.toMatch(/[0-9]\.[0-9]/);
});
test("shows the true quantity when the user tracks the output token", () => {
state.trackedTokens = [
{ address: NOVEL_OUT, symbol: "NOVEL", decimals: 6 },
];
const data = swapData(WETH, HALF_WETH, NOVEL_OUT, THOUSAND_AT_SIX);
expect(swapDetail(data, "Min. received").value).toBe("1000.0000");
});
});
describe("the permit amount takes the same rule", () => {
// PERMIT2_PERMIT (command 0x0a): the input token and amount come from the
// permit rather than from a swap step.
function permitData(token, amount) {
const input = coder.encode(
[
"tuple(tuple(address,uint160,uint48,uint48),address,uint256)",
"bytes",
],
[[[token, amount, 0, 0], ROUTER, 9999999999], "0x1234"],
);
return routerIface.encodeFunctionData("execute", [
"0x0a",
[input],
9999999999n,
]);
}
test("refuses to format a permit on a token of unknown scale", () => {
const detail = swapDetail(permitData(NOVEL, THOUSAND_AT_SIX), "Amount");
expect(detail.value).toBe("1000000000 base units (decimals unknown)");
});
test("an unbounded permit is still named, with or without a scale", () => {
const maxUint160 = (1n << 160n) - 1n;
expect(swapDetail(permitData(NOVEL, maxUint160), "Amount").value).toBe(
"Unlimited",
);
});
});

View File

@@ -1,97 +0,0 @@
// One version, three files, and the rule that they agree.
//
// package.json, manifest/chrome.json and manifest/firefox.json each declare a
// version and none is derived from another. Before this, nothing compared
// them: the manifests were hardcoded at 0.1.0 and copied to dist/ verbatim
// while package.json fed the About screen separately, so the number the
// browser reported and the number the extension displayed could drift apart
// with no check anywhere failing.
//
// The build enforces the agreement (build.js calls resolveVersion before it
// emits anything) and script/lib/package.js names the artifacts from it. This
// asserts both halves: that the tree as committed agrees, and that a tree that
// does not is refused rather than resolved to one of the answers.
const fs = require("fs");
const os = require("os");
const path = require("path");
const {
VERSION_SOURCES,
declaredVersions,
resolveVersion,
} = require("../script/lib/version");
const ROOT = path.join(__dirname, "..");
// A tree containing only the three version files, with the given versions.
function fixture(versions) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "autistmask-version-"));
fs.mkdirSync(path.join(dir, "manifest"));
VERSION_SOURCES.forEach((source, i) => {
fs.writeFileSync(
path.join(dir, source),
JSON.stringify({ version: versions[i] }),
);
});
return dir;
}
describe("the declared version", () => {
test("all three sources agree in this tree", () => {
const declared = declaredVersions(ROOT);
expect(declared.map((d) => d.source)).toEqual(VERSION_SOURCES);
expect([...new Set(declared.map((d) => d.version))]).toHaveLength(1);
expect(resolveVersion(ROOT)).toBe(declared[0].version);
});
// Semver-shaped, because it names every release artifact and is what the
// browser compares when deciding whether an install is an upgrade.
test("is semver-shaped", () => {
expect(resolveVersion(ROOT)).toMatch(/^\d+\.\d+\.\d+$/);
});
// The load-bearing case: each of the three, disagreeing on its own, has to
// fail. A check that only looked at two of them would pass one of these.
test.each([
["package.json", ["9.9.9", "0.1.0", "0.1.0"]],
["manifest/chrome.json", ["0.1.0", "9.9.9", "0.1.0"]],
["manifest/firefox.json", ["0.1.0", "0.1.0", "9.9.9"]],
])("a disagreeing %s fails rather than resolving", (source, versions) => {
const dir = fixture(versions);
try {
expect(() => resolveVersion(dir)).toThrow(
/the declared versions disagree/,
);
expect(() => resolveVersion(dir)).toThrow(new RegExp(source));
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("agreeing sources resolve", () => {
const dir = fixture(["1.2.3", "1.2.3", "1.2.3"]);
try {
expect(resolveVersion(dir)).toBe("1.2.3");
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
// A missing or empty version is not agreement. Left unchecked, three files
// that all declared nothing would "agree" on undefined and the artifacts
// would be named after it.
test.each([[undefined], [""], [" "], [3]])(
"a version of %p is refused",
(bad) => {
const dir = fixture([bad, bad, bad]);
try {
expect(() => resolveVersion(dir)).toThrow(
/declares no usable "version"/,
);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
},
);
});