From 52948a2b0b8b0e043a3f8c4c7350aa9dbf76bc8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Sat, 19 Sep 2026 01:21:01 +0200 Subject: [PATCH 01/11] Docs: product update contract (explicit activation, continuous visibility, no-wedge core) Co-Authored-By: Claude Fable 5.1 --- README.md | 1 + docs/update.md | 229 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 230 insertions(+) create mode 100644 docs/update.md diff --git a/README.md b/README.md index f58debd1..df7d2da4 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ their current supported implementation. - [Decisions, alternatives and required amendments](docs/decisions.md) - [Migration, upgrade and recovery](docs/migration-and-recovery.md) - [Proposed release cycle](docs/release-cycle.md) +- [Product update contract](docs/update.md) - [Selective Launchpad adoption and Doctor direction](docs/legacy-adoption.md) - [Developer commands and conventions](docs/development.md) - [Future shared marketplace](docs/marketplace.md) diff --git a/docs/update.md b/docs/update.md new file mode 100644 index 00000000..4b30b01d --- /dev/null +++ b/docs/update.md @@ -0,0 +1,229 @@ +# Product update + +Status: **accepted direction of the Principal (2026-09-19), implementation in +progress.** This document is the single contract for how an installed Lazurio +learns about, obtains and activates a new product version. It supersedes the +update-related *proposals* in [release cycle](release-cycle.md) and the deferred +recovery designs in [pilot repair](pilot-repair.md) and +[expired trust recovery](expired-trust-recovery.md); trust, signing and promotion +requirements stated there remain binding. Nothing is deployed to real clients on +this codebase yet, so this contract is written without a compatibility burden. +After the first client deployment every change to it must be compatible. + +## What the Principal asked for + +1. Update is a **conscious step**. Lazurio never activates a new version behind + the user's back. +2. Availability is shown **continuously**: a Machine never silently stays on an + old version. +3. When the user clicks, the update **always and dependably happens**. A failed + attempt never leaves a state that needs manual repair before the next click. +4. Small, clean mechanism. No machinery without a consumer. + +The model for the user experience and the release workflow is T3 Code's desktop +updater: a small explicit state machine, a poller in the long-running app, a +pill in the UI (`available → downloading → ready → restart`), release notes, +failures that fall back to `available` with a retry, and a tag-driven CI release +that publishes every platform at once. + +## Invariants + +- **One core.** CLI and Launchpad call the same update use case. There is no + second updater and no shell-script installer logic outside the product. +- **Explicit activation.** Checking and showing availability are automatic; + download and activation start only from `lazurio update` or the Launchpad + action. A managed policy may later request an update, never silently perform + one on a Machine with a working person. +- **No wedge.** Every failure leaves the installed product usable and the next + attempt possible. There is no pending-attempt state, no `recover` command and + no journal that a later run must replay. +- **Trust never rewinds.** TUF metadata accepted during any attempt — above all + a rotated root — is persisted durably the moment it is accepted, independently + of whether the artifact download later succeeds. Program rollback never + touches trust. +- **Program rollback is not data rollback.** A version is staged only when the + signed identity proves it can read the current Folder state; rollback is + offered only to a version that can still read what the newer one wrote. +- **Running work is never killed to finish an update.** The switch changes what + future launches select; a running Launchpad keeps its immutable executable + (`process.execPath` resolves to the versioned path, verified on macOS and + Linux) until it restarts. +- **Self-hosted works alone.** No Human and Machine service is needed to check, + download, verify or activate. Managed policy, fleet visibility and analytics + are additive consumers of the same local state. + +## State on disk + +The install base stays per user and outside the Folder (see release cycle). It +tolerates unknown entries: it verifies only what it owns, so a stray file or a +neighbouring legacy runtime can never stop product commands, and an older binary +never refuses a newer layout. + +```text +bin/lazurio -> ../versions/+/lazurio # the only active selector +versions/+/{lazurio,identity.json} # immutable +trust/ # durable accepted TUF metadata +update/state.json # what the user sees +update/intent.json # present only during activation +update/quarantine.json # artifact digests that failed their gate +update/scratch-*/ # always safe to delete +``` + +`update/state.json` is the whole user-visible truth, deliberately shaped like +T3 Code's: `status` (`idle | checking | up-to-date | available | downloading | +ready | activating | restart-pending | error`), `channel`, `currentVersion`, +`availableVersion`, `stagedVersion`, `releaseNotes`, `downloadPercent`, +`lastAttemptAt`, `lastAuthenticatedCheckAt`, `lastActivationAt`, `error` +(`code`, `context`, `message`) and `canRetry`. Four timestamps stay separate on +purpose: a successful check does not imply a converged Machine. + +The active version is recorded once, by the symlink. `update/intent.json` +(`previous`, `candidate`, `phase`) exists only between the decision to switch and +the confirmed health of the new version; any later start of any `lazurio` reads +it and either completes the confirmation or restores `previous`. Intent decides +recovery; logs only explain it. + +## The three steps + +**Check** (automatic, cheap, no mutation of the product). Refresh TUF metadata +against the durable `trust/` directory, persisting each accepted role atomically +(root chain first). Read the signed channel document. Compare with the embedded +version. Write `state.json`. Network or expiry failures produce a typed error and +leave everything else untouched. + +**Download** (explicit). Resumable ranged download with bounded retries and one +total deadline, into scratch. Verify length and digest against signed targets, +verify the signed identity (target, version, schema read-compatibility, minimum +updater contract). Run the **staged executable by its immutable path** in +self-check mode: it must report the embedded identity that matches the signed +one and read the current Folder state without writing. Only then rename into +`versions/`. A digest in quarantine is never downloaded again; identical bad +bytes do not become good because a sequence advanced. + +**Activate** (explicit, part of the same click unless the user chose "download +only"). Write intent, swap the symlink by rename, then converge the running +Launchpad: + +- no applications running → the Launchpad restarts itself (exit under its + supervisor, re-exec otherwise); a short-lived observer started from the *new* + binary waits for a readiness response carrying the expected artifact digest; +- applications running → status `restart-pending`; the UI offers "Restart now" + naming what will stop. Module applications are owned by guards that stop when + their Launchpad exits; giving them independent OS supervision so they survive + a restart is a separate, later change and is not faked here; +- readiness not confirmed within the bound → restore `previous`, restart it, + quarantine the candidate, report a typed error. The user is back on a working + version and the pill says why. + +One lock covers a whole step, acquired blocking with a timeout; lock +initialization is crash-safe; the lock works on any local filesystem that +provides `flock`. + +Retention: active, previous and any version still running are kept; everything +else, all scratch and superseded trust files are pruned after a confirmed +activation. Disk is checked before download. + +## Surfaces + +- **Launchpad.** Poller: first check shortly after start, then every few minutes + with jitter (the check is one small signed document). Pill with version, notes + and the single action appropriate to the status. The Launchpad shows when the + running version differs from the active one. +- **CLI.** `lazurio update` (check, download, activate), `lazurio update --check`, + `lazurio update status [--json]`, `lazurio update rollback`, `lazurio --version`. + Other commands print a one-line notice from `state.json`; they never touch the + network for it. Every failure has a stable error code and a non-zero exit + status that automation can classify. +- **Identity in the binary.** Version, commit and target are embedded at build + time, together with the TUF root and the default origin. A local + `update/config.json` holds only the channel. + +## Never silently stale + +1. Local: the pill and the CLI notice, fed by the poller. +2. Signed: the channel document carries `minimum_version`; below it the + Launchpad shows a prominent, non-blocking notice. A disconnected Machine + cannot learn newer policy, therefore: +3. Observed: `state.json` is the contract that an outside observer reads. A + missing or old `lastAuthenticatedCheckAt`, a version behind the channel, or a + long `restart-pending` is a failure for whoever watches the fleet. Today that + observer is the Machines readback on hosted Machines; later it is the + managed service once a Machine is enrolled through Lazurio Account. An + unused installation that nobody observes gets an OS-scheduled check only when + a real consumer needs it; it is not part of the first delivery. + +## Foundations for the managed product (not built now) + +Lazurio Account login in the Launchpad, a Machine profile selected in the +Dashboard and internal usage analytics all use one direction of authority: +the service expresses **desired state with a revision**, the Machine-local core +pulls it, validates it like any other input and applies it through the same use +cases the CLI uses; the Machine reports **observed state** (`state.json`, profile +digest, coarse consented usage). Login never becomes local authority, analytics +can never block an update, and measurement stays default-off and consent-bound +as in [profile evidence](profile-evidence.md). The update work lays exactly two +foundations for this: the observed-state document and typed, revisioned inputs. +It adds no enrollment, no heartbeat and no generic maintenance framework. + +## Publishing + +Tag-driven workflow modelled on T3 Code: quality gates, deterministic builds for +the supported targets, one GitHub Release with all artifacts and generated +notes, then publication of signed metadata. +Channels are `stable` and `preview`; promotion edits the signed channel document +to point at the same bytes. Each channel has its own sequence floor; switching +channel is explicit and never downgrades. + +TUF stays (accepted decision; rotation, expiry and rollback protection are not +worth re-implementing). Root is offline with the Principal. The targets key +authorizes releases and lives behind a protected CI environment with a required +approver. Snapshot and timestamp keys serve freshness only and are renewed by a +scheduled job **well before** expiry, with monitoring of every role's remaining +validity; a lapsed timestamp would stop every Machine from updating. + +There is one origin and no new infrastructure: GitHub Releases of this public +repository. Each product release carries its artifacts; one fixed metadata +release carries the TUF metadata and channel documents as replaceable assets at +stable URLs. The client maps `artifacts//…` to the asset URL explicitly +and the redirect origins are qualified by test, not assumed. A second origin is +added only when a real consumer (a private fork, an air-gapped customer) needs +it; the transport already takes the origin as configuration. + +## Deliberately narrow + +Elegance here means a small number of paths that are each proven, not coverage +of every combination. + +- **Targets:** `linux-x64` (hosted Machines) and `darwin-arm64` (workstations) + are supported; `linux-arm64` is built because the local qualification VM is + ARM64. Intel macOS, musl and Windows are not built until a real user needs + them; Windows gets its own activation design then, not a port of this one. +- **Install scope:** per user only. No system-wide install, no multi-user + sharing of one base. A hosted Team Workspace is one Machine with one OS user, + so it is the same case as a private workspace. +- **One channel document format, two channels, one origin.** +- **One supervisor per OS:** a systemd user unit on Linux, a launchd agent on + macOS, or the terminal that started the Launchpad. Nothing else is detected. +- **No automatic activation, no OS scheduler, no enrollment, no heartbeat** in + this delivery. Each arrives with its first real consumer. +- **No migration of the legacy installer state.** Nothing is deployed, so the + pilot layout (`active.json`, attempts, history, generations) is removed, not + adopted. + +## Evidence required before any Machine depends on this + +CI builds two real versions and proves, against a fixture repository and then +natively on Linux (x64, ARM64) and macOS ARM64: A→B, confirmed restart, automatic +rollback of an unhealthy B and quarantine, B′ after quarantine, `kill -9` at +every step of every phase, expired timestamp, repository advancing mid-update, +root rotation accepted during a failed download and retained, full disk, unknown +files in the base, two concurrent updates, update while applications run. The +same journey then runs on a hosted canary Machine. + +## Removed by this contract + +The write-ahead metadata journal, transcript replay, historical role floors, +recovery cycles, attempt history, `product recover`, the double active record and +the refusal of unknown base entries. Their security purpose — never losing +accepted trust — is kept by durable per-role persistence, which is smaller and +cannot wedge. From 87ca24df280d8496c209b5c941d171f7c0da29fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Sat, 19 Sep 2026 02:14:30 +0200 Subject: [PATCH 02/11] Update contract: durable trust promotion, activation worker from the known-good binary, atomic publishing Co-Authored-By: Claude Fable 5.1 --- docs/update.md | 180 ++++++++++++++++++++++++++++++------------------- 1 file changed, 111 insertions(+), 69 deletions(-) diff --git a/docs/update.md b/docs/update.md index 4b30b01d..ca59348e 100644 --- a/docs/update.md +++ b/docs/update.md @@ -16,8 +16,10 @@ After the first client deployment every change to it must be compatible. the user's back. 2. Availability is shown **continuously**: a Machine never silently stays on an old version. -3. When the user clicks, the update **always and dependably happens**. A failed - attempt never leaves a state that needs manual repair before the next click. +3. When the user clicks, the update **dependably happens**: every failure is + bounded, leaves the installed product working, and the same click works again + once the outside condition (network, disk, a published compatible release) + is restored. No failed attempt needs manual repair first. 4. Small, clean mechanism. No machinery without a consumer. The model for the user experience and the release workflow is T3 Code's desktop @@ -35,12 +37,15 @@ that publishes every platform at once. action. A managed policy may later request an update, never silently perform one on a Machine with a working person. - **No wedge.** Every failure leaves the installed product usable and the next - attempt possible. There is no pending-attempt state, no `recover` command and - no journal that a later run must replay. -- **Trust never rewinds.** TUF metadata accepted during any attempt — above all - a rotated root — is persisted durably the moment it is accepted, independently - of whether the artifact download later succeeds. Program rollback never - touches trust. + attempt possible. Delivery state is disposable: no pending download attempt, + no `recover` command, no journal to replay. The only transaction record is + the small activation record below, with a fixed set of transitions that any + later start can finish or undo on its own. +- **Trust never rewinds.** TUF metadata verified during any attempt — above all + a rotated root — is promoted into the durable trust directory whether or not + the rest of the attempt succeeds. Channel sequence and document-digest floors + are security state too and live beside it, not in the presentation state. + Program rollback never touches either. - **Program rollback is not data rollback.** A version is staged only when the signed identity proves it can read the current Folder state; rollback is offered only to a version that can still read what the newer one wrote. @@ -55,41 +60,54 @@ that publishes every platform at once. ## State on disk The install base stays per user and outside the Folder (see release cycle). It -tolerates unknown entries: it verifies only what it owns, so a stray file or a -neighbouring legacy runtime can never stop product commands, and an older binary -never refuses a newer layout. +tolerates unrelated entries: it verifies only what it owns, so a stray file or a +neighbouring legacy runtime can never stop product commands. Owned security +state carries a schema version; a binary refuses a newer schema it cannot read +rather than guessing, and a version is offered for rollback only if it can read +the current one. ```text bin/lazurio -> ../versions/+/lazurio # the only active selector versions/+/{lazurio,identity.json} # immutable -trust/ # durable accepted TUF metadata -update/state.json # what the user sees -update/intent.json # present only during activation -update/quarantine.json # artifact digests that failed their gate +trust/ # durable verified TUF metadata + channel floors +update/activation.json # present only during an activation +update/observed.json # derived observation for UI, CLI and observers update/scratch-*/ # always safe to delete ``` -`update/state.json` is the whole user-visible truth, deliberately shaped like -T3 Code's: `status` (`idle | checking | up-to-date | available | downloading | -ready | activating | restart-pending | error`), `channel`, `currentVersion`, -`availableVersion`, `stagedVersion`, `releaseNotes`, `downloadPercent`, -`lastAttemptAt`, `lastAuthenticatedCheckAt`, `lastActivationAt`, `error` -(`code`, `context`, `message`) and `canRetry`. Four timestamps stay separate on -purpose: a successful check does not imply a converged Machine. - -The active version is recorded once, by the symlink. `update/intent.json` -(`previous`, `candidate`, `phase`) exists only between the decision to switch and -the confirmed health of the new version; any later start of any `lazurio` reads -it and either completes the confirmation or restores `previous`. Intent decides -recovery; logs only explain it. +`update/observed.json` is an observation, never an authority. It is shaped +like T3 Code's update state — `status` (`idle | checking | up-to-date | +available | downloading | ready | activating | restart-pending | error`), +`channel`, `releaseNotes`, `downloadPercent`, `error` (`code`, `context`) and +`canRetry` — and keeps four facts apart because a successful check does not +imply a converged Machine: the **selected** artifact (the symlink), the +**running** Launchpad artifact, the **verified available** target and the +**last healthy activation**, each with its time. It carries a schema version, +an observation time and the operation identity, holds error codes rather than +localized prose, and is rebuilt from the selector, the verified artifacts and +the service manager after any crash, so a stale `downloading` can never stick. +Fleet observers read a sanitized subset. + +The active version is recorded once, by the symlink. `update/activation.json` +(`previous`, `candidate`, `phase`, `deadline`) exists only between the decision +to switch and the confirmed health of the new version. Its transitions are +fixed: `switching → confirming → confirmed | rolled-back`. It decides recovery; +logs only explain it. ## The three steps -**Check** (automatic, cheap, no mutation of the product). Refresh TUF metadata -against the durable `trust/` directory, persisting each accepted role atomically -(root chain first). Read the signed channel document. Compare with the embedded -version. Write `state.json`. Network or expiry failures produce a typed error and -leave everything else untouched. +**Check** (automatic, cheap, no mutation of the product). The TUF client +refreshes in a scratch copy seeded from `trust/`; it writes a role file only +after verifying it. When the refresh ends — success or failure — every newly +verified role is promoted into `trust/` by temporary file, file sync, rename and +directory sync, root chain first, then timestamp, snapshot, targets. A crash +before promotion equals a refresh that never ran; a crash between promotions +leaves a newer root with older roles, which the next refresh re-verifies under +that root. This needs no change to the pinned `tuf-js` and is proven by +interruption, expiry, repository-advance and key-rotation tests before the old +journal and replay code is deleted. Then read the signed channel document, +compare with the embedded version and rewrite `observed.json`. Network or +expiry failures produce a typed error and leave everything else untouched. **Download** (explicit). Resumable ranged download with bounded retries and one total deadline, into scratch. Verify length and digest against signed targets, @@ -97,27 +115,42 @@ verify the signed identity (target, version, schema read-compatibility, minimum updater contract). Run the **staged executable by its immutable path** in self-check mode: it must report the embedded identity that matches the signed one and read the current Folder state without writing. Only then rename into -`versions/`. A digest in quarantine is never downloaded again; identical bad -bytes do not become good because a sequence advanced. +`versions/`. A candidate that failed its gate is not offered again +automatically; an explicit retry re-runs the full cryptographic and readiness +gate, so a transient environmental failure is not a permanent verdict and bad +bytes still never pass. **Activate** (explicit, part of the same click unless the user chose "download -only"). Write intent, swap the symlink by rename, then converge the running -Launchpad: - -- no applications running → the Launchpad restarts itself (exit under its - supervisor, re-exec otherwise); a short-lived observer started from the *new* - binary waits for a readiness response carrying the expected artifact digest; -- applications running → status `restart-pending`; the UI offers "Restart now" - naming what will stop. Module applications are owned by guards that stop when - their Launchpad exits; giving them independent OS supervision so they survive - a restart is a separate, later change and is not faked here; -- readiness not confirmed within the bound → restore `previous`, restart it, - quarantine the candidate, report a typed error. The user is back on a working - version and the pill says why. - -One lock covers a whole step, acquired blocking with a timeout; lock -initialization is crash-safe; the lock works on any local filesystem that -provides `flock`. +only"). The activation is driven by a short-lived **activation worker started +from the previous, known-good immutable binary**, outside the Launchpad's +termination group and under the OS service manager, because a candidate that +cannot start cannot supervise its own rollback. The worker writes the activation +record, swaps the symlink by rename and restarts the Launchpad service. It then +requires readiness from a **fresh instance**: a new process that reports the +expected artifact digest, Folder and protocol compatibility, and stays healthy +for a bounded stability period; an old process answering, or a bare HTTP 200, +proves nothing. It distinguishes a candidate that fails from a gateway that is +down. Confirmed → record removed, retention applied. Not confirmed within the +deadline → symlink restored to `previous`, service restarted, typed error +reported; the user is back on a working version and the pill says why. After a +reboot mid-activation the service manager restarts the worker, which finishes +from the record. A CLI started meanwhile coordinates with the worker through the +record and never undoes its switch. + +The Launchpad restart is harmless because long-running module applications are +owned by the OS service manager, not by the Launchpad process (see +[module adoption](module-adoption.md); on Linux systemd user services). Where +that ownership is not yet available (macOS workstations run applications for +the Launchpad session), running applications turn the step into +`restart-pending` and the UI offers "Restart now" naming what will stop. + +No Folder data is written in a new format until the activation is confirmed, so +the rollback window never needs a data downgrade. + +One lock covers a step, acquired blocking with a timeout; lock initialization is +crash-safe; the lock works on any local filesystem that provides `flock`. The +lock is released before the worker waits for the restarted Launchpad, which +needs it to start. Retention: active, previous and any version still running are kept; everything else, all scratch and superseded trust files are pruned after a confirmed @@ -131,7 +164,7 @@ activation. Disk is checked before download. running version differs from the active one. - **CLI.** `lazurio update` (check, download, activate), `lazurio update --check`, `lazurio update status [--json]`, `lazurio update rollback`, `lazurio --version`. - Other commands print a one-line notice from `state.json`; they never touch the + Other commands print a one-line notice from `observed.json`; they never touch the network for it. Every failure has a stable error code and a non-zero exit status that automation can classify. - **Identity in the binary.** Version, commit and target are embedded at build @@ -144,7 +177,7 @@ activation. Disk is checked before download. 2. Signed: the channel document carries `minimum_version`; below it the Launchpad shows a prominent, non-blocking notice. A disconnected Machine cannot learn newer policy, therefore: -3. Observed: `state.json` is the contract that an outside observer reads. A +3. Observed: `observed.json` is the contract that an outside observer reads. A missing or old `lastAuthenticatedCheckAt`, a version behind the channel, or a long `restart-pending` is a failure for whoever watches the fleet. Today that observer is the Machines readback on hosted Machines; later it is the @@ -156,9 +189,11 @@ activation. Disk is checked before download. Lazurio Account login in the Launchpad, a Machine profile selected in the Dashboard and internal usage analytics all use one direction of authority: -the service expresses **desired state with a revision**, the Machine-local core +the service sends **typed, resource-specific requests with an expected local +revision** (a profile revision, a channel, never a generic desired-state +document), the Machine-local core pulls it, validates it like any other input and applies it through the same use -cases the CLI uses; the Machine reports **observed state** (`state.json`, profile +cases the CLI uses; the Machine reports **observed state** (`observed.json`, profile digest, coarse consented usage). Login never becomes local authority, analytics can never block an update, and measurement stays default-off and consent-bound as in [profile evidence](profile-evidence.md). The update work lays exactly two @@ -181,13 +216,18 @@ approver. Snapshot and timestamp keys serve freshness only and are renewed by a scheduled job **well before** expiry, with monitoring of every role's remaining validity; a lapsed timestamp would stop every Machine from updating. -There is one origin and no new infrastructure: GitHub Releases of this public -repository. Each product release carries its artifacts; one fixed metadata -release carries the TUF metadata and channel documents as replaceable assets at -stable URLs. The client maps `artifacts//…` to the asset URL explicitly -and the redirect origins are qualified by test, not assumed. A second origin is -added only when a real consumer (a private fork, an air-gapped customer) needs -it; the transport already takes the origin as configuration. +Publishing is atomic and needs no new infrastructure. The TUF repository uses +**consistent snapshots**: numbered roots, snapshots and targets and +hash-addressed artifacts are immutable and never replaced. Artifacts are assets +of the product's GitHub Release. Metadata and channel documents are a static +tree published through GitHub Pages of this repository, where one deployment +replaces the whole tree at once; the publisher uploads artifacts, verifies that +everything the new metadata references is reachable, and only then publishes the +tree, timestamp last. Old referenced objects are retained so a cached timestamp +always describes a complete repository. The client maps `artifacts//…` +to the asset URL explicitly and qualifies the redirect origins by test. A second +origin is added only when a real consumer (a private fork, an air-gapped +customer) needs it; the transport already takes the origin as configuration. ## Deliberately narrow @@ -202,8 +242,9 @@ of every combination. sharing of one base. A hosted Team Workspace is one Machine with one OS user, so it is the same case as a private workspace. - **One channel document format, two channels, one origin.** -- **One supervisor per OS:** a systemd user unit on Linux, a launchd agent on - macOS, or the terminal that started the Launchpad. Nothing else is detected. +- **One supervisor per OS:** systemd user services on Linux own the Launchpad, + the activation worker and module applications. On macOS the Launchpad and its + applications are session-scoped until a workstation consumer needs more. - **No automatic activation, no OS scheduler, no enrollment, no heartbeat** in this delivery. Each arrives with its first real consumer. - **No migration of the legacy installer state.** Nothing is deployed, so the @@ -214,7 +255,7 @@ of every combination. CI builds two real versions and proves, against a fixture repository and then natively on Linux (x64, ARM64) and macOS ARM64: A→B, confirmed restart, automatic -rollback of an unhealthy B and quarantine, B′ after quarantine, `kill -9` at +rollback of an unhealthy B, explicit retry, `kill -9` of the worker and reboot at every step of every phase, expired timestamp, repository advancing mid-update, root rotation accepted during a failed download and retained, full disk, unknown files in the base, two concurrent updates, update while applications run. The @@ -224,6 +265,7 @@ same journey then runs on a hosted canary Machine. The write-ahead metadata journal, transcript replay, historical role floors, recovery cycles, attempt history, `product recover`, the double active record and -the refusal of unknown base entries. Their security purpose — never losing -accepted trust — is kept by durable per-role persistence, which is smaller and -cannot wedge. +the refusal of unrelated base entries — **after** the interruption, expiry and +rotation tests above prove that durable per-role promotion preserves the same +trust. Their security purpose is kept by a mechanism that is smaller and cannot +wedge. From e2faf256cbb33042338cac888fae74e922237a4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Sat, 19 Sep 2026 02:36:23 +0200 Subject: [PATCH 03/11] Update core primitives: stable error codes, embedded identity, channel document, durable writes, crash-safe lock The future home of the update mechanism is src/update/; src/distribution/ stays untouched. One table defines every error code with its exit status and retryability. The identity module is the only reader of the build-time defines and falls back to a development identity that sorts below every release. The channel parser keeps the pilot parser's invariants for the stable and preview channels and adds the version a check compares. The lock is one flock-ed regular file polled to a timeout: nothing to initialize, nothing to wedge. Co-Authored-By: Claude Fable 5.1 --- src/update/channel.ts | 149 +++++++++++++++++++++++++++ src/update/durable-file.ts | 64 ++++++++++++ src/update/errors.ts | 62 +++++++++++ src/update/identity.ts | 101 ++++++++++++++++++ src/update/lock.ts | 101 ++++++++++++++++++ src/update/version.ts | 45 ++++++++ tests/fixtures/update-lock-holder.ts | 8 ++ tests/update-channel.test.ts | 124 ++++++++++++++++++++++ tests/update-lock.test.ts | 96 +++++++++++++++++ 9 files changed, 750 insertions(+) create mode 100644 src/update/channel.ts create mode 100644 src/update/durable-file.ts create mode 100644 src/update/errors.ts create mode 100644 src/update/identity.ts create mode 100644 src/update/lock.ts create mode 100644 src/update/version.ts create mode 100644 tests/fixtures/update-lock-holder.ts create mode 100644 tests/update-channel.test.ts create mode 100644 tests/update-lock.test.ts diff --git a/src/update/channel.ts b/src/update/channel.ts new file mode 100644 index 00000000..4c9af2fc --- /dev/null +++ b/src/update/channel.ts @@ -0,0 +1,149 @@ +import { createHash } from "node:crypto"; +import { parseUniqueJson } from "../providers/unique-json"; +import { UpdateFailure } from "./errors"; +import { isProductVersion, updateTargets } from "./identity"; + +/** Channels are `stable` and `preview` (docs/update.md "Publishing"). */ +export const updateChannels = ["stable", "preview"] as const; +export type UpdateChannel = (typeof updateChannels)[number]; + +export function isUpdateChannel(value: unknown): value is UpdateChannel { + return (updateChannels as readonly unknown[]).includes(value); +} + +/** Signed TUF target path of a channel document. */ +export function channelTargetPath(channel: UpdateChannel): string { + return `channels/${channel}.json`; +} + +/** A channel document is one small signed file, so a check stays cheap. */ +export const maxChannelDocumentBytes = 64 * 1024; + +export type ChannelFloor = Readonly<{ + sequence: number; + documentSha256: string; +}>; + +export type ChannelDocument = Readonly<{ + channel: UpdateChannel; + sequence: number; + documentSha256: string; + version: string; + minimumVersion: string; + /** Execution target → `artifacts//lazurio`. */ + targets: Readonly>; +}>; + +const fields = [ + "channel", + "minimumVersion", + "schemaVersion", + "sequence", + "targets", + "version", +]; + +export function isChannelFloor(value: unknown): value is ChannelFloor { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const record = value as Record; + return ( + Object.keys(record).sort().join(",") === "documentSha256,sequence" && + typeof record.sequence === "number" && + Number.isSafeInteger(record.sequence) && + record.sequence >= 1 && + typeof record.documentSha256 === "string" && + /^[a-f0-9]{64}$/.test(record.documentSha256) + ); +} + +/** Parse ONLY bytes that TUF already verified against signed targets; this + * function authenticates nothing. It keeps the invariants of the pilot parser + * (`src/distribution/channel.ts`): unique members, an exact field set, a + * digest-addressed artifact path per known target, and a channel that must + * equal the one asked for so a document can never be replayed across channels. + * It adds what a check needs without touching the artifact: the product + * version and the signed minimum version. + */ +export function parseChannelDocument( + authenticatedBytes: Uint8Array, + channel: UpdateChannel, +): ChannelDocument { + const invalid = (reason: string) => + new UpdateFailure("channel-invalid", { channel, reason }); + let value: unknown; + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(authenticatedBytes); + value = parseUniqueJson(text); + } catch { + throw invalid("malformed"); + } + if (!value || typeof value !== "object" || Array.isArray(value)) + throw invalid("malformed"); + const record = value as Record; + if (Object.keys(record).sort().join(",") !== fields.join(",")) + throw invalid("unknown-fields"); + // A newer document format is a publisher/binary mismatch, not an attack; it + // is still refused rather than guessed. + if (record.schemaVersion !== 1) throw invalid("unsupported-schema"); + if (record.channel !== channel) throw invalid("wrong-channel"); + if ( + typeof record.sequence !== "number" || + !Number.isSafeInteger(record.sequence) || + record.sequence < 1 + ) + throw invalid("sequence"); + if ( + !isProductVersion(record.version) || + !isProductVersion(record.minimumVersion) + ) + throw invalid("version"); + if ( + !record.targets || + typeof record.targets !== "object" || + Array.isArray(record.targets) + ) + throw invalid("targets"); + const targets: Record = {}; + for (const [target, path] of Object.entries(record.targets)) { + // A name here is the publisher's claim, not native qualification; the + // download step still verifies the signed identity of the bytes. + if ( + !(updateTargets as readonly string[]).includes(target) || + typeof path !== "string" || + !/^artifacts\/[a-f0-9]{64}\/lazurio$/.test(path) + ) + throw invalid("targets"); + targets[target] = path; + } + return Object.freeze({ + channel, + sequence: record.sequence, + documentSha256: createHash("sha256") + .update(authenticatedBytes) + .digest("hex"), + version: record.version, + minimumVersion: record.minimumVersion, + targets: Object.freeze(targets), + }); +} + +/** Monotonic floor: a lower sequence, or the same sequence with different + * bytes, is refused. Identical authenticated bytes are a normal repeat. + */ +export function assertNotRolledBack( + document: ChannelDocument, + floor: ChannelFloor | undefined, +): void { + if (!floor) return; + if ( + document.sequence < floor.sequence || + (document.sequence === floor.sequence && + document.documentSha256 !== floor.documentSha256) + ) + throw new UpdateFailure("channel-rollback", { + channel: document.channel, + floorSequence: floor.sequence, + offeredSequence: document.sequence, + }); +} diff --git a/src/update/durable-file.ts b/src/update/durable-file.ts new file mode 100644 index 00000000..2fddad3d --- /dev/null +++ b/src/update/durable-file.ts @@ -0,0 +1,64 @@ +import { randomBytes } from "node:crypto"; +import { open, readdir, rename, rm } from "node:fs/promises"; +import { join } from "node:path"; + +/** Temporary names are owned by this module: a leftover from a killed writer + * is never a published file and is always safe to delete. + */ +const temporaryName = /^\.[A-Za-z0-9._-]+\.tmp-[0-9a-f]{16}$/; + +export async function syncDirectory(directory: string): Promise { + const handle = await open(directory, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +/** Replace `directory/name` so that a reader — and a machine that loses power + * at any instant — sees either the complete old bytes or the complete new + * bytes: temporary file, file sync, rename, directory sync. + */ +export type DurableWriter = ( + directory: string, + name: string, + bytes: Uint8Array, +) => Promise; + +export const writeDurableFile: DurableWriter = async ( + directory, + name, + bytes, +) => { + const temporary = join( + directory, + `.${name}.tmp-${randomBytes(8).toString("hex")}`, + ); + const file = await open(temporary, "wx", 0o600); + try { + await file.writeFile(bytes); + await file.sync(); + } catch (error) { + await file.close(); + await rm(temporary, { force: true }); + throw error; + } + await file.close(); + try { + await rename(temporary, join(directory, name)); + } catch (error) { + await rm(temporary, { force: true }); + throw error; + } + await syncDirectory(directory); +}; + +/** Only under the operation lock: no writer of this directory can be alive. */ +export async function removeAbandonedTemporaries( + directory: string, +): Promise { + for (const entry of await readdir(directory)) + if (temporaryName.test(entry)) + await rm(join(directory, entry), { force: true }); +} diff --git a/src/update/errors.ts b/src/update/errors.ts new file mode 100644 index 00000000..2943e217 --- /dev/null +++ b/src/update/errors.ts @@ -0,0 +1,62 @@ +/** The ONE place that defines update error codes. They are a contract for + * automation, the Launchpad pill and fleet observers (docs/update.md + * "Surfaces"): a code is never renamed or reused, prose is never parsed. + * + * `exit` is the process status of the CLI; `retryable` feeds `canRetry` in the + * observation: true when the same action can succeed once an OUTSIDE condition + * (network, publisher, disk, another operation) changes, without manual repair. + */ +export const updateErrors = { + "invalid-request": { exit: 2, retryable: false }, + "network-unavailable": { exit: 20, retryable: true }, + "metadata-expired": { exit: 21, retryable: true }, + "metadata-invalid": { exit: 22, retryable: true }, + "channel-rollback": { exit: 23, retryable: true }, + "channel-invalid": { exit: 24, retryable: true }, + "trust-missing": { exit: 25, retryable: false }, + "trust-conflict": { exit: 26, retryable: false }, + "trust-invalid": { exit: 27, retryable: false }, + "target-unsupported": { exit: 28, retryable: true }, + busy: { exit: 29, retryable: true }, + "storage-unavailable": { exit: 30, retryable: true }, + "not-implemented": { exit: 31, retryable: false }, + internal: { exit: 70, retryable: false }, +} as const satisfies Record; + +export type UpdateErrorCode = keyof typeof updateErrors; + +/** Exit statuses for the two non-error outcomes of a check. */ +export const exitUpToDate = 0; +export const exitUpdateAvailable = 10; + +/** Context is flat, small and free of paths, URLs with credentials, raw + * filesystem errors and localized prose: it may be read by a fleet observer. + */ +export type ErrorContext = Readonly>; + +export type UpdateError = Readonly<{ + code: UpdateErrorCode; + context: ErrorContext; +}>; + +export function updateError( + code: UpdateErrorCode, + context: ErrorContext = {}, +): UpdateError { + return Object.freeze({ code, context: Object.freeze({ ...context }) }); +} + +/** Expected failures travel as values; this carrier exists only so deep + * helpers can abort with a typed error that the use case converts back. + */ +export class UpdateFailure extends Error { + readonly failure: UpdateError; + constructor(code: UpdateErrorCode, context: ErrorContext = {}) { + super(code); + this.failure = updateError(code, context); + } +} + +export function isUpdateErrorCode(value: unknown): value is UpdateErrorCode { + return typeof value === "string" && Object.hasOwn(updateErrors, value); +} diff --git a/src/update/identity.ts b/src/update/identity.ts new file mode 100644 index 00000000..3ae5c8e5 --- /dev/null +++ b/src/update/identity.ts @@ -0,0 +1,101 @@ +/** The identity this executable was built with. This module is the ONLY reader + * of the build-time defines; every other consumer receives the value. + * + * `bun build --define NAME='"value"'` replaces the bare identifier with a + * literal, so the names below must appear literally in this source. A run from + * source (tests, `bun run src/cli.ts`) has no defines: `typeof` of an + * undeclared identifier is safe and selects the development fallback, which + * sorts below every release so a development run never claims to be current. + */ +declare const LAZURIO_BUILD_VERSION: string | undefined; +declare const LAZURIO_BUILD_COMMIT: string | undefined; +declare const LAZURIO_BUILD_TARGET: string | undefined; + +/** Targets the update mechanism supports (docs/update.md "Deliberately + * narrow"). `linux-arm64` exists because the local qualification VM is ARM64. + */ +export const updateTargets = [ + "darwin-arm64", + "linux-arm64", + "linux-x64", +] as const; +export type UpdateTarget = (typeof updateTargets)[number]; + +export type ProductIdentity = Readonly<{ + version: string; + commit: string; + target: string; +}>; + +export const developmentVersion = "0.0.0-development"; +export const developmentCommit = "0".repeat(40); + +const versionPattern = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; + +export function isProductVersion(value: unknown): value is string { + return typeof value === "string" && versionPattern.test(value); +} + +/** Build tooling uses this to pass the defines; the keys are the literal + * identifiers read above, so the writer and the reader cannot drift apart. + */ +export function identityDefines(identity: ProductIdentity): string[] { + const checked = parseIdentity(identity); + return [ + ["LAZURIO_BUILD_VERSION", checked.version], + ["LAZURIO_BUILD_COMMIT", checked.commit], + ["LAZURIO_BUILD_TARGET", checked.target], + ].flatMap(([name, value]) => [ + "--define", + `${name}=${JSON.stringify(value)}`, + ]); +} + +export function parseIdentity(input: { + version: unknown; + commit: unknown; + target: unknown; +}): ProductIdentity { + if (!isProductVersion(input.version)) + throw new Error("Invalid embedded product version"); + if (typeof input.commit !== "string" || !/^[0-9a-f]{40}$/.test(input.commit)) + throw new Error("Invalid embedded source commit"); + // The target is a build fact, not a support claim: an identity may name a + // target the update mechanism refuses (`target-unsupported`). + if ( + typeof input.target !== "string" || + !/^[a-z0-9]+-[a-z0-9]+$/.test(input.target) + ) + throw new Error("Invalid embedded execution target"); + return Object.freeze({ + version: input.version, + commit: input.commit, + target: input.target, + }); +} + +export function nativeTarget(platform: string, arch: string): string { + return `${platform === "win32" ? "windows" : platform}-${arch}`; +} + +/** Either all three defines are present (a built product) or none (a source + * run). A partial set is a broken build and is refused rather than guessed. + */ +export function embeddedIdentity(): ProductIdentity { + const version = + typeof LAZURIO_BUILD_VERSION === "string" + ? LAZURIO_BUILD_VERSION + : undefined; + const commit = + typeof LAZURIO_BUILD_COMMIT === "string" ? LAZURIO_BUILD_COMMIT : undefined; + const target = + typeof LAZURIO_BUILD_TARGET === "string" ? LAZURIO_BUILD_TARGET : undefined; + if (version === undefined && commit === undefined && target === undefined) + return parseIdentity({ + version: developmentVersion, + commit: developmentCommit, + target: nativeTarget(process.platform, process.arch), + }); + return parseIdentity({ version, commit, target }); +} diff --git a/src/update/lock.ts b/src/update/lock.ts new file mode 100644 index 00000000..4f2db83c --- /dev/null +++ b/src/update/lock.ts @@ -0,0 +1,101 @@ +import { constants } from "node:fs"; +import { type FileHandle, lstat, open } from "node:fs/promises"; +import { + closeOnExecFlag, + lockDirectoryDescriptor, +} from "../folder/native-lock"; +import { UpdateFailure } from "./errors"; + +/** One lock covers one update step (docs/update.md "The three steps": acquired + * blocking with a timeout, crash-safe initialization, any local filesystem + * that provides `flock`). + * + * TODO(docs/update.md, lock paragraph): converge with `src/folder/lock.ts` in + * its own change. That helper cannot serve this contract today because it + * (a) refuses every filesystem except APFS and ext (lock.ts:15-20), + * (b) wedges forever when a process dies between `mkdir` of the lock + * directory and the durable `protocol` marker (lock.ts:22-33, 58-68), and + * equally on any stray entry inside it (lock.ts:72-76), and + * (c) never waits: a concurrent holder is an immediate failure + * (native-lock.ts:18). + * + * This acquisition has no initialization to interrupt: `O_CREAT` of one + * regular file is atomic and idempotent, the file's content is never read, and + * the kernel releases `flock` when the descriptor closes or the process dies, + * so no stale state can exist. The file is never unlinked — a waiter may hold + * a descriptor to it, and unlinking would create two lock domains. If someone + * else replaces it anyway, the identity check below retries on the new inode. + */ +export type UpdateLock = Readonly<{ release(): Promise }>; + +// The descriptor IS the lock. A handle that became unreachable would be closed +// by garbage collection and silently release it, so held handles stay rooted +// here until `release`. +const heldHandles = new Set(); + +// The pinned FFI helper reports every failure alike (it cannot read errno), so +// a filesystem without `flock` surfaces as `busy` after the timeout rather +// than as its own code. +function tryLock(descriptor: number): boolean { + try { + lockDirectoryDescriptor(descriptor); + return true; + } catch { + return false; + } +} + +export async function acquireUpdateLock( + path: string, + options: Readonly<{ + timeoutMs: number; + pollMs?: number; + sleep?: (ms: number) => Promise; + now?: () => number; + }>, +): Promise { + if (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 0) + throw new Error("Invalid lock timeout"); + const now = options.now ?? (() => performance.now()); + const sleep = + options.sleep ?? + ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + const pollMs = options.pollMs ?? 50; + // Throws on a platform without the qualified native call; that is a product + // defect (`internal`), not contention. + const cloexec = closeOnExecFlag(); + const deadline = now() + options.timeoutMs; + for (;;) { + const handle = await open( + path, + constants.O_RDWR | constants.O_CREAT | constants.O_NOFOLLOW | cloexec, + 0o600, + ); + try { + if (tryLock(handle.fd)) { + const opened = await handle.stat(); + const current = await lstat(path).catch(() => undefined); + // Otherwise an inode that is no longer the lock was locked: retry. + if ( + current?.isFile() && + current.dev === opened.dev && + current.ino === opened.ino + ) { + heldHandles.add(handle); + return Object.freeze({ + async release() { + if (!heldHandles.delete(handle)) return; + await handle.close(); + }, + }); + } + } + } catch (error) { + await handle.close(); + throw error; + } + await handle.close(); + if (now() >= deadline) throw new UpdateFailure("busy"); + await sleep(Math.min(pollMs, Math.max(1, deadline - now()))); + } +} diff --git a/src/update/version.ts b/src/update/version.ts new file mode 100644 index 00000000..ad33baf7 --- /dev/null +++ b/src/update/version.ts @@ -0,0 +1,45 @@ +import { isProductVersion } from "./identity"; + +/** Semantic Versioning 2.0.0 precedence for the product's version grammar + * (no build metadata). Returns <0, 0 or >0. "Never downgrades" depends on this + * being total and exact, so invalid input is refused instead of compared. + */ +export function compareVersions(left: string, right: string): number { + if (!isProductVersion(left) || !isProductVersion(right)) + throw new Error("Invalid product version"); + const split = (value: string) => { + const dash = value.indexOf("-"); + const core = (dash === -1 ? value : value.slice(0, dash)) + .split(".") + .map(Number); + const pre = dash === -1 ? [] : value.slice(dash + 1).split("."); + return { core, pre }; + }; + const a = split(left); + const b = split(right); + for (let index = 0; index < 3; index++) { + const difference = (a.core[index] as number) - (b.core[index] as number); + if (difference !== 0) return difference < 0 ? -1 : 1; + } + // A release outranks any of its pre-releases. + if (a.pre.length === 0 || b.pre.length === 0) + return a.pre.length === b.pre.length ? 0 : a.pre.length === 0 ? 1 : -1; + for (let index = 0; index < Math.max(a.pre.length, b.pre.length); index++) { + const x = a.pre[index]; + const y = b.pre[index]; + if (x === undefined) return -1; + if (y === undefined) return 1; + const xNumeric = /^\d+$/.test(x); + const yNumeric = /^\d+$/.test(y); + if (xNumeric && yNumeric) { + // Identifiers may exceed 2^53; compare by length, then lexically. The + // product grammar tolerates leading zeros, which carry no value. + const p = x.replace(/^0+(?=\d)/, ""); + const q = y.replace(/^0+(?=\d)/, ""); + if (p.length !== q.length) return p.length < q.length ? -1 : 1; + if (p !== q) return p < q ? -1 : 1; + } else if (xNumeric !== yNumeric) return xNumeric ? -1 : 1; + else if (x !== y) return x < y ? -1 : 1; + } + return 0; +} diff --git a/tests/fixtures/update-lock-holder.ts b/tests/fixtures/update-lock-holder.ts new file mode 100644 index 00000000..6bcbff03 --- /dev/null +++ b/tests/fixtures/update-lock-holder.ts @@ -0,0 +1,8 @@ +import { acquireUpdateLock } from "../../src/update/lock"; + +// Holds the update lock until killed; the test proves the kernel releases it. +const path = process.argv[2]; +if (!path) throw new Error("Lock path required"); +const lock = await acquireUpdateLock(path, { timeoutMs: 5_000 }); +console.log("held"); +setInterval(() => lock, 1_000); diff --git a/tests/update-channel.test.ts b/tests/update-channel.test.ts new file mode 100644 index 00000000..41bf7b34 --- /dev/null +++ b/tests/update-channel.test.ts @@ -0,0 +1,124 @@ +import { expect, test } from "bun:test"; +import { + assertNotRolledBack, + parseChannelDocument, +} from "../src/update/channel"; +import { UpdateFailure } from "../src/update/errors"; +import { compareVersions } from "../src/update/version"; + +const sha = "a".repeat(64); +const document = (overrides: Record = {}) => + Buffer.from( + JSON.stringify({ + schemaVersion: 1, + channel: "stable", + sequence: 3, + version: "1.2.0", + minimumVersion: "1.0.0", + targets: { "linux-x64": `artifacts/${sha}/lazurio` }, + ...overrides, + }), + ); +const reason = (bytes: Buffer, channel: "stable" | "preview" = "stable") => { + try { + parseChannelDocument(bytes, channel); + return "accepted"; + } catch (error) { + return error instanceof UpdateFailure + ? `${error.failure.code}:${error.failure.context.reason}` + : "threw"; + } +}; + +test("the channel parser accepts exactly the documented shape for the requested channel", () => { + const parsed = parseChannelDocument(document(), "stable"); + expect(parsed).toMatchObject({ + channel: "stable", + sequence: 3, + version: "1.2.0", + minimumVersion: "1.0.0", + targets: { "linux-x64": `artifacts/${sha}/lazurio` }, + }); + expect(parsed.documentSha256).toMatch(/^[a-f0-9]{64}$/); + expect(reason(document({ channel: "preview" }), "preview")).toBe("accepted"); + expect(reason(document(), "preview")).toBe("channel-invalid:wrong-channel"); + expect(reason(document({ channel: "pilot" }))).toBe( + "channel-invalid:wrong-channel", + ); + expect(reason(document({ schemaVersion: 2 }))).toBe( + "channel-invalid:unsupported-schema", + ); + expect(reason(document({ extra: true }))).toBe( + "channel-invalid:unknown-fields", + ); + expect(reason(document({ sequence: 0 }))).toBe("channel-invalid:sequence"); + expect(reason(document({ sequence: 1.5 }))).toBe("channel-invalid:sequence"); + expect(reason(document({ version: "1.2" }))).toBe("channel-invalid:version"); + expect(reason(document({ minimumVersion: 1 }))).toBe( + "channel-invalid:version", + ); + for (const targets of [ + { "windows-x64": `artifacts/${sha}/lazurio` }, + { "linux-x64": `artifacts/${sha}/../lazurio` }, + { "linux-x64": `artifacts/${sha.slice(1)}/lazurio` }, + { "linux-x64": 7 }, + [], + ]) + expect(reason(document({ targets }))).toBe("channel-invalid:targets"); + expect(reason(Buffer.from('{"schemaVersion":1,"schemaVersion":1}'))).toBe( + "channel-invalid:malformed", + ); + expect(reason(Buffer.from([0xff, 0xfe]))).toBe("channel-invalid:malformed"); +}); + +test("the floor refuses a lower sequence and a reused sequence with other bytes, and accepts a repeat", () => { + const parsed = parseChannelDocument(document(), "stable"); + const rollback = (floor: { sequence: number; documentSha256: string }) => { + try { + assertNotRolledBack(parsed, floor); + return "accepted"; + } catch (error) { + return (error as UpdateFailure).failure.code; + } + }; + assertNotRolledBack(parsed, undefined); + expect(rollback({ sequence: 2, documentSha256: sha })).toBe("accepted"); + expect(rollback({ sequence: 3, documentSha256: parsed.documentSha256 })).toBe( + "accepted", + ); + expect(rollback({ sequence: 3, documentSha256: sha })).toBe( + "channel-rollback", + ); + expect(rollback({ sequence: 4, documentSha256: sha })).toBe( + "channel-rollback", + ); +}); + +test("version precedence follows Semantic Versioning, so 'never downgrade' is exact", () => { + const ascending = [ + "0.0.0-development", + "0.0.0", + "1.0.0-alpha", + "1.0.0-alpha.1", + "1.0.0-alpha.beta", + "1.0.0-beta.2", + "1.0.0-beta.11", + "1.0.0-rc.1", + "1.0.0", + "1.0.1", + "1.2.0", + "1.10.0", + "2.0.0", + ]; + for (let left = 0; left < ascending.length; left++) + for (let right = 0; right < ascending.length; right++) + expect( + Math.sign( + compareVersions( + ascending[left] as string, + ascending[right] as string, + ), + ), + ).toBe(Math.sign(left - right)); + expect(() => compareVersions("1.0", "1.0.0")).toThrow(); +}); diff --git a/tests/update-lock.test.ts b/tests/update-lock.test.ts new file mode 100644 index 00000000..f2a0abe1 --- /dev/null +++ b/tests/update-lock.test.ts @@ -0,0 +1,96 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, realpath, rename, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { UpdateFailure } from "../src/update/errors"; +import { acquireUpdateLock } from "../src/update/lock"; + +async function directory() { + return realpath(await mkdtemp(join(tmpdir(), "update-lock-"))); +} + +test("the lock excludes, waits up to its timeout, and reports busy as a typed failure", async () => { + const root = await directory(); + try { + const path = join(root, "lock"); + const first = await acquireUpdateLock(path, { timeoutMs: 0 }); + const refused = await acquireUpdateLock(path, { timeoutMs: 120 }).catch( + (error) => error, + ); + expect(refused).toBeInstanceOf(UpdateFailure); + expect((refused as UpdateFailure).failure.code).toBe("busy"); + const waiting = acquireUpdateLock(path, { timeoutMs: 5_000 }); + await new Promise((resolve) => setTimeout(resolve, 120)); + await first.release(); + await first.release(); // idempotent + const second = await waiting; + await second.release(); + } finally { + await rm(root, { recursive: true }); + } +}); + +test("initialization cannot be interrupted into a wedge: any pre-existing lock file content is acceptable", async () => { + const root = await directory(); + try { + const path = join(root, "lock"); + // Whatever a killed process left behind — empty, partial, foreign bytes — + // the content is never read, so there is nothing to recover. + for (const leftover of ["", "lazurio-dir", "\u0000\u0001garbage"]) { + await writeFile(path, leftover); + const lock = await acquireUpdateLock(path, { timeoutMs: 0 }); + await lock.release(); + } + } finally { + await rm(root, { recursive: true }); + } +}); + +test("a lock file replaced while another process holds the old inode does not create a second lock domain for the holder of the new path", async () => { + const root = await directory(); + try { + const path = join(root, "lock"); + const stale = await acquireUpdateLock(path, { timeoutMs: 0 }); + await rename(path, join(root, "moved-away")); + // The path now names a new inode; acquisition binds to what the path + // names NOW and verifies that identity after locking. + const current = await acquireUpdateLock(path, { timeoutMs: 0 }); + const contender = await acquireUpdateLock(path, { timeoutMs: 100 }).catch( + (error) => error, + ); + expect(contender).toBeInstanceOf(UpdateFailure); + await current.release(); + await stale.release(); + } finally { + await rm(root, { recursive: true }); + } +}); + +test("a SIGKILLed holder releases the lock immediately: no stale state, no recovery step", async () => { + const root = await directory(); + const path = join(root, "lock"); + const holder = Bun.spawn( + [ + process.execPath, + new URL("./fixtures/update-lock-holder.ts", import.meta.url).pathname, + path, + ], + { stdout: "pipe", stderr: "inherit" }, + ); + try { + const reader = holder.stdout.getReader(); + const { value } = await reader.read(); + expect(new TextDecoder().decode(value)).toContain("held"); + const refused = await acquireUpdateLock(path, { timeoutMs: 100 }).catch( + (error) => error, + ); + expect(refused).toBeInstanceOf(UpdateFailure); + holder.kill("SIGKILL"); + await holder.exited; + const lock = await acquireUpdateLock(path, { timeoutMs: 2_000 }); + await lock.release(); + } finally { + holder.kill("SIGKILL"); + await rm(root, { recursive: true }); + } +}, 15_000); From 755b165ef88eacfaba471a3c0e2f678498178d2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Sat, 19 Sep 2026 02:36:23 +0200 Subject: [PATCH 04/11] Check use case: durable per-role trust promotion, channel floors and the observed state The pinned tuf-js refreshes in a scratch copy seeded from trust/. When the refresh ends - success or failure - every newly verified role is promoted by temporary file, sync, rename and directory sync: numbered root chain, root, timestamp, snapshot, targets, then the channel floor. trust.ts records exactly when tuf-js 6.0.0 persists relative to verification, with line references; promotion re-verifies the root chain itself and withholds the role delivered last in a failed refresh. checkForUpdate returns a typed result with stable error codes and never throws for expected failures; update/observed.json is derived state that a missing or corrupt file can never turn into a wedge. Tests run against a signed consistent-snapshot loopback repository: rotation retained across a failed refresh, a kill before every single promotion, expired timestamp/snapshot/targets/root, repository advance and channel rollback, unknown files, concurrent checks. Co-Authored-By: Claude Fable 5.1 --- scripts/update-fixture.ts | 266 ++++++++++++++ src/update/check.ts | 420 ++++++++++++++++++++++ src/update/observed.ts | 295 ++++++++++++++++ src/update/trust-fetcher.ts | 118 +++++++ src/update/trust.ts | 317 +++++++++++++++++ tests/update-check.test.ts | 686 ++++++++++++++++++++++++++++++++++++ tests/update-trust.test.ts | 132 +++++++ 7 files changed, 2234 insertions(+) create mode 100644 scripts/update-fixture.ts create mode 100644 src/update/check.ts create mode 100644 src/update/observed.ts create mode 100644 src/update/trust-fetcher.ts create mode 100644 src/update/trust.ts create mode 100644 tests/update-check.test.ts create mode 100644 tests/update-trust.test.ts diff --git a/scripts/update-fixture.ts b/scripts/update-fixture.ts new file mode 100644 index 00000000..c318ce1d --- /dev/null +++ b/scripts/update-fixture.ts @@ -0,0 +1,266 @@ +import { + createHash, + generateKeyPairSync, + type KeyObject, + sign, +} from "node:crypto"; +import { + Key, + Metadata, + MetaFile, + Root, + Signature, + Snapshot, + TargetFile, + Targets, + Timestamp, +} from "@tufjs/models"; + +/** Local synthetic signed repository for the update core: consistent + * snapshots, one ephemeral key PER role, numbered immutable roots, snapshots + * and targets, hash-addressed channel documents, and old objects retained — + * the layout docs/update.md "Publishing" prescribes. Keys never leave this + * process and the bytes never leave the loopback server. Not a publisher. + */ +type RoleName = "root" | "targets" | "snapshot" | "timestamp"; +type Signer = { key: Key; secret: KeyObject }; +export type FixtureChannel = Readonly<{ + sequence: number; + version: string; + minimumVersion?: string; + /** Replace the generated document, e.g. to publish a malformed one. */ + rawDocument?: string; + /** Omit this Machine's target from the document. */ + withoutTarget?: boolean; +}>; + +function signer(id: string): Signer { + const pair = generateKeyPairSync("ed25519"); + return { + secret: pair.privateKey, + key: new Key({ + keyID: id, + keyType: "ed25519", + scheme: "ed25519", + keyVal: { + public: pair.publicKey + .export({ type: "spki", format: "pem" }) + .toString(), + }, + }), + }; +} + +const sha256 = (bytes: Buffer) => + createHash("sha256").update(bytes).digest("hex"); + +export function createUpdateFixture(input: { + executionTarget: string; + artifact?: Buffer; +}) { + const artifact = + input.artifact ?? Buffer.from("Synthetic artifact; never executed."); + const artifactSha256 = sha256(artifact); + const artifactPath = `artifacts/${artifactSha256}/lazurio`; + const future = () => new Date(Date.now() + 3600_000).toISOString(); + const signers: Record = { + root: signer("root-1"), + targets: signer("targets-1"), + snapshot: signer("snapshot-1"), + timestamp: signer("timestamp-1"), + }; + const served = new Map(); + const blocked = new Map(); + const requests: string[] = []; + const channels = new Map(); + let rootVersion = 0; + let version = 0; + + const signedBy = ( + value: Root | Targets | Snapshot | Timestamp, + by: readonly Signer[], + ) => { + const metadata = new Metadata(value); + for (const { key, secret } of by) + metadata.sign( + (bytes) => + new Signature({ + keyID: key.keyID, + sig: sign(null, bytes, secret).toString("hex"), + }), + ); + return Buffer.from(JSON.stringify(metadata.toJSON())); + }; + + /** Publish root N+1. A rotation replaces the root key and is signed by both + * the outgoing and the incoming key, as the client requires. + */ + const publishRoot = (options: { + rotate?: RoleName[]; + expires?: string; + }): Buffer => { + const outgoing = signers.root; + for (const role of options.rotate ?? []) + signers[role] = signer(`${role}-${rootVersion + 2}`); + rootVersion += 1; + const root = new Root({ + version: rootVersion, + specVersion: "1.0.0", + expires: options.expires ?? future(), + consistentSnapshot: true, + }); + for (const role of ["root", "targets", "snapshot", "timestamp"] as const) + root.addKey(signers[role].key, role); + const bytes = signedBy( + root, + outgoing === signers.root ? [outgoing] : [outgoing, signers.root], + ); + served.set(`/metadata/${rootVersion}.root.json`, bytes); + return bytes; + }; + + /** Publish one complete generation; every numbered object stays served. */ + const publish = ( + options: { + expires?: Partial>; + tamper?: "snapshot" | "targets"; + } = {}, + ) => { + version += 1; + const files: Record = { + [artifactPath]: new TargetFile({ + path: artifactPath, + length: artifact.length, + hashes: { sha256: artifactSha256 }, + }), + }; + for (const [name, channel] of channels) { + const document = Buffer.from( + channel.rawDocument ?? + JSON.stringify({ + schemaVersion: 1, + channel: name, + sequence: channel.sequence, + version: channel.version, + minimumVersion: channel.minimumVersion ?? "0.0.1", + targets: channel.withoutTarget + ? {} + : { [input.executionTarget]: artifactPath }, + }), + ); + const path = `channels/${name}.json`; + files[path] = new TargetFile({ + path, + length: document.length, + hashes: { sha256: sha256(document) }, + }); + served.set( + `/targets/channels/${sha256(document)}.${name}.json`, + document, + ); + } + const fields = (role: "targets" | "snapshot" | "timestamp") => ({ + version, + specVersion: "1.0.0", + expires: options.expires?.[role] ?? future(), + }); + const targets = signedBy( + new Targets({ ...fields("targets"), targets: files }), + [signers.targets], + ); + const snapshot = signedBy( + new Snapshot({ + ...fields("snapshot"), + meta: { + "targets.json": new MetaFile({ + version, + length: targets.length, + hashes: { sha256: sha256(targets) }, + }), + }, + }), + [signers.snapshot], + ); + const timestamp = signedBy( + new Timestamp({ + ...fields("timestamp"), + snapshotMeta: new MetaFile({ + version, + length: snapshot.length, + hashes: { sha256: sha256(snapshot) }, + }), + }), + [signers.timestamp], + ); + // Same length, different bytes: the signed hash no longer matches. + const damaged = (bytes: Buffer) => { + const copy = Buffer.from(bytes); + copy[copy.length - 2] = copy[copy.length - 2] === 0x20 ? 0x0a : 0x20; + return copy; + }; + served.set( + `/metadata/${version}.targets.json`, + options.tamper === "targets" ? damaged(targets) : targets, + ); + served.set( + `/metadata/${version}.snapshot.json`, + options.tamper === "snapshot" ? damaged(snapshot) : snapshot, + ); + served.set("/metadata/timestamp.json", timestamp); + }; + + const bootstrapRoot = publishRoot({}); + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + const path = new URL(request.url).pathname; + requests.push(path); + const status = blocked.get(path); + if (status !== undefined) return new Response("blocked", { status }); + const bytes = served.get(path); + return bytes + ? new Response(new Uint8Array(bytes)) + : new Response("not found", { status: 404 }); + }, + }); + return Object.freeze({ + bootstrapRoot, + artifactSha256, + artifactLength: artifact.length, + metadataBaseUrl: `${server.url}metadata/`, + targetBaseUrl: `${server.url}targets/`, + origin: server.url.origin, + requests, + /** Set a channel's document and publish a new generation. */ + release( + channel: "stable" | "preview", + document: FixtureChannel, + options?: Parameters[0], + ) { + channels.set(channel, document); + publish(options); + }, + publish, + /** Rotate the named role keys (default: root) into root N+1, then publish + * a generation signed by the current keys. */ + rotateRoot(options: { rotate?: RoleName[]; expires?: string } = {}) { + publishRoot({ rotate: options.rotate ?? ["root"], ...options }); + publish(); + }, + /** Root N+1 with unchanged keys, e.g. to replace an expired root. */ + renewRoot: () => publishRoot({}), + rootVersion: () => rootVersion, + version: () => version, + block(path: string, status = 404) { + blocked.set(path, status); + }, + unblock(path: string) { + blocked.delete(path); + }, + served: (path: string) => served.get(path), + async stop() { + await server.stop(true); + }, + }); +} diff --git a/src/update/check.ts b/src/update/check.ts new file mode 100644 index 00000000..4a3dcbf6 --- /dev/null +++ b/src/update/check.ts @@ -0,0 +1,420 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, readdir, readFile, rm } from "node:fs/promises"; +import { isAbsolute, join, resolve } from "node:path"; +import { type Fetcher, Updater } from "tuf-js"; +import { ExpiredMetadataError } from "tuf-js/dist/error"; +import { + assertNotRolledBack, + type ChannelDocument, + channelTargetPath, + isUpdateChannel, + maxChannelDocumentBytes, + parseChannelDocument, + type UpdateChannel, +} from "./channel"; +import { + type DurableWriter, + removeAbandonedTemporaries, + writeDurableFile, +} from "./durable-file"; +import { + type ErrorContext, + type UpdateError, + type UpdateErrorCode, + UpdateFailure, + updateError, + updateErrors, +} from "./errors"; +import type { ProductIdentity } from "./identity"; +import { acquireUpdateLock } from "./lock"; +import { + type Observed, + type ObservedAvailable, + readObserved, + readSelected, + writeObserved, +} from "./observed"; +import { + advanceChannelFloor, + ensureOwnedDirectory, + promoteVerified, + readChannelFloors, + readSeed, + seedScratch, +} from "./trust"; +import { TrustFetcher } from "./trust-fetcher"; +import { compareVersions } from "./version"; + +export type CheckInput = Readonly<{ + /** Per-user install base; created when absent. Unrelated entries in it are + * never inspected. */ + base: string; + metadataBaseUrl: string; + targetBaseUrl: string; + channel: UpdateChannel; + identity: ProductIdentity; + /** Trust anchor for the very first check only; refused once `trust/` holds a + * root. The compiled-in root of a later slice enters through this input. */ + bootstrapRoot?: Uint8Array; + /** Owns origins, redirects, size and time limits of every transfer. */ + transport: Fetcher; + /** Times written into the observation. Metadata expiry is judged by the + * pinned TUF client against the system clock (store.js:11); it offers no + * clock input and this use case never works around that. */ + clock: () => Date; + lockTimeoutMs?: number; + operationId?: string; + /** Effect adapter for every durable write of this use case. */ + writeDurable?: DurableWriter; +}>; + +export type CheckResult = + | Readonly<{ + kind: "up-to-date"; + version: string; + channelVersion: string; + sequence: number; + }> + | Readonly<{ + kind: "available"; + version: string; + artifactSha256: string; + length: number; + sequence: number; + }> + | (Readonly<{ kind: "error" }> & UpdateError); + +type Verified = Readonly<{ + document: ChannelDocument; + artifact: Readonly<{ sha256: string; length: number }> | undefined; +}>; + +/** Pinned-client compatibility (tuf-js 6.0.0): snapshot and targets failures + * are re-thrown as `RuntimeError` carrying only the STRINGIFIED cause + * (updater.js:254, :290), so their expiry survives solely in the message. A + * wrong guess here only swaps two retryable error codes; it promotes nothing. + */ +function expired(error: unknown): boolean { + return ( + error instanceof ExpiredMetadataError || + (error instanceof Error && + /(?:^|: )(?:Final )?[a-z]+\.json is expired$/.test(error.message)) + ); +} + +function classify(error: unknown, fetcher: TrustFetcher): UpdateError { + if (error instanceof UpdateFailure) return error.failure; + if (fetcher.lastFailure) + return updateError("network-unavailable", fetcher.lastFailure); + if (expired(error)) return updateError("metadata-expired"); + return updateError("metadata-invalid"); +} + +function validUrl(value: string): boolean { + try { + const url = new URL(value); + return ( + ["https:", "http:"].includes(url.protocol) && + !url.username && + !url.password && + !url.search && + !url.hash && + url.pathname.endsWith("/") + ); + } catch { + return false; + } +} + +/** Refresh in scratch, then read the signed channel document. Throws on the + * first failure; the caller promotes whatever was verified before it. + */ +async function refreshAndReadChannel( + input: CheckInput, + scratch: string, + fetcher: TrustFetcher, + onRefreshed: () => void, +): Promise<{ bytes: Buffer; updater: Updater }> { + const updater = new Updater({ + fetcher, + metadataDir: scratch, + metadataBaseUrl: input.metadataBaseUrl, + targetBaseUrl: input.targetBaseUrl, + // No hidden retries: the caller's click is the retry. One refresh walks a + // bounded number of roots; a Machine further behind converges over + // several checks because every verified root is promoted. + config: { + fetchRetries: 0, + fetchRetry: false, + maxDelegations: 0, + maxRootRotations: 32, + }, + }); + await updater.refresh(); + onRefreshed(); + const path = channelTargetPath(input.channel); + const target = await updater.getTargetInfo(path); + if (!target) + throw new UpdateFailure("channel-invalid", { + channel: input.channel, + reason: "absent", + }); + if (target.length > maxChannelDocumentBytes) + throw new UpdateFailure("channel-invalid", { + channel: input.channel, + reason: "too-large", + }); + const file = join(scratch, "channel-document"); + await updater.downloadTarget(target, file); + return { bytes: await readFile(file), updater }; +} + +/** Check for an update: automatic, cheap, and no mutation of the product + * (docs/update.md "Check"). It advances only durable trust and rewrites the + * observation. Expected failures are returned, never thrown. + */ +export async function checkForUpdate(input: CheckInput): Promise { + const fail = (code: UpdateErrorCode, context: ErrorContext = {}) => + Object.freeze({ kind: "error" as const, ...updateError(code, context) }); + if ( + !isAbsolute(input.base) || + resolve(input.base) !== input.base || + !isUpdateChannel(input.channel) || + !validUrl(input.metadataBaseUrl) || + !validUrl(input.targetBaseUrl) + ) + return fail("invalid-request"); + const write = input.writeDurable ?? writeDurableFile; + const operationId = input.operationId ?? randomUUID(); + const trustDirectory = join(input.base, "trust"); + const updateDirectory = join(input.base, "update"); + let lock: Awaited>; + try { + await mkdir(input.base, { recursive: true, mode: 0o700 }); + await ensureOwnedDirectory(trustDirectory); + await ensureOwnedDirectory(updateDirectory); + lock = await acquireUpdateLock(join(updateDirectory, "lock"), { + timeoutMs: input.lockTimeoutMs ?? 30_000, + }); + } catch (error) { + // `busy` leaves the observation alone: the holder is about to write it. + if (error instanceof UpdateFailure) + return fail(error.failure.code, error.failure.context); + return fail(...storageOrInternal(error, "prepare")); + } + try { + const outcome = await checkUnderLock( + input, + { trustDirectory, updateDirectory, operationId }, + write, + ); + try { + await writeObserved( + input.base, + await observe(input, operationId, outcome), + write, + ); + } catch { + // Derived state: failing to write it must not change the result. + } + return "failure" in outcome + ? fail(outcome.failure.code, outcome.failure.context) + : result(input.identity, outcome); + } catch { + return fail("internal"); + } finally { + await lock.release(); + } +} + +type Outcome = Verified | Readonly<{ failure: UpdateError }>; + +async function checkUnderLock( + input: CheckInput, + owned: { + trustDirectory: string; + updateDirectory: string; + operationId: string; + }, + write: DurableWriter, +): Promise { + const { trustDirectory, updateDirectory } = owned; + const fetcher = new TrustFetcher(input.transport, input.metadataBaseUrl); + const scratch = join(updateDirectory, `scratch-${owned.operationId}`); + let received: Awaited> | undefined; + let failure: UpdateError | undefined; + try { + // Holding the lock proves no writer is alive: every scratch directory and + // every temporary file is an abandoned leftover. + for (const entry of await readdir(updateDirectory)) + if (entry.startsWith("scratch-")) + await rm(join(updateDirectory, entry), { recursive: true }); + await removeAbandonedTemporaries(trustDirectory); + await removeAbandonedTemporaries(updateDirectory); + const seed = await readSeed(trustDirectory, input.bootstrapRoot); + const floors = await readChannelFloors(trustDirectory); + await mkdir(scratch, { mode: 0o700 }); + await seedScratch(scratch, seed); + let refreshed = false; + try { + received = await refreshAndReadChannel(input, scratch, fetcher, () => { + refreshed = true; + }); + } catch (error) { + failure = classify(error, fetcher); + } + // Trust never rewinds: promote on success AND on failure, before anything + // else can go wrong with this attempt. + try { + await promoteVerified({ + trustDirectory, + scratch, + seed, + fetchedRoots: fetcher.roots, + unsettledFile: refreshed ? undefined : fetcher.unsettledFile, + write, + }); + } catch (error) { + return { failure: updateError(...storageOrInternal(error, "trust")) }; + } + if (failure || !received) + return { failure: failure ?? updateError("internal") }; + const document = parseChannelDocument(received.bytes, input.channel); + assertNotRolledBack(document, floors[input.channel]); + try { + await advanceChannelFloor({ trustDirectory, floors, document, write }); + } catch (error) { + return { failure: updateError(...storageOrInternal(error, "floor")) }; + } + const path = document.targets[input.identity.target]; + if (path === undefined) return { document, artifact: undefined }; + const artifact = await received.updater.getTargetInfo(path); + const sha256 = path.split("/")[1] as string; + if (!artifact || artifact.length < 1 || artifact.hashes.sha256 !== sha256) + return { + failure: updateError("metadata-invalid", { subject: "artifact" }), + }; + return { document, artifact: { sha256, length: artifact.length } }; + } catch (error) { + return { + failure: + error instanceof UpdateFailure + ? error.failure + : updateError(...storageOrInternal(error, "check")), + }; + } finally { + await rm(scratch, { recursive: true, force: true }); + } +} + +/** A filesystem refusal (full disk, permissions) is an outside condition the + * user can restore; anything else at these stages is a product defect. + */ +function storageOrInternal( + error: unknown, + stage: string, +): [UpdateErrorCode, ErrorContext] { + const errno = (error as NodeJS.ErrnoException | undefined)?.code; + return typeof errno === "string" && /^E[A-Z]+$/.test(errno) + ? ["storage-unavailable", { stage, errno }] + : ["internal", { stage }]; +} + +function result(identity: ProductIdentity, verified: Verified): CheckResult { + const { document, artifact } = verified; + if (!artifact) + return Object.freeze({ + kind: "error" as const, + ...updateError("target-unsupported", { + channel: document.channel, + target: identity.target, + }), + }); + // Never a downgrade: an equal or older channel version is up to date. + if (compareVersions(document.version, identity.version) <= 0) + return Object.freeze({ + kind: "up-to-date", + version: identity.version, + channelVersion: document.version, + sequence: document.sequence, + }); + return Object.freeze({ + kind: "available", + version: document.version, + artifactSha256: artifact.sha256, + length: artifact.length, + sequence: document.sequence, + }); +} + +/** Rewrite the observation from facts. The previous file contributes only + * what this check could not re-establish — the last authenticated time and a + * still-newer verified target — and only when it parses. + */ +async function observe( + input: CheckInput, + operationId: string, + outcome: Outcome, +): Promise { + const now = input.clock(); + const at = now.toISOString(); + const previous = await readObserved(input.base, now); + const checked = + "failure" in outcome ? undefined : result(input.identity, outcome); + const error: UpdateError | null = + "failure" in outcome + ? outcome.failure + : checked?.kind === "error" + ? updateError(checked.code, checked.context) + : null; + let available: ObservedAvailable | null = null; + if (!("failure" in outcome)) { + if (checked?.kind === "available") + available = Object.freeze({ + version: checked.version, + artifactSha256: checked.artifactSha256, + length: checked.length, + sequence: checked.sequence, + minimumVersion: outcome.document.minimumVersion, + verifiedAt: at, + }); + } else if ( + previous.channel === input.channel && + previous.available && + compareVersions(previous.available.version, input.identity.version) > 0 + ) + available = previous.available; + return Object.freeze({ + schemaVersion: 1, + observedAt: at, + operationId, + status: error + ? "error" + : checked?.kind === "available" + ? "available" + : "up-to-date", + channel: input.channel, + lastAuthenticatedCheckAt: + "failure" in outcome + ? previous.channel === input.channel + ? previous.lastAuthenticatedCheckAt + : null + : at, + checkedBy: input.identity, + selected: await readSelected(input.base, now), + running: previous.running, + available, + lastHealthyActivation: previous.lastHealthyActivation, + belowMinimumVersion: + "failure" in outcome + ? previous.channel === input.channel && previous.belowMinimumVersion + : compareVersions( + input.identity.version, + outcome.document.minimumVersion, + ) < 0, + releaseNotes: null, + downloadPercent: null, + error, + canRetry: error ? updateErrors[error.code].retryable : false, + }); +} diff --git a/src/update/observed.ts b/src/update/observed.ts new file mode 100644 index 00000000..de2f0774 --- /dev/null +++ b/src/update/observed.ts @@ -0,0 +1,295 @@ +import { readFile, readlink, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { isUpdateChannel, type UpdateChannel } from "./channel"; +import type { DurableWriter } from "./durable-file"; +import { + type ErrorContext, + isUpdateErrorCode, + type UpdateError, + updateError, +} from "./errors"; +import { isProductVersion, type ProductIdentity } from "./identity"; + +/** `update/observed.json`: an OBSERVATION for the UI, the CLI and outside + * observers — never an authority (docs/update.md "State on disk"). Nothing + * decides trust, selection or recovery from it, so a missing, stale or corrupt + * file can only cost a rebuilt, emptier observation; it can never wedge. + * + * Four facts stay apart because a successful check does not imply a converged + * Machine: what is SELECTED (the symlink), what is RUNNING (the Launchpad), + * what is verified AVAILABLE, and the LAST HEALTHY ACTIVATION — each with the + * time it was established. + */ +export const observedStatuses = [ + "idle", + "checking", + "up-to-date", + "available", + "downloading", + "ready", + "activating", + "restart-pending", + "error", +] as const; +export type ObservedStatus = (typeof observedStatuses)[number]; + +export type ObservedAvailable = Readonly<{ + version: string; + artifactSha256: string; + length: number; + sequence: number; + minimumVersion: string; + verifiedAt: string; +}>; + +export type Observed = Readonly<{ + schemaVersion: 1; + observedAt: string; + /** The operation that wrote this file; null for a rebuilt observation. */ + operationId: string | null; + status: ObservedStatus; + channel: UpdateChannel | null; + /** Last time signed metadata AND the channel document were verified. */ + lastAuthenticatedCheckAt: string | null; + /** Identity of the executable that performed the last check. */ + checkedBy: ProductIdentity | null; + selected: Readonly<{ + version: string; + artifactSha16: string; + observedAt: string; + }> | null; + /** The running Launchpad artifact. Unknown to a CLI check: only the + * Launchpad (a later slice) can state what it runs. */ + running: Readonly<{ version: string; observedAt: string }> | null; + available: ObservedAvailable | null; + /** Written by activation (a later slice). */ + lastHealthyActivation: Readonly<{ + version: string; + confirmedAt: string; + }> | null; + /** True when the executable that checked is below the signed minimum. */ + belowMinimumVersion: boolean; + releaseNotes: string | null; + downloadPercent: number | null; + error: UpdateError | null; + canRetry: boolean; +}>; + +export const observedPath = (base: string) => + join(base, "update", "observed.json"); + +/** The selector is the only record of the active version; read it, never + * resolve or trust more than its name. + */ +export async function readSelected( + base: string, + now: Date, +): Promise { + try { + const link = await readlink(join(base, "bin", "lazurio")); + const match = + /^\.\.\/versions\/([0-9A-Za-z.-]+)\+([0-9a-f]{16})\/lazurio$/.exec(link); + if (!match || !isProductVersion(match[1])) return null; + return Object.freeze({ + version: match[1], + artifactSha16: match[2] as string, + observedAt: now.toISOString(), + }); + } catch { + return null; + } +} + +export async function rebuiltObservation( + base: string, + now: Date, +): Promise { + return Object.freeze({ + schemaVersion: 1, + observedAt: now.toISOString(), + operationId: null, + status: "idle", + channel: null, + lastAuthenticatedCheckAt: null, + checkedBy: null, + selected: await readSelected(base, now), + running: null, + available: null, + lastHealthyActivation: null, + belowMinimumVersion: false, + releaseNotes: null, + downloadPercent: null, + error: null, + canRetry: false, + }); +} + +const isTime = (value: unknown): value is string => + typeof value === "string" && + value.length <= 40 && + !Number.isNaN(Date.parse(value)); +const isNullOr = (value: unknown, check: (value: unknown) => value is T) => + value === null || check(value); +const isRecord = (value: unknown): value is Record => + !!value && typeof value === "object" && !Array.isArray(value); + +function isAvailable(value: unknown): value is ObservedAvailable { + return ( + isRecord(value) && + isProductVersion(value.version) && + isProductVersion(value.minimumVersion) && + typeof value.artifactSha256 === "string" && + /^[a-f0-9]{64}$/.test(value.artifactSha256) && + Number.isSafeInteger(value.length) && + Number.isSafeInteger(value.sequence) && + isTime(value.verifiedAt) + ); +} + +function isContext(value: unknown): value is ErrorContext { + return ( + isRecord(value) && + Object.values(value).every((entry) => + ["string", "number", "boolean"].includes(typeof entry), + ) + ); +} + +/** Shape check of a file this product wrote. Unknown extra members are + * ignored and dropped: observers of a newer writer stay readable. + */ +export function parseObserved(value: unknown): Observed { + if (!isRecord(value) || value.schemaVersion !== 1) + throw new Error("Unrecognized observation"); + const error = value.error; + const identity = value.checkedBy; + const selected = value.selected; + const running = value.running; + const healthy = value.lastHealthyActivation; + if ( + !isTime(value.observedAt) || + !isNullOr(value.operationId, (v): v is string => typeof v === "string") || + !(observedStatuses as readonly unknown[]).includes(value.status) || + !isNullOr(value.channel, isUpdateChannel) || + !isNullOr(value.lastAuthenticatedCheckAt, isTime) || + !isNullOr(value.available, isAvailable) || + typeof value.belowMinimumVersion !== "boolean" || + typeof value.canRetry !== "boolean" || + !isNullOr(value.releaseNotes, (v): v is string => typeof v === "string") || + !isNullOr( + value.downloadPercent, + (v): v is number => typeof v === "number", + ) || + !( + error === null || + (isRecord(error) && + isUpdateErrorCode(error.code) && + isContext(error.context)) + ) || + !( + identity === null || + (isRecord(identity) && + isProductVersion(identity.version) && + typeof identity.commit === "string" && + typeof identity.target === "string") + ) || + !( + selected === null || + (isRecord(selected) && + isProductVersion(selected.version) && + typeof selected.artifactSha16 === "string" && + isTime(selected.observedAt)) + ) || + !( + running === null || + (isRecord(running) && + isProductVersion(running.version) && + isTime(running.observedAt)) + ) || + !( + healthy === null || + (isRecord(healthy) && + isProductVersion(healthy.version) && + isTime(healthy.confirmedAt)) + ) + ) + throw new Error("Unrecognized observation"); + const pick = >( + source: unknown, + keys: readonly (keyof T & string)[], + ): T | null => + source === null + ? null + : (Object.freeze( + Object.fromEntries( + keys.map((key) => [key, (source as Record)[key]]), + ), + ) as T); + return Object.freeze({ + schemaVersion: 1, + observedAt: value.observedAt as string, + operationId: value.operationId as string | null, + status: value.status as ObservedStatus, + channel: value.channel as UpdateChannel | null, + lastAuthenticatedCheckAt: value.lastAuthenticatedCheckAt as string | null, + checkedBy: pick(identity, ["version", "commit", "target"]), + selected: pick>(selected, [ + "version", + "artifactSha16", + "observedAt", + ]), + running: pick>(running, [ + "version", + "observedAt", + ]), + available: pick(value.available, [ + "version", + "artifactSha256", + "length", + "sequence", + "minimumVersion", + "verifiedAt", + ]), + lastHealthyActivation: pick>( + healthy, + ["version", "confirmedAt"], + ), + belowMinimumVersion: value.belowMinimumVersion, + releaseNotes: value.releaseNotes as string | null, + downloadPercent: value.downloadPercent as number | null, + error: + error === null + ? null + : updateError( + (error as UpdateError).code, + (error as UpdateError).context, + ), + canRetry: value.canRetry, + }); +} + +/** Never throws and never blocks: anything other than a well-formed file of a + * known schema yields a rebuilt minimal observation. + */ +export async function readObserved(base: string, now: Date): Promise { + try { + const path = observedPath(base); + if ((await stat(path)).size > 64 * 1024) throw new Error("Too large"); + return parseObserved(JSON.parse(await readFile(path, "utf8"))); + } catch { + // `rebuiltObservation` cannot fail: reading the selector swallows errors. + return rebuiltObservation(base, now); + } +} + +export async function writeObserved( + base: string, + observed: Observed, + write: DurableWriter, +): Promise { + await write( + join(base, "update"), + "observed.json", + Buffer.from(`${JSON.stringify(observed, null, 2)}\n`), + ); +} diff --git a/src/update/trust-fetcher.ts b/src/update/trust-fetcher.ts new file mode 100644 index 00000000..db5ec09e --- /dev/null +++ b/src/update/trust-fetcher.ts @@ -0,0 +1,118 @@ +import type { Fetcher } from "tuf-js"; +// Pinned-client compatibility: the Updater recognizes this class for root 404. +import { DownloadHTTPError } from "tuf-js/dist/error"; + +export type TransferFailure = Readonly<{ + resource: string; + httpStatus?: number; +}>; + +/** Observes one refresh from the outside; it never alters a response. + * + * - It keeps the numbered roots the client received. The client overwrites + * its single `root.json` for every link of a rotation, but `trust/` retains + * the whole chain. The kept bytes are UNTRUSTED until promotion re-verifies + * them as a chain (`promoteVerified`). + * - It knows which role was delivered last without a later request having + * started: that role's verification is not known to have completed. + * - It remembers the last failed transfer, so a network failure is classified + * from the transport, not by parsing the client's wrapped error messages. + * + * Only the final path segment of a URL is ever recorded. + */ +export class TrustFetcher implements Fetcher { + readonly roots = new Map(); + private delivered: string | undefined; + private failure: TransferFailure | undefined; + + constructor( + private readonly inner: Fetcher, + private readonly metadataBaseUrl: string, + ) {} + + /** File name (e.g. `snapshot.json`, `3.root.json`) or undefined. */ + get unsettledFile(): string | undefined { + return this.delivered; + } + + get lastFailure(): TransferFailure | undefined { + return this.failure; + } + + private metadataFile(url: string): string | undefined { + if (!url.startsWith(this.metadataBaseUrl)) return undefined; + // Consistent snapshots prefix the version: `7.snapshot.json`. + const name = url.slice(this.metadataBaseUrl.length); + const role = /^(?:[1-9]\d*\.)?(timestamp|snapshot|targets)\.json$/.exec( + name, + ); + if (role) return `${role[1]}.json`; + return /^[1-9]\d*\.root\.json$/.test(name) ? name : undefined; + } + + private async observe( + url: string, + transfer: () => Promise, + received: (value: T) => Buffer | undefined, + transferred: () => boolean = () => false, + ): Promise { + // A new request proves the previous role's phase returned normally. + this.delivered = undefined; + const file = this.metadataFile(url); + let value: T; + try { + value = await transfer(); + } catch (error) { + const status = + error instanceof DownloadHTTPError ? error.statusCode : undefined; + // The client probes for a newer root until 403/404; that is the normal + // end of the root chain, not a failed transfer (updater.js:181-186). + const probe = + file?.endsWith(".root.json") === true && + status !== undefined && + [403, 404].includes(status); + // A consumer that refused fully received bytes (length/hash mismatch of + // a target) is a verification failure, not a transfer failure. + if (!probe && !transferred()) + this.failure = Object.freeze({ + resource: new URL(url).pathname.split("/").at(-1) ?? "", + ...(status === undefined ? {} : { httpStatus: status }), + }); + throw error; + } + this.failure = undefined; + this.delivered = file; + const bytes = received(value); + const root = /^([1-9]\d*)\.root\.json$/.exec(file ?? ""); + // Same decoding the client applies before it parses and persists + // (updater.js:366, store.js:35). + if (root && bytes) this.roots.set(Number(root[1]), bytes.toString("utf8")); + return value; + } + + downloadBytes(url: string, maxLength: number): Promise { + return this.observe( + url, + () => this.inner.downloadBytes(url, maxLength), + (bytes) => bytes, + ); + } + + downloadFile( + url: string, + maxLength: number, + handler: (file: string) => Promise, + ): Promise { + let handed = false; + return this.observe( + url, + () => + this.inner.downloadFile(url, maxLength, (file) => { + handed = true; + return handler(file); + }), + () => undefined, + () => handed, + ); + } +} diff --git a/src/update/trust.ts b/src/update/trust.ts new file mode 100644 index 00000000..3f6893ab --- /dev/null +++ b/src/update/trust.ts @@ -0,0 +1,317 @@ +import { lstat, mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { Metadata, MetadataKind, type Root } from "@tufjs/models"; +import { parseUniqueJson } from "../providers/unique-json"; +import { + type ChannelDocument, + type ChannelFloor, + isChannelFloor, + isUpdateChannel, + type UpdateChannel, +} from "./channel"; +import type { DurableWriter } from "./durable-file"; +import { UpdateFailure } from "./errors"; + +/** Durable trust: `/trust/` holds the verified TUF metadata and the + * channel floors (docs/update.md "State on disk", "Trust never rewinds"). + * + * root.json the current trusted root — the only file the TUF + * client is seeded with as its anchor + * .root.json the retained, verified root chain + * timestamp.json, snapshot.json, targets.json + * channel-floors.json owned, schema-versioned channel high-water marks + * + * The directory tolerates unrelated entries: only these names are ever read. + * + * WHEN THE PINNED CLIENT PERSISTS (tuf-js 6.0.0, node_modules/tuf-js/dist): + * the refresh runs in a scratch copy and `Updater.persistMetadata` + * (updater.js:361-371) is a plain `writeFileSync` — neither atomic nor synced, + * which is why the client never writes into `trust/` itself. For each role the + * call order inside one refresh is: + * + * root updater.js:176 `updateRoot` → :178 persist. `updateRoot` + * (store.js:33-52) checks the signature threshold of the CURRENT + * root (:42), version == current + 1 (:44) and the new root's own + * signatures (:48). It does NOT check expiry: the final root's + * expiry is checked only when a timestamp is loaded + * (store.js:57-59), i.e. after the root was already persisted. + * timestamp updater.js:213 `updateTimestamp` → :225 persist. store.js:53-93 + * verifies the signature (:68) and rollback (:72-85), installs + * the timestamp in memory (:89) and THEN throws on expiry (:91), + * so an expired timestamp serves rollback protection in memory + * but never reaches :225. An equal version returns at :218-219 + * without persisting. + * snapshot updater.js:249 `updateSnapshot` → :251 persist. store.js:94-138 + * checks length/hash against the timestamp (:108), signature + * (:118), rollback (:122-131), then expiry and the version named + * by the timestamp (:136 → :200-217) before returning. + * targets updater.js:285 `updateDelegatedTargets` → :287 persist. + * store.js:139-176 checks hash (:156), signature (:165), version + * (:168) and expiry (:172) before installing. + * + * So timestamp, snapshot and targets are persisted only after their COMPLETE + * verification, freshness included. The root is the one exception: it is + * persisted after complete AUTHENTICITY verification but before the freshness + * check of the final root. It is promoted regardless, deliberately: + * - this is the TUF client workflow itself (persist at 5.3.8, freeze check of + * the final root at 5.3.10): expiry is a statement about the freshness of a + * refresh, not about who may sign the next root; + * - an expired root is still the only valid anchor for its successor, and a + * root that revoked a key must never be forgotten. Dropping it would let a + * holder of the revoked key fork the chain on the next refresh — exactly + * the rewind this directory exists to prevent; + * - it cannot authenticate anything stale: every refresh re-checks the final + * root's expiry before it accepts any timestamp (store.js:57-59). + * Promotion does not rest on the client's persistence order for roots at all: + * `verifiedRootChain` re-verifies every link with the same `@tufjs/models` + * primitives before a single root byte reaches `trust/`. + * + * For the other roles promotion adds one mechanical guard that does not depend + * on reading the library correctly: the client fetches strictly sequentially, + * so a role's phase is known to have completed when a LATER request started or + * the refresh resolved. The role delivered last in a refresh that then failed + * is "unsettled" and is never promoted (see `TrustFetcher.unsettledFile`). + */ +export const roleFiles = [ + "timestamp.json", + "snapshot.json", + "targets.json", +] as const; +const floorsFile = "channel-floors.json"; + +export type Seed = Readonly<{ + /** Whether `trust/root.json` existed; false means a caller-supplied root. */ + established: boolean; + root: string; + roles: ReadonlyMap; +}>; + +async function readOptional(path: string): Promise { + try { + return await readFile(path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } +} + +/** Owned security state must not be replaceable by another account. */ +export async function ensureOwnedDirectory(path: string): Promise { + await mkdir(path, { recursive: true, mode: 0o700 }); + const stat = await lstat(path); + if ( + !stat.isDirectory() || + stat.uid !== process.getuid?.() || + (stat.mode & 0o022) !== 0 + ) + throw new UpdateFailure("trust-invalid", { reason: "unsafe-directory" }); +} + +function parseRoot(text: string): Metadata { + const value = parseUniqueJson(text); + if (!value || typeof value !== "object" || Array.isArray(value)) + throw new Error("Invalid root envelope"); + const root = Metadata.fromJSON( + MetadataKind.Root, + value as Parameters[1], + ); + if (root.signed.type !== MetadataKind.Root) throw new Error("Not a root"); + return root; +} + +/** Decide the trust anchor of this refresh. The bootstrap root is accepted + * only while no root was ever promoted; an established installation never + * falls back to it, and missing trust is never silently converted into one. + */ +export async function readSeed( + trustDirectory: string, + bootstrapRoot: Uint8Array | undefined, +): Promise { + const durable = await readOptional(join(trustDirectory, "root.json")); + if (durable !== undefined && bootstrapRoot !== undefined) + throw new UpdateFailure("trust-conflict"); + if (durable === undefined && bootstrapRoot === undefined) + throw new UpdateFailure("trust-missing"); + let root: string; + try { + root = + durable ?? + new TextDecoder("utf-8", { fatal: true }).decode( + bootstrapRoot as Uint8Array, + ); + const parsed = parseRoot(root); + parsed.verifyDelegate(MetadataKind.Root, parsed); + } catch { + throw new UpdateFailure("trust-invalid", { + subject: durable === undefined ? "bootstrap-root" : "root", + }); + } + const roles = new Map(); + for (const name of roleFiles) { + // An unreadable older role is not fatal: the client discards what it + // cannot verify under the current root and fetches it again. + const text = await readOptional(join(trustDirectory, name)); + if (text !== undefined) roles.set(name, text); + } + return Object.freeze({ established: durable !== undefined, root, roles }); +} + +export async function seedScratch(scratch: string, seed: Seed): Promise { + await writeFile(join(scratch, "root.json"), seed.root, { mode: 0o600 }); + for (const [name, text] of seed.roles) + await writeFile(join(scratch, name), text, { mode: 0o600 }); +} + +/** Every root from the seed to the scratch's final root, each link verified: + * signed by the threshold of its predecessor, exactly one version ahead and + * self-signed (the checks of store.js:42-48). `fetched` is untrusted received + * bytes; only a version that verifies AND leads to the final root is returned. + */ +function verifiedRootChain( + seedRoot: string, + finalRoot: string, + fetched: ReadonlyMap, +): { version: number; text: string }[] { + let current = parseRoot(seedRoot); + const chain = [{ version: current.signed.version, text: seedRoot }]; + while (chain.at(-1)?.text !== finalRoot) { + const version = current.signed.version + 1; + const text = fetched.get(version); + if (text === undefined) throw new Error("Root chain incomplete"); + const next = parseRoot(text); + current.verifyDelegate(MetadataKind.Root, next); + if (next.signed.version !== version) throw new Error("Root chain gap"); + next.verifyDelegate(MetadataKind.Root, next); + chain.push({ version, text }); + current = next; + } + return chain; +} + +/** Promote what this refresh newly verified. Called when the refresh ENDS — + * success or failure — and before the scratch is deleted. + * + * Order: numbered root chain ascending, `root.json`, timestamp, snapshot, + * targets; each file by temporary file + sync + rename + directory sync. The + * first failed write stops promotion, so `trust/` always holds a PREFIX of + * that order. Every prefix is a state the client recovers from by itself: a + * newer root with older roles is re-verified under that root, and a newer + * timestamp with an older snapshot makes the client fetch the snapshot the + * timestamp names (updater.js:231-256). A crash before the first write equals + * a refresh that never ran. + */ +export async function promoteVerified(input: { + trustDirectory: string; + scratch: string; + seed: Seed; + fetchedRoots: ReadonlyMap; + /** File name of the role whose verification is not known to have finished. */ + unsettledFile: string | undefined; + write: DurableWriter; +}): Promise { + const { seed, trustDirectory, scratch, write } = input; + const finalRoot = await readFile(join(scratch, "root.json"), "utf8"); + const changedRoles: { name: string; text: string }[] = []; + for (const name of roleFiles) { + if (name === input.unsettledFile) break; + const text = await readOptional(join(scratch, name)); + if (text !== undefined && text !== seed.roles.get(name)) + changedRoles.push({ name, text }); + } + // A caller-supplied root that verified nothing carries no accepted trust: + // keeping it out means a wrong or mistyped bootstrap root can never wedge + // the installation, and the same command can simply be repeated. + if (!seed.established && finalRoot === seed.root && !changedRoles.length) + return []; + const promoted: string[] = []; + const put = async (name: string, text: string) => { + await write(trustDirectory, name, Buffer.from(text, "utf8")); + promoted.push(name); + }; + // The unsettled guard applies to roots too: a root delivered last in a + // failed refresh is withheld, so a chain that needs it is incomplete. + const settledRoots = new Map(input.fetchedRoots); + const unsettledRoot = /^([1-9]\d*)\.root\.json$/.exec( + input.unsettledFile ?? "", + ); + if (unsettledRoot) settledRoots.delete(Number(unsettledRoot[1])); + const chain = verifiedRootChain(seed.root, finalRoot, settledRoots); + for (const { version, text } of chain) { + const name = `${version}.root.json`; + if ((await readOptional(join(trustDirectory, name))) !== text) + await put(name, text); + } + if (!seed.established || finalRoot !== seed.root) + await put("root.json", finalRoot); + for (const { name, text } of changedRoles) await put(name, text); + return promoted; +} + +type Floors = Partial>; + +/** A missing file means no floor yet. Anything unreadable is refused rather + * than treated as absent: forgetting a floor is a rewind. A newer schema is + * refused rather than guessed (docs/update.md "State on disk"). + */ +export async function readChannelFloors( + trustDirectory: string, +): Promise { + const text = await readOptional(join(trustDirectory, floorsFile)); + if (text === undefined) return {}; + const invalid = (reason: string) => + new UpdateFailure("trust-invalid", { subject: "channel-floors", reason }); + let value: unknown; + try { + value = parseUniqueJson(text); + } catch { + throw invalid("malformed"); + } + if (!value || typeof value !== "object" || Array.isArray(value)) + throw invalid("malformed"); + const record = value as Record; + if ( + typeof record.schemaVersion === "number" && + Number.isSafeInteger(record.schemaVersion) && + record.schemaVersion > 1 + ) + throw invalid("newer-schema"); + const channels = record.channels; + if ( + record.schemaVersion !== 1 || + Object.keys(record).sort().join(",") !== "channels,schemaVersion" || + !channels || + typeof channels !== "object" || + Array.isArray(channels) + ) + throw invalid("malformed"); + const floors: Floors = {}; + for (const [channel, floor] of Object.entries(channels)) { + if (!isUpdateChannel(channel) || !isChannelFloor(floor)) + throw invalid("malformed"); + floors[channel] = Object.freeze({ ...floor }); + } + return floors; +} + +/** Only after the signed document was verified, and only forward. */ +export async function advanceChannelFloor(input: { + trustDirectory: string; + floors: Floors; + document: ChannelDocument; + write: DurableWriter; +}): Promise { + const floor = input.floors[input.document.channel]; + if (floor && input.document.sequence <= floor.sequence) return; + const channels: Floors = { + ...input.floors, + [input.document.channel]: { + sequence: input.document.sequence, + documentSha256: input.document.documentSha256, + }, + }; + await input.write( + input.trustDirectory, + floorsFile, + Buffer.from(`${JSON.stringify({ schemaVersion: 1, channels })}\n`), + ); +} diff --git a/tests/update-check.test.ts b/tests/update-check.test.ts new file mode 100644 index 00000000..b0c1b437 --- /dev/null +++ b/tests/update-check.test.ts @@ -0,0 +1,686 @@ +import { afterEach, expect, test } from "bun:test"; +import { + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createUpdateFixture } from "../scripts/update-fixture"; +import { DistributionTransport } from "../src/distribution/transport"; +import { type CheckInput, checkForUpdate } from "../src/update/check"; +import { + type DurableWriter, + writeDurableFile, +} from "../src/update/durable-file"; +import { acquireUpdateLock } from "../src/update/lock"; +import { readObserved } from "../src/update/observed"; + +// Signed loopback repository + real filesystem; the only fake is the clock of +// the observation. Expiry is produced by publishing already-expired metadata, +// never by moving a clock the TUF client cannot see. +const target = "linux-x64"; +const past = "2020-01-01T00:00:00.000Z"; +const clock = () => new Date("2026-09-19T10:00:00.000Z"); +const cleanups: (() => Promise)[] = []; +afterEach(async () => { + for (const cleanup of cleanups.splice(0)) await cleanup(); +}); + +async function scenario(version = "1.0.0") { + const fixture = createUpdateFixture({ executionTarget: target }); + const base = join( + await realpath(await mkdtemp(join(tmpdir(), "update-check-"))), + "base", + ); + cleanups.push(async () => { + await fixture.stop(); + await rm(join(base, ".."), { recursive: true }); + }); + const check = (overrides: Partial = {}) => + checkForUpdate({ + base, + metadataBaseUrl: fixture.metadataBaseUrl, + targetBaseUrl: fixture.targetBaseUrl, + channel: "stable", + identity: { version, commit: "a".repeat(40), target }, + transport: new DistributionTransport( + [fixture.origin], + 10_000, + new AbortController().signal, + true, + ), + clock, + lockTimeoutMs: 10_000, + ...overrides, + }); + const trust = (name: string) => + readFile(join(base, "trust", name), "utf8").catch(() => undefined); + const trustSnapshot = async () => { + const entries: Record = {}; + for (const name of (await readdir(join(base, "trust"))).sort()) + entries[name] = (await trust(name)) ?? ""; + return entries; + }; + const version_ = async (name: string): Promise => { + const text = await trust(name); + return text === undefined ? undefined : JSON.parse(text).signed.version; + }; + return { fixture, base, check, trust, trustSnapshot, roleVersion: version_ }; +} + +test("first check with a bootstrap root promotes trust, writes the observation and reports availability", async () => { + const { fixture, base, check, trust } = await scenario(); + fixture.release("stable", { sequence: 1, version: "1.2.0" }); + const result = await check({ bootstrapRoot: fixture.bootstrapRoot }); + expect(result).toEqual({ + kind: "available", + version: "1.2.0", + artifactSha256: fixture.artifactSha256, + length: fixture.artifactLength, + sequence: 1, + }); + expect((await readdir(join(base, "trust"))).sort()).toEqual([ + "1.root.json", + "channel-floors.json", + "root.json", + "snapshot.json", + "targets.json", + "timestamp.json", + ]); + expect(await trust("root.json")).toBe(fixture.bootstrapRoot.toString()); + expect(JSON.parse((await trust("channel-floors.json")) ?? "")).toEqual({ + schemaVersion: 1, + channels: { + stable: { + sequence: 1, + documentSha256: expect.stringMatching(/^[a-f0-9]{64}$/), + }, + }, + }); + // Scratch is always deleted; no artifact was requested. + expect(await readdir(join(base, "update"))).toEqual( + expect.arrayContaining(["lock", "observed.json"]), + ); + expect( + (await readdir(join(base, "update"))).some((name) => + name.startsWith("scratch-"), + ), + ).toBe(false); + expect(fixture.requests.some((path) => path.includes("/artifacts/"))).toBe( + false, + ); + const observed = JSON.parse( + await readFile(join(base, "update", "observed.json"), "utf8"), + ); + expect(observed).toMatchObject({ + schemaVersion: 1, + observedAt: "2026-09-19T10:00:00.000Z", + status: "available", + channel: "stable", + lastAuthenticatedCheckAt: "2026-09-19T10:00:00.000Z", + checkedBy: { version: "1.0.0", target }, + selected: null, + running: null, + lastHealthyActivation: null, + available: { + version: "1.2.0", + artifactSha256: fixture.artifactSha256, + sequence: 1, + verifiedAt: "2026-09-19T10:00:00.000Z", + }, + error: null, + canRetry: false, + }); + expect(typeof observed.operationId).toBe("string"); +}); + +test("an equal or older channel version is up to date, never a downgrade", async () => { + const { fixture, check } = await scenario("1.2.0"); + fixture.release("stable", { sequence: 1, version: "1.2.0" }); + expect(await check({ bootstrapRoot: fixture.bootstrapRoot })).toEqual({ + kind: "up-to-date", + version: "1.2.0", + channelVersion: "1.2.0", + sequence: 1, + }); + fixture.release("stable", { sequence: 2, version: "1.1.0" }); + expect(await check()).toMatchObject({ + kind: "up-to-date", + channelVersion: "1.1.0", + }); +}); + +test("later checks need no bootstrap root; a bootstrap root beside established trust is refused; no trust and no root is typed", async () => { + const { fixture, check, trustSnapshot } = await scenario(); + fixture.release("stable", { sequence: 1, version: "1.2.0" }); + expect(await check()).toMatchObject({ kind: "error", code: "trust-missing" }); + expect(await check({ bootstrapRoot: fixture.bootstrapRoot })).toMatchObject({ + kind: "available", + }); + const established = await trustSnapshot(); + expect(await check()).toMatchObject({ kind: "available" }); + expect(await check({ bootstrapRoot: fixture.bootstrapRoot })).toMatchObject({ + kind: "error", + code: "trust-conflict", + }); + expect(await trustSnapshot()).toEqual(established); + expect( + await check({ bootstrapRoot: Buffer.from('{"signed":{}}') }), + ).toMatchObject({ kind: "error", code: "trust-conflict" }); +}); + +test("a bootstrap root that verified nothing is not kept, so it can never wedge the first check", async () => { + const { fixture, base, check } = await scenario(); + fixture.release("stable", { sequence: 1, version: "1.2.0" }); + const stranger = createUpdateFixture({ executionTarget: target }); + await stranger.stop(); + // A self-consistent root of ANOTHER repository verifies nothing here. + expect(await check({ bootstrapRoot: stranger.bootstrapRoot })).toMatchObject({ + kind: "error", + code: "metadata-invalid", + }); + expect(await readdir(join(base, "trust"))).toEqual([]); + expect( + await check({ bootstrapRoot: Buffer.from("not a root") }), + ).toMatchObject({ kind: "error", code: "trust-invalid" }); + expect(await check({ bootstrapRoot: fixture.bootstrapRoot })).toMatchObject({ + kind: "available", + }); +}); + +test("a root rotation accepted during a refresh that later fails is retained, and the next check succeeds under it", async () => { + for (const failing of ["targets", "channel", "timestamp"] as const) { + const { fixture, check, trust, roleVersion } = await scenario(); + fixture.release("stable", { sequence: 1, version: "1.2.0" }); + expect(await check({ bootstrapRoot: fixture.bootstrapRoot })).toMatchObject( + { kind: "available" }, + ); + fixture.rotateRoot(); + const blocked = + failing === "targets" + ? "/metadata/2.targets.json" + : failing === "timestamp" + ? "/metadata/timestamp.json" + : (fixture.requests.find((path) => path.includes("/channels/")) ?? + ""); + fixture.block(blocked, failing === "timestamp" ? 503 : 404); + const failed = await check(); + expect(failed).toMatchObject({ + kind: "error", + code: "network-unavailable", + context: { + resource: blocked.split("/").at(-1), + httpStatus: failing === "timestamp" ? 503 : 404, + }, + }); + // The rotated root is durable although the attempt failed. + const rotated = fixture.served("/metadata/2.root.json")?.toString(); + expect(await trust("root.json")).toBe(rotated); + expect(await trust("2.root.json")).toBe(rotated); + expect(await trust("1.root.json")).toBe(fixture.bootstrapRoot.toString()); + // Everything verified before the failure moved forward too — and nothing + // after it. + expect(await roleVersion("timestamp.json")).toBe( + failing === "timestamp" ? 1 : 2, + ); + expect(await roleVersion("snapshot.json")).toBe( + failing === "timestamp" ? 1 : 2, + ); + expect(await roleVersion("targets.json")).toBe( + failing === "channel" ? 2 : 1, + ); + fixture.unblock(blocked); + fixture.requests.length = 0; + expect(await check()).toMatchObject({ kind: "available" }); + // The next refresh started FROM root 2: it only probed for root 3. + expect( + fixture.requests.filter((path) => path.endsWith(".root.json")), + ).toEqual(["/metadata/3.root.json"]); + expect(await roleVersion("targets.json")).toBe(2); + } +}); + +test("rotated timestamp and snapshot keys: old roles that no longer verify are replaced, not fatal", async () => { + const { fixture, check, roleVersion } = await scenario(); + fixture.release("stable", { sequence: 1, version: "1.2.0" }); + await check({ bootstrapRoot: fixture.bootstrapRoot }); + fixture.rotateRoot({ rotate: ["root", "timestamp", "snapshot", "targets"] }); + expect(await check()).toMatchObject({ kind: "available" }); + expect(await roleVersion("root.json")).toBe(2); + expect(await roleVersion("timestamp.json")).toBe(2); +}); + +test("a correctly signed but EXPIRED rotated root is still promoted; a fresh successor then verifies from it", async () => { + const { fixture, check, trust, roleVersion } = await scenario(); + fixture.release("stable", { sequence: 1, version: "1.2.0" }); + await check({ bootstrapRoot: fixture.bootstrapRoot }); + fixture.rotateRoot({ expires: past }); + expect(await check()).toMatchObject({ + kind: "error", + code: "metadata-expired", + }); + // Authenticity was complete; expiry is about freshness of THIS refresh. The + // root that revoked key 1 must not be forgotten (see src/update/trust.ts). + expect(await trust("root.json")).toBe( + fixture.served("/metadata/2.root.json")?.toString(), + ); + // It authenticated nothing stale: no role moved under the expired root. + expect(await roleVersion("timestamp.json")).toBe(1); + fixture.renewRoot(); + fixture.requests.length = 0; + expect(await check()).toMatchObject({ kind: "available" }); + expect(await roleVersion("root.json")).toBe(3); + expect(fixture.requests).not.toContain("/metadata/2.root.json"); +}); + +test("a crash between any two promotions converges on the next check", async () => { + // Count the writes of an uninterrupted check, then die before each one. + const counting = await scenario(); + counting.fixture.release("stable", { sequence: 1, version: "1.2.0" }); + await counting.check({ bootstrapRoot: counting.fixture.bootstrapRoot }); + counting.fixture.rotateRoot(); + counting.fixture.release("stable", { sequence: 2, version: "1.3.0" }); + let writes = 0; + const counted: DurableWriter = async (...args) => { + writes += 1; + await writeDurableFile(...args); + }; + await counting.check({ writeDurable: counted }); + const converged = await counting.trustSnapshot(); + // 2.root.json, root.json, timestamp, snapshot, targets, floors, observed. + expect(writes).toBe(7); + + for (let survive = 0; survive < writes; survive++) { + const { fixture, check, trustSnapshot, base } = await scenario(); + fixture.release("stable", { sequence: 1, version: "1.2.0" }); + await check({ bootstrapRoot: fixture.bootstrapRoot }); + fixture.rotateRoot(); + fixture.release("stable", { sequence: 2, version: "1.3.0" }); + let done = 0; + const dying: DurableWriter = async (...args) => { + // A dead process writes nothing further, the observation included. + if (done >= survive) throw new Error("killed"); + done += 1; + await writeDurableFile(...args); + }; + const interrupted = await check({ writeDurable: dying }); + if (survive < 6) + expect(interrupted).toMatchObject({ kind: "error", code: "internal" }); + // Promotion order is an invariant, not a habit: whatever survived is a + // PREFIX of root chain → root → timestamp → snapshot → targets → floor, so + // trust/ never holds a role newer than the root that verifies it. + const partial = await trustSnapshot(); + const at = (name: string): number => + JSON.parse(partial[name] ?? "").signed.version; + if (at("root.json") === 2) expect(partial["2.root.json"]).toBeDefined(); + if (at("timestamp.json") > 1) expect(at("root.json")).toBe(2); + if (at("snapshot.json") > 1) expect(at("timestamp.json")).toBe(3); + if (at("targets.json") > 1) expect(at("snapshot.json")).toBe(3); + if ( + JSON.parse(partial["channel-floors.json"] ?? "").channels.stable + .sequence === 2 + ) + expect(at("targets.json")).toBe(3); + // Exactly `survive` promotions happened before the kill. + expect( + [ + partial["2.root.json"] !== undefined, + at("root.json") === 2, + at("timestamp.json") === 3, + at("snapshot.json") === 3, + at("targets.json") === 3, + JSON.parse(partial["channel-floors.json"] ?? "").channels.stable + .sequence === 2, + ].filter(Boolean).length, + ).toBe(Math.min(survive, 6)); + // Whatever prefix survived, a plain next check finishes the job. + expect(await check()).toMatchObject({ + kind: "available", + version: "1.3.0", + sequence: 2, + }); + const after = await trustSnapshot(); + expect(Object.keys(after)).toEqual(Object.keys(converged)); + for (const name of ["root.json", "timestamp.json", "targets.json"]) + expect(JSON.parse(after[name] ?? "").signed.version).toBe( + JSON.parse(converged[name] ?? "").signed.version, + ); + expect( + JSON.parse(after["channel-floors.json"] ?? "").channels.stable, + ).toMatchObject({ sequence: 2 }); + expect((await readObserved(base, clock())).status).toBe("available"); + } +}, 60_000); + +test("abandoned scratch directories and temporary files of a killed check are removed by the next one", async () => { + const { fixture, base, check } = await scenario(); + fixture.release("stable", { sequence: 1, version: "1.2.0" }); + await check({ bootstrapRoot: fixture.bootstrapRoot }); + await mkdir(join(base, "update", "scratch-dead")); + await writeFile(join(base, "update", "scratch-dead", "root.json"), "junk"); + await writeFile( + join(base, "trust", ".timestamp.json.tmp-0123456789abcdef"), + "half", + ); + expect(await check()).toMatchObject({ kind: "available" }); + expect(await readdir(join(base, "update"))).not.toContain("scratch-dead"); + expect(await readdir(join(base, "trust"))).not.toContain( + ".timestamp.json.tmp-0123456789abcdef", + ); +}); + +test("an expired timestamp is a typed error that leaves trust untouched and never wedges", async () => { + const { fixture, base, check, trustSnapshot } = await scenario(); + // First contact: nothing verified, nothing kept, same command works later. + fixture.release( + "stable", + { sequence: 1, version: "1.2.0" }, + { expires: { timestamp: past } }, + ); + expect(await check({ bootstrapRoot: fixture.bootstrapRoot })).toMatchObject({ + kind: "error", + code: "metadata-expired", + }); + expect(await readdir(join(base, "trust"))).toEqual([]); + expect(await readObserved(base, clock())).toMatchObject({ + status: "error", + error: { code: "metadata-expired" }, + canRetry: true, + lastAuthenticatedCheckAt: null, + }); + fixture.publish(); + expect(await check({ bootstrapRoot: fixture.bootstrapRoot })).toMatchObject({ + kind: "available", + }); + // Established trust: the lapse changes nothing on disk. + const before = await trustSnapshot(); + fixture.publish({ expires: { timestamp: past } }); + expect(await check()).toMatchObject({ + kind: "error", + code: "metadata-expired", + }); + expect(await trustSnapshot()).toEqual(before); + // The verified target stays visible while the error is shown. + expect(await readObserved(base, clock())).toMatchObject({ + status: "error", + available: { version: "1.2.0" }, + lastAuthenticatedCheckAt: "2026-09-19T10:00:00.000Z", + }); + fixture.publish(); + expect(await check()).toMatchObject({ kind: "available" }); +}); + +test("expired snapshot and targets are classified as expiry through the pinned client's wrapped errors", async () => { + for (const role of ["snapshot", "targets"] as const) { + const { fixture, check, roleVersion } = await scenario(); + fixture.release("stable", { sequence: 1, version: "1.2.0" }); + await check({ bootstrapRoot: fixture.bootstrapRoot }); + fixture.publish({ expires: { [role]: past } }); + expect(await check()).toMatchObject({ + kind: "error", + code: "metadata-expired", + }); + // What was fresh and verified before the expired role moved forward. + expect(await roleVersion("timestamp.json")).toBe(2); + expect(await roleVersion(`${role}.json`)).toBe(1); + fixture.publish(); + expect(await check()).toMatchObject({ kind: "available" }); + } +}); + +test("a role that fails verification is never promoted, while the roles verified before it are", async () => { + const { fixture, check, roleVersion } = await scenario(); + fixture.release("stable", { sequence: 1, version: "1.2.0" }); + await check({ bootstrapRoot: fixture.bootstrapRoot }); + fixture.publish({ tamper: "snapshot" }); + expect(await check()).toMatchObject({ + kind: "error", + code: "metadata-invalid", + }); + expect(await roleVersion("timestamp.json")).toBe(2); + expect(await roleVersion("snapshot.json")).toBe(1); + expect(await roleVersion("targets.json")).toBe(1); + fixture.publish(); + expect(await check()).toMatchObject({ kind: "available" }); + expect(await roleVersion("snapshot.json")).toBe(3); +}); + +test("channel floors only move forward; a lower sequence or reused sequence is a rollback", async () => { + const { fixture, check, trust } = await scenario(); + const floor = async () => + JSON.parse((await trust("channel-floors.json")) ?? "").channels.stable; + fixture.release("stable", { sequence: 5, version: "1.2.0" }); + await check({ bootstrapRoot: fixture.bootstrapRoot }); + const five = await floor(); + expect(five.sequence).toBe(5); + // Identical authenticated bytes in a newer generation are a normal repeat. + fixture.publish(); + expect(await check()).toMatchObject({ kind: "available", sequence: 5 }); + expect(await floor()).toEqual(five); + fixture.release("stable", { sequence: 7, version: "1.3.0" }); + expect(await check()).toMatchObject({ kind: "available", sequence: 7 }); + const seven = await floor(); + expect(seven.sequence).toBe(7); + // TUF accepts both generations below (their metadata versions rise); only + // the channel floor refuses them. + fixture.release("stable", { sequence: 6, version: "1.4.0" }); + expect(await check()).toMatchObject({ + kind: "error", + code: "channel-rollback", + context: { channel: "stable", floorSequence: 7, offeredSequence: 6 }, + }); + expect(await floor()).toEqual(seven); + fixture.release("stable", { sequence: 7, version: "1.4.0" }); + expect(await check()).toMatchObject({ + kind: "error", + code: "channel-rollback", + }); + expect(await floor()).toEqual(seven); + // Each channel has its own floor. + fixture.release("preview", { sequence: 1, version: "2.0.0-rc.1" }); + fixture.release("stable", { sequence: 8, version: "1.4.0" }); + expect(await check({ channel: "preview" })).toMatchObject({ + kind: "available", + version: "2.0.0-rc.1", + sequence: 1, + }); + expect( + JSON.parse((await trust("channel-floors.json")) ?? "").channels, + ).toMatchObject({ stable: { sequence: 7 }, preview: { sequence: 1 } }); + expect(await check()).toMatchObject({ kind: "available", sequence: 8 }); +}); + +test("channel document problems and an unsupported target are typed; the floor still advances for a verified document", async () => { + const { fixture, check, trust } = await scenario(); + fixture.release("stable", { sequence: 1, version: "1.2.0" }); + await check({ bootstrapRoot: fixture.bootstrapRoot }); + expect(await check({ channel: "preview" })).toMatchObject({ + kind: "error", + code: "channel-invalid", + context: { reason: "absent" }, + }); + fixture.release("stable", { + sequence: 2, + version: "1.3.0", + withoutTarget: true, + }); + expect(await check()).toMatchObject({ + kind: "error", + code: "target-unsupported", + context: { target }, + }); + expect( + JSON.parse((await trust("channel-floors.json")) ?? "").channels.stable + .sequence, + ).toBe(2); + fixture.release("stable", { + sequence: 3, + version: "1.3.0", + rawDocument: '{"schemaVersion":1,"schemaVersion":1}', + }); + expect(await check()).toMatchObject({ + kind: "error", + code: "channel-invalid", + context: { reason: "malformed" }, + }); + expect( + await check({ channel: "nightly" as unknown as "stable" }), + ).toMatchObject({ kind: "error", code: "invalid-request" }); +}); + +test("a missing or corrupt observation never blocks a check and is always rebuilt", async () => { + const { fixture, base, check } = await scenario(); + fixture.release("stable", { sequence: 1, version: "1.2.0" }); + const path = join(base, "update", "observed.json"); + expect(await readObserved(base, clock())).toMatchObject({ + status: "idle", + operationId: null, + available: null, + }); + await check({ bootstrapRoot: fixture.bootstrapRoot }); + for (const garbage of [ + "{ not json", + "null", + '{"schemaVersion":99,"status":"downloading"}', + '{"schemaVersion":1,"status":"downloading"}', + "x".repeat(200_000), + ]) { + await writeFile(path, garbage); + expect(await readObserved(base, clock())).toMatchObject({ status: "idle" }); + expect(await check()).toMatchObject({ kind: "available" }); + expect(await readObserved(base, clock())).toMatchObject({ + status: "available", + }); + } + await rm(path); + expect(await check()).toMatchObject({ kind: "available" }); + // Even an observation that cannot be written does not change the result. + await rm(path); + await mkdir(path); + expect(await check()).toMatchObject({ kind: "available" }); + expect(await readObserved(base, clock())).toMatchObject({ status: "idle" }); +}); + +test("the observation reads the selector and nothing else about the active version", async () => { + const { fixture, base, check } = await scenario(); + fixture.release("stable", { sequence: 1, version: "1.2.0" }); + await mkdir(join(base, "bin"), { recursive: true }); + await symlink( + "../versions/1.0.0+0123456789abcdef/lazurio", + join(base, "bin", "lazurio"), + ); + await check({ bootstrapRoot: fixture.bootstrapRoot }); + expect((await readObserved(base, clock())).selected).toEqual({ + version: "1.0.0", + artifactSha16: "0123456789abcdef", + observedAt: "2026-09-19T10:00:00.000Z", + }); +}); + +test("unknown entries in the base, in trust/ and in update/ never stop a check and are left alone", async () => { + const { fixture, base, check } = await scenario(); + fixture.release("stable", { sequence: 1, version: "1.2.0" }); + await mkdir(join(base, "legacy-runtime", "node_modules"), { + recursive: true, + }); + await mkdir(join(base, "trust"), { recursive: true }); + await mkdir(join(base, "update"), { recursive: true }); + const strangers = [ + join(base, "active.json"), + join(base, "legacy-runtime", "node_modules", "x.js"), + join(base, "trust", "notes.txt"), + join(base, "trust", "99.root.json"), + join(base, "trust", "selected.json"), + join(base, "update", "config.json"), + ]; + for (const path of strangers) await writeFile(path, "unrelated"); + await mkdir(join(base, "trust", "7")); + expect(await check({ bootstrapRoot: fixture.bootstrapRoot })).toMatchObject({ + kind: "available", + }); + expect(await check()).toMatchObject({ kind: "available" }); + for (const path of strangers) + expect(await readFile(path, "utf8")).toBe("unrelated"); +}); + +test("owned security state of a newer schema is refused, not guessed", async () => { + const { fixture, base, check } = await scenario(); + fixture.release("stable", { sequence: 1, version: "1.2.0" }); + await check({ bootstrapRoot: fixture.bootstrapRoot }); + await writeFile( + join(base, "trust", "channel-floors.json"), + '{"schemaVersion":2,"floors":[]}', + ); + expect(await check()).toMatchObject({ + kind: "error", + code: "trust-invalid", + context: { subject: "channel-floors", reason: "newer-schema" }, + }); +}); + +test("two concurrent checks serialize on the lock and neither corrupts trust; a held lock times out as busy", async () => { + const { fixture, base, check, trustSnapshot } = await scenario(); + fixture.release("stable", { sequence: 1, version: "1.2.0" }); + await check({ bootstrapRoot: fixture.bootstrapRoot }); + fixture.rotateRoot(); + fixture.release("stable", { sequence: 2, version: "1.3.0" }); + const results = await Promise.all([check(), check(), check()]); + for (const result of results) + expect(result).toMatchObject({ kind: "available", sequence: 2 }); + const settled = await trustSnapshot(); + expect(JSON.parse(settled["root.json"] ?? "").signed.version).toBe(2); + expect(await check()).toMatchObject({ kind: "available" }); + expect(await trustSnapshot()).toEqual(settled); + + const held = await acquireUpdateLock(join(base, "update", "lock"), { + timeoutMs: 0, + }); + const observedBefore = await readFile( + join(base, "update", "observed.json"), + "utf8", + ); + try { + fixture.requests.length = 0; + expect(await check({ lockTimeoutMs: 150 })).toEqual({ + kind: "error", + code: "busy", + context: {}, + }); + // A busy check touched neither the network nor the observation. + expect(fixture.requests).toEqual([]); + expect(await readFile(join(base, "update", "observed.json"), "utf8")).toBe( + observedBefore, + ); + const waiting = check({ lockTimeoutMs: 10_000 }); + await new Promise((resolve) => setTimeout(resolve, 200)); + await held.release(); + expect(await waiting).toMatchObject({ kind: "available" }); + } finally { + await held.release(); + } +}); + +test("an unreachable origin is a retryable typed error that keeps the last verified facts", async () => { + const { fixture, base, check, trustSnapshot } = await scenario(); + fixture.release("stable", { sequence: 1, version: "1.2.0" }); + await check({ bootstrapRoot: fixture.bootstrapRoot }); + const before = await trustSnapshot(); + await fixture.stop(); + expect(await check()).toMatchObject({ + kind: "error", + code: "network-unavailable", + }); + expect(await trustSnapshot()).toEqual(before); + expect(await readObserved(base, clock())).toMatchObject({ + status: "error", + error: { code: "network-unavailable" }, + canRetry: true, + available: { version: "1.2.0" }, + }); +}); diff --git a/tests/update-trust.test.ts b/tests/update-trust.test.ts new file mode 100644 index 00000000..6d8c6782 --- /dev/null +++ b/tests/update-trust.test.ts @@ -0,0 +1,132 @@ +import { afterEach, expect, test } from "bun:test"; +import { + mkdir, + mkdtemp, + readdir, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createUpdateFixture } from "../scripts/update-fixture"; +import { writeDurableFile } from "../src/update/durable-file"; +import { promoteVerified, type Seed } from "../src/update/trust"; + +// Promotion is exercised directly with scratch contents the pinned client +// would never produce: it must be safe by itself, not by the client's habits. +const cleanups: (() => Promise)[] = []; +afterEach(async () => { + for (const cleanup of cleanups.splice(0)) await cleanup(); +}); + +async function directories() { + const root = await realpath(await mkdtemp(join(tmpdir(), "update-trust-"))); + cleanups.push(() => rm(root, { recursive: true })); + const trustDirectory = join(root, "trust"); + const scratch = join(root, "scratch"); + await mkdir(trustDirectory); + await mkdir(scratch); + return { trustDirectory, scratch }; +} + +function repository() { + const fixture = createUpdateFixture({ executionTarget: "linux-x64" }); + cleanups.push(() => fixture.stop()); + fixture.release("stable", { sequence: 1, version: "1.0.0" }); + const text = (path: string) => fixture.served(path)?.toString() ?? ""; + return { fixture, text }; +} + +test("the role delivered last in a failed refresh is withheld together with everything after it", async () => { + const { trustDirectory, scratch } = await directories(); + const { fixture, text } = repository(); + const seed: Seed = { + established: false, + root: fixture.bootstrapRoot.toString(), + roles: new Map(), + }; + await writeFile(join(scratch, "root.json"), seed.root); + await writeFile( + join(scratch, "timestamp.json"), + text("/metadata/timestamp.json"), + ); + await writeFile( + join(scratch, "snapshot.json"), + text("/metadata/1.snapshot.json"), + ); + await writeFile( + join(scratch, "targets.json"), + text("/metadata/1.targets.json"), + ); + expect( + await promoteVerified({ + trustDirectory, + scratch, + seed, + fetchedRoots: new Map(), + unsettledFile: "snapshot.json", + write: writeDurableFile, + }), + ).toEqual(["1.root.json", "root.json", "timestamp.json"]); + expect((await readdir(trustDirectory)).sort()).toEqual([ + "1.root.json", + "root.json", + "timestamp.json", + ]); +}); + +test("a root in scratch reaches trust/ only through a verified chain from the seed", async () => { + const { fixture, text } = repository(); + fixture.rotateRoot(); + const seed: Seed = { + established: true, + root: fixture.bootstrapRoot.toString(), + roles: new Map(), + }; + const genuine = text("/metadata/2.root.json"); + const forged = createUpdateFixture({ executionTarget: "linux-x64" }); + cleanups.push(() => forged.stop()); + forged.rotateRoot(); + const cases: [string, Map, string | undefined][] = [ + // Version 2 of ANOTHER repository: not signed by the seed's root key. + [ + forged.served("/metadata/2.root.json")?.toString() ?? "", + new Map(), + undefined, + ], + // The genuine successor, but its bytes were never received. + [genuine, new Map(), undefined], + // Received, but delivered last in a refresh that then failed. + [genuine, new Map([[2, genuine]]), "2.root.json"], + ]; + for (const [final, fetchedRoots, unsettledFile] of cases) { + const { trustDirectory, scratch } = await directories(); + await writeFile(join(scratch, "root.json"), final); + if (fetchedRoots.size === 0 && final !== genuine) + fetchedRoots.set(2, final); + await expect( + promoteVerified({ + trustDirectory, + scratch, + seed, + fetchedRoots, + unsettledFile, + write: writeDurableFile, + }), + ).rejects.toThrow(); + expect(await readdir(trustDirectory)).toEqual([]); + } + const { trustDirectory, scratch } = await directories(); + await writeFile(join(scratch, "root.json"), genuine); + expect( + await promoteVerified({ + trustDirectory, + scratch, + seed, + fetchedRoots: new Map([[2, genuine]]), + unsettledFile: undefined, + write: writeDurableFile, + }), + ).toEqual(["1.root.json", "2.root.json", "root.json"]); +}); From 95bcacaa6d61abd2e5799b89083f244873e46bd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Sat, 19 Sep 2026 02:36:23 +0200 Subject: [PATCH 05/11] CLI: lazurio --version, update --check and update status; candidates embed their identity build-candidate passes the identity as --define values and, for a native build, asks the produced executable to confirm it equals identity.json; the candidate smoke asserts the same. A unit test compiles the real CLI entrypoint with the defines and compares --version. Plain 'lazurio update' reports the typed not-implemented error: this build can only check. docs/update.md records what the first slice implements and the concrete choices it fixed. Co-Authored-By: Claude Fable 5.1 --- docs/update.md | 44 +++++++ scripts/build-candidate.ts | 28 +++++ scripts/smoke-candidate.ts | 18 ++- src/cli.ts | 16 +++ src/update/base.ts | 22 ++++ src/update/cli.ts | 210 ++++++++++++++++++++++++++++++++++ tests/update-cli.test.ts | 159 +++++++++++++++++++++++++ tests/update-identity.test.ts | 113 ++++++++++++++++++ 8 files changed, 609 insertions(+), 1 deletion(-) create mode 100644 src/update/base.ts create mode 100644 src/update/cli.ts create mode 100644 tests/update-cli.test.ts create mode 100644 tests/update-identity.test.ts diff --git a/docs/update.md b/docs/update.md index ca59348e..97223e25 100644 --- a/docs/update.md +++ b/docs/update.md @@ -261,6 +261,50 @@ root rotation accepted during a failed download and retained, full disk, unknown files in the base, two concurrent updates, update while applications run. The same journey then runs on a hosted canary Machine. +## Implemented so far + +The first slice lives in `src/update/` (the old installer in +`src/distribution/` is untouched until the evidence above exists). It delivers +the embedded identity and `lazurio --version`, the **Check** step with durable +trust promotion, `update/observed.json`, `lazurio update --check` and +`lazurio update status`. Download, activation, the compiled-in root and default +origins are not built; plain `lazurio update` says so with `not-implemented`. +Concrete choices this slice fixed: + +- **`trust/` layout.** `root.json` is the current trusted root and the only + anchor the TUF client is seeded with; `.root.json` is the retained verified + chain; `timestamp.json`, `snapshot.json`, `targets.json`; + `channel-floors.json` (`schemaVersion`, per-channel `sequence` and + `documentSha256`). Only these names are read; anything else is ignored. + Promotion order is the numbered chain, `root.json`, timestamp, snapshot, + targets, then the floor, and `trust/` always holds a prefix of that order. +- **When the pinned client persists.** Verified in the `tuf-js` 6.0.0 source + and recorded with line references in `src/update/trust.ts`: timestamp, + snapshot and targets are persisted only after complete verification including + expiry; a root is persisted after complete authenticity verification but + before the final-root expiry check, and is promoted regardless because a + signed successor root must never be forgotten. Promotion re-verifies the root + chain itself and withholds the role delivered last in a failed refresh. +- **First trust.** The caller supplies the bootstrap root (later: the + compiled-in root). It becomes durable only together with the first role it + verified, so a wrong root can never wedge an installation; once `trust/` holds + a root, a supplied bootstrap root is refused (`trust-conflict`). +- **Channel document.** `channels/.json`, exact fields + `schemaVersion: 1`, `channel`, `sequence`, `version`, `minimumVersion`, + `targets` (execution target → `artifacts//lazurio`). A check reads + this one document and the signed length of the artifact, never the artifact. + An update is available only when the channel version has higher Semantic + Versioning precedence than the embedded version. +- **Error codes** are defined once, in `src/update/errors.ts`, together with + their exit status and whether the same action can be retried. A check exits + `0` when up to date and `10` when an update is available. +- **Lock.** `update/lock` is one regular file locked with `flock`, polled + until a timeout (`busy`). Creating it is atomic and its content is never + read, so there is no initialization to interrupt. The Folder operation lock + is unchanged; converging the two is separate work. +- **Clock.** The injected clock stamps the observation only. Expiry is judged + by the pinned TUF client against the system clock; it has no clock input. + ## Removed by this contract The write-ahead metadata journal, transcript replay, historical role floors, diff --git a/scripts/build-candidate.ts b/scripts/build-candidate.ts index 27459ee9..01a0c7e0 100644 --- a/scripts/build-candidate.ts +++ b/scripts/build-candidate.ts @@ -2,6 +2,7 @@ import { mkdir, readFile, realpath, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { parseArgs } from "node:util"; import { folderStateSchemas } from "../src/folder/state"; +import { identityDefines, nativeTarget } from "../src/update/identity"; import { artifactIdentity } from "./artifact-identity"; import { candidateTarget } from "./candidate-target"; @@ -66,6 +67,13 @@ await run([ ...(target.bunTarget ? [`--target=${target.bunTarget}`] : []), "--no-compile-autoload-dotenv", "--no-compile-autoload-bunfig", + // The executable states its own identity (docs/update.md "Identity in the + // binary"); the same three values go into identity.json below. + ...identityDefines({ + version: pkg.version, + commit: sourceCommit, + target: target.target, + }), "--outfile", binary, ]); @@ -87,6 +95,26 @@ const identity = artifactIdentity({ lockfile, artifact: await readFile(binary), }); +// identity.json and the embedded identity must be one fact. A native build is +// asked directly; a cross-build cannot run here and is covered by the shared +// `identityDefines` input plus the target's own qualification. +if (target.target === nativeTarget(process.platform, process.arch)) { + const reported = Bun.spawnSync([binary, "--version", "--json"], { + env: {}, + stdout: "pipe", + stderr: "pipe", + }); + const embedded = + reported.exitCode === 0 ? JSON.parse(reported.stdout.toString()) : null; + if ( + embedded?.version !== identity.version || + embedded?.commit !== identity.sourceCommit || + embedded?.target !== identity.target + ) + throw new Error( + "Embedded identity differs from identity.json; retained output is not a qualified candidate", + ); +} await writeFile( join(output, "identity.json"), `${JSON.stringify( diff --git a/scripts/smoke-candidate.ts b/scripts/smoke-candidate.ts index 43945ff1..68d8d205 100644 --- a/scripts/smoke-candidate.ts +++ b/scripts/smoke-candidate.ts @@ -2,7 +2,8 @@ import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import { mkdtemp, readFile, realpath, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { isAbsolute, join } from "node:path"; +import { dirname, isAbsolute, join } from "node:path"; +import { parseIdentity } from "../src/update/identity"; // Tests the supplied bytes, never rebuilds or activates an installed product. // POSIX fixture smoke only; not Windows, browser UI or TUF qualification. @@ -40,6 +41,21 @@ const cli = async (args: string[]) => { } }; try { + // The executable states its own identity; beside a candidate build it must + // be the same fact as identity.json (docs/update.md "Identity in the binary"). + const embedded = parseIdentity(await cli(["--version", "--json"])); + const declared = await readFile( + join(dirname(binary), "identity.json"), + "utf8", + ).catch(() => undefined); + if (declared !== undefined) { + const { identity } = JSON.parse(declared); + assert.deepEqual(embedded, { + version: identity.version, + commit: identity.sourceCommit, + target: identity.target, + }); + } const choices = [ "--access", "local", diff --git a/src/cli.ts b/src/cli.ts index dd33298a..4758f599 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -20,10 +20,25 @@ import { localApplicationAdapters } from "./modules/local-application-adapters"; import { processGuardCommand, runProcessGuard } from "./modules/process-guard"; import { inspectOrganizationConversion } from "./organizations/inspect-conversion"; import { readOrganizationApplications } from "./organizations/read-applications"; +import { + type CommandOutput, + runUpdateCommand, + updateHelp, + versionCommand, +} from "./update/cli"; + +// Update commands return their own typed output and stable exit status. +function emit(output: CommandOutput): number { + if (output.stdout) console.log(output.stdout); + if (output.stderr) console.error(output.stderr); + return output.code; +} // Development CLI entrypoint. No implicit folder discovery; the only // installer surface is the explicit `product` command group. export async function runCli(args: string[]): Promise { + if (args[0] === "--version") return emit(versionCommand(args.slice(1))); + if (args[0] === "update") return emit(await runUpdateCommand(args.slice(1))); if (args[0] === "machine") { const { code, result } = await runMachineCommand(args.slice(1)); console.log(JSON.stringify(result)); @@ -154,6 +169,7 @@ No files, locks, provider requests or applications are created. Output may conta private Organization metadata: keep it in the owning scope, not public logs. This is not a migration writer or authority to apply the draft. Exit 0 draft, 2 blocked.`); console.log(productHelp); + console.log(updateHelp); console.log(machineHelp); return 0; } diff --git a/src/update/base.ts b/src/update/base.ts new file mode 100644 index 00000000..d63fcc04 --- /dev/null +++ b/src/update/base.ts @@ -0,0 +1,22 @@ +import { isAbsolute, join } from "node:path"; + +/** Per-user install base outside any Lazurio Folder (docs/release-cycle.md): + * macOS `~/Library/Application Support/Lazurio`, Linux + * `${XDG_DATA_HOME:-~/.local/share}/lazurio`. Pure resolution; nothing here + * inspects or creates a path. Other platforms get their own design first. + */ +export function resolveInstallBase(input: { + platform: string; + env: Readonly>; + homedir: string | undefined; +}): string | undefined { + if (!input.homedir || !isAbsolute(input.homedir)) return undefined; + if (input.platform === "darwin") + return join(input.homedir, "Library", "Application Support", "Lazurio"); + if (input.platform !== "linux") return undefined; + // XDG: a relative XDG_DATA_HOME is invalid and must be ignored. + const xdg = input.env.XDG_DATA_HOME; + return xdg && isAbsolute(xdg) + ? join(xdg, "lazurio") + : join(input.homedir, ".local", "share", "lazurio"); +} diff --git a/src/update/cli.ts b/src/update/cli.ts new file mode 100644 index 00000000..88ae3c72 --- /dev/null +++ b/src/update/cli.ts @@ -0,0 +1,210 @@ +import { isAbsolute, resolve } from "node:path"; +import { parseArgs } from "node:util"; +import { DistributionTransport } from "../distribution/transport"; +import { readOwnedDeclarationBytes } from "../providers/owned-json"; +import { resolveInstallBase } from "./base"; +import { isUpdateChannel } from "./channel"; +import { type CheckResult, checkForUpdate } from "./check"; +import { + type ErrorContext, + exitUpdateAvailable, + exitUpToDate, + type UpdateErrorCode, + updateError, + updateErrors, +} from "./errors"; +import { embeddedIdentity, type ProductIdentity } from "./identity"; +import { type Observed, readObserved } from "./observed"; + +/** `lazurio update …` and `lazurio --version`: terminal surface of the one + * update core (docs/update.md "Surfaces"). Origins, channel and the bootstrap + * root are explicit in this build; defaults arrive with the publisher. + */ +export const updateHelp = `--version [--json] + Prints the version, source commit and target this executable was built with. +update --check --metadata-url --target-url + --channel [--bootstrap-root ] [--base ] + [--loopback-fixture] [--json] + Verifies signed metadata and the channel document, records verified trust and + rewrites the observation. Downloads and activates nothing. The bootstrap root + is accepted only while no trust exists and refused afterwards. + Exit 0 up to date, 10 update available; an error prints its stable code. +update status [--base ] [--json] + Prints the last observation without touching the network. +update + Download and activation are not implemented in this build (not-implemented).`; + +export type CommandOutput = Readonly<{ + code: number; + stdout: string; + stderr: string; +}>; + +const failure = ( + code: UpdateErrorCode, + context: ErrorContext = {}, +): CheckResult => + Object.freeze({ kind: "error", ...updateError(code, context) }); + +export function versionCommand( + args: readonly string[], + identity: ProductIdentity = embeddedIdentity(), +): CommandOutput { + if (args.length === 1 && args[0] === "--json") + return { code: 0, stdout: JSON.stringify(identity), stderr: "" }; + if (args.length !== 0) return render(failure("invalid-request"), false); + return { + code: 0, + stdout: `lazurio ${identity.version} (commit ${identity.commit}, target ${identity.target})`, + stderr: "", + }; +} + +function render(result: CheckResult, json: boolean): CommandOutput { + const code = + result.kind === "error" + ? updateErrors[result.code].exit + : result.kind === "available" + ? exitUpdateAvailable + : exitUpToDate; + if (json) return { code, stdout: JSON.stringify(result), stderr: "" }; + if (result.kind === "error") + return { code, stdout: "", stderr: `Update failed: ${result.code}` }; + return { + code, + stdout: + result.kind === "available" + ? `Update available: ${result.version} (channel sequence ${result.sequence}). Nothing was downloaded.` + : `Up to date: ${result.version}.`, + stderr: "", + }; +} + +function renderStatus(observed: Observed, json: boolean): CommandOutput { + if (json) return { code: 0, stdout: JSON.stringify(observed), stderr: "" }; + const lines = [ + `Status: ${observed.status}${observed.error ? ` (${observed.error.code})` : ""}`, + `Channel: ${observed.channel ?? "unknown"}`, + `Selected: ${observed.selected?.version ?? "none"}`, + `Available: ${observed.available?.version ?? "none"}`, + `Last authenticated check: ${observed.lastAuthenticatedCheckAt ?? "never"}`, + ]; + return { code: 0, stdout: lines.join("\n"), stderr: "" }; +} + +/** Never throws: every refusal is a typed result with a stable exit status. */ +export async function runUpdateCommand( + args: readonly string[], + environment: Readonly<{ + identity?: ProductIdentity; + clock?: () => Date; + platform?: string; + env?: Readonly>; + }> = {}, +): Promise { + let json = args.includes("--json"); + try { + const { values, positionals, tokens } = parseArgs({ + args: [...args], + strict: true, + allowPositionals: true, + tokens: true, + options: { + check: { type: "boolean" }, + json: { type: "boolean" }, + "metadata-url": { type: "string" }, + "target-url": { type: "string" }, + channel: { type: "string" }, + "bootstrap-root": { type: "string" }, + base: { type: "string" }, + "loopback-fixture": { type: "boolean" }, + }, + }); + json = values.json === true; + const supplied = new Set(); + for (const token of tokens) { + if (token.kind !== "option") continue; + if (supplied.has(token.name)) + return render(failure("invalid-request"), json); + supplied.add(token.name); + } + const clock = environment.clock ?? (() => new Date()); + const env = environment.env ?? process.env; + const base = + values.base ?? + resolveInstallBase({ + platform: environment.platform ?? process.platform, + env, + homedir: env.HOME, + }); + if (!base || !isAbsolute(base) || resolve(base) !== base) + return render(failure("invalid-request", { option: "base" }), json); + const only = (allowed: readonly string[]) => + [...supplied].every((name) => allowed.includes(name)); + if (positionals.length === 1 && positionals[0] === "status") { + if (!only(["json", "base"])) + return render(failure("invalid-request"), json); + return renderStatus(await readObserved(base, clock()), json); + } + if (positionals.length !== 0) + return render(failure("invalid-request"), json); + if (values.check !== true) + // Explicit and typed: this build can only check. Nothing was attempted. + return render(failure("not-implemented", { step: "download" }), json); + const channel = values.channel; + if ( + !isUpdateChannel(channel) || + !values["metadata-url"] || + !values["target-url"] + ) + return render(failure("invalid-request"), json); + let transport: DistributionTransport; + try { + transport = new DistributionTransport( + [ + ...new Set([ + new URL(values["metadata-url"]).origin, + new URL(values["target-url"]).origin, + ]), + ], + 60_000, + new AbortController().signal, + values["loopback-fixture"] === true, + ); + } catch { + return render(failure("invalid-request", { option: "url" }), json); + } + let bootstrapRoot: Uint8Array | undefined; + if (values["bootstrap-root"] !== undefined) + try { + // A trust anchor is read only from a caller-owned, non-shared file. + bootstrapRoot = await readOwnedDeclarationBytes( + values["bootstrap-root"], + ); + } catch { + return render( + failure("invalid-request", { option: "bootstrap-root" }), + json, + ); + } + return render( + await checkForUpdate({ + base, + metadataBaseUrl: values["metadata-url"], + targetBaseUrl: values["target-url"], + channel, + identity: environment.identity ?? embeddedIdentity(), + ...(bootstrapRoot === undefined ? {} : { bootstrapRoot }), + transport, + clock, + }), + json, + ); + } catch (error) { + // `parseArgs` refusals are usage errors; anything else is a defect. + const usage = + typeof (error as { code?: unknown } | undefined)?.code === "string" && + String((error as { code: string }).code).startsWith("ERR_PARSE_ARGS"); + return render(failure(usage ? "invalid-request" : "internal"), json); + } +} diff --git a/tests/update-cli.test.ts b/tests/update-cli.test.ts new file mode 100644 index 00000000..5ba39f2b --- /dev/null +++ b/tests/update-cli.test.ts @@ -0,0 +1,159 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtemp, readdir, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createUpdateFixture } from "../scripts/update-fixture"; +import { runUpdateCommand } from "../src/update/cli"; +import { updateErrors } from "../src/update/errors"; + +const cleanups: (() => Promise)[] = []; +afterEach(async () => { + for (const cleanup of cleanups.splice(0)) await cleanup(); +}); + +async function scenario() { + const fixture = createUpdateFixture({ executionTarget: "linux-x64" }); + const root = await realpath(await mkdtemp(join(tmpdir(), "update-cli-"))); + cleanups.push(async () => { + await fixture.stop(); + await rm(root, { recursive: true }); + }); + const bootstrap = join(root, "root.json"); + await writeFile(bootstrap, fixture.bootstrapRoot, { mode: 0o600 }); + const base = join(root, "base"); + const environment = { + identity: { version: "1.0.0", commit: "b".repeat(40), target: "linux-x64" }, + clock: () => new Date("2026-09-19T10:00:00.000Z"), + }; + const origins = [ + "--metadata-url", + fixture.metadataBaseUrl, + "--target-url", + fixture.targetBaseUrl, + "--channel", + "stable", + "--base", + base, + "--loopback-fixture", + ]; + const run = (args: string[]) => runUpdateCommand(args, environment); + return { fixture, base, bootstrap, origins, run }; +} + +test("update --check exits 10 when an update is available, 0 when up to date, and status reads the observation offline", async () => { + const { fixture, base, bootstrap, origins, run } = await scenario(); + fixture.release("stable", { sequence: 1, version: "1.2.0" }); + const first = await run([ + "--check", + "--json", + "--bootstrap-root", + bootstrap, + ...origins, + ]); + expect(first.code).toBe(10); + expect(JSON.parse(first.stdout)).toMatchObject({ + kind: "available", + version: "1.2.0", + sequence: 1, + }); + const human = await run(["--check", ...origins]); + expect(human).toEqual({ + code: 10, + stdout: + "Update available: 1.2.0 (channel sequence 1). Nothing was downloaded.", + stderr: "", + }); + fixture.requests.length = 0; + const status = await run(["status", "--json", "--base", base]); + expect(status.code).toBe(0); + expect(JSON.parse(status.stdout)).toMatchObject({ + status: "available", + available: { version: "1.2.0" }, + }); + expect((await run(["status", "--base", base])).stdout).toContain( + "Status: available", + ); + expect(fixture.requests).toEqual([]); + const current = await runUpdateCommand(["--check", ...origins], { + identity: { version: "1.2.0", commit: "b".repeat(40), target: "linux-x64" }, + }); + expect(current).toEqual({ + code: 0, + stdout: "Up to date: 1.2.0.", + stderr: "", + }); +}); + +test("every refusal prints its stable code and exits with that code's distinct status", async () => { + const { fixture, base, bootstrap, origins, run } = await scenario(); + fixture.release("stable", { sequence: 1, version: "1.2.0" }); + // Plain `update` is explicit about what this build cannot do, and did + // nothing: no base was created. + expect(await run(["--base", base])).toEqual({ + code: 31, + stdout: "", + stderr: "Update failed: not-implemented", + }); + expect(await readdir(join(base, ".."))).not.toContain("base"); + expect(await run(["--check", ...origins])).toMatchObject({ + code: 25, + stderr: "Update failed: trust-missing", + }); + await run(["--check", "--bootstrap-root", bootstrap, ...origins]); + const conflict = await run([ + "--check", + "--json", + "--bootstrap-root", + bootstrap, + ...origins, + ]); + expect(conflict.code).toBe(26); + expect(JSON.parse(conflict.stdout)).toEqual({ + kind: "error", + code: "trust-conflict", + context: {}, + }); + await fixture.stop(); + expect(await run(["--check", ...origins])).toMatchObject({ + code: 20, + stderr: "Update failed: network-unavailable", + }); + for (const args of [ + ["--check"], + ["--check", ...origins, "--channel", "stable"], + [ + "--check", + ...origins.map((value) => (value === "stable" ? "pilot" : value)), + ], + ["--check", ...origins.filter((value) => value !== "--loopback-fixture")], + ["--check", "--bootstrap-root", join(base, "absent"), ...origins], + [ + "--check", + ...origins.map((value) => (value === base ? "relative" : value)), + ], + ["status", "--check", "--base", base], + ["status", "extra"], + ["rollback"], + ["--unknown"], + ]) + expect(await run(args)).toMatchObject({ + code: 2, + stderr: "Update failed: invalid-request", + }); + // Exit statuses are a contract: distinct per code and never 0 or 10. + const statuses = Object.values(updateErrors).map((entry) => entry.exit); + expect(new Set(statuses).size).toBe(statuses.length); + expect(statuses.some((status) => [0, 10].includes(status))).toBe(false); +}); + +test("status without any state reports a rebuilt idle observation and creates nothing", async () => { + const { base, run } = await scenario(); + const status = await run(["status", "--json", "--base", base]); + expect(status.code).toBe(0); + expect(JSON.parse(status.stdout)).toMatchObject({ + status: "idle", + operationId: null, + selected: null, + }); + expect(await readdir(join(base, ".."))).not.toContain("base"); +}); diff --git a/tests/update-identity.test.ts b/tests/update-identity.test.ts new file mode 100644 index 00000000..fd9a833a --- /dev/null +++ b/tests/update-identity.test.ts @@ -0,0 +1,113 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { versionCommand } from "../src/update/cli"; +import { + developmentCommit, + developmentVersion, + embeddedIdentity, + identityDefines, + nativeTarget, + parseIdentity, +} from "../src/update/identity"; + +test("a source run has the development identity, which sorts below every release", () => { + expect(embeddedIdentity()).toEqual({ + version: developmentVersion, + commit: developmentCommit, + target: nativeTarget(process.platform, process.arch), + }); + expect(versionCommand([]).stdout).toBe( + `lazurio ${developmentVersion} (commit ${developmentCommit}, target ${nativeTarget(process.platform, process.arch)})`, + ); + expect(versionCommand(["--unknown"]).code).toBe(2); +}); + +test("identity values are validated before they are embedded or reported", () => { + const valid = { + version: "1.2.3", + commit: "a".repeat(40), + target: "linux-x64", + }; + expect(parseIdentity(valid)).toEqual(valid); + for (const broken of [ + { ...valid, version: "1.2" }, + { ...valid, version: undefined }, + { ...valid, commit: "abc" }, + { ...valid, target: "linux x64" }, + ]) + expect(() => parseIdentity(broken)).toThrow(); + expect(identityDefines(valid)).toEqual([ + "--define", + 'LAZURIO_BUILD_VERSION="1.2.3"', + "--define", + `LAZURIO_BUILD_COMMIT="${"a".repeat(40)}"`, + "--define", + 'LAZURIO_BUILD_TARGET="linux-x64"', + ]); +}); + +// The real CLI entrypoint compiled with the same defines the candidate build +// passes (`identityDefines`), then asked as a standalone executable. +test.skipIf(process.platform === "win32")( + "a compiled executable reports exactly the identity it was built with", + async () => { + const directory = await realpath( + await mkdtemp(join(tmpdir(), "update-identity-")), + ); + try { + const identity = { + version: "3.1.4-rc.2", + commit: "0123456789abcdef0123456789abcdef01234567", + target: nativeTarget(process.platform, process.arch), + }; + const binary = join(directory, "lazurio"); + const build = Bun.spawnSync( + [ + process.execPath, + "build", + new URL("../src/cli.ts", import.meta.url).pathname, + "--compile", + "--no-compile-autoload-dotenv", + "--no-compile-autoload-bunfig", + ...identityDefines(identity), + "--outfile", + binary, + ], + { stdout: "pipe", stderr: "pipe" }, + ); + expect(build.exitCode).toBe(0); + const run = (args: string[]) => { + const child = Bun.spawnSync([binary, ...args], { + cwd: directory, + env: {}, + stdout: "pipe", + stderr: "pipe", + }); + return { + code: child.exitCode, + stdout: child.stdout.toString().trim(), + stderr: child.stderr.toString().trim(), + }; + }; + expect(run(["--version", "--json"])).toEqual({ + code: 0, + stdout: JSON.stringify(identity), + stderr: "", + }); + expect(run(["--version"]).stdout).toBe( + `lazurio 3.1.4-rc.2 (commit ${identity.commit}, target ${identity.target})`, + ); + // The same executable states plainly what it cannot do yet. + expect(run(["update", "--base", join(directory, "base")])).toEqual({ + code: 31, + stdout: "", + stderr: "Update failed: not-implemented", + }); + } finally { + await rm(directory, { recursive: true }); + } + }, + 60_000, +); From b2af76f9199c1078cf893274b19184098867d31f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Sat, 19 Sep 2026 02:39:23 +0200 Subject: [PATCH 06/11] Check: unreadable channel floors refuse the channel decision, not TUF promotion; exact lock.ts references Floors are read after promotion, so a floors file of a newer schema still lets a verified root rotation become durable. The TODO in src/update/lock.ts now names the exact lines of src/folder/lock.ts that prevent sharing it. Co-Authored-By: Claude Fable 5.1 --- src/update/check.ts | 4 +++- src/update/lock.ts | 15 ++++++++++----- tests/update-check.test.ts | 6 +++++- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/update/check.ts b/src/update/check.ts index 4a3dcbf6..36fd3599 100644 --- a/src/update/check.ts +++ b/src/update/check.ts @@ -252,7 +252,6 @@ async function checkUnderLock( await removeAbandonedTemporaries(trustDirectory); await removeAbandonedTemporaries(updateDirectory); const seed = await readSeed(trustDirectory, input.bootstrapRoot); - const floors = await readChannelFloors(trustDirectory); await mkdir(scratch, { mode: 0o700 }); await seedScratch(scratch, seed); let refreshed = false; @@ -279,6 +278,9 @@ async function checkUnderLock( } if (failure || !received) return { failure: failure ?? updateError("internal") }; + // Read only now: unreadable floors refuse the channel decision, but they + // must not stop verified TUF roles above from becoming durable. + const floors = await readChannelFloors(trustDirectory); const document = parseChannelDocument(received.bytes, input.channel); assertNotRolledBack(document, floors[input.channel]); try { diff --git a/src/update/lock.ts b/src/update/lock.ts index 4f2db83c..67aeb79d 100644 --- a/src/update/lock.ts +++ b/src/update/lock.ts @@ -12,12 +12,17 @@ import { UpdateFailure } from "./errors"; * * TODO(docs/update.md, lock paragraph): converge with `src/folder/lock.ts` in * its own change. That helper cannot serve this contract today because it - * (a) refuses every filesystem except APFS and ext (lock.ts:15-20), + * (a) refuses every filesystem except APFS and ext (lock.ts:14-19), * (b) wedges forever when a process dies between `mkdir` of the lock - * directory and the durable `protocol` marker (lock.ts:22-33, 58-68), and - * equally on any stray entry inside it (lock.ts:72-76), and - * (c) never waits: a concurrent holder is an immediate failure - * (native-lock.ts:18). + * directory (lock.ts:23) and the durable `protocol` marker + * (lock.ts:57-67): every later acquisition takes the "unrecognized lock" + * refusal at lock.ts:29-32, deliberately ("never adopted on retry", + * lock.ts:58). A second process that arrives inside that same window + * while the first is alive and healthy gets the same hard refusal. Any + * stray entry in the directory, or a half-written marker, refuses equally + * (lock.ts:70-74, 84-92), and + * (c) never waits: `flock` is called with LOCK_NB only, so a concurrent + * holder is an immediate failure (native-lock.ts:16-18). * * This acquisition has no initialization to interrupt: `O_CREAT` of one * regular file is atomic and idempotent, the file's content is never read, and diff --git a/tests/update-check.test.ts b/tests/update-check.test.ts index b0c1b437..7e1cd506 100644 --- a/tests/update-check.test.ts +++ b/tests/update-check.test.ts @@ -610,18 +610,22 @@ test("unknown entries in the base, in trust/ and in update/ never stop a check a }); test("owned security state of a newer schema is refused, not guessed", async () => { - const { fixture, base, check } = await scenario(); + const { fixture, base, check, roleVersion } = await scenario(); fixture.release("stable", { sequence: 1, version: "1.2.0" }); await check({ bootstrapRoot: fixture.bootstrapRoot }); await writeFile( join(base, "trust", "channel-floors.json"), '{"schemaVersion":2,"floors":[]}', ); + fixture.rotateRoot(); expect(await check()).toMatchObject({ kind: "error", code: "trust-invalid", context: { subject: "channel-floors", reason: "newer-schema" }, }); + // The channel decision is refused; verified TUF trust still moved forward. + expect(await roleVersion("root.json")).toBe(2); + expect(await roleVersion("targets.json")).toBe(2); }); test("two concurrent checks serialize on the lock and neither corrupts trust; a held lock times out as busy", async () => { From 5eb2e56e36b99533fce5547e838492b6cde4333e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Sat, 19 Sep 2026 03:37:04 +0200 Subject: [PATCH 07/11] Update contract: explicit authority boundary with superseded banners; durable capture of authenticated failure-state floors Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 5 +-- docs/expired-trust-recovery.md | 4 +++ docs/machine-handover.md | 3 +- docs/pilot-repair.md | 4 +++ docs/release-cycle.md | 4 +++ docs/update.md | 63 ++++++++++++++++++++++++++-------- 6 files changed, 65 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7dad69ce..f96f400d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,8 +11,9 @@ content, history and artifact contents before sharing. Follow `docs/public-devel Do not invent a secret store or hide harmless implementation details. Read `ARCHITECTURE.md`, `docs/decisions.md`, `docs/migration-and-recovery.md` and -`docs/acceptance.md` before implementation. Read `docs/release-cycle.md` for the -two distinct test/activation paths, `docs/profile-evidence.md` for optional measurement +`docs/acceptance.md` before implementation. Read `docs/update.md`, the accepted +contract for product update, before any distribution work, and `docs/release-cycle.md` +for the build, qualification and promotion lifecycle and the two distinct test paths, `docs/profile-evidence.md` for optional measurement and `docs/hosted-assistance.md` before any hosted advice/execution work. Read `docs/content-sync.md`, `docs/workspace-presets.md` and `docs/hosted-entry.md` before work on repository synchronization, presets or hosted entry; they record accepted diff --git a/docs/expired-trust-recovery.md b/docs/expired-trust-recovery.md index daac1398..77399797 100644 --- a/docs/expired-trust-recovery.md +++ b/docs/expired-trust-recovery.md @@ -1,5 +1,9 @@ # Expired pending trust: implementation work package +> **Superseded for product update (2026-09-19).** The accepted contract for +> checking, downloading, activating and rolling back a product version is +> [product update](update.md). The journal-reconstruction work package below is not continued. Its threat analysis of authenticated-but-unpersisted role floors stays valid and is answered by the Check step of the new contract. + Status: deferred automatic-recovery work, not a prerequisite for the controlled pilot (scope decision 2026-09-15; see [pilot repair](pilot-repair.md)). Stop expanding this work package for pilot readiness. Preserve completed safety checks and retained diff --git a/docs/machine-handover.md b/docs/machine-handover.md index a2954000..04b3397f 100644 --- a/docs/machine-handover.md +++ b/docs/machine-handover.md @@ -106,7 +106,8 @@ edited output, damaged journal, partial write without a receipt or abandoned loc requires operator diagnosis. No automatic cleanup or universal recovery is promised. Recreating a disposable VM requires separate infrastructure approval. -Product update repair follows [pilot repair](pilot-repair.md): retain accepted TUF +Product update follows the [product update contract](update.md); until it is +implemented the pilot installer's [repair runbook](pilot-repair.md) applies: retain accepted TUF trust and the previous working product. Folder handover does not change that rule. The local filesystem boundary assumes no hostile concurrent same-user/root directory replacement; ownership checks are not a sandbox. diff --git a/docs/pilot-repair.md b/docs/pilot-repair.md index 05c272e5..7c73d3d4 100644 --- a/docs/pilot-repair.md +++ b/docs/pilot-repair.md @@ -1,5 +1,9 @@ # Controlled pilot: bounded update failure and explicit repair +> **Superseded for product update (2026-09-19).** The accepted contract for +> checking, downloading, activating and rolling back a product version is +> [product update](update.md). This runbook and `product recover` describe the pilot installer that is still the code on `main`; they are not the target mechanism. The preservation principles below (prepare beside the active version, never reset trusted metadata, never bootstrap an established installation again) remain binding. + Accepted scope decision, 2026-09-15. This replaces a requirement to finish general automatic expired/inconsistent-transcript recovery before the first usable pilot. It is a contract and runbook boundary, not evidence that all scenarios already pass. diff --git a/docs/release-cycle.md b/docs/release-cycle.md index e7d9680a..99969754 100644 --- a/docs/release-cycle.md +++ b/docs/release-cycle.md @@ -1,5 +1,9 @@ # Build, qualification and activation lifecycle +> **Superseded for product update (2026-09-19).** The accepted contract for +> checking, downloading, activating and rolling back a product version is +> [product update](update.md). This document stays binding for the build, qualification and promotion lifecycle, the two test modes, install location and the signing, key-role and expiry requirements. Its update command proposals, pending attempts, metadata journal and replay, and the double active record describe the pilot installer still on `main` and are superseded as the target. + ## Controlled-pilot scope decision — 2026-09-15 Preparation is side-by-side; failed preparation preserves the currently usable diff --git a/docs/update.md b/docs/update.md index ca59348e..fbdca816 100644 --- a/docs/update.md +++ b/docs/update.md @@ -2,11 +2,22 @@ Status: **accepted direction of the Principal (2026-09-19), implementation in progress.** This document is the single contract for how an installed Lazurio -learns about, obtains and activates a new product version. It supersedes the -update-related *proposals* in [release cycle](release-cycle.md) and the deferred -recovery designs in [pilot repair](pilot-repair.md) and -[expired trust recovery](expired-trust-recovery.md); trust, signing and promotion -requirements stated there remain binding. Nothing is deployed to real clients on +learns about, obtains and activates a new product version. + +Authority boundary. Three older documents described this area and each now +carries a banner pointing here: + +| Document | What stays binding there | What this contract supersedes | +| --- | --- | --- | +| [release cycle](release-cycle.md) | build, qualification and promotion lifecycle; the two test modes; signing, key-role, expiry and promotion requirements; install location | the proposed update commands, pending attempts, the metadata journal and replay, the double active record, "draining" as an unimplemented phase | +| [pilot repair](pilot-repair.md) | the preservation principles: prepare beside the active version, never delete or reset trusted metadata, never bootstrap an established installation again | the `product recover` runbook and the bounded manual repair procedure | +| [expired trust recovery](expired-trust-recovery.md) | its threat analysis of accepted-but-unpersisted role floors, which the Check step below answers | the deferred journal-reconstruction work package as a mechanism | + +Until the implementing changes replace it, the code on `main` is still the pilot +installer (`product install|recover|status|activate`) with its journal and +replay. That code is the *current implementation*, this document is the +*accepted target*; the old mechanism is deleted only by the change that proves +the equivalence tests listed under Evidence, never by this document alone. Nothing is deployed to real clients on this codebase yet, so this contract is written without a compatibility burden. After the first client deployment every change to it must be compatible. @@ -97,15 +108,35 @@ logs only explain it. ## The three steps **Check** (automatic, cheap, no mutation of the product). The TUF client -refreshes in a scratch copy seeded from `trust/`; it writes a role file only -after verifying it. When the refresh ends — success or failure — every newly -verified role is promoted into `trust/` by temporary file, file sync, rename and -directory sync, root chain first, then timestamp, snapshot, targets. A crash -before promotion equals a refresh that never ran; a crash between promotions -leaves a newer root with older roles, which the next refresh re-verifies under -that root. This needs no change to the pinned `tuf-js` and is proven by -interruption, expiry, repository-advance and key-rotation tests before the old -journal and replay code is deleted. Then read the signed channel document, +refreshes in a scratch copy seeded from `trust/`. When the refresh ends — +success or failure — trust is captured durably in two ways, by temporary file, +file sync, rename and directory sync, root chain first, then timestamp, +snapshot, targets: + +1. **Roles the client persisted.** The pinned `tuf-js` writes a role file only + after its store accepted it (root: after signature and version checks, before + expiry; the others: after all checks). Those files are promoted. +2. **Authenticated failure-state floors.** The client also authenticates a newer + timestamp or snapshot *and then throws* when that role is expired; it keeps + the role only in memory as its rollback floor and never writes it. Losing it + would let a later, lower version pass. The fetcher therefore retains the raw + bytes of every role it delivered; after a failed refresh the role delivered + last is re-verified independently of the client — signature threshold under + the promoted root, version not below the trusted one, and for a snapshot the + version and hash recorded by the trusted timestamp — with expiry deliberately + ignored. A role that passes is promoted **as a floor only**. Anything that + fails re-verification is discarded. + +Expired metadata never authorizes a target: every refresh re-checks expiry +before accepting the next role, and an expired local role is loaded only as a +version floor, exactly as the TUF specification's intermediate metadata. + +A crash before promotion equals a refresh that never ran; a crash between +promotions leaves a newer root with older roles, which the next refresh +re-verifies under that root. This needs no change to the pinned `tuf-js`. The +old journal and replay remain the mechanism until the equivalence tests under +Evidence — interruption, expiry with a newer authenticated floor, repository +advance and key rotation — pass against this path. Then read the signed channel document, compare with the embedded version and rewrite `observed.json`. Network or expiry failures produce a typed error and leave everything else untouched. @@ -254,7 +285,9 @@ of every combination. ## Evidence required before any Machine depends on this CI builds two real versions and proves, against a fixture repository and then -natively on Linux (x64, ARM64) and macOS ARM64: A→B, confirmed restart, automatic +natively on Linux (x64, ARM64) and macOS ARM64 (including the floor case: an +authenticated newer-but-expired timestamp and snapshot are retained and a later +lower version is refused): A→B, confirmed restart, automatic rollback of an unhealthy B, explicit retry, `kill -9` of the worker and reboot at every step of every phase, expired timestamp, repository advancing mid-update, root rotation accepted during a failed download and retained, full disk, unknown From 278c44958547e82c078089ed8f3f7b870ad0f86d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Sat, 19 Sep 2026 04:06:08 +0200 Subject: [PATCH 08/11] Check: capture authenticated failure-state floors instead of withholding the last-delivered role The pinned tuf-js authenticates a newer timestamp or snapshot, installs it in memory and only then throws on expiry (store.js:89-91, :133-136); the Updater never persists it, so the rollback floor died with the process and a later replay of a lower, still valid version passed. The fetcher now retains the raw bytes of the role delivered last; after a failed refresh promotion re-verifies it without the client and without expiry (signature threshold under the promoted root, version rules of the store, and for a snapshot the length, hashes and version recorded by the newest authenticated timestamp) and writes it as the ordinary role file. An expired local timestamp or snapshot is only a version floor for the client (updater.js:195-206, :230-234); expired targets carry no floor (store.js:172-175) and are never captured. Expired metadata still authorizes nothing. Tests: expired newer timestamp and snapshot are kept, a replay of the last valid lower generation is refused (for the snapshot even with the timestamp file lost), a fresh higher generation is accepted, unauthentic or not-newer roles are discarded, and an interrupted capture converges. Co-Authored-By: Claude Fable 5.1 --- docs/update.md | 24 ++++- scripts/update-fixture.ts | 14 +++ src/update/check.ts | 2 +- src/update/trust-fetcher.ts | 21 +++-- src/update/trust.ts | 172 +++++++++++++++++++++++++++++++----- tests/update-check.test.ts | 145 +++++++++++++++++++++++++++--- tests/update-trust.test.ts | 172 +++++++++++++++++++++++++++--------- 7 files changed, 465 insertions(+), 85 deletions(-) diff --git a/docs/update.md b/docs/update.md index 77e59e8e..153266f2 100644 --- a/docs/update.md +++ b/docs/update.md @@ -317,7 +317,29 @@ Concrete choices this slice fixed: expiry; a root is persisted after complete authenticity verification but before the final-root expiry check, and is promoted regardless because a signed successor root must never be forgotten. Promotion re-verifies the root - chain itself and withholds the role delivered last in a failed refresh. + chain itself. +- **Authenticated failure-state floors.** The earlier rule "withhold the role + delivered last in a failed refresh" is replaced. The fetcher retains the raw + bytes of the role delivered last (only that one has a consumer); after a + failed refresh promotion re-verifies it without the client and without + expiry — timestamp: signature threshold under the promoted root, version + above and snapshot version not below the trusted timestamp; snapshot: length + and hashes recorded by the newest authenticated timestamp, signature + threshold, exactly the version that timestamp names, no targets version below + the trusted snapshot — and writes it as the ordinary role file. Verified in + the pinned source, with line references in `src/update/trust.ts`: an expired + LOCAL timestamp or snapshot is installed in memory before the expiry throw + and the throw is swallowed (`store.js:89-91`, `:133-136`; + `updater.js:195-206`, `:230-234`), so it acts as a version floor and nothing + else, and the final expiry checks (`store.js:102`, `:145`, `:172`) run again + before any target is looked up. Expired **targets** are different: the store + checks expiry before installing (`:172-175`), so an expired local targets + file is discarded without a trace and the floor of the targets version is the + snapshot's `meta`. A targets role delivered last is therefore never captured. + The floor is also captured on first contact and under an expired root: the + root authenticated a signature, and a floor can only raise the bar. A floor + that no longer verifies under a later root is not loaded by the client, so a + revoked key cannot wedge a Machine with a fast-forwarded version. - **First trust.** The caller supplies the bootstrap root (later: the compiled-in root). It becomes durable only together with the first role it verified, so a wrong root can never wedge an installation; once `trust/` holds diff --git a/scripts/update-fixture.ts b/scripts/update-fixture.ts index c318ce1d..3e158f2c 100644 --- a/scripts/update-fixture.ts +++ b/scripts/update-fixture.ts @@ -73,6 +73,7 @@ export function createUpdateFixture(input: { const blocked = new Map(); const requests: string[] = []; const channels = new Map(); + const timestamps = new Map(); let rootVersion = 0; let version = 0; @@ -207,6 +208,7 @@ export function createUpdateFixture(input: { options.tamper === "snapshot" ? damaged(snapshot) : snapshot, ); served.set("/metadata/timestamp.json", timestamp); + timestamps.set(version, timestamp); }; const bootstrapRoot = publishRoot({}); @@ -232,6 +234,14 @@ export function createUpdateFixture(input: { targetBaseUrl: `${server.url}targets/`, origin: server.url.origin, requests, + /** Serve the timestamp of an OLDER generation again: what an attacker + * replaying a once-valid repository state would do. Every numbered object + * of that generation is still served. */ + rewindTo(generation: number) { + const timestamp = timestamps.get(generation); + if (!timestamp) throw new Error("Unknown fixture generation"); + served.set("/metadata/timestamp.json", timestamp); + }, /** Set a channel's document and publish a new generation. */ release( channel: "stable" | "preview", @@ -259,6 +269,10 @@ export function createUpdateFixture(input: { blocked.delete(path); }, served: (path: string) => served.get(path), + /** Serve other bytes under an existing path. */ + substitute(path: string, bytes: Buffer) { + served.set(path, bytes); + }, async stop() { await server.stop(true); }, diff --git a/src/update/check.ts b/src/update/check.ts index 36fd3599..2d9daeb4 100644 --- a/src/update/check.ts +++ b/src/update/check.ts @@ -270,7 +270,7 @@ async function checkUnderLock( scratch, seed, fetchedRoots: fetcher.roots, - unsettledFile: refreshed ? undefined : fetcher.unsettledFile, + lastDelivered: refreshed ? undefined : fetcher.lastDelivered, write, }); } catch (error) { diff --git a/src/update/trust-fetcher.ts b/src/update/trust-fetcher.ts index db5ec09e..84dfef33 100644 --- a/src/update/trust-fetcher.ts +++ b/src/update/trust-fetcher.ts @@ -7,14 +7,22 @@ export type TransferFailure = Readonly<{ httpStatus?: number; }>; +/** File name as kept in `trust/` (`timestamp.json`, `3.root.json`) and the + * bytes exactly as received. */ +export type DeliveredRole = Readonly<{ file: string; bytes: Buffer }>; + /** Observes one refresh from the outside; it never alters a response. * * - It keeps the numbered roots the client received. The client overwrites * its single `root.json` for every link of a rotation, but `trust/` retains * the whole chain. The kept bytes are UNTRUSTED until promotion re-verifies * them as a chain (`promoteVerified`). - * - It knows which role was delivered last without a later request having - * started: that role's verification is not known to have completed. + * - It retains the RAW BYTES of the role delivered last without a later + * request having started. When a refresh fails, that is the role the client + * was judging: it may have authenticated it and thrown only on expiry + * (store.js:89-91, :133-136), keeping it in memory as its rollback floor and + * never writing it. Promotion re-verifies those bytes by itself + * (`promoteVerified`); they are UNTRUSTED here. * - It remembers the last failed transfer, so a network failure is classified * from the transport, not by parsing the client's wrapped error messages. * @@ -22,7 +30,7 @@ export type TransferFailure = Readonly<{ */ export class TrustFetcher implements Fetcher { readonly roots = new Map(); - private delivered: string | undefined; + private delivered: DeliveredRole | undefined; private failure: TransferFailure | undefined; constructor( @@ -30,8 +38,8 @@ export class TrustFetcher implements Fetcher { private readonly metadataBaseUrl: string, ) {} - /** File name (e.g. `snapshot.json`, `3.root.json`) or undefined. */ - get unsettledFile(): string | undefined { + /** The role delivered last with no later request started, or undefined. */ + get lastDelivered(): DeliveredRole | undefined { return this.delivered; } @@ -81,8 +89,9 @@ export class TrustFetcher implements Fetcher { throw error; } this.failure = undefined; - this.delivered = file; const bytes = received(value); + this.delivered = + file !== undefined && bytes ? Object.freeze({ file, bytes }) : undefined; const root = /^([1-9]\d*)\.root\.json$/.exec(file ?? ""); // Same decoding the client applies before it parses and persists // (updater.js:366, store.js:35). diff --git a/src/update/trust.ts b/src/update/trust.ts index 3f6893ab..508f22dd 100644 --- a/src/update/trust.ts +++ b/src/update/trust.ts @@ -1,6 +1,12 @@ import { lstat, mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { Metadata, MetadataKind, type Root } from "@tufjs/models"; +import { + Metadata, + MetadataKind, + type Root, + type Snapshot, + type Timestamp, +} from "@tufjs/models"; import { parseUniqueJson } from "../providers/unique-json"; import { type ChannelDocument, @@ -66,11 +72,42 @@ import { UpdateFailure } from "./errors"; * `verifiedRootChain` re-verifies every link with the same `@tufjs/models` * primitives before a single root byte reaches `trust/`. * - * For the other roles promotion adds one mechanical guard that does not depend - * on reading the library correctly: the client fetches strictly sequentially, - * so a role's phase is known to have completed when a LATER request started or - * the refresh resolved. The role delivered last in a refresh that then failed - * is "unsettled" and is never promoted (see `TrustFetcher.unsettledFile`). + * AUTHENTICATED FAILURE-STATE FLOORS (docs/update.md "Check", item 2). The + * statement "persisted only after complete verification" has a consequence: + * a NEWER timestamp or snapshot that is authentic but EXPIRED never reaches + * scratch. The store authenticates it, installs it in memory and only then + * throws — timestamp: signature store.js:68, rollback :72-85, installed :89, + * expiry thrown from :91 (→ :196-197); snapshot: hash :108, signature :118, + * rollback :122-131, installed :133, expiry/version thrown from :136 + * (→ :209-215) — and `Updater` never reaches `persistMetadata` + * (updater.js:225, :251). For the life of that process the role is the + * client's rollback floor; on disk it would be lost, and a later replay of a + * LOWER, still valid version would pass. `capturedFloor` below closes that + * gap from the raw bytes the fetcher retained. + * + * Why a plain role file in `trust/` is a floor and nothing more — how the + * pinned client loads an expired LOCAL role: + * - timestamp: updater.js:195-206 calls `updateTimestamp(local)` inside + * `try { … } catch { /* continue *\/ }`. store.js:89 installs the local + * timestamp BEFORE :91 throws on expiry, and the throw is swallowed, so the + * expired local timestamp stays in memory. The remote timestamp is then + * refused when its version (:72) or its snapshot version (:83) is lower, + * and is a no-op when equal (:76 → updater.js:218-219). With an equal, + * still expired timestamp the refresh ends at the snapshot step, because + * store.js:102 re-checks the final timestamp's expiry first. + * - snapshot: updater.js:230-233 calls `updateSnapshot(local, true)` inside a + * `try`; store.js:133 installs it BEFORE :136 throws (expired, or not the + * version the timestamp names), the catch at updater.js:234 fetches the + * remote snapshot, and store.js:122-131 refuses a remote snapshot whose + * targets version is lower than the installed local one. + * - targets: store.js:172-175 checks expiry BEFORE installing, so an expired + * local targets file is discarded without a trace. It carries no floor of + * its own in this client: the floor of the targets version is the snapshot's + * `meta` (:128). A targets role delivered last in a failed refresh is + * therefore never captured — nothing would be gained, and nothing expired + * may sit where a target could be authorized from. + * In every path the final expiry checks (:102, :145, :172) run again before + * a target is looked up, so expired metadata never authorizes a target. */ export const roleFiles = [ "timestamp.json", @@ -188,8 +225,98 @@ function verifiedRootChain( return chain; } -/** Promote what this refresh newly verified. Called when the refresh ENDS — - * success or failure — and before the scratch is deleted. +/** A role the client was judging when the refresh failed, re-verified WITHOUT + * the client and WITHOUT looking at expiry: is it an authentic, newer version + * that must be remembered as a rollback floor? Returns the text to keep, or + * undefined to discard. Every check the store makes before it installs the + * role in memory is repeated here with the same `@tufjs/models` primitives: + * + * timestamp signature threshold of the trusted root's timestamp role + * (store.js:68); version and snapshot version not below the + * trusted timestamp's (:72, :83). An equal version is nothing new. + * snapshot length and hashes recorded by the newest authenticated + * timestamp (:108), signature threshold (:118), exactly the + * version that timestamp names (:214), and no targets version + * below the trusted snapshot's (:122-131). + */ +function capturedFloor(input: { + delivered: Readonly<{ file: string; bytes: Buffer }>; + root: Metadata; + /** Text of the role as currently trusted (seed), if any. */ + trusted: string | undefined; + /** Newest authenticated timestamp: the client's, or a just captured floor. */ + timestamp: string | undefined; +}): string | undefined { + const { delivered, root } = input; + try { + const text = new TextDecoder("utf-8", { fatal: true }).decode( + delivered.bytes, + ); + const json = (source: string) => + parseUniqueJson(source) as Parameters[1]; + const timestampOf = (source: string): Metadata => { + const metadata = Metadata.fromJSON(MetadataKind.Timestamp, json(source)); + if (metadata.signed.type !== MetadataKind.Timestamp) + throw new Error("Not a timestamp"); + root.verifyDelegate(MetadataKind.Timestamp, metadata); + return metadata; + }; + const snapshotOf = (source: string): Metadata => { + const metadata = Metadata.fromJSON(MetadataKind.Snapshot, json(source)); + if (metadata.signed.type !== MetadataKind.Snapshot) + throw new Error("Not a snapshot"); + root.verifyDelegate(MetadataKind.Snapshot, metadata); + return metadata; + }; + // A trusted file that no longer verifies under this root is no floor. + const orNone = (read: (source: string) => T, source?: string) => { + try { + return source === undefined ? undefined : read(source); + } catch { + return undefined; + } + }; + if (delivered.file === "timestamp.json") { + const next = timestampOf(text); + const trusted = orNone(timestampOf, input.trusted); + if ( + trusted && + (next.signed.version <= trusted.signed.version || + next.signed.snapshotMeta.version < + trusted.signed.snapshotMeta.version) + ) + return undefined; + return text; + } + if (delivered.file === "snapshot.json") { + const timestamp = orNone(timestampOf, input.timestamp); + if (!timestamp) return undefined; + timestamp.signed.snapshotMeta.verify(delivered.bytes); + const next = snapshotOf(text); + if (next.signed.version !== timestamp.signed.snapshotMeta.version) + return undefined; + const trusted = orNone(snapshotOf, input.trusted); + for (const [name, info] of Object.entries(trusted?.signed.meta ?? {})) { + const replacement = next.signed.meta[name]; + if (!replacement || replacement.version < info.version) + return undefined; + } + return text; + } + } catch { + // Not authentic, or not metadata at all. + } + // Roots are captured by `verifiedRootChain`; targets carry no floor (above). + return undefined; +} + +/** Capture trust when the refresh ENDS — success or failure — and before the + * scratch is deleted, in the two ways of docs/update.md "Check": + * 1. role files the client persisted in scratch (complete verification); + * 2. after a FAILED refresh, the role delivered last, when `capturedFloor` + * authenticates it as a newer version. It is written as the ordinary role + * file: for the client an expired local role is a version floor and + * nothing else (see the top of this file). * * Order: numbered root chain ascending, `root.json`, timestamp, snapshot, * targets; each file by temporary file + sync + rename + directory sync. The @@ -198,23 +325,34 @@ function verifiedRootChain( * newer root with older roles is re-verified under that root, and a newer * timestamp with an older snapshot makes the client fetch the snapshot the * timestamp names (updater.js:231-256). A crash before the first write equals - * a refresh that never ran. + * a refresh that never ran — a floor included: the same repository state + * yields the same floor at the next check. */ export async function promoteVerified(input: { trustDirectory: string; scratch: string; seed: Seed; fetchedRoots: ReadonlyMap; - /** File name of the role whose verification is not known to have finished. */ - unsettledFile: string | undefined; + /** Only after a FAILED refresh: the role the client was judging. */ + lastDelivered: Readonly<{ file: string; bytes: Buffer }> | undefined; write: DurableWriter; }): Promise { const { seed, trustDirectory, scratch, write } = input; const finalRoot = await readFile(join(scratch, "root.json"), "utf8"); + const chain = verifiedRootChain(seed.root, finalRoot, input.fetchedRoots); const changedRoles: { name: string; text: string }[] = []; for (const name of roleFiles) { - if (name === input.unsettledFile) break; - const text = await readOptional(join(scratch, name)); + let text = await readOptional(join(scratch, name)); + if (input.lastDelivered?.file === name) + text = + capturedFloor({ + delivered: input.lastDelivered, + root: parseRoot(finalRoot), + trusted: seed.roles.get(name), + timestamp: + changedRoles.find((role) => role.name === "timestamp.json")?.text ?? + (await readOptional(join(scratch, "timestamp.json"))), + }) ?? text; if (text !== undefined && text !== seed.roles.get(name)) changedRoles.push({ name, text }); } @@ -228,14 +366,6 @@ export async function promoteVerified(input: { await write(trustDirectory, name, Buffer.from(text, "utf8")); promoted.push(name); }; - // The unsettled guard applies to roots too: a root delivered last in a - // failed refresh is withheld, so a chain that needs it is incomplete. - const settledRoots = new Map(input.fetchedRoots); - const unsettledRoot = /^([1-9]\d*)\.root\.json$/.exec( - input.unsettledFile ?? "", - ); - if (unsettledRoot) settledRoots.delete(Number(unsettledRoot[1])); - const chain = verifiedRootChain(seed.root, finalRoot, settledRoots); for (const { version, text } of chain) { const name = `${version}.root.json`; if ((await readOptional(join(trustDirectory, name))) !== text) diff --git a/tests/update-check.test.ts b/tests/update-check.test.ts index 7e1cd506..4b478a62 100644 --- a/tests/update-check.test.ts +++ b/tests/update-check.test.ts @@ -270,8 +270,10 @@ test("a correctly signed but EXPIRED rotated root is still promoted; a fresh suc expect(await trust("root.json")).toBe( fixture.served("/metadata/2.root.json")?.toString(), ); - // It authenticated nothing stale: no role moved under the expired root. - expect(await roleVersion("timestamp.json")).toBe(1); + // It authorized nothing: the check failed. The newer timestamp it could + // authenticate is kept as a version floor, like under any other expiry. + expect(await roleVersion("timestamp.json")).toBe(2); + expect(await roleVersion("snapshot.json")).toBe(1); fixture.renewRoot(); fixture.requests.length = 0; expect(await check()).toMatchObject({ kind: "available" }); @@ -375,9 +377,10 @@ test("abandoned scratch directories and temporary files of a killed check are re ); }); -test("an expired timestamp is a typed error that leaves trust untouched and never wedges", async () => { - const { fixture, base, check, trustSnapshot } = await scenario(); - // First contact: nothing verified, nothing kept, same command works later. +test("an expired timestamp is a typed error that never wedges; the newer version it proved is kept as a rollback floor and authorizes nothing", async () => { + const { fixture, base, check, trustSnapshot, roleVersion } = await scenario(); + // First contact: the root authenticated the timestamp, so both are kept; + // nothing is offered, and a later fresh generation simply works. fixture.release( "stable", { sequence: 1, version: "1.2.0" }, @@ -387,33 +390,146 @@ test("an expired timestamp is a typed error that leaves trust untouched and neve kind: "error", code: "metadata-expired", }); - expect(await readdir(join(base, "trust"))).toEqual([]); + expect((await readdir(join(base, "trust"))).sort()).toEqual([ + "1.root.json", + "root.json", + "timestamp.json", + ]); expect(await readObserved(base, clock())).toMatchObject({ status: "error", error: { code: "metadata-expired" }, canRetry: true, + available: null, lastAuthenticatedCheckAt: null, }); fixture.publish(); - expect(await check({ bootstrapRoot: fixture.bootstrapRoot })).toMatchObject({ - kind: "available", - }); - // Established trust: the lapse changes nothing on disk. + expect(await check()).toMatchObject({ kind: "available" }); + + // (a) Established trust at generation 2; generation 3 has an authentic but + // EXPIRED timestamp. The client authenticates it, throws, and never writes + // it — promotion keeps it from the raw bytes. + const valid = fixture.version(); const before = await trustSnapshot(); fixture.publish({ expires: { timestamp: past } }); expect(await check()).toMatchObject({ kind: "error", code: "metadata-expired", }); - expect(await trustSnapshot()).toEqual(before); + expect(await roleVersion("timestamp.json")).toBe(valid + 1); + expect({ ...(await trustSnapshot()), "timestamp.json": "" }).toEqual({ + ...before, + "timestamp.json": "", + }); // The verified target stays visible while the error is shown. expect(await readObserved(base, clock())).toMatchObject({ status: "error", available: { version: "1.2.0" }, lastAuthenticatedCheckAt: "2026-09-19T10:00:00.000Z", }); + // The same expired generation again: still an expiry, nothing authorized. + expect(await check()).toMatchObject({ code: "metadata-expired" }); + // (b) Replay of the last VALID, unexpired generation — lower than the + // floor. Without the floor this would pass as "no change". + const floor = await trustSnapshot(); + fixture.rewindTo(valid); + expect(await check()).toMatchObject({ + kind: "error", + code: "metadata-invalid", + }); + expect(await trustSnapshot()).toEqual(floor); + // (c) No wedge: a fresh higher generation is accepted. + fixture.publish(); + expect(await check()).toMatchObject({ kind: "available" }); + expect(await roleVersion("timestamp.json")).toBe(valid + 2); + expect(await roleVersion("targets.json")).toBe(valid + 2); +}); + +test("an expired snapshot: the newer version the timestamp names is kept as a floor, refuses a lower targets version even when the timestamp is lost, and never wedges", async () => { + const { fixture, base, check, trustSnapshot, roleVersion } = await scenario(); + fixture.release("stable", { sequence: 1, version: "1.2.0" }); + await check({ bootstrapRoot: fixture.bootstrapRoot }); + fixture.publish(); + expect(await check()).toMatchObject({ kind: "available" }); + const valid = fixture.version(); + // (a) Fresh timestamp, authentic but EXPIRED snapshot of a higher version. + fixture.publish({ expires: { snapshot: past } }); + expect(await check()).toMatchObject({ + kind: "error", + code: "metadata-expired", + }); + expect(await roleVersion("timestamp.json")).toBe(valid + 1); + expect(await roleVersion("snapshot.json")).toBe(valid + 1); + // Expired metadata authorizes nothing: targets did not move. + expect(await roleVersion("targets.json")).toBe(valid); + expect(await check()).toMatchObject({ code: "metadata-expired" }); + // (b) Replay of the last valid generation is refused by the timestamp… + const floor = await trustSnapshot(); + fixture.rewindTo(valid); + expect(await check()).toMatchObject({ + kind: "error", + code: "metadata-invalid", + }); + expect(await trustSnapshot()).toEqual(floor); + // …and by the snapshot floor ALONE when the timestamp file is lost: the + // older snapshot names a lower targets version than the floor does. + await rm(join(base, "trust", "timestamp.json")); + expect(await check()).toMatchObject({ + kind: "error", + code: "metadata-invalid", + }); + expect(await roleVersion("snapshot.json")).toBe(valid + 1); + expect(await roleVersion("targets.json")).toBe(valid); + // (c) No wedge. fixture.publish(); expect(await check()).toMatchObject({ kind: "available" }); + expect(await roleVersion("snapshot.json")).toBe(valid + 2); + expect(await roleVersion("targets.json")).toBe(valid + 2); +}); + +test("a role delivered last that is not authentic or not newer is never kept, and an interrupted capture converges at the next check", async () => { + const { fixture, check, trustSnapshot, roleVersion } = await scenario(); + fixture.release("stable", { sequence: 1, version: "1.2.0" }); + await check({ bootstrapRoot: fixture.bootstrapRoot }); + const before = await trustSnapshot(); + // (e) A newer, expired timestamp whose signature is damaged. + fixture.publish({ expires: { timestamp: past } }); + const genuine = fixture.served("/metadata/timestamp.json") as Buffer; + fixture.substitute( + "/metadata/timestamp.json", + Buffer.from(genuine.toString().replace(/"sig":"../, '"sig":"00')), + ); + expect(await check()).toMatchObject({ + kind: "error", + code: "metadata-invalid", + }); + expect(await trustSnapshot()).toEqual(before); + // A damaged snapshot under a fresh timestamp: the timestamp moves, the + // snapshot does not. + fixture.publish({ tamper: "snapshot" }); + expect(await check()).toMatchObject({ code: "metadata-invalid" }); + expect(await roleVersion("snapshot.json")).toBe(1); + const moved = await trustSnapshot(); + // An authentic LOWER version delivered last (a replay) is no floor. + fixture.rewindTo(1); + expect(await check()).toMatchObject({ code: "metadata-invalid" }); + expect(await trustSnapshot()).toEqual(moved); + + // (f) The process dies between capture and promotion, at every write: + // before the floor is written (a refresh that never ran) and after it + // (only the observation is lost). The next check converges either way. + for (let survive = 0; survive < 2; survive++) { + fixture.publish({ expires: { timestamp: past } }); + let done = 0; + await check({ + writeDurable: async (...args) => { + if (done >= survive) throw new Error("killed"); + done += 1; + await writeDurableFile(...args); + }, + }); + expect(await check()).toMatchObject({ code: "metadata-expired" }); + expect(await roleVersion("timestamp.json")).toBe(fixture.version()); + } }); test("expired snapshot and targets are classified as expiry through the pinned client's wrapped errors", async () => { @@ -426,9 +542,12 @@ test("expired snapshot and targets are classified as expiry through the pinned c kind: "error", code: "metadata-expired", }); - // What was fresh and verified before the expired role moved forward. + // The fresh timestamp moved; snapshot 2 is in trust/ either way — as a + // floor when it is the expired one, as a verified role otherwise — and + // expired targets are never kept: they carry no floor. expect(await roleVersion("timestamp.json")).toBe(2); - expect(await roleVersion(`${role}.json`)).toBe(1); + expect(await roleVersion("snapshot.json")).toBe(2); + expect(await roleVersion("targets.json")).toBe(1); fixture.publish(); expect(await check()).toMatchObject({ kind: "available" }); } diff --git a/tests/update-trust.test.ts b/tests/update-trust.test.ts index 6d8c6782..a080843b 100644 --- a/tests/update-trust.test.ts +++ b/tests/update-trust.test.ts @@ -38,42 +38,132 @@ function repository() { return { fixture, text }; } -test("the role delivered last in a failed refresh is withheld together with everything after it", async () => { - const { trustDirectory, scratch } = await directories(); +test("after a failed refresh the role delivered last becomes a floor only when it re-verifies, by itself, as an authentic newer version", async () => { const { fixture, text } = repository(); - const seed: Seed = { - established: false, + const forged = createUpdateFixture({ executionTarget: "linux-x64" }); + cleanups.push(() => forged.stop()); + forged.release("stable", { sequence: 1, version: "1.0.0" }); + const first = { + timestamp: text("/metadata/timestamp.json"), + snapshot: text("/metadata/1.snapshot.json"), + }; + fixture.publish(); + const second = { + timestamp: text("/metadata/timestamp.json"), + snapshot: text("/metadata/2.snapshot.json"), + targets: text("/metadata/2.targets.json"), + }; + const seed = (roles: Record): Seed => ({ + established: true, root: fixture.bootstrapRoot.toString(), - roles: new Map(), + roles: new Map(Object.entries(roles)), + }); + const promote = async (input: { + seed: Seed; + scratch: Record; + delivered: [string, string]; + }) => { + const { trustDirectory, scratch } = await directories(); + await writeFile(join(scratch, "root.json"), input.seed.root); + for (const [name, content] of Object.entries(input.scratch)) + await writeFile(join(scratch, name), content); + // The retained chain (`1.root.json`) is written into the empty directory + // every time; this test is about the roles. + return ( + await promoteVerified({ + trustDirectory, + scratch, + seed: input.seed, + fetchedRoots: new Map(), + lastDelivered: { + file: input.delivered[0], + bytes: Buffer.from(input.delivered[1]), + }, + write: writeDurableFile, + }) + ).filter((name) => !name.endsWith("root.json")); }; - await writeFile(join(scratch, "root.json"), seed.root); - await writeFile( - join(scratch, "timestamp.json"), - text("/metadata/timestamp.json"), - ); - await writeFile( - join(scratch, "snapshot.json"), - text("/metadata/1.snapshot.json"), - ); - await writeFile( - join(scratch, "targets.json"), - text("/metadata/1.targets.json"), - ); + const trusted = { "timestamp.json": first.timestamp }; + // The client never wrote the newer timestamp (it threw); the raw bytes + // verify under the root and are newer: kept. expect( - await promoteVerified({ - trustDirectory, - scratch, - seed, - fetchedRoots: new Map(), - unsettledFile: "snapshot.json", - write: writeDurableFile, + await promote({ + seed: seed(trusted), + scratch: trusted, + delivered: ["timestamp.json", second.timestamp], + }), + ).toEqual(["timestamp.json"]); + for (const [name, delivered] of [ + // Signed by another repository's timestamp key. + [ + "foreign signature", + forged.served("/metadata/timestamp.json")?.toString(), + ], + ["damaged signature", second.timestamp.replace(/"sig":"../, '"sig":"00')], + ["not metadata", "{}"], + ["not JSON", "\u0000"], + ] as const) + expect([ + name, + await promote({ + seed: seed(trusted), + scratch: trusted, + delivered: ["timestamp.json", delivered ?? ""], + }), + ]).toEqual([name, []]); + // Authentic but LOWER or EQUAL: a replay is no floor. + const newer = { "timestamp.json": second.timestamp }; + for (const delivered of [first.timestamp, second.timestamp]) + expect( + await promote({ + seed: seed(newer), + scratch: newer, + delivered: ["timestamp.json", delivered], + }), + ).toEqual([]); + + // Snapshot: held against the newest authenticated timestamp, which the + // client persisted before it asked for the snapshot. + const before = { + "timestamp.json": first.timestamp, + "snapshot.json": first.snapshot, + }; + const moved = { ...before, "timestamp.json": second.timestamp }; + expect( + await promote({ + seed: seed(before), + scratch: moved, + delivered: ["snapshot.json", second.snapshot], }), - ).toEqual(["1.root.json", "root.json", "timestamp.json"]); - expect((await readdir(trustDirectory)).sort()).toEqual([ - "1.root.json", - "root.json", - "timestamp.json", - ]); + ).toEqual(["timestamp.json", "snapshot.json"]); + for (const [name, scratch, delivered] of [ + // Not the snapshot the timestamp names: wrong version and hash. + ["other version", moved, first.snapshot], + ["foreign", moved, forged.served("/metadata/1.snapshot.json")?.toString()], + ["damaged", moved, second.snapshot.replace(/"sig":"../, '"sig":"00')], + // Without an authenticated timestamp nothing vouches for a snapshot. + ["no timestamp", {}, second.snapshot], + ] as const) + expect([ + name, + ( + await promote({ + seed: seed(before), + scratch, + delivered: ["snapshot.json", delivered ?? ""], + }) + ).includes("snapshot.json"), + ]).toEqual([name, false]); + // Targets carry no floor in the pinned client and are never captured, even + // when they are perfectly authentic. + const complete = { ...moved, "snapshot.json": second.snapshot }; + expect( + await promote({ + seed: seed(before), + scratch: complete, + delivered: ["targets.json", second.targets], + }), + ).toEqual(["timestamp.json", "snapshot.json"]); }); test("a root in scratch reaches trust/ only through a verified chain from the seed", async () => { @@ -88,19 +178,13 @@ test("a root in scratch reaches trust/ only through a verified chain from the se const forged = createUpdateFixture({ executionTarget: "linux-x64" }); cleanups.push(() => forged.stop()); forged.rotateRoot(); - const cases: [string, Map, string | undefined][] = [ + const cases: [string, Map][] = [ // Version 2 of ANOTHER repository: not signed by the seed's root key. - [ - forged.served("/metadata/2.root.json")?.toString() ?? "", - new Map(), - undefined, - ], + [forged.served("/metadata/2.root.json")?.toString() ?? "", new Map()], // The genuine successor, but its bytes were never received. - [genuine, new Map(), undefined], - // Received, but delivered last in a refresh that then failed. - [genuine, new Map([[2, genuine]]), "2.root.json"], + [genuine, new Map()], ]; - for (const [final, fetchedRoots, unsettledFile] of cases) { + for (const [final, fetchedRoots] of cases) { const { trustDirectory, scratch } = await directories(); await writeFile(join(scratch, "root.json"), final); if (fetchedRoots.size === 0 && final !== genuine) @@ -111,7 +195,7 @@ test("a root in scratch reaches trust/ only through a verified chain from the se scratch, seed, fetchedRoots, - unsettledFile, + lastDelivered: undefined, write: writeDurableFile, }), ).rejects.toThrow(); @@ -125,7 +209,9 @@ test("a root in scratch reaches trust/ only through a verified chain from the se scratch, seed, fetchedRoots: new Map([[2, genuine]]), - unsettledFile: undefined, + // A root delivered last needs no special case: the chain is verified + // link by link whatever the client did with it. + lastDelivered: { file: "2.root.json", bytes: Buffer.from(genuine) }, write: writeDurableFile, }), ).toEqual(["1.root.json", "2.root.json", "root.json"]); From d8877ed9cf852a96c5d4d16786bdddfbfd8d6a41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Sat, 19 Sep 2026 04:27:30 +0200 Subject: [PATCH 09/11] Update contract: durable floor vector (references, snapshot.meta, same-version binding, rotation) Co-Authored-By: Claude Fable 5.1 --- docs/update.md | 52 +++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/docs/update.md b/docs/update.md index fbdca816..4c4eb8eb 100644 --- a/docs/update.md +++ b/docs/update.md @@ -80,7 +80,7 @@ the current one. ```text bin/lazurio -> ../versions/+/lazurio # the only active selector versions/+/{lazurio,identity.json} # immutable -trust/ # durable verified TUF metadata + channel floors +trust/ # durable verified TUF metadata + floors.json (floor vector) update/activation.json # present only during an activation update/observed.json # derived observation for UI, CLI and observers update/scratch-*/ # always safe to delete @@ -127,16 +127,58 @@ snapshot, targets: ignored. A role that passes is promoted **as a floor only**. Anything that fails re-verification is discarded. +**The floor vector.** What must never go backwards is more than each role's own +version, and the client's in-memory checks die with the process and with a key +rotation (a retained role file signed by a revoked key no longer loads, so its +floor would silently vanish). The floors are therefore kept as an owned, +schema-versioned record `trust/floors.json`, merged monotonically and written +atomically, independent of who signed the roles: + +| Floor | Facts kept | +| --- | --- | +| root | highest trusted version; digest of each retained numbered root | +| timestamp | version; digest of its signed content; the snapshot reference it names (version, and length and hashes when recorded) | +| snapshot | version; digest of its signed content; **every** entry of `snapshot.meta` by role name (version, and length and hashes when recorded) | +| targets and any other role named in `snapshot.meta` | version and signed-content digest of the last role actually verified | + +Every candidate — a role the client persisted or a captured failure-state role — +is compared with the whole vector before anything is promoted: + +1. a version lower than its floor is refused; +2. the **same version with different signed content** is refused (equivocation), + the same version with identical content is a no-op; +3. a timestamp whose snapshot reference is lower than the floor's is refused; +4. a snapshot in which any previously recorded `snapshot.meta` entry is missing + or lower is refused, as is one that disagrees with the newest authenticated + timestamp's reference; +5. a root is accepted only as the next link of the retained chain, signed by the + thresholds of both the previous and the new root. + +A violation refuses the whole refresh result with a typed `metadata-rollback` +error; only a valid root chain is still promoted. Otherwise the role files are +promoted and the vector becomes the element-wise maximum of the old vector and +the candidates' facts, so it survives expiry, interruption, repository advance +and root rotation alike. Delegations stay disabled (`maxDelegations: 0`); the +rule is written over role names so enabling them later cannot weaken it. A +missing or unreadable `floors.json` is rebuilt from the retained role files, +each verified under the retained numbered root that was valid for it; it never +blocks a check and never resets to empty while those files exist. + Expired metadata never authorizes a target: every refresh re-checks expiry -before accepting the next role, and an expired local role is loaded only as a -version floor, exactly as the TUF specification's intermediate metadata. +before accepting the next role, a target is looked up only after a refresh that +completed without error and satisfied the vector, and an expired local role is +loaded only as a version floor, exactly as the TUF specification's intermediate +metadata. A crash before promotion equals a refresh that never ran; a crash between promotions leaves a newer root with older roles, which the next refresh re-verifies under that root. This needs no change to the pinned `tuf-js`. The old journal and replay remain the mechanism until the equivalence tests under -Evidence — interruption, expiry with a newer authenticated floor, repository -advance and key rotation — pass against this path. Then read the signed channel document, +Evidence pass against this path: every case the retained mechanism proves today +(`tests/historical-roles.test.ts`: timestamp-to-snapshot reference, every +`snapshot.meta` floor, same-version content binding, floors across root +rotation and across interruption) is ported to the floor vector and extended +with expiry carrying a newer authenticated floor and with repository advance. Then read the signed channel document, compare with the embedded version and rewrite `observed.json`. Network or expiry failures produce a typed error and leave everything else untouched. From be7f88da142021b0fac9466af675eff49c000b48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Sat, 19 Sep 2026 04:46:59 +0200 Subject: [PATCH 10/11] =?UTF-8?q?Check:=20the=20floor=20vector=20=E2=80=94?= =?UTF-8?q?=20trust/floors.json=20decides=20every=20promotion=20and=20ever?= =?UTF-8?q?y=20target=20lookup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-role version floors in the role files are not enough: they die with a lost file, with an interruption, and with a key rotation (a retained role signed by a revoked key no longer loads in the pinned client). trust/floors.json keeps the facts themselves: root versions and digests, the timestamp and its snapshot reference, the snapshot and EVERY snapshot.meta entry, and version + signed-content digest of each verified role. Signed-content digest = SHA-256 of canonicalize(signed.toJSON()), exactly what @tufjs/models verifies a signature over; @tufjs/canonical-json becomes a declared dependency. Every candidate — after a failed refresh the changed roles and the authenticated last-delivered one, after a successful refresh every role the client trusts — is compared with the whole vector: lower version, same version with other content, lowered snapshot reference, removed/lowered/changed snapshot.meta entry, contradicting root. A violation is `metadata-rollback` (exit 47) and keeps only the valid root chain. floors.json is written FIRST, then the files, so the vector is never lower than what the files prove and a vector that is ahead cannot wedge. Targets are looked up only after a refresh that completed without error and satisfied the vector. A missing or damaged vector is rebuilt from the retained files under the retained roots. tests/update-floors.test.ts ports the proofs of tests/historical-roles.test.ts to the new path, including all rules after a rotation of every role key and after an interruption — two cases in which, with the comparison switched off, the pinned client accepts the lower state. src/distribution is untouched. Co-Authored-By: Claude Fable 5.1 --- bun.lock | 1 + docs/update.md | 77 +++-- package.json | 1 + scripts/update-fixture.ts | 28 +- src/update/check.ts | 54 ++-- src/update/errors.ts | 7 + src/update/floors.ts | 556 ++++++++++++++++++++++++++++++++++++ src/update/trust.ts | 176 +++++++----- tests/update-check.test.ts | 64 ++++- tests/update-floors.test.ts | 536 ++++++++++++++++++++++++++++++++++ tests/update-trust.test.ts | 46 ++- 11 files changed, 1405 insertions(+), 141 deletions(-) create mode 100644 src/update/floors.ts create mode 100644 tests/update-floors.test.ts diff --git a/bun.lock b/bun.lock index 743437c2..d5ef6290 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "lazurio-platform-foundation", "dependencies": { + "@tufjs/canonical-json": "2.0.0", "@tufjs/models": "5.0.0", "ajv": "8.20.0", "minimatch": "10.2.6", diff --git a/docs/update.md b/docs/update.md index fb550a13..137de21c 100644 --- a/docs/update.md +++ b/docs/update.md @@ -363,25 +363,64 @@ Concrete choices this slice fixed: - **Authenticated failure-state floors.** The earlier rule "withhold the role delivered last in a failed refresh" is replaced. The fetcher retains the raw bytes of the role delivered last (only that one has a consumer); after a - failed refresh promotion re-verifies it without the client and without - expiry — timestamp: signature threshold under the promoted root, version - above and snapshot version not below the trusted timestamp; snapshot: length - and hashes recorded by the newest authenticated timestamp, signature - threshold, exactly the version that timestamp names, no targets version below - the trusted snapshot — and writes it as the ordinary role file. Verified in - the pinned source, with line references in `src/update/trust.ts`: an expired - LOCAL timestamp or snapshot is installed in memory before the expiry throw - and the throw is swallowed (`store.js:89-91`, `:133-136`; - `updater.js:195-206`, `:230-234`), so it acts as a version floor and nothing - else, and the final expiry checks (`store.js:102`, `:145`, `:172`) run again - before any target is looked up. Expired **targets** are different: the store - checks expiry before installing (`:172-175`), so an expired local targets - file is discarded without a trace and the floor of the targets version is the - snapshot's `meta`. A targets role delivered last is therefore never captured. - The floor is also captured on first contact and under an expired root: the - root authenticated a signature, and a floor can only raise the bar. A floor - that no longer verifies under a later root is not loaded by the client, so a - revoked key cannot wedge a Machine with a fast-forwarded version. + failed refresh promotion AUTHENTICATES it without the client and without + expiry — timestamp: signature threshold under the promoted root; snapshot: + length and hashes recorded by the newest authenticated timestamp, signature + threshold, exactly the version that timestamp names — and hands it to the + floor vector like any other candidate. Verified in the pinned source, with + line references in `src/update/trust.ts`: an expired LOCAL timestamp or + snapshot is installed in memory before the expiry throw and the throw is + swallowed (`store.js:89-91`, `:133-136`; `updater.js:195-206`, `:230-234`), + so the role file acts as a version floor and nothing else, and the final + expiry checks (`store.js:102`, `:145`, `:172`) run again before any target is + looked up. Expired **targets** are different: the store checks expiry before + installing (`:172-175`), so an expired local targets file is discarded + without a trace; a targets role delivered last is therefore never captured, + and its floor lives in the vector. The floor is also captured on first + contact and under an expired root: the root authenticated a signature, and a + floor can only raise the bar. +- **The floor vector** (`src/update/floors.ts`, `trust/floors.json`). Exactly + the facts of the contract's table, keyed as in `snapshot.meta` + (`targets.json`). *Signed-content digest* = SHA-256 of + `canonicalize(metadata.signed.toJSON())` from `@tufjs/canonical-json` (now a + declared dependency, the version `@tufjs/models` pins): byte for byte what a + signature is verified over (`@tufjs/models` `dist/key.js:39-41` → + `dist/utils/verify.js:10`), so formatting, key order and signatures do not + count and every signed member, known or not, does. + *Who is compared:* after a failed refresh the roles that changed and the + captured one; after a **successful** refresh every role the client now + trusts, changed or not — the pinned client treats a replayed state that + equals its files as "no change" and would authorize targets under it even + when the vector is ahead. *Write order:* `floors.json` first (element-wise + maximum), then root chain, `root.json`, timestamp, snapshot, targets, each + atomic, the first failed write stopping the rest. The vector is therefore + never lower than what the files prove; a vector that is ahead cannot wedge, + because it refuses only what is lower or different — the same repository + state is a no-op and any later valid state exceeds it. *Violation:* + `metadata-rollback` (exit 47) with `role`, `rule`, `floor`, `offered`; only + the valid root chain (and its part of the vector) is kept; a root that + contradicts a retained one keeps nothing. It outranks whatever else went + wrong in that refresh, and an authentic LOWER role that the client itself + refused is reported under the same code. `retryable` is true in the sense of + the error table — it ends when metadata at or above the floors is served + again, with no repair on the Machine — but it is a security signal: observers + watch the code. *Targets* (channel document, later identity and artifact) + are looked up only after a refresh that completed without error and + satisfied the vector. *Rebuild:* a missing, damaged or newer-schema + `floors.json` is rebuilt from the retained numbered roots (walked as a + verified chain) and the role files, each under the newest retained root that + verifies it, and is written back; it never blocks a check. Where the pinned + client forced MORE than the contract states: (a) the client loads a local + snapshot without holding it against the timestamp's hashes + (`updater.js:231-232`, `store.js:107`), so a reference — the timestamp's + snapshot reference, a `snapshot.meta` entry — that names the **same version + with other recorded length or hashes** is refused too; (b) a snapshot entry + may not fall below a role of that name that was actually verified, and a + snapshot not below the newest reference, whichever file is lost; (c) the + comparison after a successful refresh covers unchanged roles, see above. + Proven blind spots of the client, by switching the comparison off: after a + rotation of every role key, and after an interruption between vector and + files, the client accepts the lower state and reports an update. - **First trust.** The caller supplies the bootstrap root (later: the compiled-in root). It becomes durable only together with the first role it verified, so a wrong root can never wedge an installation; once `trust/` holds diff --git a/package.json b/package.json index 4a6e3466..27c448a7 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ }, "license": "Elastic-2.0", "dependencies": { + "@tufjs/canonical-json": "2.0.0", "@tufjs/models": "5.0.0", "ajv": "8.20.0", "minimatch": "10.2.6", diff --git a/scripts/update-fixture.ts b/scripts/update-fixture.ts index 3e158f2c..594b55ac 100644 --- a/scripts/update-fixture.ts +++ b/scripts/update-fixture.ts @@ -125,9 +125,18 @@ export function createUpdateFixture(input: { options: { expires?: Partial>; tamper?: "snapshot" | "targets"; + /** Publish THIS generation number instead of the next one, signed by + * the current keys: a publisher (or whoever holds its keys) going back, + * or saying something else at a version it already used. */ + at?: number; + /** Further `snapshot.meta` entries, by file name → version. */ + extraMeta?: Readonly>; + /** Let the timestamp name the snapshot of an OLDER generation that is + * still served, instead of the one published now. */ + timestampReferences?: number; } = {}, ) => { - version += 1; + version = options.at ?? version + 1; const files: Record = { [artifactPath]: new TargetFile({ path: artifactPath, @@ -178,17 +187,28 @@ export function createUpdateFixture(input: { length: targets.length, hashes: { sha256: sha256(targets) }, }), + ...Object.fromEntries( + Object.entries(options.extraMeta ?? {}).map(([name, entry]) => [ + name, + new MetaFile({ version: entry }), + ]), + ), }, }), [signers.snapshot], ); + const referenced = + options.timestampReferences === undefined + ? snapshot + : served.get(`/metadata/${options.timestampReferences}.snapshot.json`); + if (!referenced) throw new Error("Unknown fixture generation"); const timestamp = signedBy( new Timestamp({ ...fields("timestamp"), snapshotMeta: new MetaFile({ - version, - length: snapshot.length, - hashes: { sha256: sha256(snapshot) }, + version: options.timestampReferences ?? version, + length: referenced.length, + hashes: { sha256: sha256(referenced) }, }), }), [signers.timestamp], diff --git a/src/update/check.ts b/src/update/check.ts index 2d9daeb4..e2faa99f 100644 --- a/src/update/check.ts +++ b/src/update/check.ts @@ -126,15 +126,14 @@ function validUrl(value: string): boolean { } } -/** Refresh in scratch, then read the signed channel document. Throws on the - * first failure; the caller promotes whatever was verified before it. +/** Refresh in scratch. Throws on the first failure; the caller captures + * whatever was verified before it. NO target is looked up here. */ -async function refreshAndReadChannel( +async function refresh( input: CheckInput, scratch: string, fetcher: TrustFetcher, - onRefreshed: () => void, -): Promise<{ bytes: Buffer; updater: Updater }> { +): Promise { const updater = new Updater({ fetcher, metadataDir: scratch, @@ -151,7 +150,17 @@ async function refreshAndReadChannel( }, }); await updater.refresh(); - onRefreshed(); + return updater; +} + +/** Targets are looked up only after a refresh that completed without error + * AND satisfied the floor vector (docs/update.md "The floor vector"). + */ +async function readChannel( + input: CheckInput, + scratch: string, + updater: Updater, +): Promise { const path = channelTargetPath(input.channel); const target = await updater.getTargetInfo(path); if (!target) @@ -166,7 +175,7 @@ async function refreshAndReadChannel( }); const file = join(scratch, "channel-document"); await updater.downloadTarget(target, file); - return { bytes: await readFile(file), updater }; + return readFile(file); } /** Check for an update: automatic, cheap, and no mutation of the product @@ -241,7 +250,7 @@ async function checkUnderLock( const { trustDirectory, updateDirectory } = owned; const fetcher = new TrustFetcher(input.transport, input.metadataBaseUrl); const scratch = join(updateDirectory, `scratch-${owned.operationId}`); - let received: Awaited> | undefined; + let updater: Updater | undefined; let failure: UpdateError | undefined; try { // Holding the lock proves no writer is alive: every scratch directory and @@ -254,30 +263,41 @@ async function checkUnderLock( const seed = await readSeed(trustDirectory, input.bootstrapRoot); await mkdir(scratch, { mode: 0o700 }); await seedScratch(scratch, seed); - let refreshed = false; try { - received = await refreshAndReadChannel(input, scratch, fetcher, () => { - refreshed = true; - }); + updater = await refresh(input, scratch, fetcher); } catch (error) { failure = classify(error, fetcher); } - // Trust never rewinds: promote on success AND on failure, before anything - // else can go wrong with this attempt. + // Trust never rewinds: capture on success AND on failure, before anything + // else can go wrong with this attempt. A floor-vector violation outranks + // whatever else went wrong: it is the one that must be seen. try { await promoteVerified({ trustDirectory, scratch, seed, fetchedRoots: fetcher.roots, - lastDelivered: refreshed ? undefined : fetcher.lastDelivered, + refreshed: updater !== undefined, + lastDelivered: updater ? undefined : fetcher.lastDelivered, write, }); } catch (error) { - return { failure: updateError(...storageOrInternal(error, "trust")) }; + return { + failure: + error instanceof UpdateFailure + ? error.failure + : updateError(...storageOrInternal(error, "trust")), + }; } - if (failure || !received) + if (failure || !updater) return { failure: failure ?? updateError("internal") }; + let bytes: Buffer; + try { + bytes = await readChannel(input, scratch, updater); + } catch (error) { + return { failure: classify(error, fetcher) }; + } + const received = { bytes, updater }; // Read only now: unreadable floors refuse the channel decision, but they // must not stop verified TUF roles above from becoming durable. const floors = await readChannelFloors(trustDirectory); diff --git a/src/update/errors.ts b/src/update/errors.ts index 2943e217..acbc75d7 100644 --- a/src/update/errors.ts +++ b/src/update/errors.ts @@ -20,6 +20,13 @@ export const updateErrors = { busy: { exit: 29, retryable: true }, "storage-unavailable": { exit: 30, retryable: true }, "not-implemented": { exit: 31, retryable: false }, + /** Authentic metadata that goes BELOW the floor vector: a lower version, the + * same version with other signed content, a lowered snapshot reference or + * `snapshot.meta` entry. Nothing but a valid root chain was kept. Retryable + * in the sense of this table — it ends when the repository (or whoever + * stands in front of it) serves metadata at or above the floors again, with + * no repair on the Machine — but it is a security signal: watch the CODE. */ + "metadata-rollback": { exit: 47, retryable: true }, internal: { exit: 70, retryable: false }, } as const satisfies Record; diff --git a/src/update/floors.ts b/src/update/floors.ts new file mode 100644 index 00000000..78837b08 --- /dev/null +++ b/src/update/floors.ts @@ -0,0 +1,556 @@ +import { createHash } from "node:crypto"; +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { canonicalize } from "@tufjs/canonical-json"; +import { + Metadata, + MetadataKind, + type MetaFile, + type Root, + type Snapshot, + type Targets, + type Timestamp, +} from "@tufjs/models"; +import { parseUniqueJson } from "../providers/unique-json"; +import type { DurableWriter } from "./durable-file"; +import { type ErrorContext, UpdateFailure } from "./errors"; + +/** The floor vector, `trust/floors.json` (docs/update.md "The floor vector"). + * + * What must never go backwards is more than each role's own version, and the + * pinned client's checks live in memory and in the role FILES: they die with + * the process, with a lost file, and with a key rotation — a retained role + * file signed by a revoked key no longer loads (store.js:68, :118), so the + * floor inside it silently vanishes. This record keeps the facts themselves, + * independent of who signed the roles: + * + * root highest trusted version; signed-content digest of each retained + * numbered root + * timestamp version; signed-content digest; the snapshot reference it names + * snapshot version; signed-content digest; EVERY entry of `snapshot.meta` + * roles for targets and any other role named in `snapshot.meta`: + * version and signed-content digest of the last role verified + * + * SIGNED-CONTENT DIGEST: SHA-256 over the canonical JSON of the `signed` + * object — `canonicalize(metadata.signed.toJSON())` from `@tufjs/canonical-json`, + * byte for byte what `@tufjs/models` verifies a signature over + * (dist/key.js:39-41 → dist/utils/verify.js:10; `toJSON` carries unrecognized + * signed members, dist/base.js). So key order, whitespace and the SIGNATURES do + * not count — a role re-signed by a rotated key is the same content — and any + * change of a signed member, known or unknown, does. + * + * The record is facts, never authority: it authenticates nothing and selects + * nothing. It only refuses. Members are keyed as they are in `snapshot.meta` + * (`targets.json`), so enabling delegations later cannot weaken the rule. + */ +export type MetaFloor = Readonly<{ + version: number; + length?: number; + hashes?: Readonly>; +}>; +export type RoleFloor = Readonly<{ version: number; signedSha256: string }>; +export type FloorVector = Readonly<{ + root: Readonly<{ + version: number; + /** Root version → signed-content digest of the retained numbered root. */ + retained: Readonly>; + }>; + timestamp: (RoleFloor & Readonly<{ snapshot: MetaFloor }>) | null; + snapshot: + | (RoleFloor & Readonly<{ meta: Readonly> }>) + | null; + roles: Readonly>; +}>; + +export const floorsFile = "floors.json"; +export const emptyFloors: FloorVector = Object.freeze({ + root: Object.freeze({ version: 0, retained: Object.freeze({}) }), + timestamp: null, + snapshot: null, + roles: Object.freeze({}), +}); + +/** What one authenticated role states; the unit the vector is compared with + * and merged from. + */ +export type RoleFacts = + | Readonly<{ kind: "root"; version: number; signedSha256: string }> + | Readonly<{ + kind: "timestamp"; + version: number; + signedSha256: string; + snapshot: MetaFloor; + }> + | Readonly<{ + kind: "snapshot"; + version: number; + signedSha256: string; + meta: Readonly>; + }> + /** `name` as in `snapshot.meta`, e.g. `targets.json`. */ + | Readonly<{ + kind: "role"; + name: string; + version: number; + signedSha256: string; + }>; + +type AnyMetadata = Metadata; + +export function signedDigest(metadata: AnyMetadata): string { + return createHash("sha256") + .update(canonicalize(metadata.signed.toJSON())) + .digest("hex"); +} + +const metaFloor = (file: MetaFile): MetaFloor => + Object.freeze({ + version: file.version, + ...(file.length === undefined ? {} : { length: file.length }), + ...(file.hashes === undefined + ? {} + : { hashes: Object.freeze({ ...file.hashes }) }), + }); + +function parse(kind: MetadataKind, text: string): AnyMetadata { + const metadata = Metadata.fromJSON( + kind as MetadataKind.Targets, + parseUniqueJson(text) as Parameters[1], + ) as AnyMetadata; + if (metadata.signed.type !== kind) throw new Error("Wrong role type"); + return metadata; +} + +const kindOfFile = (name: string): MetadataKind => + name === "timestamp.json" + ? MetadataKind.Timestamp + : name === "snapshot.json" + ? MetadataKind.Snapshot + : name.endsWith("root.json") + ? MetadataKind.Root + : MetadataKind.Targets; + +/** Facts of a role that the CALLER has authenticated; this function parses + * and measures, it verifies no signature. `name` is the file name in `trust/`. + */ +export function factsOf(name: string, text: string): RoleFacts { + const kind = kindOfFile(name); + const metadata = parse(kind, text); + const common = { + version: metadata.signed.version, + signedSha256: signedDigest(metadata), + }; + if (kind === MetadataKind.Root) return { kind: "root", ...common }; + if (kind === MetadataKind.Timestamp) + return { + kind: "timestamp", + ...common, + snapshot: metaFloor((metadata.signed as Timestamp).snapshotMeta), + }; + if (kind === MetadataKind.Snapshot) + return { + kind: "snapshot", + ...common, + meta: Object.freeze( + Object.fromEntries( + Object.entries((metadata.signed as Snapshot).meta).map( + ([role, file]) => [role, metaFloor(file)], + ), + ), + ), + }; + return { kind: "role", name, ...common }; +} + +export type FloorViolation = Readonly<{ + /** `timestamp`, `snapshot`, `root` or the name in `snapshot.meta`. */ + role: string; + rule: "version" | "content" | "snapshot-reference" | "snapshot-meta" | "root"; + floor: number; + offered: number; +}>; + +export const violationContext = (violation: FloorViolation): ErrorContext => ({ + ...violation, +}); + +/** Same version must mean same recorded length and hashes, where both sides + * recorded them: a reference is a statement about content too. + */ +function sameReference(floor: MetaFloor, offered: MetaFloor): boolean { + if ( + floor.length !== undefined && + offered.length !== undefined && + floor.length !== offered.length + ) + return false; + for (const [algorithm, digest] of Object.entries(floor.hashes ?? {})) { + const other = offered.hashes?.[algorithm]; + if (other !== undefined && other !== digest) return false; + } + return true; +} + +/** The five rules of the contract, over ONE candidate. The first violation is + * returned; nothing is changed. The candidate is authentic already — this is + * rollback and equivocation, not authentication. + */ +export function violationOf( + vector: FloorVector, + facts: RoleFacts, +): FloorViolation | undefined { + const versionOrContent = ( + role: string, + floor: RoleFloor | null | undefined, + ): FloorViolation | undefined => { + if (!floor) return undefined; + // 1. A version lower than its floor. + if (facts.version < floor.version) + return { + role, + rule: "version", + floor: floor.version, + offered: facts.version, + }; + // 2. The same version with different signed content: equivocation. + if ( + facts.version === floor.version && + facts.signedSha256 !== floor.signedSha256 + ) + return { + role, + rule: "content", + floor: floor.version, + offered: facts.version, + }; + return undefined; + }; + if (facts.kind === "root") { + // 5. Chain verification is the caller's (`verifiedRootChain`); here: no + // other content at a version that is already retained. + const retained = vector.root.retained[String(facts.version)]; + return retained !== undefined && retained !== facts.signedSha256 + ? { + role: "root", + rule: "root", + floor: facts.version, + offered: facts.version, + } + : undefined; + } + if (facts.kind === "timestamp") { + const own = versionOrContent("timestamp", vector.timestamp); + if (own) return own; + // 3. The snapshot it names must not be lower than any snapshot version + // this Machine has authenticated — by reference or as a snapshot itself. + const floor = Math.max( + vector.timestamp?.snapshot.version ?? 0, + vector.snapshot?.version ?? 0, + ); + if (facts.snapshot.version < floor) + return { + role: "timestamp", + rule: "snapshot-reference", + floor, + offered: facts.snapshot.version, + }; + if ( + vector.timestamp && + facts.snapshot.version === vector.timestamp.snapshot.version && + !sameReference(vector.timestamp.snapshot, facts.snapshot) + ) + return { + role: "timestamp", + rule: "content", + floor, + offered: facts.snapshot.version, + }; + return undefined; + } + if (facts.kind === "snapshot") { + const own = versionOrContent("snapshot", vector.snapshot); + if (own) return own; + const named = vector.timestamp?.snapshot.version ?? 0; + if (facts.version < named) + return { + role: "snapshot", + rule: "snapshot-reference", + floor: named, + offered: facts.version, + }; + // 4. No previously recorded entry may disappear, go down, or change its + // content at the same version; and no entry may be lower than a role of + // that name that was actually verified. + const names = new Set([ + ...Object.keys(vector.snapshot?.meta ?? {}), + ...Object.keys(vector.roles), + ]); + for (const name of names) { + const recorded = vector.snapshot?.meta[name]; + const floor = Math.max( + recorded?.version ?? 0, + vector.roles[name]?.version ?? 0, + ); + const offered = facts.meta[name]; + if (!offered || offered.version < floor) + return { + role: name, + rule: "snapshot-meta", + floor, + offered: offered?.version ?? 0, + }; + if ( + recorded && + offered.version === recorded.version && + !sameReference(recorded, offered) + ) + return { + role: name, + rule: "content", + floor, + offered: offered.version, + }; + } + return undefined; + } + const own = versionOrContent(facts.name, vector.roles[facts.name]); + if (own) return own; + const listed = vector.snapshot?.meta[facts.name]?.version ?? 0; + return facts.version < listed + ? { + role: facts.name, + rule: "version", + floor: listed, + offered: facts.version, + } + : undefined; +} + +/** Element-wise maximum. Monotonic by construction: nothing in the result is + * lower than in `vector`, whatever `facts` says, so merging is safe even for + * facts that were not compared first. + */ +export function mergeFloors( + vector: FloorVector, + all: readonly RoleFacts[], +): FloorVector { + let { root, timestamp, snapshot } = vector; + const roles: Record = { ...vector.roles }; + for (const facts of all) { + const role: RoleFloor = { + version: facts.version, + signedSha256: facts.signedSha256, + }; + if (facts.kind === "root") + root = { + version: Math.max(root.version, facts.version), + retained: { + [String(facts.version)]: facts.signedSha256, + // What is retained already is never replaced. + ...root.retained, + }, + }; + else if (facts.kind === "timestamp") { + if (!timestamp || facts.version > timestamp.version) + timestamp = { + ...role, + snapshot: + timestamp && timestamp.snapshot.version > facts.snapshot.version + ? timestamp.snapshot + : facts.snapshot, + }; + } else if (facts.kind === "snapshot") { + if (!snapshot || facts.version > snapshot.version) { + const meta: Record = { ...facts.meta }; + for (const [name, floor] of Object.entries(snapshot?.meta ?? {})) + if ((meta[name]?.version ?? 0) < floor.version) meta[name] = floor; + snapshot = { ...role, meta }; + } + } else if ((roles[facts.name]?.version ?? 0) < facts.version) + roles[facts.name] = role; + } + return { root, timestamp, snapshot, roles }; +} + +/** Stable text: equal vectors are equal bytes, so "nothing changed" is a + * string comparison and an unchanged vector is never rewritten. + */ +export function serializeFloors(vector: FloorVector): string { + return `${canonicalize({ schemaVersion: 1, ...vector })}\n`; +} + +const isDigest = (value: unknown): value is string => + typeof value === "string" && /^[a-f0-9]{64}$/.test(value); +const isVersion = (value: unknown): value is number => + typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +const isRecord = (value: unknown): value is Record => + !!value && typeof value === "object" && !Array.isArray(value); + +function parseMetaFloor(value: unknown): MetaFloor { + if ( + !isRecord(value) || + !isVersion(value.version) || + !(value.length === undefined || isVersion(value.length)) || + !( + value.hashes === undefined || + (isRecord(value.hashes) && + Object.values(value.hashes).every((hash) => typeof hash === "string")) + ) + ) + throw new Error("Unrecognized floor reference"); + return Object.freeze({ + version: value.version, + ...(value.length === undefined ? {} : { length: value.length }), + ...(value.hashes === undefined + ? {} + : { hashes: value.hashes as Record }), + }); +} + +function parseRoleFloor(value: unknown): RoleFloor { + if ( + !isRecord(value) || + !isVersion(value.version) || + !isDigest(value.signedSha256) + ) + throw new Error("Unrecognized role floor"); + return { version: value.version, signedSha256: value.signedSha256 }; +} + +/** Throws on anything but a well-formed record of the known schema. */ +export function parseFloors(text: string): FloorVector { + const value = parseUniqueJson(text); + if ( + !isRecord(value) || + value.schemaVersion !== 1 || + !isRecord(value.root) || + !isVersion(value.root.version) || + !isRecord(value.root.retained) || + !Object.entries(value.root.retained).every( + ([version, digest]) => /^[1-9]\d*$/.test(version) && isDigest(digest), + ) || + !isRecord(value.roles) + ) + throw new Error("Unrecognized floor vector"); + const timestamp = value.timestamp; + const snapshot = value.snapshot; + return { + root: { + version: value.root.version, + retained: value.root.retained as Record, + }, + timestamp: + timestamp === null + ? null + : { + ...parseRoleFloor(timestamp), + snapshot: parseMetaFloor( + (timestamp as Record).snapshot, + ), + }, + snapshot: + snapshot === null + ? null + : { + ...parseRoleFloor(snapshot), + meta: Object.fromEntries( + Object.entries( + (snapshot as Record).meta as Record< + string, + unknown + >, + ).map(([name, floor]) => [name, parseMetaFloor(floor)]), + ), + }, + roles: Object.fromEntries( + Object.entries(value.roles).map(([name, floor]) => [ + name, + parseRoleFloor(floor), + ]), + ), + }; +} + +/** Rebuild the vector from what `trust/` retains, for a `floors.json` that is + * missing or unreadable. The retained numbered roots are walked as a verified + * chain; each role file then contributes its facts when it verifies under ANY + * root of that chain, newest first — the root that was valid for it. A role + * signed by a since revoked key is exactly the case this exists for. Never + * throws and never returns less than those files prove. + */ +export async function rebuildFloors( + trustDirectory: string, +): Promise { + const text = (name: string) => + readFile(join(trustDirectory, name), "utf8").catch(() => undefined); + const versions = (await readdir(trustDirectory).catch(() => [])) + .map((name) => /^([1-9]\d*)\.root\.json$/.exec(name)?.[1]) + .filter((version): version is string => version !== undefined) + .map(Number) + .sort((left, right) => left - right); + const chain: Metadata[] = []; + const facts: RoleFacts[] = []; + const candidates = [ + ...(await Promise.all( + versions.map((version) => text(`${version}.root.json`)), + )), + await text("root.json"), + ]; + for (const candidate of candidates) { + if (candidate === undefined) continue; + try { + const root = parse(MetadataKind.Root, candidate) as Metadata; + const previous = chain.at(-1); + root.verifyDelegate(MetadataKind.Root, root); + if (previous) { + if (root.signed.version === previous.signed.version) continue; + if (root.signed.version !== previous.signed.version + 1) break; + previous.verifyDelegate(MetadataKind.Root, root); + } + chain.push(root); + facts.push(factsOf("root.json", candidate)); + } catch { + // Not a link of the chain: what came before it still stands. + } + } + for (const name of ["timestamp.json", "snapshot.json", "targets.json"]) { + const candidate = await text(name); + if (candidate === undefined) continue; + for (const root of [...chain].reverse()) + try { + const kind = kindOfFile(name); + root.verifyDelegate(kind, parse(kind, candidate)); + facts.push(factsOf(name, candidate)); + break; + } catch { + // Try the root before it. + } + } + return mergeFloors(emptyFloors, facts); +} + +/** The durable vector, or the rebuilt one. `stored` is the text on disk when + * it was usable, so the caller knows whether a write is due. + */ +export async function readFloors( + trustDirectory: string, +): Promise> { + try { + const stored = await readFile(join(trustDirectory, floorsFile), "utf8"); + return { vector: parseFloors(stored), stored }; + } catch { + return { vector: await rebuildFloors(trustDirectory), stored: undefined }; + } +} + +export async function writeFloors( + trustDirectory: string, + vector: FloorVector, + write: DurableWriter, +): Promise { + await write(trustDirectory, floorsFile, Buffer.from(serializeFloors(vector))); +} + +export function rollback(violation: FloorViolation): UpdateFailure { + return new UpdateFailure("metadata-rollback", violationContext(violation)); +} diff --git a/src/update/trust.ts b/src/update/trust.ts index 508f22dd..51a32ea0 100644 --- a/src/update/trust.ts +++ b/src/update/trust.ts @@ -4,7 +4,6 @@ import { Metadata, MetadataKind, type Root, - type Snapshot, type Timestamp, } from "@tufjs/models"; import { parseUniqueJson } from "../providers/unique-json"; @@ -17,6 +16,18 @@ import { } from "./channel"; import type { DurableWriter } from "./durable-file"; import { UpdateFailure } from "./errors"; +import { + type FloorViolation, + factsOf, + mergeFloors, + type RoleFacts, + readFloors, + rollback, + serializeFloors, + floorsFile as vectorFile, + violationOf, + writeFloors, +} from "./floors"; /** Durable trust: `/trust/` holds the verified TUF metadata and the * channel floors (docs/update.md "State on disk", "Trust never rewinds"). @@ -25,6 +36,7 @@ import { UpdateFailure } from "./errors"; * client is seeded with as its anchor * .root.json the retained, verified root chain * timestamp.json, snapshot.json, targets.json + * floors.json the floor vector (`floors.ts`) * channel-floors.json owned, schema-versioned channel high-water marks * * The directory tolerates unrelated entries: only these names are ever read. @@ -82,8 +94,10 @@ import { UpdateFailure } from "./errors"; * (→ :209-215) — and `Updater` never reaches `persistMetadata` * (updater.js:225, :251). For the life of that process the role is the * client's rollback floor; on disk it would be lost, and a later replay of a - * LOWER, still valid version would pass. `capturedFloor` below closes that - * gap from the raw bytes the fetcher retained. + * LOWER, still valid version would pass. `authenticatedLastDelivered` below + * closes that gap from the raw bytes the fetcher retained, and the floor + * vector (`floors.ts`) keeps the facts even when the file itself is lost or + * no longer loads. * * Why a plain role file in `trust/` is a floor and nothing more — how the * pinned client loads an expired LOCAL role: @@ -225,26 +239,22 @@ function verifiedRootChain( return chain; } -/** A role the client was judging when the refresh failed, re-verified WITHOUT - * the client and WITHOUT looking at expiry: is it an authentic, newer version - * that must be remembered as a rollback floor? Returns the text to keep, or - * undefined to discard. Every check the store makes before it installs the - * role in memory is repeated here with the same `@tufjs/models` primitives: +/** A role the client was judging when the refresh failed, AUTHENTICATED + * without the client and without looking at expiry. Returns its text, or + * undefined when it is not authentic. Whether it is a rollback, an + * equivocation, a no-op or a newer floor is NOT decided here: that is the + * floor vector's comparison, the same one every other candidate goes through. * * timestamp signature threshold of the trusted root's timestamp role - * (store.js:68); version and snapshot version not below the - * trusted timestamp's (:72, :83). An equal version is nothing new. + * (store.js:68). * snapshot length and hashes recorded by the newest authenticated - * timestamp (:108), signature threshold (:118), exactly the - * version that timestamp names (:214), and no targets version - * below the trusted snapshot's (:122-131). + * timestamp (:108), signature threshold (:118), and exactly the + * version that timestamp names (:214). */ -function capturedFloor(input: { +function authenticatedLastDelivered(input: { delivered: Readonly<{ file: string; bytes: Buffer }>; root: Metadata; - /** Text of the role as currently trusted (seed), if any. */ - trusted: string | undefined; - /** Newest authenticated timestamp: the client's, or a just captured floor. */ + /** Newest authenticated timestamp: the client's, or a just captured one. */ timestamp: string | undefined; }): string | undefined { const { delivered, root } = input; @@ -261,47 +271,20 @@ function capturedFloor(input: { root.verifyDelegate(MetadataKind.Timestamp, metadata); return metadata; }; - const snapshotOf = (source: string): Metadata => { - const metadata = Metadata.fromJSON(MetadataKind.Snapshot, json(source)); - if (metadata.signed.type !== MetadataKind.Snapshot) - throw new Error("Not a snapshot"); - root.verifyDelegate(MetadataKind.Snapshot, metadata); - return metadata; - }; - // A trusted file that no longer verifies under this root is no floor. - const orNone = (read: (source: string) => T, source?: string) => { - try { - return source === undefined ? undefined : read(source); - } catch { - return undefined; - } - }; if (delivered.file === "timestamp.json") { - const next = timestampOf(text); - const trusted = orNone(timestampOf, input.trusted); - if ( - trusted && - (next.signed.version <= trusted.signed.version || - next.signed.snapshotMeta.version < - trusted.signed.snapshotMeta.version) - ) - return undefined; + timestampOf(text); return text; } - if (delivered.file === "snapshot.json") { - const timestamp = orNone(timestampOf, input.timestamp); - if (!timestamp) return undefined; + if (delivered.file === "snapshot.json" && input.timestamp !== undefined) { + const timestamp = timestampOf(input.timestamp); timestamp.signed.snapshotMeta.verify(delivered.bytes); - const next = snapshotOf(text); - if (next.signed.version !== timestamp.signed.snapshotMeta.version) - return undefined; - const trusted = orNone(snapshotOf, input.trusted); - for (const [name, info] of Object.entries(trusted?.signed.meta ?? {})) { - const replacement = next.signed.meta[name]; - if (!replacement || replacement.version < info.version) - return undefined; - } - return text; + const next = Metadata.fromJSON(MetadataKind.Snapshot, json(text)); + if (next.signed.type !== MetadataKind.Snapshot) + throw new Error("Not a snapshot"); + root.verifyDelegate(MetadataKind.Snapshot, next); + return next.signed.version === timestamp.signed.snapshotMeta.version + ? text + : undefined; } } catch { // Not authentic, or not metadata at all. @@ -311,28 +294,43 @@ function capturedFloor(input: { } /** Capture trust when the refresh ENDS — success or failure — and before the - * scratch is deleted, in the two ways of docs/update.md "Check": + * scratch is deleted (docs/update.md "Check"): * 1. role files the client persisted in scratch (complete verification); - * 2. after a FAILED refresh, the role delivered last, when `capturedFloor` - * authenticates it as a newer version. It is written as the ordinary role - * file: for the client an expired local role is a version floor and - * nothing else (see the top of this file). + * 2. after a FAILED refresh, the role delivered last, when it is authentic. + * It is written as the ordinary role file: for the client an expired local + * role is a version floor and nothing else (see the top of this file). + * + * THE FLOOR VECTOR decides. Every candidate is compared with `floors.json` + * (`floors.ts`) first: after a failed refresh the roles that changed and the + * captured one; after a SUCCESSFUL refresh every role the client now trusts, + * changed or not — a refresh that "succeeds" on a replayed, unchanged state + * below the vector must not authorize a target either. One violation refuses + * the whole result with `metadata-rollback`; only a valid root chain is kept. * - * Order: numbered root chain ascending, `root.json`, timestamp, snapshot, - * targets; each file by temporary file + sync + rename + directory sync. The - * first failed write stops promotion, so `trust/` always holds a PREFIX of - * that order. Every prefix is a state the client recovers from by itself: a + * WRITE ORDER, and why any interruption is safe: + * (1) `floors.json`, the element-wise maximum of the old vector and the + * accepted candidates; (2) numbered root chain ascending; (3) `root.json`; + * (4) timestamp, snapshot, targets. + * Each write is temporary file + sync + rename + directory sync, and the first + * failed write stops everything after it. The vector is therefore NEVER lower + * than what the files prove: a fact reaches a file only after it reached the + * vector. Interrupted after (1), the vector is ahead of the files — which + * cannot wedge, because the vector only refuses what is LOWER or DIFFERENT: + * the same repository state yields candidates that equal it (a no-op) and any + * later valid state exceeds it. Interrupted inside (2)–(4), `trust/` holds a + * PREFIX of that order, each of which the client recovers from by itself: a * newer root with older roles is re-verified under that root, and a newer * timestamp with an older snapshot makes the client fetch the snapshot the - * timestamp names (updater.js:231-256). A crash before the first write equals - * a refresh that never ran — a floor included: the same repository state - * yields the same floor at the next check. + * timestamp names (updater.js:231-256). Interrupted before (1), the refresh + * never ran. */ export async function promoteVerified(input: { trustDirectory: string; scratch: string; seed: Seed; fetchedRoots: ReadonlyMap; + /** The refresh completed without error. */ + refreshed: boolean; /** Only after a FAILED refresh: the role the client was judging. */ lastDelivered: Readonly<{ file: string; bytes: Buffer }> | undefined; write: DurableWriter; @@ -341,31 +339,62 @@ export async function promoteVerified(input: { const finalRoot = await readFile(join(scratch, "root.json"), "utf8"); const chain = verifiedRootChain(seed.root, finalRoot, input.fetchedRoots); const changedRoles: { name: string; text: string }[] = []; + const compared: RoleFacts[] = []; + let timestamp = await readOptional(join(scratch, "timestamp.json")); for (const name of roleFiles) { let text = await readOptional(join(scratch, name)); if (input.lastDelivered?.file === name) text = - capturedFloor({ + authenticatedLastDelivered({ delivered: input.lastDelivered, root: parseRoot(finalRoot), - trusted: seed.roles.get(name), - timestamp: - changedRoles.find((role) => role.name === "timestamp.json")?.text ?? - (await readOptional(join(scratch, "timestamp.json"))), + timestamp, }) ?? text; - if (text !== undefined && text !== seed.roles.get(name)) - changedRoles.push({ name, text }); + if (text === undefined) continue; + if (name === "timestamp.json") timestamp = text; + const changed = text !== seed.roles.get(name); + if (changed) changedRoles.push({ name, text }); + if (changed || input.refreshed) + try { + compared.push(factsOf(name, text)); + } catch { + // An unchanged seed file that is not metadata states nothing. + } } // A caller-supplied root that verified nothing carries no accepted trust: // keeping it out means a wrong or mistyped bootstrap root can never wedge // the installation, and the same command can simply be repeated. if (!seed.established && finalRoot === seed.root && !changedRoles.length) return []; + const { vector, stored } = await readFloors(trustDirectory); + const roots = chain.map(({ text }) => factsOf("root.json", text)); + // A root that contradicts a retained one ends everything: nothing is kept. + for (const facts of roots) { + const violation = violationOf(vector, facts); + if (violation) throw rollback(violation); + } + // Candidates are compared in promotion order, each against the vector as + // the ones before it leave it: a snapshot is held against the timestamp of + // THIS refresh too. + let violation: FloorViolation | undefined; + let accepted = mergeFloors(vector, roots); + for (const facts of compared) { + violation = violationOf(accepted, facts); + if (violation) break; + accepted = mergeFloors(accepted, [facts]); + } + const next = violation ? mergeFloors(vector, roots) : accepted; const promoted: string[] = []; const put = async (name: string, text: string) => { await write(trustDirectory, name, Buffer.from(text, "utf8")); promoted.push(name); }; + // (1) The vector first. An unchanged vector is not rewritten; a rebuilt one + // (`stored` undefined) is. + if (serializeFloors(next) !== stored) { + await writeFloors(trustDirectory, next, write); + promoted.push(vectorFile); + } for (const { version, text } of chain) { const name = `${version}.root.json`; if ((await readOptional(join(trustDirectory, name))) !== text) @@ -373,6 +402,7 @@ export async function promoteVerified(input: { } if (!seed.established || finalRoot !== seed.root) await put("root.json", finalRoot); + if (violation) throw rollback(violation); for (const { name, text } of changedRoles) await put(name, text); return promoted; } diff --git a/tests/update-check.test.ts b/tests/update-check.test.ts index 4b478a62..0687e451 100644 --- a/tests/update-check.test.ts +++ b/tests/update-check.test.ts @@ -88,6 +88,7 @@ test("first check with a bootstrap root promotes trust, writes the observation a expect((await readdir(join(base, "trust"))).sort()).toEqual([ "1.root.json", "channel-floors.json", + "floors.json", "root.json", "snapshot.json", "targets.json", @@ -295,8 +296,9 @@ test("a crash between any two promotions converges on the next check", async () }; await counting.check({ writeDurable: counted }); const converged = await counting.trustSnapshot(); - // 2.root.json, root.json, timestamp, snapshot, targets, floors, observed. - expect(writes).toBe(7); + // floors.json (the vector, FIRST), 2.root.json, root.json, timestamp, + // snapshot, targets, channel floors, observed. + expect(writes).toBe(8); for (let survive = 0; survive < writes; survive++) { const { fixture, check, trustSnapshot, base } = await scenario(); @@ -312,14 +314,26 @@ test("a crash between any two promotions converges on the next check", async () await writeDurableFile(...args); }; const interrupted = await check({ writeDurable: dying }); - if (survive < 6) + if (survive < 7) expect(interrupted).toMatchObject({ kind: "error", code: "internal" }); // Promotion order is an invariant, not a habit: whatever survived is a - // PREFIX of root chain → root → timestamp → snapshot → targets → floor, so - // trust/ never holds a role newer than the root that verifies it. + // PREFIX of vector → root chain → root → timestamp → snapshot → targets → + // channel floor, so trust/ never holds a role newer than the root that + // verifies it, and the vector is never LOWER than what the files prove. const partial = await trustSnapshot(); const at = (name: string): number => JSON.parse(partial[name] ?? "").signed.version; + const vector = JSON.parse(partial["floors.json"] ?? ""); + expect(vector.root.version).toBeGreaterThanOrEqual(at("root.json")); + expect(vector.timestamp.version).toBeGreaterThanOrEqual( + at("timestamp.json"), + ); + expect(vector.snapshot.version).toBeGreaterThanOrEqual(at("snapshot.json")); + expect(vector.roles["targets.json"].version).toBeGreaterThanOrEqual( + at("targets.json"), + ); + // Written first: ahead of the files from the first surviving write on. + expect(vector.timestamp.version).toBe(survive === 0 ? 1 : 3); if (at("root.json") === 2) expect(partial["2.root.json"]).toBeDefined(); if (at("timestamp.json") > 1) expect(at("root.json")).toBe(2); if (at("snapshot.json") > 1) expect(at("timestamp.json")).toBe(3); @@ -332,6 +346,7 @@ test("a crash between any two promotions converges on the next check", async () // Exactly `survive` promotions happened before the kill. expect( [ + vector.timestamp.version === 3, partial["2.root.json"] !== undefined, at("root.json") === 2, at("timestamp.json") === 3, @@ -340,7 +355,7 @@ test("a crash between any two promotions converges on the next check", async () JSON.parse(partial["channel-floors.json"] ?? "").channels.stable .sequence === 2, ].filter(Boolean).length, - ).toBe(Math.min(survive, 6)); + ).toBe(Math.min(survive, 7)); // Whatever prefix survived, a plain next check finishes the job. expect(await check()).toMatchObject({ kind: "available", @@ -356,6 +371,13 @@ test("a crash between any two promotions converges on the next check", async () expect( JSON.parse(after["channel-floors.json"] ?? "").channels.stable, ).toMatchObject({ sequence: 2 }); + // A vector that was ahead did not wedge, and ends where the files are. + expect(JSON.parse(after["floors.json"] ?? "")).toMatchObject({ + root: { version: 2 }, + timestamp: { version: 3, snapshot: { version: 3 } }, + snapshot: { version: 3, meta: { "targets.json": { version: 3 } } }, + roles: { "targets.json": { version: 3 } }, + }); expect((await readObserved(base, clock())).status).toBe("available"); } }, 60_000); @@ -392,6 +414,7 @@ test("an expired timestamp is a typed error that never wedges; the newer version }); expect((await readdir(join(base, "trust"))).sort()).toEqual([ "1.root.json", + "floors.json", "root.json", "timestamp.json", ]); @@ -416,9 +439,17 @@ test("an expired timestamp is a typed error that never wedges; the newer version code: "metadata-expired", }); expect(await roleVersion("timestamp.json")).toBe(valid + 1); - expect({ ...(await trustSnapshot()), "timestamp.json": "" }).toEqual({ - ...before, + // Only the timestamp and the vector that remembers it moved. + expect({ + ...(await trustSnapshot()), "timestamp.json": "", + "floors.json": "", + }).toEqual({ ...before, "timestamp.json": "", "floors.json": "" }); + expect( + JSON.parse((await trustSnapshot())["floors.json"] ?? ""), + ).toMatchObject({ + timestamp: { version: valid + 1, snapshot: { version: valid + 1 } }, + snapshot: { version: valid }, }); // The verified target stays visible while the error is shown. expect(await readObserved(base, clock())).toMatchObject({ @@ -434,7 +465,8 @@ test("an expired timestamp is a typed error that never wedges; the newer version fixture.rewindTo(valid); expect(await check()).toMatchObject({ kind: "error", - code: "metadata-invalid", + code: "metadata-rollback", + context: { role: "timestamp", rule: "version", floor: valid + 1 }, }); expect(await trustSnapshot()).toEqual(floor); // (c) No wedge: a fresh higher generation is accepted. @@ -467,15 +499,16 @@ test("an expired snapshot: the newer version the timestamp names is kept as a fl fixture.rewindTo(valid); expect(await check()).toMatchObject({ kind: "error", - code: "metadata-invalid", + code: "metadata-rollback", + context: { role: "timestamp", rule: "version" }, }); expect(await trustSnapshot()).toEqual(floor); - // …and by the snapshot floor ALONE when the timestamp file is lost: the - // older snapshot names a lower targets version than the floor does. + // …and still when the timestamp FILE is lost: the vector remembers it, and + // so does the expired snapshot the client loads as a floor. await rm(join(base, "trust", "timestamp.json")); expect(await check()).toMatchObject({ kind: "error", - code: "metadata-invalid", + code: "metadata-rollback", }); expect(await roleVersion("snapshot.json")).toBe(valid + 1); expect(await roleVersion("targets.json")).toBe(valid); @@ -509,9 +542,10 @@ test("a role delivered last that is not authentic or not newer is never kept, an expect(await check()).toMatchObject({ code: "metadata-invalid" }); expect(await roleVersion("snapshot.json")).toBe(1); const moved = await trustSnapshot(); - // An authentic LOWER version delivered last (a replay) is no floor. + // An authentic LOWER version delivered last (a replay) is no floor: it is + // named for what it is. fixture.rewindTo(1); - expect(await check()).toMatchObject({ code: "metadata-invalid" }); + expect(await check()).toMatchObject({ code: "metadata-rollback" }); expect(await trustSnapshot()).toEqual(moved); // (f) The process dies between capture and promotion, at every write: diff --git a/tests/update-floors.test.ts b/tests/update-floors.test.ts new file mode 100644 index 00000000..af04f75e --- /dev/null +++ b/tests/update-floors.test.ts @@ -0,0 +1,536 @@ +import { afterEach, expect, test } from "bun:test"; +import { + mkdtemp, + readdir, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createUpdateFixture } from "../scripts/update-fixture"; +import { DistributionTransport } from "../src/distribution/transport"; +import { type CheckInput, checkForUpdate } from "../src/update/check"; +import { + type DurableWriter, + writeDurableFile, +} from "../src/update/durable-file"; +import { + emptyFloors, + type FloorVector, + factsOf, + mergeFloors, + parseFloors, + type RoleFacts, + rebuildFloors, + serializeFloors, + violationOf, +} from "../src/update/floors"; + +// The floor vector (docs/update.md "The floor vector"). Every case the +// retained mechanism proves in tests/historical-roles.test.ts has its +// equivalent here, against the new path; the mapping is in the PR body and in +// the comment above each test ("ports: …"). +const target = "linux-x64"; +const past = "2020-01-01T00:00:00.000Z"; +const later = "2031-01-01T00:00:00.000Z"; +const clock = () => new Date("2026-09-19T10:00:00.000Z"); +const cleanups: (() => Promise)[] = []; +afterEach(async () => { + for (const cleanup of cleanups.splice(0)) await cleanup(); +}); + +function repository() { + const fixture = createUpdateFixture({ executionTarget: target }); + cleanups.push(() => fixture.stop()); + const text = (path: string) => fixture.served(path)?.toString() ?? ""; + /** Facts of everything the repository serves for its CURRENT generation. */ + const facts = (): RoleFacts[] => [ + factsOf("timestamp.json", text("/metadata/timestamp.json")), + factsOf( + "snapshot.json", + text(`/metadata/${fixture.version()}.snapshot.json`), + ), + factsOf( + "targets.json", + text(`/metadata/${fixture.version()}.targets.json`), + ), + ]; + return { fixture, text, facts }; +} + +const violations = (vector: FloorVector, all: readonly RoleFacts[]) => + all.flatMap((facts) => { + const violation = violationOf(vector, facts); + return violation ? [`${violation.role}:${violation.rule}`] : []; + }); + +// ports: "publication comparison refuses lost counters, omitted roles and root +// substitution" (version part), "expired distinct-role chain authenticates +// floors" (what a floor consists of) +test("rule 1: every role has its own version floor; an identical state is a no-op and a higher one is accepted", () => { + const { fixture, facts } = repository(); + fixture.release("stable", { sequence: 1, version: "1.0.0" }); + const first = facts(); + fixture.publish(); + const second = facts(); + const vector = mergeFloors(emptyFloors, second); + expect(vector).toMatchObject({ + timestamp: { version: 2, snapshot: { version: 2 } }, + snapshot: { version: 2, meta: { "targets.json": { version: 2 } } }, + roles: { "targets.json": { version: 2 } }, + }); + expect(violations(vector, second)).toEqual([]); + expect(violations(vector, first)).toEqual([ + "timestamp:version", + "snapshot:version", + "targets.json:version", + ]); + fixture.publish(); + expect(violations(vector, facts())).toEqual([]); + // Nothing at all is refused by an empty vector: first contact. + expect(violations(emptyFloors, first)).toEqual([]); +}); + +// ports: "validly signed cannot extend expiry at +// the same version", "content bindings ignore JSON formatting but retain +// unknown signed fields" +test("rule 2: the same version with different signed content is refused for timestamp, snapshot and targets; formatting, key order and signatures do not count, unknown signed members do", () => { + const { fixture, facts, text } = repository(); + fixture.release("stable", { sequence: 1, version: "1.0.0" }); + fixture.publish({ + expires: { timestamp: past, snapshot: past, targets: past }, + }); + const expired = facts(); + const vector = mergeFloors(emptyFloors, expired); + // The holder of the role keys "extends" the expiry at the SAME version: in + // isolation a perfectly valid chain. + for (const role of ["timestamp", "snapshot", "targets"] as const) { + fixture.publish({ + at: 2, + expires: { + timestamp: past, + snapshot: past, + targets: past, + [role]: later, + }, + }); + expect([role, violations(vector, facts())]).toEqual([ + role, + // The changed role itself, and every role above it: their signed + // content names the changed one by hash. + role === "timestamp" + ? ["timestamp:content"] + : role === "snapshot" + ? ["timestamp:content", "snapshot:content"] + : ["timestamp:content", "snapshot:content", "targets.json:content"], + ]); + } + // The signed content decides, not the bytes: other key order, other + // whitespace, other signatures — the same role. + const original = text("/metadata/timestamp.json"); + const envelope = JSON.parse(original); + const reordered = JSON.stringify( + { + signed: Object.fromEntries(Object.entries(envelope.signed).reverse()), + signatures: [], + }, + null, + 2, + ); + expect(reordered).not.toBe(original); + expect(factsOf("timestamp.json", reordered)).toEqual( + factsOf("timestamp.json", original), + ); + // An unknown signed member IS content. + envelope.signed.customBinding = "new signed content"; + const unknown = factsOf("timestamp.json", JSON.stringify(envelope)); + expect( + violationOf( + mergeFloors(emptyFloors, [factsOf("timestamp.json", original)]), + unknown, + ), + ).toMatchObject({ role: "timestamp", rule: "content" }); +}); + +// ports: "higher snapshot reference cannot mask rollback of the cached +// snapshot itself", "trusted input conversion preserves snapshot's own higher +// version" +test("rule 3: a timestamp may not name a snapshot below ANY snapshot version this Machine authenticated — by reference or as a snapshot", () => { + const { fixture, facts, text } = repository(); + fixture.release("stable", { sequence: 1, version: "1.0.0" }); + fixture.publish(); + fixture.publish(); + const vector = mergeFloors(emptyFloors, facts()); + // Version 4 of the timestamp, naming the still served snapshot 1. + fixture.publish({ timestampReferences: 1 }); + const lowered = factsOf("timestamp.json", text("/metadata/timestamp.json")); + expect(lowered).toMatchObject({ version: 4, snapshot: { version: 1 } }); + expect(violationOf(vector, lowered)).toEqual({ + role: "timestamp", + rule: "snapshot-reference", + floor: 3, + offered: 1, + }); + // The reference floor alone (only a timestamp was ever captured, e.g. as an + // expired floor) binds a later snapshot too… + const referenceOnly = mergeFloors(emptyFloors, [ + factsOf("timestamp.json", text("/metadata/timestamp.json")), + ]); + expect(referenceOnly.snapshot).toBeNull(); + // …and the snapshot's OWN verified version binds a timestamp whose recorded + // reference floor is lower. + const snapshotOnly = mergeFloors(emptyFloors, [ + factsOf("snapshot.json", text("/metadata/3.snapshot.json")), + ]); + expect(violationOf(snapshotOnly, lowered)).toMatchObject({ + rule: "snapshot-reference", + floor: 3, + }); + expect( + violationOf( + mergeFloors(emptyFloors, [ + factsOf("timestamp.json", text("/metadata/timestamp.json")), + { + kind: "timestamp", + version: 9, + signedSha256: "b".repeat(64), + snapshot: { version: 7 }, + }, + ]), + factsOf("snapshot.json", text("/metadata/3.snapshot.json")), + ), + ).toEqual({ + role: "snapshot", + rule: "snapshot-reference", + floor: 7, + offered: 3, + }); +}); + +// ports: "publication comparison refuses … omitted roles" (entry removed, +// entry lowered), "a later snapshot retains missing historical role floors +// without pretending it is accepted" +test("rule 4: no snapshot.meta entry that was ever recorded may disappear, go down, or name other content at the same version", () => { + const { fixture, facts } = repository(); + fixture.release("stable", { sequence: 1, version: "1.0.0" }); + fixture.publish({ extraMeta: { "retained.json": 12 } }); + const vector = mergeFloors(emptyFloors, facts()); + expect(vector.snapshot?.meta).toMatchObject({ + "targets.json": { version: 2 }, + "retained.json": { version: 12 }, + }); + fixture.publish(); + expect(violations(vector, facts())).toEqual(["retained.json:snapshot-meta"]); + fixture.publish({ extraMeta: { "retained.json": 11 } }); + expect(violations(vector, facts())).toEqual(["retained.json:snapshot-meta"]); + fixture.publish({ extraMeta: { "retained.json": 12, "new.json": 1 } }); + const grown = facts(); + expect(violations(vector, grown)).toEqual([]); + // A refused snapshot never erases what was recorded; an accepted one only + // adds. + expect(mergeFloors(vector, grown).snapshot?.meta).toMatchObject({ + "targets.json": { version: 5 }, + "retained.json": { version: 12 }, + "new.json": { version: 1 }, + }); + // An entry may not fall below a role of that name that was VERIFIED, even + // when no snapshot entry recorded it. + const verifiedOnly = mergeFloors(emptyFloors, [ + { + kind: "role", + name: "targets.json", + version: 9, + signedSha256: "a".repeat(64), + }, + ]); + expect(violations(verifiedOnly, grown)).toEqual([ + "targets.json:snapshot-meta", + "targets.json:version", + ]); +}); + +// ports: "partial subsequent cycles cannot erase earlier authenticated floors", +// "continuation rejects serialized floors … without mutating its input" +test("the vector is an element-wise maximum: partial and lower cycles never lower it, inputs are never mutated, and its text is canonical and strictly parsed", () => { + const { fixture, facts, text } = repository(); + fixture.release("stable", { sequence: 1, version: "1.0.0" }); + const first = facts(); + fixture.publish(); + const second = facts(); + fixture.publish(); + const timestampOnly = [ + factsOf("timestamp.json", text("/metadata/timestamp.json")), + ]; + const initial = mergeFloors(emptyFloors, second); + const before = serializeFloors(initial); + // A cycle that got only as far as the timestamp… + const partial = mergeFloors(initial, timestampOnly); + expect(partial).toMatchObject({ + timestamp: { version: 3, snapshot: { version: 3 } }, + snapshot: { version: 2 }, + roles: { "targets.json": { version: 2 } }, + }); + // …then a complete LOWER cycle, merged without having been compared. + const lower = mergeFloors(partial, first); + expect(serializeFloors(lower)).toBe(serializeFloors(partial)); + expect(serializeFloors(mergeFloors(lower, []))).toBe(serializeFloors(lower)); + expect(serializeFloors(initial)).toBe(before); + // Merge order does not matter. + expect( + serializeFloors(mergeFloors(mergeFloors(emptyFloors, first), second)), + ).toBe(serializeFloors(mergeFloors(mergeFloors(emptyFloors, second), first))); + expect(parseFloors(serializeFloors(partial))).toEqual(partial); + for (const damaged of [ + "", + "{}", + '{"schemaVersion":2}', + serializeFloors(partial).replace( + '"schemaVersion":1', + '"schemaVersion":1,"schemaVersion":1', + ), + serializeFloors(partial).replace( + /"signedSha256":"[a-f0-9]{8}/, + '"signedSha256":"XXXXXXXX', + ), + ]) + expect(() => parseFloors(damaged)).toThrow(); +}); + +// ————— Through the check, against the signed loopback repository ————— + +async function scenario() { + const fixture = createUpdateFixture({ executionTarget: target }); + const base = join( + await realpath(await mkdtemp(join(tmpdir(), "update-floors-"))), + "base", + ); + cleanups.push(async () => { + await fixture.stop(); + await rm(join(base, ".."), { recursive: true }); + }); + const check = (overrides: Partial = {}) => + checkForUpdate({ + base, + metadataBaseUrl: fixture.metadataBaseUrl, + targetBaseUrl: fixture.targetBaseUrl, + channel: "stable", + identity: { version: "1.0.0", commit: "a".repeat(40), target }, + transport: new DistributionTransport( + [fixture.origin], + 10_000, + new AbortController().signal, + true, + ), + clock, + lockTimeoutMs: 10_000, + ...overrides, + }); + const trust = join(base, "trust"); + const snapshot = async () => { + const entries: Record = {}; + for (const name of (await readdir(trust)).sort()) + entries[name] = await readFile(join(trust, name), "utf8"); + return entries; + }; + const vector = async () => + parseFloors(await readFile(join(trust, "floors.json"), "utf8")); + const roleVersion = async (name: string) => + JSON.parse(await readFile(join(trust, name), "utf8")).signed.version; + // Established at generation 3, then ALL role keys are rotated: every + // retained role file is signed by a revoked key and no longer loads in the + // client, so the client's own rollback checks have nothing to compare with. + const rotatedAt3 = async () => { + fixture.release("stable", { sequence: 1, version: "1.2.0" }); + await check({ bootstrapRoot: fixture.bootstrapRoot }); + fixture.publish({ extraMeta: { "retained.json": 12 } }); + fixture.publish({ extraMeta: { "retained.json": 12 } }); + expect(await check()).toMatchObject({ kind: "available" }); + expect(await roleVersion("timestamp.json")).toBe(3); + }; + const rotateRoleKeys = () => + fixture.rotateRoot({ rotate: ["timestamp", "snapshot", "targets"] }); + const targetRequests = () => + fixture.requests.filter((path) => path.startsWith("/targets/")); + return { + fixture, + base, + trust, + check, + snapshot, + vector, + roleVersion, + rotatedAt3, + rotateRoleKeys, + targetRequests, + }; +} + +// ports: every rule above "across root rotation" — the case the retained +// mechanism exists for ("root rotation requires consecutive version and old +// plus new authorization" keeps its own equivalent in update-trust.test.ts) +test("after a rotation that revoked every role key the client itself is blind; the vector refuses a lower version, a lowered reference, a removed or lowered meta entry and other content at the same version, looks up no target, and keeps only the root chain", async () => { + for (const [name, attack, expected] of [ + [ + "lower version of everything", + { at: 2, extraMeta: { "retained.json": 12 } }, + { role: "timestamp", rule: "version", floor: 3, offered: 2 }, + ], + [ + "timestamp names an older snapshot", + { timestampReferences: 1, extraMeta: { "retained.json": 12 } }, + { role: "timestamp", rule: "snapshot-reference", floor: 3, offered: 1 }, + ], + [ + "snapshot.meta entry removed", + {}, + { role: "retained.json", rule: "snapshot-meta", floor: 12, offered: 0 }, + ], + [ + "snapshot.meta entry lowered", + { extraMeta: { "retained.json": 11 } }, + { role: "retained.json", rule: "snapshot-meta", floor: 12, offered: 11 }, + ], + [ + "same version, other signed content", + { + at: 3, + extraMeta: { "retained.json": 12 }, + expires: { targets: later }, + }, + { role: "timestamp", rule: "content", floor: 3, offered: 3 }, + ], + ] as const) { + const s = await scenario(); + await s.rotatedAt3(); + const before = await s.snapshot(); + // The rotation itself publishes generation 4 with the new keys; what the + // Machine is then served instead is the attack, signed by the NEW keys. + s.fixture.rotateRoot({ rotate: ["timestamp", "snapshot", "targets"] }); + s.fixture.publish(attack); + s.fixture.requests.length = 0; + expect([name, await s.check()]).toEqual([ + name, + { kind: "error", code: "metadata-rollback", context: expected }, + ]); + // 3. No target was looked up under a refused refresh. + expect(s.targetRequests()).toEqual([]); + // Only the valid root chain was kept (and recorded in the vector). + const after = await s.snapshot(); + expect(Object.keys(after).sort()).toEqual( + [...Object.keys(before), "2.root.json"].sort(), + ); + for (const role of ["timestamp.json", "snapshot.json", "targets.json"]) + expect(after[role]).toBe(before[role] as string); + expect(JSON.parse(after["root.json"] ?? "").signed.version).toBe(2); + expect(await s.vector()).toMatchObject({ + root: { version: 2 }, + timestamp: { version: 3 }, + snapshot: { version: 3, meta: { "retained.json": { version: 12 } } }, + roles: { "targets.json": { version: 3 } }, + }); + // No wedge: what a repository that only moves forward serves is accepted. + s.fixture.publish({ at: 6, extraMeta: { "retained.json": 12 } }); + expect(await s.check()).toMatchObject({ kind: "available" }); + expect(await s.roleVersion("targets.json")).toBe(6); + } +}, 60_000); + +// ports: the interruption half of "partial subsequent cycles cannot erase +// earlier authenticated floors" +test("interrupted after the vector and before the files: the older, still valid generation — which the client would now accept again — is refused, and the same or a newer generation is not", async () => { + const s = await scenario(); + s.fixture.release("stable", { sequence: 1, version: "1.2.0" }); + await s.check({ bootstrapRoot: s.fixture.bootstrapRoot }); + s.fixture.publish(); + let done = 0; + const dying: DurableWriter = async (...args) => { + // Exactly one durable write survives: floors.json, which is first. + if (done >= 1) throw new Error("killed"); + done += 1; + await writeDurableFile(...args); + }; + expect(await s.check({ writeDurable: dying })).toMatchObject({ + code: "internal", + }); + expect(await s.roleVersion("timestamp.json")).toBe(1); + expect(await s.vector()).toMatchObject({ timestamp: { version: 2 } }); + // For the client generation 1 is "no change": it would succeed and + // authorize targets. The vector is what says no. + s.fixture.rewindTo(1); + s.fixture.requests.length = 0; + expect(await s.check()).toEqual({ + kind: "error", + code: "metadata-rollback", + context: { role: "timestamp", rule: "version", floor: 2, offered: 1 }, + }); + expect(s.targetRequests()).toEqual([]); + // A vector that is ahead does not wedge: the generation it was written for + // is a no-op for it and is promoted… + s.fixture.rewindTo(2); + expect(await s.check()).toMatchObject({ kind: "available" }); + expect(await s.roleVersion("targets.json")).toBe(2); + // …and so is a repository that simply advances. + s.fixture.publish(); + s.fixture.publish(); + expect(await s.check()).toMatchObject({ kind: "available" }); + expect(await s.vector()).toMatchObject({ + timestamp: { version: 4 }, + roles: { "targets.json": { version: 4 } }, + }); + s.fixture.rewindTo(3); + expect(await s.check()).toMatchObject({ code: "metadata-rollback" }); +}); + +// ports: "explicit previously trusted mixed-stage cache retains all historical +// versions" +test("a missing or damaged floors.json is rebuilt from the retained files, each under the retained root that was valid for it — across a rotation — and never blocks a check or resets to empty", async () => { + const s = await scenario(); + await s.rotatedAt3(); + // Rotation of every role key, and a refresh that gets only as far as the + // timestamp: trust/ now holds a timestamp signed by the NEW key and a + // snapshot and targets signed by REVOKED ones. + s.rotateRoleKeys(); + s.fixture.block(`/metadata/${s.fixture.version()}.snapshot.json`); + expect(await s.check()).toMatchObject({ code: "network-unavailable" }); + expect(await s.roleVersion("root.json")).toBe(2); + expect(await s.roleVersion("timestamp.json")).toBe(4); + expect(await s.roleVersion("snapshot.json")).toBe(3); + const recorded = await s.vector(); + expect(recorded).toMatchObject({ + root: { version: 2 }, + timestamp: { version: 4, snapshot: { version: 4 } }, + snapshot: { version: 3, meta: { "retained.json": { version: 12 } } }, + roles: { "targets.json": { version: 3 } }, + }); + expect(Object.keys(recorded.root.retained)).toEqual(["1", "2"]); + expect(await rebuildFloors(s.trust)).toEqual(recorded); + for (const damage of [ + () => rm(join(s.trust, "floors.json")), + () => writeFile(join(s.trust, "floors.json"), "{ not json"), + () => writeFile(join(s.trust, "floors.json"), '{"schemaVersion":2}'), + ]) { + await damage(); + // The rebuilt vector still refuses what the recorded one refused… + s.fixture.publish({ at: 2, extraMeta: { "retained.json": 12 } }); + expect(await s.check()).toMatchObject({ + code: "metadata-rollback", + context: { role: "timestamp", rule: "version", floor: 4 }, + }); + // …and is written back, equal to what was there before. + expect(await s.vector()).toEqual(recorded); + } + // A role file that verifies under NO retained root states nothing, and the + // rest still stands. + const stranger = createUpdateFixture({ executionTarget: target }); + cleanups.push(() => stranger.stop()); + stranger.release("stable", { sequence: 1, version: "9.9.9" }); + for (let generation = 0; generation < 8; generation++) stranger.publish(); + await writeFile( + join(s.trust, "targets.json"), + stranger.served("/metadata/9.targets.json") as Buffer, + ); + expect(await rebuildFloors(s.trust)).toEqual({ ...recorded, roles: {} }); + expect(await rebuildFloors(join(s.trust, "absent"))).toEqual(emptyFloors); +}); diff --git a/tests/update-trust.test.ts b/tests/update-trust.test.ts index a080843b..af88503d 100644 --- a/tests/update-trust.test.ts +++ b/tests/update-trust.test.ts @@ -64,24 +64,30 @@ test("after a failed refresh the role delivered last becomes a floor only when i delivered: [string, string]; }) => { const { trustDirectory, scratch } = await directories(); + // What an established installation holds: the seed IS its files. + await writeFile(join(trustDirectory, "1.root.json"), input.seed.root); + await writeFile(join(trustDirectory, "root.json"), input.seed.root); + for (const [name, content] of input.seed.roles) + await writeFile(join(trustDirectory, name), content); await writeFile(join(scratch, "root.json"), input.seed.root); for (const [name, content] of Object.entries(input.scratch)) await writeFile(join(scratch, name), content); - // The retained chain (`1.root.json`) is written into the empty directory - // every time; this test is about the roles. + // The vector is (re)built and written every time; this test is about the + // roles. return ( await promoteVerified({ trustDirectory, scratch, seed: input.seed, fetchedRoots: new Map(), + refreshed: false, lastDelivered: { file: input.delivered[0], bytes: Buffer.from(input.delivered[1]), }, write: writeDurableFile, }) - ).filter((name) => !name.endsWith("root.json")); + ).filter((name) => !name.endsWith("root.json") && name !== "floors.json"); }; const trusted = { "timestamp.json": first.timestamp }; // The client never wrote the newer timestamp (it threw); the raw bytes @@ -111,16 +117,28 @@ test("after a failed refresh the role delivered last becomes a floor only when i delivered: ["timestamp.json", delivered ?? ""], }), ]).toEqual([name, []]); - // Authentic but LOWER or EQUAL: a replay is no floor. + // Authentic and EQUAL is nothing new; authentic and LOWER is a rollback, + // named as one by the floor vector. const newer = { "timestamp.json": second.timestamp }; - for (const delivered of [first.timestamp, second.timestamp]) - expect( - await promote({ - seed: seed(newer), - scratch: newer, - delivered: ["timestamp.json", delivered], - }), - ).toEqual([]); + expect( + await promote({ + seed: seed(newer), + scratch: newer, + delivered: ["timestamp.json", second.timestamp], + }), + ).toEqual([]); + await expect( + promote({ + seed: seed(newer), + scratch: newer, + delivered: ["timestamp.json", first.timestamp], + }), + ).rejects.toMatchObject({ + failure: { + code: "metadata-rollback", + context: { role: "timestamp", rule: "version", floor: 2, offered: 1 }, + }, + }); // Snapshot: held against the newest authenticated timestamp, which the // client persisted before it asked for the snapshot. @@ -195,6 +213,7 @@ test("a root in scratch reaches trust/ only through a verified chain from the se scratch, seed, fetchedRoots, + refreshed: true, lastDelivered: undefined, write: writeDurableFile, }), @@ -209,10 +228,11 @@ test("a root in scratch reaches trust/ only through a verified chain from the se scratch, seed, fetchedRoots: new Map([[2, genuine]]), + refreshed: false, // A root delivered last needs no special case: the chain is verified // link by link whatever the client did with it. lastDelivered: { file: "2.root.json", bytes: Buffer.from(genuine) }, write: writeDurableFile, }), - ).toEqual(["1.root.json", "2.root.json", "root.json"]); + ).toEqual(["floors.json", "1.root.json", "2.root.json", "root.json"]); }); From 80770bc61c62ad13c0aaf1f23307ba8ec9b39ec5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Sat, 19 Sep 2026 04:57:39 +0200 Subject: [PATCH 11/11] =?UTF-8?q?Tests:=20damage=20whole=20signatures=20?= =?UTF-8?q?=E2=80=94=20changing=20only=20the=20first=20byte=20was=20a=20no?= =?UTF-8?q?-op=20once=20in=20256=20runs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- tests/update-check.test.ts | 6 +++++- tests/update-trust.test.ts | 17 +++++++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/tests/update-check.test.ts b/tests/update-check.test.ts index 0687e451..5c941c22 100644 --- a/tests/update-check.test.ts +++ b/tests/update-check.test.ts @@ -529,7 +529,11 @@ test("a role delivered last that is not authentic or not newer is never kept, an const genuine = fixture.served("/metadata/timestamp.json") as Buffer; fixture.substitute( "/metadata/timestamp.json", - Buffer.from(genuine.toString().replace(/"sig":"../, '"sig":"00')), + Buffer.from( + genuine + .toString() + .replace(/"sig":"[0-9a-f]+"/, `"sig":"${"0".repeat(128)}"`), + ), ); expect(await check()).toMatchObject({ kind: "error", diff --git a/tests/update-trust.test.ts b/tests/update-trust.test.ts index af88503d..d0ec80e6 100644 --- a/tests/update-trust.test.ts +++ b/tests/update-trust.test.ts @@ -105,7 +105,13 @@ test("after a failed refresh the role delivered last becomes a floor only when i "foreign signature", forged.served("/metadata/timestamp.json")?.toString(), ], - ["damaged signature", second.timestamp.replace(/"sig":"../, '"sig":"00')], + [ + "damaged signature", + second.timestamp.replace( + /"sig":"[0-9a-f]+"/, + `"sig":"${"0".repeat(128)}"`, + ), + ], ["not metadata", "{}"], ["not JSON", "\u0000"], ] as const) @@ -158,7 +164,14 @@ test("after a failed refresh the role delivered last becomes a floor only when i // Not the snapshot the timestamp names: wrong version and hash. ["other version", moved, first.snapshot], ["foreign", moved, forged.served("/metadata/1.snapshot.json")?.toString()], - ["damaged", moved, second.snapshot.replace(/"sig":"../, '"sig":"00')], + [ + "damaged", + moved, + second.snapshot.replace( + /"sig":"[0-9a-f]+"/, + `"sig":"${"0".repeat(128)}"`, + ), + ], // Without an authenticated timestamp nothing vouches for a snapshot. ["no timestamp", {}, second.snapshot], ] as const)