Compare commits

..

3 Commits

Author SHA1 Message Date
480563cd16 harden: make the background physically unable to read the shared state singleton (closes #324)
All checks were successful
check / check (push) Successful in 49s
e2e / e2e-chrome (push) Successful in 2m0s
e2e / e2e-firefox (push) Successful in 45s
Five defects traced to one fact: src/background/index.js read and wrote the
module-level `state` singleton in src/shared/state.js, which the MV3 service
worker never populates and which answered an unpopulated read out of
DEFAULT_STATE in silence. Every previous fix added a loadState() before the
access, and that is what produced the fifth: a load detaches the objects an
in-flight handler is holding.

So the reachability goes rather than a sixth call site.

The background now has its own storage layer, src/background/state.js:
getState() is a detached, normalized per-call read, and updateState() is a
queued read-modify-write whose read is one storage round trip ahead of its
write. Nothing in the background holds an in-memory copy of the profile.

- Every handler takes one snapshot and answers from it, including the address
  it names: activeAddressOf(s) replaced a second, later storage read that
  could disagree with the first.
- wallet_switchEthereumChain applies applyChainSwitchFields() (split out of
  chainSwitch.js, which keeps the singleton path for the popup) inside
  updateState() instead of calling onChainSwitch() on the singleton.
- The remembered site decision is a read-modify-write, not a load-mutate-save
  around a prompt the user takes seconds to answer.
- backgroundRefresh() refreshes a private copy of the wallets and applies the
  balances that came back by address, so it never publishes an object other
  in-flight work holds, and a wallet added or deleted during the round trip
  survives its write.
- The transaction attempt takes its chain id and its endpoint from the same
  snapshot. They used to come from different moments, so a chain switch
  committed in between moved the endpoint under an artifact already verified
  against the old chain.

getProvider(rpcUrl, networkId) now REQUIRES the network id and validates it
against networks.js. That closes the cold-worker wrong-chain send at its shape
rather than at one call site: the hint used to default to currentNetwork() off
the unpopulated singleton, so the endpoint was the user's chain and ethers
fixed chainId at 0x1, and the wallet's own verifySignedTx then refused every
non-mainnet dApp send. refreshBalances(), lookupTokenInfo(), scanForAddresses()
and resolveEnsName() carry the id through; balances.js no longer requires
state.js at all.

The prohibition is enforced mechanically, not by review, and it is enforced by
the bundler rather than by a guess at what the bundler does. build.js keeps a
FORBIDDEN_INPUTS table of modules an entry point's bundle may not contain, and
assertNoForbiddenInputs() fails the build when esbuild's metafile reports
src/shared/state.js as an input of a background bundle, naming the import chain
from the metafile's own graph. That is the resolution the shipped bundle was
built from, so no specifier syntax, no hop and no resolution rule can slip past
it; Dockerfile:42 runs make build, so it holds in CI. A FORBIDDEN_INPUTS key
that matches no bundled entry point also fails, so the table cannot rot into a
vacuous pass.

A custom ESLint rule walks the CommonJS require graph from every src/background/
file and reports the same thing in the editor, before a full bundle. It matches
specifiers textually, so it is best-effort fast feedback and not the guarantee —
two earlier revisions of it shipped holes (a template literal, a dynamic
import(), a comment inside the call, a directory resolved through package.json
main). Those are covered now and pinned by
tests/backgroundStateLintRule.test.js, and the next divergence between a
hand-rolled matcher and a real bundler is caught by the build instead. A
computed specifier (require("../shared/" + "state")) is deliberately not
matched: esbuild cannot resolve it either, so it never reaches the bundle.

Reading a persisted field of the singleton before any load now throws
StateNotLoadedError instead of serving DEFAULT_STATE.

Test stubs: chrome.storage.local is a serialization boundary, and eight files
stubbed it with an aliasing get, so the object a module held and the object
"storage" held were one object — an assertion could pass on a build that never
wrote anything. Every test that drives real persistence now goes through
tests/support/storageStub.js, which structured-clones in both directions.

closes #320
2026-08-23 14:31:32 +00:00
43784cab3f harden: resolve or refuse the swap token scale instead of guessing 18 (closes #340)
All checks were successful
check / check (push) Successful in 29s
e2e / e2e-chrome (push) Successful in 1m47s
e2e / e2e-firefox (push) Successful in 31s
tokenInfo() returned decimals 18 for any token absent from the bundled list, so the swap approval line rendered a real 1000.00 of a 6-decimal token as 0.000000000001. The scale is now resolved from what the wallet already holds (bundled list, tracked tokens, explorer-reported decimals) or refused outright, matching the rule set for the ERC-20 path in #306. A refusal reuses unknownDecimalsAmount(), so it reads as "base units (decimals unknown)" with no decimal point and no symbol, and the same string propagates to rawValue so no downstream screen can render a figure the approval screen refused. No new network call on the approval path. Verified green on all three CI contexts: check, e2e-chrome, e2e-firefox.
2026-08-23 16:20:22 +02:00
769f6a5289 release: produce a versioned per-browser artifact and pin the Chrome extension id (closes #310)
All checks were successful
check / check (push) Successful in 32s
e2e / e2e-chrome (push) Successful in 1m44s
e2e / e2e-firefox (push) Successful in 32s
manifest/chrome.json now carries a fixed public key, so the extension id and the chrome.storage.local partition holding the wallet stay stable across checkout moves and re-clones instead of being derived from the absolute path. A release entrypoint produces a self-contained versioned artifact per browser, including the files that sit at dist/ root outside both browser directories. One version source of truth, enforced: the build fails naming the culprit when the two manifests and package.json disagree, and BUILD_COMMIT now marks a dirty tree as dirty. Firefox ships an unsigned XPI; the README states that release Firefox and ESR refuse it, that Developer Edition or Unbranded is required, and that Remove is irreversible except from the recovery phrase, which is asserted by test.
2026-08-23 16:13:22 +02:00
27 changed files with 2639 additions and 111 deletions

View File

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

3
.gitignore vendored
View File

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

View File

@@ -1,4 +1,5 @@
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 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 package 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,6 +41,9 @@ check:
check-censored:
@script/check-censored
package:
@script/package
docker:
@script/docker
@@ -102,7 +105,7 @@ vendor-blocklist:
@script/vendor-blocklist
clean:
@rm -rf dist/
@rm -rf dist/ release/
dev:
@echo "Building in watch mode..."

172
README.md
View File

@@ -44,7 +44,107 @@ 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`.
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.**
### Debug Builds
@@ -146,6 +246,11 @@ 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
@@ -198,7 +303,7 @@ The Makefile shims to those. It also carries a few targets that have no
- `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/`
- `make clean` — remove `dist/` and `release/`
- `make dev` — build in watch mode
## End-to-End Tests
@@ -334,6 +439,23 @@ 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
@@ -351,6 +473,33 @@ 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`
@@ -734,6 +883,25 @@ 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

68
TODO.md
View File

@@ -63,17 +63,33 @@ but the review is broader than any of them.
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 an ESLint rule that
walks the background's require graph and matches every specifier syntax
esbuild resolves — quoted, backtick, dynamic `import()` and `from` clause — so
neither a re-export nor an unusual specifier can put the singleton back in the
bundle; the rule's own coverage is pinned by
`tests/backgroundStateLintRule.test.js`. 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
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. An ESLint rule that walks the require
graph textually gives the same answer in the editor, before a full bundle; it
is fast feedback rather than the guarantee, and the shapes it is known to
catch are pinned by `tests/backgroundStateLintRule.test.js`. 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: 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
@@ -113,6 +129,40 @@ but the review is broader than any of them.
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

157
build.js
View File

@@ -3,6 +3,7 @@ const path = require("path");
const crypto = require("crypto");
const { execSync } = require("child_process");
const esbuild = require("esbuild");
const { resolveVersion } = require("./script/lib/version");
const DIST = path.join(__dirname, "dist");
const DIST_CHROME = path.join(DIST, "chrome");
@@ -15,6 +16,26 @@ const SRC = path.join(__dirname, "src");
// rotting with it.
const AUDITED_MODULE = "src/shared/constants.js";
// Modules a given entry point's bundle may not contain, keyed by the
// repo-relative entry point.
//
// src/shared/state.js is a module-level object loaded once by loadState() and
// mutated in place from then on. That is the popup's model. The MV3 service
// worker has no "once" — it is killed when idle and revived by the next
// message — so a background read of it is answered out of DEFAULT_STATE. 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.
//
// 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.
// Dockerfile:42 runs `make build`, so it is enforced in CI.
const FORBIDDEN_INPUTS = {
"src/background/index.js": ["src/shared/state.js"],
};
// 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
@@ -64,6 +85,71 @@ 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;
}
// Entry points from FORBIDDEN_INPUTS that this build actually bundled. A key
// that matches nothing means the table has rotted away from the entry point
// list — the prohibition would then be silently unenforced, so the build fails
// on it rather than passing vacuously.
const forbiddenEntriesSeen = new Set();
// 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.
function assertNoForbiddenInputs(entryPoint, outfile, metafile) {
const entry = repoRelative(entryPoint);
const forbidden = FORBIDDEN_INPUTS[entry];
if (!forbidden) return;
forbiddenEntriesSeen.add(entry);
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),
);
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.`,
);
}
}
// 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
@@ -160,28 +246,53 @@ 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"),
);
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
}
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();
return {
version: pkg.version,
// 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),
license: pkg.license,
author: pkg.author,
commitHash,
@@ -267,6 +378,9 @@ 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.
assertNoForbiddenInputs(entryPoint, outfile, result.metafile);
recordEmitted(outfile);
auditedBundles.push(...outputsContainingAuditedModule(result.metafile));
}
@@ -323,6 +437,15 @@ async function build() {
path.join(DIST_FIREFOX, "manifest.json"),
);
for (const entry of Object.keys(FORBIDDEN_INPUTS)) {
if (!forbiddenEntriesSeen.has(entry)) {
throw new Error(
`${entry} is listed in FORBIDDEN_INPUTS but was not bundled, ` +
`so nothing checked it`,
);
}
}
// 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".

View File

@@ -3,6 +3,7 @@
"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

@@ -9,31 +9,35 @@
// (https://git.eeqj.de/sneak/AutistMask/issues/324). The background has its
// own per-call storage layer in src/background/state.js instead.
//
// A convention nobody can violate beats a convention everyone remembers, so
// this is a lint error rather than a review item. 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.
// 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.
//
// The walk reads sources from disk and matches import specifiers textually.
// That over-approximates — a specifier inside a comment or a string counts —
// and over-approximating is the safe direction for a prohibition: the failure
// mode is a spurious error naming an exact file and line, not a silent hole.
// 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 has to match EVERY specifier syntax esbuild resolves statically, because
// the hole a narrower match leaves is not "the rule is less tidy", it is a
// sixth site the build cannot see. Matching only `require("x")` and `require('x')`
// let four shapes through, each of which was confirmed to put the singleton in
// the shipped worker bundle: a backtick `require(`x`)`, a dynamic `import("x")`,
// a static `import ... from "x"` / `export ... from "x"`, and any of those one
// hop away in a shared module the background already pulls in.
// 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.
//
// Known and deliberate gap: a computed specifier, `require("../shared/" +
// "state")`. It is not matched here, and it is not a hole — esbuild cannot
// resolve it statically either, so it never reaches the bundle. Contorting the
// rule to chase it would buy nothing.
// 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.
//
// Deliberately not matched: a computed specifier, `require("../shared/" +
// "state")`. esbuild cannot resolve that statically either, so it never
// reaches the bundle.
const fs = require("fs");
const path = require("path");
@@ -41,20 +45,53 @@ const path = require("path");
// The module this rule exists to keep out, relative to the repo root.
const FORBIDDEN = path.join("src", "shared", "state.js");
// 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"`).
const SPECIFIER_RE =
/\b(?:require|import)\(\s*["'`]([^"'`]+)["'`]\s*\)|\b(?:from|import)\s+["'`]([^"'`]+)["'`]/g;
// 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",
);
// Resolve a relative require to a file path, trying the extensions node would.
// 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 {

294
script/lib/package.js Normal file
View File

@@ -0,0 +1,294 @@
// 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 };

74
script/lib/version.js Normal file
View File

@@ -0,0 +1,74 @@
// 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 };

274
script/lib/zip.js Normal file
View File

@@ -0,0 +1,274 @@
// 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 };

38
script/package Executable file
View File

@@ -0,0 +1,38 @@
#!/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,6 +78,22 @@ 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,6 +72,19 @@ 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

@@ -73,6 +73,14 @@ 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 });
@@ -84,10 +92,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, {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
});
const tokenDecimals = resolveTokenDecimals(
toAddress,
decimalsSources,
);
const contractLabel = tokenSymbol
? tokenSymbol + " (" + toAddress + ")"
: toAddress;
@@ -167,7 +175,7 @@ function decodeCalldata(data, toAddress) {
}
// Try Uniswap Universal Router
const routerResult = uniswap.decode(data, toAddress);
const routerResult = uniswap.decode(data, toAddress, decimalsSources);
if (routerResult) return routerResult;
return null;

View File

@@ -14,6 +14,10 @@
// 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

@@ -4,6 +4,10 @@
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();
@@ -44,13 +48,37 @@ function formatAmount(raw, decimals) {
return truncateAmountNeverZero(formatUnits(raw, decimals));
}
function tokenInfo(address) {
// `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) {
if (!address || address === "0x0000000000000000000000000000000000000000") {
return { symbol: "ETH", decimals: 18, address: null };
}
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
if (t) return { symbol: t.symbol, decimals: t.decimals, address };
return { symbol: null, decimals: 18, address };
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 : ""),
};
}
// Decode PERMIT2_PERMIT (command 0x0a) input bytes.
@@ -323,7 +351,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) {
function decode(data, toAddress, sources) {
try {
const parsed = ROUTER_IFACE.parseTransaction({ data });
if (!parsed) return null;
@@ -416,10 +444,10 @@ function decode(data, toAddress) {
}
// Resolve token info
const inInfo = tokenInfo(inputToken);
const inInfo = tokenInfo(inputToken, sources);
const outInfo = hasUnwrapWeth
? { symbol: "ETH", decimals: 18, address: null }
: tokenInfo(outputToken);
: tokenInfo(outputToken, sources);
const inSymbol = inInfo.symbol;
const outSymbol = outInfo.symbol;
@@ -456,16 +484,15 @@ function decode(data, toAddress) {
"0xffffffffffffffffffffffffffffffffffffffff",
);
const isUnlimited = inputAmount >= maxUint160;
const amountRaw = isUnlimited
? "Unlimited"
: formatAmount(inputAmount, inInfo.decimals);
const amountStr = isUnlimited
? "Unlimited"
: amountRaw + (inSymbol ? " " + inSymbol : "");
// 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);
details.push({
label: "Amount",
value: amountStr,
rawValue: amountRaw,
value: amount.display,
rawValue: amount.raw,
});
}
@@ -486,10 +513,10 @@ function decode(data, toAddress) {
}
if (minOutput !== null && minOutput !== undefined) {
const minStr =
formatAmount(minOutput, outInfo.decimals) +
(outSymbol ? " " + outSymbol : "");
details.push({ label: "Min. received", value: minStr });
details.push({
label: "Min. received",
value: amountText(minOutput, outInfo).display,
});
}
details.push({ label: "Steps", value: commandNames.join(" \u2192 ") });

View File

@@ -3,17 +3,20 @@
//
// 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). The prohibition is
// therefore mechanical rather than a review item — which means the rule's
// coverage is itself load-bearing, and a hole in it is indistinguishable from
// having no rule at all.
// (https://git.eeqj.de/sneak/AutistMask/issues/324).
//
// The hole this file exists to pin shut is SPECIFIER SYNTAX. The rule walks the
// require graph textually, and a first version matched only `require("x")` and
// `require('x')`. Every shape below was measured against a real `make build`:
// each one puts state.js in dist/chrome/src/background/index.js, and each one
// was invisible to the narrower match. So each is a case here, and a regression
// in the matcher fails the suite instead of shipping a sixth site.
// 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. 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`.
//
// NOT covered, deliberately: a computed specifier such as
// `require("../shared/" + "state")`. esbuild cannot resolve that statically
@@ -90,7 +93,7 @@ afterEach(() => {
roots = [];
});
describe("every specifier syntax esbuild resolves is blocked", () => {
describe("the specifier syntaxes the matcher is known to catch", () => {
test("a quoted require", () => {
const root = fixture({
"src/background/index.js":
@@ -138,6 +141,29 @@ describe("every specifier syntax esbuild resolves is blocked", () => {
);
});
// `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',
@@ -166,6 +192,24 @@ describe("reachability, not just the direct specifier", () => {
" -> 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",
);
});
});
describe("what the rule must not report", () => {

View File

@@ -72,6 +72,11 @@ RUN script/bootstrap
COPY . .
RUN make build
# 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
CMD ["node", "tests/e2e/firefox/run.js", "dist/firefox"]

View File

@@ -103,7 +103,13 @@ class Driver {
// ------------------------------------------------------------ setup
async newSession() {
// `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) {
const prefs = {
// See EXTENSION_UUID above. The pref is a string pref whose
// value is itself JSON.
@@ -132,26 +138,27 @@ 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: [
"-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",
],
args,
prefs,
},
},
@@ -162,16 +169,30 @@ class Driver {
return value;
}
// 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) {
// 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) {
return this.session("POST", "/moz/addon/install", {
path: dir,
path: pathToAddon,
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

@@ -0,0 +1,438 @@
// 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

@@ -0,0 +1,303 @@
// 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;
});

119
tests/extensionId.test.js Normal file
View File

@@ -0,0 +1,119 @@
// 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();
});
});

190
tests/packaging.test.js Normal file
View File

@@ -0,0 +1,190 @@
// 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

@@ -0,0 +1,176 @@
// 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",
);
});
});

97
tests/version.test.js Normal file
View File

@@ -0,0 +1,97 @@
// 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 });
}
},
);
});