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/README.md b/README.md index ca479fcf..3edf9469 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/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/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 new file mode 100644 index 00000000..137de21c --- /dev/null +++ b/docs/update.md @@ -0,0 +1,451 @@ +# 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. + +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. + +## 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 **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 +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. 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. +- **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 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 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 +``` + +`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). The TUF client +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. + +**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, 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 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. + +**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 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"). 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 +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 `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 + 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: `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 + 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 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** (`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 +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. + +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 + +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:** 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 + 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 (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 +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. +- **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 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 + 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, +recovery cycles, attempt history, `product recover`, the double active record and +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. 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/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/scripts/update-fixture.ts b/scripts/update-fixture.ts new file mode 100644 index 00000000..594b55ac --- /dev/null +++ b/scripts/update-fixture.ts @@ -0,0 +1,300 @@ +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(); + const timestamps = 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"; + /** 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 = options.at ?? 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) }, + }), + ...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: options.timestampReferences ?? version, + length: referenced.length, + hashes: { sha256: sha256(referenced) }, + }), + }), + [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); + timestamps.set(version, 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, + /** 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", + 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), + /** 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/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/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/check.ts b/src/update/check.ts new file mode 100644 index 00000000..e2faa99f --- /dev/null +++ b/src/update/check.ts @@ -0,0 +1,442 @@ +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. Throws on the first failure; the caller captures + * whatever was verified before it. NO target is looked up here. + */ +async function refresh( + input: CheckInput, + scratch: string, + fetcher: TrustFetcher, +): Promise { + 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(); + 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) + 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 readFile(file); +} + +/** 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 updater: Updater | 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); + await mkdir(scratch, { mode: 0o700 }); + await seedScratch(scratch, seed); + try { + updater = await refresh(input, scratch, fetcher); + } catch (error) { + failure = classify(error, fetcher); + } + // 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, + refreshed: updater !== undefined, + lastDelivered: updater ? undefined : fetcher.lastDelivered, + write, + }); + } catch (error) { + return { + failure: + error instanceof UpdateFailure + ? error.failure + : updateError(...storageOrInternal(error, "trust")), + }; + } + 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); + 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/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/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..acbc75d7 --- /dev/null +++ b/src/update/errors.ts @@ -0,0 +1,69 @@ +/** 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 }, + /** 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; + +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/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/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..67aeb79d --- /dev/null +++ b/src/update/lock.ts @@ -0,0 +1,106 @@ +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:14-19), + * (b) wedges forever when a process dies between `mkdir` of the lock + * 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 + * 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/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..84dfef33 --- /dev/null +++ b/src/update/trust-fetcher.ts @@ -0,0 +1,127 @@ +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; +}>; + +/** 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 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. + * + * Only the final path segment of a URL is ever recorded. + */ +export class TrustFetcher implements Fetcher { + readonly roots = new Map(); + private delivered: DeliveredRole | undefined; + private failure: TransferFailure | undefined; + + constructor( + private readonly inner: Fetcher, + private readonly metadataBaseUrl: string, + ) {} + + /** The role delivered last with no later request started, or undefined. */ + get lastDelivered(): DeliveredRole | 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; + 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). + 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..51a32ea0 --- /dev/null +++ b/src/update/trust.ts @@ -0,0 +1,477 @@ +import { lstat, mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { + Metadata, + MetadataKind, + type Root, + type Timestamp, +} 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"; +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"). + * + * 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 + * 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. + * + * 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/`. + * + * 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. `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: + * - 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", + "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; +} + +/** 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). + * snapshot length and hashes recorded by the newest authenticated + * timestamp (:108), signature threshold (:118), and exactly the + * version that timestamp names (:214). + */ +function authenticatedLastDelivered(input: { + delivered: Readonly<{ file: string; bytes: Buffer }>; + root: Metadata; + /** Newest authenticated timestamp: the client's, or a just captured one. */ + 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; + }; + if (delivered.file === "timestamp.json") { + timestampOf(text); + return text; + } + if (delivered.file === "snapshot.json" && input.timestamp !== undefined) { + const timestamp = timestampOf(input.timestamp); + timestamp.signed.snapshotMeta.verify(delivered.bytes); + 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. + } + // 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 (docs/update.md "Check"): + * 1. role files the client persisted in scratch (complete verification); + * 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. + * + * 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). 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; +}): 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 }[] = []; + 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 = + authenticatedLastDelivered({ + delivered: input.lastDelivered, + root: parseRoot(finalRoot), + timestamp, + }) ?? 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) + await put(name, text); + } + 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; +} + +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/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-check.test.ts b/tests/update-check.test.ts new file mode 100644 index 00000000..5c941c22 --- /dev/null +++ b/tests/update-check.test.ts @@ -0,0 +1,847 @@ +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", + "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 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" }); + 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(); + // 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(); + 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 < 7) + expect(interrupted).toMatchObject({ kind: "error", code: "internal" }); + // Promotion order is an invariant, not a habit: whatever survived is a + // 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); + 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( + [ + vector.timestamp.version === 3, + 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, 7)); + // 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 }); + // 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); + +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 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" }, + { expires: { timestamp: past } }, + ); + expect(await check({ bootstrapRoot: fixture.bootstrapRoot })).toMatchObject({ + kind: "error", + code: "metadata-expired", + }); + expect((await readdir(join(base, "trust"))).sort()).toEqual([ + "1.root.json", + "floors.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()).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 roleVersion("timestamp.json")).toBe(valid + 1); + // 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({ + 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-rollback", + context: { role: "timestamp", rule: "version", floor: valid + 1 }, + }); + 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-rollback", + context: { role: "timestamp", rule: "version" }, + }); + expect(await trustSnapshot()).toEqual(floor); + // …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-rollback", + }); + 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":"[0-9a-f]+"/, `"sig":"${"0".repeat(128)}"`), + ), + ); + 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: it is + // named for what it is. + fixture.rewindTo(1); + expect(await check()).toMatchObject({ code: "metadata-rollback" }); + 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 () => { + 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", + }); + // 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("snapshot.json")).toBe(2); + expect(await roleVersion("targets.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, 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 () => { + 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-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-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-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, +); 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); diff --git a/tests/update-trust.test.ts b/tests/update-trust.test.ts new file mode 100644 index 00000000..d0ec80e6 --- /dev/null +++ b/tests/update-trust.test.ts @@ -0,0 +1,251 @@ +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("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 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(Object.entries(roles)), + }); + const promote = async (input: { + seed: Seed; + scratch: Record; + 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 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") && name !== "floors.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 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":"[0-9a-f]+"/, + `"sig":"${"0".repeat(128)}"`, + ), + ], + ["not metadata", "{}"], + ["not JSON", "\u0000"], + ] as const) + expect([ + name, + await promote({ + seed: seed(trusted), + scratch: trusted, + delivered: ["timestamp.json", delivered ?? ""], + }), + ]).toEqual([name, []]); + // 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 }; + 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. + 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(["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":"[0-9a-f]+"/, + `"sig":"${"0".repeat(128)}"`, + ), + ], + // 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 () => { + 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][] = [ + // Version 2 of ANOTHER repository: not signed by the seed's root key. + [forged.served("/metadata/2.root.json")?.toString() ?? "", new Map()], + // The genuine successor, but its bytes were never received. + [genuine, new Map()], + ]; + for (const [final, fetchedRoots] 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, + refreshed: true, + lastDelivered: undefined, + 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]]), + 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(["floors.json", "1.root.json", "2.root.json", "root.json"]); +});