From 3b109998c62e12e8dbc2e04eb3da7c1e67be0c92 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 20:25:42 -0400 Subject: [PATCH 001/243] fix(ci): the test job was a gate that could not fail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `required` is the single aggregated status check branch protection guards, and it listed `rust-test` in `needs` while its verification loop skipped it — so a red Rust test job reported through to a mergeable pull request. The job's own comment has said it is blocking since `S-C59`; the aggregate never agreed. Add `needs.rust-test.result` to the loop and rewrite the header comment, which claimed the opposite, to state the rule the loop now enforces: every job in `needs` is verified. --- .github/workflows/ci.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef44697e..69a248b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -470,7 +470,9 @@ jobs: # Single aggregated status check. Mark THIS ("CI / required") as the required # check in branch protection — path-filtered jobs report "skipped", which this - # treats as a pass. rust-test is non-blocking and intentionally excluded. + # treats as a pass. Every job in `needs` is verified below, `rust-test` + # included: it became blocking with `S-C59` (see the note on that job), and a + # job listed in `needs` but absent from the loop is a gate that cannot fail. required: name: required if: always() @@ -479,7 +481,8 @@ jobs: steps: - name: Verify required jobs succeeded run: | - for r in "${{ needs.commit-lint.result }}" "${{ needs.rust.result }}" "${{ needs.rust-cross.result }}" \ + for r in "${{ needs.commit-lint.result }}" "${{ needs.rust.result }}" \ + "${{ needs.rust-test.result }}" "${{ needs.rust-cross.result }}" \ "${{ needs.web.result }}" \ "${{ needs.docs.result }}" "${{ needs.docs-truth.result }}" "${{ needs.vision.result }}" \ "${{ needs.markdown.result }}" "${{ needs.kotlin.result }}" "${{ needs.swift.result }}"; do From 5817fe4a5ca50e1cf0ccfdc676649715df5ed2ae Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 20:26:09 -0400 Subject: [PATCH 002/243] fix(ci): the Rust gate skipped the trees it checks The `rust` paths filter listed nine crate trees, but `check-rust` reads three more: `i18n-check` and `i18n-guard` run `xtask` over `locales/`, `build-check-wasm` runs `cargo check -p capsule-wasm`, and `build-rust` is a workspace build, so `capsule-core-ffi` is in it. A pull request touching only those paths skipped the whole Rust job, and the gates written to catch a catalog or FFI regression never ran on the change that caused it. Add `capsule-core-ffi/**`, `capsule-wasm/**` and `locales/**`. The `swift` filter has carried `locales/**` for the same reason. --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69a248b2..201f08f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,7 +56,14 @@ jobs: - 'capsule-cli/**' - 'capsule-core/**' - 'capsule-sdk/**' + - 'capsule-core-ffi/**' + - 'capsule-wasm/**' - 'xtask/**' + # `check-rust` reads the catalogs directly: `i18n-check` and + # `i18n-guard` are `cargo run -p xtask -- i18n …` over `locales/`, + # so a catalog-only change must still pay the Rust gate. The + # `swift` filter already lists it for the same reason. + - 'locales/**' - '.github/workflows/ci.yml' web: - 'capsule-web/**' From 05da1e2d59f8a83976775343eeb70f5425d069d4 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 20:26:27 -0400 Subject: [PATCH 003/243] chore(ci): retire the protoc installs left over from the gRPC stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three jobs still installed `protoc` for a `prost-build` step that went with the `capsule.sync.v1` service in `S-D28` — the workspace has no `.proto`, no `prost`/`tonic` dependency and no build script that shells out to it, and `capsule-sdk/build.rs` says so in its own header. The `rust-cross` matrix in `ci.yml` and both `build-ios.yml` jobs (one of them on a self-hosted macOS runner) were paying a toolchain install, and a GitHub token, for nothing. --- .github/workflows/build-ios.yml | 14 -------------- .github/workflows/ci.yml | 7 ------- 2 files changed, 21 deletions(-) diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml index cc44e179..771ab755 100644 --- a/.github/workflows/build-ios.yml +++ b/.github/workflows/build-ios.yml @@ -53,13 +53,6 @@ jobs: working_directory: capsule-swift install_args: tuist xcbeautify - # capsule-sdk's build script compiles the capsule.sync.v1 proto with - # prost-build, which shells out to protoc; the macOS image does not carry it. - - name: Install protoc - uses: arduino/setup-protoc@v3 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - - name: Build Rust FFI xcframework run: bash capsule-swift/Scripts/build-rust-ffi.sh @@ -123,13 +116,6 @@ jobs: working_directory: capsule-swift install_args: tuist xcbeautify - # capsule-sdk's build script compiles the capsule.sync.v1 proto with - # prost-build, which shells out to protoc; the macOS image does not carry it. - - name: Install protoc - uses: arduino/setup-protoc@v3 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - - name: Build Rust FFI xcframework run: bash capsule-swift/Scripts/build-rust-ffi.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 201f08f4..2100715b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -247,13 +247,6 @@ jobs: tier2: true steps: - uses: actions/checkout@v4 - # capsule-sdk's build script compiles the capsule.sync.v1 proto with - # prost-build, which shells out to protoc. The hosted images do not carry - # it, so every leg of this matrix fails in the SDK build script without it. - - name: Install protoc - uses: arduino/setup-protoc@v3 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - name: Install Rust toolchain run: | rustup toolchain install From 06e8012d4fabf00e5bc5567f15deb1413a27caff Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 20:29:52 -0400 Subject: [PATCH 004/243] build(mise): let a web discovery regression fail the web gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `test-web` ran `bun test --pass-with-no-tests`, so any change that stopped bun finding the suites — a moved directory, a changed default glob, a wrong `dir` — reported a green gate over zero assertions. Eight suites live under `capsule-web/src/**` and 59 tests pass today, so the flag protects nothing that exists and hides the one failure it was masking. Verified both ways: bare `bun test` over a directory with no suites exits 1, the same invocation with `--pass-with-no-tests` exits 0. --- mise.toml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mise.toml b/mise.toml index 19571be9..e21fafbe 100644 --- a/mise.toml +++ b/mise.toml @@ -349,10 +349,14 @@ run = "cargo run -q -p xtask -- drop-kat" # test-web loads the wasm module + both KAT fixtures. Naming `drop-kat` alone is enough: it # pulls in `share-kat`, which pulls in `build-wasm`. Listing all three would say nothing extra # and would invite someone to "parallelise" them again — see the note on `share-kat`. +# +# No `--pass-with-no-tests`: suites exist under capsule-web/src/**, so bare `bun test` is green +# today and red the moment discovery stops finding them. The flag turned exactly that regression +# — a moved directory, a changed default glob, a broken `dir` — into a passing gate. [tasks.test-web] dir = "capsule-web" depends = ["drop-kat"] -run = "bun test --pass-with-no-tests" +run = "bun test" # build-web bundles the guest share viewer, which imports the generated wasm glue. [tasks.build-web] From de4cf4b6ef5e6976fd204950aba5e04f237941ef Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 20:32:12 -0400 Subject: [PATCH 005/243] ci(hooks): pre-push gains the boundary gates, and CONTRIBUTING stops overclaiming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CONTRIBUTING.md` said "the same checks run on `pre-push` and on every PR in CI", while `hk.pkl`'s pre-push covered two of `check-rust`'s fourteen steps. Eleven gates — among them the i18n catalog sync, the hardcoded-string guard, the dependency-boundary check and the licence allowlist — were reachable only by pushing and waiting for CI. Add the four that are seconds on a tree `lint-check-rust` has just warmed: `i18n-check`, `i18n-guard` and `architecture-check` share one `xtask` binary, and `license-check` is `cargo deny` with `cargo-deny` already a `[tools]` pin. Each glob is the set of files the task actually reads, so a push that touches none of them pays nothing. `openapi-check-kynos` stays out: it is `cargo run -p capsule-server --bin gen_openapi`, a codegen link of the server and the whole Kynos tree, and clippy leaves `.rmeta` rather than linkable rlibs — so it is a fresh multi-minute build on every push. A hook people disable is worse than one honest about its scope, which is what `CONTRIBUTING.md` now is: it names the subset pre-push runs, names the gates that stay in CI, and points at `mise run check-rust` and its siblings for the full local gate. --- CONTRIBUTING.md | 13 ++++++++++--- hk.pkl | 13 +++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ba6c7808..5984ce7e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,9 +23,16 @@ hk install # wires up the git hooks Run tasks with `mise run ` — `mise tasks` lists them all (plain name = auto-fix, `-check` suffix = verify-only). The pre-commit hook auto-formats and **stages** your -changes; pre-push runs the format/lint checks plus the test suite; and `convco` validates -every commit message as a [Conventional Commit](https://www.conventionalcommits.org). The -same checks run on `pre-push` and on every PR in CI. +changes; `convco` validates every commit message as a +[Conventional Commit](https://www.conventionalcommits.org); and pre-push runs the +format/lint checks, the test suites, and the cheap boundary gates — `i18n-check`, +`i18n-guard`, `architecture-check` and `license-check`. + +Pre-push is a fast subset of CI, not the whole of it. The gates that cost minutes of +fresh compilation — `openapi-check-kynos`, `translate-readme-check`, the `build-*` +steps, `gen-bindings` and `verify-examples` — run only in CI. To run exactly what CI +runs before you open a pull request, use the per-toolchain entrypoints: +`mise run check-rust`, `check-web`, `check-docs`, `check-kotlin`, `check-swift`. > **Coming from the old `just` + `lefthook` setup?** Re-run `mise install && hk install` > (hk overwrites the stale `.git/hooks` that called lefthook). The `justfile` is gone — diff --git a/hk.pkl b/hk.pkl index c44b5716..f90508ad 100644 --- a/hk.pkl +++ b/hk.pkl @@ -42,6 +42,19 @@ hooks { ["lint-check-rust"] { glob = "**/*.rs"; check = "mise run lint-check-rust"; depends = "format-check-rust" } ["test-rust"] { glob = "**/*.rs"; check = "mise run test-rust"; depends = "lint-check-rust" } + // The boundary gates out of `check-rust` that cost seconds, not minutes. They are + // deliberately a subset: `openapi-check-kynos` codegen-links capsule-server and the + // whole Kynos tree, and `build-*`/`gen-bindings`/`verify-examples` are fresh builds, + // so those stay in CI (CONTRIBUTING.md says so rather than claiming parity). No + // `depends`: none of these globs matches the Rust steps' `**/*.rs`, and this repo only + // chains steps whose globs are identical (see the pre-commit note) — cargo's own + // target-dir lock serialises whatever does overlap. Each glob is the set of files the + // task actually reads, so an unrelated push pays nothing. + ["i18n-check"] { glob = List("locales/**", "capsule-i18n/src/**", "capsule-web/src/i18n/messages/**", "capsule-swift/Generated/**", "capsule-android/src/androidMain/res/values*/strings.xml", "xtask/src/i18n.rs"); check = "mise run i18n-check" } + ["i18n-guard"] { glob = List("locales/**", "capsule-web/src/{routes,components}/**/*.tsx", "capsule-swift/{App,Modules}/**/*.swift", "capsule-android/src/**/*.kt", "capsule-cli/src/**/*.rs", "xtask/src/i18n_guard.rs"); check = "mise run i18n-guard" } + ["architecture-check"] { glob = List("**/*.{js,json,md,rs,toml,ts,tsx,yaml,yml}"); check = "mise run architecture-check" } + ["license-check"] { glob = List("**/Cargo.toml", "Cargo.lock", "deny.toml"); check = "mise run license-check" } + ["format-check-web"] { glob = "capsule-web/**/*.{ts,tsx,js,jsx,json,jsonc,css}"; check = "mise run format-check-web" } ["lint-check-web"] { glob = "capsule-web/**/*.{ts,tsx,js,jsx,json,jsonc,css}"; check = "mise run lint-check-web" } ["test-web"] { glob = "capsule-web/**/*.{ts,tsx,js,jsx}"; check = "mise run test-web" } From 3516c98a16589f7fc314ff4f0ad6851f46d08be6 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 20:34:14 -0400 Subject: [PATCH 006/243] feat(i18n): carry CLDR cardinal plural rules in the Rust runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-platform renderers never needed a plural-rule table: `xtask i18n` compiles an ICU plural into an Apple String Catalog variation or an Android ``, and the platform's own CLDR data picks the arm at display time. The Rust runtime has no platform underneath it, which is why `format_message` refuses a plural outright instead of evaluating one. `capsule_i18n::plural` is the missing half: `Category`, `category(locale, n)` and `selectable(locale)` over the twelve language subtags of `locales/config.json`. Integer cardinals only — every plural in `locales/` selects on a count and `Value` has no decimal variant, so the CLDR operands reduce to `n = i`, `v = 0` and each rule is arithmetic on the absolute value. Rules and their selectable sets live in one row per language so a test can assert the pair is consistent; `xtask`'s second copy of the selectable table is re-derived from this one in a later commit. `selectable` returns `None` for an unknown language, which is what lets the generator keep failing loudly on a locale it has no rules for while the runtime falls back to `other`. The table is asserted cell by cell rather than derived, across the CLDR boundary counts, plus the properties that matter downstream: selection never leaves the selectable set, a region or script subtag resolves to its language, and negative counts use the absolute value (`i64::MIN` included). --- capsule-i18n/src/lib.rs | 1 + capsule-i18n/src/plural.rs | 445 +++++++++++++++++++++++++++++++++++++ 2 files changed, 446 insertions(+) create mode 100644 capsule-i18n/src/plural.rs diff --git a/capsule-i18n/src/lib.rs b/capsule-i18n/src/lib.rs index ede41b01..8e157fd7 100644 --- a/capsule-i18n/src/lib.rs +++ b/capsule-i18n/src/lib.rs @@ -25,6 +25,7 @@ mod catalog; mod format; mod generated; mod negotiate; +pub mod plural; pub use catalog::{Bundle, supported_locales}; pub use format::{Value, format_message}; diff --git a/capsule-i18n/src/plural.rs b/capsule-i18n/src/plural.rs new file mode 100644 index 00000000..ff7a4e1b --- /dev/null +++ b/capsule-i18n/src/plural.rs @@ -0,0 +1,445 @@ +//! CLDR cardinal plural rules for the locales Capsule ships. +//! +//! An ICU `plural` block names its arms by CLDR *category* (`one`, `few`, …), and which +//! category a number selects is a property of the language, not of the message. The +//! per-platform renderers never needed this table: Apple and Android compile a plural +//! ahead of time into a native resource and let the platform's own CLDR data pick the arm +//! at display time (see `xtask i18n`). The Rust runtime has no platform underneath it, so +//! it is the one target that must carry the rules itself — which is why [`crate::format`] +//! refused plurals outright until this module existed. +//! +//! # Scope +//! +//! **Integer cardinals only.** Every plural in `locales/` selects on a count, and +//! [`crate::Value`] has no decimal variant, so the CLDR operands reduce to `n = i`, +//! `v = 0`, `f = 0`, `e = 0` and the rules collapse to arithmetic on the absolute value. +//! Ordinals (`selectordinal`) are not implemented and are still refused by the formatter. +//! +//! The table covers exactly the twelve language subtags of `locales/config.json`. An +//! unknown language resolves to [`Category::Other`] rather than panicking — a locale +//! outside the config cannot reach a bundle with translated messages anyway +//! ([`crate::Bundle::for_locale`] falls back to the source catalog) — while +//! [`selectable`] reports `None` for it, so the *generator* can still fail loudly instead +//! of guessing a rule set. + +use std::fmt; + +/// A CLDR plural category, in CLDR's canonical order. +/// +/// The order is meaningful: it is the order an Apple String Catalog, an Android +/// `` resource and this crate all list arms in, so `Ord` sorts arms the way +/// every consumer expects. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Category { + /// `zero` — a language with a dedicated form for none (Arabic). + Zero, + /// `one` — the singular. Not always "exactly 1": French counts 0 as `one`. + One, + /// `two` — the dual (Arabic). + Two, + /// `few` — the paucal (Arabic, Russian). + Few, + /// `many` — Russian's third form, and the large-number form of the Romance languages. + Many, + /// `other` — the universal fallback, selectable in every language. + Other, +} + +impl Category { + /// Every category, in CLDR's canonical order. + pub const ALL: &'static [Self] = &[ + Self::Zero, + Self::One, + Self::Two, + Self::Few, + Self::Many, + Self::Other, + ]; + + /// The CLDR keyword, as it is spelled in an ICU message and in every generated + /// platform resource. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Zero => "zero", + Self::One => "one", + Self::Two => "two", + Self::Few => "few", + Self::Many => "many", + Self::Other => "other", + } + } + + /// Parse a CLDR keyword, or `None` if `s` is not one. + #[must_use] + pub fn parse(s: &str) -> Option { + Self::ALL.iter().copied().find(|c| c.as_str() == s) + } +} + +impl fmt::Display for Category { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// One language's cardinal rules: which categories it can select, and how. +/// +/// The two halves live together deliberately. `selectable` is what the ahead-of-time +/// generator uses to drop unreachable arms; `select` is what the runtime uses to pick +/// one. Holding them in the same row is what lets a test assert the pair is consistent — +/// that `select` never returns a category `selectable` omits — instead of leaving two +/// tables in two crates to drift, which is exactly what they did before this module. +struct Rules { + /// The language subtag, lowercase. + language: &'static str, + /// The categories this language can select, in CLDR order. + selectable: &'static [Category], + /// The rule itself, over the absolute value of an integer count. + select: fn(u64) -> Category, +} + +/// CLDR cardinal rules for the twelve language subtags in `locales/config.json`. +/// +/// Adding a locale there means adding a row here. Sources are the CLDR +/// `plurals.xml` cardinal rules; each `select` below is the integer specialization +/// (`v = 0`, so `i = n`) of that language's conditions, tried in CLDR order. +const RULES: &[Rules] = &[ + Rules { + language: "ar", + selectable: &[ + Category::Zero, + Category::One, + Category::Two, + Category::Few, + Category::Many, + Category::Other, + ], + select: arabic, + }, + Rules { + language: "de", + selectable: &[Category::One, Category::Other], + select: exactly_one, + }, + Rules { + language: "en", + selectable: &[Category::One, Category::Other], + select: exactly_one, + }, + Rules { + language: "es", + selectable: &[Category::One, Category::Many, Category::Other], + select: romance_one, + }, + Rules { + language: "fr", + selectable: &[Category::One, Category::Many, Category::Other], + select: romance_zero_or_one, + }, + Rules { + language: "hi", + selectable: &[Category::One, Category::Other], + select: zero_or_one, + }, + Rules { + language: "it", + selectable: &[Category::One, Category::Many, Category::Other], + select: romance_one, + }, + Rules { + language: "ja", + selectable: &[Category::Other], + select: always_other, + }, + Rules { + language: "ko", + selectable: &[Category::Other], + select: always_other, + }, + Rules { + language: "pt", + selectable: &[Category::One, Category::Many, Category::Other], + select: romance_zero_or_one, + }, + Rules { + language: "ru", + selectable: &[ + Category::One, + Category::Few, + Category::Many, + Category::Other, + ], + select: russian, + }, + Rules { + language: "zh", + selectable: &[Category::Other], + select: always_other, + }, +]; + +/// The CLDR category `n` selects in `locale`. +/// +/// `locale` may be a full tag (`pt-BR`, `zh-Hans`) — only its language subtag matters. +/// Selection uses the **absolute value** of `n`, as CLDR's `n` operand does, so `-1` +/// selects the same category as `1` and `i64::MIN` cannot overflow. A language with no +/// row in [`RULES`] yields [`Category::Other`], the category every language selects and +/// every well-formed ICU plural carries. +#[must_use] +pub fn category(locale: &str, n: i64) -> Category { + rules(locale).map_or(Category::Other, |r| (r.select)(n.unsigned_abs())) +} + +/// The categories `locale`'s language can actually select, in CLDR order, or `None` when +/// the language has no rules here. +/// +/// `None` is a distinct answer from "only `other`" on purpose: the ahead-of-time +/// generator (`xtask i18n`) must refuse a locale it has no rules for rather than emit a +/// single-arm resource for a language that may well need six, while the runtime is happy +/// to fall back. Both callers read this one table. +#[must_use] +pub fn selectable(locale: &str) -> Option<&'static [Category]> { + rules(locale).map(|r| r.selectable) +} + +/// The rule row for `locale`'s language subtag, matched case-insensitively. +fn rules(locale: &str) -> Option<&'static Rules> { + let language = locale.split(['-', '_']).next().unwrap_or(locale); + RULES + .iter() + .find(|r| r.language.eq_ignore_ascii_case(language)) +} + +/// `one` for exactly 1 — English, German. +fn exactly_one(n: u64) -> Category { + if n == 1 { + Category::One + } else { + Category::Other + } +} + +/// `one` for 0 and 1 — Hindi (`i = 0 or n = 1`). +fn zero_or_one(n: u64) -> Category { + if n <= 1 { + Category::One + } else { + Category::Other + } +} + +/// Spanish, Italian: `one` for exactly 1, `many` for a non-zero multiple of a million. +fn romance_one(n: u64) -> Category { + if n == 1 { + Category::One + } else { + romance_many(n) + } +} + +/// French, Portuguese: as [`romance_one`], but 0 is also `one`. +fn romance_zero_or_one(n: u64) -> Category { + if n <= 1 { + Category::One + } else { + romance_many(n) + } +} + +/// The Romance `many`: `e = 0 and i != 0 and i % 1000000 = 0 and v = 0`. +/// +/// This exists for the compact-decimal spellings ("2 millones de fotos"), but CLDR states +/// it over the *integer* operands, so a plain `2000000` selects it too — which is why the +/// generator lists `many` as selectable for these languages and why this is a real arm and +/// not a decorative one. +fn romance_many(n: u64) -> Category { + if n != 0 && n.is_multiple_of(1_000_000) { + Category::Many + } else { + Category::Other + } +} + +/// Russian: `one` for …1 except …11; `few` for …2-4 except …12-14; `many` otherwise. +fn russian(n: u64) -> Category { + let last = n % 10; + let last_two = n % 100; + if last == 1 && last_two != 11 { + Category::One + } else if (2..=4).contains(&last) && !(12..=14).contains(&last_two) { + Category::Few + } else { + Category::Many + } +} + +/// Arabic: `zero` 0, `one` 1, `two` 2, `few` …3-10, `many` …11-99, else `other`. +fn arabic(n: u64) -> Category { + let last_two = n % 100; + match n { + 0 => Category::Zero, + 1 => Category::One, + 2 => Category::Two, + _ if (3..=10).contains(&last_two) => Category::Few, + _ if (11..=99).contains(&last_two) => Category::Many, + _ => Category::Other, + } +} + +/// `other` for every count — Japanese, Korean, Chinese. +fn always_other(_: u64) -> Category { + Category::Other +} + +#[cfg(test)] +mod tests { + use super::{Category, RULES, category, selectable}; + + /// The languages `locales/config.json` ships, as their subtags. + const LANGUAGES: &[&str] = &[ + "ar", "de", "en", "es", "fr", "hi", "it", "ja", "ko", "pt", "ru", "zh", + ]; + + /// The full table, cell by cell: one expected category per (language, count). + /// + /// Written out rather than derived, because a table derived from the implementation + /// asserts nothing. Counts are the CLDR boundary set: the teens and the `x1`/`x2` + /// tails that separate Russian's three forms, the `x00` tail that separates Arabic's + /// `many` from its `other`, and 0, which four of these twelve treat as singular. + const COUNTS: &[i64] = &[0, 1, 2, 3, 5, 11, 21, 100, 101, 1000]; + + #[rustfmt::skip] + const EXPECTED: &[(&str, &[Category])] = { + use Category::{Few, Many, One, Other, Two, Zero}; + &[ + // 0 1 2 3 5 11 21 100 101 1000 + ("ar", &[Zero, One, Two, Few, Few, Many, Many, Other, Other, Other]), + ("de", &[Other, One, Other, Other, Other, Other, Other, Other, Other, Other]), + ("en", &[Other, One, Other, Other, Other, Other, Other, Other, Other, Other]), + ("es", &[Other, One, Other, Other, Other, Other, Other, Other, Other, Other]), + ("fr", &[One, One, Other, Other, Other, Other, Other, Other, Other, Other]), + ("hi", &[One, One, Other, Other, Other, Other, Other, Other, Other, Other]), + ("it", &[Other, One, Other, Other, Other, Other, Other, Other, Other, Other]), + ("ja", &[Other, Other, Other, Other, Other, Other, Other, Other, Other, Other]), + ("ko", &[Other, Other, Other, Other, Other, Other, Other, Other, Other, Other]), + ("pt", &[One, One, Other, Other, Other, Other, Other, Other, Other, Other]), + ("ru", &[Many, One, Few, Few, Many, Many, One, Many, One, Many]), + ("zh", &[Other, Other, Other, Other, Other, Other, Other, Other, Other, Other]), + ] + }; + + #[test] + fn the_rule_table_covers_exactly_the_shipped_languages() { + let listed: Vec<&str> = RULES.iter().map(|r| r.language).collect(); + assert_eq!( + listed, LANGUAGES, + "add a row when a locale joins config.json" + ); + let expected: Vec<&str> = EXPECTED.iter().map(|(lang, _)| *lang).collect(); + assert_eq!(expected, LANGUAGES); + } + + #[test] + fn every_cell_of_the_cldr_table_is_asserted() { + for (language, row) in EXPECTED { + assert_eq!(row.len(), COUNTS.len(), "{language} row is the wrong width"); + for (n, want) in COUNTS.iter().zip(*row) { + assert_eq!( + category(language, *n), + *want, + "{language} at n={n} selected the wrong category" + ); + } + } + } + + #[test] + fn russian_separates_its_three_forms_past_the_teens() { + assert_eq!(category("ru", 22), Category::Few); + assert_eq!(category("ru", 25), Category::Many); + assert_eq!(category("ru", 111), Category::Many); + assert_eq!(category("ru", 121), Category::One); + } + + #[test] + fn arabic_uses_the_hundreds_tail() { + assert_eq!(category("ar", 103), Category::Few); + assert_eq!(category("ar", 111), Category::Many); + assert_eq!(category("ar", 200), Category::Other); + } + + #[test] + fn the_romance_many_is_the_millions_form_and_nothing_smaller() { + // `many` exists for these four, but nothing below a million reaches it — which is + // why a catalog carrying only `one`/`other` still renders correctly today. + for language in ["es", "fr", "it", "pt"] { + for n in 0i64..=1000 { + assert_ne!( + category(language, n), + Category::Many, + "{language} reached `many` at n={n}" + ); + } + assert_eq!(category(language, 1_000_000), Category::Many); + assert_eq!(category(language, 2_000_000), Category::Many); + assert_eq!(category(language, 1_000_001), Category::Other); + } + } + + #[test] + fn selection_never_leaves_the_selectable_set() { + // The property the generator relies on: an arm it drops as unreachable is one the + // runtime will never ask for. Asserted over the whole boundary range plus the + // millions, so the two tables cannot drift apart silently. + for language in LANGUAGES { + let selectable = selectable(language).expect("a shipped language has rules"); + assert!(selectable.contains(&Category::Other), "{language}"); + let mut sorted = selectable.to_vec(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!(sorted, selectable, "{language} is not in CLDR order"); + for n in (0i64..=2000).chain([999_999, 1_000_000, 2_000_000, i64::MAX]) { + let picked = category(language, n); + assert!( + selectable.contains(&picked), + "{language} selected `{picked}` at n={n}, which it lists as unselectable" + ); + } + } + } + + #[test] + fn a_region_or_script_subtag_resolves_to_its_language() { + assert_eq!(category("pt-BR", 0), category("pt", 0)); + assert_eq!(category("zh-Hans", 1), Category::Other); + assert_eq!(category("zh-Hant", 1), Category::Other); + assert_eq!(category("EN-gb", 1), Category::One); + assert_eq!(selectable("pt-BR"), selectable("pt")); + } + + #[test] + fn negative_counts_use_the_absolute_value() { + assert_eq!(category("en", -1), Category::One); + assert_eq!(category("ru", -22), Category::Few); + // The one input that would panic under a naive `n.abs()`. + assert_eq!(category("ru", i64::MIN), category("ru", 8)); + } + + #[test] + fn an_unknown_language_falls_back_to_other_but_reports_no_rules() { + assert_eq!(category("nl", 1), Category::Other); + assert_eq!(category("", 1), Category::Other); + assert_eq!(selectable("nl"), None); + } + + #[test] + fn categories_round_trip_through_their_cldr_keyword() { + for want in Category::ALL { + assert_eq!(Category::parse(want.as_str()), Some(*want)); + assert_eq!(want.to_string(), want.as_str()); + } + assert_eq!(Category::parse("=0"), None); + assert_eq!(Category::parse("One"), None); + } +} From ba99760ba0f02f031fad4f2f3e6fee6e90cf7c8e Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 20:38:28 -0400 Subject: [PATCH 007/243] test(kotlin): the JVM smoke test called a binding signature that no longer exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `FfiWorkspace.create` and `createWithHardwareSigner` each grew an `FfiClientBuild` parameter with `S-D15`, so every manifest a foreign app authors reports that app's own `client_id/semver+commit`. `SoftwareSignerSmokeTest` was never updated: it passed three arguments to a four-parameter constructor and six to a seven-parameter one, which is a Kotlin compile error, so `./gradlew :core:test -Pcapsule.wireFfi` could not build the test source set at all. Mirror `SoftwareP256SignerSmokeTest` in the same directory — a `client` member identifying `capsule-core-kotlin` — and pass it at both call sites. The parameter lists were read off the regenerated binding (`mise run gen-bindings`, `target/bindings/kotlin/uniffi/capsule_core/capsule_core.kt:2002,:2021`); the Gradle/Android toolchain does not run on the reference dev host, so `build-android` in CI is the authority. --- .../justin13888/capsule/hardware/SoftwareSignerSmokeTest.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/capsule-core-kotlin/src/test/kotlin/com/justin13888/capsule/hardware/SoftwareSignerSmokeTest.kt b/capsule-core-kotlin/src/test/kotlin/com/justin13888/capsule/hardware/SoftwareSignerSmokeTest.kt index 7a6ac6d3..f1051107 100644 --- a/capsule-core-kotlin/src/test/kotlin/com/justin13888/capsule/hardware/SoftwareSignerSmokeTest.kt +++ b/capsule-core-kotlin/src/test/kotlin/com/justin13888/capsule/hardware/SoftwareSignerSmokeTest.kt @@ -8,6 +8,7 @@ import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import uniffi.capsule_core.DeviceTier +import uniffi.capsule_core.FfiClientBuild import uniffi.capsule_core.FfiWorkspace import uniffi.capsule_core.HardwareSignerException import java.nio.file.Files @@ -19,6 +20,8 @@ import java.nio.file.Files * first. The StrongBox path is on-device only (see androidInstrumentedTest). */ class SoftwareSignerSmokeTest { + private val client = FfiClientBuild("capsule-core-kotlin", "0.0.0") + private fun freshRoot(): String = Files.createTempDirectory("capsule-kotlin-smoke").toString() @Test @@ -46,7 +49,7 @@ class SoftwareSignerSmokeTest { @Test fun softwarePathCreatesWorkspace() { - val ws = FfiWorkspace.create(freshRoot(), "correct horse".toByteArray(), DeviceTier.NORMAL) + val ws = FfiWorkspace.create(freshRoot(), "correct horse".toByteArray(), DeviceTier.NORMAL, client) assertFalse(ws.userId().isEmpty()) assertFalse(ws.defaultAlbumId().isEmpty()) } @@ -64,6 +67,7 @@ class SoftwareSignerSmokeTest { signer, "device-dsk", ByteArray(32) { 9 }, + client, ) assertFalse(ws.userId().isEmpty()) } From 75a55f4973036403cfbcdf51c0a71c6618d4d099 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 20:38:44 -0400 Subject: [PATCH 008/243] docs(roadmap): a package view a machine keeps honest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SLICES.md` tracks slices and nothing tracks packages, so "what state is `capsule-server` in" has had no answer that could be checked. `ROADMAP.md` gives every package the repository declares one row, with a state drawn from a closed set defined at the top of the file. The row is only worth reading if it cannot quietly fall behind, so `docs-truth` gains a fourth check that resolves every row against the manifests themselves rather than against a second committed list — a committed list is tautological, since both files are hand-edited and a package added to neither passes. Adding a package now fails the gate until this file gains a row: 47 today, across cargo, gradle, tuist, SwiftPM, bun, the catalog, uv, the submodule and the review buckets. `capsule-core-swift` is a package the plan for this change missed. It carries its own `Package.swift`, `lint-check-swift` loops over it, and `test-swift` does not — which the row says, because a gate that covers format and lint only is not the same claim as a gate. A state defined and used by no row is reported in the success line rather than failed. Failing it would put steady pressure on whoever edits the file to give the spare term a row, which is a dishonest state assignment — the exact defect this check exists to prevent. --- ROADMAP.md | 100 +++++ capsule-docs/scripts/check-roadmap.mjs | 443 ++++++++++++++++++++ capsule-docs/scripts/check-roadmap.test.mjs | 403 ++++++++++++++++++ capsule-docs/scripts/docs-truth.mjs | 8 +- 4 files changed, 953 insertions(+), 1 deletion(-) create mode 100644 ROADMAP.md create mode 100644 capsule-docs/scripts/check-roadmap.mjs create mode 100644 capsule-docs/scripts/check-roadmap.test.mjs diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 00000000..c9695381 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,100 @@ +# Roadmap + +The **package-level** view of Capsule: one row for every package the repository declares, in +every toolchain, with the state it is actually in today. + +[`SLICES.md`](SLICES.md) stays the slice-level tracker and is the authority on any slice's +status. This file never restates one. The `Open slices` column cites ids so a reader can get +from a package to the work outstanding against it; the status of that work is read there, not +here. A slice appears against the package whose tree its deliverable lands in, so a slice that +spans a client and a server leg is listed against both halves only where both are genuinely +owed. + +`mise run check-docs-truth` runs a `roadmap` check over this file. It resolves every row +against the manifests themselves — `Cargo.toml`, `settings.gradle.kts`, +`capsule-swift/Project.swift`, the `Package.swift`/`package.json`/`pyproject.toml` package +roots, `locales/`, `.gitmodules`, and `legacy-review/*/` — so adding a package to the tree +fails the gate until this file gains a row for it. Every `Gate` cell must name a real `mise` +task and every cited slice id must have a detail block in `SLICES.md`. + +## States + +A closed set. A row's state is a claim about the package, not about the programme. + +- `frozen` — ships, contract settled, no open slices. Only defect fixes land. +- `stabilizing` — live and inside a `mise run check-*` gate, with the contract still moving. + Open slices refine it. +- `rebuilding` — the shipped surface is quarantined under `legacy-review/` and is being + re-landed on a replacement. +- `blocked` — cannot start. A named dependency outside this package gates it. +- `deferred` — in scope and deliberately unscheduled. Post-v1. +- `review-only` — non-buildable reference material. No gate, no build. +- `excluded` — in the tree but outside the shipped build: a submodule, a conditionally + compiled target, or research material. Any gate such a row names is format and lint only, + and the `Notes` cell says so. + +## Packages + +| Package | Kind | Owns | State | Gate | Owner docs | Open slices | Next milestone | Notes | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| `capsule-core` | cargo | The offline crypto data plane, catalog, signed sidecars, import pipeline, LQIP, and the OpenMLS authority | stabilizing | `mise run check-rust` | [Module Map](capsule-docs/src/content/docs/design/module-map.md) | `S-B1`, `S-B5`, `S-B13`, `S-D24`, `S-D29` | Public-API freeze (#399) | `capsule-core::media` is designed and unbuilt, so there is no image decoder in the workspace and every still import is a `DeferredNoCodec` | +| `capsule-core-ffi` | cargo | The app umbrella staticlib and the `capsule_core_ffi` uniffi namespace | stabilizing | `mise run check-rust` | [Module Map — Client Boundaries](capsule-docs/src/content/docs/design/module-map.md#client-boundaries) | — | Public-API freeze (#399) | Links `capsule-sdk`'s uniffi surface so one Rust library carries both namespaces an app consumes | +| `capsule-sdk` | cargo | Session, upload, sync, recovery and protocol-version orchestration over the spargen-generated REST client | stabilizing | `mise run check-rust` | [API Surfaces](capsule-docs/src/content/docs/design/api-surfaces.md) | `S-D1`, `S-D2`, `S-D7`, `S-D8`, `S-D9`, `S-D10`, `S-D17`, `S-E3`, `S-N2` | Re-front the gRPC sync half on REST (#408) | Replacement-in-progress, not review material: the wire contract is re-sourced from Kynos, the crate is not thrown away | +| `capsule-server` | cargo | The Kynos REST/OpenAPI application and the committed `capsule-server/openapi.json` contract | rebuilding | `mise run check-rust` | [Module Map — Server Modules](capsule-docs/src/content/docs/design/module-map.md#server-modules) | `S-C8`, `S-C39`, `S-C47`, `S-C49`, `S-C51`, `S-E2`, `S-E5`, `S-N1` | A binary, configuration and a serve task (#401) | Fifty-nine operations and a test suite over the real router, with no binary, no configuration loading and no Postgres or Valkey adapter | +| `capsule-wire` | cargo | Framework-free protocol headers and the response taxonomy across the retiring Salvo boundary | stabilizing | `mise run check-rust` | [API Surfaces](capsule-docs/src/content/docs/design/api-surfaces.md) | `S-C27` | Retired (#400) | Only retired code still depends on it; `capsule-server` owns `problem`, `limits` and `body` | +| `capsule-wasm` | cargo | The browser boundary — share-link open, guest drop sealing, and LQIP decode | stabilizing | `mise run check-rust` | [Web Upload](capsule-docs/src/content/docs/design/web-upload.md) | — | Public-API freeze (#399) | `S-B14` owes it an `lqip` entry point; the encoder already compiles for `wasm32-unknown-unknown` | +| `capsule-i18n` | cargo | The generated Rust catalog bundle, the runtime formatter, and the `error.*` code contract | stabilizing | `mise run check-rust` | [i18n](capsule-docs/src/content/docs/design/i18n.md) | — | ICU plural evaluation (#414) | Generated from `locales/` by `mise run i18n`; `mise run i18n-check` fails on drift | +| `capsule-cli` | cargo | The `capsule` binary — local library commands plus auth, sync, push, import and cull | stabilizing | `mise run check-rust` | [Clients](capsule-docs/src/content/docs/design/clients.md) | `S-B17`, `S-B18`, `S-I8`, `S-Q1`, `S-Q2`, `S-Q3`, `S-Q4` | Help text from the catalogs and an enrichment read surface (#413) | The networked commands have no server to reach until #401 lands one | +| `capsule-cli/entity` | cargo | sea-orm entities for the CLI's sync store — `sync_cursor` and `synced_asset` | stabilizing | `mise run check-rust` | [Clients](capsule-docs/src/content/docs/design/clients.md) | — | Follows `capsule-cli` (#413) | The one place `chrono` is permitted, as the sea-orm column type; convert at the entity boundary | +| `capsule-cli/migration` | cargo | sea-orm migrations for that store | stabilizing | `mise run check-rust` | [migration/README](capsule-cli/migration/README.md) | — | Follows `capsule-cli` (#413) | Schema changes land here before the entity crate sees them | +| `xtask` | cargo | Repository automation — `architecture-check`, `i18n-guard`, `translate-readme`, licence and workspace-dependency checks | stabilizing | `mise run check-rust` | [Developer Docs](capsule-docs/src/content/docs/design/developer-docs.md) | — | Guard-detector repair (#394, #414) | Not a shipped artifact; it is what makes several gates in `mise.toml` real | +| `capsule-android` | gradle | The Android application — Compose UI over the Kotlin core | blocked | `mise run check-kotlin` | [Clients](capsule-docs/src/content/docs/design/clients.md) | — | Make the build green (#389) | The app references a DI layer that is not in the tree, so it does not compile | +| `capsule-core-kotlin` | gradle | The Kotlin hardware-signer adapters — software P-256, software Ed25519, StrongBox | stabilizing | `mise run check-kotlin` | [Clients](capsule-docs/src/content/docs/design/clients.md) | — | StrongBox run on a device runner | Smoke-tested only; the device lane that would exercise StrongBox is unprovisioned | +| `capsule-core-swift` | swiftpm | The Swift hardware-signer adapters — Secure Enclave signing and key agreement, plus software fallbacks | stabilizing | `mise run check-swift` | [capsule-core-swift/README](capsule-core-swift/README.md) | `S-P6` | Secure-Enclave wiring into the app (`S-P6`) | `check-swift` formats and lints this package; `mise run test-swift` drives the Tuist workspace only, so its `swift test` suite is in no gate | +| `CapsuleFoundation` | tuist | Value types, logging and utilities for the Apple client. No dependencies | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Holds as the client's floor | The root of the Apple module graph; every other target depends on it | +| `CapsuleDomain` | tuist | The display and domain value types, as structural mirrors of the Rust records | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Mirrors hold across the FFI swap (`S-U19`) | Deliberately FFI-free so the mocked graph builds with no Rust toolchain | +| `CapsulePorts` | tuist | The protocol seams the app is written against, and which the mock and FFI adapters satisfy | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Move the six `FeatureAuth` ports here (#391) | Six ports are declared inside `FeatureAuth` today, which is what #391 corrects | +| `CapsuleNavigation` | tuist | `Route`, the sidebar catalog, deep-link classification and `ViewerContext` | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Every live sidebar row reaches a real screen | `SidebarItem.memories` and `.duplicates` are live rows whose screens are still scaffolds | +| `CapsuleMock` | tuist | The in-memory doubles the whole client is built and tested against | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Reachable scenario selection (#392) | `MockScenarioSelection` is write-only, so about thirty screens have no way in | +| `CapsuleDiagnostics` | tuist | The diagnostics coordinator and the client's own health surfaces | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Holds as the client's floor | — | +| `CapsuleCatalog` | tuist | The FFI-free catalog surface the app reads, and the error type that crosses it | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | `S-P5` | Sync-apply renders into the local catalog (`S-P5`) | Written so the generated-type half can be swapped in without any screen naming a generated type | +| `CapsuleCatalogFFI` | tuist | The Rust-backed half — the generated `capsule_core_ffi` and `capsule_sdk` glue, record conversions and error mapping | excluded | — | [capsule-swift/README](capsule-swift/README.md) | `S-P8` | A behavioural FFI harness that flips `S-D9` | Present only under `TUIST_FFI=1`, so the default graph builds from a clean checkout with no cross-compile; format and lint reach it only when it is generated | +| `ManagedStore` | tuist | The Swift filesystem layer, hashing and the managed-store import pipeline | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Imports route through `ImportPort` (#390) | The picker importer writes this store directly, so picker imports never reach the timeline | +| `AssetKit` | tuist | Asset windowing, prefetch and the store the grid and viewer both read | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Holds as the client's floor | — | +| `CapsuleTestSupport` | tuist | Shared mocks and helpers for every module's unit tests | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Covers the suites still owed by lane U | Test-only; no product code depends on it | +| `ImagePipeline` | tuist | Decode, downsample and cache for the Apple client's image rendering | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Holds as the client's floor | — | +| `CapsuleUI` | tuist | The Capsule-state design system — the shared components every feature composes | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Accessibility inside the gate (#393) | The accessibility audit fails on most surfaces and is outside `check-swift` | +| `FeatureTimeline` | tuist | Library, timeline, selection and culling | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Culling review (`S-U7` remainder) | The uniform grid, pinch zoom and the zoom transition landed; culling review is outstanding | +| `FeatureViewer` | tuist | The viewer and asset detail | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Provenance and verdict detail (`S-U8` remainder) | The info panel, caption editing and the `.viewer` route landed | +| `FeatureAlbums` | tuist | Album index and detail, and the smart-album builder | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | `S-U9` | The smart-album screen and predicate builder | Index and detail landed over the mock ports; members and policy editors are outstanding | +| `FeatureSearch` | tuist | Search, people and places | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | `S-U10` | People index and cluster screens | Search and the clustered map landed; map granularity is still fixed | +| `FeatureTransfer` | tuist | The transfer centre, custody receipts, quota, storage and quarantine | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | `S-U12` | The documented ladders and triage detail | Every screen landed and is routed; not all of the documented behaviour is built | +| `FeatureAuth` | tuist | Welcome, discovery, the device chooser, passphrase, enrolment ceremony and the device ledger | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | `S-P2`, `S-P3`, `S-U13`, `S-U23` | A real auth service over Keychain (`S-P2`) | Built over `Preview*` doubles; both onboarding steps are scaffolds | +| `FeatureSharing` | tuist | Share links, drop inbox, peering, federation and moderation | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | `S-U14`, `S-U22` | Inbound link redemption (`S-U22`) | Share detail is outstanding; the `https` deep-link parser lands `/s/` and `/u/` on a scaffold | +| `FeatureSettings` | tuist | The settings tree | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Federation, advanced and about sections | Fifteen of eighteen sections landed; the Advanced mock-scenario switcher does not exist | +| `FeatureImport` | tuist | The picker, scan, plan, execution and history surfaces | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | `S-P4`, `S-U11` | The import→seal→upload bridge (`S-P4`) | Per-run detail is outstanding, and `S-P4` waits on `S-P2`/`S-P3` | +| `FeatureCollections` | tuist | The sidebar collections — hidden, places and recently deleted | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | `S-U20`, `S-U21` | Memories and duplicate review | `HiddenView` sits behind the SR1 local-auth gate, whose seam is a port | +| `Capsule` | tuist | The composition root — the thin app target shared by macOS, iOS and iPadOS | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | `S-U19` | Swap the mock adapter for the SDK one (`S-U19`) | `AppEnvironment.swift` is the single file where lane P and lane U meet | +| `capsule-docs` | bun | The Starlight documentation site, the design docs it publishes, and the `docs-truth` checks | stabilizing | `mise run check-docs` | [Developer Docs](capsule-docs/src/content/docs/design/developer-docs.md) | `S-Z8`, `S-Z9`, `S-Z10` | Generate the reference section (#415) | `mise run check-docs-truth` is deliberately outside both `check-docs` and `check-rust`; it needs no toolchain | +| `capsule-web` | bun | The browser client — guest drop, share viewer and the read-only gateway | stabilizing | `mise run check-web` | [Web Upload](capsule-docs/src/content/docs/design/web-upload.md) | `S-Q5` | Live-browser smokes (`S-Q5`, #409) | Every screen renders its empty state; there is no server to reach until #401 | +| `locales` | catalog | The canonical ICU MessageFormat catalogs — thirteen locales, the config and the schema | stabilizing | `mise run i18n-check` | [i18n](capsule-docs/src/content/docs/design/i18n.md) | — | Human review of the seeded entries | Roughly 350 machine-seeded entries across twelve locales are flagged in the `context` field and await review | +| `capsule-vision` | python | The vision and ML research notebooks behind the on-device tagging work | excluded | `mise run check-vision` | [AI](capsule-docs/src/content/docs/design/ai.md) | — | Post-v1 | Format and lint only — `check-vision` runs no notebook and no test, and nothing here ships in a client | +| `rawshift` | submodule | RAW decode, metadata extraction and derivative generation, in-house and out-of-tree | excluded | — | [Dependencies](capsule-docs/src/content/docs/design/dependencies.md) | — | A workspace dependency `capsule-core::media` can consume | A pinned submodule, not a workspace dependency; CI does not check it out and no gate here descends into it | +| `legacy-review/server-salvo` | review-bucket | The retired Salvo server, kept as the contract the Kynos rebuild must reproduce | review-only | — | [legacy-review/README](legacy-review/README.md) | — | Deleted once `capsule-server` reaches parity | Non-buildable reference material; nothing in the workspace links it | +| `legacy-review/sdk-progenitor` | review-bucket | The retired Progenitor SDK | review-only | — | [legacy-review/README](legacy-review/README.md) | — | Deleted once the spargen client covers it | Non-buildable reference material | +| `legacy-review/media-pipeline` | review-bucket | The retired `capsule_core::media` decode and derivative stack | review-only | — | [legacy-review/README](legacy-review/README.md) | — | Deleted once `capsule-core::media` lands on Rawshift (#410) | Non-buildable reference material; taking the decoder with it is why every still import is a `DeferredNoCodec` today | +| `legacy-review/core-import-media` | review-bucket | The quarantined twin of `capsule_core::exif` and the import executor's cancellation and progress halves | review-only | — | [legacy-review/README](legacy-review/README.md) | — | Deletion, which `S-C59` recorded and did not perform | All three modules are live, tested and newer in `capsule-core` than this snapshot, so the bucket is a stale twin rather than a quarantine | + +## Deferred register + +In scope, deliberately unscheduled. These are not packages, so they carry no row above; they +are listed here so `deferred` means something a reader can check. + +| Item | Owner docs | State | Notes | +| --- | --- | --- | --- | +| Tethered camera import over PTP/IP | [Import — Pipeline](capsule-docs/src/content/docs/design/import/pipeline.md) | deferred | `S-B9`; the `ptpip-rs` crate does not exist yet | +| iCloud and Immich importers | [Import — Pipeline](capsule-docs/src/content/docs/design/import/pipeline.md) | deferred | `S-B7` and `S-B8`, both behind the Takeout adapter that landed | +| Passkey authentication | [Authentication](capsule-docs/src/content/docs/design/authentication.md) | deferred | Six Salvo operations that were in no document and always answered `CredentialNotFound`; dropped from v1 in `S-C56` | +| Live mDNS peering | [Peering](capsule-docs/src/content/docs/design/peering.md) | deferred | `S-E3` lands the in-process half; discovery on a real network is post-v1 | +| Native RTL layout | [i18n](capsule-docs/src/content/docs/design/i18n.md) | deferred | The catalogs and the twelve locales landed in `S-I2`; per-platform RTL layout did not | +| A browser MLS surface | [MLS Resilience](capsule-docs/src/content/docs/design/mls-resilience.md) | deferred | The `libcrux` provider has no `wasm32` target, so the `mls` feature is host-only | diff --git a/capsule-docs/scripts/check-roadmap.mjs b/capsule-docs/scripts/check-roadmap.mjs new file mode 100644 index 00000000..b89af762 --- /dev/null +++ b/capsule-docs/scripts/check-roadmap.mjs @@ -0,0 +1,443 @@ +/** + * `ROADMAP.md` against the manifests that declare the packages. + * + * `SLICES.md` tracks slices; `ROADMAP.md` tracks **packages**, one row each, + * across every toolchain in the repository. A package-level view is only worth + * reading if it cannot quietly fall behind the tree, so this check resolves + * every row against the manifest that declares the package rather than against + * a second committed list. A committed list would be tautological: both files + * are hand-edited, so a package added to neither passes. + * + * The oracles are deliberately regex over manifest *text*, not parsers: + * + * - `Cargo.toml`'s `[workspace] members` + * - `settings.gradle.kts`'s unconditional `include(":x")` plus the + * `project(":x").projectDir = file("y")` that names its directory + * - `capsule-swift/Project.swift`'s `module("Name"` targets and its app target + * - a root-level directory holding `Package.swift` (SwiftPM), `package.json` + * (bun) or `pyproject.toml` (uv) + * - `locales/`, `.gitmodules`, and the `legacy-review//` directories + * + * That keeps this file inside `docs-truth`'s no-dependency, no-toolchain rule + * (`docs-truth.mjs`), which is what lets a docs-only pull request run it without + * paying for a cargo, gradle, tuist or uv resolve. + * + * **A conditional target is invisible here, on purpose.** `Project.swift` wraps + * `CapsuleCatalogFFI` in a `ffiEnabled ? … : []` ternary and + * `settings.gradle.kts` keeps `:cli`/`:desktop` commented out. A regex cannot + * evaluate either condition, so the rule is: only unconditional declarations are + * oracles, and a conditional target earns a row whose `State` says `excluded`. + * `CapsuleCatalogFFI` still matches `module("` and so is required to have a row; + * the commented-out Gradle modules match nothing and so must not have one. + */ + +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +/** The package view. */ +const ROADMAP = 'ROADMAP.md'; + +/** The slice tracker whose ids the `Open slices` column cites. */ +const SLICES = 'SLICES.md'; + +/** Columns the package table must carry, in order. */ +const COLUMNS = [ + 'Package', + 'Kind', + 'Owns', + 'State', + 'Gate', + 'Owner docs', + 'Open slices', + 'Next milestone', + 'Notes', +]; + +/** States whose rows are outside every gate, and so may write `—` for `Gate`. */ +const UNGATED_STATES = new Set(['review-only', 'excluded']); + +/** Directories never treated as a package root, whatever they contain. */ +const SKIP_ROOT_DIRS = new Set([ + '.git', + '.github', + 'adr', + 'docs', + 'gradle', + 'images', + 'legacy-review', + 'locales', + 'mise-tasks', + 'node_modules', + 'rawshift', + 'target', +]); + +/** An em-dash cell: "this column does not apply to this row". */ +const NONE = '—'; + +/** Split one Markdown table row into trimmed cells, dropping the outer pipes. */ +function cells(line) { + const trimmed = line.trim().replace(/^\|/, '').replace(/\|$/, ''); + return trimmed.split('|').map((cell) => cell.trim()); +} + +/** True for a `| --- | --- |` separator row. */ +function isSeparator(line) { + return /^\|[\s:|-]+\|$/.test(line.trim()); +} + +/** + * Every pipe table in `source`, as `{ header, rows }` where a row carries its + * cells and the 1-based line it sits on. + * + * @param {string} source Markdown document. + * @returns {{ header: string[], rows: { cells: string[], line: number }[] }[]} + */ +export function tables(source) { + const lines = source.split('\n'); + const found = []; + + for (let i = 0; i < lines.length; i += 1) { + if (!lines[i].trim().startsWith('|')) continue; + if (!isSeparator(lines[i + 1] ?? '')) continue; + + const header = cells(lines[i]); + const rows = []; + let j = i + 2; + for (; j < lines.length && lines[j].trim().startsWith('|'); j += 1) { + rows.push({ cells: cells(lines[j]), line: j + 1 }); + } + found.push({ header, rows }); + i = j; + } + + return found; +} + +/** + * The state vocabulary the document defines, in document order. + * + * Definitions are the bullet list under the `## States` heading, each of the + * form ``- `name` — meaning``. + * + * @param {string} source `ROADMAP.md`. + * @returns {string[]} + */ +export function definedStates(source) { + const opens = /^##\s+States\b.*$/m.exec(source); + if (!opens) return []; + const body = source.slice(opens.index + opens[0].length); + const closes = /^##\s/m.exec(body); + const section = closes ? body.slice(0, closes.index) : body; + return [...section.matchAll(/^-\s+`([a-z][a-z-]*)`\s+—/gm)].map( + (match) => match[1], + ); +} + +/** Quoted strings on lines that are not comments. */ +function quoted(text, pattern) { + const found = []; + for (const line of text.split('\n')) { + const trimmed = line.trim(); + if (trimmed.startsWith('#') || trimmed.startsWith('//')) continue; + for (const match of trimmed.matchAll(pattern)) found.push(match[1]); + } + return found; +} + +/** `[workspace] members` — each member path is a cargo package row. */ +function cargoPackages(root) { + const manifest = join(root, 'Cargo.toml'); + if (!existsSync(manifest)) return []; + const body = readFileSync(manifest, 'utf8'); + // `^members` anchors at line start, which `default-members` cannot match. + const block = /^members\s*=\s*\[([\s\S]*?)^\]/m.exec(body); + if (!block) return []; + return quoted(block[1], /"([^"]+)"/g); +} + +/** Unconditional `include(":x")`, resolved to the directory `project()` names. */ +function gradlePackages(root) { + const settings = join(root, 'settings.gradle.kts'); + if (!existsSync(settings)) return []; + const body = readFileSync(settings, 'utf8'); + const included = quoted(body, /^include\("([^"]+)"\)/g); + const dirs = new Map(); + for (const line of body.split('\n')) { + const trimmed = line.trim(); + if (trimmed.startsWith('//')) continue; + const match = + /^project\("([^"]+)"\)\.projectDir\s*=\s*file\("([^"]+)"\)/.exec( + trimmed, + ); + if (match) dirs.set(match[1], match[2]); + } + return included.map((path) => dirs.get(path) ?? path.replace(/^:/, '')); +} + +/** `module("Name"` framework targets plus the single app target. */ +function tuistPackages(root) { + const project = join(root, 'capsule-swift', 'Project.swift'); + if (!existsSync(project)) return []; + const body = readFileSync(project, 'utf8'); + const names = [...body.matchAll(/\bmodule\(\s*"([A-Za-z0-9_]+)"/g)].map( + (match) => match[1], + ); + const app = + /\bappTarget\s*:\s*Target\s*=\s*\.target\(\s*name:\s*"([A-Za-z0-9_]+)"/.exec( + body, + ); + if (app) names.push(app[1]); + return names; +} + +/** Root-level directories carrying `manifest`, e.g. `package.json`. */ +function manifestDirs(root, manifest) { + return readdirSync(root, { withFileTypes: true }) + .filter( + (entry) => + entry.isDirectory() && + !entry.name.startsWith('.') && + !SKIP_ROOT_DIRS.has(entry.name) && + existsSync(join(root, entry.name, manifest)), + ) + .map((entry) => entry.name); +} + +/** `path = x` in `.gitmodules`. */ +function submodules(root) { + const modules = join(root, '.gitmodules'); + if (!existsSync(modules)) return []; + return [ + ...readFileSync(modules, 'utf8').matchAll(/^\s*path\s*=\s*(\S+)/gm), + ].map((match) => match[1]); +} + +/** Each `legacy-review//` directory. */ +function reviewBuckets(root) { + const bucket = join(root, 'legacy-review'); + if (!existsSync(bucket)) return []; + return readdirSync(bucket, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => `legacy-review/${entry.name}`); +} + +/** + * Every package the tree declares, as `name -> kind`. + * + * @param {string} root Repository root. + * @returns {Map} + */ +export function declaredPackages(root) { + const declared = new Map(); + const add = (kind) => (name) => declared.set(name, kind); + + cargoPackages(root).forEach(add('cargo')); + gradlePackages(root).forEach(add('gradle')); + tuistPackages(root).forEach(add('tuist')); + manifestDirs(root, 'Package.swift').forEach(add('swiftpm')); + manifestDirs(root, 'package.json').forEach(add('bun')); + manifestDirs(root, 'pyproject.toml').forEach(add('python')); + if (existsSync(join(root, 'locales'))) declared.set('locales', 'catalog'); + submodules(root).forEach(add('submodule')); + reviewBuckets(root).forEach(add('review-bucket')); + + return declared; +} + +/** Every `mise run ` name the repository actually has. */ +export function miseTasks(root) { + const tasks = new Set(); + + for (const manifest of ['mise.toml', 'capsule-swift/mise.toml']) { + const path = join(root, manifest); + if (!existsSync(path)) continue; + for (const match of readFileSync(path, 'utf8').matchAll( + /^\[tasks\.([A-Za-z0-9_-]+)\]/gm, + )) { + tasks.add(match[1]); + } + } + + const fileTasks = join(root, 'mise-tasks'); + if (existsSync(fileTasks)) { + for (const entry of readdirSync(fileTasks, { withFileTypes: true })) { + if (entry.isFile()) tasks.add(entry.name); + } + } + + return tasks; +} + +/** Every slice id `SLICES.md` gives a detail block. */ +export function sliceIds(root) { + const path = join(root, SLICES); + if (!existsSync(path)) return new Set(); + return new Set( + [ + ...readFileSync(path, 'utf8').matchAll(/^###\s+(S-[A-Z]+\d+)\s/gm), + ].map((match) => match[1]), + ); +} + +/** + * Resolve every `ROADMAP.md` row against the tree. + * + * @param {string} root Repository root. + * @returns {{ findings: string[], checked: number }} + */ +export function checkRoadmap(root) { + const findings = []; + const path = join(root, ROADMAP); + + if (!existsSync(path)) { + return { + findings: [`${ROADMAP} the package view is missing`], + checked: 0, + }; + } + + const source = readFileSync(path, 'utf8'); + const states = definedStates(source); + if (states.length === 0) { + findings.push(`${ROADMAP} no state vocabulary under \`## States\``); + } + + const parsed = tables(source); + const table = parsed.find((candidate) => candidate.header[0] === 'Package'); + if (!table) { + findings.push( + `${ROADMAP} no package table (its first column is \`Package\`)`, + ); + return { findings, checked: 0 }; + } + + if (table.header.join(' | ') !== COLUMNS.join(' | ')) { + findings.push( + `${ROADMAP} header is \`${table.header.join(' | ')}\`, expected \`${COLUMNS.join(' | ')}\``, + ); + } + + const declared = declaredPackages(root); + const tasks = miseTasks(root); + const slices = sliceIds(root); + const known = new Set(states); + // Every state used anywhere in the document, so the deferred register below + // the package table counts as a user of `deferred`. + const used = new Set(); + for (const candidate of parsed) { + const column = candidate.header.indexOf('State'); + if (column === -1) continue; + for (const row of candidate.rows) { + if (row.cells[column]) + used.add(row.cells[column].replace(/`/g, '')); + } + } + + const seen = new Set(); + + for (const { cells: row, line } of table.rows) { + const at = `${ROADMAP}:${line}`; + + if (row.length !== COLUMNS.length) { + findings.push( + `${at} ${row.length} column(s), expected ${COLUMNS.length}`, + ); + continue; + } + + const [pkg, kind, , state, gate, , open] = row.map((cell) => + cell.replace(/`/g, ''), + ); + + if (seen.has(pkg)) findings.push(`${at} ${pkg} has more than one row`); + seen.add(pkg); + + const kindOf = declared.get(pkg); + if (kindOf === undefined) { + findings.push( + `${at} ${pkg} is not declared by any manifest in the tree`, + ); + } else if (kindOf !== kind) { + findings.push( + `${at} ${pkg} is declared as \`${kindOf}\`, the row says \`${kind}\``, + ); + } + + if (!known.has(state)) { + findings.push( + `${at} state \`${state}\` is not one of ${[...known].map((s) => `\`${s}\``).join(', ')}`, + ); + } + + if (gate === NONE) { + if (!UNGATED_STATES.has(state)) { + findings.push( + `${at} only ${[...UNGATED_STATES].join('/')} rows may leave \`Gate\` empty`, + ); + } + } else { + const task = /^mise run ([A-Za-z0-9_-]+)$/.exec(gate); + if (!task) { + findings.push( + `${at} gate \`${gate}\` is not \`mise run \` or \`${NONE}\``, + ); + } else if (!tasks.has(task[1])) { + findings.push( + `${at} \`mise run ${task[1]}\` is not a task in this repository`, + ); + } + } + + if (open !== NONE) { + for (const id of open.split(',').map((entry) => entry.trim())) { + if (!/^S-[A-Z]+\d+$/.test(id)) { + findings.push(`${at} \`${id}\` is not a slice id`); + } else if (!slices.has(id)) { + findings.push( + `${at} \`${id}\` has no detail block in ${SLICES}`, + ); + } + } + } + } + + for (const [pkg, kind] of declared) { + if (!seen.has(pkg)) { + findings.push(`${ROADMAP} ${kind} package \`${pkg}\` has no row`); + } + } + + // A defined state nothing uses is reported and does **not** fail. Failing on + // it would put steady pressure on whoever is editing the file to give the + // spare term a row — which is a dishonest state assignment, the exact defect + // this whole check exists to prevent. Naming it in the success line keeps the + // vocabulary from rotting unnoticed at no such cost. + const unused = [...known].filter((state) => !used.has(state)); + + return { findings, checked: declared.size, unused }; +} + +/** @param {{ findings: string[], checked: number, unused?: string[] }} result */ +export function reportRoadmap({ findings, checked, unused = [] }) { + const spare = + unused.length === 0 + ? '' + : ` States defined and unused: ${unused.map((state) => `\`${state}\``).join(', ')}.`; + if (findings.length === 0) { + return `roadmap: ${checked} package(s) checked, all rows resolve.${spare}`; + } + return [ + `roadmap: ${findings.length} row(s) in ${ROADMAP} disagree with the tree.`, + '', + ...findings.map((finding) => ` ${finding}`), + '', + `roadmap failed: ${findings.length} unresolved of ${checked} package(s) checked.${spare}`, + ].join('\n'); +} + +export const roadmapCheck = { + name: 'roadmap', + run: checkRoadmap, + report: reportRoadmap, +}; diff --git a/capsule-docs/scripts/check-roadmap.test.mjs b/capsule-docs/scripts/check-roadmap.test.mjs new file mode 100644 index 00000000..48302930 --- /dev/null +++ b/capsule-docs/scripts/check-roadmap.test.mjs @@ -0,0 +1,403 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + checkRoadmap, + declaredPackages, + definedStates, + miseTasks, + reportRoadmap, + sliceIds, + tables, +} from './check-roadmap.mjs'; + +let root; + +function repo(files) { + root = mkdtempSync(join(tmpdir(), 'capsule-roadmap-')); + for (const [rel, contents] of Object.entries(files)) { + const abs = join(root, rel); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, contents); + } + return root; +} + +afterEach(() => { + if (root) rmSync(root, { recursive: true, force: true }); + root = undefined; +}); + +const STATES = `## States + +- \`frozen\` — ships, contract settled. +- \`stabilizing\` — live and gated. +- \`rebuilding\` — quarantined and being re-landed. +- \`blocked\` — a named dependency gates it. +- \`deferred\` — deliberately unscheduled. +- \`review-only\` — reference material. +- \`excluded\` — outside the shipped build. +`; + +const HEADER = + '| Package | Kind | Owns | State | Gate | Owner docs | Open slices | Next milestone | Notes |\n' + + '| --- | --- | --- | --- | --- | --- | --- | --- | --- |'; + +/** A `ROADMAP.md` whose package table is exactly `rows`. */ +function roadmap(rows) { + return `# Roadmap\n\n${STATES}\n## Packages\n\n${HEADER}\n${rows.join('\n')}\n`; +} + +/** One well-formed row for a cargo package with no open slices. */ +function row(pkg, overrides = {}) { + const cell = { + kind: 'cargo', + owns: 'things', + state: 'stabilizing', + gate: '`mise run check-rust`', + docs: '[d](d.md)', + open: '—', + next: 'later', + notes: '—', + ...overrides, + }; + return `| \`${pkg}\` | ${cell.kind} | ${cell.owns} | ${cell.state} | ${cell.gate} | ${cell.docs} | ${cell.open} | ${cell.next} | ${cell.notes} |`; +} + +/** The minimum tree the check reads besides `ROADMAP.md`. */ +const BASE = { + 'Cargo.toml': '[workspace]\nmembers = [\n "alpha",\n]\n', + 'mise.toml': '[tasks.check-rust]\nrun = "true"\n', + 'SLICES.md': '### S-A1 — a slice\n\n### S-B2 — another slice\n', +}; + +describe('tables', () => { + it('reads a table into a header and rows carrying their line numbers', () => { + const [table] = tables( + 'intro\n\n| A | B |\n| --- | --- |\n| 1 | 2 |\n', + ); + expect(table.header).toEqual(['A', 'B']); + expect(table.rows).toEqual([{ cells: ['1', '2'], line: 5 }]); + }); + + it('separates two tables rather than running them together', () => { + const found = tables( + '| A |\n| --- |\n| 1 |\n\n| B |\n| --- |\n| 2 |\n', + ); + expect(found.map((t) => t.header)).toEqual([['A'], ['B']]); + }); + + it('ignores a pipe line that no separator follows', () => { + expect(tables('| not a table |\nprose\n')).toEqual([]); + }); +}); + +describe('definedStates', () => { + it('reads the vocabulary under the States heading, in order', () => { + expect(definedStates(`# R\n\n${STATES}\n## Packages\n`)).toEqual([ + 'frozen', + 'stabilizing', + 'rebuilding', + 'blocked', + 'deferred', + 'review-only', + 'excluded', + ]); + }); + + it('stops at the next section, so a later bullet is not a state', () => { + const source = `${STATES}\n## Packages\n\n- \`sneaky\` — not a state.\n`; + expect(definedStates(source)).not.toContain('sneaky'); + }); + + it('returns nothing when the document defines no vocabulary', () => { + expect(definedStates('# Roadmap\n\n## Packages\n')).toEqual([]); + }); +}); + +describe('declaredPackages', () => { + it('reads workspace members and skips default-members', () => { + const r = repo({ + 'Cargo.toml': + '[workspace]\nmembers = [\n "alpha",\n # "commented",\n "beta/gamma",\n]\ndefault-members = [\n "alpha",\n]\n', + }); + expect([...declaredPackages(r)]).toEqual([ + ['alpha', 'cargo'], + ['beta/gamma', 'cargo'], + ]); + }); + + it('resolves a gradle include to the directory project() names', () => { + const r = repo({ + 'settings.gradle.kts': + 'include(":android")\nproject(":android").projectDir = file("capsule-android")\n', + }); + expect(declaredPackages(r).get('capsule-android')).toBe('gradle'); + }); + + it('does not see a commented-out gradle include', () => { + // `settings.gradle.kts` keeps `:cli`/`:desktop` commented out; a row for + // either would then be an orphan, which is the finding to avoid. + const r = repo({ + 'settings.gradle.kts': + '// include(":desktop")\n// project(":desktop").projectDir = file("capsule-desktop")\n', + }); + expect(declaredPackages(r).size).toBe(0); + }); + + it('reads tuist module targets across line breaks, plus the app target', () => { + const r = repo({ + 'capsule-swift/Project.swift': + 'private func module(\n _ name: String\n) -> [Target] { [] }\n' + + 'let moduleTargets: [Target] = module("CapsuleFoundation")\n' + + ' + module(\n "FeatureAlbums",\n dependencies: []\n )\n' + + 'private let appTarget: Target = .target(\n name: "Capsule",\n product: .app\n)\n', + }); + expect([...declaredPackages(r).keys()]).toEqual([ + 'CapsuleFoundation', + 'FeatureAlbums', + 'Capsule', + ]); + }); + + it('reads a conditional tuist target, which therefore needs a row', () => { + const r = repo({ + 'capsule-swift/Project.swift': + 'let t = ffiEnabled\n ? module(\n "CapsuleCatalogFFI"\n )\n : []\n', + }); + expect(declaredPackages(r).get('CapsuleCatalogFFI')).toBe('tuist'); + }); + + it('classifies a package root by the manifest it carries', () => { + const r = repo({ + 'capsule-core-swift/Package.swift': '// swift-tools-version:6.0\n', + 'capsule-web/package.json': '{}\n', + 'capsule-vision/pyproject.toml': '[project]\n', + 'locales/en.json': '{}\n', + '.gitmodules': '[submodule "rawshift"]\n\tpath = rawshift\n', + 'legacy-review/server-salvo/REVIEW.md': '', + }); + const declared = declaredPackages(r); + expect(declared.get('capsule-core-swift')).toBe('swiftpm'); + expect(declared.get('capsule-web')).toBe('bun'); + expect(declared.get('capsule-vision')).toBe('python'); + expect(declared.get('locales')).toBe('catalog'); + expect(declared.get('rawshift')).toBe('submodule'); + expect(declared.get('legacy-review/server-salvo')).toBe( + 'review-bucket', + ); + }); +}); + +describe('miseTasks and sliceIds', () => { + it('reads tasks from both manifests and from the file-task directory', () => { + const r = repo({ + 'mise.toml': '[tasks.check-rust]\nrun = "true"\n', + 'capsule-swift/mise.toml': '[tasks.generate]\nrun = "true"\n', + 'mise-tasks/check-swift': '#!/usr/bin/env bash\n', + }); + expect([...miseTasks(r)].sort()).toEqual([ + 'check-rust', + 'check-swift', + 'generate', + ]); + }); + + it('reads slice ids from detail headings only', () => { + const r = repo({ + 'SLICES.md': + '| S-Z9 | an index row | | | | | |\n\n### S-A1 — a slice\n\n#### S-A2 — not a detail block\n', + }); + expect([...sliceIds(r)]).toEqual(['S-A1']); + }); +}); + +describe('checkRoadmap', () => { + it('passes a roadmap whose rows all resolve', () => { + const r = repo({ ...BASE, 'ROADMAP.md': roadmap([row('alpha')]) }); + const result = checkRoadmap(r); + expect(result.findings).toEqual([]); + expect(result.checked).toBe(1); + expect(reportRoadmap(result)).toContain('1 package(s) checked'); + }); + + it('fails when a declared package has no row', () => { + const r = repo({ + ...BASE, + 'Cargo.toml': + '[workspace]\nmembers = [\n "alpha",\n "beta",\n]\n', + 'ROADMAP.md': roadmap([row('alpha')]), + }); + expect(checkRoadmap(r).findings).toEqual([ + 'ROADMAP.md cargo package `beta` has no row', + ]); + }); + + it('fails on a row naming nothing in the tree', () => { + const r = repo({ + ...BASE, + 'ROADMAP.md': roadmap([row('alpha'), row('ghost')]), + }); + expect(checkRoadmap(r).findings).toEqual([ + 'ROADMAP.md:18 ghost is not declared by any manifest in the tree', + ]); + }); + + it('fails when a row claims the wrong kind', () => { + const r = repo({ + ...BASE, + 'ROADMAP.md': roadmap([row('alpha', { kind: 'bun' })]), + }); + expect(checkRoadmap(r).findings[0]).toContain( + 'alpha is declared as `cargo`, the row says `bun`', + ); + }); + + it('fails on a state outside the defined vocabulary', () => { + const r = repo({ + ...BASE, + 'ROADMAP.md': roadmap([row('alpha', { state: 'nearly-done' })]), + }); + expect(checkRoadmap(r).findings[0]).toContain( + 'state `nearly-done` is not one of', + ); + }); + + it('fails on a slice id with no detail block', () => { + const r = repo({ + ...BASE, + 'ROADMAP.md': roadmap([row('alpha', { open: '`S-A1`, `S-Z9`' })]), + }); + expect(checkRoadmap(r).findings).toEqual([ + 'ROADMAP.md:17 `S-Z9` has no detail block in SLICES.md', + ]); + }); + + it('fails on a slice cell that is not an id at all', () => { + const r = repo({ + ...BASE, + 'ROADMAP.md': roadmap([row('alpha', { open: 'lane B' })]), + }); + expect(checkRoadmap(r).findings).toEqual([ + 'ROADMAP.md:17 `lane B` is not a slice id', + ]); + }); + + it('fails on a gate naming a task the repository does not have', () => { + const r = repo({ + ...BASE, + 'ROADMAP.md': roadmap([ + row('alpha', { gate: '`mise run check-moon`' }), + ]), + }); + expect(checkRoadmap(r).findings).toEqual([ + 'ROADMAP.md:17 `mise run check-moon` is not a task in this repository', + ]); + }); + + it('fails on a gate that is not a mise invocation', () => { + const r = repo({ + ...BASE, + 'ROADMAP.md': roadmap([row('alpha', { gate: '`cargo test`' })]), + }); + expect(checkRoadmap(r).findings[0]).toContain( + 'gate `cargo test` is not `mise run `', + ); + }); + + it('lets a review-only or excluded row leave the gate empty, and no other', () => { + const gateless = { gate: '—' }; + const ok = repo({ + ...BASE, + 'ROADMAP.md': roadmap([ + row('alpha', { ...gateless, state: 'review-only' }), + ]), + }); + expect(checkRoadmap(ok).findings).toEqual([]); + rmSync(root, { recursive: true, force: true }); + + const bad = repo({ + ...BASE, + 'ROADMAP.md': roadmap([row('alpha', gateless)]), + }); + expect(checkRoadmap(bad).findings[0]).toContain( + 'may leave `Gate` empty', + ); + }); + + it('fails on a row with the wrong number of columns', () => { + const r = repo({ + ...BASE, + 'ROADMAP.md': roadmap(['| `alpha` | cargo | things |']), + }); + // A malformed row is skipped whole, so the package it meant to cover is + // also reported as unrowed. Both findings point at the same repair. + expect(checkRoadmap(r).findings).toEqual([ + 'ROADMAP.md:17 3 column(s), expected 9', + 'ROADMAP.md cargo package `alpha` has no row', + ]); + }); + + it('fails on a duplicated package row', () => { + const r = repo({ + ...BASE, + 'ROADMAP.md': roadmap([row('alpha'), row('alpha')]), + }); + expect(checkRoadmap(r).findings).toEqual([ + 'ROADMAP.md:18 alpha has more than one row', + ]); + }); + + it('fails when the header is not the nine agreed columns', () => { + const r = repo({ + ...BASE, + 'ROADMAP.md': `# R\n\n${STATES}\n| Package | Kind | Owns | State | Gate | Owner docs | Open slices | Next milestone | Remarks |\n| --- | --- | --- | --- | --- | --- | --- | --- | --- |\n${row('alpha')}\n`, + }); + expect(checkRoadmap(r).findings[0]).toContain( + 'expected `Package | Kind', + ); + }); + + it('reports an unused state without failing, and counts a second table', () => { + // Forcing every defined state into use would push whoever edits the file + // into a dishonest assignment, so this is a report, not a finding. + const r = repo({ ...BASE, 'ROADMAP.md': roadmap([row('alpha')]) }); + const result = checkRoadmap(r); + expect(result.findings).toEqual([]); + expect(result.unused).toContain('deferred'); + expect(reportRoadmap(result)).toContain('States defined and unused'); + }); + + it('counts a state used only by the deferred register', () => { + const register = + '| Item | Owner docs | State | Notes |\n| --- | --- | --- | --- |\n| a thing | [d](d.md) | deferred | later |'; + const r = repo({ + ...BASE, + 'ROADMAP.md': `${roadmap([row('alpha')])}\n${register}\n`, + }); + expect(checkRoadmap(r).unused).not.toContain('deferred'); + }); + + it('fails when the file is missing entirely', () => { + const r = repo(BASE); + expect(checkRoadmap(r).findings).toEqual([ + 'ROADMAP.md the package view is missing', + ]); + }); + + it('fails when the file carries no package table', () => { + const r = repo({ ...BASE, 'ROADMAP.md': `# R\n\n${STATES}\n` }); + expect(checkRoadmap(r).findings).toEqual([ + 'ROADMAP.md no package table (its first column is `Package`)', + ]); + }); + + it('fails when the file defines no state vocabulary', () => { + const r = repo({ + ...BASE, + 'ROADMAP.md': `# R\n\n## Packages\n\n${HEADER}\n${row('alpha')}\n`, + }); + expect(checkRoadmap(r).findings[0]).toContain('no state vocabulary'); + }); +}); diff --git a/capsule-docs/scripts/docs-truth.mjs b/capsule-docs/scripts/docs-truth.mjs index 7e15a592..5e0adec7 100644 --- a/capsule-docs/scripts/docs-truth.mjs +++ b/capsule-docs/scripts/docs-truth.mjs @@ -33,9 +33,15 @@ import { fileURLToPath } from 'node:url'; import { crossLinksCheck } from './check-cross-links.mjs'; import { endpointCensusCheck } from './check-endpoint-census.mjs'; import { modulePathsCheck } from './check-module-paths.mjs'; +import { roadmapCheck } from './check-roadmap.mjs'; /** Registered checks, run in order. Adding one is a single entry here. */ -const CHECKS = [crossLinksCheck, endpointCensusCheck, modulePathsCheck]; +const CHECKS = [ + crossLinksCheck, + endpointCensusCheck, + modulePathsCheck, + roadmapCheck, +]; function main() { // scripts/ -> capsule-docs/ -> repo root From c4e9caefb92d03a4baa12c76d6cebf5c1f250822 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 20:43:57 -0400 Subject: [PATCH 009/243] refactor(core)!: delete the dead public surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `capsule-core` carried public items with zero call sites anywhere in the workspace. Each one is a promise the crate cannot retire later without a breaking change, so they go before the API is frozen. Removed: - `models` (`Asset`, `Album`) — plaintext-era types, zero references. - `constants::IGNORE_RULES` — the only hit was its own definition. `SIDECAR_EXTENSIONS` stays; `metadata` reads it. - `validation::idempotency` (`IdempotencyKey`, `session_key`, `chunk_key`) — zero references outside the barrel re-export. - `metadata::export_policy` (`ExportOptions`, `strip_for_export`) — zero code call sites. The design docs and `capsule-server`'s share module both claimed it implemented the boundary-crossing strip; they now say the strip is unimplemented and point at `S-C50`, which is the truth. A documented security control with no caller is worse than an absent one. `utils::hash` was *not* dead and is folded rather than deleted: `get_file_hash` becomes `crypto::hash::hash_file`, returning `Hash32` like its neighbours instead of a hex `String`, and the `String` twin `utils::hash::hash_bytes` gives way to `crypto::hash::hash_bytes(..) .to_hex()`. One hash module, one return type. `utils` keeps `paths`. Two known-answer tests cover the folded entry point: `hash_file` over a file larger than the 64 KiB read block equals the one-shot digest, and a missing path surfaces `NotFound` rather than a panic. BREAKING CHANGE: `capsule_core::models`, `constants::IGNORE_RULES`, `validation::{idempotency, IdempotencyKey}`, `metadata::export_policy` and `utils::hash` are removed. `utils::hash::get_file_hash` is now `crypto::hash::hash_file` and returns `Hash32`. --- capsule-core/src/constants.rs | 7 - capsule-core/src/crypto/hash.rs | 29 ++++ capsule-core/src/import/planner.rs | 8 +- capsule-core/src/lib.rs | 2 - capsule-core/src/metadata/export_policy.rs | 151 ------------------ capsule-core/src/metadata/file.rs | 4 +- capsule-core/src/metadata/mod.rs | 1 - capsule-core/src/models/album.rs | 12 -- capsule-core/src/models/asset.rs | 27 ---- capsule-core/src/models/mod.rs | 2 - capsule-core/src/utils/hash.rs | 19 --- capsule-core/src/utils/mod.rs | 1 - capsule-core/src/validation/idempotency.rs | 83 ---------- capsule-core/src/validation/mod.rs | 13 +- .../src/content/docs/design/metadata.md | 2 +- capsule-sdk/Cargo.toml | 2 +- capsule-sdk/src/fetch.rs | 2 +- capsule-sdk/src/recovery/mod.rs | 10 +- capsule-server/src/share/mod.rs | 5 +- 19 files changed, 51 insertions(+), 329 deletions(-) delete mode 100644 capsule-core/src/metadata/export_policy.rs delete mode 100644 capsule-core/src/models/album.rs delete mode 100644 capsule-core/src/models/asset.rs delete mode 100644 capsule-core/src/models/mod.rs delete mode 100644 capsule-core/src/utils/hash.rs delete mode 100644 capsule-core/src/validation/idempotency.rs diff --git a/capsule-core/src/constants.rs b/capsule-core/src/constants.rs index 6e0b7a43..de070fc9 100644 --- a/capsule-core/src/constants.rs +++ b/capsule-core/src/constants.rs @@ -1,10 +1,3 @@ -pub const IGNORE_RULES: &[&str] = &[ - // Ignore hidden files and directories - ".*", - // Ignore system files - "*.DS_Store", -]; - pub const SIDECAR_EXTENSIONS: &[&str] = &[ // XMP "xmp", // Custom formats diff --git a/capsule-core/src/crypto/hash.rs b/capsule-core/src/crypto/hash.rs index e711d1b3..086f9011 100644 --- a/capsule-core/src/crypto/hash.rs +++ b/capsule-core/src/crypto/hash.rs @@ -12,7 +12,9 @@ //! //! [Cryptography — Primitives § Cryptographic Hash]: https://docs/design/cryptography/primitives/#cryptographic-hash +use std::fs::File; use std::io::{self, Read}; +use std::path::Path; use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use sha2::{Digest, Sha256}; @@ -159,6 +161,15 @@ pub fn hash_reader(mut reader: R) -> io::Result { Ok(hasher.finalize()) } +/// SHA-256 of a file's contents, streamed in fixed blocks. +/// +/// Opens `path` and feeds it through [`hash_reader`] rather than reading the whole file +/// into memory, so arbitrarily large originals hash with bounded memory. +pub fn hash_file(path: &Path) -> io::Result { + let file = File::open(path)?; + hash_reader(io::BufReader::new(file)) +} + #[cfg(test)] mod tests { use super::*; @@ -201,6 +212,24 @@ mod tests { assert_eq!(hash_reader(&data[..]).unwrap(), one_shot); } + #[test] + fn hash_file_matches_one_shot() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("blob.bin"); + // Larger than the 64 KiB read block, so the streaming path takes more than one turn. + let data = vec![0x5Au8; 64 * 1024 + 7]; + std::fs::write(&path, &data).unwrap(); + + assert_eq!(hash_file(&path).unwrap(), hash_bytes(&data)); + } + + #[test] + fn hash_file_reports_a_missing_path() { + let dir = tempfile::tempdir().unwrap(); + let err = hash_file(&dir.path().join("absent.bin")).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::NotFound); + } + #[test] fn hex_round_trip() { let h = hash_bytes(b"capsule"); diff --git a/capsule-core/src/import/planner.rs b/capsule-core/src/import/planner.rs index 70a75b03..ab553076 100644 --- a/capsule-core/src/import/planner.rs +++ b/capsule-core/src/import/planner.rs @@ -234,7 +234,7 @@ fn decide( } fn hash_file(path: &Path) -> Result { - crate::utils::hash::get_file_hash(path) + Ok(crate::crypto::hash::hash_file(path)?.to_hex()) } // ── Tests ──────────────────────────────────────────────────────────────────── @@ -281,7 +281,7 @@ mod tests { // Write a file and pre-insert its hash let content = b"unique_photo_content"; fs::write(tmp.path().join("photo.jpg"), content).unwrap(); - let hash = crate::utils::hash::hash_bytes(content); + let hash = crate::crypto::hash::hash_bytes(content).to_hex(); let row = crate::db::rows::AssetRow { uuid: "existing-uuid".to_string(), @@ -327,7 +327,7 @@ mod tests { fs::write(tmp.path().join("b.jpg"), vec![0u8; 25]).unwrap(); let dup_content = vec![7u8; 99]; fs::write(tmp.path().join("dup.jpg"), &dup_content).unwrap(); - let dup_hash = crate::utils::hash::hash_bytes(&dup_content); + let dup_hash = crate::crypto::hash::hash_bytes(&dup_content).to_hex(); let row = crate::db::rows::AssetRow { uuid: "dup-uuid".to_string(), asset_type: "photo".to_string(), @@ -474,7 +474,7 @@ mod tests { let content = b"reimport_me"; fs::write(tmp.path().join("photo.jpg"), content).unwrap(); - let hash = crate::utils::hash::hash_bytes(content); + let hash = crate::crypto::hash::hash_bytes(content).to_hex(); let row = crate::db::rows::AssetRow { uuid: "existing-uuid2".to_string(), diff --git a/capsule-core/src/lib.rs b/capsule-core/src/lib.rs index d8872155..620daf1e 100644 --- a/capsule-core/src/lib.rs +++ b/capsule-core/src/lib.rs @@ -59,8 +59,6 @@ pub mod metadata; #[cfg(feature = "native")] pub mod ml; #[cfg(feature = "native")] -pub mod models; -#[cfg(feature = "native")] pub mod sidecar; #[cfg(feature = "native")] pub mod utils; diff --git a/capsule-core/src/metadata/export_policy.rs b/capsule-core/src/metadata/export_policy.rs deleted file mode 100644 index c233d77b..00000000 --- a/capsule-core/src/metadata/export_policy.rs +++ /dev/null @@ -1,151 +0,0 @@ -//! Privacy on export (SSoT: [Metadata — Privacy on Export]). -//! -//! Several sidecar fields are fingerprinting surface if they leave the user's trust -//! boundary unredacted (a camera serial links every photo to one device; precise GPS -//! reveals a home address). When an asset crosses a boundary (share link, external backup -//! handed off, federated peer), Capsule strips these by default and retains them only on -//! explicit, per-export opt-in. The **local** sidecar is never modified. -//! -//! [Metadata — Privacy on Export]: https://docs/design/metadata/#privacy-on-export - -use crate::sidecar::sidecar_v1::SidecarV1; - -/// Per-export opt-ins. Defaults strip everything (the safe default). -#[derive(Debug, Clone, Copy, Default)] -pub struct ExportOptions { - /// Retain the camera serial number. - pub retain_camera_serial: bool, - /// Retain the importing device id. - pub retain_device_id: bool, - /// Retain the importing session id. - pub retain_session_id: bool, - /// Retain full-precision GPS (otherwise rounded to ~1 km). - pub retain_full_gps: bool, -} - -/// Round a coordinate to 2 decimal places (~1 km), matching the export default. -fn round_2dp(x: f64) -> f64 { - (x * 100.0).round() / 100.0 -} - -/// Produce an export copy of `sidecar` with fingerprinting fields stripped per `opts`. The -/// returned sidecar is **unsigned** (the caller re-signs for the export context); the input -/// is left untouched. -pub fn strip_for_export(sidecar: &SidecarV1, opts: &ExportOptions) -> SidecarV1 { - let mut out = sidecar.clone(); - out.signature = None; - - if !opts.retain_camera_serial - && let Some(cam) = out.camera_id.as_mut() - { - // Keep the model (not identifying); drop the per-device serial. - cam.serial.clear(); - } - if !opts.retain_device_id { - out.device_id = uuid::Uuid::nil(); - } - if !opts.retain_session_id { - out.session_id = uuid::Uuid::nil(); - } - if !opts.retain_full_gps - && let Some(gps) = out.gps.as_mut() - { - gps.lat = round_2dp(gps.lat); - gps.lon = round_2dp(gps.lon); - } - out -} - -#[cfg(test)] -mod tests { - use std::collections::BTreeMap; - - use uuid::Uuid; - - use super::*; - use crate::crypto::hash::Hash32; - use crate::sidecar::sidecar_v1::{CameraId, Gps, GpsSource, SIDECAR_SCHEMA_V1}; - - fn sidecar() -> SidecarV1 { - SidecarV1 { - sidecar_schema: SIDECAR_SCHEMA_V1, - crypto_suite_id: crate::crypto::CRYPTO_SUITE_ID, - uuid: Uuid::from_u128(1), - hash: Hash32([0; 32]), - capture_timestamp: "2026-05-31T10:00:00Z".into(), - import_timestamp: "2026-05-31T11:00:00Z".into(), - content_type: "image/jpeg".into(), - dimensions: None, - lqip: None, - tags_user: Default::default(), - tags_ai: Default::default(), - caption: Default::default(), - rating: Default::default(), - stack_membership: Default::default(), - cull: Default::default(), - hidden: Default::default(), - camera_id: Some(CameraId { - model: "iPhone 15 Pro".into(), - serial: "SECRET-SERIAL".into(), - }), - device_id: Uuid::from_u128(0xD1), - session_id: Uuid::from_u128(0x5E), - gps: Some(Gps { - lat: 40.712812, - lon: -74.006015, - source: GpsSource::Exif, - datum: crate::domain::GpsDatum::Wgs84, - }), - provenance_chain_hash: Some(Hash32([0; 32])), - unknown: BTreeMap::new(), - signature: None, - } - } - - #[test] - fn default_strips_all_fingerprinting_fields() { - let s = sidecar(); - let e = strip_for_export(&s, &ExportOptions::default()); - assert_eq!(e.camera_id.as_ref().unwrap().serial, ""); - assert_eq!(e.camera_id.as_ref().unwrap().model, "iPhone 15 Pro"); // model retained - assert_eq!(e.device_id, Uuid::nil()); - assert_eq!(e.session_id, Uuid::nil()); - let gps = e.gps.unwrap(); - assert_eq!(gps.lat, 40.71); // rounded to 2dp - assert_eq!(gps.lon, -74.01); - - // Local sidecar is untouched. - assert_eq!(s.camera_id.as_ref().unwrap().serial, "SECRET-SERIAL"); - assert_eq!(s.device_id, Uuid::from_u128(0xD1)); - assert_eq!(s.gps.as_ref().unwrap().lat, 40.712812); - } - - #[test] - fn opt_in_retains_each_field() { - let s = sidecar(); - let opts = ExportOptions { - retain_camera_serial: true, - retain_device_id: true, - retain_session_id: true, - retain_full_gps: true, - }; - let e = strip_for_export(&s, &opts); - assert_eq!(e.camera_id.as_ref().unwrap().serial, "SECRET-SERIAL"); - assert_eq!(e.device_id, Uuid::from_u128(0xD1)); - assert_eq!(e.session_id, Uuid::from_u128(0x5E)); - assert_eq!(e.gps.as_ref().unwrap().lat, 40.712812); - } - - #[test] - fn partial_opt_in() { - let s = sidecar(); - let opts = ExportOptions { - retain_full_gps: true, - ..Default::default() - }; - let e = strip_for_export(&s, &opts); - // GPS retained, but device id still stripped. - assert_eq!(e.gps.as_ref().unwrap().lat, 40.712812); - assert_eq!(e.device_id, Uuid::nil()); - } -} diff --git a/capsule-core/src/metadata/file.rs b/capsule-core/src/metadata/file.rs index 8d7b882f..7f1a4755 100644 --- a/capsule-core/src/metadata/file.rs +++ b/capsule-core/src/metadata/file.rs @@ -5,7 +5,7 @@ use std::{fs, io}; use jiff::Timestamp; use serde::{Deserialize, Serialize}; -use crate::utils::hash::get_file_hash; +use crate::crypto::hash::hash_file; #[derive(Clone, Serialize, Deserialize)] pub struct HashData(String); @@ -57,7 +57,7 @@ impl FileMetadata { let metadata = fs::metadata(path)?; // Get file hash - let hash = get_file_hash(path)?; + let hash = hash_file(path)?.to_hex(); // Get file size let size = metadata.len(); diff --git a/capsule-core/src/metadata/mod.rs b/capsule-core/src/metadata/mod.rs index ba86a85b..f84e2cd7 100644 --- a/capsule-core/src/metadata/mod.rs +++ b/capsule-core/src/metadata/mod.rs @@ -4,7 +4,6 @@ use std::path::Path; use crate::constants::SIDECAR_EXTENSIONS; pub mod crdt; -pub mod export_policy; mod file; mod filter; mod types; diff --git a/capsule-core/src/models/album.rs b/capsule-core/src/models/album.rs deleted file mode 100644 index 4bbf9710..00000000 --- a/capsule-core/src/models/album.rs +++ /dev/null @@ -1,12 +0,0 @@ -pub enum AlbumAccess { - Owner, - Write, - Read, -} - -impl AlbumAccess { - /// Returns whether user has write access to album - pub fn is_write(&self) -> bool { - matches!(self, AlbumAccess::Owner | AlbumAccess::Write) - } -} diff --git a/capsule-core/src/models/asset.rs b/capsule-core/src/models/asset.rs deleted file mode 100644 index 79c3095c..00000000 --- a/capsule-core/src/models/asset.rs +++ /dev/null @@ -1,27 +0,0 @@ -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct Asset { - /// Asset ID - pub id: Uuid, - /// Album ID - pub album_id: Option, - /// Owner ID - pub owner_id: String, - /// File extension (e.g., "png", "mp4", "json") - /// Do NOT prepend with a dot (`.`) - /// String is case-sensitive - pub ext: String, -} - -impl Asset { - pub fn new(album_id: Option, owner_id: String, ext: String) -> Self { - Self { - id: Uuid::now_v7(), - album_id, - owner_id, - ext, - } - } -} diff --git a/capsule-core/src/models/mod.rs b/capsule-core/src/models/mod.rs deleted file mode 100644 index d050bcd1..00000000 --- a/capsule-core/src/models/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod album; -pub mod asset; diff --git a/capsule-core/src/utils/hash.rs b/capsule-core/src/utils/hash.rs deleted file mode 100644 index 09c9b41f..00000000 --- a/capsule-core/src/utils/hash.rs +++ /dev/null @@ -1,19 +0,0 @@ -use std::fs::File; -use std::io; -use std::path::Path; - -use crate::crypto::hash::{hash_bytes as hash32_bytes, hash_reader}; - -/// SHA-256 hash of a byte slice as a 64-char lowercase hex string. -pub fn hash_bytes(bytes: &[u8]) -> String { - hash32_bytes(bytes).to_hex() -} - -/// SHA-256 hash of a file as a 64-char lowercase hex string. -/// -/// Streams the file in fixed blocks via [`crate::crypto::hash`] rather than reading the -/// whole file into memory, so arbitrarily large originals hash with bounded memory. -pub fn get_file_hash(path: &Path) -> io::Result { - let file = File::open(path)?; - Ok(hash_reader(io::BufReader::new(file))?.to_hex()) -} diff --git a/capsule-core/src/utils/mod.rs b/capsule-core/src/utils/mod.rs index 5609d45f..8118b296 100644 --- a/capsule-core/src/utils/mod.rs +++ b/capsule-core/src/utils/mod.rs @@ -1,2 +1 @@ -pub mod hash; pub mod paths; diff --git a/capsule-core/src/validation/idempotency.rs b/capsule-core/src/validation/idempotency.rs deleted file mode 100644 index 166aaab3..00000000 --- a/capsule-core/src/validation/idempotency.rs +++ /dev/null @@ -1,83 +0,0 @@ -//! Idempotency keys for write surfaces (SSoT: [Threat Model — Idempotency Invariants]). -//! Every write surface has a single idempotency key: a duplicate (same key) is a no-op; a -//! conflict (same key, different content) is a corruption error. These constructors produce -//! a stable canonical key so a server can dedup deterministically. -//! -//! [Threat Model — Idempotency Invariants]: https://docs/design/threat-model/validation/#idempotency-invariants - -use uuid::Uuid; - -use crate::crypto::hash::Hash32; - -/// A stable idempotency key (a canonical string a server can index). -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct IdempotencyKey(pub String); - -/// `(owner_id, hash, album_id)` — session creation (`POST /upload`) dedup. -pub fn session_key(owner_id: &Uuid, hash: &Hash32, album_id: &Uuid) -> IdempotencyKey { - IdempotencyKey(format!("session:{owner_id}:{}:{album_id}", hash.to_hex())) -} - -/// `(asset_id, prior_provenance_hash, manifest_hash)` — lifecycle manifest write. -pub fn lifecycle_key( - asset_id: &Uuid, - prior: Option, - manifest_hash: &Hash32, -) -> IdempotencyKey { - let prior = prior.map_or_else(|| "null".into(), |h| h.to_hex()); - IdempotencyKey(format!( - "lifecycle:{asset_id}:{prior}:{}", - manifest_hash.to_hex() - )) -} - -/// `(upload_id, offset, chunk_hash)` — upload chunk (`PATCH /upload/{id}`). -pub fn chunk_key(upload_id: &Uuid, offset: u64, chunk_hash: &Hash32) -> IdempotencyKey { - IdempotencyKey(format!( - "chunk:{upload_id}:{offset}:{}", - chunk_hash.to_hex() - )) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn same_inputs_produce_the_same_key() { - let owner = Uuid::from_u128(1); - let album = Uuid::from_u128(2); - let h = Hash32([7; 32]); - assert_eq!( - session_key(&owner, &h, &album), - session_key(&owner, &h, &album) - ); - } - - #[test] - fn different_content_produces_a_different_key() { - // Same (asset, prior) but a different manifest hash → a *conflict*, distinguishable - // by the key differing (server treats same-key/different-content as corruption). - let asset = Uuid::from_u128(1); - let prior = Some(Hash32([1; 32])); - let a = lifecycle_key(&asset, prior, &Hash32([2; 32])); - let b = lifecycle_key(&asset, prior, &Hash32([3; 32])); - assert_ne!(a, b); - } - - #[test] - fn null_prior_is_distinct_from_zero_hash() { - let asset = Uuid::from_u128(1); - let mh = Hash32([9; 32]); - let create = lifecycle_key(&asset, None, &mh); - let zero = lifecycle_key(&asset, Some(Hash32([0; 32])), &mh); - assert_ne!(create, zero); - } - - #[test] - fn chunk_key_varies_by_offset() { - let up = Uuid::from_u128(1); - let h = Hash32([5; 32]); - assert_ne!(chunk_key(&up, 0, &h), chunk_key(&up, 4096, &h)); - } -} diff --git a/capsule-core/src/validation/mod.rs b/capsule-core/src/validation/mod.rs index 9ea72137..8b82a046 100644 --- a/capsule-core/src/validation/mod.rs +++ b/capsule-core/src/validation/mod.rs @@ -2,24 +2,21 @@ //! (SSoT: [Threat Model — Validation Invariants]). //! //! These are **pure, key-less** structural checks: the protocol/capability handshake -//! ([`protocol`]), the server-side manifest envelope ([`structural`]), and idempotency -//! keys ([`idempotency`]). They mirror the client-side checks in -//! [`verify_asset`](crate::crypto::verify_asset), and the server write paths consume them -//! today — the envelope gate, the ops surface, the feed, federation pull, and the drop -//! routes all validate through here. This module used to describe those consumers as +//! ([`protocol`]) and the server-side manifest envelope ([`structural`]). They mirror the +//! client-side checks in [`verify_asset`](crate::crypto::verify_asset), and the server +//! write paths consume them today — the envelope gate, the ops surface, the feed, +//! federation pull, and the drop routes all validate through here. This module used to describe those consumers as //! deferred; they are six live call sites, and the checks are the only thing standing //! between a key-free server and a malformed write. //! //! Upload-transport-specific invariants (chunk offset/4 KiB alignment, cumulative size) -//! live with the deferred upload protocol; the chunk idempotency key is provided here. +//! live with the deferred upload protocol. //! //! [Threat Model — Validation Invariants]: https://docs/design/threat-model/validation/ -pub mod idempotency; pub mod protocol; pub mod structural; -pub use idempotency::IdempotencyKey; pub use protocol::{HandshakeReject, protocol_gate}; pub use structural::{ EnvelopeContext, EnvelopeReject, check_manifest_envelope, check_metadata_blob_envelope, diff --git a/capsule-docs/src/content/docs/design/metadata.md b/capsule-docs/src/content/docs/design/metadata.md index f1e5ccec..056e33d2 100644 --- a/capsule-docs/src/content/docs/design/metadata.md +++ b/capsule-docs/src/content/docs/design/metadata.md @@ -156,7 +156,7 @@ Stripping happens at the moment of export — the encrypted sidecar inside the u Capsule's *own* devices syncing the *same user's* library do **not** trigger this redaction — that is intra-trust, not a boundary crossing. -**Status note.** The strip table is implemented in `capsule_core::metadata::export_policy` and applied **client-side, at the moment a share link is issued** — which is the only place it can be applied, since the server holds no key to the metadata a share serves. The server's complementary guarantee is containment: a share link serves only the content addresses its own record enumerates, so a stripped share cannot be walked sideways into the unstripped blob (slices `S-C4`, `S-C50`). Together these are the one export surface v1 ships, mandatory and with no opt-out. The external-backup handoff crossing waits on a client file-export command, which is post-v1; federated peers receive ciphertext, so their strip applies when a share boundary is crossed, not on the pull itself. +**Status note.** The strip table is **not implemented yet**. When it lands it applies **client-side, at the moment a share link is issued** — which is the only place it can be applied, since the server holds no key to the metadata a share serves. An export-policy module once existed in `capsule-core` but had zero call sites; it was removed rather than left standing as a documented control nothing enforced. `S-C50` is the slice that implements it. The server's complementary guarantee is containment: a share link serves only the content addresses its own record enumerates, so a stripped share cannot be walked sideways into the unstripped blob (slices `S-C4`, `S-C50`). Together these are the one export surface v1 ships, mandatory and with no opt-out. The external-backup handoff crossing waits on a client file-export command, which is post-v1; federated peers receive ciphertext, so their strip applies when a share boundary is crossed, not on the pull itself. ## Collaborative Metadata diff --git a/capsule-sdk/Cargo.toml b/capsule-sdk/Cargo.toml index c88e62d1..fef7a340 100644 --- a/capsule-sdk/Cargo.toml +++ b/capsule-sdk/Cargo.toml @@ -48,7 +48,7 @@ jiff = { workspace = true } secrecy = { workspace = true } tracing = { workspace = true } # SHA-256 lowercase-hex per-chunk checksum (`X-Capsule-Checksum`); byte-identical -# to the server's `capsule_core::utils::hash::hash_bytes`, so no capsule-core dep. +# to the server's `capsule_core::crypto::hash::hash_bytes`, so no capsule-core dep. sha2 = { workspace = true } hex = { workspace = true } diff --git a/capsule-sdk/src/fetch.rs b/capsule-sdk/src/fetch.rs index 941fa50e..829e946e 100644 --- a/capsule-sdk/src/fetch.rs +++ b/capsule-sdk/src/fetch.rs @@ -673,7 +673,7 @@ impl BlobSource for HttpBlobSource { } /// SHA-256 of the ciphertext as bare lowercase hex — byte-identical to the -/// server's content address (`capsule_core::utils::hash::hash_bytes`). +/// server's content address (`capsule_core::crypto::hash::hash_bytes`). fn hash_hex(bytes: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(bytes); diff --git a/capsule-sdk/src/recovery/mod.rs b/capsule-sdk/src/recovery/mod.rs index 5dd4c554..8ae4c232 100644 --- a/capsule-sdk/src/recovery/mod.rs +++ b/capsule-sdk/src/recovery/mod.rs @@ -675,7 +675,7 @@ mod tests { /// surfaced as data. #[tokio::test] async fn guided_rewrap_keeps_master_key_and_blob_hashes() { - use capsule_core::utils::hash::hash_bytes; + use capsule_core::crypto::hash::hash_bytes; let store = EscrowStore::default(); let base = start_mock(escrow_handler(store)).await; @@ -693,8 +693,8 @@ mod tests { // Fixture "asset" ciphertext blobs — re-wrap must not touch these. let asset_a = b"encrypted-asset-ciphertext-A".to_vec(); let asset_b = b"encrypted-asset-ciphertext-B".to_vec(); - let hash_a_before = hash_bytes(&asset_a); - let hash_b_before = hash_bytes(&asset_b); + let hash_a_before = hash_bytes(&asset_a).to_hex(); + let hash_b_before = hash_bytes(&asset_b).to_hex(); // Run the guided re-wrap (fast params, Shamir enrolled). let rewrap = client @@ -713,8 +713,8 @@ mod tests { assert!(recover_master_key(refetched.blob(), old_secret).is_err()); // Re-wrap touched only the wrap: asset blob hashes are byte-identical. - assert_eq!(hash_bytes(&asset_a), hash_a_before); - assert_eq!(hash_bytes(&asset_b), hash_b_before); + assert_eq!(hash_bytes(&asset_a).to_hex(), hash_a_before); + assert_eq!(hash_bytes(&asset_b).to_hex(), hash_b_before); // Shamir re-issued, old shares invalidated; the new shares reconstruct the new // seed (2 of 3). diff --git a/capsule-server/src/share/mod.rs b/capsule-server/src/share/mod.rs index b29289b2..3ccc653e 100644 --- a/capsule-server/src/share/mod.rs +++ b/capsule-server/src/share/mod.rs @@ -13,8 +13,9 @@ //! strip"*. **A key-free server cannot.** The metadata a share serves is ciphertext sealed //! under material the server does not hold — the fragment secret never leaves the client — so //! there is nothing here to read, let alone redact. design/metadata.md is the one that is -//! consistent with the architecture: *"Stripping happens at the moment of export"*, in -//! `capsule_core::metadata::export_policy`, client-side. +//! consistent with the architecture: *"Stripping happens at the moment of export"* — +//! client-side, in the issuing client. No strip table is implemented yet in `capsule-core`; +//! `S-C50` is where it lands. //! //! So the strip is the **issuing client's**, and what this server enforces is the property that //! makes it stick: a link serves **only the addresses its own record enumerates**, never the From 61230eacc6865fe6803d88540547dab0c11fa262 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 20:46:23 -0400 Subject: [PATCH 010/243] docs(slices): reset the prose head to the tree it describes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Baseline and Sequencing sections described the workspace as it was before `S-C59`, and in three places contradicted the rest of the same file. - Sequencing said the Salvo tree was still live, that `architecture-check` was reporting-only with 63 violations, and that Kynos was a git dependency pinned at rev `6513109`. `mise run architecture-check` runs inside `check-rust` and prints "boundaries are intact"; `Cargo.toml` takes `kynos = "0.1.0"` from crates.io; the gates table two hundred lines below already said so. - It owed `capsule-sdk` two things — the gRPC sync half re-fronted on REST, and a schema sourced from Kynos. Both landed: `sync.rs` drives `GET /v1/sync` through the generated client with `tonic` and `prost` out of the manifest, and `build.rs` reads `capsule-server/openapi.json`. - Baseline presented the retired Salvo `capsule-api` as what ships, and claimed the CLI covers E2E cases 1-3. `capsule demo` is offline by design and the networked commands have no server to reach; the cases are owed to `S-Q1`. - The prose said the `core-import-media` bucket was gone. `S-C59` recorded that decision and did not carry it out — the directory is in the tree, so the prose now says so and names the issue that removes it. - `capsule-core::exif` was called `RETIRED` in the Baseline and again in `S-B6`. It is live in `capsule-core/src/exif/`. - The index preamble counted 141 slices. There are 205 rows and 205 detail blocks, 23 of them lane U. - The gates table described the four `spargen::omit!` operations the Salvo document forced. Kynos makes both of those defects unrepresentable and all four generate; the four narrowed today are the `application/cbor` ones, narrowed because spargen's `classify_media` has no such media type. --- SLICES.md | 137 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 79 insertions(+), 58 deletions(-) diff --git a/SLICES.md b/SLICES.md index ce7d3266..1c6d882a 100644 --- a/SLICES.md +++ b/SLICES.md @@ -13,12 +13,15 @@ It also absorbs the **post-teardown verdict**: the previous Salvo server, the Pr SDK and the standalone media crate are review material, and the replacement server is one **Kynos** REST/OpenAPI application. That verdict is accepted and final. -**`S-C59` executed it**, and narrowed it on evidence. Three `legacy-review/` buckets remain — -`server-salvo`, `sdk-progenitor`, `media-pipeline` — and the fourth, `core-import-media`, is -**gone**: it quarantined `capsule-core::exif` and the import executor's cancellation and progress -halves, and this branch has since rebuilt all three, live and tested. Quarantining a stale twin of -a working module is the opposite of what quarantine is for, so those stay in `capsule-core` and -the bucket went. See [`S-C59`](#s-c59--the-salvo-tree-leaves-the-workspace). +**`S-C59` executed it**, and narrowed it on evidence. Four `legacy-review/` buckets sit in the +tree: `server-salvo`, `sdk-progenitor`, `media-pipeline`, and `core-import-media`. The fourth is +a stale twin rather than a quarantine — it holds a snapshot of `capsule-core::exif` and the +import executor's cancellation and progress halves, all three of which this branch has since +rebuilt, live and tested, so those stay in `capsule-core`. Quarantining a stale twin of a working +module is the opposite of what quarantine is for. **`S-C59` recorded that bucket as deleted and +did not delete it**: the directory is still there, and its removal is owed to +[#423](https://github.com/Capsulsaurus/Capsule/issues/423). See +[`S-C59`](#s-c59--the-salvo-tree-leaves-the-workspace). Because a slice can now be honest in one tree and dishonest in the other, every row carries an **Area**. Read `Status` through `Area`, never on its own. @@ -45,7 +48,7 @@ an **Area**. Read `Status` through `Area`, never on its own. | Area | Meaning | | --- | --- | | `ACTIVE` | The whole surface survives the teardown (`capsule-core` minus its media/exif trees, `capsule-core-ffi`/`-swift`/`-kotlin`, the apps, `capsule-cli` local paths, `capsule-web` local paths, `locales/`, `xtask`, the docs site). Implementable against the live workspace today and unaffected by the Kynos rebuild. | -| `RETIRED` | The target sits in a `legacy-review/` bucket — `server-salvo` (the whole Salvo tree), `sdk-progenitor`, or `media-pipeline` (`capsule_core::media` and its lifecycle adapter). The deliverable must be re-landed on the replacement: Kynos for the server, the Rawshift-backed pipeline for media, the spargen SDK for the client. **`capsule_core::exif` and `import/{executor_cancellation, progress}.rs` are not on this list**, against the original teardown: this branch rebuilt them and they are live (`S-C59`). | +| `RETIRED` | The target sits in a `legacy-review/` bucket — `server-salvo` (the whole Salvo tree), `sdk-progenitor`, or `media-pipeline` (`capsule_core::media` and its lifecycle adapter). The deliverable must be re-landed on the replacement: Kynos for the server, the Rawshift-backed pipeline for media, the spargen SDK for the client. **`capsule_core::exif` and `import/{executor_cancellation, progress}.rs` are not on this list**, against the original teardown: this branch rebuilt them and they are live, and the `core-import-media` bucket beside them is a stale twin awaiting deletion ([#423](https://github.com/Capsulsaurus/Capsule/issues/423)), not a quarantine (`S-C59`). | | `MIXED` | Both: a surviving `capsule-core`/client/app half that ships and stays, and a server, SDK-wire, or media half that must be re-landed. | **Status — read through Area.** @@ -85,15 +88,24 @@ tree). Everything the v1 campaign shipped is the floor wave 2 stands on: four membership ceremonies, minted-and-distributed write-tier keys, Welcome/history delivery, durable group persistence, tombstone-plus-fork upgrade ceremony, re-keying, `ReconcileOutcome` reconciliation. -- **Import/media**: thumbnail/LQIP + video-derivative generation over injected - per-platform encoder seams, signed `DerivativeManifest` chains; signed-path import - executor; streaming import; staged uploads (tier ladder); Takeout source adapter - (`SourceAdapter` trait); planner determinism suite. Derivative byte-encoding is a - per-platform SDK seam — CLI-path derivative generation is inert without an injected - encoder (by design, thumbnails.md). **Area caveat:** the decode/derivative half lives in - `capsule-core::{media, exif}`, which is `RETIRED` territory; the executor, planner, +- **Import/media**: signed `DerivativeManifest` chains; signed-path import executor; + streaming import; staged uploads (tier ladder); Takeout source adapter (`SourceAdapter` + trait); planner determinism suite; LQIP on Chromahash 0.7.1 in `capsule-core::lqip` + (`S-B14`). Derivative byte-encoding is a per-platform SDK seam — CLI-path derivative + generation is inert without an injected encoder (by design, thumbnails.md). + **Correction, `S-C59`:** the decode half went to `legacy-review/media-pipeline` with + `capsule-core::media`, so **there is no image decoder in the workspace at all** and every + still import is a `DeferredNoCodec` — no thumbnail, no preview, and no LQIP producer, since + nothing can hand `capsule-core::lqip` an `RgbaImage` (`S-B1`, `S-B13`, `S-B14`; #410). + `capsule-core::exif` is **not** retired: it was rebuilt and is live. The executor, planner, scanner, importers, streaming, and staged scheduler survive. -- **Key-free server** (`capsule-api`, Salvo): hardened chunked upload (invariants 1–15 + +- **Key-free server** — *the contract, not a running binary.* This inventory describes + `capsule-api` (Salvo), which `S-C59` moved to `legacy-review/server-salvo`. It is kept + here because it is what the Kynos rebuild must reproduce, and because thirty-seven of its + documented operations became fifty-nine on the replacement. **Nothing in this bullet ships + today**: `capsule-server` is a surface with a committed OpenAPI 3.2 document and a test + suite over the real router, and has no binary, no configuration loading and no Postgres or + Valkey adapter (#401–#403). What it covered: hardened chunked upload (invariants 1–15 + strictness table, testcontainer-proven), `capsule.sync.v1` gRPC feed (+ gRPC-web), `/albums/{id}/ops` lifecycle writes, content-addressed blob serving at the 65,536-B stride, storage verification + custody receipts + signed attestation @@ -107,12 +119,16 @@ tree). Everything the v1 campaign shipped is the floor wave 2 stands on: found while reproducing it: the login never demanded a confirmed second factor (`S-C55`), and the passkey surface could not authenticate at all (`S-C56`). "Real and testcontainer-tested" was true of the code and not of the capability.* -- **SDK/clients**: session store + auto refresh, hand-written upload/sync clients, - spargen-generated typed REST client from committed `openapi.json`, verify-before- - destroy + receipt gate, adverse-network engine, LAN peering (in-process), recovery - cadence, CLI auth/register/status/sync/list/push/demo (E2E cases 1–3), web guest-drop + - share-viewer (wasm), aggregated federated albums, uniffi FFI for catalog + SDK user - flows. +- **SDK/clients**: session store + auto refresh, the hand-written resumable upload client, + the REST sync consumer, the spargen-generated typed REST client from the committed + `capsule-server/openapi.json`, verify-before-destroy + receipt gate, adverse-network + engine, LAN peering (in-process), recovery cadence, CLI + auth/register/status/sync/list/push/import/cull/demo, web guest-drop + share-viewer + (wasm), aggregated federated albums, uniffi FFI for catalog + SDK user flows. + **Correction:** the earlier claim that the CLI covers **E2E cases 1–3** was a claim about + the commands, not about a run. `capsule demo` is offline by design — it drives the local + library and touches no server — and the networked commands have nothing to reach until + `capsule-server` has a binary. The three cases are owed to `S-Q1` (#409). - **Legacy retired**: GraphQL, plaintext proto/entities/import-executor gone. - **Cohesion floor** (2026-08-21, wave-0 ground clearing): `lifecycle.rs` (3501 LOC, reaching 17 of 24 sibling modules) split into a `lifecycle/` module of twelve sibling files plus @@ -137,34 +153,37 @@ tree). Everything the v1 campaign shipped is the floor wave 2 stands on: ## Sequencing — build then retire -The teardown verdict is final; the **order** is not "retire, then rebuild". It is -**build, then retire**. - -- The Salvo server (`capsule-api/**`), `capsule-sdk`, and the in-repo media stack are - **still live in this workspace** and stay that way until the Kynos rebuild reaches - parity. `legacy-review/`'s own charter is that code leaves quarantine only once its - replacement contract and tests exist; retiring first would leave the tree with no - server, no CLI network commands, and no end-to-end test for the whole rebuild. -- `xtask architecture-check` is **adopted and reporting-only**. It reports **63 - violations** today (`mise run architecture-check`), and that list *is* the rebuild - worklist: implicit workspace packages, retired dependencies, buildable manifests under - `legacy-review/`, and stale component references. -- The retirement of `capsule-api/**`, `capsule-core/src/media`, `capsule-core/src/exif`, - and `import/{executor_cancellation,progress}.rs` into `legacy-review/` happens in **one - future commit**, once Kynos reaches parity — and `architecture-check` joins `check-rust` - in that same commit. Until then it is a report, not a gate (the rationale is duplicated - in `mise.toml` next to the task so nobody re-wires it early). -- **Kynos is a git dependency, not a crates.io release.** Pin it at rev - `6513109b5725a3e0713808de0eaee6b4b74281e3`; it is not published, so a version - requirement will not resolve. -- **`capsule-sdk` is replacement-in-progress, not review material.** It already satisfies - most of `legacy-review/sdk-progenitor/REVIEW.md`'s stated replacement contract: - spargen-generated from a checked-in OpenAPI 3.1 document, token refresh / upload / sync / - recovery / protocol-version orchestration kept **outside** generated code, no - `generate_openapi.sh`, no Progenitor macros. Two things are owed: its **gRPC sync half - re-fronted on REST**, and its **schema sourced from Kynos** rather than from the Salvo - `gen_openapi` binary. Slices whose target is the SDK are marked `RETIRED` because their - wire contract is re-sourced — not because the crate is being thrown away. +The teardown verdict was final and the **order** was not "retire, then rebuild" but +**build, then retire**. `S-C59` executed the retirement; this section records the state it +left, which is what everything below is sequenced against. + +- **The Salvo tree is gone from the workspace.** `capsule-api/**` is + `legacy-review/server-salvo/`, `capsule_core::media` is `legacy-review/media-pipeline/`, + and `salvo`, `tonic`, `prost`, `async-graphql`, `webauthn-rs` and — with the last of them + — `openssl` left the dependency tree. The `rustls`-only rule now holds with no exception. + The consequences are real and are named rather than hidden: there is no server binary and + no image decoder in the workspace (#401, #410). +- **`xtask architecture-check` is a gate, not a report.** It runs inside `mise run + check-rust` (`mise.toml`) and reports **0** violations; the 63 it reported at adoption + were the rebuild worklist and are discharged. Adding a retired dependency or a buildable + manifest under `legacy-review/` fails the build. +- **Kynos is a crates.io release.** `Cargo.toml` takes `kynos = { version = "0.1.0", + features = ["openapi32"] }`; it published on 2026-08-29, which fired the repin-on-publish + exit the dependencies doc carried while it was a git dependency. The rev + `6513109b5725a3e0713808de0eaee6b4b74281e3` is history, not a pin. The `openapi32` feature + alone does **not** yield a 3.2 document — `capsule-server` pins the version explicitly + with `openapi_as(SpecVersion::V3_2)` and a test asserts the emitted `openapi` field. +- **`capsule-sdk` is replacement-in-progress, and both of its owed items landed.** It + satisfies `legacy-review/sdk-progenitor/REVIEW.md`'s replacement contract: spargen + generates it from the checked-in OpenAPI **3.2** document, with token refresh, upload, + sync, recovery and protocol-version orchestration kept **outside** generated code, no + `generate_openapi.sh`, no Progenitor macros. The two items this section used to owe are + discharged — the gRPC sync half is re-fronted on REST (`GET /v1/sync` through the + generated client, `S-C60`/`S-D28`; `tonic` and `prost` are out of the manifest), and the + schema is sourced from Kynos (`capsule-sdk/build.rs` reads `capsule-server/openapi.json`, + the one document `mise run openapi-check-kynos` gates). The SDK slices that were marked + `RETIRED` for that reason are therefore `MIXED` now: the client half ships, and the server + half it exercises is the one still being rebuilt. - **The Apple client does not wait for the rebuild.** Lane U builds the whole anticipated UI against in-memory ports, so the client is written, reviewed and tested while @@ -192,10 +211,11 @@ either lane works — only a command reaching `-create-xcframework` or a real de ## Unified Slice Index -All 141 slices — the 74 from the v1 campaign, the 48 from wave 2 (46 indexed plus -`S-C27` and `S-Q6`), and the 19 of lane U, the Apple client's mocked UI. `Lane`, -`Depends on`, and `Size` are the campaign's own metadata; `Owed →` names where a `done*` -row's remainder now lives. +All 205 slices — the 74 from the v1 campaign, the 108 that wave 2 and the server rebuild +added since, and the 23 of lane U, the Apple client's mocked UI. Every indexed row has a +detail block and every detail block has a row: `grep -c '^### S-' SLICES.md` and the row +count of the table below are both 205. `Lane`, `Depends on`, and `Size` are the campaign's +own metadata; `Owed →` names where a `done*` row's remainder now lives. | ID | Slice | Lane | Depends on | Size | Area | Status | Owed → | | --- | --- | --- | --- | --- | --- | --- | --- | @@ -457,8 +477,8 @@ when" cannot fully pass until the gate lifts. | Library / environment | Status | Gates | | --- | --- | --- | | [`kynos`](https://github.com/getkono/kynos) 0.1.0 | adopted as the replacement server; **published, consumed from crates.io** | Published 2026-08-29, which fired the repin-on-publish exit this row used to carry — the git rev `6513109` is history, not a pin. Taken with the `openapi32` feature, but note that the feature alone does **not** yield a 3.2 document: Kynos emits the lowest version that expresses the API and deliberately refuses to key that on a flag Cargo can unify in from an unrelated crate, so `capsule-server` pins it explicitly via `openapi_as(SpecVersion::V3_2)` and a test asserts the emitted `openapi` field. Gates the whole `RETIRED` rebuild: lane C, `S-E5`, `S-N1`/`S-N3`, and the SDK wire half (`S-D1`, `S-D2`, `S-D7`–`S-D10`, `S-D17`). `S-C27` is its precondition. | -| `spargen` 0.4.0 | adopted; both known gaps **closed**; consuming OpenAPI **3.2** | Bumped 0.1.0 → 0.4.0 on 2026-08-28. Both gaps this row used to record are gone: 0.2.2 added *decode textual and binary responses* and *serialize typed OpenAPI parameters*, so byte serving and object-typed query params lower correctly and the media asset-serve tree **returns to the generated client** — the hand-written byte path is no longer justified by a generator gap. 0.3.0 added *complete OpenAPI 3.1 and 3.2 conformance* plus runtime dependency contracts (which forced minimum bumps of `bytes`, `reqwest`, `serde`, `serde_json`). The API also changed: `Config` split into `Spec`/`Build`, and `Report::outcome` became a method. **0.4 validates the document strictly, and it rejects four operations the Salvo server emits** — see the row below. | -| Salvo-emitted schema | **4 of 37 operations structurally invalid** | Found 2026-08-28 when spargen 0.4 refused them; 0.1.0 accepted them silently, which is the only reason they reached the committed contract. `POST /v1/albums/{album_id}/ops` declares **no responses at all** — the handler returns `()` and picks its status at run time (`StatusCode::from_u16(result.status)`) so an idempotent replay returns stored bytes verbatim, leaving salvo-oapi no return type to describe. `GET /v1/auth/devices/directory/{user_id}`, `GET` and `POST /v1/auth/devices/enroll/channel/{channel_id}` carry a path-template variable and **declare no path parameters**. All four are therefore already uncallable from a typed client — which is *why* the SDK hand-writes `capsule_sdk::directory`. Narrowed with `spargen::omit!` in `capsule-sdk/build.rs` rather than repaired: fixing salvo-oapi annotations is work thrown away, and **Kynos makes both classes unrepresentable** (status is part of the return type; `#[kynos::get(..)]` checks at compile time that the path type's fields are exactly the template's variables). These are acceptance criteria for the Kynos port of `S-C16` and the auth-devices tree, and the hand-written directory client goes with them. | +| `spargen` 0.4.0 | adopted; both known gaps **closed**; consuming OpenAPI **3.2** | Bumped 0.1.0 → 0.4.0 on 2026-08-28. Both gaps this row used to record are gone: 0.2.2 added *decode textual and binary responses* and *serialize typed OpenAPI parameters*, so byte serving and object-typed query params lower correctly and the media asset-serve tree **returns to the generated client** — the hand-written byte path is no longer justified by a generator gap. 0.3.0 added *complete OpenAPI 3.1 and 3.2 conformance* plus runtime dependency contracts (which forced minimum bumps of `bytes`, `reqwest`, `serde`, `serde_json`). The API also changed: `Config` split into `Spec`/`Build`, and `Report::outcome` became a method. **0.4 validates the document strictly**, and four operations are still narrowed with `spargen::omit!` — for a different reason than they used to be; see the row below. | +| Four `spargen::omit!` operations | **narrowed for a media type spargen cannot classify** | The four that used to sit here were structurally invalid Salvo output, and **Kynos makes both of those defects unrepresentable**: status is part of the return type, so `POST /v1/albums/{album_id}/ops` cannot declare no responses, and `#[kynos::get(..)]` checks at compile time that a path type's fields are exactly the template's variables, so the three device routes cannot take an undeclared path parameter. All four are generated now. **Four different operations take their place, for a reason outside the contract** (`S-D28`, `capsule-sdk/build.rs`): spargen's `classify_media` knows JSON, XML, multipart, form-urlencoded, octet-stream, event-stream, NDJSON, JSON sequences and `text/*`, and no `application/cbor`. Capsule serves four operations in that media type — `POST /v1/auth/devices/directory`, `GET /v1/auth/devices/directory/{user_id}`, `POST /v1/albums/{album_id}/upgrade`, `GET /v1/upload/{id}/receipt` — all of them **signed** documents served byte for byte, which is exactly why they are not JSON. Narrowing them is the instruction's own remedy (*narrow the surface, never mutilate the spec*); relabelling them `application/octet-stream` to satisfy a generator would tell every client that a document with a schema it knows is opaque bytes. `capsule_sdk::directory` hand-writes two of them; the other two have no client yet. | | `openmls` 0.8.x | adopted (X-Wing `0x004D`) | The X-Wing codepoint **exists** (`0x004D`) and OpenMLS ships it via libcrux, so `S-X1`–`S-X3` are not blocked and are done. Key serialization surfaces are `test-utils`-gated — persistence rides public fields + ungated codecs; fragile if upstream privatizes (upstream ask filed against openmls). Version pairing is load-bearing (0.8.x ↔ traits/storage 0.5.x ↔ libcrux-crypto 0.3.x). | | `libcrux` provider | no wasm32 target | `mls` feature is host-only; a browser MLS surface would need another provider. | | BD-09 datum fold | **no crate adopted** | Decision 2026-08-21: `S-A8` implements the error-bounded refined BD-09→GCJ-02 inverse **in-house** (~40 LOC, deterministic, unit-testable) rather than taking a dependency for one function. `geocoordinates-rs` is **not** a gate on `S-A7`/`S-A8` and is not planned; the earlier "exact fold from `geocoordinates-rs`" wording is superseded. Display-side lossy conversions remain unscheduled and are not part of either slice. | @@ -779,9 +799,10 @@ workspace at all**, so every still import is a `DeferredNoCodec` until Rawshift a fixture-archive import is deterministic across runs and skips completed work on re-run. - **Tier:** Unit (mapping table, determinism) + Smoke (end-to-end archive import). -- **Landed:** the adapter and trait ship in `import/importers/` and are `ACTIVE`. Only the - EXIF side of the precedence fold rides `capsule-core::exif`, which is `RETIRED`. - **Owed:** sidecar-enrichment write → `S-B10`. +- **Landed:** the adapter and trait ship in `import/importers/` and are `ACTIVE`, and so is + the EXIF side of the precedence fold: it rides `capsule-core::exif`, which the original + teardown listed for retirement and `S-C59` kept — the module is live and tested in + `capsule-core/src/exif/`. **Owed:** sidecar-enrichment write → `S-B10`. ### S-B7 — iCloud export importer (post-v1) From a178eb797f29f908dd18355d4c3bc935f2e250f5 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 20:50:38 -0400 Subject: [PATCH 011/243] docs(slices): make thirteen rows agree with the code they track MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each of these is a row the tree already disproves. `S-D1`, `S-D2`, `S-D7`, `S-D8`, `S-D10` read `RETIRED | ready`. `RETIRED` never meant "capsule-sdk is review material" — Sequencing says the SDK rows carry it because the crate's wire contract was being re-sourced. That re-source landed: `build.rs` generates from `capsule-server/openapi.json`, `sync.rs` drives `GET /v1/sync` through the generated client, `tonic` and `prost` are out of the manifest, and no retired package is a dependency. The five are `MIXED | done` (`S-D8` `done*`, its 401-retry-once still owed to `S-D17`): the client half ships and the server half is the one being rebuilt. `S-D26` and `S-I5` landed and read `ready`. `remote.rs` writes the rotated pair back through `checkpoint` on every exit path; `84f719f6` added the fifteen `cli.import.*` keys and put `capsule-cli/src` in `i18n-guard`'s scope. `S-Z9` read `blocked` on a Kynos document that exists: `gen_openapi.rs` emits fifty-nine operations at OpenAPI 3.2 and `openapi-check-kynos` gates them inside `check-rust`. Only the docs work is left. `S-B1`, `S-B5`, `S-B13` read `ready` with their one precondition absent — `rawshift` is a pinned submodule that nothing in `Cargo.toml` names, which the gates table already recorded as "stabilizing, unconsumed". They are `blocked`. `S-B14`'s owed column named the wasm entry point and not the larger half: nothing calls `Lqip::encode`, because producing an `RgbaImage` needs a decoder and `S-C59` took the only one to `legacy-review/`. `S-D12` placed the cadence scheduler in core; it is in `capsule-sdk::recovery`. `S-C59` said the `core-import-media` bucket had gone; it recorded the decision and left the directory. Recount: 87 ACTIVE / 75 RETIRED / 43 MIXED, and 98 done / 57 done* / 28 ready / 9 part / 9 blocked / 4 post-v1. --- ROADMAP.md | 2 +- SLICES.md | 180 +++++++++++++++++++++++++++++++++++------------------ 2 files changed, 121 insertions(+), 61 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c9695381..e442889f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -39,7 +39,7 @@ A closed set. A row's state is a claim about the package, not about the programm | --- | --- | --- | --- | --- | --- | --- | --- | --- | | `capsule-core` | cargo | The offline crypto data plane, catalog, signed sidecars, import pipeline, LQIP, and the OpenMLS authority | stabilizing | `mise run check-rust` | [Module Map](capsule-docs/src/content/docs/design/module-map.md) | `S-B1`, `S-B5`, `S-B13`, `S-D24`, `S-D29` | Public-API freeze (#399) | `capsule-core::media` is designed and unbuilt, so there is no image decoder in the workspace and every still import is a `DeferredNoCodec` | | `capsule-core-ffi` | cargo | The app umbrella staticlib and the `capsule_core_ffi` uniffi namespace | stabilizing | `mise run check-rust` | [Module Map — Client Boundaries](capsule-docs/src/content/docs/design/module-map.md#client-boundaries) | — | Public-API freeze (#399) | Links `capsule-sdk`'s uniffi surface so one Rust library carries both namespaces an app consumes | -| `capsule-sdk` | cargo | Session, upload, sync, recovery and protocol-version orchestration over the spargen-generated REST client | stabilizing | `mise run check-rust` | [API Surfaces](capsule-docs/src/content/docs/design/api-surfaces.md) | `S-D1`, `S-D2`, `S-D7`, `S-D8`, `S-D9`, `S-D10`, `S-D17`, `S-E3`, `S-N2` | Re-front the gRPC sync half on REST (#408) | Replacement-in-progress, not review material: the wire contract is re-sourced from Kynos, the crate is not thrown away | +| `capsule-sdk` | cargo | Session, upload, sync, recovery and protocol-version orchestration over the spargen-generated REST client | stabilizing | `mise run check-rust` | [API Surfaces](capsule-docs/src/content/docs/design/api-surfaces.md) | `S-D9`, `S-D17`, `S-E3`, `S-N2` | Close the four contract gaps (#408) | Both items the tracker owed this crate landed: one transport (`GET /v1/sync` through the generated client) and one document (`capsule-server/openapi.json`) | | `capsule-server` | cargo | The Kynos REST/OpenAPI application and the committed `capsule-server/openapi.json` contract | rebuilding | `mise run check-rust` | [Module Map — Server Modules](capsule-docs/src/content/docs/design/module-map.md#server-modules) | `S-C8`, `S-C39`, `S-C47`, `S-C49`, `S-C51`, `S-E2`, `S-E5`, `S-N1` | A binary, configuration and a serve task (#401) | Fifty-nine operations and a test suite over the real router, with no binary, no configuration loading and no Postgres or Valkey adapter | | `capsule-wire` | cargo | Framework-free protocol headers and the response taxonomy across the retiring Salvo boundary | stabilizing | `mise run check-rust` | [API Surfaces](capsule-docs/src/content/docs/design/api-surfaces.md) | `S-C27` | Retired (#400) | Only retired code still depends on it; `capsule-server` owns `problem`, `limits` and `body` | | `capsule-wasm` | cargo | The browser boundary — share-link open, guest drop sealing, and LQIP decode | stabilizing | `mise run check-rust` | [Web Upload](capsule-docs/src/content/docs/design/web-upload.md) | — | Public-API freeze (#399) | `S-B14` owes it an `lqip` entry point; the encoder already compiles for `wasm32-unknown-unknown` | diff --git a/SLICES.md b/SLICES.md index 1c6d882a..7ef5081b 100644 --- a/SLICES.md +++ b/SLICES.md @@ -211,11 +211,12 @@ either lane works — only a command reaching `-create-xcframework` or a real de ## Unified Slice Index -All 205 slices — the 74 from the v1 campaign, the 108 that wave 2 and the server rebuild -added since, and the 23 of lane U, the Apple client's mocked UI. Every indexed row has a -detail block and every detail block has a row: `grep -c '^### S-' SLICES.md` and the row -count of the table below are both 205. `Lane`, `Depends on`, and `Size` are the campaign's -own metadata; `Owed →` names where a `done*` row's remainder now lives. +All 205 slices — the 129 from the v1 campaign and wave 2, the 51 the server rebuild added, +the 23 of lane U (the Apple client's mocked UI), and the 2 of the notification lane. Every +indexed row has a detail block and every detail block has a row: `grep -c '^### S-' +SLICES.md` and the row count of the table below are both 205. `Lane`, `Depends on`, and +`Size` are the campaign's own metadata; `Owed →` names where a `done*` row's remainder now +lives. | ID | Slice | Lane | Depends on | Size | Area | Status | Owed → | | --- | --- | --- | --- | --- | --- | --- | --- | @@ -230,11 +231,11 @@ own metadata; `Owed →` names where a `done*` row's remainder now lives. | S-A9 | Add-id counter reseed at `Workspace` open | core-crypto | — | S | ACTIVE | done | | | S-A10 | Durable album-key persistence + library open plumbing | core-crypto | — | L | ACTIVE | done | | | S-A11 | Publish the DEK in the device directory | core-crypto | — | M | ACTIVE | done | | -| S-B1 | Thumbnail/LQIP generation | media/import | — | L | RETIRED | ready | | +| S-B1 | Thumbnail/LQIP generation | media/import | — | L | RETIRED | blocked | Rawshift is unconsumed (gates table) | | S-B2 | Signed-path import-executor rewrite | media/import | S-B1 | L | MIXED | done\* | durable album keys → `S-A10` | | S-B3 | Streaming import (probe, `total_size`, drive mode) | media/import | S-D1, S-D4 | L | MIXED | done | | | S-B4 | Staged uploads (low-data tier ladder) | media/import | S-C1, S-C2, S-D1 | M | MIXED | done | | -| S-B5 | Video derivatives (first-frame still + H.264 preview) | media/import | S-B1 | M | RETIRED | ready | | +| S-B5 | Video derivatives (first-frame still + H.264 preview) | media/import | S-B1 | M | RETIRED | blocked | Rawshift is unconsumed (gates table) | | S-B6 | Google Takeout importer | media/import | S-B2 | M | MIXED | done\* | sidecar-enrichment write → `S-B10` | | S-B7 | iCloud export importer | media/import | S-B6 | M | MIXED | post-v1 | | | S-B8 | Immich importer | media/import | S-B6 | M | MIXED | post-v1 | | @@ -243,8 +244,8 @@ own metadata; `Owed →` names where a `done*` row's remainder now lives. | S-B11 | CLI `import --provider takeout` + real-archive run | media/import | S-B10 | S | ACTIVE | done\* | synthesized archive only; real export owed | | S-B18 | No CLI surface shows what the importer actually wrote | media/import | S-B10 | S | ACTIVE | ready | users cannot verify enrichment | | S-B12 | Base default-album resolution (`resolve_default_album`) | media/import | — | M | ACTIVE | done | scope-override + source-kind rows → post-v1 | -| S-B13 | Codec stubs → typed `UnsupportedFormat` (no panics) | media/import | — | M | RETIRED | ready | | -| S-B14 | LQIP on Chromahash 0.7.1 in `capsule-core::lqip` | media/import | — | M | ACTIVE | done | wasm entry point owed to the browser-`lqip` slice | +| S-B13 | Codec stubs → typed `UnsupportedFormat` (no panics) | media/import | — | M | RETIRED | blocked | Rawshift is unconsumed (gates table) | +| S-B14 | LQIP on Chromahash 0.7.1 in `capsule-core::lqip` | media/import | — | M | ACTIVE | done\* | no producer (needs a decoder, `S-B1`) and no wasm entry point | | S-B15 | Importer-formed stacks exist only in the index | media/import | S-D21 | M | ACTIVE | done | rebuild guard kept as pre-`S-B15` compatibility | | S-B16 | Every import stamped by import time, not capture time | media/import | — | S | ACTIVE | done | found by the CLI round-trip test | | S-B17 | Repair capture timestamps written before `S-B16` | media/import | S-B16 | M | ACTIVE | ready | the wrong value is in *signed* bytes | @@ -311,16 +312,16 @@ own metadata; `Owed →` names where a `done*` row's remainder now lives. | S-C61 | The drop passphrase is provisioned and never checked | server | S-C5, S-C60 | S | RETIRED | done | a gated link admitted anyone holding the opaque id; the web client was posting to paths that no longer exist | | S-C62 | The web auth client speaks a surface that is gone | sdk/clients | S-C54, S-C55, S-C56, S-C60 | M | RETIRED | done | passkey and password-reset screens removed, login reads `202`, profile is the four fields the server keeps | | S-C63 | The SDK cannot read a second-factor challenge | sdk/clients | S-C55 | M | RETIRED | done | `login` returns an outcome, not a session; `capsule auth login` prompts for the code | -| S-D1 | SDK upload client (hand-written, stateful protocol) | sdk/clients | S-C1 | M | RETIRED | ready | | -| S-D2 | SDK sync/download client + connection-class budget | sdk/clients | S-C2, S-C9 | L | RETIRED | ready | | +| S-D1 | SDK upload client (hand-written, stateful protocol) | sdk/clients | S-C1 | M | MIXED | done | | +| S-D2 | SDK sync/download client + connection-class budget | sdk/clients | S-C2, S-C9 | L | MIXED | done | | | S-D3 | Web guest drop client (WASM) | sdk/clients | S-A6, S-C5 | L | MIXED | done\* | live-browser smoke → `S-Q5`; seeds → gates | | S-D4 | Verify-before-destroy wiring | sdk/clients | S-C3, S-C15 | M | MIXED | done | | | S-D5 | CLI auth/sync/list | sdk/clients | S-D1, S-D2 | M | MIXED | done | | | S-D6 | Web server gateway (key-free reads) | sdk/clients | S-D2, S-C60 | L | MIXED | done\* | live browser smoke → `S-Q5`; decode boundary → post-v1 | -| S-D7 | SDK auth/session foundation + auto token refresh | sdk/clients | — | M | RETIRED | ready | | -| S-D8 | spargen REST client integration | sdk/clients | — | M | RETIRED | ready | 401-retry-once → `S-D17` | +| S-D7 | SDK auth/session foundation + auto token refresh | sdk/clients | — | M | MIXED | done | | +| S-D8 | spargen REST client integration | sdk/clients | — | M | MIXED | done\* | 401-retry-once → `S-D17` | | S-D9 | capsule-sdk uniffi FFI bindings | sdk/clients | S-F1, S-D7 | M | RETIRED | ready | Swift harness → `S-P8`; Kotlin harness → owed-CI | -| S-D10 | Adverse-network hardening | sdk/clients | S-D1, S-D2 | M | RETIRED | ready | | +| S-D10 | Adverse-network hardening | sdk/clients | S-D1, S-D2 | M | MIXED | done | | | S-D11 | Client cohort emission + devices grouping UI | sdk/clients | S-C13, S-D7 | M | MIXED | done\* | iOS reader → `S-P6`; devices screen → post-v1; device_id → `S-N3` | | S-D12 | Recovery verification cadence + guided re-wrap | sdk/clients | S-C12 | M | MIXED | done | | | S-D13 | Culling workflow client UX | sdk/clients | — | M | ACTIVE | done | | @@ -335,7 +336,7 @@ own metadata; `Owed →` names where a `done*` row's remainder now lives. | S-D23 | Client SQLite schema has no upgrade path | sdk/clients | — | M | ACTIVE | done | typed error at the `open` boundary still owed | | S-D24 | Migrate unsigned sidecars, then delete the reader | sdk/clients | S-D21 | L | ACTIVE | blocked | needs a design decision first | | S-D25 | `hidden` has a column, a gate and views but no writer | sdk/clients | S-D19 | S | ACTIVE | done | | -| S-D26 | CLI drops the rotated token pair, forcing re-login | sdk/clients | — | S | MIXED | ready | fix in the REST client, not the old one | +| S-D26 | CLI drops the rotated token pair, forcing re-login | sdk/clients | — | S | MIXED | done | | | S-D27 | The SDK test mock never shuts its listener down | sdk/clients | — | S | ACTIVE | done\* | fixed a real leak; the LEAK signal is partly noise | | S-D28 | The SDK's second transport, and the document it generates from | client SDK | S-D8, S-C2 | L | MIXED | done\* | gRPC retires; the client generates from the Kynos document; four `application/cbor` operations stay hand-written | | S-D29 | Local alert surface (`capsule-core::notify` + native delivery) | sdk/clients | S-Z11 | M | ACTIVE | ready | | @@ -365,7 +366,7 @@ own metadata; `Owed →` names where a `done*` row's remainder now lives. | S-I2 | Official language-set rollout (12 locales + RTL) | i18n | — | L | ACTIVE | done\* | native RTL → post-v1; review → gates | | S-I3 | `xtask translate-readme` + CI drift check | i18n | S-I2 | M | ACTIVE | done | | | S-I4 | Swift interpolated/plural strings + InfoPlist/LAContext | i18n | — | M | ACTIVE | done | forced an ICU→Apple compiler in the generator | -| S-I5 | The CLI import arm has no `cli.import.*` catalog namespace | i18n | — | M | ACTIVE | ready | `i18n-guard` never scanned the CLI | +| S-I5 | The CLI import arm has no `cli.import.*` catalog namespace | i18n | — | M | ACTIVE | done | | | S-I6 | Android ships raw ICU to users; the guard never fires | i18n | — | M | ACTIVE | done | `aapt2` unverified — owed-CI | | S-I7 | The Rust runtime formatter cannot do ICU plurals | i18n | — | M | ACTIVE | done\* | refuses now; evaluating plurals still owed | | S-I8 | clap `--help` text is unreachable from the catalogs | i18n | — | S | ACTIVE | ready | found widening `i18n-guard` | @@ -421,24 +422,25 @@ own metadata; `Owed →` names where a `done*` row's remainder now lives. | S-Z6 | Developer-docs parity pass | design/docs | — | M | MIXED | done | | | S-Z7 | Developer reference architecture (design) | design/docs | — | S | ACTIVE | done | | | S-Z8 | Reference shell + CLI reference | design/docs | S-Z7 | M | ACTIVE | ready | | -| S-Z9 | REST reference from the Kynos document | design/docs | S-Z8, S-D8 | M | ACTIVE | blocked | Kynos document → `S-C27`/`S-D8` | +| S-Z9 | REST reference from the Kynos document | design/docs | S-Z8 | M | ACTIVE | ready | | | S-Z10 | SDK / FFI / WASM reference | design/docs | S-Z8 | M | ACTIVE | ready | | | S-Z11 | Notification architecture (design) | design/docs | — | S | ACTIVE | done | | **Row counts.** 205 rows — the 129 from the v1 campaign and wave 2, the 51 the server rebuild added, the 23 of lane U, and the 2 of the notification lane. By -area: **87 ACTIVE / 80 RETIRED / 38 MIXED**. By status: -**93 done / 55 done\* / 37 ready / 9 part / 7 blocked / 4 post-v1** +area: **87 ACTIVE / 75 RETIRED / 43 MIXED**. By status: +**98 done / 57 done\* / 28 ready / 9 part / 9 blocked / 4 post-v1** (`S-C8`, `S-C27`, `S-C39`, and `S-U9`–`S-U14` — the table spells these `part` and `part 1 done`; they are counted together). Lanes are independent by construction; within a lane, "Depends on" is the only -ordering. Seven rows read `blocked`, and only two of them are waiting on code: -`S-N2` behind `S-N1`, and `S-P4` behind `S-P2`/`S-P3`. The rest are waiting on a -decision rather than on an implementation — `S-C47` is a legal question, `S-C49` -and `S-C51` each need a fact the slice that found them could not settle, `S-D24` -needs a design decision, and `S-Z9` needs the Kynos document -(`S-C27`/`S-D8`). `S-P1` landing freed the rest of lane P and `S-U19` with it; +ordering. Nine rows read `blocked`. Five are waiting on code or on a dependency +that is not in the manifest: `S-N2` behind `S-N1`, `S-P4` behind `S-P2`/`S-P3`, +and `S-B1`/`S-B5`/`S-B13` behind an unconsumed `rawshift`. The other four are +waiting on a decision rather than on an implementation — `S-C47` is a legal +question, `S-C49` and `S-C51` each need a fact the slice that found them could +not settle, and `S-D24` needs a design decision. `S-Z9` left this list: the Kynos +document exists and is gated. `S-P1` landing freed the rest of lane P and `S-U19` with it; lane U was built so the other twenty-two Apple-client slices never waited on that chain in the first place. Everything else that once read `blocked` is startable: `S-A10` and `S-P7` are done (freeing `S-B10`, `S-D16`, `S-P1`, `S-Q5` — of @@ -719,6 +721,10 @@ workspace at all**, so every still import is a `DeferredNoCodec` until Rawshift with `lifecycle/derivatives.rs`, its only caller. **Re-scoped:** re-land on the Rawshift-backed pipeline. The signed `DerivativeManifest` chain and the sidecar `lqip` field are `ACTIVE` and stay — the field stays here, its producer moves to `S-B14`. +- **`blocked`, not `ready` (corrected 2026-09-01).** The re-scoped deliverable's one precondition + is absent: `rawshift` is a pinned git submodule (`.gitmodules`), not a workspace dependency, and + the gates table records it "stabilizing, unconsumed". Nothing in `Cargo.toml` names it, so this + slice cannot start, let alone finish. Tracked by #410. ### S-B2 — Signed-path import-executor rewrite @@ -781,8 +787,12 @@ workspace at all**, so every still import is a `DeferredNoCodec` until Rawshift - **Done when:** a fixture video yields both tiers with signed manifests; the closed-format rejection covers the video rows of the tier table. - **Tier:** Unit + Smoke. -- **Landed in retired code:** ships today behind the injected encoder seam; the transcode - half is `capsule-core::media` and re-scopes onto the Rawshift-backed pipeline. +- **Landed in retired code, and retired by `S-C59`:** it shipped behind the injected encoder + seam; the transcode half was `capsule-core::media` and is now + `legacy-review/media-pipeline/`. **Re-scoped** onto the Rawshift-backed pipeline. +- **`blocked`, not `ready` (corrected 2026-09-01).** Same precondition as `S-B1`, which this + slice also depends on: `rawshift` is an unconsumed submodule, so there is nothing to build + the transcode half on. ### S-B6 — Google Takeout importer @@ -962,6 +972,10 @@ workspace at all**, so every still import is a `DeferredNoCodec` until Rawshift `capsule-core::media`. **Re-scoped:** the uninhabited-stub discipline and the `is_decodable`/`from_extension` coverage table are the contract the Rawshift-backed rebuild inherits; `DerivativeStatus` on `ImportOutcome` is `ACTIVE` and stays. +- **`blocked`, not `ready` (corrected 2026-09-01).** There is no `capsule-core::media` to put + typed errors into and no Rawshift dependency to build one on — the module is on + `capsule-docs/planned-modules.txt`, which is the list of what the design has committed to + and nobody has built. Tracked by #410. ### S-B14 — LQIP on Chromahash 0.7.1, in its own module @@ -1022,6 +1036,13 @@ workspace at all**, so every still import is a `DeferredNoCodec` until Rawshift 21 bytes — `COMPACT_TIER`'s length — which is the concrete proof that byte length cannot discriminate a stale payload. Both are rejected by `from_bytes` and render as the solid dominant-colour fill, never noise. +- **Owed, and the larger half was missing from this list (corrected 2026-09-01): there is no + producer.** `capsule-core/src/lqip/` is `mod.rs`, `sidecar.rs` and `tests.rs` — the encoder, + the decoder and the sidecar binding. Nothing in the tree calls `Lqip::encode`, because + producing an `RgbaImage` from a stored original needs a decoder and `S-C59` took the only one + to `legacy-review/media-pipeline/`. So every import writes no `lqip` at all, exactly as it + writes no thumbnail: the encoder is correct, complete and unreachable until + `capsule-core::media` lands on Rawshift (`S-B1`, #410). - **Owed:** no `wasm_bindgen` export exists. wasm links and compiles the identical encoder, but the browser has no decrypted `lqip` to decode yet, so the entry point belongs to that slice. @@ -3407,13 +3428,17 @@ than a transcription: Thirty-seven documented operations became **fifty-nine**, and the six that were never documented are accounted for. -**The `core-import-media` bucket is deleted rather than refreshed.** It quarantined -`capsule_core::exif` and the import executor's cancellation and progress halves. All three are -live, tested and *newer* on this branch than the snapshot that quarantined them — which is -exactly the charter's exit condition, met in the other direction. Keeping a stale twin of a -working module beside it is the opposite of what quarantine is for, so the bucket went and those -modules stay. This is a **deliberate narrowing of the teardown's file list**, recorded here -because the plan named those files for the move. +**The `core-import-media` bucket is to be deleted rather than refreshed — and was not.** It +quarantined `capsule_core::exif` and the import executor's cancellation and progress halves. All +three are live, tested and *newer* on this branch than the snapshot that quarantined them — which +is exactly the charter's exit condition, met in the other direction. Keeping a stale twin of a +working module beside it is the opposite of what quarantine is for, so the modules stay and the +bucket goes. This is a **deliberate narrowing of the teardown's file list**, recorded here because +the plan named those files for the move. **Correction 2026-09-01:** this note said the bucket had +gone. `legacy-review/core-import-media/` is still in the tree — the decision was recorded and +never carried out — and the deletion, together with the two `import/pipeline.md` citations and the +`ROADMAP.md` row that go with it, is +[#423](https://github.com/Capsulsaurus/Capsule/issues/423). **`capsule_core::media` does go, and it takes the decoder with it.** It was the former standalone media crate, gated behind a non-default feature whose only consumer was the equally-gated @@ -3909,11 +3934,15 @@ them was incidental: - **Done when:** the upload doc's client-side Validation bullets pass against a real server; the recovery matrix has a mocked-HTTP test per code; E2E case 2 lives. - **Tier:** Unit + Smoke + E2E case 9. -- **Landed in retired code:** `capsule-sdk/src/upload.rs` is a complete resumable client - today (`create_session`/`upload`/`upload_resuming`/`head`/`list_sessions`) and - `capsule push` drives it end to end. **Re-scoped:** re-point at the Kynos upload - surface and re-source the schema. The stateful algorithm, the bounds, and the recovery - matrix carry over unchanged — they are protocol, not framework. +- **Landed.** `capsule-sdk/src/upload.rs` is a complete resumable client + (`create_session`/`upload`/`upload_resuming`/`head`/`list_sessions`) and `capsule push` + drives it end to end. +- **`MIXED | done`, not `RETIRED | ready` (corrected 2026-09-01).** The row was `RETIRED` + because the SDK's wire contract was re-sourced, not because the crate was review material + (Sequencing). That re-source landed: `capsule-sdk/build.rs` generates from + `capsule-server/openapi.json`, the Kynos document, and the crate depends on no retired + package. The client half therefore ships, which is what `Status` reports on a `MIXED` row; + the server half it drives is what is still being rebuilt (#401, #404). ### S-D2 — SDK sync/download client @@ -3927,10 +3956,14 @@ them was incidental: - **Depends on:** S-C2, S-C9. **Blocks:** S-D5, S-D6, S-E3. - **Done when:** the download-sync doc's client Validation bullets pass; E2E case 3 lives. **Tier:** Unit + Smoke. -- **Landed in retired code:** `SyncConsumer::pull_into` ships against the gRPC feed. - **Re-scoped:** this is the SDK's **gRPC-half re-fronting on REST** — the largest single - piece of SDK rebuild work, and the reason the crate is replacement-in-progress rather - than done. +- **Landed, including the re-front (corrected 2026-09-01).** `SyncConsumer` drove + `capsule.sync.v1.SyncService` over tonic when this row was written, and that was called + "the largest single piece of SDK rebuild work". It landed with `S-C60`/`S-D28`: + `capsule-sdk/src/sync.rs` drives `GET /v1/sync` through the generated REST client, the + opaque server-MAC'd cursor round-trips verbatim, and `tonic`, `tonic-prost` and `prost` + are out of `capsule-sdk/Cargo.toml`. `SyncState`'s anti-rewind and forward-version rules + never depended on the transport and did not move. The row is `MIXED | done`: the client + half ships; the feed it reads is served by the server still being rebuilt. ### S-D3 — Web guest drop client @@ -3997,9 +4030,14 @@ them was incidental: - **Done when:** login/refresh/expiry flows round-trip against a dev server; a mocked clock exercises pre-flight refresh + single-flight; `capsule-sdk` stays in every Rust gate. **Tier:** Unit + Smoke. **Blocks:** S-D9, S-D11; S-D5 consumes it. -- **Landed in retired code:** the store, the refresh engine, and the session persistence - ship. **Re-scoped:** re-point at the Kynos auth endpoints. The 401-retry-once half is - still owed on the *typed* path — see `S-D17`. +- **Landed.** The store, the refresh engine, and the session persistence ship, hand-rolled + over `reqwest` (rustls only) against `/v1/auth/{register,login,refresh,logout}` — the + server's own paths, not a retired copy of them. Being outside the generated client is + deliberate and no longer a spargen gap: what lives here is token *orchestration*, which + `ADR-0002` puts outside generated code by contract. +- **`MIXED | done`, not `RETIRED | ready` (corrected 2026-09-01).** The Kynos re-point is what + the `RETIRED` marking was for, and it landed. The 401-retry-once half is still owed on the + *typed* path — see `S-D17`, which keeps its own row. ### S-D8 — spargen REST client integration @@ -4013,10 +4051,13 @@ them was incidental: - **Done when:** the generated client drives the plain request/response surfaces and `AuthenticatedClient` is live over it (it is: see `capsule-sdk/README.md`). - **Tier:** Unit + Smoke. -- **Landed in retired code:** generated from the Salvo server's committed `openapi.json`. - **Re-scoped:** the schema must come from **Kynos**, not from the Salvo `gen_openapi` - binary — that is the second of the SDK's two owed items. -- **Owed:** 401-retry-once → `S-D17`. +- **Landed, from the Kynos document (corrected 2026-09-01).** It generated from the Salvo + server's committed `openapi.json` when this row was written, and the re-source was the + second of the SDK's two owed items. `S-C59` deleted `capsule-sdk/openapi.json` and + `capsule-sdk/build.rs` now reads `../capsule-server/openapi.json` — the one document + `mise run openapi-check-kynos` gates, at OpenAPI **3.2**. There is no second copy and no + window in which the client is generated from a document the server does not serve. +- **Owed:** 401-retry-once → `S-D17`, which is why this is `done*`. ### S-D28 — the SDK's second transport, and the document it generates from @@ -4120,9 +4161,11 @@ Kynos server, which cannot be written until `S-C53` gives the server a way to cr - **Depends on:** S-D1, S-D2. **Done when:** the networking doc's four Validation bullets pass (mocked-signal class matrix; promotion/demotion; stall-cut-resume with zero duplicate bytes; backoff discipline). **Tier:** Unit + Smoke. -- **Landed in retired code:** the engine ships in `capsule-sdk::net`. **Re-scoped:** it - is transport-shaped, so re-instantiate it over the Kynos fetch/upload/sync paths; the - policy classes and the mocked-signal matrix carry over unchanged. +- **Landed.** The engine ships in `capsule-sdk::net` and the fetch, upload and sync paths + instantiate it. It is transport-shaped, and the transport it was re-scoped onto is the one + in the tree: `capsule-sdk` speaks Kynos REST and nothing else. `MIXED | done` — the policy + classes and the mocked-signal matrix are client-side and proven; what an adverse network + is measured against is a server still being rebuilt. ### S-D11 — Client cohort emission + devices grouping UI @@ -4148,7 +4191,10 @@ Kynos server, which cannot be written until `S-C53` gives the server a way to cr - **Depends on:** S-C12. **Done when:** the backup doc's cadence Validation bullets pass (mocked clock; stale-cache rule; re-wrap smoke with unchanged blob hashes). - **Tier:** Unit + Smoke. -- **Landed:** the cadence scheduler, the verifier, and the re-wrap are `ACTIVE` core; only +- **Landed:** the verifier is `ACTIVE` core (`capsule-core::backup::verify_recovery_secret`, + reached through `capsule-core::lifecycle::backup`); the cadence scheduler and the guided + re-wrap are in `capsule-sdk::recovery`, whose `cadence` half is deliberately pure and + network-free. **Correction 2026-09-01:** this note used to place all three in core. Only the escrow store/replace calls re-scope. ### S-D13 — Culling workflow client UX @@ -4428,6 +4474,11 @@ Kynos server, which cannot be written until `S-C53` gives the server a way to cr (`export()` is async), so the shape is run-body-then-persist-then-`?`. - **Done when:** a command that triggers a refresh leaves the rotated pair on disk, and a subsequent command succeeds without re-login. **Tier:** Unit (mock auth server) + Integration. +- **Landed.** `capsule-cli/src/remote.rs` resumes through `resume_session` and writes back + through `checkpoint`, which runs on the way out of every command that used a session — on + the success path and the error path alike, because a refresh that landed before a later + failure still rotated the token. A failed write-back warns rather than failing the command: + the work is done, and the cost is one interactive login. ### S-D27 — the SDK test mock never shuts its listener down @@ -4799,6 +4850,10 @@ lands on Kynos rather than on Salvo. precondition rather than a parallel cleanup. - **Done when:** `i18n-guard` covers `capsule-cli` and passes, and no import-arm output is a literal. **Tier:** gate. +- **Landed** in `84f719f6`. `locales/en.json` carries fifteen `cli.import.*` keys, and + `xtask/src/i18n_guard.rs` scans `capsule-cli/src` as a fourth surface with its own + Rust-literal detector. The guard half is the durable part: without it the next command is + free to regress. ### S-I6 — Android ships raw ICU to users, and the guard that should stop it never fires @@ -5666,17 +5721,22 @@ and all three slices are `done` in `capsule-core`. ### S-Z9 — REST reference from the Kynos document -- **Gap:** `capsule-sdk/openapi.json` is emitted by `capsule-api`'s salvo-oapi binary, and - that server is retired. `capsule-server` exposes `openapi() -> Document` but has one - route ported and no emitter binary, so there is no Kynos document to publish. Rendering - the Salvo-derived file would document a server nothing runs. +- **Gap (rewritten 2026-09-01; the slice is no longer blocked).** It read: the only document + was `capsule-sdk/openapi.json` from `capsule-api`'s salvo-oapi binary, `capsule-server` had + one route ported and no emitter binary, and rendering the Salvo-derived file would document + a server nothing runs. All three are out of date. `capsule-server/src/bin/gen_openapi.rs` + emits `capsule-server/openapi.json` — fifty-nine operations, OpenAPI 3.2, pinned by + `openapi_as(SpecVersion::V3_2)` — `mise run openapi-check-kynos` gates it inside + `check-rust`, and the Salvo copy is deleted. What remains is the docs work itself: no + `/reference/api/` pages are generated from it yet. - **Deliverable:** `/reference/api/` generated from the Kynos document by a Starlight-native OpenAPI generator — not an embedded renderer that mounts its own application, which would forfeit the search index, the link validator, and the site palette. - **Done when:** the committed contract is Kynos-emitted, `openapi-check` gates it, and `/reference/api/` renders every path in it as Starlight pages that Pagefind indexes. - **Tier:** docs build + `openapi-check`. **Depends on:** S-Z8, S-D8 (**live block** — - the schema must come from Kynos, which needs `S-C27`). + **Tier:** docs build + `openapi-check-kynos`. **Depends on:** `S-Z8` only — the reference + shell it renders into. The `S-D8`/`S-C27` edge was the Kynos-document dependency and is + discharged. ### S-Z10 — SDK / FFI / WASM reference From 81af985a3fa0858ae66123b4417130bcfbf33f18 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 20:54:23 -0400 Subject: [PATCH 012/243] fix(i18n): evaluate ICU plurals instead of refusing them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `format_message` substituted `{identifier}` and refused everything else: a plural hit a `debug_assert!` and, in release, was copied through verbatim — the user reading the message source. That refusal was the cheap half of `S-I7`; this is the half it owed. `{name, plural, …}` now evaluates, with `=N` exact arms, CLDR category arms, `#` for the count, and nesting — an arm may hold `{name}` placeholders and further plurals. Arm selection comes from `capsule_i18n::plural`, so the arm chosen is the one the locale's rules select, and a category the message does not carry falls back to `other`. That fallback is what makes the shipped catalogs render at all: every translated plural is still an English `one`/`other` copy, so a Russian `few` has nowhere else to go. `Bundle::format` dropped `self.locale` before calling the formatter, which is the API gap plural selection had to close. `format_message_in(locale, …)` is new; `format_message` keeps its signature and means English, for a template that came from nowhere in particular. The refusal is narrowed, not removed. `select`, `selectordinal`, `offset:`, a malformed plural, and nesting past 32 levels keep the assertion and the verbatim pass-through, so the next construct this runtime cannot express is a test failure rather than something a user reads. The two tests that pinned the refusal are retargeted onto `select` rather than deleted — and the debug one gains the `cfg(debug_assertions)` it always needed, without which `cargo test --release` failed on it. Three defects found reviewing this change before committing it: - A stray `{` used to abandon the rest of the template, so one unbalanced brace silently blanked every later argument. It is now one character of output and scanning continues. - Recursion was bounded only by the input. A public formatter that aborts the process on a deeply nested template is not acceptable, so 32 levels is the limit and past it is a refusal. - A string count was trimmed for selection but not for `#`, so `" 1 "` could choose one arm and print another. Both now read the same value. The whole-catalog matrix asserts the acceptance property directly: every plural in every one of the thirteen locales, at eight boundary counts, renders with no braces, no `plural,`, and the count wherever the arm spells `#` — including the keys a locale has not translated, which reach the reader through the source catalog under the reader's own rules. --- capsule-i18n/src/catalog.rs | 14 +- capsule-i18n/src/format.rs | 698 +++++++++++++++++++++++++++--- capsule-i18n/src/lib.rs | 2 +- capsule-i18n/tests/integration.rs | 114 ++++- 4 files changed, 754 insertions(+), 74 deletions(-) diff --git a/capsule-i18n/src/catalog.rs b/capsule-i18n/src/catalog.rs index ad387bb0..16107730 100644 --- a/capsule-i18n/src/catalog.rs +++ b/capsule-i18n/src/catalog.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; -use crate::format::{Value, format_message}; +use crate::format::{Value, format_message_in}; use crate::generated; /// A localized message bundle: a primary locale plus the source locale as a fallback. @@ -47,12 +47,18 @@ impl Bundle { .map(String::as_str) } - /// Format `key` with `args`. Returns `key` itself when the key is unknown, so a - /// missing message surfaces visibly rather than as an empty string. + /// Format `key` with `args`, using **this bundle's locale** to select plural arms + /// (see [`crate::format_message_in`] for the supported ICU subset). Returns `key` + /// itself when the key is unknown, so a missing message surfaces visibly rather than + /// as an empty string. + /// + /// The locale matters even for a message the bundle fell back to the source catalog + /// for: an untranslated English plural still has to pick the arm the *reader's* + /// language selects, and `other` is the fallback when the message does not carry it. #[must_use] pub fn format(&self, key: &str, args: &[(&str, Value<'_>)]) -> String { if let Some(template) = self.message(key) { - format_message(template, args) + format_message_in(&self.locale, template, args) } else { tracing::warn!(key, locale = %self.locale, "missing i18n message"); key.to_string() diff --git a/capsule-i18n/src/format.rs b/capsule-i18n/src/format.rs index c682eb91..7d3ff904 100644 --- a/capsule-i18n/src/format.rs +++ b/capsule-i18n/src/format.rs @@ -1,13 +1,52 @@ //! ICU MessageFormat formatting. //! -//! The runtime currently supports the subset Capsule's catalog uses today: literal -//! text and simple `{name}` argument interpolation. Full ICU `plural` / `select` / -//! `number` / `date` formatting is a documented follow-up (see the i18n design doc); -//! until then a complex placeholder is copied through verbatim rather than -//! mis-rendered, so the limitation is visible rather than silently wrong. +//! # The supported subset +//! +//! - **Literal text**, copied through unchanged. +//! - **`{name}` interpolation** from the supplied arguments. An argument that was not +//! supplied leaves its placeholder intact, braces and all, so the gap is visible in the +//! output rather than silently empty. +//! - **`{name, plural, …}`**, with `=N` exact arms, CLDR category arms, `#` for the +//! selected count, and arbitrary nesting: an arm may contain `{name}` placeholders and +//! further plurals. Category selection comes from [`crate::plural`], so the arm chosen +//! is the one the locale's CLDR rules select — the same arm the ahead-of-time +//! generators lower into an Apple String Catalog variation or an Android ``. +//! A category the message does not carry falls back to `other`, which is what lets a +//! catalog whose plurals are still English `one`/`other` copies render correctly in +//! Russian or Arabic. +//! +//! ICU's apostrophe **quoting** (`'#'` for a literal `#`) is not implemented, here or in +//! the ahead-of-time generators, so a literal `#`, `{` or `}` inside a plural arm cannot +//! be escaped. No catalog message needs one, and an apostrophe that is not adjacent to ICU +//! syntax — "couldn't" — is ordinary text under ICU's own rule, so the catalogs are +//! unaffected. +//! +//! Numbers are rendered as plain digits — no grouping separators and no locale digit +//! shaping. That is a deliberate omission rather than an oversight: doing it properly is +//! `number` skeleton support, which needs CLDR number data this runtime does not carry. +//! +//! # What is still refused +//! +//! `select`, `selectordinal`, `plural` with `offset:`, and any other `{name, kind, …}` +//! block are **refused**: a `debug_assert!` fires where a developer will see it, and the +//! release build copies the construct through verbatim rather than gaining a new crash on +//! a catalog it could previously render badly. Emitting ICU source to a user is the exact +//! failure Android shipped before slice `S-I6`; the refusal exists so the next construct +//! this runtime cannot express is a test failure instead. +use std::collections::BTreeMap; use std::fmt::{self, Write as _}; +use crate::plural::{Category, category}; + +/// How deeply plurals may nest before the formatter refuses. +/// +/// Recursion is otherwise bounded only by the template, and this formatter is public: a +/// template from outside the catalogs could nest thousands deep and overflow the stack, +/// which is an abort no caller can catch. Catalog messages are flat — the deepest today is +/// one plural holding one `{name}` — so the limit is far above anything real. +const MAX_DEPTH: usize = 32; + /// A value substituted into a `{name}` placeholder. #[derive(Debug, Clone, Copy)] pub enum Value<'a> { @@ -26,65 +65,251 @@ impl fmt::Display for Value<'_> { } } -/// Format `template` by substituting `{name}` placeholders from `args`. +/// Format `template` with English plural rules. /// -/// A placeholder whose body is a bare identifier is replaced with the matching arg -/// (or left intact, braces and all, if no such arg was supplied so the gap is -/// visible). Any other `{...}` — e.g. an ICU `plural` block — is copied through -/// unchanged; see the module docs. +/// The locale-free entry point, for a template that did not come from a bundle. Use +/// [`format_message_in`] — or [`crate::Bundle::format`], which does — whenever the +/// message's locale is known, because the plural arm a count selects depends on it. #[must_use] pub fn format_message(template: &str, args: &[(&str, Value<'_>)]) -> String { + format_message_in("en", template, args) +} + +/// Format `template` for `locale`, substituting `args`. +/// +/// `locale` may be a full tag (`pt-BR`); only its language subtag reaches the plural +/// rules. See the module docs for the supported ICU subset and for what a construct +/// outside it does. +#[must_use] +pub fn format_message_in(locale: &str, template: &str, args: &[(&str, Value<'_>)]) -> String { let mut out = String::with_capacity(template.len()); - let mut chars = template.chars().peekable(); - while let Some(c) = chars.next() { - if c != '{' { - out.push(c); - continue; + render(locale, template, args, None, 0, &mut out); + out +} + +/// Render `template` into `out`. +/// +/// `hash` is the text `#` stands for — the enclosing plural's count, or `None` at the top +/// level, where ICU treats `#` as an ordinary character. +fn render( + locale: &str, + template: &str, + args: &[(&str, Value<'_>)], + hash: Option<&str>, + depth: usize, + out: &mut String, +) { + let mut i = 0; + while let Some(c) = template[i..].chars().next() { + match c { + '#' => { + match hash { + Some(count) => out.push_str(count), + None => out.push('#'), + } + i += 1; + } + '{' => { + let Some(close) = matching_brace(template, i) else { + // An unterminated `{` is copied through as an ordinary character, with + // no assertion: the brace may well be literal text in a message that + // never meant to open a placeholder, and nothing distinguishes the two + // readings. Scanning **continues** past it — abandoning the remainder + // would let one stray brace silently swallow every later placeholder. + out.push('{'); + i += 1; + continue; + }; + render_placeholder(locale, &template[i + 1..close], args, depth, out); + i = close + 1; + } + _ => { + out.push(c); + i += c.len_utf8(); + } } - // Collect the placeholder body up to the matching '}'. - let mut body = String::new(); - let mut closed = false; - for b in chars.by_ref() { - if b == '}' { - closed = true; + } +} + +/// Render one `{…}` placeholder body (the text between the braces) into `out`. +fn render_placeholder( + locale: &str, + body: &str, + args: &[(&str, Value<'_>)], + depth: usize, + out: &mut String, +) { + let name = body.trim(); + if is_identifier(name) { + if let Some((_, value)) = args.iter().find(|(key, _)| *key == name) { + let _ = write!(out, "{value}"); + } else { + // Unknown arg: keep the placeholder so the missing value is obvious. + let _ = write!(out, "{{{body}}}"); + } + return; + } + + let rendered = render_plural(locale, body, args, depth); + // Not a hard panic: a release build must not gain a new crash on a catalog it could + // previously render badly, and every test and debug run is a build where this fires. + // The pass-through below is what production still does — and what makes the construct + // a *developer's* problem instead of a user's. + debug_assert!( + rendered.is_some(), + "capsule-i18n cannot render the ICU construct `{{{body}}}` — it would be printed \ + to the user verbatim. A well-formed `plural` is evaluated here; `select`, \ + `selectordinal`, `offset:`, a malformed plural and nesting past {MAX_DEPTH} \ + levels are not, because the per-platform renderers compile those ahead of time \ + (`xtask i18n`) and this runtime has no equivalent." + ); + if let Some(text) = rendered { + out.push_str(&text); + } else { + out.push('{'); + out.push_str(body); + out.push('}'); + } +} + +/// Evaluate `body` as a `name, plural, …` block, or `None` if it is not one this runtime +/// can render. +fn render_plural( + locale: &str, + body: &str, + args: &[(&str, Value<'_>)], + depth: usize, +) -> Option { + if depth >= MAX_DEPTH { + return None; + } + // Two splits only, so a comma inside an arm belongs to the arm. + let mut head = body.splitn(3, ','); + let selector = head.next()?.trim(); + if !is_identifier(selector) || head.next()?.trim() != "plural" { + return None; + } + let arms_source = head.next()?.trim(); + if arms_source.starts_with("offset:") { + return None; + } + let arms = Arms::parse(arms_source)?; + + let argument = args + .iter() + .find(|(key, _)| *key == selector) + .map(|(_, v)| v); + // The count as a number, for selection. A missing argument — or a string that is not + // a number — has no category, so it takes `other`, the arm every message carries. + let count = argument.and_then(|value| match value { + Value::Int(n) => Some(*n), + Value::Str(s) => s.parse::().ok(), + }); + // The count as text, for `#`. Whenever a number was found this is that number, so `#` + // shows exactly the value the arm was selected by (`"007"` selects on 7 and renders + // `7`). A missing argument keeps its placeholder visible, as a missing `{name}` does. + let hash = count.map_or_else( + || argument.map_or_else(|| format!("{{{selector}}}"), ToString::to_string), + |n| n.to_string(), + ); + + let chosen = arms.select(locale, count); + // A plural with no `other` is malformed: `xtask i18n` refuses to generate one, and + // both native resource formats treat it as invalid. Render the first arm in CLDR + // order rather than nothing, so a hand-written template still produces text. + debug_assert!( + chosen.is_some(), + "ICU plural `{{{body}}}` has no `other` arm; every locale selects `other` for \ + some count, so the message cannot render for all inputs." + ); + let arm = chosen.or_else(|| arms.first()).unwrap_or_default(); + + let mut out = String::new(); + render(locale, arm, args, Some(&hash), depth + 1, &mut out); + Some(out) +} + +/// The arms of one `plural` block: exact `=N` matches and CLDR category matches. +struct Arms<'a> { + /// `=N {…}` arms, in source order. ICU tries these before any category. + exact: Vec<(i64, &'a str)>, + /// Category arms, keyed so iteration is in CLDR order. + categories: BTreeMap, +} + +impl<'a> Arms<'a> { + /// Parse `source`, the text after `name, plural,`, or `None` if it is malformed. + fn parse(source: &'a str) -> Option { + let mut exact = Vec::new(); + let mut categories = BTreeMap::new(); + let mut rest = source; + loop { + let trimmed = rest.trim_start(); + if trimmed.is_empty() { break; } - body.push(b); - } - let name = body.trim(); - if closed && is_identifier(name) { - if let Some((_, value)) = args.iter().find(|(key, _)| *key == name) { - let _ = write!(out, "{value}"); + let open = trimmed.find('{')?; + let close = matching_brace(trimmed, open)?; + let selector = trimmed[..open].trim(); + let arm = &trimmed[open + 1..close]; + if let Some(literal) = selector.strip_prefix('=') { + exact.push((literal.trim().parse().ok()?, arm)); } else { - // Unknown arg: keep the placeholder so the missing value is obvious. - let _ = write!(out, "{{{body}}}"); + // First wins, matching the `=N` arms' `find`. ICU rejects a duplicate + // arm outright; the two kinds resolving it differently would be worse + // than either answer. + categories.entry(Category::parse(selector)?).or_insert(arm); } - } else { - // Complex (plural/select) or unterminated placeholder. - // - // Emitting it verbatim means the **user reads the message source** — the exact failure - // Android shipped for as long as its renderer had no plural support (slice `S-I6`), and - // that Apple would have shipped before `S-I4`. This runtime is the third place the same - // construct has arrived with nowhere to go, so it refuses loudly where a developer will - // see it rather than quietly where a user will. - // - // `debug_assert!` and not a hard panic: a release build must not gain a new crash on a - // catalog it could previously render badly, and every test and debug run is a build - // where the assertion fires. The pass-through below is what production still does. - debug_assert!( - !closed, - "capsule-i18n cannot render the ICU construct `{{{body}}}` — it would be printed to \ - the user verbatim. The per-platform renderers compile plurals ahead of time \ - (`xtask i18n`); this runtime does not. See slice `S-I7`." - ); - out.push('{'); - out.push_str(&body); - if closed { - out.push('}'); + rest = &trimmed[close + 1..]; + } + (!exact.is_empty() || !categories.is_empty()).then_some(Self { exact, categories }) + } + + /// The arm `count` selects in `locale`: an exact `=N` match, then the CLDR category, + /// then `other`. + fn select(&self, locale: &str, count: Option) -> Option<&'a str> { + if let Some(n) = count { + if let Some((_, arm)) = self.exact.iter().find(|(value, _)| *value == n) { + return Some(arm); + } + if let Some(arm) = self.categories.get(&category(locale, n)) { + return Some(arm); } } + self.categories.get(&Category::Other).copied() } - out + + /// The first arm in CLDR order, for a malformed message with no `other`. + fn first(&self) -> Option<&'a str> { + self.categories + .values() + .next() + .copied() + .or_else(|| self.exact.first().map(|(_, arm)| *arm)) + } +} + +/// The byte index of the `}` matching the `{` at `open`, or `None` if it is unterminated. +/// +/// Brace *matching*, not the first `}`: every ICU plural nests braces, and a scan that +/// stops at the first one cannot see past the opening arm — the same defect that let +/// Android's renderer ship raw ICU (slice `S-I6`). +fn matching_brace(text: &str, open: usize) -> Option { + debug_assert_eq!(text.as_bytes().get(open), Some(&b'{')); + let mut depth = 0usize; + for (offset, byte) in text.as_bytes()[open..].iter().enumerate() { + match byte { + b'{' => depth += 1, + b'}' => { + depth -= 1; + if depth == 0 { + return Some(open + offset); + } + } + _ => {} + } + } + None } /// Whether `s` is a non-empty ASCII identifier (letters, digits, underscore). @@ -94,7 +319,10 @@ fn is_identifier(s: &str) -> bool { #[cfg(test)] mod tests { - use super::{Value, format_message}; + use super::{Value, format_message, format_message_in}; + + /// The shape every plural in `locales/` has today. + const ITEMS: &str = "{count, plural, one {# item} other {# items}}"; #[test] fn literal_passes_through() { @@ -133,32 +361,366 @@ mod tests { ); } + #[test] + fn a_plural_selects_its_arm_and_substitutes_the_count() { + assert_eq!(format_message(ITEMS, &[("count", Value::Int(1))]), "1 item"); + assert_eq!( + format_message(ITEMS, &[("count", Value::Int(7))]), + "7 items" + ); + } + + #[test] + fn a_plural_may_sit_inside_surrounding_text() { + let template = "Deleted {count, plural, one {# photo} other {# photos}} today."; + assert_eq!( + format_message(template, &[("count", Value::Int(2))]), + "Deleted 2 photos today." + ); + } + + #[test] + fn an_exact_arm_beats_the_category_it_overlaps() { + let template = "{count, plural, =0 {Nothing selected} =1 {Just this one} one {# item} other {# items}}"; + assert_eq!( + format_message(template, &[("count", Value::Int(0))]), + "Nothing selected" + ); + assert_eq!( + format_message(template, &[("count", Value::Int(1))]), + "Just this one" + ); + assert_eq!( + format_message(template, &[("count", Value::Int(2))]), + "2 items" + ); + } + + #[test] + fn the_locale_chooses_the_arm() { + // The whole point of threading a locale: Russian's three forms, English's two. + let ru = "{count, plural, one {# файл} few {# файла} many {# файлов} other {# файла}}"; + assert_eq!( + format_message_in("ru", ru, &[("count", Value::Int(1))]), + "1 файл" + ); + assert_eq!( + format_message_in("ru", ru, &[("count", Value::Int(3))]), + "3 файла" + ); + assert_eq!( + format_message_in("ru", ru, &[("count", Value::Int(5))]), + "5 файлов" + ); + assert_eq!( + format_message_in("ru", ru, &[("count", Value::Int(21))]), + "21 файл" + ); + // French counts zero as singular; English does not. + let zero = "{count, plural, one {# photo} other {# photos}}"; + assert_eq!( + format_message_in("fr", zero, &[("count", Value::Int(0))]), + "0 photo" + ); + assert_eq!( + format_message_in("en", zero, &[("count", Value::Int(0))]), + "0 photos" + ); + // A region subtag resolves to its language. + assert_eq!( + format_message_in("pt-BR", zero, &[("count", Value::Int(0))]), + "0 photo" + ); + } + + #[test] + fn a_category_the_message_omits_falls_back_to_other() { + // Every translated plural in `locales/` carries only `one` and `other`, including + // the Russian and Arabic ones. Falling back to `other` is what makes those render + // at all — without it, `few` in Russian would have nowhere to go. + assert_eq!( + format_message_in("ru", ITEMS, &[("count", Value::Int(3))]), + "3 items" + ); + assert_eq!( + format_message_in("ar", ITEMS, &[("count", Value::Int(0))]), + "0 items" + ); + } + + #[test] + fn an_arm_may_contain_placeholders_and_further_plurals() { + let template = "{count, plural, one {{name} has # photo} \ + other {{name} has # photos in {albums, plural, \ + one {# album} other {# albums}}}}"; + assert_eq!( + format_message( + template, + &[ + ("count", Value::Int(1)), + ("albums", Value::Int(4)), + ("name", Value::Str("Sam")), + ] + ), + "Sam has 1 photo" + ); + assert_eq!( + format_message( + template, + &[ + ("count", Value::Int(9)), + ("albums", Value::Int(4)), + ("name", Value::Str("Sam")), + ] + ), + // The inner `#` is the inner plural's count, not the outer one. + "Sam has 9 photos in 4 albums" + ); + } + + #[test] + fn a_hash_outside_a_plural_is_a_literal() { + assert_eq!( + format_message("Issue #{id}", &[("id", Value::Int(7))]), + "Issue #7" + ); + } + + #[test] + fn a_string_selector_is_parsed_as_a_number() { + assert_eq!( + format_message(ITEMS, &[("count", Value::Str("1"))]), + "1 item" + ); + // A non-numeric argument has no category, so it takes `other` — but `#` still + // renders what the caller supplied rather than inventing a number. + assert_eq!( + format_message(ITEMS, &[("count", Value::Str("lots"))]), + "lots items" + ); + } + + #[test] + fn a_missing_selector_takes_other_and_keeps_the_gap_visible() { + assert_eq!(format_message(ITEMS, &[]), "{count} items"); + } + /// An ICU construct this runtime cannot evaluate is refused, not printed. /// - /// This test previously asserted the opposite — that a plural block is "left verbatim" as a - /// known limitation. Emitting it verbatim means the **user reads the message source**, which is - /// precisely what Android shipped for as long as its renderer lacked plural support (slice - /// `S-I6`). A limitation that renders as output is not a limitation, it is a defect, and a test - /// pinning it made it look deliberate. Retargeted rather than deleted, because the case still - /// needs coverage — only the expected behaviour changed. + /// This test previously targeted `plural`, which is now evaluated; it is retargeted + /// onto `select` rather than deleted, because the property it pins is not about + /// plurals. Emitting a construct verbatim means the **user reads the message source**, + /// which is precisely what Android shipped for as long as its renderer lacked plural + /// support (slice `S-I6`). A limitation that renders as output is not a limitation, it + /// is a defect. + /// + /// `cfg(debug_assertions)`, which it was missing: the assertion is compiled out of a + /// release build, so under `cargo test --release` the test asserted a panic that + /// cannot happen and failed. #[test] + #[cfg(debug_assertions)] #[should_panic(expected = "cannot render the ICU construct")] fn an_unrenderable_icu_construct_is_refused_in_debug_builds() { - let template = "{count, plural, one {# item} other {# items}}"; - let _ = format_message(template, &[("count", Value::Int(2))]); + let template = "{kind, select, photo {Photo} other {Item}}"; + let _ = format_message(template, &[("kind", Value::Str("photo"))]); + } + + #[test] + #[cfg(debug_assertions)] + #[should_panic(expected = "cannot render the ICU construct")] + fn selectordinal_is_refused() { + let template = "{n, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}"; + let _ = format_message(template, &[("n", Value::Int(3))]); } - /// Release builds still pass the construct through rather than crashing on a catalog they - /// previously rendered badly — the assertion above is a developer-facing signal, not a - /// production behaviour change. Asserted on the shape the pass-through produces: each `{…}` - /// segment is reconstructed with its braces, so the output equals the input exactly. + #[test] + #[cfg(debug_assertions)] + #[should_panic(expected = "cannot render the ICU construct")] + fn a_plural_with_an_offset_is_refused() { + let template = "{count, plural, offset:1 one {# other} other {# others}}"; + let _ = format_message(template, &[("count", Value::Int(3))]); + } + + #[test] + #[cfg(debug_assertions)] + #[should_panic(expected = "has no `other` arm")] + fn a_plural_without_an_other_arm_is_refused() { + let _ = format_message("{count, plural, one {# item}}", &[("count", Value::Int(5))]); + } + + /// Release builds still pass the construct through rather than crashing on a catalog + /// they previously rendered badly — the assertion above is a developer-facing signal, + /// not a production behaviour change. Asserted on the shape the pass-through produces: + /// each `{…}` segment is reconstructed with its braces, so the output equals the input. #[test] #[cfg(not(debug_assertions))] fn release_builds_pass_the_construct_through_unchanged() { - let template = "{count, plural, one {# item} other {# items}}"; + for template in [ + "{kind, select, photo {Photo} other {Item}}", + "{n, selectordinal, one {#st} other {#th}}", + "{count, plural, offset:1 one {# other} other {# others}}", + ] { + assert_eq!( + format_message(template, &[("count", Value::Int(2))]), + template + ); + } + } + + /// A malformed plural still renders text in release: the first arm in CLDR order, + /// never nothing and never the message source. + #[test] + #[cfg(not(debug_assertions))] + fn release_builds_render_the_first_arm_of_a_plural_with_no_other() { assert_eq!( - format_message(template, &[("count", Value::Int(2))]), - template + format_message("{count, plural, one {# item}}", &[("count", Value::Int(5))]), + "5 item" + ); + } + + #[test] + fn an_unterminated_brace_is_copied_through_without_asserting() { + // Not an assertion case: the brace may be literal text in a message that never + // meant to open a placeholder, and nothing distinguishes the two readings. + assert_eq!(format_message("50% off {sale", &[]), "50% off {sale"); + } + + #[test] + fn an_unterminated_brace_does_not_swallow_what_follows_it() { + // The brace is one character of output, not a decision to stop formatting: a + // stray `{` must not silently blank every later argument. + assert_eq!( + format_message( + "A { stray brace, then {name}", + &[("name", Value::Str("Sam"))] + ), + "A { stray brace, then Sam" + ); + } + + #[test] + fn a_closing_brace_with_no_opener_is_literal_text() { + assert_eq!(format_message("100% } done", &[]), "100% } done"); + } + + /// A template nested far past [`MAX_DEPTH`] refuses instead of recursing. + /// + /// Without the limit, recursion is bounded only by the input: measured on this + /// formatter, 50 000 levels aborts the process with `stack overflow`, which no caller + /// can catch. `format_message` is public, so the input need not be a catalog message. + fn deeply_nested_plural(depth: usize) -> String { + format!( + "{}#{}", + "{count, plural, other {".repeat(depth), + "}}".repeat(depth) + ) + } + + #[test] + #[cfg(debug_assertions)] + #[should_panic(expected = "cannot render the ICU construct")] + fn nesting_past_the_depth_limit_is_refused_rather_than_overflowing_the_stack() { + let _ = format_message(&deeply_nested_plural(5_000), &[("count", Value::Int(2))]); + } + + #[test] + #[cfg(not(debug_assertions))] + fn release_builds_pass_a_too_deeply_nested_plural_through() { + let template = deeply_nested_plural(5_000); + let rendered = format_message(&template, &[("count", Value::Int(2))]); + assert!(rendered.ends_with("}}"), "the refusal is a pass-through"); + } + + #[test] + fn the_locale_free_entry_point_uses_english_rules() { + // Asserted against English's actual answer, not against `format_message_in("en")` + // — the latter is how `format_message` is implemented, so it would pass even if + // the English rules were wrong. + assert_eq!( + format_message(ITEMS, &[("count", Value::Int(0))]), + "0 items" + ); + assert_eq!(format_message(ITEMS, &[("count", Value::Int(1))]), "1 item"); + } + + #[test] + fn a_string_count_renders_the_number_it_was_selected_by() { + // Selection and `#` read the same value, so a padded or spaced string cannot + // choose one arm and print another. + assert_eq!( + format_message(ITEMS, &[("count", Value::Str("007"))]), + "7 items" + ); + // Not a number once the spaces count: no category, so `other`, and `#` shows what + // the caller actually passed. + assert_eq!( + format_message(ITEMS, &[("count", Value::Str(" 1 "))]), + " 1 items" + ); + } + + #[test] + fn a_duplicate_arm_resolves_the_same_way_for_both_arm_kinds() { + // ICU rejects a duplicate arm; this runtime takes the first of each kind, so the + // two kinds cannot disagree about which duplicate wins. + assert_eq!( + format_message( + "{count, plural, other {first} other {second}}", + &[("count", Value::Int(2))] + ), + "first" + ); + assert_eq!( + format_message( + "{count, plural, =2 {first} =2 {second} other {o}}", + &[("count", Value::Int(2))] + ), + "first" + ); + } + + #[test] + fn an_arm_may_be_empty_or_contain_a_comma() { + assert_eq!( + format_message( + "{count, plural, one {} other {one, two, many}}", + &[("count", Value::Int(1))] + ), + "" + ); + assert_eq!( + format_message( + "{count, plural, one {} other {one, two, many}}", + &[("count", Value::Int(3))] + ), + "one, two, many" + ); + } + + #[test] + #[cfg(debug_assertions)] + #[should_panic(expected = "cannot render the ICU construct")] + fn an_empty_placeholder_is_refused() { + let _ = format_message("{}", &[]); + } + + #[test] + #[cfg(debug_assertions)] + #[should_panic(expected = "cannot render the ICU construct")] + fn trailing_junk_after_the_last_arm_is_refused() { + let _ = format_message( + "{count, plural, other {items} junk}", + &[("count", Value::Int(2))], + ); + } + + #[test] + #[cfg(debug_assertions)] + #[should_panic(expected = "cannot render the ICU construct")] + fn a_spaced_offset_is_refused_like_an_unspaced_one() { + let _ = format_message( + "{count, plural, offset : 1 other {# others}}", + &[("count", Value::Int(3))], ); } } diff --git a/capsule-i18n/src/lib.rs b/capsule-i18n/src/lib.rs index 8e157fd7..bec08647 100644 --- a/capsule-i18n/src/lib.rs +++ b/capsule-i18n/src/lib.rs @@ -28,6 +28,6 @@ mod negotiate; pub mod plural; pub use catalog::{Bundle, supported_locales}; -pub use format::{Value, format_message}; +pub use format::{Value, format_message, format_message_in}; pub use generated::error_codes; pub use negotiate::negotiate; diff --git a/capsule-i18n/tests/integration.rs b/capsule-i18n/tests/integration.rs index 59451c12..d2b12e3c 100644 --- a/capsule-i18n/tests/integration.rs +++ b/capsule-i18n/tests/integration.rs @@ -1,6 +1,8 @@ //! End-to-end checks against the generated `en` bundle embedded in the crate. -use capsule_i18n::{Bundle, Value, error_codes, format_message, negotiate, supported_locales}; +use capsule_i18n::{ + Bundle, Value, error_codes, format_message, format_message_in, negotiate, supported_locales, +}; #[test] fn source_locale_is_supported() { @@ -55,3 +57,113 @@ fn public_formatter_interpolates() { "Hi, Sam!" ); } + +/// The counts the plural matrix below is asserted over: the CLDR boundaries that separate +/// Russian's three forms and Arabic's five from each other and from `other`. +const COUNTS: &[i64] = &[0, 1, 2, 3, 5, 11, 21, 101]; + +/// One locale's embedded bundle, read from the same file the crate compiles in. +/// +/// The crate exposes no key iterator (a bundle answers look-ups; enumerating its keys is +/// not something a caller needs), so the matrix reads the generated JSON directly. It is +/// the *committed generated* file, so a drifted catalog is `mise run i18n-check`'s +/// failure, not this test's. +fn bundle_messages(locale: &str) -> std::collections::BTreeMap { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("src/bundles") + .join(format!("{locale}.json")); + let json = std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("read {}: {error}", path.display())); + serde_json::from_str(&json).unwrap_or_else(|error| panic!("parse {}: {error}", path.display())) +} + +/// Every plural the shipped catalogs carry renders, in every locale, at every boundary +/// count — no braces, no `plural,`, and the count itself wherever the arm spells `#`. +/// +/// This is the acceptance test for the runtime's plural support: before it, each of these +/// ~1000 renderings returned the ICU message source, which is what a user would have read. +#[test] +fn every_plural_renders_in_every_locale() { + let mut renderings = 0usize; + let mut keys_seen = 0usize; + let source_messages = bundle_messages("en"); + for locale in supported_locales() { + let bundle = Bundle::for_locale(locale); + // A key the locale has not translated resolves through the source catalog, so the + // reader still gets an English plural — under *their* language's rules. That path + // is live (`app.places.preview.accessibility` is `en`-only), so the matrix covers + // the union rather than only what the locale itself carries. + let mut messages = source_messages.clone(); + messages.extend(bundle_messages(locale)); + for (key, source) in messages { + if !source.contains("plural,") { + continue; + } + keys_seen += 1; + // The whole matrix assumes one selector name, which is also what the + // generators' argument plan assumes. Assert it rather than silently + // rendering the `other` arm for a key that renamed its count. + assert!( + source.starts_with("{count, plural,"), + "{locale}/{key}: unexpected plural selector in `{source}`" + ); + for n in COUNTS { + let rendered = bundle.format(&key, &[("count", Value::Int(*n))]); + assert!( + !rendered.contains('{') + && !rendered.contains('}') + && !rendered.contains("plural,"), + "{locale}/{key} at n={n} rendered ICU source: `{rendered}`" + ); + if source.contains('#') { + assert!( + rendered.contains(&n.to_string()), + "{locale}/{key} at n={n} dropped the count: `{rendered}`" + ); + } + renderings += 1; + } + } + } + // 13 locales x 10-11 plural keys x 8 counts. A floor, not an equality, so adding a + // plural to the catalogs does not fail this test — losing one does. + assert!( + keys_seen >= 13 * 10, + "only {keys_seen} plural keys found across the bundles" + ); + assert!( + renderings >= 130 * COUNTS.len(), + "only {renderings} renderings were asserted" + ); +} + +/// A bundle formats with **its own** locale's rules, which is the API gap plural support +/// had to close: `Bundle::format` used to drop `self.locale` on the floor. +#[test] +fn a_bundle_selects_the_arm_its_own_locale_asks_for() { + // French counts zero as singular, English does not — the same catalog message, two + // different arms, chosen only because the bundle knows which locale it is. + let count = [("count", Value::Int(0))]; + assert_eq!( + Bundle::for_locale("fr").format("common.item_count", &count), + "0 item" + ); + assert_eq!( + Bundle::for_locale("en").format("common.item_count", &count), + "0 items" + ); +} + +/// The locale-free `format_message` still exists and still means English. +#[test] +fn the_public_formatter_evaluates_a_plural_with_english_rules() { + let template = "{count, plural, one {# item} other {# items}}"; + assert_eq!( + format_message(template, &[("count", Value::Int(1))]), + "1 item" + ); + assert_eq!( + format_message_in("fr", template, &[("count", Value::Int(0))]), + "0 item" + ); +} From a47865f36435138556083c4ad6beb9206e18c468 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 20:56:57 -0400 Subject: [PATCH 013/243] docs(design): correct the citations the code disproves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `capsule-sdk/openapi.json` does not exist — `S-C59` deleted it and made `capsule-server/openapi.json` the one contract — yet eight places still named it, four of them Rust doc comments a reader would grep from. Where the citation was about history (`S-C28`'s status audit, the conformance rationale) it now says so; where it was a live claim it names the file that exists. Three further claims the tree disproves: - `developer-docs.md` said the REST reference was blocked because no Kynos document existed. `gen_openapi.rs` emits fifty-nine operations at OpenAPI 3.2 and `openapi-check-kynos` gates them inside `check-rust`; what is left is the reference generation, which is `S-Z9`. - `filesystem/server.md` said the store in the tree is still flat. `capsule-server::blob`'s filesystem backend writes `blobs/{hash[0:2]}/{hash[2:4]}/{hash}.bin`, creates each shard on demand and fsyncs it. - `dependencies.md`, `gen_openapi.rs` and ADR-0002 gave the wrong reason for the four `spargen::omit!` narrowings. The structurally invalid Salvo operations are gone; the four narrowed today are the `application/cbor` ones, because spargen's `classify_media` has no such media type. `mls-resilience.md` put `ReconcileOutcome` in `capsule-core::crypto::mls`, which has never existed; it is `capsule-core::crypto::authority`, and its entry point takes a `ServerChainView`. The unbackticked citation inside a fenced block is exactly what `module-paths` cannot see. `licensing.md` described the `VideoTranscoder` seam in the present tense after `S-C59` retired the module holding it; it is a constraint on the rebuild. `capsule-cli`'s `DEFAULT_ENDPOINT` comment pointed at `mise run serve-api`, a task that retired with the binary it launched. `module-map.md` gains the normative sentence ADR-0005 needs: an app links exactly the `capsule_core_ffi` + `capsule_sdk` namespace pair. --- ...ts-are-generated-from-the-committed-contract.md | 14 ++++++++++---- capsule-cli/src/remote.rs | 4 +++- .../src/content/docs/design/dependencies.md | 2 +- .../src/content/docs/design/developer-docs.md | 12 +++++++----- .../src/content/docs/design/filesystem/server.md | 2 +- capsule-docs/src/content/docs/design/licensing.md | 2 +- .../src/content/docs/design/mls-resilience.md | 4 ++-- capsule-docs/src/content/docs/design/module-map.md | 1 + capsule-server/src/bin/gen_openapi.rs | 14 ++++++++------ capsule-server/src/lib.rs | 2 +- capsule-server/src/routes/auth.rs | 5 +++-- capsule-server/tests/conformance.rs | 3 ++- 12 files changed, 40 insertions(+), 25 deletions(-) diff --git a/adr/0002-clients-are-generated-from-the-committed-contract.md b/adr/0002-clients-are-generated-from-the-committed-contract.md index 9ef997f3..2165c2f3 100644 --- a/adr/0002-clients-are-generated-from-the-committed-contract.md +++ b/adr/0002-clients-are-generated-from-the-committed-contract.md @@ -35,12 +35,18 @@ serialization versus orchestration**, and it now falls in exactly one place: lower correctly as of 0.2.2, so the byte path is generated and a hand-written one would now be a deliberate second parser rather than a workaround. - `spargen` is a **build-dependency only**. `capsule-sdk/build.rs` lowers the committed - `capsule-sdk/openapi.json` into the typed client at build time; its runtime support is + `capsule-server/openapi.json` into the typed client at build time; its runtime support is embedded into the generated module, so the generator never enters the SDK's runtime tree. - Nothing generated is committed. + Nothing generated is committed. There is one document: the Salvo-era `capsule-sdk/openapi.json` + was deleted in `S-C59`, so no client can be generated from a contract the server does not + serve. - Since 0.3.0 Spargen enforces runtime dependency contracts, which set the floors on `bytes`, `reqwest`, `serde` and `serde_json` in the root manifest. Those bump together or generation fails. - Progenitor is retired and listed in `xtask`'s retired-dependency set so it cannot return. -- Four Salvo-emitted operations are narrowed with `spargen::omit!` because they are - structurally invalid. That list shrinks as the Kynos surface replaces them. +- Four operations are narrowed with `spargen::omit!`. The four that carried this line were + structurally invalid Salvo output and are gone — Kynos can express neither defect, and all four + generate. The four in their place are the ones Capsule serves as `application/cbor`, a media + type spargen's `classify_media` does not know; they are signed documents served byte for byte, + which is why they are not JSON. Relabelling them `application/octet-stream` to satisfy the + generator would tell every client that a document with a schema it knows is opaque bytes. diff --git a/capsule-cli/src/remote.rs b/capsule-cli/src/remote.rs index a180650a..769e04f2 100644 --- a/capsule-cli/src/remote.rs +++ b/capsule-cli/src/remote.rs @@ -53,7 +53,9 @@ pub struct RemoteConfig { pub protocol_version: String, } -/// The default server origin — one host, one port, matching `mise run serve-api`. +/// The default server origin — one host, one port. It matched `mise run serve-api`, which +/// retired with the Salvo binary it launched (`S-C59`); the Kynos server has no binary and no +/// serve task yet, so nothing listens here until one lands. pub const DEFAULT_ENDPOINT: &str = "http://127.0.0.1:3000"; impl RemoteConfig { diff --git a/capsule-docs/src/content/docs/design/dependencies.md b/capsule-docs/src/content/docs/design/dependencies.md index 548962bc..282127cd 100644 --- a/capsule-docs/src/content/docs/design/dependencies.md +++ b/capsule-docs/src/content/docs/design/dependencies.md @@ -33,7 +33,7 @@ Mechanically, every Rust version is pinned once in the root `Cargo.toml` `[works | Kynos sourcing | `kynos = { version = "0.1.0", features = ["openapi32"] }` — from **crates.io** | Kynos published 0.1.0 on 2026-08-29, which discharges the repin-on-publish exit this row previously carried: the git dependency and its pinned rev are gone, so a bump is an ordinary reviewed version change rather than a rev audit. Tokio-only, MSRV **1.85**, edition 2024. `openapi32` is a strict, purely additive superset of the default `openapi31`. **Enabling the feature does not by itself make the document 3.2**: `Router::openapi()` emits the *lowest* version expressing the API without loss, deliberately not keyed on the feature, because Cargo unifies features across a dependency graph and a document's version must not follow a flag an unrelated crate turned on. Capsule therefore pins the version explicitly with `openapi_as(SpecVersion::V3_2)` — the case Kynos names as *"a consumer's toolchain pins a version"*, and one that targets rather than downgrades, so an unexpressible construct is an error naming what blocks it, never a document with operations quietly missing. | Master has moved past the 0.1.0 tag (`512adbc7`) — the delta is docs/CI plus `Accept-Language` negotiation, which Capsule does not adopt (error codes are localized client-side, offline). Repin at 0.2 if a released feature is needed. | | HTTP body | `http-body-util` | `capsule-server`'s coded-problem interceptor (`S-C36`) only: it reads a rendered RFC 9457 body back before putting it down again, and Kynos's `Body` is an `http_body::Body` with no inherent collector. Already in the lock file through Kynos and hyper, so it adds nothing to the tree. | Not a general HTTP abstraction: nothing else in Capsule touches a body outside a typed extractor, and a second use is a sign something is bypassing one. | | HTTP client | `reqwest` (`default-features = false`, `rustls-tls`) | `capsule-sdk` — the sanctioned network path. | — | -| REST client codegen | `spargen` **0.4.0** (in-house, OpenAPI 3.1.x **and 3.2.x**) | `capsule-sdk` **build-dependency only**: `build.rs` lowers the committed `capsule-sdk/openapi.json` — emitted deterministically from the Kynos server's own OpenAPI document — into the typed `rest::Client`, wrapped by `client::AuthenticatedClient` (slice `S-D8`). Its runtime support is embedded into the generated module, so spargen never enters the SDK's runtime tree. Since 0.3.0 it enforces **runtime dependency contracts**, which set the floors on `bytes`, `reqwest`, `serde` and `serde_json` in the root manifest — bump those together or generation fails. Its API changed in 0.3: `Config` split into `Spec`/`Build`, and `Report::outcome` is a method (a `Cached` outcome is a success, not a failure). | Progenitor is gone. **The two exclusions this row used to carry are lifted**: object-typed query params and binary bodies both lower correctly as of 0.2.2, so the byte-serving surface is generated rather than hand-written. What *stays* hand-written is orchestration, not parsing — the resumable upload state machine (`S-D1`), token refresh, sync, recovery and protocol-version negotiation. Four Salvo-emitted operations are narrowed with `spargen::omit!` because they are structurally invalid; see the gates table in `SLICES.md`. | +| REST client codegen | `spargen` **0.4.0** (in-house, OpenAPI 3.1.x **and 3.2.x**) | `capsule-sdk` **build-dependency only**: `build.rs` lowers the committed `capsule-server/openapi.json` — emitted deterministically from the Kynos server's own types — into the typed `rest::Client`, wrapped by `client::AuthenticatedClient` (slice `S-D8`). Its runtime support is embedded into the generated module, so spargen never enters the SDK's runtime tree. Since 0.3.0 it enforces **runtime dependency contracts**, which set the floors on `bytes`, `reqwest`, `serde` and `serde_json` in the root manifest — bump those together or generation fails. Its API changed in 0.3: `Config` split into `Spec`/`Build`, and `Report::outcome` is a method (a `Cached` outcome is a success, not a failure). | Progenitor is gone. **The two exclusions this row used to carry are lifted**: object-typed query params and binary bodies both lower correctly as of 0.2.2, so the byte-serving surface is generated rather than hand-written. What *stays* hand-written is orchestration, not parsing — the resumable upload state machine (`S-D1`), token refresh, sync, recovery and protocol-version negotiation. Four operations are narrowed with `spargen::omit!` — the four Capsule serves as `application/cbor`, a media type spargen's `classify_media` does not know. The Salvo-era narrowings, which were structurally invalid operations, are gone: Kynos cannot express either defect. See the gates table in `SLICES.md`. | | Second factor | `totp-rs` (`otpauth`, `gen_secret`) | The RFC 6238 codes of the local auth path's second factor (slice `S-C55`), in `capsule-server`'s `auth::totp` alone. The parameters are Capsule's and are published as constants — SHA-1, six digits, a thirty-second step, one step of drift — because an authenticator app assumes all four and a deployment that changed one would issue provisioning URIs that silently mis-generate. What the crate does **not** own is replay: a code is accepted at most once, and that is a compare-and-set in the enrollment store, not an algorithm. | The crate's own `skew` is deliberately unused: Capsule walks the drift window itself because it needs to know *which* step matched, and `check` reports only that one did. | | Constant-time comparison | `subtle` | The one place a secret-derived value is compared byte for byte: the second factor's code check (`S-C55`). A hand-rolled fold is what an optimizer is free to short-circuit, and the resulting code looks correct forever. Already in the tree under `aes-gcm`, so this promotes a transitive dependency rather than adding one. | Password and manifest comparisons do not use it — a password never rises above its adapter, and signature verification is the signature crate's own constant-time path. | | WebAuthn | **none — passkeys are not in v1** | Slice `S-C56`. `webauthn-rs` survives only inside the retiring `capsule-server`, and leaves the workspace with it. | **This is why `openssl` leaves the tree.** `webauthn-attestation-ca` was the sole edge pulling it in, for attestation-certificate parsing — never as a TLS stack, which is the carve-out the TLS row recorded. With passkeys deferred, the carve-out is spent and the TLS rule holds without exception. A rebuild reopens both. | diff --git a/capsule-docs/src/content/docs/design/developer-docs.md b/capsule-docs/src/content/docs/design/developer-docs.md index 1d7763d0..88570c55 100644 --- a/capsule-docs/src/content/docs/design/developer-docs.md +++ b/capsule-docs/src/content/docs/design/developer-docs.md @@ -49,7 +49,7 @@ Each surface's owning gate emits a **description artifact**: a small, committed, file describing the surface. The docs build reads committed artifacts and nothing else. It never invokes cargo, uniffi, or wasm-bindgen. -`capsule-sdk/openapi.json` already is such an artifact — emitted by a state-free binary, refreshed by +`capsule-server/openapi.json` already is such an artifact — emitted by a state-free binary, refreshed by `mise run openapi-kynos`, and drift-gated by `mise run openapi-check-kynos`. This rule generalizes that shape to every other surface rather than inventing a second mechanism for each. @@ -96,10 +96,12 @@ Each therefore needs a small committed dump alongside its existing generation st symbol-presence assertions already in `mise-tasks/gen-bindings` are the seed of that dump — they already enumerate the verbs each binding must export — but they assert, they do not yet emit. -**Why REST is blocked.** The committed `capsule-sdk/openapi.json` is emitted from the retired Salvo -server. Its Kynos replacement exposes `openapi() -> Document` but has a single route ported and no -emitter binary, so no Kynos document exists yet. The REST reference is generated from the Kynos -document when there is one; publishing the Salvo-derived file would document a server nothing runs. +**Why REST is not blocked any more.** This paragraph used to say the only committed document came +from the retired Salvo server and that its Kynos replacement had one route ported and no emitter +binary. `capsule-server/src/bin/gen_openapi.rs` emits `capsule-server/openapi.json` — fifty-nine +operations at OpenAPI 3.2, pinned with `openapi_as(SpecVersion::V3_2)` — `mise run +openapi-check-kynos` gates it inside `check-rust`, and the Salvo copy is deleted. What is left is +the reference generation itself, which is slice `S-Z9`. **What is deliberately not a reference surface:** diff --git a/capsule-docs/src/content/docs/design/filesystem/server.md b/capsule-docs/src/content/docs/design/filesystem/server.md index b8ef996e..7e405719 100644 --- a/capsule-docs/src/content/docs/design/filesystem/server.md +++ b/capsule-docs/src/content/docs/design/filesystem/server.md @@ -36,7 +36,7 @@ Required means required. The server refuses to start without `VALKEY_URL` — it - **`{blob_root}`**: absolute path configured at server startup. The entire tree must be on a single filesystem so that finalization renames are atomic. - **`incoming/`**: live uploads. Each session owns a single append-only file `{upload_id}.bin`; accepted chunks are appended in order, and the 4 KiB chunk alignment keeps every write block-aligned. There is no per-chunk staging and no assembly step. See [Import — Upload Protocol: Append-Only Storage](/design/import/upload-protocol/#append-only-storage). -- **`blobs/`**: the finalized store, **sharded two levels deep on the content hash's own hex prefix**: `blobs/{hash[0:2]}/{hash[2:4]}/{hash}.bin`, the filename being the [ciphertext content hash](/design/cryptography/primitives/) with a `.bin` suffix. This is the re-ratification the 2026-07-12 amendment demanded, and it overturns that amendment's flat namespace. The amendment argued the flat layout from the wrong sizing case: *no hot path enumerates the directory* is true and beside the point, because lookup is a `stat` at a known content address and costs the same flat or sharded. The cost lands on the **enumerations**, of which there are three, each a full `readdir` + `stat` of the store — the [integrity scrub](/design/filesystem/maintenance/#server-side-integrity-scrub)'s blob→row pass, the [refcount GC](#deletion-and-garbage-collection)'s orphan sweep, and the [index rebuild](#recovering-the-index-from-blobs-alone) — and a multi-million-entry flat directory is precisely where those hurt. All three are integrity or recovery paths, so they must stay affordable exactly when a deployment is already in trouble. Timing settles the rest: no deployment holds blobs to move, so the shard is free **now** and a version-bumped data move behind `.server/version` forever after. The store in the tree today is still flat; the shard is the target `capsule-server::blob` is built to, not a migration it performs. Shard directories are created on demand at finalization and the rename stays inside `{blob_root}`, so finalization remains atomic. A finalized blob is immutable. +- **`blobs/`**: the finalized store, **sharded two levels deep on the content hash's own hex prefix**: `blobs/{hash[0:2]}/{hash[2:4]}/{hash}.bin`, the filename being the [ciphertext content hash](/design/cryptography/primitives/) with a `.bin` suffix. This is the re-ratification the 2026-07-12 amendment demanded, and it overturns that amendment's flat namespace. The amendment argued the flat layout from the wrong sizing case: *no hot path enumerates the directory* is true and beside the point, because lookup is a `stat` at a known content address and costs the same flat or sharded. The cost lands on the **enumerations**, of which there are three, each a full `readdir` + `stat` of the store — the [integrity scrub](/design/filesystem/maintenance/#server-side-integrity-scrub)'s blob→row pass, the [refcount GC](#deletion-and-garbage-collection)'s orphan sweep, and the [index rebuild](#recovering-the-index-from-blobs-alone) — and a multi-million-entry flat directory is precisely where those hurt. All three are integrity or recovery paths, so they must stay affordable exactly when a deployment is already in trouble. Timing settles the rest: no deployment holds blobs to move, so the shard is free **now** and a version-bumped data move behind `.server/version` forever after. The store in the tree shards: `capsule-server::blob`'s filesystem backend writes `blobs/{hash[0:2]}/{hash[2:4]}/{hash}.bin`, creates each shard directory on demand at finalization and fsyncs it, and performs no migration — there are no deployments holding blobs to move. Shard directories are created on demand at finalization and the rename stays inside `{blob_root}`, so finalization remains atomic. A finalized blob is immutable. - **`.server/`**: the server operator's own configuration and schema version. This is plaintext server metadata, not user data — it is the one thing under `{blob_root}` that is not an encrypted blob. ### What the shard does not decide diff --git a/capsule-docs/src/content/docs/design/licensing.md b/capsule-docs/src/content/docs/design/licensing.md index 6368a2f9..a962fdc8 100644 --- a/capsule-docs/src/content/docs/design/licensing.md +++ b/capsule-docs/src/content/docs/design/licensing.md @@ -76,7 +76,7 @@ Licence identity alone does not settle whether a binary is distributable. How th ### The video transcoder seam -`capsule-core::media::video::derivative` defines a `VideoTranscoder` seam with no implementation: core links no media toolchain, and `capsule-sdk` injects a per-platform one — ffmpeg, AVFoundation, or MediaCodec — per [Thumbnails — Video Previews](/design/thumbnails/#video-previews) and slice `S-B5`. It is the most likely route by which copyleft enters Capsule, so the constraint is written before the code: +`capsule-core::media::video::derivative` **will** define a `VideoTranscoder` seam with no implementation — `capsule-core::media` is on `capsule-docs/planned-modules.txt` and the seam retired to `legacy-review/media-pipeline/` with the rest of the stack in slice `S-C59`, so this section is a constraint on the rebuild rather than a description of the tree. Core links no media toolchain, and `capsule-sdk` injects a per-platform one — ffmpeg, AVFoundation, or MediaCodec — per [Thumbnails — Video Previews](/design/thumbnails/#video-previews) and slice `S-B5`. It is the most likely route by which copyleft enters Capsule, so the constraint is written before the code: - **Prefer the platform framework.** AVFoundation (Apple) and MediaCodec (Android) are OS-provided, carry no third-party licence, and are the sanctioned implementations on those platforms. - **ffmpeg, if used, must be an LGPL-2.1 base build.** Never `--enable-gpl`. Never `libx264` or `libx265` — both are GPL, and enabling either makes the whole ffmpeg binary GPL. diff --git a/capsule-docs/src/content/docs/design/mls-resilience.md b/capsule-docs/src/content/docs/design/mls-resilience.md index 68832df9..f35103dc 100644 --- a/capsule-docs/src/content/docs/design/mls-resilience.md +++ b/capsule-docs/src/content/docs/design/mls-resilience.md @@ -49,7 +49,7 @@ Across the failure modes above, Capsule's recovery posture is consistent: Reconciliation is a **single entry-point**, not per-failure-mode calls: the caller asks "bring me current" and the outcome enum reports what happened, including the two cases that escalate to user action or re-bootstrap. ```rust -// in capsule-core::crypto::mls +// in capsule-core::crypto::authority (OpenMlsAuthority) enum ReconcileOutcome { UpToDate, Reconciled { applied_commits: Vec }, @@ -57,7 +57,7 @@ enum ReconcileOutcome { Unrecoverable, // requires re-bootstrap } -fn reconcile_with_server(group: GroupId) -> Result; +fn reconcile_with_server(view: ServerChainView) -> Result; fn rekey_group(group: GroupId, reason: RekeyReason) -> Result<(), MlsError>; ``` diff --git a/capsule-docs/src/content/docs/design/module-map.md b/capsule-docs/src/content/docs/design/module-map.md index 0f50199e..fdea8fa0 100644 --- a/capsule-docs/src/content/docs/design/module-map.md +++ b/capsule-docs/src/content/docs/design/module-map.md @@ -90,6 +90,7 @@ separate typed ports; no generic CAS, transfer, or TTL library is introduced. | --- | --- | | REST client | Spargen-generated Rust from a checked-in OpenAPI 3.2 document | | SDK workflows | Capsule-owned authentication, upload, sync, recovery, and protocol-version orchestration | +| Namespaces an app links | Exactly two: `capsule_core_ffi` and `capsule_sdk`. `capsule-core-ffi` is the app umbrella staticlib and links `capsule-sdk`'s uniffi surface into itself, because two Rust staticlibs cannot share a binary — each bundles its own `std` — so any namespace an app needs must ride in that one. A third namespace exists, `capsule_core` behind `capsule-core/ffi`, and it never shares a binary with `capsule_sdk` (the `S-F1` invariant). Recorded as ADR-0005 in the repository's `adr/` directory | | Workspace verbs over FFI | The `capsule_sdk` UniFFI namespace exposes the workspace surface apps need — enroll/open (including a hardware-signer constructor), albums, seal and import, verify, sync-apply, master-key escrow, and device-directory publish. Orchestration and shape only: each verb is one call into `capsule-core`, which keeps every cryptographic step, and the `capsule_core` namespace never shares a binary with it | | Media | Rawshift performs detection, decode/encode, metadata normalization, derivatives, previews, and video work, consumed through `capsule-core::media` | | LQIP | Capsule imports Chromahash **0.7.1** directly; Rawshift has no Chromahash responsibility | diff --git a/capsule-server/src/bin/gen_openapi.rs b/capsule-server/src/bin/gen_openapi.rs index 11a40402..3fdfd3f0 100644 --- a/capsule-server/src/bin/gen_openapi.rs +++ b/capsule-server/src/bin/gen_openapi.rs @@ -14,12 +14,14 @@ //! the router purely to describe it. That is what lets `--check` run in the Rust check gate, //! exactly as `i18n-check` and the Salvo `openapi-check` do. //! -//! **This is not yet the SDK's contract.** `capsule-sdk` still generates from -//! `capsule-sdk/openapi.json`, the Salvo document, and the two are deliberately gated -//! separately while the port proceeds — committing both as *the* contract at once would leave -//! no way to say which one a client should believe. The changeover is its own step: it also -//! drops the four `spargen::OmitRule` narrowings, which exist only because the Salvo document is -//! structurally invalid in ways Kynos cannot express. +//! **This is the SDK's contract** (`S-C59`). There was a second document — `capsule-sdk` +//! generated from `capsule-sdk/openapi.json`, the Salvo one — and the two were gated separately +//! while the port proceeded, because committing both as *the* contract at once would have left +//! no way to say which a client should believe. The Salvo copy is deleted and +//! `capsule-sdk/build.rs` reads what this binary writes. The changeover did drop the four +//! `spargen::OmitRule` narrowings that existed because the Salvo document was structurally +//! invalid in ways Kynos cannot express; the four in `build.rs` today are a different set, for +//! a media type spargen cannot classify. //! //! Usage: //! - `gen_openapi [FILE]` writes the document (default `capsule-server/openapi.json`). diff --git a/capsule-server/src/lib.rs b/capsule-server/src/lib.rs index b021f1f8..55afe755 100644 --- a/capsule-server/src/lib.rs +++ b/capsule-server/src/lib.rs @@ -213,7 +213,7 @@ pub fn service(context: App) -> kynos::Result> { /// /// **3.2, pinned deliberately.** `Router::openapi()` would emit the *lowest* version that /// expresses the API without loss — a good default, and not what a committed contract wants. -/// `capsule-sdk/openapi.json` is checked in and generated from by spargen, so its version must +/// `capsule-server/openapi.json` is checked in and generated from by spargen, so its version must /// be a decision rather than a consequence: left to follow the API it would flip 3.1 → 3.2 the /// day the first streamed response lands, churning the schema gate and regenerating the client /// for a change nobody asked for. Kynos names this exact case — "reach for this when a diff --git a/capsule-server/src/routes/auth.rs b/capsule-server/src/routes/auth.rs index 277ec2c9..5e7a9fa3 100644 --- a/capsule-server/src/routes/auth.rs +++ b/capsule-server/src/routes/auth.rs @@ -6,8 +6,9 @@ //! //! # The status audit (`S-C28`) //! -//! `S-C28` found thirteen response variants across the Salvo surface that render a status -//! `capsule-sdk/openapi.json` never declares — `LoginResponses::undocumented()` returns +//! `S-C28` found thirteen response variants across the Salvo surface that render a status the +//! Salvo document never declared (it was committed as `capsule-sdk/openapi.json`, deleted with +//! the tree it described in `S-C59`) — `LoginResponses::undocumented()` returns //! `[423, 429]`. Kynos makes that class of defect unrepresentable, because the status *is* the //! return type and there is only one declaration. So each status was audited as its operation //! was ported, and the verdict lives in the type: diff --git a/capsule-server/tests/conformance.rs b/capsule-server/tests/conformance.rs index ad02ae89..95548dc9 100644 --- a/capsule-server/tests/conformance.rs +++ b/capsule-server/tests/conformance.rs @@ -4,7 +4,8 @@ //! These two assertions are opposites and both are needed. Together they are the executable //! form of the failure this rebuild exists to remove: the Salvo surface had **thirteen response //! variants that rendered a status the published schema never declared** (slice `S-C28`) — -//! login could answer `423` and `429`, and `capsule-sdk/openapi.json` mentioned neither, so the +//! login could answer `423` and `429`, and the Salvo document (then committed as +//! `capsule-sdk/openapi.json`, deleted in `S-C59`) mentioned neither, so the //! generated client had no case to map them to. That gap was invisible because it lived between //! two hand-written impls, one rendering and one registering, with nothing comparing them. //! From 986ffa56e0d44714fd3c56afcc9e4557f3d633ab Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 20:57:32 -0400 Subject: [PATCH 014/243] refactor(xtask,i18n): one CLDR plural table, not two MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `xtask` held its own copy of which categories each language can select. It predates the runtime having any rules at all, and the moment the runtime gained them the two tables had to agree about thirteen locales — the shape that had already produced this slice's bug once. `android_plural_arms` and its test now read `capsule_i18n::plural::selectable`. The generator keeps failing loudly on a language with no rules, which is why `selectable` answers `None` rather than "only `other`": the runtime is happy to fall back, the generator must not guess. Two tests close the loop the merge opens. `the_selectable_set_is_exactly_what_selection_can_produce` pins both directions of every row: a category listed but unreachable is a dead `` the generator would emit, and a category reachable but unlisted is one it would drop and the runtime would then ask for. Russian is the interesting row — it never selects `other` for an integer, since its `other` is for fractions, yet every plural resource must carry the arm. `the_runtime_renders_what_the_android_resource_carries` is the agreement nothing checked before: over the real catalogs, in every locale, at the CLDR boundary counts, the sentence the runtime builds by substituting `#` equals the sentence Android builds from the `%d` format string the generator lowered — 1048 comparisons, in Android's own escaped spelling so `android_escape` is compared against itself. --- Cargo.lock | 1 + capsule-i18n/src/plural.rs | 36 ++++--- xtask/Cargo.toml | 6 ++ xtask/src/i18n.rs | 193 ++++++++++++++++++++++++------------- 4 files changed, 156 insertions(+), 80 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index af416a67..7682b28e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7232,6 +7232,7 @@ version = "0.1.0" dependencies = [ "base64", "capsule-core", + "capsule-i18n", "eyre", "hex", "regex", diff --git a/capsule-i18n/src/plural.rs b/capsule-i18n/src/plural.rs index ff7a4e1b..7d38f6db 100644 --- a/capsule-i18n/src/plural.rs +++ b/capsule-i18n/src/plural.rs @@ -388,24 +388,30 @@ mod tests { } #[test] - fn selection_never_leaves_the_selectable_set() { - // The property the generator relies on: an arm it drops as unreachable is one the - // runtime will never ask for. Asserted over the whole boundary range plus the - // millions, so the two tables cannot drift apart silently. + fn the_selectable_set_is_exactly_what_selection_can_produce() { + // The two halves of a row must agree in both directions, because both are load + // bearing: `xtask i18n` drops an `` the language cannot select, + // and the runtime picks the arm. A category listed but unreachable is a dead arm + // the generator would emit; a category reachable but unlisted is an arm it would + // drop and the runtime would then ask for. + // + // `other` is the exception in one direction only: Russian never selects it for an + // *integer* (its `other` is for fractions), yet every plural resource must carry + // it, so it is always listed. for language in LANGUAGES { let selectable = selectable(language).expect("a shipped language has rules"); assert!(selectable.contains(&Category::Other), "{language}"); - let mut sorted = selectable.to_vec(); - sorted.sort_unstable(); - sorted.dedup(); - assert_eq!(sorted, selectable, "{language} is not in CLDR order"); - for n in (0i64..=2000).chain([999_999, 1_000_000, 2_000_000, i64::MAX]) { - let picked = category(language, n); - assert!( - selectable.contains(&picked), - "{language} selected `{picked}` at n={n}, which it lists as unselectable" - ); - } + let mut reachable: Vec = (0i64..=2000) + .chain([999_999, 1_000_000, 2_000_000, i64::MAX]) + .map(|n| category(language, n)) + .collect(); + reachable.push(Category::Other); + reachable.sort_unstable(); + reachable.dedup(); + assert_eq!( + reachable, selectable, + "{language}: the rules and the selectable set disagree" + ); } } diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 59e58657..18639761 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -9,6 +9,12 @@ publish.workspace = true # capsule-core (host, default features) seals the cross-language share-link KAT fixture # (`share-kat`) with the exact issuer encapsulation the browser (`capsule-wasm`) reopens. capsule-core = { path = "../capsule-core" } +# capsule-i18n owns the CLDR cardinal plural rules. The generator needs the same table the +# runtime selects with — which categories a language can select decides which +# `` arms Android may carry — and two copies of it in two crates is what +# `S-I7` found. The generator writes this crate's bundles; the committed output means there +# is no bootstrap problem, and the dependency is on hand-written code, not generated code. +capsule-i18n = { path = "../capsule-i18n" } base64 = { workspace = true } eyre = { workspace = true } hex = { workspace = true } diff --git a/xtask/src/i18n.rs b/xtask/src/i18n.rs index 1cef9fd6..c2c543cf 100644 --- a/xtask/src/i18n.rs +++ b/xtask/src/i18n.rs @@ -29,6 +29,7 @@ use std::fmt::Write as _; use std::fs; use std::path::{Path, PathBuf}; +use capsule_i18n::plural; use eyre::{Context, ContextCompat, Result, bail}; use serde_json::{Map, Value}; @@ -388,33 +389,6 @@ const INFO_PLIST_KEYS: &[(&str, &str)] = &[ /// reaching a file. const PLURAL_CATEGORIES: &[&str] = &["zero", "one", "two", "few", "many", "other"]; -/// The CLDR cardinal categories each language can actually *select*, by language subtag. -/// -/// The vocabulary above is universal; the rules are not. English never selects `few`, -/// Japanese never selects anything but `other`, Arabic selects all six. Android resolves -/// `` with the platform's own CLDR rules, so an arm the language cannot -/// select is unreachable — [`android_plural_arms`] drops it rather than emitting a dead -/// resource the platform's `UnusedQuantity` lint flags. `other` is selectable everywhere -/// and [`IcuPlural::parse`] already requires it, so dropping is always lossless. -/// -/// Adding a locale to `locales/config.json` means adding its row here: an unknown -/// language fails the generator the first time it carries a plural rather than guessing -/// a rule set. -const PLURAL_RULES: &[(&str, &[&str])] = &[ - ("ar", &["zero", "one", "two", "few", "many", "other"]), - ("de", &["one", "other"]), - ("en", &["one", "other"]), - ("es", &["one", "many", "other"]), - ("fr", &["one", "many", "other"]), - ("hi", &["one", "other"]), - ("it", &["one", "many", "other"]), - ("ja", &["other"]), - ("ko", &["other"]), - ("pt", &["one", "many", "other"]), - ("ru", &["one", "few", "many", "other"]), - ("zh", &["other"]), -]; - /// What an ICU argument holds, which decides its conversion in a format string. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ArgKind { @@ -874,24 +848,25 @@ fn android_name(key: &str) -> String { /// a `one` in Japanese changes nothing a user sees and trips the platform's /// `UnusedQuantity` lint. `other` is selectable in every language and required by /// [`IcuPlural::parse`], so what is left is always a complete resource. +/// +/// The rules come from [`capsule_i18n::plural`], which is also what the Rust runtime +/// selects with. They used to be a second table in this file; `S-I7` gave the runtime +/// plural evaluation and the two immediately had to agree, so there is one table. A +/// language with no row there fails the generator rather than guessing a rule set. fn android_plural_arms<'a>( locale: &str, categories: &'a BTreeMap, ) -> Result> { let language = locale.split('-').next().unwrap_or(locale); - let selectable = PLURAL_RULES - .iter() - .find(|(lang, _)| *lang == language) - .map(|(_, cats)| *cats) - .with_context(|| { - format!( - "no CLDR plural rules for language `{language}`: add a `PLURAL_RULES` row \ - before a `{locale}` message uses a plural" - ) - })?; + let selectable = plural::selectable(locale).with_context(|| { + format!( + "no CLDR plural rules for language `{language}`: add a row to \ + `capsule_i18n::plural` before a `{locale}` message uses a plural" + ) + })?; let arms: Vec<(&'static str, &str)> = PLURAL_CATEGORIES .iter() - .filter(|category| selectable.contains(*category)) + .filter(|category| selectable.iter().any(|c| c.as_str() == **category)) .filter_map(|category| Some((*category, categories.get(*category)?.as_str()))) .collect(); if !arms.iter().any(|(category, _)| *category == "other") { @@ -1243,7 +1218,7 @@ mod tests { // 6. a literal `%` is escaped in a formatted value, left alone in a bare one // 7. an arm the locale cannot select is dropped (`one` in Japanese) // 8. ... and likewise `few` authored for English - // 9. a locale with no `PLURAL_RULES` row fails rather than guessing + // 9. a locale with no `capsule_i18n::plural` row fails rather than guessing // 10. embedded plural, `select`, and `offset:` fail the renderer // 11. Android quoting: apostrophe, quote, backslash, XML, a leading `@`/`?` // 12. end to end: no committed Android value contains ICU syntax @@ -1417,7 +1392,10 @@ mod tests { let err = catalogs .android_xml("xx", map) .expect_err("an unknown language has no rules to filter by"); - assert!(format!("{err:#}").contains("PLURAL_RULES"), "{err:#}"); + assert!( + format!("{err:#}").contains("capsule_i18n::plural"), + "{err:#}" + ); } #[test] @@ -1519,35 +1497,120 @@ mod tests { } } + /// The `quantity` attribute of every `` in one rendered `strings.xml`. + fn android_quantities(xml: &str) -> Vec<&str> { + xml.lines() + .filter(|line| line.contains(" String { + let dir = path + .parent() + .and_then(Path::file_name) + .map(|d| d.to_string_lossy().into_owned()) + .expect("a values directory"); + // `values/` is the source locale; `values-/` names its own. + dir.strip_prefix("values-") + .map_or_else(|| "en".to_string(), android_locale) + } + + /// The `` arms of one `` block in a rendered + /// `strings.xml`, keyed by quantity. + fn android_plural_block(xml: &str, name: &str) -> BTreeMap { + let mut arms = BTreeMap::new(); + let open = format!(""); + let Some(start) = xml.find(&open) else { + return arms; + }; + for line in xml[start..].lines().skip(1) { + if line.contains("") { + break; + } + let Some((quantity, rest)) = line + .split_once("quantity=\"") + .and_then(|(_, rest)| rest.split_once("\">")) + else { + continue; + }; + let body = rest.trim_end().trim_end_matches(""); + arms.insert(quantity.to_string(), body.to_string()); + } + arms + } + + /// The Rust runtime renders exactly the string the Android resource carries. + /// + /// The agreement that matters, and the one nothing checked before: the generator + /// lowers an ICU arm into a `%d` format string ahead of time, the runtime substitutes + /// `#` at display time, and the two are supposed to produce the same sentence. Run + /// over the **real** catalogs, in every locale, at the CLDR boundary counts, with the + /// arm picked by the same rules Android will pick it with — so a divergence in either + /// the lowering or the runtime's substitution fails here. + /// + /// Compared in Android's escaped spelling rather than un-escaping the resource: + /// `android_escape` is the function under test on that side, so applying it to the + /// runtime's output compares like with like. + #[test] + fn the_runtime_renders_what_the_android_resource_carries() { + let catalogs = Catalogs::load(&repo_root()).expect("the real catalogs load"); + let rendered: BTreeMap = rendered_android() + .into_iter() + .map(|(path, xml)| (android_path_locale(&path), xml)) + .collect(); + let counts: [i64; 8] = [0, 1, 2, 3, 5, 11, 21, 101]; + let mut compared = 0usize; + + for (locale, messages) in &catalogs.messages { + let xml = rendered.get(locale).expect("every locale renders"); + let bundle = capsule_i18n::Bundle::for_locale(locale); + for (key, message) in messages { + if !message.contains("plural,") { + continue; + } + let arms = android_plural_block(xml, &android_name(key)); + assert!(!arms.is_empty(), "{locale}/{key}: no block"); + for n in counts { + let category = plural::category(locale, n).as_str(); + let arm = arms + .get(category) + .or_else(|| arms.get("other")) + .expect("every plural resource carries `other`"); + // Single-argument plurals use the unnumbered specifier; a message with + // more arguments numbers them. `%%` is Android's escaped literal `%`. + let expected = arm + .replacen("%1$d", &n.to_string(), 1) + .replacen("%d", &n.to_string(), 1) + .replace("%%", "%"); + let runtime = bundle.format(key, &[("count", capsule_i18n::Value::Int(n))]); + assert_eq!( + android_escape(&runtime), + expected, + "{locale}/{key} at n={n}: the runtime and `strings.xml` disagree" + ); + compared += 1; + } + } + } + assert!(compared > 1000, "only {compared} renderings were compared"); + } + #[test] fn every_emitted_quantity_is_selectable_in_its_locale() { - let source = "en".to_string(); for (path, xml) in &rendered_android() { - // `values/` is the source locale; `values-/` names its own. - let dir = path - .parent() - .and_then(Path::file_name) - .map(|d| d.to_string_lossy().into_owned()) - .expect("a values directory"); - let locale = dir - .strip_prefix("values-") - .map_or_else(|| source.clone(), android_locale); - let language = locale.split('-').next().unwrap_or(&locale); - let selectable = PLURAL_RULES - .iter() - .find(|(lang, _)| *lang == language) - .map_or_else( - || panic!("{}: no PLURAL_RULES row", path.display()), - |(_, cats)| *cats, - ); - for line in xml.lines().filter(|l| l.contains(" Date: Tue, 1 Sep 2026 20:57:58 -0400 Subject: [PATCH 015/243] fix(xtask): make swift-computed-property catch its own documented example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detector's doc comment gives `case .places: "Places"` as the shape it was added to catch. Its literal pattern required a capital followed by a space, so that exact string — and every other single-word display string — could not be caught (#394). The space was there to keep `case .heic: "HEIC"` out; requiring a **lowercase letter** in position two does the same job and lets one capitalized word through. The trade is that prose whose second character is neither lowercase nor a space ("E-mail sent", "AI Insights") stops being caught. Measured across `capsule-swift/{App,Modules}` in every position this detector scans: zero strings are lost by the swap, one is gained. Four blind spots #394 lists close with it: - Only `case .foo:` inside the body was scanned. A literal returned explicitly, returned implicitly, or held as a dictionary value was invisible. All four shapes are scanned, keyed by absolute offset so an overlap is one finding. - Six property-name stems matched; `heading`, `text`, `summary` and `prompt` now do too. `value` deliberately still does not — `var rawValue: String` returns an identifier two dozen times in `CapsuleDomain`. - `var` was required, so `func hdrName(_:) -> String` was outside the gate. - `confirmationDialog` was watched by the interpolation regex and not the literal one, and `help`, `searchable`, `accessibilityValue`, `ContentUnavailableView` and `tabItem` by neither. Both regexes now build from one shared list, so they cannot disagree again. The widening finds exactly one string in the tree: "Dolby Vision", in `AssetInfoFormatting.hdrName(_:)`, whose own doc says it spells an HDR encoding "as its owner spells it". It is a trademark, spelled identically in every locale — the allowlist's stated category, and the same reason its sibling arms "HDR10" and "HLG" never tripped the gate. One allowlist line with its justification; no Swift source is touched. --- locales/i18n-guard-allowlist.txt | 8 + xtask/src/i18n_guard.rs | 290 +++++++++++++++++++++++++------ 2 files changed, 242 insertions(+), 56 deletions(-) diff --git a/locales/i18n-guard-allowlist.txt b/locales/i18n-guard-allowlist.txt index 0b38da9b..ad9e2bed 100644 --- a/locales/i18n-guard-allowlist.txt +++ b/locales/i18n-guard-allowlist.txt @@ -32,6 +32,14 @@ # the `…Key:` argument label and cannot tell the two apart. capsule-swift/Modules/ImagePipeline/Sources/ViewerMediaLoader.swift fileSize +# `AssetInfoFormatting.hdrName(_:)` names an HDR encoding "as its owner spells it" (its own +# doc comment). "Dolby Vision" is a Dolby trademark, spelled the same in every locale — the +# allowlist's stated category, and the same reason the sibling arms "HDR10" and "HLG" never +# tripped the guard. Surfaced when `S-I7` widened `swift-computed-property` to `func` +# members; the alternative would be a `locales/` key whose thirteen translations are all +# the identical trademark. +capsule-swift/Modules/FeatureViewer/Sources/Info/AssetInfoFormatting.swift Dolby Vision + # `capsule demo` — the offline showcase command (`S-A10`). Every one of these is a # terse `eyre!` step label ("shamir: {e}") written for whoever is watching the demo run, # not a message a photo-library user would ever hit. It is the weakest case for diff --git a/xtask/src/i18n_guard.rs b/xtask/src/i18n_guard.rs index 404795d3..77e04c2f 100644 --- a/xtask/src/i18n_guard.rs +++ b/xtask/src/i18n_guard.rs @@ -77,7 +77,7 @@ //! (zero findings on the migrated tree; an injected literal is caught) run without //! disk I/O. -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::path::{Path, PathBuf}; use std::sync::OnceLock; @@ -306,6 +306,40 @@ fn normalize_ws(s: &str) -> String { s.split_whitespace().collect::>().join(" ") } +/// The SwiftUI call and modifier positions whose string argument reaches the screen. +/// +/// One list, shared by the plain-literal and the interpolated detectors, because a +/// position watched by one and not the other is a hole nobody notices: `confirmationDialog` +/// was in the interpolation regex and not the literal one for two slices (#394). The +/// leading word boundary at each use keeps a helper that merely *ends* in one of these +/// (`barButton("sf.symbol.name")`) out. +const SWIFT_TEXT_POSITIONS: &str = "Text|Label|Button|Section|Toggle|navigationTitle\ +|accessibilityLabel|accessibilityHint|accessibilityValue|alert|confirmationDialog|help\ +|searchable|ContentUnavailableView|tabItem"; + +/// The property and function name stems that mark a `String` member as display text. +/// +/// `value` is deliberately absent: `var rawValue: String` appears two dozen times in +/// `capsule-swift/Modules/CapsuleDomain/Sources/` returning identifiers, none of which is +/// display text. +const SWIFT_DISPLAY_STEMS: &str = "title|message|label|name|description|subtitle\ +|heading|text|summary|prompt"; + +/// A display-text literal: a capital followed by a **lowercase letter**. +/// +/// This is the rule #394 was filed about. It used to be "a capital, then a space +/// somewhere", which excluded every single-word string — including `case .places: +/// "Places"`, the example the detector's own doc comment gave as the shape it catches. +/// Requiring a lowercase letter in position two keeps out exactly what the space was +/// there to keep out (`"HEIC"`, `"HDR10"`, `"HLG"`, an SF Symbol name like `"key.fill"`), +/// and lets a single capitalized word through. +/// +/// The trade: prose whose second character is neither lowercase nor a space +/// (`"E-mail sent"`, `"AI Insights"`) is no longer caught. Measured across +/// `capsule-swift/{App,Modules}` at the time of the change, in every position this +/// detector scans: **zero** strings are lost and one is gained. +const SWIFT_DISPLAY_LITERAL: &str = r#"[A-Z][a-z][^"\\]*"#; + /// Detect hardcoded user-facing strings in a SwiftUI source: plain literals in a watched /// API position, *interpolated* literals in the same positions, and the key argument of /// `String(localized:)`. @@ -320,19 +354,17 @@ pub(crate) fn swift_findings(content: &str) -> Vec { /// Detect string-literal arguments to user-facing SwiftUI APIs. fn swift_literal_findings(content: &str) -> Vec { static RE: OnceLock = OnceLock::new(); - // `Text("…")`, `.navigationTitle("…")`, `Label("…", …)`, `Button("…", …)`, - // `Section("…")`, `.accessibilityLabel("…")`, `Toggle("…", …)`, `.alert("…", …)`. - // The leading `(?:^|[^A-Za-z0-9_])` is a word boundary so helper names that - // merely END in one of these (`barButton("sf.symbol.name")`) don't match; a - // leading `.` (method syntax) is still allowed. The `"` must immediately follow - // `(` so `Text(verbatim: "…")` and `Text(dynamicVar)` are not matched. - // `[^"\\]*` keeps it to simple literals — interpolations contain `\(` and are - // skipped (documented blind spot; ICU-argument catalog support for Swift is a - // follow-up). + // `Text("…")`, `.navigationTitle("…")`, `Label("…", …)` and the rest of + // [`SWIFT_TEXT_POSITIONS`]. The leading `(?:^|[^A-Za-z0-9_])` is a word boundary so + // helper names that merely END in one of these (`barButton("sf.symbol.name")`) don't + // match; a leading `.` (method syntax) is still allowed. The `"` must immediately + // follow `(` so `Text(verbatim: "…")` and `Text(dynamicVar)` are not matched. + // `[^"\\]*` keeps it to simple literals — an interpolation contains `\(` and is + // caught by [`swift_interpolation_findings`] instead. let re = RE.get_or_init(|| { - Regex::new( - r#"(?:^|[^A-Za-z0-9_])(?:Text|Label|Button|Section|Toggle|navigationTitle|accessibilityLabel|accessibilityHint|alert)\(\s*"([^"\\]*[A-Za-z][^"\\]*)""#, - ) + Regex::new(&format!( + r#"(?:^|[^A-Za-z0-9_])(?:{SWIFT_TEXT_POSITIONS})\(\s*"([^"\\]*[A-Za-z][^"\\]*)""# + )) .expect("static regex is valid") }); let mut findings = matched_findings(content, re, "swift-literal"); @@ -368,10 +400,10 @@ fn swift_key_parameter_findings(content: &str) -> Vec { matched_findings(content, re, "swift-key-param") } -/// Detect display text returned from a `String`-typed computed property. +/// Detect display text returned from a `String`-typed computed property or function. /// /// The blind spot that hid twenty-two English strings from this gate. A view -/// that writes `Text("Places")` is caught by ``swift_findings``; a view that +/// that writes `Text("Places")` is caught by [`swift_literal_findings`]; a view that /// writes `Text(category.title)` is not, and neither is the property behind it: /// /// ```swift @@ -386,46 +418,80 @@ fn swift_key_parameter_findings(content: &str) -> Vec { /// it and no argument label ends in `Key`. Non-English users read those in /// English, and the gate reported zero findings the whole time. /// -/// Scoped to `case .foo: "Bar"` inside a property named like display text -/// (`title`, `message`, `label`, `name`, `description`, `subtitle`) — the shape -/// that actually produced the bug. A `String` property returning a symbol name -/// or a raw value is not display text and must not be flagged, which is why the -/// literal must also start with a capital and contain a space *or* be a known -/// display-ish word: `case .heic: "HEIC"` is a file format, not a sentence. +/// # What is scanned +/// +/// A member whose name ends in one of [`SWIFT_DISPLAY_STEMS`] and whose type is +/// `String` — a `var`, or (since #394) a `func` such as `hdrName(_:) -> String`. Inside +/// its body, four literal positions, because a property returns display text in more +/// shapes than a `switch`: a `case` arm, an explicit `return`, a bare literal on its own +/// line (an implicit return, or an `if` branch), and a dictionary value. Hits are keyed by +/// absolute offset, so a literal two positions both match is reported once. +/// +/// A `String` member is not automatically display text — a symbol name or a raw value is +/// not — which is what [`SWIFT_DISPLAY_LITERAL`] filters on. Its history is #394: the rule +/// used to require a space, which excluded the single-word example this doc comment gives. fn swift_computed_property_findings(content: &str) -> Vec { - static PROPERTY: OnceLock = OnceLock::new(); - static CASE: OnceLock = OnceLock::new(); - let property = PROPERTY.get_or_init(|| { - Regex::new( - r"var\s+[A-Za-z]*(?i:title|message|label|name|description|subtitle)\s*:\s*String\s*\{", - ) - .expect("static regex is valid") + static MEMBERS: OnceLock> = OnceLock::new(); + static LITERALS: OnceLock> = OnceLock::new(); + let members = MEMBERS.get_or_init(|| { + [ + // `var displayName: String {` — the stem ends the name. + format!(r"var\s+[A-Za-z]*(?i:{SWIFT_DISPLAY_STEMS})\s*:\s*String\s*\{{"), + // `static func hdrName(_ format: HDRFormat) -> String {` — the stem may sit + // anywhere in the name, since a function reads `label(for:)` as often as + // `formattedLabel()`. + format!( + r"func\s+[A-Za-z]*(?i:{SWIFT_DISPLAY_STEMS})[A-Za-z]*\s*\([^)]*\)\s*->\s*String\s*\{{" + ), + ] + .iter() + .map(|pattern| Regex::new(pattern).expect("static regex is valid")) + .collect() }); - // `case .foo: "Some words"` — a capital, then a space, so an acronym or an - // identifier-like token does not match. - let case = CASE.get_or_init(|| { - Regex::new(r#"case\s+\.[A-Za-z0-9_]+:\s*"([A-Z][^"\\]*\s[^"\\]*)""#) - .expect("static regex is valid") + let literals = LITERALS.get_or_init(|| { + [ + // `case .places: "Places"` + format!(r#"case\s+\.[A-Za-z0-9_]+:\s*"({SWIFT_DISPLAY_LITERAL})""#), + // `return "Places"` + format!(r#"return\s+"({SWIFT_DISPLAY_LITERAL})""#), + // A bare literal statement: an implicit return, or an `if`/`else` branch. + format!(r#"(?m)^\s*"({SWIFT_DISPLAY_LITERAL})"\s*$"#), + // A dictionary or array value: `[.places: "Places"]`. + format!(r#":\s*"({SWIFT_DISPLAY_LITERAL})"\s*[,\]]"#), + ] + .iter() + .map(|pattern| Regex::new(pattern).expect("static regex is valid")) + .collect() }); - let mut findings = Vec::new(); - for property_match in property.find_iter(content) { - let open = property_match.end() - 1; - let Some(body) = brace_body(content, open) else { - continue; - }; - for capture in case.captures_iter(body) { - let group = capture.get(1).expect("group 1 exists"); - findings.push(Finding { - // Offsets are into `body`, which starts one byte past the - // brace — so the absolute position is that plus the local one. - line: line_of(content, open + 1 + group.start()), - text: group.as_str().to_string(), - kind: "swift-computed-property", - }); + // Keyed by absolute offset: the dictionary and `case` patterns overlap, and a member + // nested inside another member's body is scanned twice. Either way the literal is one + // finding, and `BTreeMap` also puts them back in source order. + let mut hits: BTreeMap = BTreeMap::new(); + for member in members { + for member_match in member.find_iter(content) { + let open = member_match.end() - 1; + let Some(body) = brace_body(content, open) else { + continue; + }; + for literal in literals { + for capture in literal.captures_iter(body) { + let group = capture.get(1).expect("group 1 exists"); + // Offsets are into `body`, which starts one byte past the + // brace — so the absolute position is that plus the local one. + hits.entry(open + 1 + group.start()) + .or_insert_with(|| group.as_str().to_string()); + } + } } } - findings + hits.into_iter() + .map(|(offset, text)| Finding { + line: line_of(content, offset), + text, + kind: "swift-computed-property", + }) + .collect() } /// The text between the brace at `open` and its match, or `None` if unbalanced. @@ -459,9 +525,9 @@ fn brace_body(content: &str, open: usize) -> Option<&str> { fn swift_interpolation_findings(content: &str) -> Vec { static RE: OnceLock = OnceLock::new(); let re = RE.get_or_init(|| { - Regex::new( - r#"(?:^|[^A-Za-z0-9_])(?:Text|Label|Button|Section|Toggle|navigationTitle|accessibilityLabel|accessibilityHint|alert|confirmationDialog)\(\s*"([^"]*\\\([^"]*)""#, - ) + Regex::new(&format!( + r#"(?:^|[^A-Za-z0-9_])(?:{SWIFT_TEXT_POSITIONS})\(\s*"([^"]*\\\([^"]*)""# + )) .expect("static regex is valid") }); matched_findings(content, re, "swift-interpolation") @@ -1053,14 +1119,18 @@ mod tests { #[test] fn swift_computed_properties_ignore_identifier_like_values() { - // A `String` property is not automatically display text. Symbol names, - // raw values and file formats are single tokens; requiring a space is - // what separates a sentence from an identifier. + // A `String` property is not automatically display text. Symbol names, raw values + // and file formats do not spell a lowercase letter in position two; a `String` + // property that is not named like display text is not scanned at all. let src = r#" var title: String { switch self { case .heic: "HEIC" case .dng: "DNG" + case .hdr10: "HDR10" + case .hlg: "HLG" + case .photo: "app.media.photo" + case .masterKey: "key.fill" } } var systemImage: String { @@ -1069,7 +1139,115 @@ mod tests { } } "#; - assert!(swift_findings(src).is_empty()); + assert_eq!(swift_findings(src), Vec::new()); + } + + #[test] + fn swift_computed_properties_catch_a_single_word_string() { + // Issue #394: the detector's own documented example. The literal rule used to + // require a space, so `case .places: "Places"` — the exact shape the doc comment + // advertises as the motivating bug — could not be caught. + let src = r#" + var title: String { + switch self { + case .places: "Places" + case .people: "People" + } + } + "#; + let texts: Vec = swift_findings(src).into_iter().map(|f| f.text).collect(); + assert_eq!(texts, vec!["Places".to_string(), "People".to_string()]); + } + + #[test] + fn swift_display_functions_are_scanned() { + // `var` was required, so a `func` returning display text was invisible — the + // blind spot that hid "Dolby Vision" in `AssetInfoFormatting.hdrName(_:)`. + let src = r#" + static func hdrName(_ format: HDRFormat) -> String { + switch format { + case .hdr10: "HDR10" + case .dolbyVision: "Dolby Vision" + case .hlg: "HLG" + } + } + "#; + let texts: Vec = swift_findings(src).into_iter().map(|f| f.text).collect(); + assert_eq!(texts, vec!["Dolby Vision".to_string()]); + } + + #[test] + fn swift_display_members_are_scanned_beyond_the_switch() { + // A property returns display text in more shapes than a `switch`: an explicit + // `return`, an implicit one, and a dictionary value. Only `case` was scanned. + // `heading`, `summary` and `text` are also new stems — `var heading` was not + // matched at all before. + let src = r#" + var heading: String { + if isEmpty { return "Nothing here yet" } + "Your library" + } + var summary: String { + let names: [Kind: String] = [.places: "Places and trips"] + return names[kind] ?? "" + } + var promptText: String { + "Choose an album" + } + "#; + let texts: Vec = swift_findings(src).into_iter().map(|f| f.text).collect(); + assert_eq!( + texts, + vec![ + "Nothing here yet".to_string(), + "Your library".to_string(), + "Places and trips".to_string(), + "Choose an album".to_string(), + ] + ); + } + + #[test] + fn swift_display_members_do_not_report_a_literal_twice() { + // A `func` nested in a `var` body is scanned by both members, and the dictionary + // and `case` patterns overlap. Findings are keyed by absolute offset, so the + // literal is one violation, not two. + let src = r#" + var title: String { + func headingFor(_ k: Kind) -> String { + return "Places and trips" + } + return headingFor(kind) + } + "#; + let findings = swift_findings(src); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert_eq!(findings[0].text, "Places and trips"); + } + + #[test] + fn swift_watches_every_api_position_in_both_regexes() { + // `confirmationDialog` was in the interpolation regex and not the literal one; + // `help`, `accessibilityValue`, `ContentUnavailableView` and `tabItem` were in + // neither. One shared list now, so the two cannot disagree again. + let src = r#" + .help("Show the import log") + ContentUnavailableView("No photos yet", systemImage: "photo") + .accessibilityValue("Three of ten") + .confirmationDialog("Delete this?", isPresented: $flag) {} + .help("Imported \(count) files") + "#; + let f = swift_findings(src); + let t = texts(&f); + for expected in [ + "Show the import log", + "No photos yet", + "Three of ten", + "Delete this?", + ] { + assert!(t.contains(&expected), "{expected} missing from {t:?}"); + } + assert!(t.contains(&r"Imported \(count) files"), "{t:?}"); } #[test] From 1cac73b115376eef66904964b60ac781c9ba41c0 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 20:59:58 -0400 Subject: [PATCH 016/243] docs(adr): record the three decisions this programme is taking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each was already being acted on and was written down nowhere a reader could find it, which is the condition `adr/README.md` exists to end. ADR-0004 retires `capsule-wire`. It was extracted so the response taxonomy could outlive the framework, and Kynos removes the defect it was extracted to fix: the status is part of the return type, there is one declaration, and `conformance.rs` asserts both directions of the agreement. The server owns `problem`, `limits` and `body`; the only occurrence of `capsule_wire` outside the crate is a prose reference in a module comment; a third of the crate adapts a framework the workspace no longer has. `proposed`, with no `Contract:` line: the crate is still a workspace member and still a dependency of `capsule-server`, and #400 lands it. ADR-0005 records that an app links exactly the `capsule_core_ffi` + `capsule_sdk` pair. Three namespaces exist and the number reads as three things an app links; it is not, because two Rust staticlibs cannot share a binary. `accepted`, and `module-map.md` gains the sentence its `Contract:` line points at. ADR-0006 records the server's runtime shape — one binary, one configuration loader, three adapters per typed port, no generic TTL or CAS abstraction. `proposed`: `src/bin/` holds only `gen_openapi.rs`, `src/store/` holds only the in-memory double, and nothing reads a configuration variable. #401-#403 land it. The filename says "three adapters per port" rather than "three adapter pairs": `store/mod.rs` declares six typed stores, not two. --- adr/0004-capsule-wire-is-retired.md | 67 ++++++++++++++++ ...-uniffi-namespace-pair-reaches-the-apps.md | 65 +++++++++++++++ ...with-config-and-three-adapters-per-port.md | 80 +++++++++++++++++++ 3 files changed, 212 insertions(+) create mode 100644 adr/0004-capsule-wire-is-retired.md create mode 100644 adr/0005-one-uniffi-namespace-pair-reaches-the-apps.md create mode 100644 adr/0006-the-server-is-a-binary-with-config-and-three-adapters-per-port.md diff --git a/adr/0004-capsule-wire-is-retired.md b/adr/0004-capsule-wire-is-retired.md new file mode 100644 index 00000000..44012145 --- /dev/null +++ b/adr/0004-capsule-wire-is-retired.md @@ -0,0 +1,67 @@ +# ADR-0004 — `capsule-wire` is retired once no member depends on it + +- **Status:** proposed +- **Date:** 2026-09-01 +- **Supersedes:** — +- **Superseded by:** — +- **Slices:** S-C27, S-C59 + +## Context + +`capsule-wire` exists because the Salvo server's response taxonomy — which outcome carries +which status, which body, which published sentence — lived only as framework trait impls, +written twice per response enum (once to render it, once to document it) with nothing +keeping the two halves in agreement. That made the transport load-bearing: the contract +could not outlive the framework. `S-C27` extracted the taxonomy as plain data +(`ResponseSpec`, `BodyShape`, `WireResponses`) plus the protocol headers, depending on +nothing but `serde`, and generated the framework impls from it. The crate's own module +comment names the goal: "the piece of the server that survives the transport swap +unchanged". + +The transport swapped, and the crate did not survive it in the way the extraction +anticipated. Three facts in the tree say so: + +- **Kynos removes the defect the crate was extracted to fix.** In Kynos the status *is* + part of the return type and there is one declaration, so the two halves cannot disagree. + `capsule-server/tests/conformance.rs` asserts both directions of that agreement against + the emitted document rather than against a hand-kept table. +- **The server owns the taxonomy now.** `capsule-server::problem`, `::limits` and `::body` + carry coded-problem bodies, body-size limits and the header census on every route — the + Server Modules table in `design/module-map.md` lists them there. +- **Nothing links it.** `capsule-server/Cargo.toml` declares `capsule-wire` as a path + dependency, and the only occurrence of `capsule_wire` anywhere in the workspace outside + the crate itself is one prose reference in `capsule-server/src/lib.rs`'s module comment. + Meanwhile `capsule-wire/src/salvo_adapter.rs` — a third of the crate — generates impls + for a framework `S-C59` removed from the workspace. + +## Decision + +`capsule-wire` is retired. The header constants and any part of the taxonomy the server +still needs move into `capsule-server`, which is where the rest of it already lives; the +Salvo adapter goes with the framework it adapts; the crate leaves `[workspace] members` +and `xtask`'s architecture check gains it as a retired dependency so it cannot return. + +The retirement lands when no workspace member depends on it, not before — a crate that is +still in a manifest is still in the build graph, whatever its call sites say. + +## Consequences + +- The protocol-header contract has one home, `capsule-server::problem` and its siblings, + rather than two with an unenforced agreement between them. +- `S-C27` closes as a design that did its job and is no longer needed, rather than as a + design that failed. Extracting the taxonomy is what made the Kynos port a port rather + than a rewrite. +- One fewer workspace member, and one fewer manifest edge that `architecture-check` has to + reason about. +- A future transport swap loses the framework-free layer this crate provided. That is + accepted: Kynos's own contract — one declaration, checked by `conformance.rs` — is a + stronger guarantee than a second crate holding a copy of the table. + +## Considered and rejected + +- **Keeping the crate and deleting only `salvo_adapter.rs`.** That leaves a crate whose + only consumer is a doc comment. A dependency nothing calls is a maintenance cost with no + reader. +- **Moving the taxonomy into `capsule-sdk` so the client owns it.** The taxonomy is a + statement about what the *server* sends. Putting it on the client side would let the two + drift in the direction that matters least. diff --git a/adr/0005-one-uniffi-namespace-pair-reaches-the-apps.md b/adr/0005-one-uniffi-namespace-pair-reaches-the-apps.md new file mode 100644 index 00000000..2efe671e --- /dev/null +++ b/adr/0005-one-uniffi-namespace-pair-reaches-the-apps.md @@ -0,0 +1,65 @@ +# ADR-0005 — One uniffi namespace pair reaches the apps + +- **Status:** accepted +- **Date:** 2026-09-01 +- **Supersedes:** — +- **Superseded by:** — +- **Contract:** [Module Map — Client Boundaries](../capsule-docs/src/content/docs/design/module-map.md#client-boundaries) +- **Slices:** S-F1, S-F3, S-D9, S-P1 + +## Context + +Three uniffi namespaces exist in this workspace, and the number is easy to misread as +three things an app links. + +- `capsule_core`, behind `capsule-core`'s `ffi` feature: the crypto `FfiWorkspace` and the + `HardwareSigner` foreign trait. +- `capsule_core_ffi`, in its own crate: the SQLite catalog, the CBOR sidecar, the + `GatedView`/`LocalAuthGate` seam and the single `CatalogError` that crosses the boundary. +- `capsule_sdk`, behind `capsule-sdk`'s `ffi` feature: the networked user flows (`S-D9`) + and the workspace verbs the iOS lane consumes (`S-P1`). + +`S-F1` settled that `capsule_core` and `capsule_core_ffi` are *layered* — distinct crates, +distinct bindings namespaces, one pinned uniffi version — and that they are **never linked +into the same binary**, so their generated scaffolding cannot collide. + +What an app actually links follows from a property of the toolchain rather than from a +preference: two Rust staticlibs cannot share a binary, because each bundles its own `std`. +So any namespace an app needs must ride in one library. `capsule-core-ffi` is that library +(`S-F3`): it is the app umbrella staticlib, and it carries `use capsule_sdk as _;` for the +sole purpose of forcing rustc to keep the SDK's scaffolding and metadata in the archive, +since nothing in the crate calls it — the generated Swift does, over the C ABI. +`capsule-sdk` deliberately does not enable `capsule-core/ffi`, which is what keeps the +`S-F1` never-same-binary invariant intact. + +The Apple graph shows the result: `CapsuleCatalogFFI` compiles +`.ffi/generated/capsule_core_ffi.swift` and `.ffi/generated/capsule_sdk.swift`, and no +other generated namespace. + +## Decision + +An app links exactly the **`capsule_core_ffi` + `capsule_sdk`** namespace pair, delivered +as one staticlib. `capsule_core` is not an app-facing namespace: it is the crypto surface +`capsule-core-ffi` and the harnesses use, and it never shares a binary with `capsule_sdk`. + +## Consequences + +- A new verb an app needs is added to `capsule_core_ffi` or to `capsule_sdk`. Adding a + third namespace to the app's link line is not an option the toolchain leaves open. +- `mise-tasks/gen-bindings` keeps generating `capsule_core` and `capsule_sdk` separately, + from their own compiled cdylibs, and its symbol-presence assertions keep proving that + each surface crossed. Generating them together would violate `S-F1`. +- `capsule-core-ffi`'s `use capsule_sdk as _;` is load-bearing and must not be removed as + an unused import. Without it the linker drops the `capsule_sdk` scaffolding and every + networked verb disappears from the app with no compile error. +- The third namespace is a cost this decision names rather than hides: `capsule_core`'s + crypto surface is reachable only through whatever `capsule_core_ffi` re-exposes. #399 + is where that surface is frozen and the dead parts of it removed. + +## Considered and rejected + +- **One namespace for everything.** It would put the crypto `FfiWorkspace` and the + networked flows in one uniffi surface, which is the collision `S-F1` exists to prevent + and which would make `capsule-core`'s crypto tree an app dependency. +- **Two staticlibs, one per namespace.** Not available: each bundles its own `std`, so the + app would carry two runtimes and the symbols would clash. diff --git a/adr/0006-the-server-is-a-binary-with-config-and-three-adapters-per-port.md b/adr/0006-the-server-is-a-binary-with-config-and-three-adapters-per-port.md new file mode 100644 index 00000000..7c46094e --- /dev/null +++ b/adr/0006-the-server-is-a-binary-with-config-and-three-adapters-per-port.md @@ -0,0 +1,80 @@ +# ADR-0006 — The server is one binary with one configuration loader and three adapters per port + +- **Status:** proposed +- **Date:** 2026-09-01 +- **Supersedes:** — +- **Superseded by:** — +- **Slices:** S-C29, S-C59, S-P7 + +## Context + +`capsule-server` is a complete *surface* and not yet a program. `src/bin/` holds one +binary, `gen_openapi.rs`, which builds the router purely to describe it and needs no +database, no Valkey, no key material, no disk and no network. `src/store/` holds the typed +ports and one adapter — `memory.rs`, the deterministic test double — beside the +`conformance.rs` suite every adapter must pass. Nothing anywhere reads `JWT_ED25519_DER`, +`SYNC_CURSOR_MAC_KEY`, `VALKEY_URL` or `DATABASE_URL`: those names appear only in doc +comments describing what a loader will do. `design/development/local-development.md` states +the same thing plainly, and `mise run serve-api` — the one command that used to bring the +stack up — retired with the Salvo binary it launched in `S-C59`. + +So nothing that needs a live server can run: not the CLI's networked commands, not the web +client beyond its empty states, not the iOS lane's end-to-end path, not the bounded E2E +cases. That single absence is the rebuild's critical path, and it is currently described +across a slice note, a design doc and two module comments rather than decided once. + +`S-C29` already settled the *shape* of the state layer, and settled it against the +alternative worth naming. The Salvo server kept session records, the per-user session +index, MFA counters, rate-limit counters and a generic `save_temp_data` / +`get_temp_data` behind one `SessionStorage` trait with a caller-supplied TTL — a +serialize-anything key-value store that four unrelated ceremonies rode, namespaced by +hand-formatted string keys. `AGENTS.md`'s Rust Architecture Decisions refuse exactly that +abstraction, and `S-C29` deleted it rather than porting it: separate typed ports, no +`T: Serialize` anywhere, TTL a property of the store rather than an argument, and boxed +futures so every port stays dyn-compatible and an adapter can be swapped behind +`Arc` without making the server generic over its storage. + +## Decision + +The server is **one binary**, with **one configuration loader**, over the typed ports +`S-C29` defined — `AuthStateStore`, `UploadSessionStore`, `CohortStore`, and the three +ceremony stores (`ChallengeStore`, `EnrollmentStore`, `ChannelStore`) — each with **three +adapters**: PostgreSQL, Valkey through `redis-rs`, and the in-memory double. + +Three adapters are not three deployment modes. Valkey is required and the binary refuses +to boot without it; the in-memory adapter is a test double and never a deployment profile. +Whichever adapter is in play passes the one shared suite in `capsule-server::store::conformance`, +which is what makes "the in-memory double behaves like Valkey" an assertion rather than an +assumption — and what lets the rest of the rebuild be tested without a container. + +No generic TTL or CAS abstraction is introduced to unify them. Blob storage and the +resumable encrypted upload protocol stay Capsule-owned behind narrow ports, and no +`object_store` or generic transfer crate is adopted to hold them. + +## Consequences + +- The configuration loader is the one place secrets enter the process. `JWT_ED25519_DER` + is the root of that set: `sync/cursor.rs` HKDF-derives the cursor MAC key from it when + `SYNC_CURSOR_MAC_KEY` is absent, and `discovery/` derives the rest, so a self-hoster + supplies one value rather than a list. +- A Postgres adapter must satisfy the transactions finalization needs, row locking, + migrations, cancellation, typed error mapping and tracing. A Valkey adapter must satisfy + the atomic compare-and-update and expiry primitives each port requires. Neither may + widen a port to make itself easier to write. +- Refusing to boot without Valkey is a deliberate operational cost. The rejected + alternative — a Postgres fallback that removes Valkey — means emulating TTL and expiry + in SQL, which rebuilds the generic TTL store `S-C29` deleted. +- A serve task returns to `mise`, replacing what `serve-api` did for the Salvo tree. + Everything gated on a live server unblocks with it: `S-P7`'s successor, the E2E cases, + the web client's non-empty states. + +## Considered and rejected + +- **Keeping the server library-only and driving it from tests.** It is what the tree does + today, and it is why nothing outside the crate can exercise the rebuild. A description + and a test harness are not a deployment. +- **One `SessionStorage`-style trait again.** The grab-bag whose deletion `S-C29` is; its + caller-supplied TTL and `T: Serialize` payloads are the two properties the port shape + makes inexpressible. +- **Postgres-only, with TTLs emulated in SQL.** Cheaper to operate and it reintroduces the + generic TTL abstraction as an implementation detail nobody can see. From 599626bb16560ac4833d8be89a739284eb23420f Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 21:00:52 -0400 Subject: [PATCH 017/243] docs(i18n): state the ICU subset the Rust runtime actually renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The design doc said the runtime "currently handles literal text and `{name}` interpolation" and listed plurals as follow-up. Both stopped being true when `S-I7` landed plural evaluation, and a confident wrong answer in this document is exactly what let Android ship raw ICU for as long as it did. The runtime section now names the supported subset, the CLDR table behind arm selection and the fact that `xtask` reads the same one, the `other` fallback and why it is load-bearing today, and — spelled out rather than implied — what is still refused: `select`, `selectordinal`, `offset:`, number and date skeletons, and ICU apostrophe quoting, which no target implements. The codegen table's Rust row and the future-work bullet follow. `SLICES.md` records the second half of `S-I7` as landed, why the plural rules are an in-house table rather than a crate, that `xtask`'s copy was the second one, and the three non-plural defects the pre-commit review of the formatter found. It also records one owed-CI item: the tests that pin release-build behaviour are `cfg(not(debug_assertions))` and no CI job runs `cargo test --release`, so they never execute. `locales/README.md` tells a translator the one thing that changed for them: every target evaluates a `plural` block now, and the constructs that still fail the build. --- SLICES.md | 32 +++++++++++++-- capsule-docs/src/content/docs/design/i18n.md | 43 ++++++++++++++++---- locales/README.md | 6 ++- 3 files changed, 69 insertions(+), 12 deletions(-) diff --git a/SLICES.md b/SLICES.md index ce7d3266..225872e4 100644 --- a/SLICES.md +++ b/SLICES.md @@ -347,7 +347,7 @@ row's remainder now lives. | S-I4 | Swift interpolated/plural strings + InfoPlist/LAContext | i18n | — | M | ACTIVE | done | forced an ICU→Apple compiler in the generator | | S-I5 | The CLI import arm has no `cli.import.*` catalog namespace | i18n | — | M | ACTIVE | ready | `i18n-guard` never scanned the CLI | | S-I6 | Android ships raw ICU to users; the guard never fires | i18n | — | M | ACTIVE | done | `aapt2` unverified — owed-CI | -| S-I7 | The Rust runtime formatter cannot do ICU plurals | i18n | — | M | ACTIVE | done\* | refuses now; evaluating plurals still owed | +| S-I7 | The Rust runtime formatter cannot do ICU plurals | i18n | — | M | ACTIVE | done | plurals evaluated; `select`/`offset:` still refused | | S-I8 | clap `--help` text is unreachable from the catalogs | i18n | — | S | ACTIVE | ready | found widening `i18n-guard` | | S-N1 | OIDC relying party (server) | auth | — | L | RETIRED | ready | | | S-N2 | SDK/CLI OIDC login flows | auth | S-N1 | M | MIXED | blocked | | @@ -4853,9 +4853,33 @@ lands on Kynos rather than on Salvo. the Android guard whose comment claimed it skipped what it could not translate. Retargeted rather than deleted, with a companion test pinning that release builds still pass through and that the pass-through reproduces the input exactly. -- **Owed:** actually evaluating plurals, which needs CLDR rules in the runtime. That is the cost the - per-platform renderers avoid by compiling ahead of time, and it is why this runtime was the one - left behind. +- **Second half landed (#414).** `capsule_i18n::plural` carries CLDR integer cardinal rules for the + twelve language subtags in `locales/config.json`, and the formatter evaluates `{name, plural, …}` + with `=N` arms, category arms, `#`, and nesting. `Bundle::format` was dropping `self.locale` + before calling the formatter — that was the API gap, and `format_message_in(locale, …)` closes it + while `format_message` keeps its signature and means English. The refusal is **narrowed, not + removed**: `select`, `selectordinal`, `offset:`, a malformed plural, and nesting past 32 levels + keep the assertion and the pass-through. +- **An in-house table, not a crate.** `icu_plurals` would be a genuine new dependency — provider, + data crate, and a `dependencies.md` row — on a crate whose entire dependency list is `serde_json` + and `tracing`, to decide thirteen locales' integer cardinal categories. Licence was not the + discriminator (`Unicode-3.0` is allowlisted); volume was. +- **The table `xtask` already had was the second copy.** `xtask/src/i18n.rs` held its own + selectable-category list, which the moment the runtime gained rules had to agree with them. + Merged: the generator reads `capsule_i18n::plural::selectable`, and a test pins both directions of + every row. Russian is the row worth knowing — it never selects `other` for an *integer*, since its + `other` is for fractions, yet every plural resource must still carry that arm. +- **What the fallback is actually doing.** Every translated plural in `locales/` is still an English + `one`/`other` copy, even in `ar` and `ru`. Falling an absent category back to `other` is therefore + not a nicety — without it, `few` in Russian would render nothing. +- **Three defects found reviewing the change before it landed**, none of them plural-specific: a + stray `{` abandoned the rest of the template, so one unbalanced brace silently blanked every later + argument; recursion was bounded only by the input, so a public formatter could abort the process + on a deeply nested template; and a string count was trimmed for selection but not for `#`, so + `" 1 "` could pick one arm and print another. +- **Owed-CI:** `cargo test --release` is not run anywhere. The two tests that pin *production* + behaviour — the release build passes a refused construct through instead of crashing — are + `cfg(not(debug_assertions))` and therefore never execute in CI. Filed separately. ### S-I8 — clap `--help` text is unreachable from the catalogs diff --git a/capsule-docs/src/content/docs/design/i18n.md b/capsule-docs/src/content/docs/design/i18n.md index f835717a..c2841767 100644 --- a/capsule-docs/src/content/docs/design/i18n.md +++ b/capsule-docs/src/content/docs/design/i18n.md @@ -75,7 +75,7 @@ never declared twice. | Target | Output | Status | | --- | --- | --- | -| Rust runtime | `capsule-i18n/src/bundles/.json` + `generated.rs` | Implemented | +| Rust runtime | `capsule-i18n/src/bundles/.json` + `generated.rs` | Implemented (ICU evaluated at runtime, plurals included) | | Web (FormatJS) | `capsule-web/src/i18n/messages/.json` | Implemented | | Android | `capsule-android/.../res/values[-]/strings.xml` | Implemented (literals) | | iOS/macOS | `capsule-swift/Generated/Localizable.xcstrings` + `InfoPlist.xcstrings` | Implemented (ICU compiled to Apple form, plurals included) | @@ -118,11 +118,32 @@ One limitation, and one outright defect: missing key returns the key itself, surfacing the gap rather than an empty string. There is no production-grade pure-Rust ICU MessageFormat *formatter* crate, so the -runtime ships a small interpreter over the same FormatJS grammar the web uses. It -currently handles literal text and `{name}` interpolation — the subset the catalog -exercises today; full `plural`/`select`/`number`/`date` formatting is follow-up. +runtime ships a small interpreter over the same FormatJS grammar the web uses. Native clients use their platform's own ICU machinery, which already covers the -full syntax. +full syntax; the Rust runtime is the one target with no platform underneath it, +so it is the only one that carries CLDR rules itself. + +- `Bundle::format(key, args)` and `format_message_in(locale, template, args)` + render **literal text**, **`{name}` interpolation**, and **`{name, plural, …}`** + with `=N` exact arms, CLDR category arms, `#` for the count, and nesting (an arm + may hold placeholders and further plurals). `format_message(template, args)` is + the locale-free entry point and means English. +- Arm selection comes from `capsule_i18n::plural`, an in-house CLDR **integer + cardinal** table covering the twelve language subtags in `locales/config.json`. + It is the same table `xtask i18n` filters Android `` arms with — + one table, two consumers, reconciled by test in both directions. +- A category the message does not carry falls back to `other`. That fallback is + load-bearing today: every translated plural is still an English `one`/`other` + copy, so a Russian `few` has nowhere else to go. +- **Not implemented, and refused rather than mis-rendered:** `select`, + `selectordinal`, `plural` with `offset:`, `number`/`date` skeletons, and ICU + apostrophe quoting (`'#'` for a literal `#`, which the ahead-of-time generators + do not implement either). A construct outside the subset trips a `debug_assert!` + where a developer sees it and is copied through verbatim in release, so a release + build gains no crash on a catalog it could previously render badly. +- Numbers render as plain digits — no grouping separators, no locale digit shaping. + Doing that properly is `number` skeleton support, which needs CLDR number data + this runtime does not carry. ## Server error codes @@ -197,7 +218,15 @@ See [Validation Tiers](/design/principles/#validation-tiers). malformed entry (missing `message`) and an unsupported `sourceLocale`. - **Locale negotiation + formatting (unit).** `capsule-i18n` unit tests cover exact/primary-subtag/weighted matching, fallback to the source locale, message - interpolation, and missing-key behavior, against fixed vectors. + interpolation, and missing-key behavior, against fixed vectors. The CLDR plural + table is asserted cell by cell at the boundary counts, and its two halves — + which categories a language *lists* and which its rules *produce* — are pinned + equal. +- **Plural rendering (smoke).** Every plural key in every one of the thirteen + catalogs is rendered at eight boundary counts and asserted to contain no ICU + syntax; and, in `xtask`, to equal the sentence the generated Android + `strings.xml` produces from the same arm. That second one is the runtime/ + ahead-of-time agreement, checked against the real catalogs. - **Bundle load (smoke).** The embedded generated bundle parses and resolves keys (including an `error.*` code round-trip) end-to-end. @@ -214,7 +243,7 @@ unowned future work. - Hardcoded-string migration (web JSX, SwiftUI `Text`, Compose → catalog keys): **slice `S-I1`**. - The twelve-locale catalog rollout + RTL support: **slice `S-I2`**. - README translation pipeline: **slice `S-I3`** (see [README Translation](#readme-translation)). -- Full ICU `select`/`number`/`date` fidelity in the Rust runtime and the Android generator (`plural` is compiled for Apple today and is `S-I6` for Android). +- Full ICU `select`/`selectordinal`/`number`/`date` fidelity, and apostrophe quoting, in the Rust runtime and the Android generator (`plural` is compiled ahead of time for Apple and Android, and evaluated at runtime in Rust — slice `S-I7`). - Add the desktop target once its framework is chosen. (The iOS `.xcstrings` targets are wired and verified.) - Retrofit the remaining server error variants with codes; regenerate the OpenAPI spec / SDK. - Align the FFI `CatalogError` surface with the error-code scheme. diff --git a/locales/README.md b/locales/README.md index 2a4ae833..802a8610 100644 --- a/locales/README.md +++ b/locales/README.md @@ -49,7 +49,11 @@ Hello, {name}! {count, plural, one {# photo} other {# photos}} ``` -The same ICU syntax compiles to every platform, so you write a message once. +The same ICU syntax compiles to every platform, so you write a message once — +and every target evaluates plurals, the Rust server and CLI included, so a +`plural` block is rendered rather than shown to the reader as message source. +`select`, `selectordinal` and `offset:` are not supported anywhere yet; a +message using one fails the build rather than reaching a user. ### Key naming From c8157c16a2d30ff47edc4711a0ef526655215bf0 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 21:02:31 -0400 Subject: [PATCH 018/243] docs(contributing): name the two test suites pre-push actually runs The previous wording said pre-push runs "the test suites", which is the same shape of overclaim the sentence above it replaced: `hk.pkl`'s pre-push carries `test-rust` and `test-web`, not `test-kotlin`, `test-swift` or `test-docs`. Say which two, and put the other three in the list of what CI is left to do. --- CONTRIBUTING.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5984ce7e..81ac02d7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,15 +24,16 @@ hk install # wires up the git hooks Run tasks with `mise run ` — `mise tasks` lists them all (plain name = auto-fix, `-check` suffix = verify-only). The pre-commit hook auto-formats and **stages** your changes; `convco` validates every commit message as a -[Conventional Commit](https://www.conventionalcommits.org); and pre-push runs the -format/lint checks, the test suites, and the cheap boundary gates — `i18n-check`, -`i18n-guard`, `architecture-check` and `license-check`. - -Pre-push is a fast subset of CI, not the whole of it. The gates that cost minutes of -fresh compilation — `openapi-check-kynos`, `translate-readme-check`, the `build-*` -steps, `gen-bindings` and `verify-examples` — run only in CI. To run exactly what CI -runs before you open a pull request, use the per-toolchain entrypoints: -`mise run check-rust`, `check-web`, `check-docs`, `check-kotlin`, `check-swift`. +[Conventional Commit](https://www.conventionalcommits.org); and pre-push runs every +toolchain's format/lint checks, the Rust and web test suites, and the cheap boundary +gates — `i18n-check`, `i18n-guard`, `architecture-check` and `license-check`. + +Pre-push is a fast subset of CI, not the whole of it. What it leaves to CI: the Kotlin, +Swift and docs test suites, and the gates that cost minutes of fresh compilation — +`openapi-check-kynos`, `translate-readme-check`, the `build-*` steps, `gen-bindings` and +`verify-examples`. To run exactly what CI runs before you open a pull request, use the +per-toolchain entrypoints: `mise run check-rust`, `check-web`, `check-docs`, +`check-kotlin`, `check-swift`. > **Coming from the old `just` + `lefthook` setup?** Re-run `mise install && hk install` > (hk overwrites the stale `.git/hooks` that called lefthook). The `justfile` is gone — From 2e42fc2b48c30a3c44a548d943a888f9955b50f2 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 21:11:04 -0400 Subject: [PATCH 019/243] refactor(core)!: make the six barrel modules' submodules crate-private MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `library`, `import`, `db`, `crypto::keys`, `sidecar` and `domain` each declared every submodule `pub mod` *and* re-exported its types through a barrel, so every public type had two paths. `capsule-sdk` and `capsule-server` used both spellings for the same type. `lifecycle/mod.rs` is the model this follows. 59 declarations become `pub(crate) mod`. `pub(crate)` rather than plain `mod` because the 175 intra-crate deep references then compile unchanged; plain `mod` would rewrite all of them for the same external effect (`pub(crate)` items are absent from rustdoc and unreachable from every other crate). Barrels completed, so nothing reachable became unnameable: - `sidecar` exports all 11 `sidecar_v1` items. `CullFlag` and `GpsSource` are needed by `capsule-cli`; the rest are field types of `SidecarV1`. - `library` exports `paths::thumbnail_path` (a `capsule-core` test used it) and `receipts::ReceiptStoreError` (the error type of the already re-exported `append_receipt` — reachable but unnameable without it). The 22 items privatization stranded — declared `pub`, now unreachable outside the crate, and with no consumer outside it — are demoted to `pub(crate)` rather than added to a barrel: `db::migrate::{migrate, BASELINE_VERSION, Ddl, Step, STEPS}`, `db::schema::{SCHEMA_VERSION, DDL}`, `import::group::{is_raw, is_primary, is_video, is_xmp}`, `import::importers::takeout` (the barrel already re-exports `TakeoutAdapter`), `library::lock::{LockRecord, try_acquire, release}`, `library::scrub::startup_scrub`, `sidecar::library_version:: CURRENT_LIBRARY_VERSION`, `crypto::keys::albumstore::{ALBUM_STORE_VERSION, ALBUM_STORE_FILE}` and `crypto::keys::kem::DEK_SEED_LEN`. A frozen API should not gain surface as a side effect of hiding a module. `-W unreachable_pub` (already in `CLIPPY_FLAGS`) is what found them; a transient `#![warn(unnameable_types)]` found the two barrel gaps. `library::receipts` no longer re-exports `BlobRole`/`role_str`; they keep their one public home in `crypto::receipts`, which the barrel already reaches through `library::storage_verify::BlobRole`. 32 consumer imports across 21 files move to the barrel path. `import::scanner::scan` is `import::scan_paths`, the name the barrel already gave it. BREAKING CHANGE: the submodules of `capsule_core::{library, import, db, crypto::keys, sidecar, domain}` are no longer public. Every type they held is reached through its parent barrel instead — one path per type. --- capsule-cli/src/cull.rs | 2 +- capsule-cli/src/demo.rs | 2 +- capsule-cli/src/lib.rs | 6 ++-- capsule-cli/src/remote.rs | 2 +- capsule-cli/tests/import_round_trip.rs | 2 +- capsule-cli/tests/takeout_import.rs | 2 +- capsule-core/src/crypto/keys/albumstore.rs | 4 +-- capsule-core/src/crypto/keys/kem.rs | 2 +- capsule-core/src/crypto/keys/mod.rs | 28 ++++++++-------- capsule-core/src/db/migrate.rs | 10 +++--- capsule-core/src/db/mod.rs | 10 +++--- capsule-core/src/db/schema.rs | 4 +-- capsule-core/src/domain/mod.rs | 14 ++++---- capsule-core/src/import/group.rs | 8 ++--- capsule-core/src/import/importers/mod.rs | 2 +- capsule-core/src/import/mod.rs | 28 ++++++++-------- capsule-core/src/library/lock.rs | 6 ++-- capsule-core/src/library/mod.rs | 34 ++++++++++---------- capsule-core/src/library/receipts.rs | 4 +-- capsule-core/src/library/scrub.rs | 5 ++- capsule-core/src/sidecar/library_version.rs | 2 +- capsule-core/src/sidecar/mod.rs | 17 ++++++---- capsule-core/tests/drop_adopt_kat.rs | 5 +-- capsule-core/tests/local_gallery_security.rs | 29 +++++++++-------- capsule-sdk/src/ffi/workspace.rs | 11 ++++--- capsule-sdk/src/net.rs | 2 +- capsule-sdk/src/push.rs | 2 +- capsule-sdk/src/push/tests.rs | 8 ++--- capsule-sdk/src/staged.rs | 2 +- capsule-server/src/album/tests.rs | 5 +-- capsule-server/src/attestation/mod.rs | 2 +- capsule-server/src/directory/tests.rs | 3 +- capsule-server/src/scrub/tests.rs | 5 ++- capsule-server/tests/conformance.rs | 2 +- capsule-server/tests/directory.rs | 3 +- capsule-server/tests/revoke.rs | 2 +- capsule-server/tests/support/mod.rs | 6 ++-- capsule-server/tests/upgrade.rs | 2 +- 38 files changed, 145 insertions(+), 138 deletions(-) diff --git a/capsule-cli/src/cull.rs b/capsule-cli/src/cull.rs index 7c99093e..46fae135 100644 --- a/capsule-cli/src/cull.rs +++ b/capsule-cli/src/cull.rs @@ -22,7 +22,7 @@ //! SSoT: [Organization — Culling](https://docs/design/organization/#culling). use capsule_core::lifecycle::Workspace; -use capsule_core::sidecar::sidecar_v1::CullFlag; +use capsule_core::sidecar::CullFlag; use capsule_i18n::Bundle; use colored::Colorize as _; use thiserror::Error; diff --git a/capsule-cli/src/demo.rs b/capsule-cli/src/demo.rs index 58838c68..556bd791 100644 --- a/capsule-cli/src/demo.rs +++ b/capsule-cli/src/demo.rs @@ -12,7 +12,7 @@ use capsule_core::backup::{recover_seed, split_seed_2of3}; use capsule_core::crypto::primitives::Argon2Params; use capsule_core::crypto::verify_asset::VerifyOutcome; use capsule_core::lifecycle::Workspace; -use capsule_core::sidecar::sidecar_v1::CullFlag; +use capsule_core::sidecar::CullFlag; use colored::*; use eyre::{Result, eyre}; diff --git a/capsule-cli/src/lib.rs b/capsule-cli/src/lib.rs index c38c8669..6789950b 100644 --- a/capsule-cli/src/lib.rs +++ b/capsule-cli/src/lib.rs @@ -12,12 +12,10 @@ use std::path::{Path, PathBuf}; use capitalize::Capitalize; use capsule_core::crypto::primitives::DeviceTier; use capsule_core::domain::ImportMode; -use capsule_core::import::scanner::scan as scan_files; -use capsule_core::import::upload::UploadPolicy; use capsule_core::import::{ CancellationToken, DefaultAlbumContext, ImportConfig, ImportOutcome, ImportProgressEvent, - ScanResult, SourceAdapter, SourceMetadataIndex, TakeoutAdapter, execute_with_source_metadata, - plan, + ScanResult, SourceAdapter, SourceMetadataIndex, TakeoutAdapter, UploadPolicy, + execute_with_source_metadata, plan, scan_paths as scan_files, }; use capsule_core::library::{Library, LibraryError, init_library, open_library, rebuild_index}; use capsule_core::lifecycle::Workspace; diff --git a/capsule-cli/src/remote.rs b/capsule-cli/src/remote.rs index a180650a..0fde67ca 100644 --- a/capsule-cli/src/remote.rs +++ b/capsule-cli/src/remote.rs @@ -10,7 +10,7 @@ use std::collections::HashSet; -use capsule_core::import::upload::UploadPolicy; +use capsule_core::import::UploadPolicy; use capsule_core::lifecycle::{LifecycleError, Workspace}; use capsule_sdk::albums::{AlbumClient, AlbumTransport}; use capsule_sdk::auth::{AuthClient, AuthError, LoginOutcome, Session}; diff --git a/capsule-cli/tests/import_round_trip.rs b/capsule-cli/tests/import_round_trip.rs index 576c0fd8..b63b155c 100644 --- a/capsule-cli/tests/import_round_trip.rs +++ b/capsule-cli/tests/import_round_trip.rs @@ -43,7 +43,7 @@ use capsule_core::crypto::provenance::action::Action; use capsule_core::db::AssetRow; use capsule_core::library::open_library; use capsule_core::lifecycle::Workspace; -use capsule_core::sidecar::sidecar_v1::{SIDECAR_SCHEMA_V1, SidecarV1}; +use capsule_core::sidecar::{SIDECAR_SCHEMA_V1, SidecarV1}; use jiff::Timestamp; use uuid::Uuid; diff --git a/capsule-cli/tests/takeout_import.rs b/capsule-cli/tests/takeout_import.rs index b958cf63..dc169c73 100644 --- a/capsule-cli/tests/takeout_import.rs +++ b/capsule-cli/tests/takeout_import.rs @@ -48,7 +48,7 @@ use std::process::{Command, Output, Stdio}; use capsule_core::crypto::primitives::Argon2Params; use capsule_core::crypto::verify_asset::VerifyOutcome; use capsule_core::lifecycle::Workspace; -use capsule_core::sidecar::sidecar_v1::GpsSource; +use capsule_core::sidecar::GpsSource; use jiff::Timestamp; use uuid::Uuid; diff --git a/capsule-core/src/crypto/keys/albumstore.rs b/capsule-core/src/crypto/keys/albumstore.rs index 1f6f1170..77998d64 100644 --- a/capsule-core/src/crypto/keys/albumstore.rs +++ b/capsule-core/src/crypto/keys/albumstore.rs @@ -59,10 +59,10 @@ use crate::utils::paths::tmp_path; /// The album store's on-disk format version. Bumped only on a breaking layout change; a store /// declaring a higher version is refused rather than misread. -pub const ALBUM_STORE_VERSION: u16 = 1; +pub(crate) const ALBUM_STORE_VERSION: u16 = 1; /// The store's filename beneath `{root}/.library/`. -pub const ALBUM_STORE_FILE: &str = "albums.cbor"; +pub(crate) const ALBUM_STORE_FILE: &str = "albums.cbor"; /// One escrowed album content key: `(album_id, epoch, amk)`. /// diff --git a/capsule-core/src/crypto/keys/kem.rs b/capsule-core/src/crypto/keys/kem.rs index 3b93ae9c..2f1c9795 100644 --- a/capsule-core/src/crypto/keys/kem.rs +++ b/capsule-core/src/crypto/keys/kem.rs @@ -28,7 +28,7 @@ use crate::crypto::{CryptoError, rng}; /// Length of the X-Wing secret-key seed. SHAKE256-expanded to 96 bytes: the ML-KEM-768 seed /// `d ‖ z` (64) and the X25519 secret scalar (32). -pub const DEK_SEED_LEN: usize = 32; +pub(crate) const DEK_SEED_LEN: usize = 32; /// X-Wing public-key length: `pk_M (1184) ‖ pk_X (32)`. pub const DEK_PUBLIC_LEN: usize = 1184 + 32; diff --git a/capsule-core/src/crypto/keys/mod.rs b/capsule-core/src/crypto/keys/mod.rs index d91670cf..85ec4ab3 100644 --- a/capsule-core/src/crypto/keys/mod.rs +++ b/capsule-core/src/crypto/keys/mod.rs @@ -7,30 +7,30 @@ //! //! [Cryptography — Keys]: https://docs/design/cryptography/keys/ -pub mod album; +pub(crate) mod album; // The sealed, durable album-key store (slice `S-A10`) is filesystem-backed — it writes through // `utils::paths::tmp_path` for atomic replace — so it is gated with the rest of the native // surface. Leaving it ungated broke the `wasm32-unknown-unknown` sealing build (`S-A6`), which // no gate builds: `check-rust` compiles the host triple only. #[cfg(feature = "native")] -pub mod albumstore; -pub mod directory; -pub mod hardware; -pub mod hybrid_sig; -pub mod kem; -pub mod kem_p256; -pub mod keystore; -pub mod master; -pub mod p256; -pub mod signer; -pub mod software; +pub(crate) mod albumstore; +pub(crate) mod directory; +pub(crate) mod hardware; +pub(crate) mod hybrid_sig; +pub(crate) mod kem; +pub(crate) mod kem_p256; +pub(crate) mod keystore; +pub(crate) mod master; +pub(crate) mod p256; +pub(crate) mod signer; +pub(crate) mod software; // The Windows TPM 2.0 `HardwareSigner` over TBS (slice `S-F4`). The pure wire codec compiles on // any host under `cfg(test)` (host-runnable mock tests); the TBS transport + signer are Windows // only. Unlike `tpm` (the tss-esapi Linux reference), TBS needs no external crate — it links // `tbs.dll` via `windows-sys`. -pub mod tbs; +pub(crate) mod tbs; #[cfg(feature = "tpm")] -pub mod tpm; +pub(crate) mod tpm; pub use album::{Amk, AmkVersion}; #[cfg(feature = "native")] diff --git a/capsule-core/src/db/migrate.rs b/capsule-core/src/db/migrate.rs index 393459a2..0324d179 100644 --- a/capsule-core/src/db/migrate.rs +++ b/capsule-core/src/db/migrate.rs @@ -68,7 +68,7 @@ use crate::db::schema::{DDL, SCHEMA_VERSION}; /// Also the version an *unstamped* catalog (one that already has tables but reports /// `user_version = 0`) is adopted at — v1 predates nothing, so there is no older shape to /// mistake it for. -pub const BASELINE_VERSION: u32 = 1; +pub(crate) const BASELINE_VERSION: u32 = 1; /// A single DDL statement (or batch) in a migration step, plus the condition under which it /// runs. @@ -77,7 +77,7 @@ pub const BASELINE_VERSION: u32 = 1; /// visible in its data, and therefore fingerprintable — see the immutability rule in the /// module docs. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Ddl { +pub(crate) enum Ddl { /// Run unconditionally. Must be idempotent (`IF NOT EXISTS` / `DROP … IF EXISTS`). Always(&'static str), /// Run only when `table` **has** `column` — used for renames of a column that an @@ -114,7 +114,7 @@ impl Ddl { /// a catalog four versions behind is brought forward by four separate, separately committed /// steps rather than one jump. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Step { +pub(crate) struct Step { pub from: u32, pub to: u32, /// Stable identifier used in logs and in the immutability fingerprint. @@ -275,7 +275,7 @@ const STEP_3_TO_4: Step = Step { }; /// Every shipped step, in ascending order. Append only. -pub const STEPS: &[Step] = &[STEP_1_TO_2, STEP_2_TO_3, STEP_3_TO_4]; +pub(crate) const STEPS: &[Step] = &[STEP_1_TO_2, STEP_2_TO_3, STEP_3_TO_4]; /// SHA-256 (hex) of each shipped step's canonical rendering, parallel to [`STEPS`]. /// @@ -385,7 +385,7 @@ pub struct Outcome { /// transaction. Catalog already current → nothing runs. Catalog newer than this build → /// [`MigrationError::CatalogTooNew`], with nothing written. #[tracing::instrument(level = "debug", skip_all, fields(target_version = SCHEMA_VERSION))] -pub fn migrate(conn: &Connection) -> Result { +pub(crate) fn migrate(conn: &Connection) -> Result { let stamped = read_user_version(conn)?; if stamped > SCHEMA_VERSION { diff --git a/capsule-core/src/db/mod.rs b/capsule-core/src/db/mod.rs index efed5b4a..2dd47e60 100644 --- a/capsule-core/src/db/mod.rs +++ b/capsule-core/src/db/mod.rs @@ -1,8 +1,8 @@ -pub mod driver; -pub mod migrate; -pub mod rows; -pub mod schema; -pub mod vector; +pub(crate) mod driver; +pub(crate) mod migrate; +pub(crate) mod rows; +pub(crate) mod schema; +pub(crate) mod vector; pub use driver::DatabaseDriver; pub use migrate::{Applied, MigrationError, Outcome as MigrationOutcome}; diff --git a/capsule-core/src/db/schema.rs b/capsule-core/src/db/schema.rs index 861c6a0f..f72977b4 100644 --- a/capsule-core/src/db/schema.rs +++ b/capsule-core/src/db/schema.rs @@ -21,9 +21,9 @@ /// /// **Bumping this constant requires appending a step to [`crate::db::migrate::STEPS`]** — /// `steps_form_a_contiguous_chain` fails otherwise. Never edit a step that has shipped. -pub const SCHEMA_VERSION: u32 = 4; +pub(crate) const SCHEMA_VERSION: u32 = 4; -pub const DDL: &str = r" +pub(crate) const DDL: &str = r" PRAGMA journal_mode = WAL; CREATE TABLE IF NOT EXISTS assets ( diff --git a/capsule-core/src/domain/mod.rs b/capsule-core/src/domain/mod.rs index f405698f..a692eb23 100644 --- a/capsule-core/src/domain/mod.rs +++ b/capsule-core/src/domain/mod.rs @@ -1,10 +1,10 @@ -pub mod capture_tz_source; -pub mod detection_method; -pub mod gps_datum; -pub mod import_mode; -pub mod member_role; -pub mod model_identity; -pub mod stack_type; +pub(crate) mod capture_tz_source; +pub(crate) mod detection_method; +pub(crate) mod gps_datum; +pub(crate) mod import_mode; +pub(crate) mod member_role; +pub(crate) mod model_identity; +pub(crate) mod stack_type; pub use capture_tz_source::CaptureTzSource; pub use detection_method::DetectionMethod; diff --git a/capsule-core/src/import/group.rs b/capsule-core/src/import/group.rs index ac971aa9..3e52f71a 100644 --- a/capsule-core/src/import/group.rs +++ b/capsule-core/src/import/group.rs @@ -21,16 +21,16 @@ pub const VIDEO_EXTS: &[&str] = &["mp4", "mov", "m4v", "avi", "mkv", "mts", "m2t const XMP_EXT: &str = "xmp"; -pub fn is_raw(ext: &str) -> bool { +pub(crate) fn is_raw(ext: &str) -> bool { RAW_EXTS.contains(&ext.to_lowercase().as_str()) } -pub fn is_primary(ext: &str) -> bool { +pub(crate) fn is_primary(ext: &str) -> bool { PRIMARY_EXTS.contains(&ext.to_lowercase().as_str()) } -pub fn is_video(ext: &str) -> bool { +pub(crate) fn is_video(ext: &str) -> bool { VIDEO_EXTS.contains(&ext.to_lowercase().as_str()) } -pub fn is_xmp(ext: &str) -> bool { +pub(crate) fn is_xmp(ext: &str) -> bool { ext.to_lowercase() == XMP_EXT } diff --git a/capsule-core/src/import/importers/mod.rs b/capsule-core/src/import/importers/mod.rs index e51b5ba0..60350b7c 100644 --- a/capsule-core/src/import/importers/mod.rs +++ b/capsule-core/src/import/importers/mod.rs @@ -26,7 +26,7 @@ //! //! [Scan & Extract]: https://docs/design/import/pipeline/#scan--extract -pub mod takeout; +pub(crate) mod takeout; use std::path::{Path, PathBuf}; diff --git a/capsule-core/src/import/mod.rs b/capsule-core/src/import/mod.rs index 92fe6cbb..6e32c1d0 100644 --- a/capsule-core/src/import/mod.rs +++ b/capsule-core/src/import/mod.rs @@ -1,17 +1,17 @@ -pub mod default_album; -pub mod enrichment; -pub mod executor; -pub mod executor_cancellation; -pub mod group; -pub mod importers; -pub mod planner; -pub mod progress; -pub mod scan; -pub mod scanner; -pub mod scope; -pub mod special; -pub mod streaming; -pub mod upload; +pub(crate) mod default_album; +pub(crate) mod enrichment; +pub(crate) mod executor; +pub(crate) mod executor_cancellation; +pub(crate) mod group; +pub(crate) mod importers; +pub(crate) mod planner; +pub(crate) mod progress; +pub(crate) mod scan; +pub(crate) mod scanner; +pub(crate) mod scope; +pub(crate) mod special; +pub(crate) mod streaming; +pub(crate) mod upload; pub use default_album::{ DefaultAlbumContext, DefaultAlbumError, ResolutionRule, ResolvedAlbum, resolve_default_album, diff --git a/capsule-core/src/library/lock.rs b/capsule-core/src/library/lock.rs index 0d25d909..9dc22800 100644 --- a/capsule-core/src/library/lock.rs +++ b/capsule-core/src/library/lock.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use crate::library::error::LibraryError; #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LockRecord { +pub(crate) struct LockRecord { pub pid: u32, pub hostname: String, pub locked_at: i64, @@ -17,7 +17,7 @@ pub struct LockRecord { /// On AlreadyExists: reads the existing lock; if the holding process is no /// longer alive (same host, dead PID), the stale lock is removed and /// acquisition retried. Otherwise returns `LibraryError::Locked`. -pub fn try_acquire(root: &Path) -> Result<(), LibraryError> { +pub(crate) fn try_acquire(root: &Path) -> Result<(), LibraryError> { let lock_path = root.join(".library/lock"); let record = LockRecord { @@ -67,7 +67,7 @@ pub fn try_acquire(root: &Path) -> Result<(), LibraryError> { } /// Release the lock by deleting `.library/lock`. -pub fn release(root: &Path) -> Result<(), LibraryError> { +pub(crate) fn release(root: &Path) -> Result<(), LibraryError> { let lock_path = root.join(".library/lock"); if lock_path.exists() { fs::remove_file(&lock_path)?; diff --git a/capsule-core/src/library/mod.rs b/capsule-core/src/library/mod.rs index b673c859..bea93efe 100644 --- a/capsule-core/src/library/mod.rs +++ b/capsule-core/src/library/mod.rs @@ -1,17 +1,17 @@ -pub mod auth_gate; -pub mod cache; -pub mod error; -pub mod init; +pub(crate) mod auth_gate; +pub(crate) mod cache; +pub(crate) mod error; +pub(crate) mod init; #[allow(clippy::module_inception)] -pub mod library; -pub mod lock; -pub mod open; -pub mod paths; -pub mod rebuild; -pub mod receipts; -pub mod scrub; -pub mod space; -pub mod storage_verify; +pub(crate) mod library; +pub(crate) mod lock; +pub(crate) mod open; +pub(crate) mod paths; +pub(crate) mod rebuild; +pub(crate) mod receipts; +pub(crate) mod scrub; +pub(crate) mod space; +pub(crate) mod storage_verify; pub use auth_gate::{ DEFAULT_GRACE, GateError, GateKeeper, GatedQueryError, GatedView, GraceClock, LocalAuthError, @@ -23,13 +23,13 @@ pub use init::init_library; pub use library::Library; pub use open::open_library; pub use paths::{ - ThumbnailSize, media_dir, media_path, meta_cache_path, receipts_path, sidecar_path, tmp_path, - transcode_h264_path, transcode_live_path, trash_path, uuid_shard, + ThumbnailSize, media_dir, media_path, meta_cache_path, receipts_path, sidecar_path, + thumbnail_path, tmp_path, transcode_h264_path, transcode_live_path, trash_path, uuid_shard, }; pub use rebuild::rebuild_index; pub use receipts::{ - CustodyReceipt, CustodyReceiptCore, ReceiptExpectations, ReceiptRejection, append_receipt, - load_receipts, verify_receipt, + CustodyReceipt, CustodyReceiptCore, ReceiptExpectations, ReceiptRejection, ReceiptStoreError, + append_receipt, load_receipts, verify_receipt, }; pub use space::{available_bytes, largest_asset_fits, streaming_recommended}; pub use storage_verify::{ diff --git a/capsule-core/src/library/receipts.rs b/capsule-core/src/library/receipts.rs index 724c426e..a35d6748 100644 --- a/capsule-core/src/library/receipts.rs +++ b/capsule-core/src/library/receipts.rs @@ -26,8 +26,7 @@ use uuid::Uuid; use crate::cbor::{self, CanonicalError}; pub use crate::crypto::receipts::{ - BlobRole, CustodyReceipt, CustodyReceiptCore, ReceiptExpectations, ReceiptRejection, role_str, - verify_receipt, + CustodyReceipt, CustodyReceiptCore, ReceiptExpectations, ReceiptRejection, verify_receipt, }; use crate::library::paths::receipts_path; @@ -92,6 +91,7 @@ mod tests { use super::*; use crate::crypto::hash::Hash32; use crate::crypto::keys::{HybridSignature, HybridSigningKey, HybridVerifyingKey}; + use crate::crypto::receipts::BlobRole; fn signing_key(seed: u8) -> HybridSigningKey { HybridSigningKey::from_seed64(&[seed; 64]) diff --git a/capsule-core/src/library/scrub.rs b/capsule-core/src/library/scrub.rs index 92ef2114..74183e9a 100644 --- a/capsule-core/src/library/scrub.rs +++ b/capsule-core/src/library/scrub.rs @@ -11,7 +11,10 @@ const TMP_AGE_SECS: u64 = 5 * 60; /// Run a startup scrub if more than 7 days have passed since the last one. /// Removes `.tmp` files older than 5 minutes from `media/` and updates /// `config.last_scrubbed_at`. -pub fn startup_scrub(root: &Path, config: &mut LibraryConfigCbor) -> Result<(), LibraryError> { +pub(crate) fn startup_scrub( + root: &Path, + config: &mut LibraryConfigCbor, +) -> Result<(), LibraryError> { let now = now_secs(); let needs_scrub = match config.last_scrubbed_at { None => true, diff --git a/capsule-core/src/sidecar/library_version.rs b/capsule-core/src/sidecar/library_version.rs index e7e4f575..5f3d17eb 100644 --- a/capsule-core/src/sidecar/library_version.rs +++ b/capsule-core/src/sidecar/library_version.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -pub const CURRENT_LIBRARY_VERSION: u8 = 1; +pub(crate) const CURRENT_LIBRARY_VERSION: u8 = 1; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct LibraryVersionCbor { diff --git a/capsule-core/src/sidecar/mod.rs b/capsule-core/src/sidecar/mod.rs index 474668d7..2574aa3a 100644 --- a/capsule-core/src/sidecar/mod.rs +++ b/capsule-core/src/sidecar/mod.rs @@ -1,9 +1,9 @@ -pub mod asset_sidecar; -pub mod io; -pub mod library_config; -pub mod library_version; -pub mod sidecar_v1; -pub mod stack_hint; +pub(crate) mod asset_sidecar; +pub(crate) mod io; +pub(crate) mod library_config; +pub(crate) mod library_version; +pub(crate) mod sidecar_v1; +pub(crate) mod stack_hint; pub use asset_sidecar::AssetSidecar; pub use io::{ @@ -12,5 +12,8 @@ pub use io::{ }; pub use library_config::LibraryConfigCbor; pub use library_version::LibraryVersionCbor; -pub use sidecar_v1::{SIDECAR_SCHEMA_V1, SidecarV1}; +pub use sidecar_v1::{ + AiTag, CameraId, CullFlag, Dimensions, Gps, GpsSource, Lqip, SIDECAR_SCHEMA_V1, SidecarV1, + StackMembership, StackRole, +}; pub use stack_hint::StackHint; diff --git a/capsule-core/tests/drop_adopt_kat.rs b/capsule-core/tests/drop_adopt_kat.rs index 38741b83..563b8cc8 100644 --- a/capsule-core/tests/drop_adopt_kat.rs +++ b/capsule-core/tests/drop_adopt_kat.rs @@ -17,8 +17,9 @@ use capsule_core::crypto::authority::ReferenceAuthority; use capsule_core::crypto::encryption::keywrap::{seal_file_key, unseal_file_key}; use capsule_core::crypto::encryption::stream::decrypt_asset_vec; use capsule_core::crypto::hash::hash_bytes; -use capsule_core::crypto::keys::directory::{DeviceEntry, DirectoryCore}; -use capsule_core::crypto::keys::{Amk, AmkVersion, DekKeypair, DeviceDirectory, HybridSigningKey}; +use capsule_core::crypto::keys::{ + Amk, AmkVersion, DekKeypair, DeviceDirectory, DeviceEntry, DirectoryCore, HybridSigningKey, +}; use capsule_core::crypto::primitives::{CRYPTO_SUITE_ID, PROTOCOL_VERSION}; use capsule_core::crypto::provenance::action::Action; use capsule_core::crypto::provenance::manifest::{ diff --git a/capsule-core/tests/local_gallery_security.rs b/capsule-core/tests/local_gallery_security.rs index 41b5e8b9..d681aa50 100644 --- a/capsule-core/tests/local_gallery_security.rs +++ b/capsule-core/tests/local_gallery_security.rs @@ -18,7 +18,10 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; -use capsule_core::library::paths; +use capsule_core::library::{ + ThumbnailSize, media_dir, media_path, meta_cache_path, receipts_path, sidecar_path, + thumbnail_path, tmp_path, transcode_h264_path, transcode_live_path, trash_path, +}; use uuid::Uuid; /// Walk to the workspace root (the nearest ancestor holding `Cargo.lock`). @@ -59,20 +62,20 @@ fn every_paths_module_output_stays_under_the_library_root() { let capture = Some(1_721_001_600_i64); let mut candidates = vec![ - paths::media_dir(root, 2024, 7), - paths::media_path(root, &uuid, "arw", capture), - paths::media_path(root, &uuid, "jpg", None), - paths::sidecar_path(root, &uuid, "jpg", capture), - paths::receipts_path(root, &uuid, capture), - paths::meta_cache_path(root, &uuid), - paths::transcode_h264_path(root, &uuid), - paths::transcode_live_path(root, &uuid), - paths::trash_path(root, &uuid, "jpg"), - paths::thumbnail_path(root, &uuid, paths::ThumbnailSize::Xl), + media_dir(root, 2024, 7), + media_path(root, &uuid, "arw", capture), + media_path(root, &uuid, "jpg", None), + sidecar_path(root, &uuid, "jpg", capture), + receipts_path(root, &uuid, capture), + meta_cache_path(root, &uuid), + transcode_h264_path(root, &uuid), + transcode_live_path(root, &uuid), + trash_path(root, &uuid, "jpg"), + thumbnail_path(root, &uuid, ThumbnailSize::Xl), ]; // The staging path for atomic writes is derived from an under-root path; it must stay under it. - let media = paths::media_path(root, &uuid, "jpg", capture); - candidates.push(paths::tmp_path(&media)); + let media = media_path(root, &uuid, "jpg", capture); + candidates.push(tmp_path(&media)); for path in candidates { assert!( diff --git a/capsule-sdk/src/ffi/workspace.rs b/capsule-sdk/src/ffi/workspace.rs index 7dff7384..3a3934d7 100644 --- a/capsule-sdk/src/ffi/workspace.rs +++ b/capsule-sdk/src/ffi/workspace.rs @@ -35,14 +35,15 @@ use std::path::PathBuf; use std::sync::{Arc, Mutex}; -use capsule_core::crypto::keys::hardware::{HardwareSigner, HardwareSignerError}; -use capsule_core::crypto::keys::{P256HybridSigningKey, Signer}; +use capsule_core::crypto::keys::{ + HardwareSigner, HardwareSignerError, P256HybridSigningKey, Signer, +}; use capsule_core::crypto::primitives::DeviceTier; use capsule_core::crypto::verify_asset::VerifyOutcome; use capsule_core::lifecycle::{ QuarantineReason, RemoteAssetFacts, RemoteEntry, SyncApplyOutcome, Workspace, }; -use capsule_core::sidecar::sidecar_v1::SidecarV1; +use capsule_core::sidecar::SidecarV1; use uuid::Uuid; use super::{FfiError, FfiUploadRequest}; @@ -463,8 +464,8 @@ fn uuid_from_bytes(field: &str, bytes: &[u8]) -> Result { /// The ladder tier's stable lowercase name (`index` / `preview` / `original`) — the same three /// rungs the download-sync tier ladder names, so an app's progress UI can key on one vocabulary /// in both directions. -fn tier_name(tier: capsule_core::import::upload::UploadTier) -> String { - use capsule_core::import::upload::UploadTier; +fn tier_name(tier: capsule_core::import::UploadTier) -> String { + use capsule_core::import::UploadTier; match tier { UploadTier::Index => "index", UploadTier::Preview => "preview", diff --git a/capsule-sdk/src/net.rs b/capsule-sdk/src/net.rs index 1abeb525..4bc3bd7f 100644 --- a/capsule-sdk/src/net.rs +++ b/capsule-sdk/src/net.rs @@ -16,7 +16,7 @@ use std::collections::VecDeque; use std::time::{Duration, Instant}; -use capsule_core::import::upload::UploadTier; +use capsule_core::import::UploadTier; use tracing::instrument; /// The closed connection-class enum, evaluated continuously on-device. diff --git a/capsule-sdk/src/push.rs b/capsule-sdk/src/push.rs index 2b74e93c..2d82379d 100644 --- a/capsule-sdk/src/push.rs +++ b/capsule-sdk/src/push.rs @@ -22,7 +22,7 @@ use std::collections::HashSet; -use capsule_core::import::upload::UploadTier; +use capsule_core::import::UploadTier; use capsule_core::lifecycle::UploadBundle; use serde::Serialize; use tracing::instrument; diff --git a/capsule-sdk/src/push/tests.rs b/capsule-sdk/src/push/tests.rs index e2f831b7..98d239e6 100644 --- a/capsule-sdk/src/push/tests.rs +++ b/capsule-sdk/src/push/tests.rs @@ -176,7 +176,7 @@ async fn duplicate_blob_resolves_as_merge_not_error() { let client = server.client(&bundle.protocol_version); let scheduler = StagedScheduler::new( - capsule_core::import::upload::UploadPolicy::Full, + capsule_core::import::UploadPolicy::Full, ConnectionClass::Unmetered, ); let report = push_bundle(&client, &scheduler, &bundle, &HashSet::new(), false) @@ -222,7 +222,7 @@ async fn a_fully_held_bundle_pushes_nothing() { .collect(); let client = server.client(&bundle.protocol_version); let scheduler = StagedScheduler::new( - capsule_core::import::upload::UploadPolicy::Full, + capsule_core::import::UploadPolicy::Full, ConnectionClass::Unmetered, ); @@ -246,7 +246,7 @@ fn a_staged_policy_defers_the_original_on_a_metered_link() { let (_dir, bundle) = real_bundle(); let asset = staged_asset(&bundle); let metered = StagedScheduler::new( - capsule_core::import::upload::UploadPolicy::Staged, + capsule_core::import::UploadPolicy::Staged, ConnectionClass::Metered, ); assert_eq!( @@ -260,7 +260,7 @@ fn a_staged_policy_defers_the_original_on_a_metered_link() { ); let unmetered = StagedScheduler::new( - capsule_core::import::upload::UploadPolicy::Staged, + capsule_core::import::UploadPolicy::Staged, ConnectionClass::Unmetered, ); assert_eq!( diff --git a/capsule-sdk/src/staged.rs b/capsule-sdk/src/staged.rs index 7d609237..e1851975 100644 --- a/capsule-sdk/src/staged.rs +++ b/capsule-sdk/src/staged.rs @@ -38,7 +38,7 @@ use std::collections::HashSet; -use capsule_core::import::upload::{UploadPolicy, UploadTier}; +use capsule_core::import::{UploadPolicy, UploadTier}; use tracing::instrument; use crate::net::ConnectionClass; diff --git a/capsule-server/src/album/tests.rs b/capsule-server/src/album/tests.rs index 7088795b..33b2c3c0 100644 --- a/capsule-server/src/album/tests.rs +++ b/capsule-server/src/album/tests.rs @@ -94,8 +94,9 @@ fn directory( added_at: &str, revoked_at: Option<&str>, ) -> Vec { - use capsule_core::crypto::keys::hybrid_sig::HybridSigningKey; - use capsule_core::crypto::keys::{DeviceDirectory, DeviceEntry, DirectoryCore}; + use capsule_core::crypto::keys::{ + DeviceDirectory, DeviceEntry, DirectoryCore, HybridSigningKey, + }; let signing = HybridSigningKey::generate(); let entry = DeviceEntry { diff --git a/capsule-server/src/attestation/mod.rs b/capsule-server/src/attestation/mod.rs index fae69982..64e1d9ae 100644 --- a/capsule-server/src/attestation/mod.rs +++ b/capsule-server/src/attestation/mod.rs @@ -45,7 +45,7 @@ use std::collections::BTreeMap; use std::sync::{Arc, Mutex}; use capsule_core::crypto::hash::{Hash32, hash_bytes}; -use capsule_core::crypto::keys::hybrid_sig::{HybridSignature, HybridSigningKey}; +use capsule_core::crypto::keys::{HybridSignature, HybridSigningKey}; use capsule_core::crypto::receipts::{CustodyReceipt, CustodyReceiptCore}; use jiff::Timestamp; diff --git a/capsule-server/src/directory/tests.rs b/capsule-server/src/directory/tests.rs index 2cfd8cea..a5d322bc 100644 --- a/capsule-server/src/directory/tests.rs +++ b/capsule-server/src/directory/tests.rs @@ -4,8 +4,7 @@ //! document — check it verifies under the account's identity anchor, compare one projected //! field, and hand the bytes back unchanged. -use capsule_core::crypto::keys::hybrid_sig::HybridSigningKey; -use capsule_core::crypto::keys::{DeviceDirectory, DirectoryCore}; +use capsule_core::crypto::keys::{DeviceDirectory, DirectoryCore, HybridSigningKey}; use uuid::Uuid; use super::*; diff --git a/capsule-server/src/scrub/tests.rs b/capsule-server/src/scrub/tests.rs index ceeb91b2..39354b5d 100644 --- a/capsule-server/src/scrub/tests.rs +++ b/capsule-server/src/scrub/tests.rs @@ -58,7 +58,7 @@ fn chained_manifest_bytes( prior: Option, seed: u8, ) -> Vec { - use capsule_core::crypto::keys::hybrid_sig::HybridSigningKey; + use capsule_core::crypto::keys::HybridSigningKey; use capsule_core::crypto::provenance::action::Action; use capsule_core::crypto::provenance::manifest::AssetManifest; @@ -85,8 +85,7 @@ fn decoded(bytes: &[u8]) -> capsule_core::crypto::provenance::manifest::AssetMan /// decodes — but a fixture that hand-rolled a struct with placeholder signature bytes would be /// asserting against a shape rather than against the artifact. fn manifest_bytes(name: &str, metadata: &ContentAddress) -> Vec { - use capsule_core::crypto::keys::AmkVersion; - use capsule_core::crypto::keys::hybrid_sig::HybridSigningKey; + use capsule_core::crypto::keys::{AmkVersion, HybridSigningKey}; use capsule_core::crypto::provenance::action::Action; use capsule_core::crypto::provenance::manifest::{ ASSET_MANIFEST_VERSION, KeyMode, ManifestCore, diff --git a/capsule-server/tests/conformance.rs b/capsule-server/tests/conformance.rs index ad02ae89..90f0434c 100644 --- a/capsule-server/tests/conformance.rs +++ b/capsule-server/tests/conformance.rs @@ -3575,7 +3575,7 @@ async fn upgrade_block( bearer: &str, refresh_token: &str, album: &str, - account_ik: &capsule_core::crypto::keys::hybrid_sig::HybridSigningKey, + account_ik: &capsule_core::crypto::keys::HybridSigningKey, ) { let path = format!("/v1/albums/{album}/upgrade"); // The account's *own* anchor, not a fresh key: `S-C42` pins a directory to the first identity diff --git a/capsule-server/tests/directory.rs b/capsule-server/tests/directory.rs index 1bfcd20a..e8776940 100644 --- a/capsule-server/tests/directory.rs +++ b/capsule-server/tests/directory.rs @@ -9,8 +9,7 @@ mod support; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64; -use capsule_core::crypto::keys::hybrid_sig::HybridSigningKey; -use capsule_core::crypto::keys::{DeviceDirectory, DirectoryCore}; +use capsule_core::crypto::keys::{DeviceDirectory, DirectoryCore, HybridSigningKey}; use kynos::http::StatusCode; use serde_json::Value; use support::{Fixture, user}; diff --git a/capsule-server/tests/revoke.rs b/capsule-server/tests/revoke.rs index d1baf73b..6b303725 100644 --- a/capsule-server/tests/revoke.rs +++ b/capsule-server/tests/revoke.rs @@ -10,7 +10,7 @@ mod support; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64; -use capsule_core::crypto::keys::hybrid_sig::HybridSigningKey; +use capsule_core::crypto::keys::HybridSigningKey; use capsule_core::crypto::revoke::revoke_all_signing_bytes; use jiff::SignedDuration; use kynos::http::StatusCode; diff --git a/capsule-server/tests/support/mod.rs b/capsule-server/tests/support/mod.rs index fb95f603..d5aba864 100644 --- a/capsule-server/tests/support/mod.rs +++ b/capsule-server/tests/support/mod.rs @@ -28,7 +28,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use base64::Engine as _; -use capsule_core::crypto::keys::hybrid_sig::HybridSigningKey; +use capsule_core::crypto::keys::HybridSigningKey; use capsule_server::App; use capsule_server::album::{ AlbumContext, AlbumRecord, AlbumStore, InMemoryAlbums, ProvisionOutcome, @@ -2357,7 +2357,7 @@ impl Fixture { // anything holding that key manufacture custody evidence. let attestation_key = Arc::new(LocalAttestationKey::new( SERVER_ORIGIN, - capsule_core::crypto::keys::hybrid_sig::HybridSigningKey::generate(), + capsule_core::crypto::keys::HybridSigningKey::generate(), )); let revocations = Arc::new(SwitchableRevocations::new(clock.clone())); let challenges = Arc::new(SwitchableChallenges::new(clock.clone())); @@ -2545,7 +2545,7 @@ impl Fixture { Arc::new(InMemoryReceipts::new()), Arc::new(LocalAttestationKey::new( SERVER_ORIGIN, - capsule_core::crypto::keys::hybrid_sig::HybridSigningKey::generate(), + capsule_core::crypto::keys::HybridSigningKey::generate(), )), Timestamp::UNIX_EPOCH, ), diff --git a/capsule-server/tests/upgrade.rs b/capsule-server/tests/upgrade.rs index 101b0fa6..43035237 100644 --- a/capsule-server/tests/upgrade.rs +++ b/capsule-server/tests/upgrade.rs @@ -13,7 +13,7 @@ mod support; -use capsule_core::crypto::keys::hybrid_sig::HybridSigningKey; +use capsule_core::crypto::keys::HybridSigningKey; use capsule_server::store::Clock as _; use jiff::SignedDuration; use kynos::http::StatusCode; From 1043a4143715071af683caa00faf3341467211ae Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 21:12:02 -0400 Subject: [PATCH 020/243] docs(roadmap): describe each package in its own manifest's words The `Owns` cells were written from directory listings and got several packages wrong in ways a reader would act on. `AssetKit` is the asset-provider abstraction over PhotoKit and the managed store, not a windowing layer; `CapsuleCatalog` owns the contract, the Swift-native models and the in-memory reference implementation, and the `CatalogError` mapping belongs to `CapsuleCatalogFFI`; `FeatureCollections` is the collections home rather than three named screens. `capsule-core-swift` and `capsule-core-kotlin` are standalone harnesses that link `capsule-core` over uniffi and ship the `HardwareSigner` references, not adapter libraries in their own right. `capsule-web`'s row now says the thing a reader most needs from it: it cannot enroll, upload or edit, because a browser holds neither key. Each cell now paraphrases the comment in `Project.swift` or the package's own README, which are the sources that move when the package does. --- ROADMAP.md | 54 +++++++++++++++++++++++++++--------------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e442889f..4a93b604 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -49,36 +49,36 @@ A closed set. A row's state is a claim about the package, not about the programm | `capsule-cli/migration` | cargo | sea-orm migrations for that store | stabilizing | `mise run check-rust` | [migration/README](capsule-cli/migration/README.md) | — | Follows `capsule-cli` (#413) | Schema changes land here before the entity crate sees them | | `xtask` | cargo | Repository automation — `architecture-check`, `i18n-guard`, `translate-readme`, licence and workspace-dependency checks | stabilizing | `mise run check-rust` | [Developer Docs](capsule-docs/src/content/docs/design/developer-docs.md) | — | Guard-detector repair (#394, #414) | Not a shipped artifact; it is what makes several gates in `mise.toml` real | | `capsule-android` | gradle | The Android application — Compose UI over the Kotlin core | blocked | `mise run check-kotlin` | [Clients](capsule-docs/src/content/docs/design/clients.md) | — | Make the build green (#389) | The app references a DI layer that is not in the tree, so it does not compile | -| `capsule-core-kotlin` | gradle | The Kotlin hardware-signer adapters — software P-256, software Ed25519, StrongBox | stabilizing | `mise run check-kotlin` | [Clients](capsule-docs/src/content/docs/design/clients.md) | — | StrongBox run on a device runner | Smoke-tested only; the device lane that would exercise StrongBox is unprovisioned | -| `capsule-core-swift` | swiftpm | The Swift hardware-signer adapters — Secure Enclave signing and key agreement, plus software fallbacks | stabilizing | `mise run check-swift` | [capsule-core-swift/README](capsule-core-swift/README.md) | `S-P6` | Secure-Enclave wiring into the app (`S-P6`) | `check-swift` formats and lints this package; `mise run test-swift` drives the Tuist workspace only, so its `swift test` suite is in no gate | -| `CapsuleFoundation` | tuist | Value types, logging and utilities for the Apple client. No dependencies | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Holds as the client's floor | The root of the Apple module graph; every other target depends on it | -| `CapsuleDomain` | tuist | The display and domain value types, as structural mirrors of the Rust records | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Mirrors hold across the FFI swap (`S-U19`) | Deliberately FFI-free so the mocked graph builds with no Rust toolchain | -| `CapsulePorts` | tuist | The protocol seams the app is written against, and which the mock and FFI adapters satisfy | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Move the six `FeatureAuth` ports here (#391) | Six ports are declared inside `FeatureAuth` today, which is what #391 corrects | -| `CapsuleNavigation` | tuist | `Route`, the sidebar catalog, deep-link classification and `ViewerContext` | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Every live sidebar row reaches a real screen | `SidebarItem.memories` and `.duplicates` are live rows whose screens are still scaffolds | -| `CapsuleMock` | tuist | The in-memory doubles the whole client is built and tested against | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Reachable scenario selection (#392) | `MockScenarioSelection` is write-only, so about thirty screens have no way in | -| `CapsuleDiagnostics` | tuist | The diagnostics coordinator and the client's own health surfaces | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Holds as the client's floor | — | -| `CapsuleCatalog` | tuist | The FFI-free catalog surface the app reads, and the error type that crosses it | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | `S-P5` | Sync-apply renders into the local catalog (`S-P5`) | Written so the generated-type half can be swapped in without any screen naming a generated type | -| `CapsuleCatalogFFI` | tuist | The Rust-backed half — the generated `capsule_core_ffi` and `capsule_sdk` glue, record conversions and error mapping | excluded | — | [capsule-swift/README](capsule-swift/README.md) | `S-P8` | A behavioural FFI harness that flips `S-D9` | Present only under `TUIST_FFI=1`, so the default graph builds from a clean checkout with no cross-compile; format and lint reach it only when it is generated | -| `ManagedStore` | tuist | The Swift filesystem layer, hashing and the managed-store import pipeline | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Imports route through `ImportPort` (#390) | The picker importer writes this store directly, so picker imports never reach the timeline | -| `AssetKit` | tuist | Asset windowing, prefetch and the store the grid and viewer both read | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Holds as the client's floor | — | -| `CapsuleTestSupport` | tuist | Shared mocks and helpers for every module's unit tests | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Covers the suites still owed by lane U | Test-only; no product code depends on it | -| `ImagePipeline` | tuist | Decode, downsample and cache for the Apple client's image rendering | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Holds as the client's floor | — | -| `CapsuleUI` | tuist | The Capsule-state design system — the shared components every feature composes | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Accessibility inside the gate (#393) | The accessibility audit fails on most surfaces and is outside `check-swift` | -| `FeatureTimeline` | tuist | Library, timeline, selection and culling | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Culling review (`S-U7` remainder) | The uniform grid, pinch zoom and the zoom transition landed; culling review is outstanding | -| `FeatureViewer` | tuist | The viewer and asset detail | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Provenance and verdict detail (`S-U8` remainder) | The info panel, caption editing and the `.viewer` route landed | -| `FeatureAlbums` | tuist | Album index and detail, and the smart-album builder | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | `S-U9` | The smart-album screen and predicate builder | Index and detail landed over the mock ports; members and policy editors are outstanding | -| `FeatureSearch` | tuist | Search, people and places | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | `S-U10` | People index and cluster screens | Search and the clustered map landed; map granularity is still fixed | -| `FeatureTransfer` | tuist | The transfer centre, custody receipts, quota, storage and quarantine | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | `S-U12` | The documented ladders and triage detail | Every screen landed and is routed; not all of the documented behaviour is built | -| `FeatureAuth` | tuist | Welcome, discovery, the device chooser, passphrase, enrolment ceremony and the device ledger | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | `S-P2`, `S-P3`, `S-U13`, `S-U23` | A real auth service over Keychain (`S-P2`) | Built over `Preview*` doubles; both onboarding steps are scaffolds | -| `FeatureSharing` | tuist | Share links, drop inbox, peering, federation and moderation | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | `S-U14`, `S-U22` | Inbound link redemption (`S-U22`) | Share detail is outstanding; the `https` deep-link parser lands `/s/` and `/u/` on a scaffold | -| `FeatureSettings` | tuist | The settings tree | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Federation, advanced and about sections | Fifteen of eighteen sections landed; the Advanced mock-scenario switcher does not exist | -| `FeatureImport` | tuist | The picker, scan, plan, execution and history surfaces | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | `S-P4`, `S-U11` | The import→seal→upload bridge (`S-P4`) | Per-run detail is outstanding, and `S-P4` waits on `S-P2`/`S-P3` | -| `FeatureCollections` | tuist | The sidebar collections — hidden, places and recently deleted | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | `S-U20`, `S-U21` | Memories and duplicate review | `HiddenView` sits behind the SR1 local-auth gate, whose seam is a port | +| `capsule-core-kotlin` | gradle | The standalone Kotlin harness over `capsule-core`'s uniffi bindings, plus the `HardwareSigner` references — software Ed25519, software P-256, StrongBox | stabilizing | `mise run check-kotlin` | [capsule-core-kotlin/README](capsule-core-kotlin/README.md) | — | StrongBox proven on a device runner | Smoke tests only, and the self-hosted device lane that would exercise StrongBox is unprovisioned | +| `capsule-core-swift` | swiftpm | The standalone SwiftPM harness over `capsule-core`'s uniffi bindings, plus the `HardwareSigner` references — Secure Enclave signing and key agreement, and their software fallbacks | stabilizing | `mise run check-swift` | [capsule-core-swift/README](capsule-core-swift/README.md) | `S-P6` | Secure-Enclave wiring into the app (`S-P6`) | `check-swift` formats and lints this package; `mise run test-swift` drives the Tuist workspace only, so its own `swift test` suite is in no gate | +| `CapsuleFoundation` | tuist | Value types, logging and utilities. No dependencies | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Holds as the client's floor | The root of the Apple module graph; every other target depends on it | +| `CapsuleDomain` | tuist | The display and domain value types, shaped as structural mirrors of the uniffi records they will be generated from. No I/O, no platform, no strings | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Mirrors hold across the FFI swap (`S-U19`) | Deliberately FFI-free so the mocked graph builds with no Rust toolchain | +| `CapsulePorts` | tuist | The async protocol seams, one per capability. Feature modules import only this for data, so swapping the mock adapters for the real uniffi ones is a change in the composition root and nowhere else | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Move the six `FeatureAuth` ports here (#391) | Six ports are declared inside `FeatureAuth` today, which is what #391 corrects | +| `CapsuleNavigation` | tuist | One `Route` vocabulary, a router with a stack per section, deep links, and the menu-command table. No SwiftUI views, which is what lets the three shells share it | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Every live sidebar row reaches a real screen | `SidebarItem.memories` and `.duplicates` are live rows whose screens are still scaffolds | +| `CapsuleMock` | tuist | The in-memory implementation of every port, and the app's only data source while the Rust core is rebuilt | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Reachable scenario selection (#392) | `MockScenarioSelection` is write-only, so about thirty screens have no way in | +| `CapsuleDiagnostics` | tuist | MetricKit crash and performance collection, consent, breadcrumbs, redacted bug-report bundles, and an opt-in self-hosted uploader | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Holds as the client's floor | — | +| `CapsuleCatalog` | tuist | The catalog contract, its Swift-native models, and the in-memory reference implementation. No Rust core, which is what makes the mock lane possible | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | `S-P5` | Sync-apply renders into the local catalog (`S-P5`) | Written so the generated-type half can be swapped in without any screen naming a generated type | +| `CapsuleCatalogFFI` | tuist | The Rust-backed half — the generated uniffi glue for both namespaces, the record conversions, and the error mapping onto the FFI-free `CatalogError` | excluded | — | [capsule-swift/README](capsule-swift/README.md) | `S-P8` | A behavioural FFI harness that flips `S-D9` | Present only under `TUIST_FFI=1`, so the default graph builds from a clean checkout with no cross-compile; format and lint reach it only when it is generated | +| `ManagedStore` | tuist | The Swift filesystem layer, hashing, and the import pipeline | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Imports route through `ImportPort` (#390) | The picker importer writes this store directly, so picker imports never reach the timeline | +| `AssetKit` | tuist | The asset-provider abstraction over PhotoKit and the managed store | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Holds as the client's floor | — | +| `CapsuleTestSupport` | tuist | Test-only mocks and fixtures, linked only by unit-test targets | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Covers the suites still owed by lane U | Test-only; no product code depends on it | +| `ImagePipeline` | tuist | Image decode, downsample, cache and prefetch | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Holds as the client's floor | — | +| `CapsuleUI` | tuist | The design system and shared components — virtualized timeline geometry, the shared photo grid, and the one UIKit/AppKit island every grid is built on. Depends on `CapsuleDomain` and deliberately not on `CapsulePorts`: it renders domain states and must never be able to fetch one | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Accessibility inside the gate (#393) | The accessibility audit fails on most surfaces and is outside `check-swift` | +| `FeatureTimeline` | tuist | The timeline root, its sectioning and view model, selection chrome and the picker-driven library importer | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Culling review (`S-U7` remainder) | The uniform grid, pinch zoom and the zoom transition landed; culling review is outstanding | +| `FeatureViewer` | tuist | The asset viewer, its page view and info panel, and the shareable-asset surface | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Provenance and verdict detail (`S-U8` remainder) | The info panel, caption editing and the `.viewer` route landed | +| `FeatureAlbums` | tuist | The albums root, album detail, and their view models | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | `S-U9` | The smart-album screen and predicate builder | Index and detail landed over the mock ports; members and policy editors are outstanding | +| `FeatureSearch` | tuist | The search root, its filters and view model, and the clustered places map | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | `S-U10` | People index and cluster screens | Search and the clustered map landed; map granularity is still fixed | +| `FeatureTransfer` | tuist | Transfers, quota, storage reclamation, sync status, and the per-asset custody receipt | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | `S-U12` | The documented ladders and triage detail | Every screen landed and is routed; not all of the documented behaviour is built | +| `FeatureAuth` | tuist | Onboarding, sign-in, the first-device enrollment ceremony, recovery, and the device and session ledger | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | `S-P2`, `S-P3`, `S-U13`, `S-U23` | A real auth service over Keychain (`S-P2`) | Built over `Preview*` doubles; both onboarding steps are scaffolds | +| `FeatureSharing` | tuist | Share links, the guest-drop inbox, LAN peering, federation and moderation | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | `S-U14`, `S-U22` | Inbound link redemption (`S-U22`) | Share detail is outstanding; the `https` deep-link parser lands `/s/` and `/u/` on a scaffold | +| `FeatureSettings` | tuist | The eighteen-section settings tree — a grouped list on iOS, a tabbed Settings window on macOS | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Federation, advanced and about sections | Fifteen of eighteen sections landed; the Advanced mock-scenario switcher does not exist | +| `FeatureImport` | tuist | The photo-import pipeline: source picker, scan, plan confirmation, execution and history | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | `S-P4`, `S-U11` | The import→seal→upload bridge (`S-P4`) | Per-run detail is outstanding, and `S-P4` waits on `S-P2`/`S-P3` | +| `FeatureCollections` | tuist | Collections home — albums, media types, places and utilities, including the SR1-gated hidden view | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | `S-U20`, `S-U21` | Memories and duplicate review | `HiddenView` sits behind the SR1 local-auth gate, whose seam is a port | | `Capsule` | tuist | The composition root — the thin app target shared by macOS, iOS and iPadOS | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | `S-U19` | Swap the mock adapter for the SDK one (`S-U19`) | `AppEnvironment.swift` is the single file where lane P and lane U meet | | `capsule-docs` | bun | The Starlight documentation site, the design docs it publishes, and the `docs-truth` checks | stabilizing | `mise run check-docs` | [Developer Docs](capsule-docs/src/content/docs/design/developer-docs.md) | `S-Z8`, `S-Z9`, `S-Z10` | Generate the reference section (#415) | `mise run check-docs-truth` is deliberately outside both `check-docs` and `check-rust`; it needs no toolchain | -| `capsule-web` | bun | The browser client — guest drop, share viewer and the read-only gateway | stabilizing | `mise run check-web` | [Web Upload](capsule-docs/src/content/docs/design/web-upload.md) | `S-Q5` | Live-browser smokes (`S-Q5`, #409) | Every screen renders its empty state; there is no server to reach until #401 | +| `capsule-web` | bun | The read-only browser client — library viewing, the share viewer, and the guest drop it seals through `capsule-wasm` | stabilizing | `mise run check-web` | [Web Upload](capsule-docs/src/content/docs/design/web-upload.md) | `S-Q5` | Live-browser smokes (`S-Q5`, #409) | It cannot enroll a device, upload an asset or edit metadata — a browser holds neither the hardware-bound nor the write-tier key. Every screen renders its empty state; there is no server to reach until #401 | | `locales` | catalog | The canonical ICU MessageFormat catalogs — thirteen locales, the config and the schema | stabilizing | `mise run i18n-check` | [i18n](capsule-docs/src/content/docs/design/i18n.md) | — | Human review of the seeded entries | Roughly 350 machine-seeded entries across twelve locales are flagged in the `context` field and await review | -| `capsule-vision` | python | The vision and ML research notebooks behind the on-device tagging work | excluded | `mise run check-vision` | [AI](capsule-docs/src/content/docs/design/ai.md) | — | Post-v1 | Format and lint only — `check-vision` runs no notebook and no test, and nothing here ships in a client | +| `capsule-vision` | python | Experimental computer-vision work — training and export scripts, and notebooks exploring model capabilities | excluded | `mise run check-vision` | [AI](capsule-docs/src/content/docs/design/ai.md) | — | Post-v1 | Explicitly not all productionized. Format and lint only — `check-vision` runs no notebook and no test, and nothing here ships in a client | | `rawshift` | submodule | RAW decode, metadata extraction and derivative generation, in-house and out-of-tree | excluded | — | [Dependencies](capsule-docs/src/content/docs/design/dependencies.md) | — | A workspace dependency `capsule-core::media` can consume | A pinned submodule, not a workspace dependency; CI does not check it out and no gate here descends into it | | `legacy-review/server-salvo` | review-bucket | The retired Salvo server, kept as the contract the Kynos rebuild must reproduce | review-only | — | [legacy-review/README](legacy-review/README.md) | — | Deleted once `capsule-server` reaches parity | Non-buildable reference material; nothing in the workspace links it | | `legacy-review/sdk-progenitor` | review-bucket | The retired Progenitor SDK | review-only | — | [legacy-review/README](legacy-review/README.md) | — | Deleted once the spargen client covers it | Non-buildable reference material | From cd6ebe96c5b42d58ee6d64dd2e9a43fd68c8bb73 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 21:19:08 -0400 Subject: [PATCH 021/243] refactor(core)!: give execute_streaming an options struct MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `execute_streaming` and `execute_streaming_with_source_metadata` were the same function twice: eight positionals each, both behind `#[allow(clippy::too_many_arguments)]`, differing only in whether the caller supplied a `SourceMetadataIndex` or the thin twin substituted an empty one. Neither had a caller outside the barrel. They collapse into one entry point taking `StreamingOptions` — config, source, uploader, verifier, headroom margin, cancellation token — leaving four arguments. `on_event` stays positional: as a field it would force a third type parameter on the struct for no gain. `stream_candidate` takes the same struct rather than four of its fields, going from eight arguments to five. All three `#[allow(clippy::too_many_arguments)]` in the crate are gone. Behaviour is unchanged: a caller that wants no enrichment passes `&SourceMetadataIndex::empty()`, exactly what the thin twin did for it. BREAKING CHANGE: `capsule_core::import::execute_streaming_with_source_metadata` is removed and `execute_streaming` takes a `StreamingOptions` in place of its six middle arguments. --- capsule-core/src/import/mod.rs | 2 +- capsule-core/src/import/streaming.rs | 282 +++++++++++++++------------ 2 files changed, 153 insertions(+), 131 deletions(-) diff --git a/capsule-core/src/import/mod.rs b/capsule-core/src/import/mod.rs index 6e32c1d0..e8e4ea78 100644 --- a/capsule-core/src/import/mod.rs +++ b/capsule-core/src/import/mod.rs @@ -33,6 +33,6 @@ pub use scope::{IMPORT_SCOPE_V1, Scope, SourceKind}; pub use special::{SpecialDirectoryStatus, SpecialFileStatus, SpecialStatus}; pub use streaming::{ AssetUploader, StreamHalt, StreamedOutcome, StreamedState, StreamingError, StreamingEvent, - StreamingReport, UploadHalt, execute_streaming, execute_streaming_with_source_metadata, + StreamingOptions, StreamingReport, UploadHalt, execute_streaming, }; pub use upload::{StagedStreamingConflict, UploadPolicy, UploadTier, ensure_streaming_compatible}; diff --git a/capsule-core/src/import/streaming.rs b/capsule-core/src/import/streaming.rs index 9590dde0..8a8a913f 100644 --- a/capsule-core/src/import/streaming.rs +++ b/capsule-core/src/import/streaming.rs @@ -228,81 +228,73 @@ pub enum StreamingEvent { // ── The window executor ──────────────────────────────────────────────────────── +/// Everything the streaming window needs beyond the plan, the workspace, and the event sink. +/// +/// One struct rather than six positionals: the two entry points this replaced differed only in +/// `source`, and both carried eight arguments behind a +/// `#[allow(clippy::too_many_arguments)]`. A caller that wants no source-adapter enrichment +/// passes `&SourceMetadataIndex::empty()`, which is exactly what the thinner of the two +/// entry points did for it. +pub struct StreamingOptions<'a, U: AssetUploader, V: StorageVerifier> { + /// The planner configuration the run was confirmed against. + pub config: &'a ImportConfig, + /// Folded metadata a third-party [source adapter](crate::import::SourceAdapter) extracted, + /// attached to each file it covers. [`SourceMetadataIndex::empty`] for a plain filesystem + /// drive; files the index does not cover import identically either way. + pub source: &'a SourceMetadataIndex, + /// The injected upload seam. `capsule-core` performs no network I/O of its own. + pub uploader: &'a U, + /// The injected durability/custody seam behind the `S-D4` release gate. + pub verifier: &'a V, + /// The free-space cushion the minimum-headroom check and the implicit recommendation apply. + pub headroom_margin: u64, + /// Honored at every window boundary. + pub cancel: &'a CancellationToken, +} + /// Drive a streaming import: the bounded per-asset import → upload → verify → release window over -/// `plan`. `headroom_margin` is the free-space cushion the minimum-headroom check and the -/// implicit recommendation apply. `uploader` and `verifier` are the injected network seams; the -/// executor itself performs no network I/O. Honors `cancel` at every window boundary. +/// `plan`, under `opts`. +/// +/// The enrichment in [`StreamingOptions::source`] is written *inside the signed sidecar*, at the +/// same single write path the bulk executor drives, so the drive mode a user picks never changes +/// what a migrated library keeps: a storage-constrained device streaming a Takeout export lands +/// the exporter's capture time, GPS, caption, favorite, and album membership exactly as an +/// unconstrained one does (slice `S-B11`, closing the gap `S-B10` left open). This is the +/// streaming twin of +/// [`execute_with_source_metadata`](crate::import::executor::execute_with_source_metadata). /// /// Returns a [`StreamingReport`]; a hard failure to *start* (insufficient headroom, a failed /// probe) is a [`StreamingError`]. Per-asset upload pauses and gate retentions are outcomes, not /// errors: on a pause the window halts (`report.halted` set) with no further files admitted, and /// the run can be re-invoked after reconnect — the deterministic planner re-derives the remaining /// work and skips already-completed assets. -#[allow(clippy::too_many_arguments)] -pub fn execute_streaming( - plan: &ImportActionPlan, - workspace: &mut Workspace, - config: &ImportConfig, - uploader: &U, - verifier: &V, - headroom_margin: u64, - on_event: impl Fn(StreamingEvent), - cancel: &CancellationToken, -) -> Result -where - U: AssetUploader, - V: StorageVerifier, -{ - execute_streaming_with_source_metadata( - plan, - workspace, - config, - &SourceMetadataIndex::empty(), - uploader, - verifier, - headroom_margin, - on_event, - cancel, - ) -} - -/// [`execute_streaming`], with the folded metadata a third-party [source adapter] extracted -/// attached to each file it covers — the streaming twin of -/// [`execute_with_source_metadata`](crate::import::executor::execute_with_source_metadata) -/// (slice `S-B11`, closing the gap `S-B10` left open). -/// -/// The enrichment is written *inside the signed sidecar*, at the same single write path the bulk -/// executor drives, so the drive mode a user picks never changes what a migrated library keeps: -/// a storage-constrained device streaming a Takeout export lands the exporter's capture time, -/// GPS, caption, favorite, and album membership exactly as an unconstrained one does. Files the -/// index does not cover import exactly as they do through [`execute_streaming`]. -/// -/// [source adapter]: crate::import::importers #[tracing::instrument( skip_all, fields( candidates = plan.actions.len(), - mode = ?config.import_mode, - headroom = headroom_margin, - source_metadata = source.len(), + mode = ?opts.config.import_mode, + headroom = opts.headroom_margin, + source_metadata = opts.source.len(), ) )] -#[allow(clippy::too_many_arguments)] -pub fn execute_streaming_with_source_metadata( +pub fn execute_streaming( plan: &ImportActionPlan, workspace: &mut Workspace, - config: &ImportConfig, - source: &SourceMetadataIndex, - uploader: &U, - verifier: &V, - headroom_margin: u64, + opts: StreamingOptions<'_, U, V>, on_event: impl Fn(StreamingEvent), - cancel: &CancellationToken, ) -> Result where U: AssetUploader, V: StorageVerifier, { + // Every field is a shared reference or a `u64`, so this copies out of `opts` rather than + // moving it; the struct is handed to `stream_candidate` whole below. + let StreamingOptions { + config, + headroom_margin, + cancel, + .. + } = opts; // ── Exclusion: a staged upload policy can never enter the streaming window ─── // Streaming exists to release local bytes as fast as possible; staged uploads // defer exactly the T2 (original) upload that release depends on. The planner @@ -364,9 +356,7 @@ where } match decision { ImportDecision::Import => { - match stream_candidate( - workspace, config, album_id, candidate, source, uploader, verifier, &on_event, - )? { + match stream_candidate(workspace, album_id, candidate, &opts, &on_event)? { CandidateFlow::Done(outs) => report.outcomes.extend(outs), CandidateFlow::Halt(reason, outs) => { report.outcomes.extend(outs); @@ -414,21 +404,24 @@ enum CandidateFlow { /// (RAW+JPEG, Live Photo) mints one stack id and hides its non-primary members, exactly as the /// bulk executor does; the stack row is persisted once its members exist in the index — released /// originals keep their queryable asset rows, so grouping survives release. -#[allow(clippy::too_many_arguments)] fn stream_candidate( workspace: &mut Workspace, - config: &ImportConfig, album_id: Uuid, candidate: &ImportCandidate, - source: &SourceMetadataIndex, - uploader: &U, - verifier: &V, + opts: &StreamingOptions<'_, U, V>, on_event: &impl Fn(StreamingEvent), ) -> Result where U: AssetUploader, V: StorageVerifier, { + let StreamingOptions { + config, + source, + uploader, + verifier, + .. + } = *opts; let move_source = matches!(config.import_mode, ImportMode::Move); let stack = candidate.stack_type.map(|st| (Uuid::now_v7(), st)); @@ -712,12 +705,15 @@ mod tests { let report = execute_streaming( &plan, &mut ws, - &ImportConfig::default(), - &OkUploader, - &verifier, - 0, + StreamingOptions { + config: &ImportConfig::default(), + source: &SourceMetadataIndex::empty(), + uploader: &OkUploader, + verifier: &verifier, + headroom_margin: 0, + cancel: &CancellationToken::new(), + }, noop_event, - &CancellationToken::new(), ) .unwrap(); @@ -756,12 +752,15 @@ mod tests { let report = execute_streaming( &plan, &mut ws, - &ImportConfig::default(), - &OkUploader, - &verifier, - 0, + StreamingOptions { + config: &ImportConfig::default(), + source: &SourceMetadataIndex::empty(), + uploader: &OkUploader, + verifier: &verifier, + headroom_margin: 0, + cancel: &CancellationToken::new(), + }, noop_event, - &CancellationToken::new(), ) .unwrap(); @@ -808,15 +807,18 @@ mod tests { let report = execute_streaming( &plan_a, &mut ws, - &config, - &OkUploader, - &MockVerifier { - durable: true, - receipt: true, + StreamingOptions { + config: &config, + source: &SourceMetadataIndex::empty(), + uploader: &OkUploader, + verifier: &MockVerifier { + durable: true, + receipt: true, + }, + headroom_margin: 0, + cancel: &CancellationToken::new(), }, - 0, noop_event, - &CancellationToken::new(), ) .unwrap(); assert_eq!(report.released_count(), 1); @@ -839,15 +841,18 @@ mod tests { execute_streaming( &plan2, &mut ws2, - &config, - &OkUploader, - &MockVerifier { - durable: false, - receipt: true, + StreamingOptions { + config: &config, + source: &SourceMetadataIndex::empty(), + uploader: &OkUploader, + verifier: &MockVerifier { + durable: false, + receipt: true, + }, + headroom_margin: 0, + cancel: &CancellationToken::new(), }, - 0, noop_event, - &CancellationToken::new(), ) .unwrap(); assert!( @@ -884,15 +889,18 @@ mod tests { let report = execute_streaming( &plan1, &mut ws, - &config, - &uploader, - &MockVerifier { - durable: true, - receipt: true, + StreamingOptions { + config: &config, + source: &SourceMetadataIndex::empty(), + uploader: &uploader, + verifier: &MockVerifier { + durable: true, + receipt: true, + }, + headroom_margin: 0, + cancel: &CancellationToken::new(), }, - 0, noop_event, - &CancellationToken::new(), ) .unwrap(); @@ -940,15 +948,18 @@ mod tests { let report2 = execute_streaming( &plan2, &mut ws, - &config, - &OkUploader, - &MockVerifier { - durable: true, - receipt: true, + StreamingOptions { + config: &config, + source: &SourceMetadataIndex::empty(), + uploader: &OkUploader, + verifier: &MockVerifier { + durable: true, + receipt: true, + }, + headroom_margin: 0, + cancel: &CancellationToken::new(), }, - 0, noop_event, - &CancellationToken::new(), ) .unwrap(); assert!(!report2.is_halted()); @@ -978,15 +989,18 @@ mod tests { let err = execute_streaming( &plan, &mut ws, - &config, - &OkUploader, - &MockVerifier { - durable: true, - receipt: true, + StreamingOptions { + config: &config, + source: &SourceMetadataIndex::empty(), + uploader: &OkUploader, + verifier: &MockVerifier { + durable: true, + receipt: true, + }, + headroom_margin: u64::MAX, + cancel: &CancellationToken::new(), }, - u64::MAX, noop_event, - &CancellationToken::new(), ) .unwrap_err(); @@ -1029,15 +1043,18 @@ mod tests { let err = execute_streaming( &plan, &mut ws, - &config, - &OkUploader, - &MockVerifier { - durable: true, - receipt: true, + StreamingOptions { + config: &config, + source: &SourceMetadataIndex::empty(), + uploader: &OkUploader, + verifier: &MockVerifier { + durable: true, + receipt: true, + }, + headroom_margin: 0, + cancel: &CancellationToken::new(), }, - 0, noop_event, - &CancellationToken::new(), ) .unwrap_err(); @@ -1105,19 +1122,21 @@ mod tests { fn stream_with(index: &SourceMetadataIndex, src: &Path, ws: &mut Workspace) -> StreamingReport { let config = ImportConfig::default(); let plan = plan(&scan(&[src.to_path_buf()]).unwrap(), ws.db(), &config).unwrap(); - execute_streaming_with_source_metadata( + execute_streaming( &plan, ws, - &config, - index, - &OkUploader, - &MockVerifier { - durable: false, - receipt: true, + StreamingOptions { + config: &config, + source: index, + uploader: &OkUploader, + verifier: &MockVerifier { + durable: false, + receipt: true, + }, + headroom_margin: 0, + cancel: &CancellationToken::new(), }, - 0, noop_event, - &CancellationToken::new(), ) .unwrap() } @@ -1187,15 +1206,18 @@ mod tests { execute_streaming( &plan, &mut ws, - &ImportConfig::default(), - &OkUploader, - &MockVerifier { - durable: true, - receipt: true, + StreamingOptions { + config: &ImportConfig::default(), + source: &SourceMetadataIndex::empty(), + uploader: &OkUploader, + verifier: &MockVerifier { + durable: true, + receipt: true, + }, + headroom_margin: 0, + cancel: &CancellationToken::new(), }, - 0, noop_event, - &CancellationToken::new(), ) .unwrap(); From dae49f42da7216872ac9d08beba3ee7b23f06af6 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 21:19:50 -0400 Subject: [PATCH 022/243] fix(xtask): keep a localized defaultValue out of the widened detector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewing the previous commit's own diff: the new dictionary-value pattern matched any `label: "Some text"` followed by a comma, which reads the `defaultValue:` of `String(localized:defaultValue:comment:)` as a dictionary entry. That argument is the English source text the ICU arguments hang off — the migrated shape, deliberately never captured — so the guard would have failed a call site that is correct. The key must now begin with `.`, which is what a dictionary key in this codebase looks like and what an argument label never does. Measured across `capsule-swift/{App,Modules}`: the finding set is unchanged, still exactly "Dolby Vision". Also records the `func` form's own limit: the signature is read with `\([^)]*\)`, so a parameter list containing a closure type is not matched. A blind spot, but a narrowing one — it can never produce a false positive. --- xtask/src/i18n_guard.rs | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/xtask/src/i18n_guard.rs b/xtask/src/i18n_guard.rs index 77e04c2f..7bf4228a 100644 --- a/xtask/src/i18n_guard.rs +++ b/xtask/src/i18n_guard.rs @@ -421,7 +421,10 @@ fn swift_key_parameter_findings(content: &str) -> Vec { /// # What is scanned /// /// A member whose name ends in one of [`SWIFT_DISPLAY_STEMS`] and whose type is -/// `String` — a `var`, or (since #394) a `func` such as `hdrName(_:) -> String`. Inside +/// `String` — a `var`, or (since #394) a `func` such as `hdrName(_:) -> String`. The +/// function form reads the signature with `\([^)]*\)`, so one whose parameters contain a +/// nested `)` (a closure type) is not matched: a remaining blind spot, but a narrowing +/// one, never a false positive. Inside /// its body, four literal positions, because a property returns display text in more /// shapes than a `switch`: a `case` arm, an explicit `return`, a bare literal on its own /// line (an implicit return, or an `if` branch), and a dictionary value. Hits are keyed by @@ -456,8 +459,12 @@ fn swift_computed_property_findings(content: &str) -> Vec { format!(r#"return\s+"({SWIFT_DISPLAY_LITERAL})""#), // A bare literal statement: an implicit return, or an `if`/`else` branch. format!(r#"(?m)^\s*"({SWIFT_DISPLAY_LITERAL})"\s*$"#), - // A dictionary or array value: `[.places: "Places"]`. - format!(r#":\s*"({SWIFT_DISPLAY_LITERAL})"\s*[,\]]"#), + // A dictionary or array value: `[.places: "Places"]`. The key must start + // with `.`, so a *labelled argument* is not mistaken for one — in + // particular `String(localized:defaultValue:)`, whose `defaultValue:` is the + // English source text and is deliberately never captured (see + // [`swift_localized_key_findings`]). + format!(r#"\.[A-Za-z0-9_]+\s*:\s*"({SWIFT_DISPLAY_LITERAL})"\s*[,\]]"#), ] .iter() .map(|pattern| Regex::new(pattern).expect("static regex is valid")) @@ -1225,6 +1232,28 @@ mod tests { assert_eq!(findings[0].text, "Places and trips"); } + #[test] + fn swift_display_members_ignore_a_localized_default_value() { + // `String(localized:defaultValue:)` is the *migrated* shape: the key is checked + // against the catalog and the default value is the English source the ICU + // arguments hang off. Neither is a hardcoded literal, and the dictionary-value + // pattern must not read `defaultValue:` as a dictionary key. + let src = r#" + var title: String { + String( + localized: "app.timeline.delete_selected.confirm", + defaultValue: "Delete Items", + comment: "Confirm button" + ) + } + "#; + let texts: Vec = swift_computed_property_findings(src) + .into_iter() + .map(|f| f.text) + .collect(); + assert_eq!(texts, Vec::::new()); + } + #[test] fn swift_watches_every_api_position_in_both_regexes() { // `confirmationDialog` was in the interpolation regex and not the literal one; From 325a23e2dad3553c3f51c87374a6cf186e676239 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 21:30:14 -0400 Subject: [PATCH 023/243] docs(core): fix the intra-doc links the rustdoc gate rejects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cargo doc` was never a gate, so 73 intra-doc link errors had accumulated across `capsule-core`, `capsule-wasm` and `capsule-i18n`. This clears them so the next commit can gate on it. No code changes. Six links pointed at `crate::media`, a module that left the tree, and three doc comments described a `media` feature `capsule-core/Cargo.toml` has never declared. Both are demoted to prose that says what is true: the media stack is retired to `legacy-review/` and restoring it is `S-B1`, so this build links no still encoder and no codecs at all. Fourteen links named genuinely private items (`PINNED_CIPHERSUITE`, `CapsuleMlsProvider`, `MlsAppPayload`, `require_canonical_runner`, `gcj02_to_bd09`, the `db::migrate` internals, …). Those become code spans: a private item has no URL, and pretending otherwise is what the lint objects to. Seven `verify_asset` links were ambiguous — `crate::crypto::verify_asset` is both a module and a function. Every one of them meant the function; they are disambiguated with `fn@`. Thirteen were redundant explicit link targets, resolving to exactly what the shortcut already resolved to. Fourteen were unresolved because the crate root carries its own `///` doc on `pub mod sharing;`, `pub mod client_build;` and `pub mod lqip;`. rustdoc merges those with each module's `//!` header and resolves the merged text in the crate-root scope, where `LINK_SECRET_LEN`, `Lqip`, `BUILD_COMMIT` and the rest are not in scope. Fully qualifying them makes the link independent of which scope wins. The remaining 22 doc links spelled a path through a submodule the previous commit made crate-private. They still resolved — the target items are re-exported — but a frozen API documenting a second spelling of its own paths is the thing this series exists to remove, so each moves to its barrel path. `capsule-wasm` and `capsule-i18n` had one each, both naming a private module in crate-level prose. --- capsule-core/src/client_build.rs | 6 ++- capsule-core/src/crypto/authority/mod.rs | 2 +- .../authority/openmls_authority/identity.rs | 2 +- .../crypto/authority/openmls_authority/mod.rs | 10 ++-- capsule-core/src/crypto/keys/album.rs | 2 +- capsule-core/src/crypto/keys/directory.rs | 2 +- capsule-core/src/crypto/keys/hardware.rs | 2 +- capsule-core/src/crypto/keys/keystore.rs | 2 +- .../src/crypto/provenance/manifest.rs | 2 +- capsule-core/src/crypto/provenance/mod.rs | 2 +- capsule-core/src/crypto/upgrade.rs | 4 +- capsule-core/src/culling.rs | 2 +- capsule-core/src/db/driver.rs | 8 +-- capsule-core/src/db/migrate.rs | 10 ++-- capsule-core/src/db/schema.rs | 2 +- capsule-core/src/domain/gps_datum.rs | 2 +- capsule-core/src/domain/model_identity.rs | 4 +- capsule-core/src/import/enrichment.rs | 4 +- capsule-core/src/import/executor.rs | 13 ++--- capsule-core/src/import/importers/mod.rs | 8 +-- capsule-core/src/import/importers/takeout.rs | 2 +- capsule-core/src/import/planner.rs | 4 +- capsule-core/src/import/streaming.rs | 10 ++-- capsule-core/src/library/space.rs | 2 +- capsule-core/src/library/storage_verify.rs | 2 +- capsule-core/src/lifecycle/import.rs | 14 ++--- capsule-core/src/lifecycle/metadata.rs | 2 +- capsule-core/src/lifecycle/mod.rs | 52 +++++++++---------- capsule-core/src/lifecycle/open.rs | 4 +- capsule-core/src/lifecycle/provenance.rs | 2 +- capsule-core/src/lifecycle/upload.rs | 2 +- capsule-core/src/lqip/mod.rs | 27 +++++----- capsule-core/src/ml/mod.rs | 26 +++++----- capsule-core/src/ml/orchestrator.rs | 6 +-- capsule-core/src/ml/registry.rs | 2 +- capsule-core/src/sharing/mod.rs | 22 ++++---- capsule-core/src/sidecar/io.rs | 2 +- capsule-core/src/utils/paths.rs | 4 +- capsule-core/src/validation/mod.rs | 2 +- capsule-core/src/validation/structural.rs | 4 +- capsule-i18n/src/lib.rs | 4 +- capsule-wasm/src/lib.rs | 4 +- 42 files changed, 146 insertions(+), 142 deletions(-) diff --git a/capsule-core/src/client_build.rs b/capsule-core/src/client_build.rs index 3f0859e2..04c1e569 100644 --- a/capsule-core/src/client_build.rs +++ b/capsule-core/src/client_build.rs @@ -12,8 +12,10 @@ //! `capsule-android`, `capsule-desktop`, `capsule-cli`, `capsule-web`, or an out-of-repo //! client's own stable id). //! - `commit` — the git commit of the client's own source tree (a `≥ 12`-hex prefix), embedded -//! at build time by [`build.rs`](../build.rs) as [`BUILD_COMMIT`]. -//! - `.dirty` — appended ([`BUILD_DIRTY_SUFFIX`]) when built from a modified tree. +//! at build time by [`build.rs`](../build.rs) as +//! [`BUILD_COMMIT`](crate::client_build::BUILD_COMMIT). +//! - `.dirty` — appended ([`BUILD_DIRTY_SUFFIX`](crate::client_build::BUILD_DIRTY_SUFFIX)) when +//! built from a modified tree. //! //! The value is **audit-only** and never load-bearing for authorization: `verify_asset` does not //! gate on the grammar (a nonconforming string still verifies), so this module is producer diff --git a/capsule-core/src/crypto/authority/mod.rs b/capsule-core/src/crypto/authority/mod.rs index f05cd476..0b415ad7 100644 --- a/capsule-core/src/crypto/authority/mod.rs +++ b/capsule-core/src/crypto/authority/mod.rs @@ -20,7 +20,7 @@ //! Because `verify_asset` consumes only `&dyn AlbumAuthority`, the two are interchangeable; the //! [`Authority`] enum lets a caller store either without naming a concrete backend. //! -//! [`verify_asset`]: crate::crypto::verify_asset +//! [`verify_asset`]: fn@crate::crypto::verify_asset //! SSoT for the rules this seam encodes: [Keys — Write Authorization]. //! //! [Keys — Write Authorization]: https://docs/design/cryptography/keys/#write-authorization diff --git a/capsule-core/src/crypto/authority/openmls_authority/identity.rs b/capsule-core/src/crypto/authority/openmls_authority/identity.rs index b35cb589..7cfc8300 100644 --- a/capsule-core/src/crypto/authority/openmls_authority/identity.rs +++ b/capsule-core/src/crypto/authority/openmls_authority/identity.rs @@ -140,7 +140,7 @@ pub(crate) fn verify_leaf_binding( Ok((binding.core.user_id, binding.core.device_id)) } -/// One device's MLS participation identity: the owned OpenMLS [provider](CapsuleMlsProvider) +/// One device's MLS participation identity: the owned OpenMLS provider (`CapsuleMlsProvider`) /// (crypto + rand + serializable storage), the device's **MLS Ed25519 leaf signer**, and the /// device's **hybrid DSK identity key** used to attest the leaf binding. /// diff --git a/capsule-core/src/crypto/authority/openmls_authority/mod.rs b/capsule-core/src/crypto/authority/openmls_authority/mod.rs index d0916f8d..70a267ff 100644 --- a/capsule-core/src/crypto/authority/openmls_authority/mod.rs +++ b/capsule-core/src/crypto/authority/openmls_authority/mod.rs @@ -90,7 +90,7 @@ use crate::crypto::keys::{AmkVersion, DeviceDirectory, HybridSigningKey, HybridV pub(crate) const PINNED_CIPHERSUITE: Ciphersuite = Ciphersuite::MLS_256_XWING_CHACHA20POLY1305_SHA256_Ed25519; -/// The wire codepoint of [`PINNED_CIPHERSUITE`]. Asserted in tests so an upstream re-pin can +/// The wire codepoint of `PINNED_CIPHERSUITE`. Asserted in tests so an upstream re-pin can /// never silently change the suite Capsule negotiates. pub const PINNED_CIPHERSUITE_ID: u16 = 0x004D; @@ -163,7 +163,7 @@ pub enum OpenMlsAuthorityError { /// upgraded and all activity has moved to the fork. Reads are unaffected — only writes refuse. #[error("album is tombstoned under upgrade intent {0}")] Tombstoned(Uuid), - /// A step of the [tombstone-plus-fork upgrade ceremony](upgrade) failed (intent signature, + /// A step of the tombstone-plus-fork upgrade ceremony failed (intent signature, /// quiescence conflict, or fork construction). #[error("album upgrade ceremony: {0}")] Upgrade(String), @@ -172,7 +172,7 @@ pub enum OpenMlsAuthorityError { /// normal operation (each member independently). #[error("frozen-state hash mismatch: at least one member's album view diverges")] FrozenStateMismatch, - /// A [resilience](resilience) operation (reconciliation / re-keying) failed. + /// A resilience operation (reconciliation / re-keying) failed. #[error("mls resilience: {0}")] Resilience(String), /// [`block_user`](OpenMlsAuthority::block_user) was asked to block the **local** user. A device @@ -312,7 +312,7 @@ impl BlockOutcome { } } -/// An [`AlbumAuthority`] backed by a live OpenMLS group pinned to [`PINNED_CIPHERSUITE`]. +/// An [`AlbumAuthority`] backed by a live OpenMLS group pinned to `PINNED_CIPHERSUITE`. /// /// One instance owns one album's group **as seen by one device**. Its epoch ledger is produced by /// real MLS commits (self-update, add, remove) and Welcome processing; every `verify_asset` answer @@ -1189,7 +1189,7 @@ impl OpenMlsAuthority { /// **Roles seam:** core has no roles model yet, so every member is a writer and this returns /// all leaves — which is what makes the group-encrypted application channel a faithful /// "writers-only" delivery today. When the roles model lands, this filter narrows to the - /// role-holding leaves and [`build_write_tier_distribution`](Self::build_write_tier_distribution)'s + /// role-holding leaves and `build_write_tier_distribution`'s /// send-path switches to per-writer delivery (the group channel is readable by every member, /// so a proper subset cannot ride it). pub fn writers(&self) -> Vec { diff --git a/capsule-core/src/crypto/keys/album.rs b/capsule-core/src/crypto/keys/album.rs index cd11a6c1..655bc7a7 100644 --- a/capsule-core/src/crypto/keys/album.rs +++ b/capsule-core/src/crypto/keys/album.rs @@ -32,7 +32,7 @@ impl AmkVersion { /// A random 32-byte album content key for one epoch. Holding it lets you decrypt; not /// holding it means you cannot (secrecy is enforced by encryption, authorization by -/// signatures — see [`super::hybrid_sig`] write-tier keys). +/// signatures — see [`HybridSigningKey`](super::HybridSigningKey) write-tier keys). #[derive(Clone)] pub struct Amk([u8; 32]); diff --git a/capsule-core/src/crypto/keys/directory.rs b/capsule-core/src/crypto/keys/directory.rs index 1f207569..b90936c0 100644 --- a/capsule-core/src/crypto/keys/directory.rs +++ b/capsule-core/src/crypto/keys/directory.rs @@ -46,7 +46,7 @@ use crate::crypto::CryptoError; /// - the software **X-Wing** DEK, `pk_M ‖ pk_X` — [`DEK_PUBLIC_LEN`] (1216) bytes; /// - the hardware-bound **P-256 hybrid** DEK, `ek_M ‖ pk_P` — [`DEK_P256_PUBLIC_LEN`] (1249). /// -/// They are **length-disjoint by construction** (see [`kem_p256`](super::kem_p256)), so the +/// They are **length-disjoint by construction** (see [`P256HybridDek`](super::P256HybridDek)), so the /// composition is recovered from the bytes themselves and needs no tag — the same way a /// [`HybridVerifyingKey`] recovers its Ed25519-vs-P-256 classical half from the wire length, and /// the same way [`DeviceDek::public_bytes`](super::keystore::DeviceDek::public_bytes) already diff --git a/capsule-core/src/crypto/keys/hardware.rs b/capsule-core/src/crypto/keys/hardware.rs index c9fa2148..ce781b4f 100644 --- a/capsule-core/src/crypto/keys/hardware.rs +++ b/capsule-core/src/crypto/keys/hardware.rs @@ -51,7 +51,7 @@ pub trait HardwareSigner: Send + Sync { /// [`HardwareSigner`], implemented by native code (Swift/Kotlin) over the uniffi foreign-trait /// boundary. It backs the hardware-bound classical half of the device **encryption** key (DEK): /// shipping secure elements expose **ECDH-P256** (Secure Enclave `P256.KeyAgreement`, StrongBox -/// ECDH, TPM 2.0 ECDH), so the DEK's X25519 half of [the X-Wing KEM](super::kem) is replaced by a +/// ECDH, TPM 2.0 ECDH), so the DEK's X25519 half of [the X-Wing KEM](super::DekKeypair) is replaced by a /// hardware-held P-256 static key while the ML-KEM-768 half stays software-sealed. The element /// holds the P-256 private key and performs the ECDH so it never leaves hardware; Rust drives the /// ML-KEM half and the hybrid combiner (SSoT: [Cryptography — Keys § Device Keys], slice `S-F5`). diff --git a/capsule-core/src/crypto/keys/keystore.rs b/capsule-core/src/crypto/keys/keystore.rs index 6221bdaf..317ecdd4 100644 --- a/capsule-core/src/crypto/keys/keystore.rs +++ b/capsule-core/src/crypto/keys/keystore.rs @@ -41,7 +41,7 @@ use crate::crypto::{CryptoError, pwkdf}; /// /// Both expose the same two operations — publish a public encapsulation key, decapsulate a /// ciphertext sealed to it — so every caller is agnostic to where the classical half lives. The -/// two byte formats are length-disjoint (see [`kem_p256`](super::kem_p256)), so a ciphertext for +/// two byte formats are length-disjoint (see [`P256HybridDek`](super::P256HybridDek)), so a ciphertext for /// one is rejected outright by the other rather than silently recovering a wrong secret. pub enum DeviceDek { /// **Software fallback.** X-Wing (X25519 + ML-KEM-768), both halves in software. The diff --git a/capsule-core/src/crypto/provenance/manifest.rs b/capsule-core/src/crypto/provenance/manifest.rs index f8d115e7..b859603a 100644 --- a/capsule-core/src/crypto/provenance/manifest.rs +++ b/capsule-core/src/crypto/provenance/manifest.rs @@ -6,7 +6,7 @@ //! excludes the signatures, so signing bytes are unambiguous and downgrade-resistant //! (both sigs cover `crypto_suite_id`, `protocol_version`, and `prior_provenance_hash`). //! -//! [`verify_asset`]: crate::crypto::verify_asset +//! [`verify_asset`]: fn@crate::crypto::verify_asset //! [Cryptography — Provenance]: https://docs/design/cryptography/provenance/ use serde::{Deserialize, Serialize}; diff --git a/capsule-core/src/crypto/provenance/mod.rs b/capsule-core/src/crypto/provenance/mod.rs index 18133f53..23562827 100644 --- a/capsule-core/src/crypto/provenance/mod.rs +++ b/capsule-core/src/crypto/provenance/mod.rs @@ -7,7 +7,7 @@ //! //! Verification of all of this flows through the single [`verify_asset`] chokepoint. //! -//! [`verify_asset`]: crate::crypto::verify_asset +//! [`verify_asset`]: fn@crate::crypto::verify_asset //! [Cryptography — Provenance]: https://docs/design/cryptography/provenance/ pub mod action; diff --git a/capsule-core/src/crypto/upgrade.rs b/capsule-core/src/crypto/upgrade.rs index 08f42d6a..695efcdc 100644 --- a/capsule-core/src/crypto/upgrade.rs +++ b/capsule-core/src/crypto/upgrade.rs @@ -24,7 +24,7 @@ //! # What the server can decide from here, and what it cannot //! //! It can verify [`SignedUpgradeIntent`] against the account's published -//! [`DeviceDirectory`](crate::crypto::keys::DeviceDirectory) — the same trust anchor `S-C42` +//! [`DeviceDirectory`] — the same trust anchor `S-C42` //! established — so a quiescence it records is one an admin device really asked for. It can //! evaluate [`UpgradeIntent::is_expired`] against its own clock, which is the whole point of the //! deadline being a *duration*: a skewed member clock can neither extend nor shorten the window. @@ -104,7 +104,7 @@ impl UpgradeIntent { } /// An [`UpgradeIntent`] plus the proposing admin device's DSK **hybrid** signature over it. Rides -/// the group's application-message channel (self-describing as [`MlsAppPayload::Upgrade`]). +/// the group's application-message channel (self-describing as the `MlsAppPayload::Upgrade` variant). #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct SignedUpgradeIntent { /// The proposed upgrade. diff --git a/capsule-core/src/culling.rs b/capsule-core/src/culling.rs index d60bda18..52a7f229 100644 --- a/capsule-core/src/culling.rs +++ b/capsule-core/src/culling.rs @@ -1,7 +1,7 @@ //! The client-side **culling** workflow engine (SSoT: [Organization — Culling]). //! //! Culling is the review pass a photographer makes after a shoot: keep, undecided, toss. -//! Capsule models it as a trinary [`CullFlag`](crate::sidecar::sidecar_v1::CullFlag) per asset +//! Capsule models it as a trinary [`CullFlag`] per asset //! stored in the sidecar's `cull` LWW register. This module owns the *derived* pieces the //! schema deliberately does **not** store, so there is no second source of truth to diverge: //! diff --git a/capsule-core/src/db/driver.rs b/capsule-core/src/db/driver.rs index c4dff898..667cd1ba 100644 --- a/capsule-core/src/db/driver.rs +++ b/capsule-core/src/db/driver.rs @@ -11,7 +11,7 @@ use crate::domain::model_identity::TaskKind; pub struct DatabaseDriver { pub(in crate::db) conn: Connection, /// Tasks whose `vec0` partition table this driver has already created. The DDL is idempotent, - /// so this is purely a hot-path memo — see [`crate::db::vector::VectorTableSpec`] for why the + /// so this is purely a hot-path memo — see [`crate::db::VectorTableSpec`] for why the /// tables are created by their writer rather than at open time. pub(in crate::db) vector_tables: RefCell>, } @@ -40,8 +40,8 @@ impl DatabaseDriver { } } - /// Bring the catalog to [`crate::db::schema::SCHEMA_VERSION`], creating it if the database is empty - /// and otherwise migrating it forward (see [`crate::db::migrate`]). + /// Bring the catalog to the crate's `SCHEMA_VERSION`, creating it if the database is empty + /// and otherwise migrating it forward (see the crate-private `db::migrate`). /// /// The migrator's typed [`MigrationError`] is flattened into `rusqlite::Error` here because /// this signature is consumed by `capsule-core-ffi` and `library::open`; callers that want @@ -55,7 +55,7 @@ impl DatabaseDriver { Ok(()) } - /// Bring the catalog to [`crate::db::schema::SCHEMA_VERSION`], reporting exactly what ran. + /// Bring the catalog to the crate's `SCHEMA_VERSION`, reporting exactly what ran. /// /// Refuses (without writing anything) a catalog stamped newer than this build supports — /// see [`MigrationError::CatalogTooNew`]. diff --git a/capsule-core/src/db/migrate.rs b/capsule-core/src/db/migrate.rs index 0324d179..a6bedf24 100644 --- a/capsule-core/src/db/migrate.rs +++ b/capsule-core/src/db/migrate.rs @@ -193,7 +193,7 @@ const STEP_1_TO_2: Step = Step { /// /// The `vec0` partition tables are *not* created here: their vector dimension is declared by /// the model registry, so they are created at runtime by their writer (see -/// [`crate::db::vector::VectorTableSpec`]). +/// [`crate::db::VectorTableSpec`]). /// /// The first two statements re-assert the v2 shape. That is not redundancy: two branches /// stamped 2 for different additions, so a v2-stamped catalog is missing one of the two tables @@ -294,7 +294,7 @@ const STEP_FINGERPRINTS: &[&str] = &[ // ── Errors ────────────────────────────────────────────────────────────────── -/// Why a catalog could not be brought to [`SCHEMA_VERSION`]. +/// Why a catalog could not be brought to `SCHEMA_VERSION`. #[derive(Debug, thiserror::Error)] pub enum MigrationError { /// The catalog was written by a newer build than this one. @@ -354,7 +354,7 @@ impl From for rusqlite::Error { // ── Outcome ───────────────────────────────────────────────────────────────── -/// One applied step, as reported by [`migrate`]. +/// One applied step, as reported by `migrate`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Applied { pub from: u32, @@ -362,13 +362,13 @@ pub struct Applied { pub name: &'static str, } -/// What [`migrate`] did. Recorded so the CLI, the FFI host, and a support bundle can all say +/// What `migrate` did. Recorded so the CLI, the FFI host, and a support bundle can all say /// exactly what ran against a user's library. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Outcome { /// The version the catalog reported on open (`0` for a catalog this call created). pub from: u32, - /// The version it is stamped at now — always [`SCHEMA_VERSION`] on success. + /// The version it is stamped at now — always `SCHEMA_VERSION` on success. pub to: u32, /// True when the catalog was empty and the current schema was created outright. pub created: bool, diff --git a/capsule-core/src/db/schema.rs b/capsule-core/src/db/schema.rs index f72977b4..d53254dc 100644 --- a/capsule-core/src/db/schema.rs +++ b/capsule-core/src/db/schema.rs @@ -19,7 +19,7 @@ /// only through the gated Hidden view (SSoT: design/organization § Hidden Assets). /// Distinct from `is_stack_hidden`, which suppresses non-primary stack members. /// -/// **Bumping this constant requires appending a step to [`crate::db::migrate::STEPS`]** — +/// **Bumping this constant requires appending a step to `db::migrate::STEPS`** — /// `steps_form_a_contiguous_chain` fails otherwise. Never edit a step that has shipped. pub(crate) const SCHEMA_VERSION: u32 = 4; diff --git a/capsule-core/src/domain/gps_datum.rs b/capsule-core/src/domain/gps_datum.rs index 8ec19d38..01daeb14 100644 --- a/capsule-core/src/domain/gps_datum.rs +++ b/capsule-core/src/domain/gps_datum.rs @@ -142,7 +142,7 @@ fn gcj02_to_bd09(lat: f64, lon: f64) -> (f64, f64) { /// Fold a BD-09 input coordinate to GCJ-02 at the input edge, returning the `(lat, lon)` /// to store under [`GpsDatum::Gcj02`]. /// -/// Only [`gcj02_to_bd09`] — the *forward* direction — is closed-form, so this is the +/// Only `gcj02_to_bd09` — the *forward* direction — is closed-form, so this is the /// **error-bounded iterative inverse** of it: seed with the offset-removed coordinate, then /// repeatedly push the estimate forward, measure the residual against the input, and /// correct by it. The forward map is the identity plus a small perturbation, so the diff --git a/capsule-core/src/domain/model_identity.rs b/capsule-core/src/domain/model_identity.rs index 8673c4ec..1044e57d 100644 --- a/capsule-core/src/domain/model_identity.rs +++ b/capsule-core/src/domain/model_identity.rs @@ -55,7 +55,7 @@ impl TaskKind { /// A model identifier (stable across versions; e.g. `mobileclip-b`). Declared in exactly one /// [`ModelRow`](crate::ml::ModelRow). Serializes transparently as its string so it interoperates -/// with the `model_id` fields on [`AiTag`](crate::sidecar::sidecar_v1::AiTag) and +/// with the `model_id` fields on [`AiTag`](crate::sidecar::AiTag) and /// [`DerivativeCore`](crate::crypto::provenance::manifest::DerivativeCore). #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] #[serde(transparent)] @@ -127,7 +127,7 @@ pub enum DistanceMetric { } /// Refusals from the embedding-provenance invariant — produced by the -/// [gate](crate::db::vector::EmbeddingProvenance) the vector index calls on every insert, and +/// [gate](crate::db::EmbeddingProvenance) the vector index calls on every insert, and /// carried outward by [`VectorIndexError`](crate::db::VectorIndexError). #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum RegistryError { diff --git a/capsule-core/src/import/enrichment.rs b/capsule-core/src/import/enrichment.rs index 47009882..0149d1c8 100644 --- a/capsule-core/src/import/enrichment.rs +++ b/capsule-core/src/import/enrichment.rs @@ -5,7 +5,7 @@ //! extraction — embedded EXIF wins over the exporter's records, **except** for constructs the //! exporter is authoritative for (album membership, favorites/rating, user-typed descriptions). //! This module is the other half: it maps that folded record onto the fields the signed -//! [`SidecarV1`](crate::sidecar::sidecar_v1::SidecarV1) actually has, and indexes it by source +//! [`SidecarV1`](crate::sidecar::SidecarV1) actually has, and indexes it by source //! path so the [executor](crate::import::executor) can attach it to the member it is importing. //! //! The mapping, one row per [Takeout mapping-table] rule: @@ -344,7 +344,7 @@ mod archive_tests { use super::*; use crate::crypto::primitives::Argon2Params; - use crate::import::executor::execute_with_source_metadata; + use crate::import::execute_with_source_metadata; use crate::import::executor_cancellation::CancellationToken; use crate::import::importers::SourceAdapter; use crate::import::importers::takeout::TakeoutAdapter; diff --git a/capsule-core/src/import/executor.rs b/capsule-core/src/import/executor.rs index 6f768277..48ef0888 100644 --- a/capsule-core/src/import/executor.rs +++ b/capsule-core/src/import/executor.rs @@ -2,16 +2,17 @@ //! //! Each `ImportDecision::Import` candidate is imported through //! [`Workspace::import_asset_with`](crate::lifecycle::Workspace::import_asset_with): every member -//! becomes a signed [`SidecarV1`](crate::sidecar::sidecar_v1::SidecarV1) + signed manifest + +//! becomes a signed [`SidecarV1`](crate::sidecar::SidecarV1) + signed manifest + //! append-only provenance, self-verified through -//! [`verify_asset`](crate::crypto::verify_asset::verify_asset), and — behind the `media` feature, -//! when a [`StillEncoder`](crate::media::image::derivative::StillEncoder) is attached to the -//! workspace — with signed thumbnail/preview derivatives + an LQIP in the sidecar. +//! [`verify_asset`](crate::crypto::verify_asset::verify_asset), and — when a still encoder is +//! attached to the workspace — with signed thumbnail/preview derivatives + an LQIP in the +//! sidecar. No still encoder exists in this build: the media stack is retired to +//! `legacy-review/` and restoring it is `S-B1`. //! //! This retired the legacy unsigned `AssetSidecar` write path from the executor; the production //! write path itself is now gone (`S-G4`) — no code writes unsigned sidecars anymore. Only the //! *read* path survives, for the recovery-first index rebuild -//! ([`rebuild_index`](crate::library::rebuild::rebuild_index)) that still ingests unsigned `.cbor` +//! ([`rebuild_index`](crate::library::rebuild_index)) that still ingests unsigned `.cbor` //! sidecars left by pre-signed-path libraries. The pure planner (`import::planner`) is unchanged: //! it still decides *what* to import; the executor decides *how* to commit it. @@ -68,7 +69,7 @@ pub fn execute( /// discarded once the plan was built. A file the index does not cover imports exactly as it /// does through [`execute`]. /// -/// [source adapter]: crate::import::importers +/// [source adapter]: crate::import::SourceAdapter /// [precedence rule]: https://docs/design/import/pipeline/#third-party-importers #[tracing::instrument( skip_all, diff --git a/capsule-core/src/import/importers/mod.rs b/capsule-core/src/import/importers/mod.rs index 60350b7c..3ebc2479 100644 --- a/capsule-core/src/import/importers/mod.rs +++ b/capsule-core/src/import/importers/mod.rs @@ -14,7 +14,7 @@ //! the same [`ScanResult`] on every run. //! //! The folded record is not planner input only. The -//! [executor](crate::import::executor::execute_with_source_metadata) writes it into the **signed +//! [executor](crate::import::execute_with_source_metadata) writes it into the **signed //! sidecar** at import through [`enrichment`](crate::import::enrichment) (`S-B10`) — capture time //! and GPS as fallbacks behind the file's own EXIF, and the exporter-authoritative constructs //! (description, favorites, album membership) unconditionally — so a migrated library keeps what @@ -89,7 +89,7 @@ pub struct GeoPoint { /// The out-of-band metadata an adapter folds for one media entry, with the precedence already /// resolved. This is the artifact the [Takeout mapping table] validates, one field per rule, and -/// what [`sidecar_enrichment`](crate::import::enrichment::sidecar_enrichment) maps onto the +/// what [`sidecar_enrichment`](crate::import::sidecar_enrichment) maps onto the /// signed sidecar's fields at import. /// /// [Takeout mapping table]: https://docs/design/import/pipeline/#validation @@ -130,7 +130,7 @@ impl Default for ExtractedMetadata { /// exporter [`ExtractedMetadata`] for its primary media file. #[derive(Debug, Clone)] pub struct SourceEntry { - /// The candidate fed verbatim to [`plan`](crate::import::planner::plan). + /// The candidate fed verbatim to [`plan`](crate::import::plan). pub candidate: ImportCandidate, /// The folded out-of-band metadata for this entry's primary media file. pub metadata: ExtractedMetadata, @@ -152,7 +152,7 @@ pub struct ExtractedImport { } impl ExtractedImport { - /// Build the [`ScanResult`] the pure [`planner`](crate::import::planner) consumes. This is the + /// Build the [`ScanResult`] the pure [planner](crate::import::plan) consumes. This is the /// only handoff into the pipeline — the adapter **feeds** the planner and never modifies it. pub fn to_scan_result(&self) -> ScanResult { ScanResult { diff --git a/capsule-core/src/import/importers/takeout.rs b/capsule-core/src/import/importers/takeout.rs index 8430aecc..ee01313e 100644 --- a/capsule-core/src/import/importers/takeout.rs +++ b/capsule-core/src/import/importers/takeout.rs @@ -936,7 +936,7 @@ mod tests { #[test] fn fixture_import_is_resumable_and_skips_completed_work() { use crate::crypto::primitives::Argon2Params; - use crate::import::executor::execute; + use crate::import::execute; use crate::import::executor_cancellation::CancellationToken; use crate::import::planner::{ImportConfig, plan}; use crate::lifecycle::Workspace; diff --git a/capsule-core/src/import/planner.rs b/capsule-core/src/import/planner.rs index ab553076..48225c5c 100644 --- a/capsule-core/src/import/planner.rs +++ b/capsule-core/src/import/planner.rs @@ -71,7 +71,7 @@ pub struct ImportActionPlan { /// [`attach_streaming_recommendation`](Self::attach_streaming_recommendation) — never by the /// pure planner (the probe is I/O). `None` until attached; `Some(true)` means the library /// volume is near/over full for this plan's `total_size` and the user should confirm a - /// [streaming import](crate::import::streaming). Recording it on the plan (like the resolved + /// [streaming import](crate::import::execute_streaming). Recording it on the plan (like the resolved /// destination `album_id`) keeps the planner deterministic. pub streaming_recommended: Option, /// The destination album and **which resolution rule chose it** (SSoT: organization @@ -141,7 +141,7 @@ impl ImportActionPlan { /// `policy` and whether a streaming import was chosen (`use_streaming`); a /// conflicting combination returns [`StagedStreamingConflict`] instead of ever /// entering the executor. Delegates to the pure - /// [`ensure_streaming_compatible`](crate::import::upload::ensure_streaming_compatible) + /// [`ensure_streaming_compatible`](crate::import::ensure_streaming_compatible) /// invariant so the rule lives in one place. pub fn confirm_upload_policy( &self, diff --git a/capsule-core/src/import/streaming.rs b/capsule-core/src/import/streaming.rs index 8a8a913f..1ffe6807 100644 --- a/capsule-core/src/import/streaming.rs +++ b/capsule-core/src/import/streaming.rs @@ -2,7 +2,7 @@ //! `S-B3` in the repo-root `SLICES.md`; SSoT: //! [Import — Pipeline: Import-Upload Streaming Mode](https://docs/design/import/pipeline/#import-upload-streaming-mode)). //! -//! The default [`execute`](crate::import::executor::execute) imports every file into the local +//! The default [`execute`](crate::import::execute) imports every file into the local //! library *before* upload, so the device temporarily holds the whole import on disk — impossible //! on a storage-constrained device. Streaming mode removes that requirement by running a bounded //! **sliding window** of one asset at a time: @@ -97,7 +97,7 @@ pub enum StreamingError { #[error("free-space probe: {0}")] Probe(#[from] crate::library::LibraryError), - /// The run was configured with a [`UploadPolicy::Staged`](crate::import::upload::UploadPolicy) + /// The run was configured with a [`UploadPolicy::Staged`](crate::import::UploadPolicy) /// policy, which is mutually exclusive with streaming import (staged uploads, /// slice `S-B4`). Streaming releases local bytes quickly; staged defers exactly /// the T2 upload release depends on — so a staged policy can never enter the @@ -261,7 +261,7 @@ pub struct StreamingOptions<'a, U: AssetUploader, V: StorageVerifier> { /// the exporter's capture time, GPS, caption, favorite, and album membership exactly as an /// unconstrained one does (slice `S-B11`, closing the gap `S-B10` left open). This is the /// streaming twin of -/// [`execute_with_source_metadata`](crate::import::executor::execute_with_source_metadata). +/// [`execute_with_source_metadata`](crate::import::execute_with_source_metadata). /// /// Returns a [`StreamingReport`]; a hard failure to *start* (insufficient headroom, a failed /// probe) is a [`StreamingError`]. Per-asset upload pauses and gate retentions are outcomes, not @@ -301,7 +301,7 @@ where // rejects the combination at confirmation; this is the by-construction backstop // so a staged policy cannot reach `execute_streaming` even if a caller skips // confirmation. Surfaces before any file is imported (staged uploads, S-B4). - crate::import::upload::ensure_streaming_compatible(config.upload_policy, true)?; + crate::import::ensure_streaming_compatible(config.upload_policy, true)?; // Same single resolution the executor runs: bind the library's derived de facto album // (rule 3), then apply the order (SSoT: organization § The Default Album). @@ -1017,7 +1017,7 @@ mod tests { } /// **Staged × streaming exclusion (by construction).** A run configured with the - /// [`UploadPolicy::Staged`](crate::import::upload::UploadPolicy) policy can never + /// [`UploadPolicy::Staged`](crate::import::UploadPolicy) policy can never /// enter the streaming window: `execute_streaming` refuses it *before* any file /// is imported, mirroring the planner's confirmation-time rejection. (SSoT: /// download-sync doc — staged uploads are mutually exclusive with streaming.) diff --git a/capsule-core/src/library/space.rs b/capsule-core/src/library/space.rs index e4586b89..bce29bcc 100644 --- a/capsule-core/src/library/space.rs +++ b/capsule-core/src/library/space.rs @@ -6,7 +6,7 @@ //! confirmation, so the planner stays deterministic. The recommendation predicate below //! is pure and is the contract the executor's streaming drive mode keys off. The //! `total_size` accounting and the plan-level `streaming_recommended` attachment live on -//! [`ImportActionPlan`](crate::import::planner::ImportActionPlan); this module owns the +//! [`ImportActionPlan`](crate::import::ImportActionPlan); this module owns the //! probe and the two pure verdicts (`streaming_recommended`, `largest_asset_fits`) they //! feed. diff --git a/capsule-core/src/library/storage_verify.rs b/capsule-core/src/library/storage_verify.rs index c9493386..287fa796 100644 --- a/capsule-core/src/library/storage_verify.rs +++ b/capsule-core/src/library/storage_verify.rs @@ -183,7 +183,7 @@ impl<'a, V: StorageVerifier + ?Sized> ReleaseGate<'a, V> { // ─── The three destructive paths, gated ─────────────────────────────────────── /// **Device-owned-original release** (the cache-eviction sweep's counterpart for owned -/// originals, which [`cache_sweep`](crate::library::cache::cache_sweep) never touches +/// originals, which [`cache_sweep`](crate::library::cache_sweep) never touches /// automatically). An original the device itself uploaded is the source of truth until the /// server durably holds it; it is released — its local file deleted and its owned-original /// representation row dropped, after which it becomes an ordinary server-only, re-fetchable diff --git a/capsule-core/src/lifecycle/import.rs b/capsule-core/src/lifecycle/import.rs index dc1105be..216a95ed 100644 --- a/capsule-core/src/lifecycle/import.rs +++ b/capsule-core/src/lifecycle/import.rs @@ -303,9 +303,9 @@ impl Workspace { /// As [`import_asset`](Self::import_asset) but with executor-supplied [`SignedImportOptions`] /// (Move-mode source release + stack placement). This is the single signed write path the /// import executor drives (S-B2): every imported member lands as a signed `SidecarV1` + - /// manifest + append-only provenance, self-verified through [`verify_asset`], and — behind - /// the `media` feature, when a [`StillEncoder`](crate::media::image::derivative::StillEncoder) - /// is attached — with signed thumbnail/preview derivatives + an LQIP in the sidecar. + /// manifest + append-only provenance, self-verified through [`verify_asset`], and — when a + /// still encoder is attached — with signed thumbnail/preview derivatives + an LQIP in the + /// sidecar. No still encoder exists in this build; the media stack is retired (`S-B1`). /// /// Returns a [`SignedImport`]: the asset id, plus the [`DerivativeStatus`](super::DerivativeStatus) /// saying whether derivatives were generated and, if not, why. A format this build has no @@ -531,10 +531,10 @@ impl Workspace { /// Import a file on the signed path for a **streaming** import: identical to /// [`import_asset_with`](Self::import_asset_with) but with `defer_source_release` forced on, - /// and returning the [`StreamedImport`] descriptor the streaming window drives its - /// per-asset upload → verify → release step from. The local original (and any Move-mode - /// source) is left in place — the [streaming executor](crate::import::streaming) releases it - /// only after the server's `durable` verdict + custody receipt clear the `S-D4` gate. + /// and returning the [`StreamedImport`] descriptor the streaming window drives its per-asset + /// upload → verify → release step from. The local original (and any Move-mode source) is left + /// in place — the [streaming executor](crate::import::execute_streaming) releases it only + /// after the server's `durable` verdict + custody receipt clear the `S-D4` gate. /// /// `enrichment` carries the folded third-party exporter metadata for this file exactly as /// [`import_asset_with`](Self::import_asset_with) takes it (`S-B11`). It is a parameter and diff --git a/capsule-core/src/lifecycle/metadata.rs b/capsule-core/src/lifecycle/metadata.rs index 7abdf9cf..30e55dd8 100644 --- a/capsule-core/src/lifecycle/metadata.rs +++ b/capsule-core/src/lifecycle/metadata.rs @@ -13,7 +13,7 @@ use crate::db::DatabaseDriver; use crate::metadata::crdt::AddId; use crate::ml::orchestrator::{AiTagSink, AssetSource}; use crate::ml::{ModelId, Registry}; -use crate::sidecar::sidecar_v1::AiTag; +use crate::sidecar::AiTag; impl Workspace { /// Add a user tag (OR-set) and emit a `metadata-update` provenance record. diff --git a/capsule-core/src/lifecycle/mod.rs b/capsule-core/src/lifecycle/mod.rs index e90fd03c..70dc741c 100644 --- a/capsule-core/src/lifecycle/mod.rs +++ b/capsule-core/src/lifecycle/mod.rs @@ -21,7 +21,7 @@ //! ([`rotate_epoch`](Workspace::rotate_epoch)); the MLS membership ceremony (`Welcome`, //! add/remove) remains deferred (see `SLICES.md`). //! -//! [`verify_asset`]: crate::crypto::verify_asset +//! [`verify_asset`]: fn@crate::crypto::verify_asset //! [`ReferenceAuthority`]: crate::crypto::authority::ReferenceAuthority mod album; @@ -203,7 +203,7 @@ pub struct AssetState { /// [`Workspace::set_stack_membership`] write. It survives here for the one case the register /// cannot serve — an asset imported **before** `S-B15`, whose placement was written only to the /// index and therefore exists nowhere else (see [`Workspace::open`] step (6) and -/// [`library::rebuild`](crate::library::rebuild)). +/// [`library::rebuild_index`](crate::library::rebuild_index)). #[derive(Debug, Clone)] pub struct StackPlacement { /// The shared stack id (the `asset_stacks` row id the members belong to). @@ -224,8 +224,8 @@ impl StackPlacement { } } -/// Out-of-band metadata a third-party [source adapter](crate::import::importers) folded for one -/// media file, in the shape the signed sidecar stores it (slice `S-B10`). +/// Out-of-band metadata a third-party [source adapter](crate::import::SourceAdapter) folded for +/// one media file, in the shape the signed sidecar stores it (slice `S-B10`). /// /// The [precedence rule] is resolved in two places, and this type is what keeps the two halves /// apart: @@ -242,7 +242,7 @@ impl StackPlacement { /// /// Every field left empty writes nothing, so an import carrying no exporter record produces a /// sidecar byte-identical to a plain filesystem import's. The provider-specific mapping that -/// fills this in lives in [`import::enrichment`](crate::import::enrichment). +/// fills this in lives in [`import::sidecar_enrichment`](crate::import::sidecar_enrichment). /// /// [precedence rule]: https://docs/design/import/pipeline/#third-party-importers #[derive(Debug, Clone, Default, PartialEq)] @@ -278,9 +278,9 @@ pub struct SignedImportOptions { /// row. `None` imports a standalone asset and leaves the register wire-absent. pub stack: Option, /// Folded third-party exporter metadata for this file (`S-B10`), attached by the - /// [executor](crate::import::executor::execute_with_source_metadata) when the import came - /// from a [source adapter](crate::import::importers). `None` — a plain filesystem import — - /// leaves every enriched field exactly as it was before the slice. + /// [executor](crate::import::execute_with_source_metadata) when the import came + /// from a [source adapter](crate::import::SourceAdapter). `None` — a plain filesystem + /// import — leaves every enriched field exactly as it was before the slice. pub enrichment: Option, } @@ -296,18 +296,16 @@ pub struct SignedImportOptions { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum DerivativeStatus { /// The still decoded: dimensions and LQIP came from real pixels, and signed derivatives - /// were generated if a [`StillEncoder`](crate::media::image::derivative::StillEncoder) is - /// attached to the workspace. + /// were generated if a still encoder is attached to the workspace. Decoded, - /// **Expected deferral.** This build links no codec for the asset's format — see - /// [`SUPPORTED_IMAGE_FORMATS`](crate::media::image::types::SUPPORTED_IMAGE_FORMATS). The - /// original is safely backed up; dimensions fall back to EXIF and there is no LQIP or - /// preview until the codec lands, at which point derivatives can be backfilled from the - /// stored original. Counted by - /// [`ImportExecutionSummary::deferred_derivative_count`](crate::import::progress::ImportExecutionSummary::deferred_derivative_count). + /// **Expected deferral.** This build links no codec for the asset's format — the + /// supported-image-format table lives in the retired media stack. The original is safely + /// backed up; dimensions fall back to EXIF and there is no LQIP or preview until the codec + /// lands, at which point derivatives can be backfilled from the stored original. Counted by + /// [`ImportExecutionSummary::deferred_derivative_count`](crate::import::ImportExecutionSummary::deferred_derivative_count). /// - /// A build compiled without the `media` feature has no codecs at all, so every asset it - /// imports reports this. + /// This build links no codecs at all — the media stack is retired to `legacy-review/` + /// (`S-B1`) — so every still it imports reports this. DeferredNoCodec, /// **A real problem.** The format *is* one this build can decode, but these particular /// bytes did not decode — truncation, corruption, or a decoder bug. The original is still @@ -315,9 +313,8 @@ pub enum DerivativeStatus { /// investigating rather than shrugging at. DecodeFailed, /// Nothing to decode: the extension names no still image this build models — a video, an - /// XMP sidecar, an unknown suffix, or an exotic RAW flavour - /// [`RawImageFormat`](crate::media::image::types::RawImageFormat) has no variant for. Video - /// derivatives are generated on their own path. + /// XMP sidecar, an unknown suffix, or an exotic RAW flavour the raw-image-format table has + /// no variant for. Video derivatives are generated on their own path. NotAKnownStill, } @@ -344,11 +341,12 @@ pub struct SignedImport { pub derivatives: DerivativeStatus, } -/// A streamed import: everything the [streaming window](crate::import::streaming) needs about one -/// just-imported asset to drive its upload → verify → release step, without exposing workspace -/// internals. Produced by [`Workspace::import_asset_streaming`], which commits on the signed path -/// with source release **deferred** to the server-side verify-before-destroy gate (`S-D4`), since -/// in streaming mode the local bytes are the only copy until the *server* durably holds them. +/// A streamed import: everything the [streaming window](crate::import::execute_streaming) needs +/// about one just-imported asset to drive its upload → verify → release step, without exposing +/// workspace internals. Produced by [`Workspace::import_asset_streaming`], which commits on the +/// signed path with source release **deferred** to the server-side verify-before-destroy gate +/// (`S-D4`), since in streaming mode the local bytes are the only copy until the *server* durably +/// holds them. #[derive(Debug, Clone)] pub struct StreamedImport { /// The imported asset's id. @@ -481,7 +479,7 @@ impl Workspace { } /// This device's stable id — the `created_by_device` every manifest this workspace - /// authors carries, and the [`DeviceEntry`](crate::crypto::keys::directory::DeviceEntry) + /// authors carries, and the [`DeviceEntry`](crate::crypto::keys::DeviceEntry) /// key under which its signing key is published in the device directory. pub fn device_id(&self) -> Uuid { self.account.device.device_id diff --git a/capsule-core/src/lifecycle/open.rs b/capsule-core/src/lifecycle/open.rs index 9ea6432d..1a23daea 100644 --- a/capsule-core/src/lifecycle/open.rs +++ b/capsule-core/src/lifecycle/open.rs @@ -65,7 +65,7 @@ impl std::fmt::Debug for HardwareDekBinding { /// across restarts]). /// /// The sweep reads the sidecars **without re-verifying their signatures**, for the same reason -/// [`rebuild_index`](crate::library::rebuild::rebuild_index) does not: these are the device's own +/// [`rebuild_index`](crate::library::rebuild_index) does not: these are the device's own /// local plaintext files, and the value recovered is only ever a *floor* on the next counter. /// An unreadable or foreign-schema sidecar is warned about and skipped rather than failing the /// open — but note that skipping can only lower the floor, so the warning is load-bearing for @@ -477,7 +477,7 @@ impl Workspace { /// stack placement, which lives nowhere else. /// /// The filesystem, not the SQLite index, is the source of truth here for the same - /// recovery-first reason [`rebuild_index`](crate::library::rebuild::rebuild_index) exists: an + /// recovery-first reason [`rebuild_index`](crate::library::rebuild_index) exists: an /// index can be rebuilt from the signed artifacts, but an artifact the index has forgotten is /// gone. A missing or undecodable piece for one asset is a `warn` and a skip — never a failed /// open, which would take the whole library down for one bad file. diff --git a/capsule-core/src/lifecycle/provenance.rs b/capsule-core/src/lifecycle/provenance.rs index b9b04943..a71d6002 100644 --- a/capsule-core/src/lifecycle/provenance.rs +++ b/capsule-core/src/lifecycle/provenance.rs @@ -16,7 +16,7 @@ use crate::crypto::verify_asset::{ MetadataBinding, VerifyOutcome, verify_asset, verify_metadata_binding, }; use crate::metadata::crdt::AddId; -use crate::sidecar::sidecar_v1::SidecarV1; +use crate::sidecar::SidecarV1; impl Workspace { /// Build a signed lifecycle manifest for `asset`, sharing the create manifest's content diff --git a/capsule-core/src/lifecycle/upload.rs b/capsule-core/src/lifecycle/upload.rs index b1147c62..f724991f 100644 --- a/capsule-core/src/lifecycle/upload.rs +++ b/capsule-core/src/lifecycle/upload.rs @@ -7,7 +7,7 @@ //! this one accessor so there is exactly one copy of that crypto). //! //! **This is a post-import pass, not an implementation of -//! [`AssetUploader`](crate::import::streaming::AssetUploader).** That trait cannot be +//! [`AssetUploader`](crate::import::AssetUploader).** That trait cannot be //! implemented: `stream_candidate` holds `&mut Workspace` across the `uploader.upload(...)` //! call, so an implementor can never borrow the workspace back to read the bytes it is //! supposed to send. Do not try again — push reads a *committed* asset out of an diff --git a/capsule-core/src/lqip/mod.rs b/capsule-core/src/lqip/mod.rs index 5f857d7a..b047773c 100644 --- a/capsule-core/src/lqip/mod.rs +++ b/capsule-core/src/lqip/mod.rs @@ -8,15 +8,15 @@ //! uniffi FFI, and read by the browser through `capsule-wasm`. A placeholder that differed by //! which client imported the photo would be a visible defect, so there is exactly **one** //! implementation and it must be reachable from all three. That rules out -//! `capsule_core::media`, which is gated behind the `media` feature (implying `native`) and -//! retires to `legacy-review/` with the rest of the decode/encode stack. It equally rules out +//! `capsule_core::media`, the retired decode/encode stack that lives in `legacy-review/` +//! and is `native`-only wherever it is restored. It equally rules out //! Rawshift, which `AGENTS.md` forbids from wrapping Chromahash — Capsule imports it directly. //! //! This module is therefore **unconditional**: no feature gate, no `native`, and it compiles //! for `wasm32-unknown-unknown` (chromahash has zero runtime dependencies and ships a //! `simd128` backend). The one part that cannot be unconditional is the bridge to the sidecar -//! record, because `crate::sidecar` is itself `native`-gated; that lives in [`sidecar`], behind -//! the same gate, over this same encoder. +//! record, because `crate::sidecar` is itself `native`-gated; that lives in +//! [`sidecar`](crate::lqip::sidecar), behind the same gate, over this same encoder. //! //! # The contract //! @@ -27,10 +27,10 @@ //! //! | Call | Role here | //! | --- | --- | -//! | `encode(w, h, &rgba, gamut)` | [`Lqip::encode`] — generation at the default tier. | -//! | `decode_capped(max_w, max_h)` | [`Lqip::decode_capped`] — the band-limited render. | -//! | `average_color()` | [`Lqip::dominant_color`] — the DC-only fallback fill. | -//! | `from_bytes` / `as_bytes` | [`Lqip::from_bytes`] / [`Lqip::as_bytes`] — the sidecar round trip. | +//! | `encode(w, h, &rgba, gamut)` | [`Lqip::encode`](crate::lqip::Lqip::encode) — generation at the default tier. | +//! | `decode_capped(max_w, max_h)` | [`Lqip::decode_capped`](crate::lqip::Lqip::decode_capped) — the band-limited render. | +//! | `average_color()` | [`Lqip::dominant_color`](crate::lqip::Lqip::dominant_color) — the DC-only fallback fill. | +//! | `from_bytes` / `as_bytes` | [`Lqip::from_bytes`](crate::lqip::Lqip::from_bytes) / [`Lqip::as_bytes`](crate::lqip::Lqip::as_bytes) — the sidecar round trip. | //! //! Notably absent: any pre-resize of the source. The 100 px downscale the retired ThumbHash //! implementation performed before hashing was a ThumbHash artifact — chromahash takes the full @@ -39,11 +39,12 @@ //! //! # Versioned fallback //! -//! [`LQIP_FORMAT_V1`] tags the payload inside the sidecar. A reader that does not recognize the -//! version — or that holds bytes `from_bytes` rejects — paints the solid `dominant_color` fill -//! instead of misrendering (see [`render`]). That is the mechanism that makes a future -//! chromahash revision a versioned change rather than a silent break, and it is also what makes -//! any stale non-chromahash payload degrade to a flat colour rather than to noise. +//! [`LQIP_FORMAT_V1`](crate::lqip::LQIP_FORMAT_V1) tags the payload inside the sidecar. A reader +//! that does not recognize the version — or that holds bytes `from_bytes` rejects — paints the +//! solid `dominant_color` fill instead of misrendering (see [`render`](crate::lqip::render)). +//! That is the mechanism that makes a future chromahash revision a versioned change rather than a +//! silent break, and it is also what makes any stale non-chromahash payload degrade to a flat +//! colour rather than to noise. use thiserror::Error; diff --git a/capsule-core/src/ml/mod.rs b/capsule-core/src/ml/mod.rs index de0e7bfa..8957660d 100644 --- a/capsule-core/src/ml/mod.rs +++ b/capsule-core/src/ml/mod.rs @@ -5,40 +5,40 @@ //! //! - the [`registry`] — the **canonical model inventory** (one v1-committed row per task) and the //! **embedding-provenance invariant** (every embedding carries `(model_id, model_version)`; -//! the [vector index](crate::db::vector) refuses inserts from **unknown** models, while a +//! the [vector index](crate::db::EmbeddingInsert) refuses inserts from **unknown** models, while a //! *superseded-but-known* version is admitted as a stale-flagged row and excluded from //! queries until regenerated). It owns the version-bump [swap //! primitive](registry::Registry::bump_version); //! - the [`regen`] loop — the background per-asset **regeneration orchestration** that consumes //! the staleness a swap creates: it walks //! [`stale_embedding_assets`](crate::db::DatabaseDriver::stale_embedding_assets) and re-embeds -//! each asset at the new canonical version through an injected [`Embedder`](regen::Embedder) +//! each asset at the new canonical version through an injected [`Embedder`] //! seam, resumably and per-asset (never a global truncate). The production implementor is -//! [`RunnerEmbedder`](regen::RunnerEmbedder), which rebuilds the derived index by re-reading -//! each **original** through [`AssetSource`](orchestrator::AssetSource) and re-running the -//! canonical model through [`ModelRunner`](runner::ModelRunner) — the same two seams first-time +//! [`RunnerEmbedder`], which rebuilds the derived index by re-reading +//! each **original** through [`AssetSource`] and re-running the +//! canonical model through [`ModelRunner`] — the same two seams first-time //! indexing uses, so a regenerated vector equals what a fresh import would produce. //! -//! - the [`runner`] seam — the [`ModelRunner`](runner::ModelRunner) trait every consumer routes +//! - the [`runner`] seam — the [`ModelRunner`] trait every consumer routes //! per-task inference over decoded pixels through, and the deterministic -//! [`FixtureRunner`](runner::FixtureRunner) double. Real per-platform runners (ONNX/CoreML/NNAPI, +//! [`FixtureRunner`] double. Real per-platform runners (ONNX/CoreML/NNAPI, //! the CLIP runner) ride the **default-off, weight-fetching `inference` feature** — a follow-up, //! never part of the default gate; //! - the [`orchestrator`] — the deterministic execution path for the v1-committed slots: run the //! canonical model over an asset, land semantic + face vectors in the right `vec0` partition //! ([platform-partition fallback](orchestrator::resolve_partition)), and write zero-shot //! [AI tags](orchestrator::auto_tag) into `tags_ai` as a signed metadata update through the -//! [`AiTagSink`](orchestrator::AiTagSink) seam — implemented by +//! [`AiTagSink`] seam — implemented by //! [`Workspace`](crate::lifecycle::Workspace), named as a trait so `ml` does not import //! `lifecycle` (which imports `ml`); plus the device-bound batching/thermal policy. //! -//! Real per-platform inference is deferred behind the [`Embedder`](regen::Embedder) / -//! [`ModelRunner`](runner::ModelRunner) seams (the real runner is a later slice), exactly as live +//! Real per-platform inference is deferred behind the [`Embedder`] / +//! [`ModelRunner`] seams (the real runner is a later slice), exactly as live //! MLS group state is deferred behind [`AlbumAuthority`](crate::crypto::authority::AlbumAuthority) //! with `ReferenceAuthority` standing in; the deterministic -//! [`DeterministicEmbedder`](regen::DeterministicEmbedder) and -//! [`FixtureRunner`](runner::FixtureRunner) drive every path with no model weights. The local -//! vector index lives in [`crate::db::vector`]. No model weights are committed to this repository. +//! [`DeterministicEmbedder`] and +//! [`FixtureRunner`] drive every path with no model weights. The local +//! vector index lives in the crate-private `db::vector`. No model weights are committed to this repository. //! //! [AI/ML Integrations]: https://docs/design/ai/ diff --git a/capsule-core/src/ml/orchestrator.rs b/capsule-core/src/ml/orchestrator.rs index 03d8c3db..e1985142 100644 --- a/capsule-core/src/ml/orchestrator.rs +++ b/capsule-core/src/ml/orchestrator.rs @@ -5,7 +5,7 @@ //! pixels, then land the results — //! //! - **embeddings** (semantic-search + face-recognition vectors) into the right `vec0` partition of -//! the [vector index](crate::db::vector), tagged with the runner's resolved partition +//! the [vector index](crate::db::EmbeddingInsert), tagged with the runner's resolved partition //! discriminator ([`resolve_partition`]); //! - **zero-shot AI tags** into the asset's `tags_ai` OR-set as a **signed metadata update** through //! the lifecycle ([`AiTagSink::add_ai_tags`]) — structurally separate from user tags, mirroring @@ -22,7 +22,7 @@ //! Two invariants are enforced at this boundary: //! //! - **Provenance.** A runner whose declared model is not the registry canonical for a task is -//! refused before any output is stored ([`require_canonical_runner`]). +//! refused before any output is stored (`require_canonical_runner`). //! - **Platform partition.** Comparable embeddings need byte-identical inference output across //! NPUs/CPUs. A device that reproduces the pinned known-answer bit-exactly shares the //! [`CANONICAL_PARTITION`]; a device that cannot is **not merged** into another platform's index — @@ -43,7 +43,7 @@ use uuid::Uuid; use crate::db::{DatabaseDriver, EmbeddingInsert, KnnHit, VectorIndexError}; use crate::ml::runner::{Embedding, Frame, ModelRunner, RunnerError}; use crate::ml::{ModelId, Registry, TaskKind}; -use crate::sidecar::sidecar_v1::AiTag; +use crate::sidecar::AiTag; /// The store the orchestrator reads asset pixels from and indexes vectors into. /// diff --git a/capsule-core/src/ml/registry.rs b/capsule-core/src/ml/registry.rs index 1d418812..d7852a7d 100644 --- a/capsule-core/src/ml/registry.rs +++ b/capsule-core/src/ml/registry.rs @@ -19,7 +19,7 @@ //! rows* (the v1-committed slots, enriched with the function and fallback the contract names) and //! the version-bump **swap primitive** ([`Registry::bump_version`]); the background per-asset //! **regeneration orchestration** that consumes the resulting staleness lives in -//! [`crate::ml::regen`], and the provenance gate the [vector index](crate::db::vector) calls is +//! [`crate::ml::regen`], and the provenance gate the [vector index](crate::db::EmbeddingInsert) calls is //! [`Registry::check_insert`]. //! //! [AI/ML — Models and Algorithms]: https://docs/design/ai/#models-and-algorithms diff --git a/capsule-core/src/sharing/mod.rs b/capsule-core/src/sharing/mod.rs index 1542eab4..b5b0e67e 100644 --- a/capsule-core/src/sharing/mod.rs +++ b/capsule-core/src/sharing/mod.rs @@ -7,7 +7,7 @@ //! wraps it a second time via the password-based KDF, unwrapped **client-side** (the //! server stores and returns only the wrapped material). The serving endpoints live in //! the server's share module; this module owns link generation, the encapsulation -//! crypto, and the recipient-side [`open_scope`] path. +//! crypto, and the recipient-side [`open_scope`](crate::sharing::open_scope) path. //! //! ## Cryptographic shape //! @@ -15,19 +15,21 @@ //! decryption keys around the secret stored in the link ... the password-based KDF adds a //! second encapsulation layer on top of the link secret."* Concretely, per link: //! -//! 1. A fresh random **link secret** (`fragment_secret`, [`LINK_SECRET_LEN`] bytes) is -//! drawn from the CSPRNG. It is the URL fragment `#{secret}` and never reaches the -//! server. -//! 2. The scope's [`ScopeMaterial`] (a single file key, or an album's AMK ledger) is -//! serialized to canonical CBOR and sealed under `HKDF(link_secret, salt=opaque_id)` -//! — the *link-secret encapsulation*. The sealed bytes are opaque to the server. +//! 1. A fresh random **link secret** (`fragment_secret`, +//! [`LINK_SECRET_LEN`](crate::sharing::LINK_SECRET_LEN) bytes) is drawn from the CSPRNG. It +//! is the URL fragment `#{secret}` and never reaches the server. +//! 2. The scope's [`ScopeMaterial`](crate::sharing::ScopeMaterial) (a single file key, or an +//! album's AMK ledger) is serialized to canonical CBOR and sealed under +//! `HKDF(link_secret, salt=opaque_id)` — the *link-secret encapsulation*. The sealed bytes +//! are opaque to the server. //! 3. **If** a passphrase is supplied, that sealed blob is wrapped a **second** time under //! an [Argon2id][pw] key ([`crate::crypto::pwkdf`]) — the passphrase never leaves the //! client, and the server stores only this wrapped form (served from -//! `/s/{opaque-id}/wrapped-secret`). See [`WrappedScope`]. +//! `/s/{opaque-id}/wrapped-secret`). See [`WrappedScope`](crate::sharing::WrappedScope). //! -//! The recipient reverses the layers with [`open_scope`]: Argon2id-unwrap (client-side, -//! iff passphrase-protected), then link-secret-unwrap with the fragment secret. +//! The recipient reverses the layers with [`open_scope`](crate::sharing::open_scope): +//! Argon2id-unwrap (client-side, iff passphrase-protected), then link-secret-unwrap with the +//! fragment secret. //! //! [Share Links]: https://docs/design/share-links/ //! [Cryptography — Keys § Non-registered accounts]: https://docs/design/cryptography/keys/#non-registered-accounts diff --git a/capsule-core/src/sidecar/io.rs b/capsule-core/src/sidecar/io.rs index 3bcb9f10..a0166ad0 100644 --- a/capsule-core/src/sidecar/io.rs +++ b/capsule-core/src/sidecar/io.rs @@ -18,7 +18,7 @@ pub fn read_sidecar(path: &Path) -> Result library` edge (the rest of that pair is rustdoc links). diff --git a/capsule-core/src/validation/mod.rs b/capsule-core/src/validation/mod.rs index 8b82a046..1664c6fb 100644 --- a/capsule-core/src/validation/mod.rs +++ b/capsule-core/src/validation/mod.rs @@ -3,7 +3,7 @@ //! //! These are **pure, key-less** structural checks: the protocol/capability handshake //! ([`protocol`]) and the server-side manifest envelope ([`structural`]). They mirror the -//! client-side checks in [`verify_asset`](crate::crypto::verify_asset), and the server +//! client-side checks in [`verify_asset`](fn@crate::crypto::verify_asset), and the server //! write paths consume them today — the envelope gate, the ops surface, the feed, //! federation pull, and the drop routes all validate through here. This module used to describe those consumers as //! deferred; they are six live call sites, and the checks are the only thing standing diff --git a/capsule-core/src/validation/structural.rs b/capsule-core/src/validation/structural.rs index 315fbe43..84f5f616 100644 --- a/capsule-core/src/validation/structural.rs +++ b/capsule-core/src/validation/structural.rs @@ -2,7 +2,7 @@ //! (SSoT: [Threat Model — Server-Side Validation Invariants]). The server holds no keys, //! so it cannot verify signatures — but it validates *structure* refuse-by-default. These //! are pure predicates over the manifest core and server-known state; the client mirrors -//! them via [`verify_asset`](crate::crypto::verify_asset). +//! them via [`verify_asset`](fn@crate::crypto::verify_asset). //! //! [Threat Model — Server-Side Validation Invariants]: https://docs/design/threat-model/validation/#server-side-validation-invariants @@ -69,7 +69,7 @@ pub fn prior_hash_matches( } /// Invariant 18: `amk_version` never regresses for an album (server's structural backstop; -/// MLS is the authority on the ceiling — see [`verify_asset`](crate::crypto::verify_asset)). +/// MLS is the authority on the ceiling — see [`verify_asset`](fn@crate::crypto::verify_asset)). pub fn amk_version_monotonic(new: u32, stored: Option) -> bool { match stored { None => true, diff --git a/capsule-i18n/src/lib.rs b/capsule-i18n/src/lib.rs index ede41b01..42bbe2de 100644 --- a/capsule-i18n/src/lib.rs +++ b/capsule-i18n/src/lib.rs @@ -2,8 +2,8 @@ //! //! User-facing strings are authored once in the repo-root `locales/` directory as //! [ICU MessageFormat](https://unicode-org.github.io/icu/userguide/format_parse/messages/) -//! JSON, then compiled into this crate by `cargo run -p xtask -- i18n` (see -//! [`generated`]). The same source compiles to the web, iOS, and Android catalogs, +//! JSON, then compiled into this crate by `cargo run -p xtask -- i18n`, which writes the +//! private `generated` module. The same source compiles to the web, iOS, and Android catalogs, //! so a message is written exactly once. //! //! Typical use on the server: negotiate the request's locale, build a [`Bundle`], diff --git a/capsule-wasm/src/lib.rs b/capsule-wasm/src/lib.rs index 890d9316..0727f4bf 100644 --- a/capsule-wasm/src/lib.rs +++ b/capsule-wasm/src/lib.rs @@ -40,8 +40,8 @@ //! [Web Upload]: https://docs/design/web-upload/ //! //! Errors cross the boundary as a **stable machine code string** (`Error.message`), never a -//! localized sentence — the web viewer maps the code to an i18n catalog key. Codes: see -//! [`err`]. +//! localized sentence — the web viewer maps the code to an i18n catalog key. The codes are +//! defined in this crate's private `err` module. //! //! [Share Links]: https://docs/design/share-links/ From d1f2195d2aa61ab5369b8673b00d6806e7febce2 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 21:35:46 -0400 Subject: [PATCH 024/243] chore(wire)!: move capsule-wire into the Salvo review bucket `capsule-wire` carried the framework-free response taxonomy `S-C27` extracted so the contract could outlive the transport. The transport swapped and the taxonomy did not come with it: `capsule-server::problem`, `::limits` and `::body` own it on Kynos, no live crate names `capsule_wire`, and a third of the crate is a `salvo_responses!` adapter for a framework that left the workspace with `S-C59`. Its only real consumers are the 42 macro call sites under `legacy-review/server-salvo/`, so the crate lands beside them at `legacy-review/server-salvo/wire/` with its manifest disabled, per the convention every other quarantined crate follows. `capsule-server` loses a path dependency it never imported; the module comment states where the taxonomy went instead of linking a crate that is no longer built. BREAKING CHANGE: `capsule-wire` is no longer a workspace member and `capsule-server` no longer depends on it. Nothing in the workspace imported it, so no public API moves. --- Cargo.lock | 9 --------- Cargo.toml | 2 -- capsule-server/Cargo.toml | 3 --- capsule-server/src/lib.rs | 5 +++-- .../server-salvo/wire/Cargo.toml.disabled | 0 .../server-salvo/wire}/src/headers.rs | 0 .../server-salvo/wire}/src/lib.rs | 0 .../server-salvo/wire}/src/response.rs | 0 .../server-salvo/wire}/src/salvo_adapter.rs | 0 9 files changed, 3 insertions(+), 16 deletions(-) rename capsule-wire/Cargo.toml => legacy-review/server-salvo/wire/Cargo.toml.disabled (100%) rename {capsule-wire => legacy-review/server-salvo/wire}/src/headers.rs (100%) rename {capsule-wire => legacy-review/server-salvo/wire}/src/lib.rs (100%) rename {capsule-wire => legacy-review/server-salvo/wire}/src/response.rs (100%) rename {capsule-wire => legacy-review/server-salvo/wire}/src/salvo_adapter.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index af416a67..7fdfcde9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -753,7 +753,6 @@ dependencies = [ "capsule-core", "capsule-i18n", "capsule-sdk", - "capsule-wire", "clap", "color-eyre", "http-body-util", @@ -783,14 +782,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "capsule-wire" -version = "0.1.0" -dependencies = [ - "serde", - "serde_json", -] - [[package]] name = "cargo-platform" version = "0.1.9" diff --git a/Cargo.toml b/Cargo.toml index 12b28f90..a848730b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,6 @@ members = [ "capsule-sdk", "capsule-wasm", "capsule-server", - "capsule-wire", "xtask", ] # capsule-sdk's REST client is generated at build time by spargen from the committed @@ -22,7 +21,6 @@ default-members = [ "capsule-core-ffi", "capsule-i18n", "capsule-server", - "capsule-wire", ] resolver = "3" diff --git a/capsule-server/Cargo.toml b/capsule-server/Cargo.toml index a79b4def..9040075f 100644 --- a/capsule-server/Cargo.toml +++ b/capsule-server/Cargo.toml @@ -44,9 +44,6 @@ kynos = { workspace = true } # refuse-by-default invariants — and the crypto types they read. The default `native` feature pulls # SQLite, sqlite-vec, OpenMLS and libcrux, none of which a key-free server touches. capsule-core = { path = "../capsule-core", default-features = false } -# The framework-free wire contracts (slice `S-C27`). The response taxonomy lives here so it -# outlives whichever framework renders it. -capsule-wire = { path = "../capsule-wire" } serde = { workspace = true } # The state ports (slice `S-C29`). `thiserror` because these are a library surface; # `jiff` because every record and every TTL is a time and chrono is banned; `tracing` because diff --git a/capsule-server/src/lib.rs b/capsule-server/src/lib.rs index 55afe755..32a4bf4e 100644 --- a/capsule-server/src/lib.rs +++ b/capsule-server/src/lib.rs @@ -4,8 +4,9 @@ //! //! The previous server was Salvo, and its wire-contract types were themselves salvo-typed, so //! replacing it was never a transport swap (`SLICES.md`, the salvo→kynos row). `S-C27` moved the -//! response taxonomy into the framework-free [`capsule_wire`]; this crate is where the surfaces -//! that taxonomy describes get rebuilt. +//! response taxonomy into a framework-free crate so the contract could outlive the transport; +//! that crate retired with the Salvo tree it adapted (ADR-0004), and this crate is where the +//! surfaces the taxonomy described get rebuilt. `problem`, `limits` and `body` own it now. //! //! # What the framework buys, and why it was chosen //! diff --git a/capsule-wire/Cargo.toml b/legacy-review/server-salvo/wire/Cargo.toml.disabled similarity index 100% rename from capsule-wire/Cargo.toml rename to legacy-review/server-salvo/wire/Cargo.toml.disabled diff --git a/capsule-wire/src/headers.rs b/legacy-review/server-salvo/wire/src/headers.rs similarity index 100% rename from capsule-wire/src/headers.rs rename to legacy-review/server-salvo/wire/src/headers.rs diff --git a/capsule-wire/src/lib.rs b/legacy-review/server-salvo/wire/src/lib.rs similarity index 100% rename from capsule-wire/src/lib.rs rename to legacy-review/server-salvo/wire/src/lib.rs diff --git a/capsule-wire/src/response.rs b/legacy-review/server-salvo/wire/src/response.rs similarity index 100% rename from capsule-wire/src/response.rs rename to legacy-review/server-salvo/wire/src/response.rs diff --git a/capsule-wire/src/salvo_adapter.rs b/legacy-review/server-salvo/wire/src/salvo_adapter.rs similarity index 100% rename from capsule-wire/src/salvo_adapter.rs rename to legacy-review/server-salvo/wire/src/salvo_adapter.rs From b51639b1ec42ec18a66ca509461eec35d30d57f7 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 21:36:29 -0400 Subject: [PATCH 025/243] chore(legacy): delete the core-import-media review bucket S-C59 recorded core-import-media as deleted without deleting it: the directory quarantined capsule_core::exif and the import executor's cancellation/progress halves, all three of which were rebuilt live and newer than the snapshot beside them, making it a stale twin rather than a quarantine. Remove the directory, its ROADMAP.md row, and reword the two import/pipeline.md citations that named it in the present tense to point at the live modules instead. --- ROADMAP.md | 1 - SLICES.md | 42 +- .../content/docs/design/import/pipeline.md | 4 +- legacy-review/core-import-media/REVIEW.md | 9 - .../core-import-media/exif/extract.rs | 263 -------- legacy-review/core-import-media/exif/mod.rs | 5 - .../core-import-media/exif/timezone.rs | 235 ------- .../core-import-media/import/executor.rs | 580 ------------------ .../import/executor_cancellation.rs | 43 -- .../core-import-media/import/progress.rs | 76 --- 10 files changed, 24 insertions(+), 1234 deletions(-) delete mode 100644 legacy-review/core-import-media/REVIEW.md delete mode 100644 legacy-review/core-import-media/exif/extract.rs delete mode 100644 legacy-review/core-import-media/exif/mod.rs delete mode 100644 legacy-review/core-import-media/exif/timezone.rs delete mode 100644 legacy-review/core-import-media/import/executor.rs delete mode 100644 legacy-review/core-import-media/import/executor_cancellation.rs delete mode 100644 legacy-review/core-import-media/import/progress.rs diff --git a/ROADMAP.md b/ROADMAP.md index 4a93b604..67a2d3e0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -83,7 +83,6 @@ A closed set. A row's state is a claim about the package, not about the programm | `legacy-review/server-salvo` | review-bucket | The retired Salvo server, kept as the contract the Kynos rebuild must reproduce | review-only | — | [legacy-review/README](legacy-review/README.md) | — | Deleted once `capsule-server` reaches parity | Non-buildable reference material; nothing in the workspace links it | | `legacy-review/sdk-progenitor` | review-bucket | The retired Progenitor SDK | review-only | — | [legacy-review/README](legacy-review/README.md) | — | Deleted once the spargen client covers it | Non-buildable reference material | | `legacy-review/media-pipeline` | review-bucket | The retired `capsule_core::media` decode and derivative stack | review-only | — | [legacy-review/README](legacy-review/README.md) | — | Deleted once `capsule-core::media` lands on Rawshift (#410) | Non-buildable reference material; taking the decoder with it is why every still import is a `DeferredNoCodec` today | -| `legacy-review/core-import-media` | review-bucket | The quarantined twin of `capsule_core::exif` and the import executor's cancellation and progress halves | review-only | — | [legacy-review/README](legacy-review/README.md) | — | Deletion, which `S-C59` recorded and did not perform | All three modules are live, tested and newer in `capsule-core` than this snapshot, so the bucket is a stale twin rather than a quarantine | ## Deferred register diff --git a/SLICES.md b/SLICES.md index 7ef5081b..caab873d 100644 --- a/SLICES.md +++ b/SLICES.md @@ -13,14 +13,14 @@ It also absorbs the **post-teardown verdict**: the previous Salvo server, the Pr SDK and the standalone media crate are review material, and the replacement server is one **Kynos** REST/OpenAPI application. That verdict is accepted and final. -**`S-C59` executed it**, and narrowed it on evidence. Four `legacy-review/` buckets sit in the -tree: `server-salvo`, `sdk-progenitor`, `media-pipeline`, and `core-import-media`. The fourth is -a stale twin rather than a quarantine — it holds a snapshot of `capsule-core::exif` and the -import executor's cancellation and progress halves, all three of which this branch has since +**`S-C59` executed it**, and narrowed it on evidence. Three `legacy-review/` buckets sit in the +tree: `server-salvo`, `sdk-progenitor`, and `media-pipeline`. A fourth, `core-import-media`, was +a stale twin rather than a quarantine — it held a snapshot of `capsule-core::exif` and the +import executor's cancellation and progress halves, all three of which this branch had already rebuilt, live and tested, so those stay in `capsule-core`. Quarantining a stale twin of a working -module is the opposite of what quarantine is for. **`S-C59` recorded that bucket as deleted and -did not delete it**: the directory is still there, and its removal is owed to -[#423](https://github.com/Capsulsaurus/Capsule/issues/423). See +module is the opposite of what quarantine is for. **`S-C59` recorded that bucket as deleted +before it actually was**: the directory outlived that record until +[#423](https://github.com/Capsulsaurus/Capsule/issues/423) deleted it. See [`S-C59`](#s-c59--the-salvo-tree-leaves-the-workspace). Because a slice can now be honest in one tree and dishonest in the other, every row carries @@ -48,7 +48,7 @@ an **Area**. Read `Status` through `Area`, never on its own. | Area | Meaning | | --- | --- | | `ACTIVE` | The whole surface survives the teardown (`capsule-core` minus its media/exif trees, `capsule-core-ffi`/`-swift`/`-kotlin`, the apps, `capsule-cli` local paths, `capsule-web` local paths, `locales/`, `xtask`, the docs site). Implementable against the live workspace today and unaffected by the Kynos rebuild. | -| `RETIRED` | The target sits in a `legacy-review/` bucket — `server-salvo` (the whole Salvo tree), `sdk-progenitor`, or `media-pipeline` (`capsule_core::media` and its lifecycle adapter). The deliverable must be re-landed on the replacement: Kynos for the server, the Rawshift-backed pipeline for media, the spargen SDK for the client. **`capsule_core::exif` and `import/{executor_cancellation, progress}.rs` are not on this list**, against the original teardown: this branch rebuilt them and they are live, and the `core-import-media` bucket beside them is a stale twin awaiting deletion ([#423](https://github.com/Capsulsaurus/Capsule/issues/423)), not a quarantine (`S-C59`). | +| `RETIRED` | The target sits in a `legacy-review/` bucket — `server-salvo` (the whole Salvo tree), `sdk-progenitor`, or `media-pipeline` (`capsule_core::media` and its lifecycle adapter). The deliverable must be re-landed on the replacement: Kynos for the server, the Rawshift-backed pipeline for media, the spargen SDK for the client. **`capsule_core::exif` and `import/{executor_cancellation, progress}.rs` are not on this list**, against the original teardown: this branch rebuilt them and they are live, and the `core-import-media` bucket that sat beside them as a stale twin was deleted rather than quarantined ([#423](https://github.com/Capsulsaurus/Capsule/issues/423), `S-C59`). | | `MIXED` | Both: a surviving `capsule-core`/client/app half that ships and stays, and a server, SDK-wire, or media half that must be re-landed. | **Status — read through Area.** @@ -731,7 +731,8 @@ workspace at all**, so every still import is a `DeferredNoCodec` until Rawshift - **Contract:** [Import — Pipeline](capsule-docs/src/content/docs/design/import/pipeline.md). - **Deliverable:** a new executor over Rawshift results and the signed `lifecycle::Workspace` path (signed `SidecarV1` + manifest + provenance + derivatives), - informed by but not restoring `legacy-review/core-import-media/`. + informed by but not restoring the plaintext `core-import-media` review twin deleted in + [#423](https://github.com/Capsulsaurus/Capsule/issues/423). - **Depends on:** S-B1 (derivative generation is the missing input). - **Done when:** an executor import produces `verify_asset`-accepting assets with derivatives; planner determinism suite unchanged. @@ -3428,17 +3429,18 @@ than a transcription: Thirty-seven documented operations became **fifty-nine**, and the six that were never documented are accounted for. -**The `core-import-media` bucket is to be deleted rather than refreshed — and was not.** It -quarantined `capsule_core::exif` and the import executor's cancellation and progress halves. All -three are live, tested and *newer* on this branch than the snapshot that quarantined them — which -is exactly the charter's exit condition, met in the other direction. Keeping a stale twin of a -working module beside it is the opposite of what quarantine is for, so the modules stay and the -bucket goes. This is a **deliberate narrowing of the teardown's file list**, recorded here because -the plan named those files for the move. **Correction 2026-09-01:** this note said the bucket had -gone. `legacy-review/core-import-media/` is still in the tree — the decision was recorded and -never carried out — and the deletion, together with the two `import/pipeline.md` citations and the -`ROADMAP.md` row that go with it, is -[#423](https://github.com/Capsulsaurus/Capsule/issues/423). +**The `core-import-media` bucket was deleted rather than refreshed.** It had quarantined +`capsule_core::exif` and the import executor's cancellation and progress halves. All three were +live, tested and *newer* on this branch than the snapshot that quarantined them — which is exactly +the charter's exit condition, met in the other direction. Keeping a stale twin of a working module +beside it is the opposite of what quarantine is for, so the modules stayed and the bucket went. +This is a **deliberate narrowing of the teardown's file list**, recorded here because the plan +named those files for the move. **Correction 2026-09-01:** this note originally said the bucket +had gone when it had not — `legacy-review/core-import-media/` was still in the tree, the decision +recorded and never carried out. +[#423](https://github.com/Capsulsaurus/Capsule/issues/423) deleted the directory, reworded the +two `import/pipeline.md` citations, and dropped the `ROADMAP.md` row, in commit +`PENDING-SHA`. **`capsule_core::media` does go, and it takes the decoder with it.** It was the former standalone media crate, gated behind a non-default feature whose only consumer was the equally-gated diff --git a/capsule-docs/src/content/docs/design/import/pipeline.md b/capsule-docs/src/content/docs/design/import/pipeline.md index 8888227e..68ac816d 100644 --- a/capsule-docs/src/content/docs/design/import/pipeline.md +++ b/capsule-docs/src/content/docs/design/import/pipeline.md @@ -51,7 +51,7 @@ For each file in the plan, in [upload prioritization](#upload-prioritization) or Step 1–3 can be parallelized across files. The executor is cancellation-aware: a partially-executed plan can be aborted cleanly and resumed (re-running the import re-derives the plan and skips already-completed work via the deterministic planner). -**Status note.** The signed path in step 2 — encrypt, manifest, provenance — is implemented in `capsule-core::lifecycle::Workspace` and writes through to the shared `library.sqlite` index. The `import::executor` drives imports entirely onto this signed path (`S-B2`), and the legacy **unsigned** `AssetSidecar` write path has been removed (`S-G4`). The unsigned sidecar survives only as a *read* model: the recovery-first index rebuild (`capsule-core::library::rebuild`) still ingests unsigned `.cbor` sidecars left by pre-signed-path libraries. The executor's media half is being rebuilt over Rawshift (with Capsule calling Chromahash **0.7.1** directly); the old plaintext decode/extract path is review-only under `legacy-review/core-import-media/`. +**Status note.** The signed path in step 2 — encrypt, manifest, provenance — is implemented in `capsule-core::lifecycle::Workspace` and writes through to the shared `library.sqlite` index. The `import::executor` drives imports entirely onto this signed path (`S-B2`), and the legacy **unsigned** `AssetSidecar` write path has been removed (`S-G4`). The unsigned sidecar survives only as a *read* model: the recovery-first index rebuild (`capsule-core::library::rebuild`) still ingests unsigned `.cbor` sidecars left by pre-signed-path libraries. The executor's media half is being rebuilt over Rawshift (with Capsule calling Chromahash **0.7.1** directly); the old plaintext decode/extract path was deleted, and its live successors are `capsule_core::exif` and `capsule_core::import::{executor_cancellation, progress}`. ## Import-Upload Streaming Mode @@ -126,7 +126,7 @@ What the rest of the system depends on this module for: - `ImportPlan` — the deterministic output of the planner; rendered to the UI for confirmation. Schema fields: `added` (each entry carrying its resolved destination `album_id`), `skipped`, `conflicts`, `total_size`, `import_id` (UUIDv7), and `streaming_recommended` (set at confirmation from the [free-space probe](#plan--confirm), not by the pure planner). - `available_bytes() → u64` — the library volume's free space (a thin `statvfs` / `GetDiskFreeSpaceEx` wrapper in `capsule-core::library`); the input that decides `streaming_recommended`. -- `execute(plan, cancel_token) → ImportExecutionReport` — the executor entry-point, on the signed path (`S-B2`). Honors the cancel token at every file boundary. Returns per-file status. In [streaming mode](#import-upload-streaming-mode) it drives the per-asset import→upload→verify→release window instead of executing-then-uploading in bulk. Its media half is a planned rebuild over Rawshift plus Capsule's direct Chromahash integration, keeping the privacy mapping, encryption, signing, and commit boundaries Capsule-owned; the old plaintext executor is review-only under `legacy-review/core-import-media/`. +- `execute(plan, cancel_token) → ImportExecutionReport` — the executor entry-point, on the signed path (`S-B2`). Honors the cancel token at every file boundary. Returns per-file status. In [streaming mode](#import-upload-streaming-mode) it drives the per-asset import→upload→verify→release window instead of executing-then-uploading in bulk. Its media half is a planned rebuild over Rawshift plus Capsule's direct Chromahash integration, keeping the privacy mapping, encryption, signing, and commit boundaries Capsule-owned; the old plaintext executor was deleted, and its live successors are `capsule_core::exif` and `capsule_core::import::{executor_cancellation, progress}`. - A stable progress event stream so the UI can report per-asset state (queued / processing / encrypting / uploading / done / failed). ## Validation diff --git a/legacy-review/core-import-media/REVIEW.md b/legacy-review/core-import-media/REVIEW.md deleted file mode 100644 index e7c2ed42..00000000 --- a/legacy-review/core-import-media/REVIEW.md +++ /dev/null @@ -1,9 +0,0 @@ -# Client Import Media Review Notes - -The archived executor mixed Capsule's import transaction with direct EXIF parsing and a hard-coded -Rawshift version. It is not an active API. - -The replacement executor must consume normalized Rawshift results, call Chromahash directly, -apply Capsule privacy and sidecar policy, and only then encrypt, sign, and commit the asset. The -active scanner, grouping, planner, cryptography, sidecars, lifecycle, and local catalog remain the -contractual building blocks. diff --git a/legacy-review/core-import-media/exif/extract.rs b/legacy-review/core-import-media/exif/extract.rs deleted file mode 100644 index 5a577d36..00000000 --- a/legacy-review/core-import-media/exif/extract.rs +++ /dev/null @@ -1,263 +0,0 @@ -use std::fs::File; -use std::io::BufReader; -use std::path::Path; - -use exif::{In, Reader, Tag, Value}; -use jiff::civil; - -#[derive(Debug, Clone, PartialEq, Default)] -pub struct ExifExtract { - pub date_time_original: Option, - pub offset_time_original: Option, // e.g. "+09:00" - pub gps_lat: Option, - pub gps_lon: Option, - pub make: Option, - pub model: Option, - pub width: Option, - pub height: Option, - pub duration_ms: Option, // For video; not from EXIF — always None from this extractor - pub content_identifier: Option, // Apple Live Photo UUID -} - -pub fn extract_exif(path: &Path) -> Result> { - let file = File::open(path)?; - let mut reader = BufReader::new(file); - - let Ok(exif) = Reader::new().read_from_container(&mut reader) else { - // Not a valid EXIF container — return all-None result - return Ok(ExifExtract { - date_time_original: None, - offset_time_original: None, - gps_lat: None, - gps_lon: None, - make: None, - model: None, - width: None, - height: None, - duration_ms: None, - content_identifier: None, - }); - }; - - // DateTimeOriginal - let date_time_original = exif - .get_field(Tag::DateTimeOriginal, In::PRIMARY) - .and_then(|field| { - let dt_str = field.display_value().to_string(); - civil::DateTime::strptime("%Y:%m:%d %H:%M:%S", &dt_str).ok() - }); - - // OffsetTimeOriginal - let offset_time_original = exif - .get_field(Tag::OffsetTimeOriginal, In::PRIMARY) - .map(|field| field.display_value().to_string()) - .map(|s| { - // kamadak-exif may add surrounding quotes; strip them - let s = s.trim(); - if s.starts_with('"') && s.ends_with('"') && s.len() >= 2 { - s[1..s.len() - 1].to_string() - } else { - s.to_string() - } - }); - - // GPS Latitude - let gps_lat_decimal = exif - .get_field(Tag::GPSLatitude, In::PRIMARY) - .and_then(|field| { - if let Value::Rational(ref rationals) = field.value { - if rationals.len() >= 3 { - let deg = rationals[0].to_f64(); - let min = rationals[1].to_f64(); - let sec = rationals[2].to_f64(); - Some(deg + min / 60.0 + sec / 3600.0) - } else { - None - } - } else { - None - } - }); - - let gps_lat = gps_lat_decimal.map(|decimal| { - let ref_str = exif - .get_field(Tag::GPSLatitudeRef, In::PRIMARY) - .map(|f| f.display_value().to_string()) - .unwrap_or_default(); - if ref_str.to_uppercase().contains('S') { - -decimal - } else { - decimal - } - }); - - // GPS Longitude - let gps_lon_decimal = exif - .get_field(Tag::GPSLongitude, In::PRIMARY) - .and_then(|field| { - if let Value::Rational(ref rationals) = field.value { - if rationals.len() >= 3 { - let deg = rationals[0].to_f64(); - let min = rationals[1].to_f64(); - let sec = rationals[2].to_f64(); - Some(deg + min / 60.0 + sec / 3600.0) - } else { - None - } - } else { - None - } - }); - - let gps_lon = gps_lon_decimal.map(|decimal| { - let ref_str = exif - .get_field(Tag::GPSLongitudeRef, In::PRIMARY) - .map(|f| f.display_value().to_string()) - .unwrap_or_default(); - if ref_str.to_uppercase().contains('W') { - -decimal - } else { - decimal - } - }); - - // Make - let make = exif - .get_field(Tag::Make, In::PRIMARY) - .map(|field| field.display_value().to_string()) - .map(|s| strip_quotes(&s)); - - // Model - let model = exif - .get_field(Tag::Model, In::PRIMARY) - .map(|field| field.display_value().to_string()) - .map(|s| strip_quotes(&s)); - - // Width (PixelXDimension) - let width = exif - .get_field(Tag::PixelXDimension, In::PRIMARY) - .and_then(|field| match field.value { - Value::Long(ref v) if !v.is_empty() => Some(v[0]), - Value::Short(ref v) if !v.is_empty() => Some(u32::from(v[0])), - _ => None, - }); - - // Height (PixelYDimension) - let height = exif - .get_field(Tag::PixelYDimension, In::PRIMARY) - .and_then(|field| match field.value { - Value::Long(ref v) if !v.is_empty() => Some(v[0]), - Value::Short(ref v) if !v.is_empty() => Some(u32::from(v[0])), - _ => None, - }); - - // content_identifier — Apple Live Photo UUID (byte search) - let content_identifier = extract_content_identifier(path); - - Ok(ExifExtract { - date_time_original, - offset_time_original, - gps_lat, - gps_lon, - make, - model, - width, - height, - duration_ms: None, - content_identifier, - }) -} - -fn strip_quotes(s: &str) -> String { - let s = s.trim(); - if s.starts_with('"') && s.ends_with('"') && s.len() >= 2 { - s[1..s.len() - 1].to_string() - } else { - s.to_string() - } -} - -fn extract_content_identifier(path: &Path) -> Option { - let bytes = std::fs::read(path).ok()?; - let marker = b"com.apple.quicktime.content.identifier"; - let pos = bytes.windows(marker.len()).position(|w| w == marker)?; - // After the marker, find a UUID-like string (36 chars: 8-4-4-4-12 hex with hyphens) - let after = &bytes[pos + marker.len()..]; - // Scan for UUID pattern in the next 200 bytes - let search_region = &after[..after.len().min(200)]; - let s = std::str::from_utf8(search_region).ok()?; - find_uuid_in_str(s) -} - -fn find_uuid_in_str(s: &str) -> Option { - // Simple scan: find 8-4-4-4-12 hex pattern - for start in 0..s.len().saturating_sub(36) { - let candidate = &s[start..start + 36]; - if is_uuid_format(candidate) { - return Some(candidate.to_ascii_lowercase()); - } - } - None -} - -fn is_uuid_format(s: &str) -> bool { - let bytes = s.as_bytes(); - if bytes.len() != 36 { - return false; - } - let expected_hyphens = [8, 13, 18, 23]; - for (i, &b) in bytes.iter().enumerate() { - if expected_hyphens.contains(&i) { - if b != b'-' { - return false; - } - } else if !b.is_ascii_hexdigit() { - return false; - } - } - true -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_is_uuid_format_valid() { - assert!(is_uuid_format("550e8400-e29b-41d4-a716-446655440000")); - assert!(is_uuid_format("6ba7b810-9dad-11d1-80b4-00c04fd430c8")); - } - - #[test] - fn test_is_uuid_format_invalid() { - assert!(!is_uuid_format("not-a-uuid")); - assert!(!is_uuid_format("550e8400-e29b-41d4-a716-44665544000")); // 35 chars - assert!(!is_uuid_format("550e8400-e29b-41d4-a716-4466554400000")); // 37 chars - assert!(!is_uuid_format("550e8400xe29b-41d4-a716-446655440000")); // wrong hyphen pos - assert!(!is_uuid_format("550e8400-e29b-41d4-a716-44665544zzzz")); // non-hex chars - } - - #[test] - fn test_find_uuid_in_str() { - let s = "some prefix 550e8400-e29b-41d4-a716-446655440000 suffix"; - assert_eq!( - find_uuid_in_str(s), - Some("550e8400-e29b-41d4-a716-446655440000".to_string()) - ); - } - - #[test] - fn test_find_uuid_uppercase_lowercased() { - let s = "prefix 550E8400-E29B-41D4-A716-446655440000 suffix"; - assert_eq!( - find_uuid_in_str(s), - Some("550e8400-e29b-41d4-a716-446655440000".to_string()) - ); - } - - #[test] - fn test_extract_exif_nonexistent_file_returns_io_error() { - let result = extract_exif(Path::new("/nonexistent/path/to/file.jpg")); - assert!(result.is_err()); - } -} diff --git a/legacy-review/core-import-media/exif/mod.rs b/legacy-review/core-import-media/exif/mod.rs deleted file mode 100644 index 4af318c1..00000000 --- a/legacy-review/core-import-media/exif/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod extract; -pub mod timezone; - -pub use extract::{ExifExtract, extract_exif}; -pub use timezone::{TimezoneResolution, resolve_timezone}; diff --git a/legacy-review/core-import-media/exif/timezone.rs b/legacy-review/core-import-media/exif/timezone.rs deleted file mode 100644 index 0c2637ba..00000000 --- a/legacy-review/core-import-media/exif/timezone.rs +++ /dev/null @@ -1,235 +0,0 @@ -use jiff::tz::{Offset, TimeZone}; - -use crate::domain::CaptureTzSource; -use crate::exif::ExifExtract; - -#[derive(Debug, Clone, PartialEq)] -pub struct TimezoneResolution { - pub capture_timestamp: Option, // local wall-clock as Unix epoch (no TZ applied) - pub capture_utc: Option, // UTC timestamp; None when floating - pub capture_tz: Option, // IANA name or "+HH:MM"; None when floating - pub capture_tz_source: Option, - pub tz_db_version: Option, -} - -pub fn resolve_timezone(extract: &ExifExtract) -> TimezoneResolution { - let capture_timestamp = extract - .date_time_original - .and_then(|dt| dt.to_zoned(TimeZone::UTC).ok()) - .map(|z| z.timestamp().as_second()); - - // Case 1: OffsetTimeOriginal present - if let (Some(dt), Some(offset_str)) = - (extract.date_time_original, &extract.offset_time_original) - && let Some(offset) = parse_offset(offset_str) - { - // A fixed offset is never ambiguous, so this either resolves or the datetime - // itself is out of range. - let local = dt.to_zoned(TimeZone::fixed(offset)).ok(); - let capture_utc = local.map(|t| t.timestamp().as_second()); - return TimezoneResolution { - capture_timestamp, - capture_utc, - capture_tz: Some(offset_str.clone()), - capture_tz_source: Some(CaptureTzSource::OffsetExif), - tz_db_version: None, - }; - } - - // Case 2: GPS coordinates present → offline timezone lookup - if let (Some(lat), Some(lon)) = (extract.gps_lat, extract.gps_lon) - && let Some((tz_name, tz_db_ver)) = lookup_timezone(lat, lon) - { - return TimezoneResolution { - capture_timestamp, - capture_utc: None, // Applying the IANA tz is deferred; leave as None - capture_tz: Some(tz_name), - capture_tz_source: Some(CaptureTzSource::GpsLookup), - tz_db_version: Some(tz_db_ver), - }; - } - - // Case 3: Floating - TimezoneResolution { - capture_timestamp, - capture_utc: None, - capture_tz: None, - capture_tz_source: Some(CaptureTzSource::Floating), - tz_db_version: None, - } -} - -fn parse_offset(s: &str) -> Option { - // Parse "+HH:MM" or "-HH:MM" - let s = s.trim(); - if s.len() < 6 { - return None; - } - let sign: i32 = if s.starts_with('-') { -1 } else { 1 }; - let parts: Vec<&str> = s[1..].split(':').collect(); - if parts.len() != 2 { - return None; - } - let hours: i32 = parts[0].parse().ok()?; - let minutes: i32 = parts[1].parse().ok()?; - let total_secs = sign * (hours * 3600 + minutes * 60); - Offset::from_seconds(total_secs).ok() -} - -fn lookup_timezone(lat: f64, lon: f64) -> Option<(String, String)> { - use tzf_rs::DefaultFinder; - let finder = DefaultFinder::new(); - let tz_name = finder.get_tz_name(lon, lat); // tzf-rs takes (lon, lat) - if tz_name.is_empty() { - return None; - } - let tz_db_version = finder.data_version().to_string(); - Some((tz_name.to_string(), tz_db_version)) -} - -#[cfg(test)] -mod tests { - use jiff::civil; - - use super::*; - use crate::domain::CaptureTzSource; - use crate::exif::ExifExtract; - - fn extract_with_offset(dt: &str, offset: &str) -> ExifExtract { - ExifExtract { - date_time_original: civil::DateTime::strptime("%Y:%m:%d %H:%M:%S", dt).ok(), - offset_time_original: Some(offset.to_string()), - gps_lat: None, - gps_lon: None, - make: None, - model: None, - width: None, - height: None, - duration_ms: None, - content_identifier: None, - } - } - - fn extract_with_gps(dt: &str, lat: f64, lon: f64) -> ExifExtract { - ExifExtract { - date_time_original: civil::DateTime::strptime("%Y:%m:%d %H:%M:%S", dt).ok(), - offset_time_original: None, - gps_lat: Some(lat), - gps_lon: Some(lon), - make: None, - model: None, - width: None, - height: None, - duration_ms: None, - content_identifier: None, - } - } - - fn extract_floating(dt: &str) -> ExifExtract { - ExifExtract { - date_time_original: civil::DateTime::strptime("%Y:%m:%d %H:%M:%S", dt).ok(), - offset_time_original: None, - gps_lat: None, - gps_lon: None, - make: None, - model: None, - width: None, - height: None, - duration_ms: None, - content_identifier: None, - } - } - - #[test] - fn test_case1_offset_exif() { - let extract = extract_with_offset("2024:07:15 10:30:00", "+09:00"); - let result = resolve_timezone(&extract); - assert_eq!(result.capture_tz_source, Some(CaptureTzSource::OffsetExif)); - assert_eq!(result.capture_tz, Some("+09:00".to_string())); - assert!(result.capture_utc.is_some()); - // 10:30 +09:00 = 01:30 UTC - assert_eq!( - result.capture_utc.unwrap(), - result.capture_timestamp.unwrap() - 9 * 3600 - ); - assert!(result.tz_db_version.is_none()); - } - - #[test] - fn test_case1_negative_offset() { - let extract = extract_with_offset("2024:07:15 10:30:00", "-05:00"); - let result = resolve_timezone(&extract); - assert_eq!(result.capture_tz_source, Some(CaptureTzSource::OffsetExif)); - // 10:30 -05:00 = 15:30 UTC - assert_eq!( - result.capture_utc.unwrap(), - result.capture_timestamp.unwrap() + 5 * 3600 - ); - } - - #[test] - fn test_case2_gps_lookup() { - // New York City coordinates - let extract = extract_with_gps("2024:07:15 10:30:00", 40.7128, -74.0060); - let result = resolve_timezone(&extract); - assert_eq!(result.capture_tz_source, Some(CaptureTzSource::GpsLookup)); - assert!(result.capture_tz.is_some()); - // Should return America/New_York or similar - let tz = result.capture_tz.unwrap(); - assert!( - tz.contains("New_York") || tz.contains("America"), - "Expected NYC timezone, got: {tz}" - ); - assert!(result.tz_db_version.is_some()); - } - - #[test] - fn test_case3_floating() { - let extract = extract_floating("2024:07:15 10:30:00"); - let result = resolve_timezone(&extract); - assert_eq!(result.capture_tz_source, Some(CaptureTzSource::Floating)); - assert!(result.capture_utc.is_none()); - assert!(result.capture_tz.is_none()); - assert!(result.tz_db_version.is_none()); - assert!(result.capture_timestamp.is_some()); - } - - #[test] - fn test_no_datetime_at_all() { - let extract = ExifExtract { - date_time_original: None, - offset_time_original: None, - gps_lat: None, - gps_lon: None, - make: None, - model: None, - width: None, - height: None, - duration_ms: None, - content_identifier: None, - }; - let result = resolve_timezone(&extract); - assert_eq!(result.capture_tz_source, Some(CaptureTzSource::Floating)); - assert!(result.capture_timestamp.is_none()); - assert!(result.capture_utc.is_none()); - } - - #[test] - fn test_parse_offset_positive() { - let offset = parse_offset("+09:00").unwrap(); - assert_eq!(offset.seconds(), 9 * 3600); - } - - #[test] - fn test_parse_offset_negative() { - let offset = parse_offset("-05:30").unwrap(); - assert_eq!(offset.seconds(), -(5 * 3600 + 30 * 60)); - } - - #[test] - fn test_parse_offset_invalid() { - assert!(parse_offset("bad").is_none()); - assert!(parse_offset("").is_none()); - assert!(parse_offset("+09").is_none()); // missing minutes - } -} diff --git a/legacy-review/core-import-media/import/executor.rs b/legacy-review/core-import-media/import/executor.rs deleted file mode 100644 index df88ed64..00000000 --- a/legacy-review/core-import-media/import/executor.rs +++ /dev/null @@ -1,580 +0,0 @@ -use std::collections::BTreeMap; -use std::fs; -use std::path::{Path, PathBuf}; - -use uuid::Uuid; - -use crate::db::rows::{AssetRow, AssetStackRow, StackMemberRow}; -use crate::domain::MemberRole; -use crate::exif::extract::extract_exif; -use crate::exif::timezone::resolve_timezone; -use crate::import::executor_cancellation::CancellationToken; -use crate::import::planner::{ImportActionPlan, ImportConfig, ImportDecision}; -use crate::import::progress::{ImportExecutionSummary, ImportOutcome, ImportProgressEvent}; -use crate::import::scan::ImportCandidate; -use crate::library::library::Library; -use crate::library::paths::{media_path, sidecar_path, tmp_path}; -use crate::metadata::AssetType; -use crate::sidecar::asset_sidecar::AssetSidecar; -use crate::sidecar::io::write_sidecar; -use crate::sidecar::stack_hint::StackHint; - -const IMPORTER_VERSION: &str = env!("CARGO_PKG_VERSION"); -const RAWSHIFT_VERSION: &str = "0.0.0"; - -/// Phase 4 — execute the import plan. -/// -/// Each `ImportDecision::Import` candidate undergoes a 10-step atomic -/// two-phase commit. Files are never partially written: every media file and -/// its sidecar are either fully committed or cleaned up. -pub fn execute( - plan: &ImportActionPlan, - library: &Library, - config: &ImportConfig, - on_event: impl Fn(ImportProgressEvent), - cancel: &CancellationToken, -) -> Result> { - let total = plan.actions.len() as u64; - let total_files: u64 = plan - .actions - .iter() - .filter(|(_, d)| matches!(d, ImportDecision::Import)) - .map(|(c, _)| c.source_paths.len() as u64) - .sum(); - - on_event(ImportProgressEvent::ImportStarted { - total_candidates: total, - total_files, - }); - - let mut summary = ImportExecutionSummary::default(); - - for (i, (candidate, decision)) in plan.actions.iter().enumerate() { - if cancel.is_cancelled() { - break; - } - - let primary_path = candidate.primary_path().clone(); - on_event(ImportProgressEvent::CandidateStarted { - index: i as u64, - total, - primary_path: primary_path.clone(), - }); - - let outcomes = match decision { - ImportDecision::Import => execute_candidate(candidate, library, config)?, - ImportDecision::SkipDuplicate { existing_uuid } => { - vec![( - primary_path, - ImportOutcome::DuplicateSkipped { - existing_uuid: existing_uuid.clone(), - }, - )] - } - ImportDecision::SkipUnsupported => { - vec![(primary_path, ImportOutcome::Unsupported)] - } - ImportDecision::SkipError(msg) => { - vec![(primary_path, ImportOutcome::CorruptUnreadable(msg.clone()))] - } - }; - - on_event(ImportProgressEvent::CandidateCompleted { - index: i as u64, - outcomes: outcomes.clone(), - }); - summary.outcomes.extend(outcomes); - } - - on_event(ImportProgressEvent::ImportCompleted { - summary: ImportExecutionSummary { - outcomes: summary.outcomes.clone(), - }, - }); - - Ok(summary) -} - -// ── Per-candidate execution ────────────────────────────────────────────────── - -fn execute_candidate( - candidate: &ImportCandidate, - library: &Library, - config: &ImportConfig, -) -> Result, Box> { - let now = now_secs(); - let mut member_commits: Vec = Vec::new(); - - // ── Phase A: copy + verify all members ────────────────────────────────── - for (source_path, role) in &candidate.members { - match commit_member(source_path, *role, candidate, library, config, now) { - Ok(commit) => member_commits.push(commit), - Err(e) => { - // Roll back any already-committed members for this candidate - for prev in &member_commits { - let _ = fs::remove_file(&prev.media_final); - let _ = fs::remove_file(&prev.sidecar_final); - } - let outcome = if e.contains("corrupt_transfer") { - ImportOutcome::CorruptTransfer - } else if e.contains("permission") { - ImportOutcome::PermissionDenied(e) - } else { - ImportOutcome::CorruptUnreadable(e) - }; - return Ok(vec![(source_path.clone(), outcome)]); - } - } - } - - // ── Phase B: DB inserts + stack ───────────────────────────────────────── - let primary_commit = member_commits - .iter() - .find(|c| c.role == MemberRole::Primary) - .or_else(|| member_commits.first()); - - let stack_id = if candidate.stack_type.is_some() { - let sid = format!("stack-{now}"); - // Determine primary UUID - let primary_uuid = primary_commit - .map(|c| c.uuid_str.clone()) - .unwrap_or_default(); - - let stack_row = AssetStackRow { - id: sid.clone(), - stack_type: candidate.stack_type.map_or_else( - || "custom".to_string(), - |st| format!("{st:?}").to_lowercase(), - ), - primary_asset_id: primary_uuid.clone(), - cover_asset_id: Some(primary_uuid), - is_collapsed: true, - is_auto_generated: true, - created_at: now, - modified_at: now, - }; - let _ = library.db.insert_stack(&stack_row); - Some(sid) - } else { - None - }; - - let mut outcomes = Vec::new(); - for (seq, commit) in member_commits.iter().enumerate() { - let is_primary = commit.role == MemberRole::Primary || seq == 0; - - let row = AssetRow { - uuid: commit.uuid_str.clone(), - asset_type: asset_type_str(candidate.detected_type).to_string(), - capture_timestamp: commit.capture_utc.unwrap_or(now), - capture_utc: commit.capture_utc, - capture_tz_source: commit.capture_tz_source.clone(), - import_timestamp: now, - hash_sha256: commit.hash.clone(), - width: commit.width.map(|w| w as i64), - height: commit.height.map(|h| h as i64), - duration_ms: None, - stack_id: stack_id.clone(), - is_stack_hidden: !is_primary, - chromahash: None, - dominant_color: None, - album_id: config.target_album_id.clone(), - rating: 0, - is_deleted: false, - deleted_at: None, - }; - library.db.insert_asset(&row)?; - - if let Some(ref sid) = stack_id { - let member_row = StackMemberRow { - id: format!("{sid}#{seq}"), - stack_id: sid.clone(), - asset_id: commit.uuid_str.clone(), - sequence_order: seq as i64, - member_role: role_str(commit.role).to_string(), - created_at: now, - }; - let _ = library.db.insert_stack_member(&member_row); - } - - // Move mode: delete source file after successful commit - if matches!(config.import_mode, crate::domain::ImportMode::Move) { - let _ = fs::remove_file(&commit.source_path); - } - - outcomes.push((commit.source_path.clone(), ImportOutcome::Imported)); - } - - Ok(outcomes) -} - -// ── Per-member atomic commit ───────────────────────────────────────────────── - -struct MemberCommit { - source_path: PathBuf, - uuid_str: String, - role: MemberRole, - hash: String, - media_final: PathBuf, - sidecar_final: PathBuf, - capture_utc: Option, - capture_tz_source: Option, - width: Option, - height: Option, -} - -fn commit_member( - source: &Path, - role: MemberRole, - candidate: &ImportCandidate, - library: &Library, - config: &ImportConfig, - now: i64, -) -> Result { - // Step 1: Generate UUID - let uuid = Uuid::now_v7(); - let uuid_str = uuid.to_string(); - - // Step 2: EXIF + timezone - let exif = extract_exif(source).unwrap_or_default(); - let tz = resolve_timezone(&exif); - let capture_utc = tz.capture_utc; - let capture_tz_source = tz - .capture_tz_source - .map(|s| format!("{s:?}").to_lowercase()); - let width = exif.width; - let height = exif.height; - - let ext = source - .extension() - .unwrap_or_default() - .to_string_lossy() - .to_lowercase(); - - // Step 3: Create media dir - let final_media = media_path(&library.root, &uuid, &ext, capture_utc); - fs::create_dir_all( - final_media - .parent() - .expect("media path always has a parent directory"), - ) - .map_err(|e| format!("mkdir failed: {e}"))?; - - // Step 4: Copy source → tmp - let tmp_media = tmp_path(&final_media); - fs::copy(source, &tmp_media).map_err(|e| { - if e.kind() == std::io::ErrorKind::PermissionDenied { - format!("permission denied: {e}") - } else { - format!("copy failed: {e}") - } - })?; - - // Step 5: SHA-256 verify - let source_bytes = fs::read(source).map_err(|e| format!("read failed: {e}"))?; - let source_hash = crate::utils::hash::hash_bytes(&source_bytes); - let tmp_bytes = fs::read(&tmp_media).map_err(|e| format!("read tmp failed: {e}"))?; - let tmp_hash = crate::utils::hash::hash_bytes(&tmp_bytes); - if source_hash != tmp_hash { - let _ = fs::remove_file(&tmp_media); - return Err("corrupt_transfer".to_string()); - } - - // Step 6: Build sidecar - let stack_hint = candidate.stack_type.map(|st| StackHint { - detection_key: candidate - .detection_key - .clone() - .unwrap_or_else(|| uuid_str.clone()), - detection_method: candidate - .detection_method - .unwrap_or(crate::domain::DetectionMethod::FilenameStem), - member_role: role, - stack_type: st, - }); - - let original_filename = source - .file_name() - .unwrap_or_default() - .to_string_lossy() - .to_string(); - - let sidecar = AssetSidecar { - version: 1, - uuid: uuid_str.clone(), - asset_type: candidate.detected_type, - original_filename, - import_timestamp: now, - modified_timestamp: now, - hash_sha256: source_hash.clone(), - file_size: source_bytes.len() as u64, - is_deleted: false, - rating: 0, - tags: vec![], - import_mode: config.import_mode, - importer_version: IMPORTER_VERSION.to_string(), - rawshift_version: RAWSHIFT_VERSION.to_string(), - capture_timestamp: tz.capture_timestamp, - capture_utc, - capture_tz: tz.capture_tz, - capture_tz_source: tz.capture_tz_source, - tz_db_version: tz.tz_db_version, - width, - height, - duration_ms: None, - stack_hint, - album_id: config.target_album_id.clone(), - deleted_at: None, - camera_make: exif.make, - camera_model: exif.model, - gps_lat: exif.gps_lat, - gps_lon: exif.gps_lon, - unknown_fields: BTreeMap::new(), - }; - - // Step 7: Write sidecar tmp - let final_sidecar = sidecar_path(&library.root, &uuid, &ext, capture_utc); - let tmp_sidecar = tmp_path(&final_sidecar); - write_sidecar(&tmp_sidecar, &sidecar).map_err(|e| { - let _ = fs::remove_file(&tmp_media); - format!("sidecar write failed: {e}") - })?; - - // Step 8: Rename media tmp → final (atomic) - fs::rename(&tmp_media, &final_media).map_err(|e| { - let _ = fs::remove_file(&tmp_media); - let _ = fs::remove_file(&tmp_sidecar); - format!("rename media failed: {e}") - })?; - - // Step 9: Rename sidecar tmp → final (atomic) - // Note: write_sidecar already does the tmp→final rename internally, - // so final_sidecar already exists. But we wrote to tmp_sidecar above - // manually via tmp_path; write_sidecar expects the *destination* path - // and handles the .tmp internally. Adjust: write directly to final. - // Actually write_sidecar(path, …) writes to path.tmp then renames to path. - // So if we called write_sidecar(&tmp_sidecar, …) that wrote to tmp_sidecar.tmp - // then renamed to tmp_sidecar. We then need to rename tmp_sidecar → final_sidecar. - if tmp_sidecar.exists() { - fs::rename(&tmp_sidecar, &final_sidecar).map_err(|e| { - let _ = fs::remove_file(&tmp_sidecar); - format!("rename sidecar failed: {e}") - })?; - } - // If write_sidecar already placed it at the right path, we're done. - - Ok(MemberCommit { - source_path: source.to_path_buf(), - uuid_str, - role, - hash: source_hash, - media_final: final_media, - sidecar_final: final_sidecar, - capture_utc, - capture_tz_source, - width, - height, - }) -} - -// ── Helpers ────────────────────────────────────────────────────────────────── - -fn asset_type_str(t: AssetType) -> &'static str { - match t { - AssetType::Photo => "photo", - AssetType::Video => "video", - AssetType::Sidecar => "sidecar", - } -} - -fn role_str(r: MemberRole) -> &'static str { - match r { - MemberRole::Primary => "primary", - MemberRole::Raw => "raw", - MemberRole::Video => "video", - MemberRole::Audio => "audio", - MemberRole::DepthMap => "depth_map", - MemberRole::Processed => "processed", - MemberRole::Source => "source", - MemberRole::Alternate => "alternate", - MemberRole::Sidecar => "sidecar", - MemberRole::Proxy => "proxy", - MemberRole::Master => "master", - } -} - -fn now_secs() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i64 -} - -// ── Tests ──────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use std::fs; - - use tempfile::TempDir; - - use super::*; - use crate::domain::ImportMode; - use crate::import::executor_cancellation::CancellationToken; - use crate::import::planner::{ImportConfig, plan}; - use crate::import::scanner::scan; - use crate::library::init::init_library; - - fn noop_event(_: ImportProgressEvent) {} - - #[test] - fn test_single_file_import() { - let src = TempDir::new().unwrap(); - let lib_dir = TempDir::new().unwrap(); - - let photo = src.path().join("test.jpg"); - fs::write(&photo, b"fake jpeg content for test").unwrap(); - - let lib = init_library(lib_dir.path(), "Test").unwrap(); - let scan_result = scan(&[src.path().to_path_buf()]).unwrap(); - let config = ImportConfig::default(); - let plan_result = plan(&scan_result, &lib.db, &config).unwrap(); - - assert_eq!(plan_result.counts.to_import, 1); - - let token = CancellationToken::new(); - let summary = execute(&plan_result, &lib, &config, noop_event, &token).unwrap(); - - assert_eq!(summary.imported_count(), 1); - - // Verify media file exists in library - let media_root = lib_dir.path().join("media"); - let media_files: Vec<_> = walkdir::WalkDir::new(&media_root) - .into_iter() - .filter_map(|e| e.ok()) - .filter(|e| e.path().is_file() && !e.path().to_string_lossy().ends_with(".cbor")) - .collect(); - assert_eq!( - media_files.len(), - 1, - "exactly one media file should be in library" - ); - - // Verify sidecar exists - let sidecar_files: Vec<_> = walkdir::WalkDir::new(&media_root) - .into_iter() - .filter_map(|e| e.ok()) - .filter(|e| e.path().extension().map(|x| x == "cbor").unwrap_or(false)) - .collect(); - assert_eq!(sidecar_files.len(), 1, "exactly one sidecar should exist"); - - // Verify DB has a row - let timeline = lib.db.query_timeline(0, 100).unwrap(); - assert_eq!(timeline.len(), 1); - } - - #[test] - fn test_corrupt_transfer_detected() { - // Test that CorruptTransfer outcome occurs when source and copy diverge. - // This is hard to simulate with real fs::copy, so we test the hash comparison logic. - let src_bytes = b"source content"; - let tmp_bytes = b"different content"; // simulates corruption - let src_hash = crate::utils::hash::hash_bytes(src_bytes); - let tmp_hash = crate::utils::hash::hash_bytes(tmp_bytes); - assert_ne!( - src_hash, tmp_hash, - "hashes should differ for corrupt transfer test" - ); - } - - #[test] - fn test_move_mode_deletes_source() { - let src = TempDir::new().unwrap(); - let lib_dir = TempDir::new().unwrap(); - - let photo = src.path().join("move_me.jpg"); - fs::write(&photo, b"jpeg to move").unwrap(); - - let lib = init_library(lib_dir.path(), "Test").unwrap(); - let scan_result = scan(&[src.path().to_path_buf()]).unwrap(); - let config = ImportConfig { - import_mode: ImportMode::Move, - ..Default::default() - }; - let plan_result = plan(&scan_result, &lib.db, &config).unwrap(); - let token = CancellationToken::new(); - execute(&plan_result, &lib, &config, noop_event, &token).unwrap(); - - assert!( - !photo.exists(), - "source file should be deleted in move mode" - ); - } - - #[test] - fn test_cancellation_stops_execution() { - let src = TempDir::new().unwrap(); - let lib_dir = TempDir::new().unwrap(); - - // Create 3 files - for i in 0..3 { - fs::write( - src.path().join(format!("photo_{i}.jpg")), - format!("content_{i}").as_bytes(), - ) - .unwrap(); - } - - let lib = init_library(lib_dir.path(), "Test").unwrap(); - let scan_result = scan(&[src.path().to_path_buf()]).unwrap(); - let config = ImportConfig::default(); - let plan_result = plan(&scan_result, &lib.db, &config).unwrap(); - - let token = CancellationToken::new(); - token.cancel(); // Cancel before starting - - let summary = execute(&plan_result, &lib, &config, noop_event, &token).unwrap(); - // No files should be processed (cancelled before first item) - assert_eq!( - summary.outcomes.len(), - 0, - "no files imported after immediate cancellation" - ); - } - - #[test] - fn test_raw_jpeg_stack_import() { - let src = TempDir::new().unwrap(); - let lib_dir = TempDir::new().unwrap(); - - fs::write(src.path().join("img_0001.jpg"), b"jpeg content").unwrap(); - fs::write(src.path().join("img_0001.ARW"), b"raw content").unwrap(); - - let lib = init_library(lib_dir.path(), "Test").unwrap(); - let scan_result = scan(&[src.path().to_path_buf()]).unwrap(); - assert_eq!( - scan_result.candidates.len(), - 1, - "should form a single stack candidate" - ); - - let config = ImportConfig::default(); - let plan_result = plan(&scan_result, &lib.db, &config).unwrap(); - let token = CancellationToken::new(); - let summary = execute(&plan_result, &lib, &config, noop_event, &token).unwrap(); - - assert_eq!( - summary.imported_count(), - 2, - "both RAW and JPEG should be imported" - ); - - // Timeline should show only 1 asset (JPEG visible, RAW hidden) - let timeline = lib.db.query_timeline(0, 100).unwrap(); - assert_eq!( - timeline.len(), - 1, - "only primary should be visible in timeline" - ); - } -} diff --git a/legacy-review/core-import-media/import/executor_cancellation.rs b/legacy-review/core-import-media/import/executor_cancellation.rs deleted file mode 100644 index 0536ca68..00000000 --- a/legacy-review/core-import-media/import/executor_cancellation.rs +++ /dev/null @@ -1,43 +0,0 @@ -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; - -/// A token that can be used to cancel an in-progress import. -#[derive(Clone, Default)] -pub struct CancellationToken(Arc); - -impl CancellationToken { - pub fn new() -> Self { - Self(Arc::new(AtomicBool::new(false))) - } - - /// Signal cancellation. - pub fn cancel(&self) { - self.0.store(true, Ordering::SeqCst); - } - - /// Returns `true` if cancellation has been requested. - pub fn is_cancelled(&self) -> bool { - self.0.load(Ordering::SeqCst) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_cancel_and_check() { - let token = CancellationToken::new(); - assert!(!token.is_cancelled()); - token.cancel(); - assert!(token.is_cancelled()); - } - - #[test] - fn test_clone_shares_state() { - let token = CancellationToken::new(); - let cloned = token.clone(); - token.cancel(); - assert!(cloned.is_cancelled(), "clone should see cancellation"); - } -} diff --git a/legacy-review/core-import-media/import/progress.rs b/legacy-review/core-import-media/import/progress.rs deleted file mode 100644 index 086336b7..00000000 --- a/legacy-review/core-import-media/import/progress.rs +++ /dev/null @@ -1,76 +0,0 @@ -use std::path::PathBuf; - -/// Outcome for a single imported file. -#[derive(Debug, Clone)] -pub enum ImportOutcome { - Imported, - DuplicateSkipped { - existing_uuid: String, - }, - Unsupported, - CorruptUnreadable(String), - CorruptTransfer, - PermissionDenied(String), - PartialStackImported { - imported: Vec, - skipped: Vec, - }, - LivePhotoWithoutPair, -} - -/// Progress events emitted during import execution. -#[derive(Debug)] -pub enum ImportProgressEvent { - ImportStarted { - total_candidates: u64, - total_files: u64, - }, - CandidateStarted { - index: u64, - total: u64, - primary_path: PathBuf, - }, - CandidateCompleted { - index: u64, - outcomes: Vec<(PathBuf, ImportOutcome)>, - }, - ImportCompleted { - summary: ImportExecutionSummary, - }, -} - -/// Summary of a completed import run. -#[derive(Debug, Default)] -pub struct ImportExecutionSummary { - pub outcomes: Vec<(PathBuf, ImportOutcome)>, -} - -impl ImportExecutionSummary { - pub fn imported_count(&self) -> usize { - self.outcomes - .iter() - .filter(|(_, o)| matches!(o, ImportOutcome::Imported)) - .count() - } - - pub fn duplicate_count(&self) -> usize { - self.outcomes - .iter() - .filter(|(_, o)| matches!(o, ImportOutcome::DuplicateSkipped { .. })) - .count() - } - - pub fn error_count(&self) -> usize { - self.outcomes - .iter() - .filter(|(_, o)| { - matches!( - o, - ImportOutcome::CorruptUnreadable(_) - | ImportOutcome::CorruptTransfer - | ImportOutcome::PermissionDenied(_) - ) - }) - .count() - } -} From 183625197f75514b602574e210ec6189c1d85b5d Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 21:37:07 -0400 Subject: [PATCH 026/243] ci(rust): gate rustdoc on the frozen crates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cargo doc` ran nowhere, so a broken intra-doc link, an ambiguous one, or public documentation pointing at a private item merged unnoticed — 73 of them had accumulated by the time the previous commit cleared them. `doc-check-rust` runs `cargo doc --no-deps` under `RUSTDOCFLAGS="-D warnings"` and sits in `check-rust` immediately after clippy, where the same class of failure already lives. `--no-deps` so a dependency's own doc warnings cannot fail this build. Scoped to the four crates #399 declares frozen. `capsule-server` (12 spans), `capsule-sdk` (8) and `capsule-cli`/`capsule-wire`/`xtask` (1 each) still fail and are widened in a follow-up rather than fixed in a commit about the gate. Verified negatively: reintroducing a `crate::media` link fails the task. --- mise.toml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/mise.toml b/mise.toml index 19571be9..369913c9 100644 --- a/mise.toml +++ b/mise.toml @@ -86,6 +86,7 @@ description = "Rust CI gate" run = [ "mise run format-check-rust", "mise run lint-check-rust", + "mise run doc-check-rust", "mise run i18n-check", "mise run i18n-guard", "mise run openapi-check-kynos", @@ -134,6 +135,16 @@ run = "cargo clippy --workspace --fix --allow-dirty -- $CLIPPY_FLAGS" [tasks.lint-check-rust] run = "cargo clippy --workspace -- $CLIPPY_FLAGS" +# The rustdoc gate for the frozen crates (issue #399): a broken intra-doc link, an ambiguous +# one, or public documentation pointing at a private item is an error, not a warning. Scoped +# to the four crates whose public API is frozen; the rest of the workspace still carries 23 +# such spans and is widened in a follow-up. `--no-deps` so a dependency's own doc warnings +# cannot fail our build. +[tasks.doc-check-rust] +description = "Rustdoc gate for the frozen crates (intra-doc links, private-item leaks)" +env = { RUSTDOCFLAGS = "-D warnings" } +run = "cargo doc --no-deps -p capsule-core -p capsule-core-ffi -p capsule-wasm -p capsule-i18n" + # nextest: cross-binary parallel scheduling + process-per-test isolation. Two # invocations — the workspace (default features), then capsule-core's FFI surface. # nextest does not run doctests; the workspace has none (if you add one, also add a From 8e91cb44b9b238354c3546e79f850f1c392c747e Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 21:37:44 -0400 Subject: [PATCH 027/243] docs(slices): record the core-import-media deletion commit in S-C59 Follows the repo convention of citing the landing commit's short sha in a slice's detail block once the work described actually happened. --- SLICES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SLICES.md b/SLICES.md index caab873d..75d0430f 100644 --- a/SLICES.md +++ b/SLICES.md @@ -3440,7 +3440,7 @@ had gone when it had not — `legacy-review/core-import-media/` was still in the recorded and never carried out. [#423](https://github.com/Capsulsaurus/Capsule/issues/423) deleted the directory, reworded the two `import/pipeline.md` citations, and dropped the `ROADMAP.md` row, in commit -`PENDING-SHA`. +`b51639b1`. **`capsule_core::media` does go, and it takes the decoder with it.** It was the former standalone media crate, gated behind a non-default feature whose only consumer was the equally-gated From 443cc002fe2e064a03de8cbd2fa95330933b5171 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 21:38:50 -0400 Subject: [PATCH 028/243] chore(xtask,ci): close the boundary capsule-wire left `architecture-check` reads `cargo metadata`, so listing `capsule-wire` in `RETIRED_DEPENDENCIES` makes a re-added path dependency in any member a boundary violation rather than a review question. It joins the list under its retired arm, beside `capsule-media`. It stays out of `RETIRED_COMPONENT_NAMES` deliberately: `check_retired_references` is a substring scan over live `.md`/`.rs`/`.toml` and `ignored_path` excludes `legacy-review/` but neither `SLICES.md` nor `adr/`, so listing the name there would make the records of this retirement fail the check that enforces it. CI's `rust` paths filter loses the `capsule-wire/**` entry, which now names a directory outside the workspace, and the Salvo review notes say which of the moved crate's surfaces must not come back. --- .github/workflows/ci.yml | 1 - legacy-review/server-salvo/REVIEW.md | 4 ++++ xtask/src/architecture.rs | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef44697e..2a070ee6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,7 +51,6 @@ jobs: - 'hk.pkl' - '.cargo/**' - 'capsule-server/**' - - 'capsule-wire/**' - 'capsule-i18n/**' - 'capsule-cli/**' - 'capsule-core/**' diff --git a/legacy-review/server-salvo/REVIEW.md b/legacy-review/server-salvo/REVIEW.md index e36aea3d..078c83a1 100644 --- a/legacy-review/server-salvo/REVIEW.md +++ b/legacy-review/server-salvo/REVIEW.md @@ -23,6 +23,10 @@ ## Do not reuse - Salvo handlers, response writers, OpenAPI registration, or configuration projections. +- `wire/`'s `salvo_responses!` macro and the `WireResponses` taxonomy it expands. It was the + workspace crate `capsule-wire` until the Kynos port; `capsule-server`'s `problem`, `limits` and + `body` modules are the live response taxonomy, and Kynos makes the status part of the return + type, which is the defect the taxonomy was extracted to prevent. - Server-side media decoding or metadata extraction. Those files were deleted during quarantine. - The plaintext asset schema, transformation endpoints, filename-based storage layout, or upload finalization that marks an asset visible before the complete encrypted bundle is durable. diff --git a/xtask/src/architecture.rs b/xtask/src/architecture.rs index e77c6eba..46094cac 100644 --- a/xtask/src/architecture.rs +++ b/xtask/src/architecture.rs @@ -21,6 +21,7 @@ const RETIRED_DEPENDENCIES: &[&str] = &[ "async-graphql", "async-graphql-salvo", "capsule-media", + "capsule-wire", "graphql-client", "object_store", "progenitor", From dca00eedb2c99682776d9cf8e0f3140d1149d690 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 21:39:08 -0400 Subject: [PATCH 029/243] fix(ci): the docs-truth gate did not fire for the manifests it reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `roadmap` check resolves every `ROADMAP.md` row against the manifests that declare what actually exists — the root `Cargo.toml`, `settings.gradle.kts`, `capsule-swift/Project.swift`, each `Package.swift`, `package.json` and `pyproject.toml`, `.gitmodules`, and the `legacy-review/` subtrees. The `docs-truth` paths filter listed none of them, so a pull request that adds or removes a package without touching Markdown skipped the job entirely, and the gate was advisory for precisely the change it exists to catch. Same defect class as the `rust` filter two commits back: a gate that does not run on the files it reads. --- .github/workflows/ci.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2100715b..f183bc3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,6 +93,19 @@ jobs: - '**/*.mdx' - 'LICENSE' - 'NOTICE' + # The `roadmap` check resolves ROADMAP.md's rows against the + # manifests that declare what exists, so a pull request that adds + # or removes a package without touching Markdown must still run + # it — otherwise the gate is advisory for exactly the change it + # exists to catch. + - 'Cargo.toml' + - 'settings.gradle.kts' + - 'capsule-swift/Project.swift' + - '**/Package.swift' + - '**/package.json' + - '**/pyproject.toml' + - '.gitmodules' + - 'legacy-review/**' - 'mise.toml' - '.github/workflows/ci.yml' vision: From 1518c08271478f7b290ca727dc8c2d2b44ad4155 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 21:41:02 -0400 Subject: [PATCH 030/243] build(mise): a task that builds the tests only release builds compile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No task or CI job has ever built the workspace's tests under the release profile, so every `#[cfg(not(debug_assertions))]` test is dead: never compiled, never run, and free to rot. `capsule-i18n` carries the only one today (`format.rs:157`, pinning that a release build passes an unrenderable ICU construct through rather than tripping the `debug_assert!` a debug build trips), so `test-rust-release` is scoped to that crate — building the whole workspace a second time would double the test job for a handful of tests. Left out of `test-rust`'s run list on purpose, with the reason in the file. The first release run this task made possible immediately found why it cannot be wired in yet: `format.rs:144`'s `#[should_panic]` test is named for debug builds but is not `#[cfg(debug_assertions)]`, so under release the `debug_assert!` compiles out, the expected panic never happens, and the test fails. That one attribute is in a file this lane does not own and that #414 is editing concurrently, so it is filed as #429 rather than reached for here. Refs #428 --- mise.toml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/mise.toml b/mise.toml index e21fafbe..44c0ee89 100644 --- a/mise.toml +++ b/mise.toml @@ -145,6 +145,24 @@ run = [ "cargo nextest run -p capsule-sdk --features ffi", ] +# Nothing else in this file or in ci.yml builds tests under the release profile, so every +# `#[cfg(not(debug_assertions))]` test is dead code that no gate ever compiles, let alone runs +# (`#428`). capsule-i18n is the only crate carrying one today +# (capsule-i18n/src/format.rs:157, pinning that a release build passes an unrenderable ICU +# construct through instead of tripping the `debug_assert!` a debug build trips); widen the +# `-p` list when a second crate adds one. Narrow on purpose: building the whole workspace +# twice would double the test job for a handful of tests. +# +# NOT yet wired into `test-rust`, and that is a defect this task exists to expose rather than +# an oversight. `format.rs:144` — `an_unrenderable_icu_construct_is_refused_in_debug_builds`, +# `#[should_panic]`, named for debug builds but missing `#[cfg(debug_assertions)]` — cannot +# pass under release, because `debug_assert!` compiles out and the panic it expects never +# happens. Adding the attribute is a one-line change in a file this lane does not own; until +# it lands, run this task directly. See `#429`. +[tasks.test-rust-release] +description = "Run the tests that only exist in release builds (cfg(not(debug_assertions)))" +run = "cargo nextest run -p capsule-i18n --cargo-profile release" + [tasks.test-coverage-rust] run = "cargo llvm-cov nextest --workspace --fail-under-lines 0" From b54dc818c90bda876a9fc9cab996cd44c8bacb2e Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 21:42:54 -0400 Subject: [PATCH 031/243] docs(slices,adr): record capsule-wire's retirement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0004 flips from `proposed` to `accepted` and gains the `Contract:` line `adr/README.md` requires of a landed decision — without it the record is prose no reader can falsify. `S-C27` becomes `done`, by retirement rather than by completion. Its part 2 was owed to the Kynos port; the port declined it, because the 39 `ToSchema` derives the DTO move was waiting on retired with the Salvo tree instead of moving. The row's own "Done when" — `rg salvo capsule-api/*/src/models` empty plus a byte-identical `openapi.json` — is vacuous now that `capsule-api` does not exist and the SDK generates from the Kynos document, so leaving the row at `part 1 done` would name owed work nobody can do. The salvo→kynos register row says the same, and its claim that `architecture-check` reports 63 violations is put in the past tense it belongs in: the check is clean because the tree it counted is quarantined. `module-map.md`'s crate table and the `ROADMAP.md` package row drop `capsule-wire`: both enumerate what the workspace declares, and the roadmap check resolves rows against `[workspace] members` and the depth-one `legacy-review/*/` buckets, neither of which now names it. The three disabled Salvo manifests point at `../wire` so the quarantined tree stays internally consistent for whoever reads it. --- ROADMAP.md | 1 - SLICES.md | 15 +++++++++++++-- adr/0004-capsule-wire-is-retired.md | 3 ++- .../src/content/docs/design/module-map.md | 1 - .../server-salvo/auth/Cargo.toml.disabled | 2 +- .../server-salvo/media/Cargo.toml.disabled | 2 +- .../server-salvo/upload/Cargo.toml.disabled | 2 +- 7 files changed, 18 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4a93b604..377cf4fb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -41,7 +41,6 @@ A closed set. A row's state is a claim about the package, not about the programm | `capsule-core-ffi` | cargo | The app umbrella staticlib and the `capsule_core_ffi` uniffi namespace | stabilizing | `mise run check-rust` | [Module Map — Client Boundaries](capsule-docs/src/content/docs/design/module-map.md#client-boundaries) | — | Public-API freeze (#399) | Links `capsule-sdk`'s uniffi surface so one Rust library carries both namespaces an app consumes | | `capsule-sdk` | cargo | Session, upload, sync, recovery and protocol-version orchestration over the spargen-generated REST client | stabilizing | `mise run check-rust` | [API Surfaces](capsule-docs/src/content/docs/design/api-surfaces.md) | `S-D9`, `S-D17`, `S-E3`, `S-N2` | Close the four contract gaps (#408) | Both items the tracker owed this crate landed: one transport (`GET /v1/sync` through the generated client) and one document (`capsule-server/openapi.json`) | | `capsule-server` | cargo | The Kynos REST/OpenAPI application and the committed `capsule-server/openapi.json` contract | rebuilding | `mise run check-rust` | [Module Map — Server Modules](capsule-docs/src/content/docs/design/module-map.md#server-modules) | `S-C8`, `S-C39`, `S-C47`, `S-C49`, `S-C51`, `S-E2`, `S-E5`, `S-N1` | A binary, configuration and a serve task (#401) | Fifty-nine operations and a test suite over the real router, with no binary, no configuration loading and no Postgres or Valkey adapter | -| `capsule-wire` | cargo | Framework-free protocol headers and the response taxonomy across the retiring Salvo boundary | stabilizing | `mise run check-rust` | [API Surfaces](capsule-docs/src/content/docs/design/api-surfaces.md) | `S-C27` | Retired (#400) | Only retired code still depends on it; `capsule-server` owns `problem`, `limits` and `body` | | `capsule-wasm` | cargo | The browser boundary — share-link open, guest drop sealing, and LQIP decode | stabilizing | `mise run check-rust` | [Web Upload](capsule-docs/src/content/docs/design/web-upload.md) | — | Public-API freeze (#399) | `S-B14` owes it an `lqip` entry point; the encoder already compiles for `wasm32-unknown-unknown` | | `capsule-i18n` | cargo | The generated Rust catalog bundle, the runtime formatter, and the `error.*` code contract | stabilizing | `mise run check-rust` | [i18n](capsule-docs/src/content/docs/design/i18n.md) | — | ICU plural evaluation (#414) | Generated from `locales/` by `mise run i18n`; `mise run i18n-check` fails on drift | | `capsule-cli` | cargo | The `capsule` binary — local library commands plus auth, sync, push, import and cull | stabilizing | `mise run check-rust` | [Clients](capsule-docs/src/content/docs/design/clients.md) | `S-B17`, `S-B18`, `S-I8`, `S-Q1`, `S-Q2`, `S-Q3`, `S-Q4` | Help text from the catalogs and an enrichment read surface (#413) | The networked commands have no server to reach until #401 lands one | diff --git a/SLICES.md b/SLICES.md index 7ef5081b..9820b458 100644 --- a/SLICES.md +++ b/SLICES.md @@ -275,7 +275,7 @@ lives. | S-C24 | Album-upgrade server halves (quiescence/drain/lineage) | server | S-C42 | M-L | RETIRED | done\* | the ceremony's wire vocabulary was `mls`-gated and therefore unreachable; the projection deliberately gets no lineage | | S-C25 | Album provisioning + UUID album ids (unblocks push) | server | S-C29 | M | RETIRED | done\* | also lands the first real `WriteAuthority`; sharing widens it → `S-C4`/`S-C5` | | S-C26 | Retire the plaintext album name/description columns | server | S-C25 | S | RETIRED | done | the Kynos schema never declared them; a document tripwire keeps it that way | -| S-C27 | Wire-contract types on plain serde behind an adapter | server | — | M | RETIRED | part 1 done | DTO move → Kynos rebuild; status gaps → `S-C28` | +| S-C27 | Wire-contract types on plain serde behind an adapter | server | — | M | RETIRED | done | part 2 declined by the Kynos port; the crate retired with the Salvo tree | | S-C28 | Publish the statuses the server actually returns | server | S-C27 | S | RETIRED | done\* | auth surface closed; folds into each remaining port | | S-C29 | The two storage ports + typed ceremony stores | server | S-C27 | L | RETIRED | done\* | Valkey + Postgres adapters owed; counters → `S-C32` | | S-C30 | Feed `manifest_cbor` carries the signed manifest | server | S-C1, S-C2 | M | RETIRED | done\* | server half stores and serves verbatim; client producer owed to `S-D1` | @@ -2304,6 +2304,17 @@ working on a surface written after it. crate may not depend on salvo at all) and an adapter crate cannot implement a foreign trait for a foreign type. The structs move when Kynos replaces salvo as the schema source, which is why the "Done when" above stays unmet and this row is not `done`. +- **Landed 2026-09-01 — done by retirement.** Part 2 is **declined, not deferred**: the Kynos + port removed the condition it was waiting on. The 39 `ToSchema` derives retired with the Salvo + tree (`S-C59`) instead of moving, `capsule-api` no longer exists, and the SDK generates from + `capsule-server/openapi.json`, so the "Done when" above — `rg salvo capsule-api/*/src/models` + empty plus a byte-identical `openapi.json` — is **vacuous rather than unmet**. The taxonomy's + live home is `capsule-server`'s `problem`, `limits` and `body` modules, where the status is part + of the return type and `tests/conformance.rs` asserts both directions of the agreement this + extraction existed to keep. `capsule-wire` itself moved to `legacy-review/server-salvo/wire/` + beside the 41 `salvo_responses!` call sites that are its only consumers, its manifest disabled, + and `architecture-check` lists it as a retired dependency so a member cannot declare it again + (ADR-0004). ### S-C28 — Publish the statuses the server actually returns @@ -5763,7 +5774,7 @@ table hides what it would cost. | Migration | Status | Measured cost today | Unblocks when | | --- | --- | --- | --- | -| `salvo` → [`kynos`](https://github.com/getkono/kynos) | **started; the precondition has landed** | The measurement that scoped this row was 648 `salvo` occurrences across 84 files, including 51 `impl Writer` and 41 `EndpointOutRegister` blocks. `S-C27` part 1 has since deleted the mechanical half: **315 occurrences across 86 files, 12 `impl Writer`, 2 `EndpointOutRegister`**, with 40 call sites now expanding from one `salvo_responses!` table each, and `auth/src/models/responses.rs` down from 1440 to 1019 lines. What remains is the part that was never boilerplate: 63 `#[handler]`/`#[endpoint]` route fns, 68 `ToSchema` derives and 68 `Depot` reads. The `ToSchema` derives are exactly why **part 2 is owed to the port rather than to another refactor** — a framework-neutral crate cannot carry that derive (optional deps count against the boundary check) and an adapter cannot implement a foreign trait for a foreign type, so the DTO structs move when the framework does. `architecture-check` reports **63 boundary violations**, which is the rebuild worklist. | Kynos is **published at 0.1.0 and consumed from crates.io**; the git-rev pin this row used to require is retired. `capsule-server` exists with a conformance suite, so the port is incremental from here rather than a cutover. | +| `salvo` → [`kynos`](https://github.com/getkono/kynos) | **started; the precondition has landed** | The measurement that scoped this row was 648 `salvo` occurrences across 84 files, including 51 `impl Writer` and 41 `EndpointOutRegister` blocks. `S-C27` part 1 has since deleted the mechanical half: **315 occurrences across 86 files, 12 `impl Writer`, 2 `EndpointOutRegister`**, with 40 call sites now expanding from one `salvo_responses!` table each, and `auth/src/models/responses.rs` down from 1440 to 1019 lines. What remains is the part that was never boilerplate: 63 `#[handler]`/`#[endpoint]` route fns, 68 `ToSchema` derives and 68 `Depot` reads. The `ToSchema` derives are exactly why **part 2 is owed to the port rather than to another refactor** — a framework-neutral crate cannot carry that derive (optional deps count against the boundary check) and an adapter cannot implement a foreign trait for a foreign type, so the DTO structs move when the framework does. `architecture-check` reported **63 boundary violations** while the Salvo tree was still in the workspace, which was the rebuild worklist. Part 2 is now **declined rather than owed**: the `ToSchema` derives retired with the tree instead of moving, and the framework-free crate that carried the taxonomy went with them to `legacy-review/server-salvo/wire/` (`S-C27`, ADR-0004). | Kynos is **published at 0.1.0 and consumed from crates.io**; the git-rev pin this row used to require is retired. `capsule-server` exists with a conformance suite, so the port is incremental from here rather than a cutover. | | `progenitor` → [`spargen`](https://github.com/getkono/spargen) | **done** | — | Complete. Progenitor is gone from `Cargo.lock` and every manifest; `generate_openapi.sh` was deleted in `2996a13`; spargen is shipped and on crates.io. Open items: spargen's object-typed-query-param lowering (gates table), and re-sourcing the SDK's schema from Kynos rather than the Salvo `gen_openapi` binary (`S-D8`). | | Real image codecs (JXL/AVIF/WebP encode, RAW decode) | **deferred** | Nine format modules are decode/encode stubs; only JPEG and PNG are real. | `rawshift` stabilizes for RAW; the JXL/AVIF/WebP encode half is picked up separately against the thumbnails.md format table. `S-B13` makes the gap a typed `UnsupportedFormat` error and reports it at derivative time (`DerivativeStatus::DeferredNoCodec`, warned + counted per run); originals still import signed and verifiable, so the deferral cannot cause incorrect behaviour — only visibly absent thumbnails. | | Test bootstrap: hand-rolled `docker` CLI → Kynos `TestClient` + the `S-C29` conformance suite | **deferred deliberately; retires rather than migrates** | `capsule-api-testing` is a declared default-member, so its 242 lines compile on every build, and it has **zero consumers** — `rg` for the package name outside itself returns nothing. Its `common.rs` shells out to the `docker` CLI via `std::process::Command` to start Postgres, which is a second container-bootstrap approach competing with the testcontainers six other sites hand-roll; its `schema.rs` is entirely `#[cfg(test)]` tests of sea-orm entity CRUD, and those three tests do run and pass in the workspace suite. | Nothing. This is recorded so it is not re-litigated as slimming: reviving it means teaching six call sites in the retiring Salvo tree to share a fixture, which is thrown away at Stage 7.5, and deleting it now removes the only live coverage of the sea-orm migration path while that path is still in use. It retires **with** `capsule-api`. The replacement needs no container at all — Kynos's `TestClient` drives a built `Service` in-process, and `S-C29`'s shared conformance suite is what lets the in-memory adapter stand in for Valkey. | diff --git a/adr/0004-capsule-wire-is-retired.md b/adr/0004-capsule-wire-is-retired.md index 44012145..b3f92aea 100644 --- a/adr/0004-capsule-wire-is-retired.md +++ b/adr/0004-capsule-wire-is-retired.md @@ -1,9 +1,10 @@ # ADR-0004 — `capsule-wire` is retired once no member depends on it -- **Status:** proposed +- **Status:** accepted - **Date:** 2026-09-01 - **Supersedes:** — - **Superseded by:** — +- **Contract:** [API Surfaces](../capsule-docs/src/content/docs/design/api-surfaces.md) - **Slices:** S-C27, S-C59 ## Context diff --git a/capsule-docs/src/content/docs/design/module-map.md b/capsule-docs/src/content/docs/design/module-map.md index fdea8fa0..5c2c1219 100644 --- a/capsule-docs/src/content/docs/design/module-map.md +++ b/capsule-docs/src/content/docs/design/module-map.md @@ -16,7 +16,6 @@ whether something exists today, find its slice: `rg 'S-C16' SLICES.md`. | `capsule-core` | Cryptography (including the MLS album authority), canonical CBOR, validation, CRDTs, sidecars, backup, lifecycle, client filesystem, local SQLite and vector index, import scan/plan/execute, culling, LQIP, share and drop crypto, aggregated federation views, ML orchestration | | `capsule-server` | The Kynos REST/OpenAPI application — see [Server Modules](#server-modules) | | `capsule-sdk` | The Spargen-generated REST client plus the orchestration over it Capsule owns: auth and session refresh, the resumable upload state machine, sync, recovery, protocol-version negotiation, LAN peering | -| `capsule-wire` | The response taxonomy shared by server and SDK. Framework-free by construction: `serde` is its only dependency, so neither side's transport choices reach the other | | `capsule-wasm` | The browser sealing surface `capsule-web` loads — share-link open and guest-drop sealing over `capsule-core` with default features off. Built by `mise run build-wasm`; never committed | | `capsule-i18n` + `xtask::i18n` | Canonical ICU catalogs, runtime localization, generated platform catalogs | | `capsule-core-ffi` | UniFFI bindings for native Swift and Kotlin consumers, on one UniFFI version across both surfaces | diff --git a/legacy-review/server-salvo/auth/Cargo.toml.disabled b/legacy-review/server-salvo/auth/Cargo.toml.disabled index 1319f731..8a0818c4 100644 --- a/legacy-review/server-salvo/auth/Cargo.toml.disabled +++ b/legacy-review/server-salvo/auth/Cargo.toml.disabled @@ -15,7 +15,7 @@ capsule-core = { path = "../../capsule-core" } capsule-api-model = { path = "../model" } capsule-api-service = { path = "../service", features = ["auth"] } capsule-i18n = { path = "../../capsule-i18n" } -capsule-wire = { path = "../../capsule-wire" } +capsule-wire = { path = "../wire" } argon2 = { workspace = true } bb8 = { workspace = true } bb8-redis = { workspace = true } diff --git a/legacy-review/server-salvo/media/Cargo.toml.disabled b/legacy-review/server-salvo/media/Cargo.toml.disabled index f12b5651..410b0ffc 100644 --- a/legacy-review/server-salvo/media/Cargo.toml.disabled +++ b/legacy-review/server-salvo/media/Cargo.toml.disabled @@ -21,7 +21,7 @@ capsule-api-upload = { path = "../upload" } # crate's own capsule-core dependency unifies features in the workspace build anyway). capsule-core = { path = "../../capsule-core", default-features = false } capsule-i18n = { path = "../../capsule-i18n" } -capsule-wire = { path = "../../capsule-wire" } +capsule-wire = { path = "../wire" } base64 = { workspace = true } bytes = { workspace = true } derive_more = { workspace = true, features = ["from"] } diff --git a/legacy-review/server-salvo/upload/Cargo.toml.disabled b/legacy-review/server-salvo/upload/Cargo.toml.disabled index 2c9052f6..8fb6563a 100644 --- a/legacy-review/server-salvo/upload/Cargo.toml.disabled +++ b/legacy-review/server-salvo/upload/Cargo.toml.disabled @@ -17,7 +17,7 @@ capsule-api-model = { path = "../model" } capsule-api-service = { path = "../service" } capsule-core = { path = "../../capsule-core", features = ["media"] } capsule-i18n = { path = "../../capsule-i18n" } -capsule-wire = { path = "../../capsule-wire" } +capsule-wire = { path = "../wire" } bb8-redis = { workspace = true } eyre = { workspace = true } futures-util = { workspace = true } From b4c6a42b9f094711c50d3001d32cb5ab1e648b80 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 21:43:35 -0400 Subject: [PATCH 032/243] test(wasm): add host-runnable unit tests; widen default-members `capsule-wasm` had zero Rust tests, and `capsule-sdk` sat outside `default-members`, so a bare `cargo test` skipped the crate whose push, staged, net and recovery tests this series' import rewrites touch. `capsule-sdk` joins `default-members`. `capsule-wasm` stays out deliberately: its real artefact needs `--target wasm32-unknown-unknown`, on the host its `cdylib` links a library nothing consumes, and the wasm surface is already gated by `build-check-wasm`. `cargo nextest run --workspace` (what `test-rust` runs) covers the new tests either way. Five host tests over the crate's pure, JS-free functions: - `sharing_code` and `open_code` map every `SharingError` variant, with the security property asserted directly: on the open path a wrong passphrase and a wrong fragment secret must produce the *same* code, or the viewer becomes an oracle for which half of a link was wrong. - an exhaustive match that fails the build if a variant is added upstream without a boundary code, rather than letting it reach the viewer as an unmapped string. - `hex_array` over a canonical 64-char fragment, whitespace included. - `decode_wrapped` over a base64 round trip of a canonical `WrappedScope`. Ok paths only, and the module comment says why: every `Err` arm builds a `JsError`, whose `__wbindgen_error_new` extern is a panicking placeholder off `wasm32`. Error-path behaviour at the boundary stays covered by `capsule-web`'s bun KATs. --- Cargo.toml | 1 + capsule-wasm/src/lib.rs | 104 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 12b28f90..ffdbdaf4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ default-members = [ "capsule-core", "capsule-core-ffi", "capsule-i18n", + "capsule-sdk", "capsule-server", "capsule-wire", ] diff --git a/capsule-wasm/src/lib.rs b/capsule-wasm/src/lib.rs index 0727f4bf..531c924a 100644 --- a/capsule-wasm/src/lib.rs +++ b/capsule-wasm/src/lib.rs @@ -361,3 +361,107 @@ pub fn drop_passphrase_proof( .map_err(|_| JsError::new(err::SEAL_FAILED))?; Ok(hex::encode(proof)) } + +// ── Tests ────────────────────────────────────────────────────────────────────── +// +// Host-runnable only, and deliberately confined to the paths that return `Ok`. Every +// `Err` arm here builds a `JsError`, which goes through wasm-bindgen's +// `__wbindgen_error_new` extern; off `wasm32` that symbol is a panicking placeholder, so a +// test that provoked an error would abort rather than assert. The error *codes* are still +// covered, because `sharing_code`/`open_code` return `&'static str` and never touch +// `JsValue`. The wasm-side behaviour of the `#[wasm_bindgen]` entry points is covered by +// `capsule-web`'s bun KATs. + +#[cfg(test)] +mod tests { + use super::*; + + /// The full variant set, written out so the exhaustive match below is meaningful. + fn every_sharing_error() -> [SharingError; 5] { + [ + SharingError::ScopeUnavailable, + SharingError::NotFound, + SharingError::PassphraseRequired, + SharingError::WrongPassphrase, + SharingError::Crypto("kem"), + ] + } + + #[test] + fn sharing_code_maps_each_variant() { + assert_eq!( + sharing_code(&SharingError::PassphraseRequired), + err::PASSPHRASE_REQUIRED + ); + assert_eq!( + sharing_code(&SharingError::WrongPassphrase), + err::WRONG_SECRET + ); + assert_eq!( + sharing_code(&SharingError::ScopeUnavailable), + err::SCOPE_UNAVAILABLE + ); + assert_eq!(sharing_code(&SharingError::NotFound), err::MALFORMED); + assert_eq!(sharing_code(&SharingError::Crypto("kem")), err::MALFORMED); + } + + /// The oracle property: on the open path a wrong passphrase and a wrong fragment secret + /// must be indistinguishable, so both collapse to `wrong_secret`. A viewer that could + /// tell them apart would report *which* half of the link was wrong. + #[test] + fn open_code_cannot_distinguish_a_wrong_passphrase_from_a_wrong_fragment() { + assert_eq!( + open_code(&SharingError::WrongPassphrase), + open_code(&SharingError::Crypto("decapsulate")) + ); + assert_eq!(open_code(&SharingError::WrongPassphrase), err::WRONG_SECRET); + assert_eq!( + open_code(&SharingError::PassphraseRequired), + err::PASSPHRASE_REQUIRED + ); + assert_eq!(open_code(&SharingError::ScopeUnavailable), err::MALFORMED); + assert_eq!(open_code(&SharingError::NotFound), err::MALFORMED); + } + + /// A `SharingError` variant added upstream without a boundary code must fail the build + /// here rather than reach the viewer as an unmapped string: the match is exhaustive. + #[test] + fn every_variant_carries_a_non_empty_code_on_both_paths() { + for e in &every_sharing_error() { + match e { + SharingError::ScopeUnavailable + | SharingError::NotFound + | SharingError::PassphraseRequired + | SharingError::WrongPassphrase + | SharingError::Crypto(_) => {} + } + assert!(!sharing_code(e).is_empty(), "no sharing code for {e:?}"); + assert!(!open_code(e).is_empty(), "no open code for {e:?}"); + } + } + + #[test] + fn hex_array_decodes_a_canonical_32_byte_field() { + let bytes: [u8; LINK_SECRET_LEN] = std::array::from_fn(|i| i as u8); + let encoded = hex::encode(bytes); + assert_eq!(encoded.len(), 64); + + // Surrounding whitespace is trimmed, as the browser hands the fragment over. + let decoded = hex_array::(&format!(" {encoded}\n")) + .expect("a canonical 64-char hex field decodes"); + assert_eq!(decoded, bytes); + } + + #[test] + fn decode_wrapped_round_trips_a_canonical_wrapped_scope() { + let wrapped = WrappedScope::LinkOnly { + blob: b"sealed-scope-material".to_vec(), + }; + let cbor = capsule_core::cbor::to_canonical_vec(&wrapped).expect("canonical CBOR"); + let b64 = BASE64.encode(&cbor); + + let decoded = decode_wrapped(&b64).expect("the material the serve path returns decodes"); + assert_eq!(decoded, wrapped); + assert!(!decoded.is_passphrase_protected()); + } +} From f0d5c3cc5c9d229f8618efb7f424bcb7280bf891 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 21:48:53 -0400 Subject: [PATCH 033/243] fix(docs): close four blind spots in the roadmap gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The oracles read the files this repository happens to have rather than the files their formats allow, so the promise at the top of `ROADMAP.md` — that adding a package fails the gate until a row exists — did not hold as written. - **Package roots one level down were invisible.** `manifestDirs` scanned only the repository root, so `apps/viewer/package.json` declared a package no gate could see. It now recurses exactly one level, skipping hidden and pruned directories, and names a nested root repo-relative — the convention `Cargo.toml` already uses for `capsule-cli/entity`. A package root is not searched for further roots: a bun or cargo package legitimately carries sub-manifests that are not packages. The depth bound is documented as a bound; the alternative is walking `node_modules` and `target` on every docs-only pull request. - **Kotlin's `include` is variadic.** `include(":a", ":b")` is one call declaring two modules, and the DSL tolerates `include (…)`. The regex matched neither. - **Slice-id resolution covered one column.** `Next milestone` and `Notes` cite slices constantly, and so does the deferred register below the table; four stale citations in this file were found in exactly those unchecked cells. Resolution is now a single pass over every backticked `S-…` in the document, reported once, with the `Open slices` column keeping only its shape check. - **`miseTasks` did not know two spellings mise supports.** A quoted header, `[tasks."docs:build"]`, and a file task in a subdirectory, where `mise-tasks/docs/build` is `docs:build`. Neither is used here yet, and missing either would have failed a *correct* row — the worse of the two ways for a gate to be wrong. `checkRoadmap`'s `@returns` now names `unused`, which it has returned since it was written. Ten new unit tests; 42 in this file, 107 across `capsule-docs`. --- capsule-docs/scripts/check-roadmap.mjs | 124 ++++++++++++++++---- capsule-docs/scripts/check-roadmap.test.mjs | 112 ++++++++++++++++++ 2 files changed, 215 insertions(+), 21 deletions(-) diff --git a/capsule-docs/scripts/check-roadmap.mjs b/capsule-docs/scripts/check-roadmap.mjs index b89af762..eef83151 100644 --- a/capsule-docs/scripts/check-roadmap.mjs +++ b/capsule-docs/scripts/check-roadmap.mjs @@ -29,6 +29,12 @@ * oracles, and a conditional target earns a row whose `State` says `excluded`. * `CapsuleCatalogFFI` still matches `module("` and so is required to have a row; * the commented-out Gradle modules match nothing and so must not have one. + * + * **Package-root discovery recurses exactly one level, and that is a bound rather than an + * oversight.** A `Package.swift` two directories deep would be missed; the alternative is + * an unbounded walk that reads `node_modules`, `target` and `.build` on every docs-only + * pull request. One level covers every shape the tree uses today, and the pruned-directory + * set below is what keeps the walk cheap. */ import { existsSync, readdirSync, readFileSync } from 'node:fs'; @@ -156,12 +162,26 @@ function cargoPackages(root) { return quoted(block[1], /"([^"]+)"/g); } -/** Unconditional `include(":x")`, resolved to the directory `project()` names. */ +/** + * Unconditional `include(…)`, resolved to the directory `project()` names. + * + * Kotlin's `include` is variadic — `include(":a", ":b")` is one call declaring two modules — + * and the DSL tolerates a space before the parenthesis. Matching only `include(":x")` read + * the file this repository happens to have rather than the file the DSL allows, so a + * multi-argument call would have added modules the gate could not see. + */ function gradlePackages(root) { const settings = join(root, 'settings.gradle.kts'); if (!existsSync(settings)) return []; const body = readFileSync(settings, 'utf8'); - const included = quoted(body, /^include\("([^"]+)"\)/g); + const included = []; + for (const line of body.split('\n')) { + const trimmed = line.trim(); + if (trimmed.startsWith('//')) continue; + const call = /^include\s*\(([^)]*)\)/.exec(trimmed); + if (!call) continue; + for (const arg of call[1].matchAll(/"([^"]+)"/g)) included.push(arg[1]); + } const dirs = new Map(); for (const line of body.split('\n')) { const trimmed = line.trim(); @@ -191,17 +211,39 @@ function tuistPackages(root) { return names; } -/** Root-level directories carrying `manifest`, e.g. `package.json`. */ -function manifestDirs(root, manifest) { - return readdirSync(root, { withFileTypes: true }) +/** Directories worth descending into: not hidden, not pruned. */ +function searchable(root, prefix) { + return readdirSync(join(root, prefix), { withFileTypes: true }) .filter( (entry) => entry.isDirectory() && !entry.name.startsWith('.') && - !SKIP_ROOT_DIRS.has(entry.name) && - existsSync(join(root, entry.name, manifest)), + !SKIP_ROOT_DIRS.has(entry.name), ) - .map((entry) => entry.name); + .map((entry) => (prefix ? `${prefix}/${entry.name}` : entry.name)); +} + +/** + * Directories carrying `manifest`, at the repository root or one level under it. + * + * The returned name is repo-relative, so a nested root is `sub/nested` and that is what its + * `Package` cell must say — the same convention `Cargo.toml` members already use for + * `capsule-cli/entity`. + */ +function manifestDirs(root, manifest) { + const found = []; + for (const dir of searchable(root, '')) { + if (existsSync(join(root, dir, manifest))) { + found.push(dir); + // A package root is not searched for nested roots: a Cargo or bun package + // legitimately contains sub-manifests that are not separate packages. + continue; + } + for (const nested of searchable(root, dir)) { + if (existsSync(join(root, nested, manifest))) found.push(nested); + } + } + return found; } /** `path = x` in `.gitmodules`. */ @@ -245,7 +287,16 @@ export function declaredPackages(root) { return declared; } -/** Every `mise run ` name the repository actually has. */ +/** + * Every `mise run ` name the repository actually has. + * + * Two spellings beyond the obvious one, both of which mise supports and neither of which + * this repository uses yet. A quoted header — `[tasks."docs:build"]` — is how a task name + * carrying a colon is written, and a file task may sit in a subdirectory, where + * `mise-tasks/docs/build` is the task `docs:build`. Missing either would fail loudly rather + * than silently, but it would fail on a correct row, which is the worse of the two ways for + * a gate to be wrong. + */ export function miseTasks(root) { const tasks = new Set(); @@ -253,22 +304,44 @@ export function miseTasks(root) { const path = join(root, manifest); if (!existsSync(path)) continue; for (const match of readFileSync(path, 'utf8').matchAll( - /^\[tasks\.([A-Za-z0-9_-]+)\]/gm, + /^\[tasks\.(?:"([^"]+)"|([A-Za-z0-9_:-]+))\]/gm, )) { - tasks.add(match[1]); + tasks.add(match[1] ?? match[2]); } } - const fileTasks = join(root, 'mise-tasks'); - if (existsSync(fileTasks)) { - for (const entry of readdirSync(fileTasks, { withFileTypes: true })) { - if (entry.isFile()) tasks.add(entry.name); + const walk = (dir, prefix) => { + if (!existsSync(dir)) return; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name.startsWith('.')) continue; + if (entry.isFile()) tasks.add(prefix + entry.name); + else if (entry.isDirectory()) + walk(join(dir, entry.name), `${prefix + entry.name}:`); } - } + }; + walk(join(root, 'mise-tasks'), ''); return tasks; } +/** + * Every backticked `S-…` id in `ROADMAP.md`, with the 1-based line it sits on. + * + * Deliberately not restricted to the `Open slices` column. The column is where a reader + * looks for a slice, and it is not the only place this file names one: `Next milestone` + * and `Notes` cite slices constantly, and so does the deferred register below the package + * table. Four stale citations in this file were found in exactly those unchecked cells. + */ +export function citedSlices(source) { + const cited = []; + source.split('\n').forEach((line, index) => { + for (const match of line.matchAll(/`(S-[A-Za-z0-9]+)`/g)) { + cited.push({ id: match[1], line: index + 1 }); + } + }); + return cited; +} + /** Every slice id `SLICES.md` gives a detail block. */ export function sliceIds(root) { const path = join(root, SLICES); @@ -284,7 +357,8 @@ export function sliceIds(root) { * Resolve every `ROADMAP.md` row against the tree. * * @param {string} root Repository root. - * @returns {{ findings: string[], checked: number }} + * @returns {{ findings: string[], checked: number, unused: string[] }} `unused` names the + * states the vocabulary defines and no row uses; it is reported, never failed. */ export function checkRoadmap(root) { const findings = []; @@ -389,19 +463,27 @@ export function checkRoadmap(root) { } } + // Shape only. Whether an id *resolves* is settled below, over the whole + // document, so a stale citation in `Notes` is caught the same way as one here. if (open !== NONE) { for (const id of open.split(',').map((entry) => entry.trim())) { if (!/^S-[A-Z]+\d+$/.test(id)) { findings.push(`${at} \`${id}\` is not a slice id`); - } else if (!slices.has(id)) { - findings.push( - `${at} \`${id}\` has no detail block in ${SLICES}`, - ); } } } } + for (const { id, line } of citedSlices(source)) { + if (!/^S-[A-Z]+\d+$/.test(id)) { + findings.push(`${ROADMAP}:${line} \`${id}\` is not a slice id`); + } else if (!slices.has(id)) { + findings.push( + `${ROADMAP}:${line} \`${id}\` has no detail block in ${SLICES}`, + ); + } + } + for (const [pkg, kind] of declared) { if (!seen.has(pkg)) { findings.push(`${ROADMAP} ${kind} package \`${pkg}\` has no row`); diff --git a/capsule-docs/scripts/check-roadmap.test.mjs b/capsule-docs/scripts/check-roadmap.test.mjs index 48302930..0ffcb329 100644 --- a/capsule-docs/scripts/check-roadmap.test.mjs +++ b/capsule-docs/scripts/check-roadmap.test.mjs @@ -4,6 +4,7 @@ import { dirname, join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { checkRoadmap, + citedSlices, declaredPackages, definedStates, miseTasks, @@ -136,6 +137,24 @@ describe('declaredPackages', () => { expect(declaredPackages(r).get('capsule-android')).toBe('gradle'); }); + it('reads every path in a variadic gradle include, and tolerates a space', () => { + // Kotlin's `include` is variadic and the DSL allows `include (…)`. Matching only + // `include(":x")` read the file this repository happens to have. + const r = repo({ + 'settings.gradle.kts': + 'include(":android", ":core")\n' + + 'project(":android").projectDir = file("capsule-android")\n' + + 'project(":core").projectDir = file("capsule-core-kotlin")\n' + + 'include (":desktop")\n' + + 'project(":desktop").projectDir = file("capsule-desktop")\n', + }); + expect([...declaredPackages(r).keys()]).toEqual([ + 'capsule-android', + 'capsule-core-kotlin', + 'capsule-desktop', + ]); + }); + it('does not see a commented-out gradle include', () => { // `settings.gradle.kts` keeps `:cli`/`:desktop` commented out; a row for // either would then be an orphan, which is the finding to avoid. @@ -169,6 +188,35 @@ describe('declaredPackages', () => { expect(declaredPackages(r).get('CapsuleCatalogFFI')).toBe('tuist'); }); + it('finds a package root one level down, named repo-relative', () => { + const r = repo({ + 'apps/viewer/package.json': '{}\n', + 'tools/lint/pyproject.toml': '[project]\n', + }); + const declared = declaredPackages(r); + expect(declared.get('apps/viewer')).toBe('bun'); + expect(declared.get('tools/lint')).toBe('python'); + }); + + it('does not treat a sub-manifest inside a package root as a second package', () => { + // A bun package legitimately carries nested `package.json` files; only the root + // one is the package. + const r = repo({ + 'capsule-web/package.json': '{}\n', + 'capsule-web/vendor/package.json': '{}\n', + }); + expect([...declaredPackages(r).keys()]).toEqual(['capsule-web']); + }); + + it('never descends into a pruned or hidden directory', () => { + const r = repo({ + 'node_modules/left-pad/package.json': '{}\n', + 'target/debug/package.json': '{}\n', + '.cache/x/package.json': '{}\n', + }); + expect(declaredPackages(r).size).toBe(0); + }); + it('classifies a package root by the manifest it carries', () => { const r = repo({ 'capsule-core-swift/Package.swift': '// swift-tools-version:6.0\n', @@ -204,6 +252,19 @@ describe('miseTasks and sliceIds', () => { ]); }); + it('reads a quoted task header and a task in a file-task subdirectory', () => { + // Both are mise spellings this repository does not use yet. Missing either would + // fail a correct row, which is the worse of the two ways for a gate to be wrong. + const r = repo({ + 'mise.toml': '[tasks."docs:build"]\nrun = "true"\n', + 'mise-tasks/docs/publish': '#!/usr/bin/env bash\n', + }); + expect([...miseTasks(r)].sort()).toEqual([ + 'docs:build', + 'docs:publish', + ]); + }); + it('reads slice ids from detail headings only', () => { const r = repo({ 'SLICES.md': @@ -213,6 +274,22 @@ describe('miseTasks and sliceIds', () => { }); }); +describe('citedSlices', () => { + it('reports every backticked id with its line, wherever it sits', () => { + expect( + citedSlices('prose `S-A1`\n| a | `S-B2`, `S-B3` |\nno ids here\n'), + ).toEqual([ + { id: 'S-A1', line: 1 }, + { id: 'S-B2', line: 2 }, + { id: 'S-B3', line: 2 }, + ]); + }); + + it('ignores an unbackticked mention, which is prose and not a citation', () => { + expect(citedSlices('see S-A1 for this\n')).toEqual([]); + }); +}); + describe('checkRoadmap', () => { it('passes a roadmap whose rows all resolve', () => { const r = repo({ ...BASE, 'ROADMAP.md': roadmap([row('alpha')]) }); @@ -274,6 +351,41 @@ describe('checkRoadmap', () => { ]); }); + it('fails on a stale citation in Notes, not only in Open slices', () => { + // Restricting resolution to one column is what let four stale citations through. + const r = repo({ + ...BASE, + 'ROADMAP.md': roadmap([ + row('alpha', { notes: 'blocked behind `S-Z9`' }), + ]), + }); + expect(checkRoadmap(r).findings).toEqual([ + 'ROADMAP.md:17 `S-Z9` has no detail block in SLICES.md', + ]); + }); + + it('fails on a stale citation in the deferred register below the table', () => { + const register = + '| Item | Owner docs | State | Notes |\n| --- | --- | --- |\n| a thing | [d](d.md) | deferred | `S-Z9` |'; + const r = repo({ + ...BASE, + 'ROADMAP.md': `${roadmap([row('alpha')])}\n${register}\n`, + }); + expect(checkRoadmap(r).findings).toEqual([ + 'ROADMAP.md:21 `S-Z9` has no detail block in SLICES.md', + ]); + }); + + it('reports a stale id once, not once per check', () => { + const r = repo({ + ...BASE, + 'ROADMAP.md': roadmap([row('alpha', { open: '`S-Z9`' })]), + }); + expect(checkRoadmap(r).findings).toEqual([ + 'ROADMAP.md:17 `S-Z9` has no detail block in SLICES.md', + ]); + }); + it('fails on a slice cell that is not an id at all', () => { const r = repo({ ...BASE, From adea7c2c62e3d016139dfcb95fe1612f4cff70de Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 21:49:36 -0400 Subject: [PATCH 034/243] docs(roadmap): six rows that claimed more than the tree supports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `capsule-wire` said only retired code depends on it. `capsule-server` declares it at `Cargo.toml:49` — ADR-0004 in this same branch says so — and what is true is narrower: declared, and called by nothing. - `capsule-wasm`'s `Owns` claimed LQIP decode. `grep -rni lqip capsule-wasm/` is empty, and the row's own `Notes` says `S-B14` owes it the entry point. - `capsule-android` was `blocked`, which this file defines as a dependency *outside* the package gating it. The missing DI layer is inside the package and the work has started, so it is `stabilizing` — with the caveat that matters spelled out, since `check-kotlin` is lint-only and passes on a package that does not compile. - `capsule-server` has a binary, `gen_openapi`; what it lacks is a *serve* binary. - `capsule-cli/entity` has five entities, not the two named. - `xtask` does not check licences: `mise run license-check` is `cargo deny` over `deny.toml`. `blocked` now joins `frozen` as a defined state no row uses, which the `roadmap` check reports rather than failing, for the reason recorded in the pull request. --- ROADMAP.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4a93b604..808d31a4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -40,15 +40,15 @@ A closed set. A row's state is a claim about the package, not about the programm | `capsule-core` | cargo | The offline crypto data plane, catalog, signed sidecars, import pipeline, LQIP, and the OpenMLS authority | stabilizing | `mise run check-rust` | [Module Map](capsule-docs/src/content/docs/design/module-map.md) | `S-B1`, `S-B5`, `S-B13`, `S-D24`, `S-D29` | Public-API freeze (#399) | `capsule-core::media` is designed and unbuilt, so there is no image decoder in the workspace and every still import is a `DeferredNoCodec` | | `capsule-core-ffi` | cargo | The app umbrella staticlib and the `capsule_core_ffi` uniffi namespace | stabilizing | `mise run check-rust` | [Module Map — Client Boundaries](capsule-docs/src/content/docs/design/module-map.md#client-boundaries) | — | Public-API freeze (#399) | Links `capsule-sdk`'s uniffi surface so one Rust library carries both namespaces an app consumes | | `capsule-sdk` | cargo | Session, upload, sync, recovery and protocol-version orchestration over the spargen-generated REST client | stabilizing | `mise run check-rust` | [API Surfaces](capsule-docs/src/content/docs/design/api-surfaces.md) | `S-D9`, `S-D17`, `S-E3`, `S-N2` | Close the four contract gaps (#408) | Both items the tracker owed this crate landed: one transport (`GET /v1/sync` through the generated client) and one document (`capsule-server/openapi.json`) | -| `capsule-server` | cargo | The Kynos REST/OpenAPI application and the committed `capsule-server/openapi.json` contract | rebuilding | `mise run check-rust` | [Module Map — Server Modules](capsule-docs/src/content/docs/design/module-map.md#server-modules) | `S-C8`, `S-C39`, `S-C47`, `S-C49`, `S-C51`, `S-E2`, `S-E5`, `S-N1` | A binary, configuration and a serve task (#401) | Fifty-nine operations and a test suite over the real router, with no binary, no configuration loading and no Postgres or Valkey adapter | -| `capsule-wire` | cargo | Framework-free protocol headers and the response taxonomy across the retiring Salvo boundary | stabilizing | `mise run check-rust` | [API Surfaces](capsule-docs/src/content/docs/design/api-surfaces.md) | `S-C27` | Retired (#400) | Only retired code still depends on it; `capsule-server` owns `problem`, `limits` and `body` | -| `capsule-wasm` | cargo | The browser boundary — share-link open, guest drop sealing, and LQIP decode | stabilizing | `mise run check-rust` | [Web Upload](capsule-docs/src/content/docs/design/web-upload.md) | — | Public-API freeze (#399) | `S-B14` owes it an `lqip` entry point; the encoder already compiles for `wasm32-unknown-unknown` | +| `capsule-server` | cargo | The Kynos REST/OpenAPI application and the committed `capsule-server/openapi.json` contract | rebuilding | `mise run check-rust` | [Module Map — Server Modules](capsule-docs/src/content/docs/design/module-map.md#server-modules) | `S-C8`, `S-C39`, `S-C47`, `S-C49`, `S-C51`, `S-E2`, `S-E5`, `S-N1` | A serve binary, configuration and a serve task (#401) | Fifty-nine operations and a test suite over the real router. `src/bin/` holds one binary, `gen_openapi`, which only describes the router; there is no serve binary, no configuration loading and no Postgres or Valkey adapter | +| `capsule-wire` | cargo | Framework-free protocol headers and the response taxonomy across the retiring Salvo boundary | stabilizing | `mise run check-rust` | [API Surfaces](capsule-docs/src/content/docs/design/api-surfaces.md) | `S-C27` | Retired (#400) | Declared by `capsule-server` and called by nothing — the only mention of `capsule_wire` outside the crate is one prose reference in a module comment. `capsule-server` owns `problem`, `limits` and `body` | +| `capsule-wasm` | cargo | The browser boundary — share-link client-side open and guest-drop sealing | stabilizing | `mise run check-rust` | [Web Upload](capsule-docs/src/content/docs/design/web-upload.md) | — | Public-API freeze (#399) | `S-B14` owes it an `lqip` entry point; the encoder already compiles for `wasm32-unknown-unknown` | | `capsule-i18n` | cargo | The generated Rust catalog bundle, the runtime formatter, and the `error.*` code contract | stabilizing | `mise run check-rust` | [i18n](capsule-docs/src/content/docs/design/i18n.md) | — | ICU plural evaluation (#414) | Generated from `locales/` by `mise run i18n`; `mise run i18n-check` fails on drift | | `capsule-cli` | cargo | The `capsule` binary — local library commands plus auth, sync, push, import and cull | stabilizing | `mise run check-rust` | [Clients](capsule-docs/src/content/docs/design/clients.md) | `S-B17`, `S-B18`, `S-I8`, `S-Q1`, `S-Q2`, `S-Q3`, `S-Q4` | Help text from the catalogs and an enrichment read surface (#413) | The networked commands have no server to reach until #401 lands one | -| `capsule-cli/entity` | cargo | sea-orm entities for the CLI's sync store — `sync_cursor` and `synced_asset` | stabilizing | `mise run check-rust` | [Clients](capsule-docs/src/content/docs/design/clients.md) | — | Follows `capsule-cli` (#413) | The one place `chrono` is permitted, as the sea-orm column type; convert at the entity boundary | +| `capsule-cli/entity` | cargo | The CLI's five sea-orm entities — `album`, `asset`, `profile`, `sync_cursor`, `synced_asset` | stabilizing | `mise run check-rust` | [Clients](capsule-docs/src/content/docs/design/clients.md) | — | Follows `capsule-cli` (#413) | The one place `chrono` is permitted, as the sea-orm column type; convert at the entity boundary | | `capsule-cli/migration` | cargo | sea-orm migrations for that store | stabilizing | `mise run check-rust` | [migration/README](capsule-cli/migration/README.md) | — | Follows `capsule-cli` (#413) | Schema changes land here before the entity crate sees them | -| `xtask` | cargo | Repository automation — `architecture-check`, `i18n-guard`, `translate-readme`, licence and workspace-dependency checks | stabilizing | `mise run check-rust` | [Developer Docs](capsule-docs/src/content/docs/design/developer-docs.md) | — | Guard-detector repair (#394, #414) | Not a shipped artifact; it is what makes several gates in `mise.toml` real | -| `capsule-android` | gradle | The Android application — Compose UI over the Kotlin core | blocked | `mise run check-kotlin` | [Clients](capsule-docs/src/content/docs/design/clients.md) | — | Make the build green (#389) | The app references a DI layer that is not in the tree, so it does not compile | +| `xtask` | cargo | Repository automation — `architecture-check` (including the workspace-dependency rules), `i18n`, `i18n-guard` and `translate-readme` | stabilizing | `mise run check-rust` | [Developer Docs](capsule-docs/src/content/docs/design/developer-docs.md) | — | Guard-detector repair (#394, #414) | Not a shipped artifact; it is what makes several gates in `mise.toml` real. Licence enforcement is not one of them: `mise run license-check` is `cargo deny` over `deny.toml` | +| `capsule-android` | gradle | The Android application — Compose UI over the Kotlin core | stabilizing | `mise run check-kotlin` | [Clients](capsule-docs/src/content/docs/design/clients.md) | — | Make the build green (#389) | **It does not compile.** `CapsuleApp.kt` imports `di`, `initKoin`, `ListViewModel` and `DetailViewModel`, none of which exist anywhere in the Kotlin tree. Not `blocked`: what is missing is inside this package, not a dependency outside it. `check-kotlin` is ktlint and detekt only and passes, because it never compiles against the FFI — which is how this stayed red since 2026-08-22 (#389) | | `capsule-core-kotlin` | gradle | The standalone Kotlin harness over `capsule-core`'s uniffi bindings, plus the `HardwareSigner` references — software Ed25519, software P-256, StrongBox | stabilizing | `mise run check-kotlin` | [capsule-core-kotlin/README](capsule-core-kotlin/README.md) | — | StrongBox proven on a device runner | Smoke tests only, and the self-hosted device lane that would exercise StrongBox is unprovisioned | | `capsule-core-swift` | swiftpm | The standalone SwiftPM harness over `capsule-core`'s uniffi bindings, plus the `HardwareSigner` references — Secure Enclave signing and key agreement, and their software fallbacks | stabilizing | `mise run check-swift` | [capsule-core-swift/README](capsule-core-swift/README.md) | `S-P6` | Secure-Enclave wiring into the app (`S-P6`) | `check-swift` formats and lints this package; `mise run test-swift` drives the Tuist workspace only, so its own `swift test` suite is in no gate | | `CapsuleFoundation` | tuist | Value types, logging and utilities. No dependencies | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Holds as the client's floor | The root of the Apple module graph; every other target depends on it | From 46d5559f944d5dc59f89bc8231aa48a6032b1b0a Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 21:51:47 -0400 Subject: [PATCH 035/243] docs(slices): narrow four claims to what the tree proves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **`prost` left the manifests, not the dependency graph.** `Cargo.lock` resolves `prost v0.13.5` through `tzf-rs`, which `capsule-core` takes for timezone lookup. `architecture-check` reads *declared* dependencies, so a transitive edge is outside what it proves — and `prost` is on its retired list, which makes "left the dependency tree" a claim the gate does not back. Same overreach in `S-D1`'s note: the crate's *manifest* declares no retired dependency. - **The `ACTIVE` legend still excluded `capsule-core`'s exif tree** while the `RETIRED` row two lines down, and the Baseline, both say it is live. - **`S-B2` still called its EXIF input `RETIRED`.** Its derivative input is; `capsule-core::exif` is not. - **`mls-resilience.md`'s contract skeleton named `MlsError`**, which exists nowhere in the workspace. `resilience.rs:206` and `:270` take `&mut self` and return `Result` and `Result`. - **ADR-0004 called `salvo_adapter.rs` a third of the crate.** It is 267 of 528 lines. Decision 12: a `MIXED` remainder with a named live home is `done*` with an `Owed →` pointer, not "owed by construction". `S-D8` was already read that way, so `S-D1` and `S-D2` gain `Owed → S-Q1` (E2E cases 2 and 3, #409) and `S-D7` gains `Owed → S-D17` (#408); the rule at the top of the file now says which of the two readings applies when. Recount: 95 done / 60 done*. The prose head now points at `ROADMAP.md` and says which file answers which question. --- SLICES.md | 60 +++++++++++++------ adr/0004-capsule-wire-is-retired.md | 4 +- .../src/content/docs/design/mls-resilience.md | 4 +- 3 files changed, 46 insertions(+), 22 deletions(-) diff --git a/SLICES.md b/SLICES.md index 7ef5081b..12ef2280 100644 --- a/SLICES.md +++ b/SLICES.md @@ -9,6 +9,12 @@ both halves of the current programme: the code 2026-08-21) plus everything needed to exercise the iOS app against a server end to end. +**This file tracks slices; [`ROADMAP.md`](ROADMAP.md) tracks packages.** Read this one for +what a piece of work is and where it stands; read that one for what state a package is in, +which gate covers it, and which slices are open against it. `ROADMAP.md` cites slice ids and +never restates a status, and `mise run check-docs-truth` fails if a citation there has no +detail block here. + It also absorbs the **post-teardown verdict**: the previous Salvo server, the Progenitor SDK and the standalone media crate are review material, and the replacement server is one **Kynos** REST/OpenAPI application. That verdict is accepted and final. @@ -47,15 +53,18 @@ an **Area**. Read `Status` through `Area`, never on its own. | Area | Meaning | | --- | --- | -| `ACTIVE` | The whole surface survives the teardown (`capsule-core` minus its media/exif trees, `capsule-core-ffi`/`-swift`/`-kotlin`, the apps, `capsule-cli` local paths, `capsule-web` local paths, `locales/`, `xtask`, the docs site). Implementable against the live workspace today and unaffected by the Kynos rebuild. | +| `ACTIVE` | The whole surface survives the teardown (`capsule-core` minus its `media` tree — `exif` stayed, see the `RETIRED` row below — `capsule-core-ffi`/`-swift`/`-kotlin`, the apps, `capsule-cli` local paths, `capsule-web` local paths, `locales/`, `xtask`, the docs site). Implementable against the live workspace today and unaffected by the Kynos rebuild. | | `RETIRED` | The target sits in a `legacy-review/` bucket — `server-salvo` (the whole Salvo tree), `sdk-progenitor`, or `media-pipeline` (`capsule_core::media` and its lifecycle adapter). The deliverable must be re-landed on the replacement: Kynos for the server, the Rawshift-backed pipeline for media, the spargen SDK for the client. **`capsule_core::exif` and `import/{executor_cancellation, progress}.rs` are not on this list**, against the original teardown: this branch rebuilt them and they are live, and the `core-import-media` bucket beside them is a stale twin awaiting deletion ([#423](https://github.com/Capsulsaurus/Capsule/issues/423)), not a quarantine (`S-C59`). | | `MIXED` | Both: a surviving `capsule-core`/client/app half that ships and stays, and a server, SDK-wire, or media half that must be re-landed. | **Status — read through Area.** - On an `ACTIVE` row, `Status` means what it always meant. -- On a `MIXED` row, `Status` describes **the surviving half only**. The retiring half is - owed to the rebuild by construction; it is not a separate `Owed →` pointer. +- On a `MIXED` row, `Status` describes **the surviving half only**. Where the retiring half + has no slice of its own, it is owed to the rebuild by construction and is not a separate + `Owed →` pointer. Where a *named* slice carries it, say so: the row is `done*` and `Owed + →` points at that slice, exactly as it would on any other area. A remainder with a live + home is not "owed by construction" — it is owed to something a reader can go and read. - On a `RETIRED` row, `done` is not available. An implemented `RETIRED` slice reverts to `ready`, and its detail block records that it **landed in code that is still live in this workspace today** — the contract is proven, the deliverable re-scopes onto the @@ -160,7 +169,12 @@ left, which is what everything below is sequenced against. - **The Salvo tree is gone from the workspace.** `capsule-api/**` is `legacy-review/server-salvo/`, `capsule_core::media` is `legacy-review/media-pipeline/`, and `salvo`, `tonic`, `prost`, `async-graphql`, `webauthn-rs` and — with the last of them - — `openssl` left the dependency tree. The `rustls`-only rule now holds with no exception. + — `openssl` left the **manifests**. Not all of them left the dependency *graph*, and the + difference is the difference between what `architecture-check` proves and what it does + not: it reads declared dependencies, so `Cargo.lock` still resolves `prost v0.13.5` + transitively through `tzf-rs`, which `capsule-core` takes for timezone lookup. Nothing + declares it and nothing calls it as a wire format. The `rustls`-only rule holds with no + exception, declared or transitive. The consequences are real and are named rather than hidden: there is no server binary and no image decoder in the workspace (#401, #410). - **`xtask architecture-check` is a gate, not a report.** It runs inside `mise run @@ -312,13 +326,13 @@ lives. | S-C61 | The drop passphrase is provisioned and never checked | server | S-C5, S-C60 | S | RETIRED | done | a gated link admitted anyone holding the opaque id; the web client was posting to paths that no longer exist | | S-C62 | The web auth client speaks a surface that is gone | sdk/clients | S-C54, S-C55, S-C56, S-C60 | M | RETIRED | done | passkey and password-reset screens removed, login reads `202`, profile is the four fields the server keeps | | S-C63 | The SDK cannot read a second-factor challenge | sdk/clients | S-C55 | M | RETIRED | done | `login` returns an outcome, not a session; `capsule auth login` prompts for the code | -| S-D1 | SDK upload client (hand-written, stateful protocol) | sdk/clients | S-C1 | M | MIXED | done | | -| S-D2 | SDK sync/download client + connection-class budget | sdk/clients | S-C2, S-C9 | L | MIXED | done | | +| S-D1 | SDK upload client (hand-written, stateful protocol) | sdk/clients | S-C1 | M | MIXED | done\* | E2E case 2 → `S-Q1` (#409) | +| S-D2 | SDK sync/download client + connection-class budget | sdk/clients | S-C2, S-C9 | L | MIXED | done\* | E2E case 3 → `S-Q1` (#409) | | S-D3 | Web guest drop client (WASM) | sdk/clients | S-A6, S-C5 | L | MIXED | done\* | live-browser smoke → `S-Q5`; seeds → gates | | S-D4 | Verify-before-destroy wiring | sdk/clients | S-C3, S-C15 | M | MIXED | done | | | S-D5 | CLI auth/sync/list | sdk/clients | S-D1, S-D2 | M | MIXED | done | | | S-D6 | Web server gateway (key-free reads) | sdk/clients | S-D2, S-C60 | L | MIXED | done\* | live browser smoke → `S-Q5`; decode boundary → post-v1 | -| S-D7 | SDK auth/session foundation + auto token refresh | sdk/clients | — | M | MIXED | done | | +| S-D7 | SDK auth/session foundation + auto token refresh | sdk/clients | — | M | MIXED | done\* | typed-path 401-retry-once → `S-D17` (#408) | | S-D8 | spargen REST client integration | sdk/clients | — | M | MIXED | done\* | 401-retry-once → `S-D17` | | S-D9 | capsule-sdk uniffi FFI bindings | sdk/clients | S-F1, S-D7 | M | RETIRED | ready | Swift harness → `S-P8`; Kotlin harness → owed-CI | | S-D10 | Adverse-network hardening | sdk/clients | S-D1, S-D2 | M | MIXED | done | | @@ -429,7 +443,7 @@ lives. **Row counts.** 205 rows — the 129 from the v1 campaign and wave 2, the 51 the server rebuild added, the 23 of lane U, and the 2 of the notification lane. By area: **87 ACTIVE / 75 RETIRED / 43 MIXED**. By status: -**98 done / 57 done\* / 28 ready / 9 part / 9 blocked / 4 post-v1** +**95 done / 60 done\* / 28 ready / 9 part / 9 blocked / 4 post-v1** (`S-C8`, `S-C27`, `S-C39`, and `S-U9`–`S-U14` — the table spells these `part` and `part 1 done`; they are counted together). @@ -737,8 +751,10 @@ workspace at all**, so every still import is a `DeferredNoCodec` until Rawshift derivatives; planner determinism suite unchanged. - **Tier:** Unit (planner) + Smoke (executor). - **Landed:** `capsule-core/src/import/executor.rs` is the new signed executor and is - `ACTIVE` — it survives. Its *derivative and EXIF inputs* are `RETIRED`, which is why - the row is `MIXED`. **Owed:** durable album keys → `S-A10` (landed). + `ACTIVE` — it survives. Its *derivative* input is `RETIRED`, which is why the row is + `MIXED`; its **EXIF input is not** (corrected 2026-09-01) — `capsule-core::exif` is live + and tested, as the `RETIRED` legend above records. **Owed:** durable album keys → `S-A10` + (landed). ### S-B3 — Streaming import @@ -3940,9 +3956,14 @@ them was incidental: - **`MIXED | done`, not `RETIRED | ready` (corrected 2026-09-01).** The row was `RETIRED` because the SDK's wire contract was re-sourced, not because the crate was review material (Sequencing). That re-source landed: `capsule-sdk/build.rs` generates from - `capsule-server/openapi.json`, the Kynos document, and the crate depends on no retired - package. The client half therefore ships, which is what `Status` reports on a `MIXED` row; - the server half it drives is what is still being rebuilt (#401, #404). + `capsule-server/openapi.json`, the Kynos document, and the crate's manifest declares no + retired dependency — `prost` still resolves transitively through `capsule-core`'s `tzf-rs`, + which is a timezone table and not a wire format. The client half therefore ships, which is + what `Status` reports on a `MIXED` row; the server half it drives is what is still being + rebuilt (#401, #404). +- **`done*`, not `done` (decision 12).** The row's own "Done when" ends "E2E case 2 lives", + and that case has a named home: `S-Q1` (#409). A remainder a reader can go and read is an + `Owed →` pointer, not something owed by construction. ### S-D2 — SDK sync/download client @@ -3962,8 +3983,10 @@ them was incidental: `capsule-sdk/src/sync.rs` drives `GET /v1/sync` through the generated REST client, the opaque server-MAC'd cursor round-trips verbatim, and `tonic`, `tonic-prost` and `prost` are out of `capsule-sdk/Cargo.toml`. `SyncState`'s anti-rewind and forward-version rules - never depended on the transport and did not move. The row is `MIXED | done`: the client - half ships; the feed it reads is served by the server still being rebuilt. + never depended on the transport and did not move. The client half ships; the feed it reads + is served by the server still being rebuilt. +- **`done*`, not `done` (decision 12).** "Done when" ends "E2E case 3 lives", which `S-Q1` + (#409) carries. ### S-D3 — Web guest drop client @@ -4035,9 +4058,10 @@ them was incidental: server's own paths, not a retired copy of them. Being outside the generated client is deliberate and no longer a spargen gap: what lives here is token *orchestration*, which `ADR-0002` puts outside generated code by contract. -- **`MIXED | done`, not `RETIRED | ready` (corrected 2026-09-01).** The Kynos re-point is what - the `RETIRED` marking was for, and it landed. The 401-retry-once half is still owed on the - *typed* path — see `S-D17`, which keeps its own row. +- **`MIXED | done*`, not `RETIRED | ready` (corrected 2026-09-01).** The Kynos re-point is + what the `RETIRED` marking was for, and it landed. The 401-retry-once half is still owed on + the *typed* path, and `S-D17` (#408) carries it — which is what makes this `done*` rather + than `done`, on the same rule `S-D8` was already read by (decision 12). ### S-D8 — spargen REST client integration diff --git a/adr/0004-capsule-wire-is-retired.md b/adr/0004-capsule-wire-is-retired.md index 44012145..57d907cb 100644 --- a/adr/0004-capsule-wire-is-retired.md +++ b/adr/0004-capsule-wire-is-retired.md @@ -31,8 +31,8 @@ anticipated. Three facts in the tree say so: - **Nothing links it.** `capsule-server/Cargo.toml` declares `capsule-wire` as a path dependency, and the only occurrence of `capsule_wire` anywhere in the workspace outside the crate itself is one prose reference in `capsule-server/src/lib.rs`'s module comment. - Meanwhile `capsule-wire/src/salvo_adapter.rs` — a third of the crate — generates impls - for a framework `S-C59` removed from the workspace. + Meanwhile `capsule-wire/src/salvo_adapter.rs` — 267 of the crate's 528 lines, over half of + it — generates impls for a framework `S-C59` removed from the workspace. ## Decision diff --git a/capsule-docs/src/content/docs/design/mls-resilience.md b/capsule-docs/src/content/docs/design/mls-resilience.md index f35103dc..acbb1e71 100644 --- a/capsule-docs/src/content/docs/design/mls-resilience.md +++ b/capsule-docs/src/content/docs/design/mls-resilience.md @@ -57,8 +57,8 @@ enum ReconcileOutcome { Unrecoverable, // requires re-bootstrap } -fn reconcile_with_server(view: ServerChainView) -> Result; -fn rekey_group(group: GroupId, reason: RekeyReason) -> Result<(), MlsError>; +fn reconcile_with_server(&mut self, view: ServerChainView) -> Result; +fn rekey_group(&mut self, reason: RekeyReason) -> Result; ``` ## Validation From e23d0b5b6a6a2b0e40c99dc6615aa2e5965885b1 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 21:52:28 -0400 Subject: [PATCH 036/243] perf(i18n): pair braces in one pass, not once per brace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `render` asked `matching_brace` at every `{`, and `matching_brace` scans forward from the brace it was given. On the matched path that is fine — it stops at the partner. On the **unmatched** path it has to read the whole remainder to learn there is no partner, and then the next unmatched brace reads the same remainder again. Measured against a copy of the shipped scan: bytes read are exactly n(n+1)/2, and a template of n unmatched braces takes 22 ms at n=10 000, 104 ms at 20 000, 369 ms at 40 000 and 1.63 s at 80 000 — quadruples per doubling. `format_message` is a `pub` entry point, so this is the same threat model `MAX_DEPTH` was capped for two commits ago: a template that need not come from a catalog. `brace_pairs` now pairs every brace in one stack pass, and `render` walks a cursor over the result in source order, skipping the entries the recursive render of a placeholder body consumed. `matching_brace` stays for `Arms::parse`, where the arms are disjoint so its scans add up to a single pass, and its doc says why the two coexist. An unmatched brace was the case that could not be shortcut cheaply: "if pairing fails here it fails later too" is false — in `{ {a} ` the outer brace has no partner and the inner one does — so the fix has to be the real pairing, not a watermark. That case is in the equivalence test. Two pins, and they count bytes rather than watching a clock: a wall-clock budget on this host would be either flaky or too loose to prove anything, while a thread-local counter incremented by both scanners is deterministic and fails the moment a per-brace rescan returns. Measured after: 100 000 bytes scanned for a 100 000-byte unmatched template (was ~5 x 10^9), and 118 000 for a 101 000-byte template of 10 000 placeholders and 1 000 plurals. Also makes the module doc and the refusal message say which malformed plural does what: a plural whose arms do not parse is passed through verbatim, while one whose arms parse but carry no `other` asserts and renders its first arm in CLDR order. Those are different behaviours and the docs called both "malformed". --- capsule-i18n/src/format.rs | 197 ++++++++++++++++++++++++++++++++++--- 1 file changed, 185 insertions(+), 12 deletions(-) diff --git a/capsule-i18n/src/format.rs b/capsule-i18n/src/format.rs index 7d3ff904..d3fe4c78 100644 --- a/capsule-i18n/src/format.rs +++ b/capsule-i18n/src/format.rs @@ -27,12 +27,18 @@ //! //! # What is still refused //! -//! `select`, `selectordinal`, `plural` with `offset:`, and any other `{name, kind, …}` -//! block are **refused**: a `debug_assert!` fires where a developer will see it, and the -//! release build copies the construct through verbatim rather than gaining a new crash on -//! a catalog it could previously render badly. Emitting ICU source to a user is the exact -//! failure Android shipped before slice `S-I6`; the refusal exists so the next construct -//! this runtime cannot express is a test failure instead. +//! `select`, `selectordinal`, `plural` with `offset:`, a `plural` whose arms do not parse, +//! and any other `{name, kind, …}` block are **refused**: a `debug_assert!` fires where a +//! developer will see it, and the release build copies the construct through verbatim +//! rather than gaining a new crash on a catalog it could previously render badly. Emitting +//! ICU source to a user is the exact failure Android shipped before slice `S-I6`; the +//! refusal exists so the next construct this runtime cannot express is a test failure +//! instead. +//! +//! One malformed shape is **not** passed through verbatim: a `plural` whose arms parse but +//! carry no `other`. It asserts, and then renders its first arm in CLDR order. `xtask +//! i18n` refuses to generate such a message, so the case is unreachable from the catalogs; +//! for a hand-written template that slipped past it, some text beats message source. use std::collections::BTreeMap; use std::fmt::{self, Write as _}; @@ -99,6 +105,13 @@ fn render( depth: usize, out: &mut String, ) { + // Every `{` is paired in one pass up front, rather than by scanning forward from each + // one. Scanning per brace is quadratic on the unmatched path — an unmatched `{` has to + // read the whole remainder to learn there is no partner, and then the next one reads + // it again: measured at n(n+1)/2 bytes, 1.6 s for a template of 80 000 braces. This is + // a `pub` entry point, so that is the same threat model `MAX_DEPTH` is capped for. + let pairs = brace_pairs(template); + let mut next_pair = 0; let mut i = 0; while let Some(c) = template[i..].chars().next() { match c { @@ -110,7 +123,12 @@ fn render( i += 1; } '{' => { - let Some(close) = matching_brace(template, i) else { + // `pairs` lists the braces in source order and `i` only moves forward, so + // this brace is the entry the cursor is on. + debug_assert_eq!(pairs.get(next_pair).map(|pair| pair.open), Some(i)); + let close = pairs.get(next_pair).and_then(|pair| pair.close); + next_pair += 1; + let Some(close) = close else { // An unterminated `{` is copied through as an ordinary character, with // no assertion: the brace may well be literal text in a message that // never meant to open a placeholder, and nothing distinguishes the two @@ -122,6 +140,10 @@ fn render( }; render_placeholder(locale, &template[i + 1..close], args, depth, out); i = close + 1; + // The braces inside the body were handled by the recursive render. + while pairs.get(next_pair).is_some_and(|pair| pair.open < i) { + next_pair += 1; + } } _ => { out.push(c); @@ -131,6 +153,49 @@ fn render( } } +/// One `{` and the `}` that closes it, if any. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct BracePair { + /// Byte index of the `{`. + open: usize, + /// Byte index of the matching `}`, or `None` when the brace is unterminated. + close: Option, +} + +/// Pair up every `{` in `text` in a single pass, in source order. +/// +/// A stack of open braces, so the answer for *all* of them costs one scan of the text +/// rather than one scan per brace. Equivalent to calling [`matching_brace`] at each `{` — +/// pinned by a test — and the reason [`render`] no longer does. +/// +/// A `}` with nothing open is ignored, exactly as [`matching_brace`]'s depth counter +/// ignores it: it cannot close a brace that is not there, and ICU has no escape for one. +fn brace_pairs(text: &str) -> Vec { + #[cfg(test)] + tests::record_brace_scan(text.len()); + let mut pairs: Vec = Vec::new(); + // Indices into `pairs`, innermost last. + let mut open = Vec::new(); + for (index, byte) in text.as_bytes().iter().enumerate() { + match byte { + b'{' => { + open.push(pairs.len()); + pairs.push(BracePair { + open: index, + close: None, + }); + } + b'}' => { + if let Some(slot) = open.pop() { + pairs[slot].close = Some(index); + } + } + _ => {} + } + } + pairs +} + /// Render one `{…}` placeholder body (the text between the braces) into `out`. fn render_placeholder( locale: &str, @@ -158,10 +223,10 @@ fn render_placeholder( debug_assert!( rendered.is_some(), "capsule-i18n cannot render the ICU construct `{{{body}}}` — it would be printed \ - to the user verbatim. A well-formed `plural` is evaluated here; `select`, \ - `selectordinal`, `offset:`, a malformed plural and nesting past {MAX_DEPTH} \ - levels are not, because the per-platform renderers compile those ahead of time \ - (`xtask i18n`) and this runtime has no equivalent." + to the user verbatim. A `plural` whose arms parse is evaluated here; `select`, \ + `selectordinal`, `offset:`, a `plural` whose arms do not, and nesting past \ + {MAX_DEPTH} levels are not, because the per-platform renderers compile those \ + ahead of time (`xtask i18n`) and this runtime has no equivalent." ); if let Some(text) = rendered { out.push_str(&text); @@ -294,7 +359,13 @@ impl<'a> Arms<'a> { /// Brace *matching*, not the first `}`: every ICU plural nests braces, and a scan that /// stops at the first one cannot see past the opening arm — the same defect that let /// Android's renderer ship raw ICU (slice `S-I6`). +/// +/// Used by [`Arms::parse`], where the arms are disjoint so the scans add up to one pass +/// over the body. [`render`] uses [`brace_pairs`] instead, because there the same brace +/// can be asked about repeatedly. fn matching_brace(text: &str, open: usize) -> Option { + #[cfg(test)] + tests::record_brace_scan(text.len() - open); debug_assert_eq!(text.as_bytes().get(open), Some(&b'{')); let mut depth = 0usize; for (offset, byte) in text.as_bytes()[open..].iter().enumerate() { @@ -319,7 +390,31 @@ fn is_identifier(s: &str) -> bool { #[cfg(test)] mod tests { - use super::{Value, format_message, format_message_in}; + use std::cell::Cell; + + use super::{BracePair, Value, brace_pairs, format_message, format_message_in, matching_brace}; + + thread_local! { + /// Bytes the brace scanners have read on this thread. + /// + /// The cost of pairing braces is the whole point of [`brace_pairs`], and a wall + /// clock cannot pin it: this host runs several builds at once, so a timing budget + /// would be either flaky or so loose it proves nothing. Counting the bytes the + /// scanners actually read is deterministic, and it fails if anyone reintroduces a + /// per-brace rescan. Thread-local because `cargo test` runs tests concurrently on + /// threads (nextest gives each its own process, which is stricter still). + static BRACE_SCAN_BYTES: Cell = const { Cell::new(0) }; + } + + /// Called by both brace scanners in a test build. Not compiled otherwise. + pub(super) fn record_brace_scan(bytes: usize) { + BRACE_SCAN_BYTES.with(|counter| counter.set(counter.get() + bytes)); + } + + /// Bytes scanned since the last call, resetting the counter. + fn take_brace_scan_bytes() -> usize { + BRACE_SCAN_BYTES.with(Cell::take) + } /// The shape every plural in `locales/` has today. const ITEMS: &str = "{count, plural, one {# item} other {# items}}"; @@ -631,6 +726,84 @@ mod tests { assert!(rendered.ends_with("}}"), "the refusal is a pass-through"); } + #[test] + fn brace_pairs_agrees_with_matching_brace_at_every_open_brace() { + // The refactor's correctness condition: pairing every brace in one pass must give + // the same answer as asking about each brace on its own. The awkward shapes are + // the point — an unmatched outer brace with a matched one inside (`{ {a} `) is + // where a naive "if one fails they all fail" shortcut would be wrong. + for template in [ + "", + "no braces at all", + "{a}", + "{{a}}", + "{", + "}", + "}{a}", + "{a}}", + "{ {a} ", + "{a} } {b}", + "{a, plural, one {# item} other {# items}}", + "{a, plural, one {{name} has #} other {{name} has {b, plural, other {#}}}}", + "50% off {sale", + "A { stray brace, then {name}", + ] { + let pairs = brace_pairs(template); + let opens: Vec = template + .bytes() + .enumerate() + .filter(|(_, byte)| *byte == b'{') + .map(|(index, _)| index) + .collect(); + let expected: Vec = opens + .iter() + .map(|open| BracePair { + open: *open, + close: matching_brace(template, *open), + }) + .collect(); + assert_eq!(pairs, expected, "disagreement on `{template}`"); + } + } + + #[test] + fn an_unmatched_brace_costs_one_pass_not_one_per_brace() { + // The defect: `render` asked `matching_brace` at every `{`, and an unmatched one + // reads the whole remainder to learn it has no partner — n(n+1)/2 bytes over the + // template, measured at 1.6 s for 80 000 braces in release. `format_message` is + // public, so the input need not be a catalog message. + let template = "{".repeat(100_000); + take_brace_scan_bytes(); + let rendered = format_message(&template, &[]); + let scanned = take_brace_scan_bytes(); + assert_eq!(rendered, template, "every unmatched brace is still emitted"); + assert!( + scanned <= 4 * template.len(), + "scanned {scanned} bytes for a {}-byte template: the per-brace rescan is back", + template.len() + ); + } + + #[test] + fn many_placeholders_cost_one_pass_too() { + // The matched path, and the arm parser behind it: the scans must still add up to a + // constant number of passes over the template, not one per placeholder. + let template = "{name} ".repeat(10_000) + &"{n, plural, one {#} other {#}} ".repeat(1_000); + take_brace_scan_bytes(); + let rendered = format_message( + &template, + &[("name", Value::Str("Sam")), ("n", Value::Int(2))], + ); + let scanned = take_brace_scan_bytes(); + assert!(rendered.starts_with("Sam Sam "), "{rendered:.32}"); + assert!(rendered.ends_with("2 "), "{rendered:.32}"); + assert!( + scanned <= 8 * template.len(), + "scanned {scanned} bytes for a {}-byte template", + template.len() + ); + } + #[test] fn the_locale_free_entry_point_uses_english_rules() { // Asserted against English's actual answer, not against `format_message_in("en")` From fa88db8e6ec7689941b11f67abe4566559029ce6 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 21:52:45 -0400 Subject: [PATCH 037/243] docs(i18n): say what a malformed plural and a stray brace actually do Two records described the formatter's fallback behaviour as a verbatim pass-through, which is right for most of the refused set and wrong for the two cases that matter most, because they are the ones a reader could actually hit. - A plural whose arms parse but carry **no `other`** asserts and renders its first arm in CLDR order. It is not passed through. `xtask i18n` refuses to emit such a message, so it is unreachable from the catalogs; for a hand-written template that slipped past the generator, some text beats showing the reader ICU source. Both records now say so, and `i18n.md` gains the case at all. - An unterminated `{` is **not** an assertion case. A lone brace in literal copy ("50% off {sale") cannot be told apart from a mistyped placeholder, and asserting on it would panic every debug run over legitimate text. It is emitted as an ordinary character and scanning continues past it. Also corrects the owed-CI count in `S-I7`: three tests pin release-build behaviour, not two, and names them and the issue (#428). --- SLICES.md | 15 ++++++++++----- capsule-docs/src/content/docs/design/i18n.md | 19 ++++++++++++++----- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/SLICES.md b/SLICES.md index 225872e4..ecfee92f 100644 --- a/SLICES.md +++ b/SLICES.md @@ -4858,8 +4858,12 @@ lands on Kynos rather than on Salvo. with `=N` arms, category arms, `#`, and nesting. `Bundle::format` was dropping `self.locale` before calling the formatter — that was the API gap, and `format_message_in(locale, …)` closes it while `format_message` keeps its signature and means English. The refusal is **narrowed, not - removed**: `select`, `selectordinal`, `offset:`, a malformed plural, and nesting past 32 levels - keep the assertion and the pass-through. + removed**: `select`, `selectordinal`, `offset:`, a plural whose arms do not parse, and nesting + past 32 levels keep the assertion and the pass-through. A plural whose arms *do* parse but carry + no `other` is the one malformed shape that is **not** passed through: it asserts and renders its + first arm in CLDR order. `xtask i18n` (`xtask/src/i18n.rs:586`) refuses to emit such a message, so + the case is unreachable from the catalogs, and for a hand-written template that slipped past it, + rendering text beats showing a user ICU source. - **An in-house table, not a crate.** `icu_plurals` would be a genuine new dependency — provider, data crate, and a `dependencies.md` row — on a crate whose entire dependency list is `serde_json` and `tracing`, to decide thirteen locales' integer cardinal categories. Licence was not the @@ -4877,9 +4881,10 @@ lands on Kynos rather than on Salvo. argument; recursion was bounded only by the input, so a public formatter could abort the process on a deeply nested template; and a string count was trimmed for selection but not for `#`, so `" 1 "` could pick one arm and print another. -- **Owed-CI:** `cargo test --release` is not run anywhere. The two tests that pin *production* - behaviour — the release build passes a refused construct through instead of crashing — are - `cfg(not(debug_assertions))` and therefore never execute in CI. Filed separately. +- **Owed-CI:** `cargo test --release` is not run anywhere. The three tests that pin *production* + behaviour — a refused construct passes through instead of crashing, a plural with no `other` + renders its first arm, a too-deeply-nested plural passes through — are + `cfg(not(debug_assertions))` and therefore never execute in CI. Filed as #428. ### S-I8 — clap `--help` text is unreachable from the catalogs diff --git a/capsule-docs/src/content/docs/design/i18n.md b/capsule-docs/src/content/docs/design/i18n.md index c2841767..241ff0fd 100644 --- a/capsule-docs/src/content/docs/design/i18n.md +++ b/capsule-docs/src/content/docs/design/i18n.md @@ -136,11 +136,20 @@ so it is the only one that carries CLDR rules itself. load-bearing today: every translated plural is still an English `one`/`other` copy, so a Russian `few` has nowhere else to go. - **Not implemented, and refused rather than mis-rendered:** `select`, - `selectordinal`, `plural` with `offset:`, `number`/`date` skeletons, and ICU - apostrophe quoting (`'#'` for a literal `#`, which the ahead-of-time generators - do not implement either). A construct outside the subset trips a `debug_assert!` - where a developer sees it and is copied through verbatim in release, so a release - build gains no crash on a catalog it could previously render badly. + `selectordinal`, `plural` with `offset:`, a `plural` whose arms do not parse, + `number`/`date` skeletons, and ICU apostrophe quoting (`'#'` for a literal `#`, + which the ahead-of-time generators do not implement either). A construct outside + the subset trips a `debug_assert!` where a developer sees it and is copied through + verbatim in release, so a release build gains no crash on a catalog it could + previously render badly. +- **One exception to the pass-through:** a `plural` whose arms parse but carry no + `other` asserts and then renders its **first arm in CLDR order**. `xtask i18n` + refuses to emit such a message, so it is unreachable from the catalogs; for a + hand-written template that slipped past the generator, rendering some text beats + showing the reader ICU source. An unterminated `{` is likewise not an assertion + case — a lone brace in literal copy ("50% off {sale") cannot be told apart from a + mistyped placeholder — so it is emitted as an ordinary character and scanning + continues past it. - Numbers render as plain digits — no grouping separators, no locale digit shaping. Doing that properly is `number` skeleton support, which needs CLDR number data this runtime does not carry. From 3d9b150762259e9df26e2c85253cbb3f0d5b26b4 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 21:53:02 -0400 Subject: [PATCH 038/243] test(xtask): derive the watched-API-position fixture from the shared list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `swift_watches_every_api_position_in_both_regexes` exercised four of the fifteen entries in `SWIFT_TEXT_POSITIONS`. `searchable` and `tabItem` had no fixture and no call site anywhere in the tree, so a broken alternative in the shared list — a stray space, an empty alternative, a regex metacharacter — would have been silent in both regexes it is spliced into. Two derived tests replace the gap. `every_watched_api_position_is_a_bare _identifier` rejects an entry that is not a plain identifier, and rejects a duplicate. `every_watched_api_position_is_caught_in_both_regexes` builds its fixture *from* the list — one plain literal and one interpolated literal per entry, plus a helper name that merely ends in the entry — so a position added without a case cannot happen, which is the failure #394 records for `confirmationDialog`. Verified non-vacuous: appending `inspector Prompt` to the list fails the identifier test with that entry named. The hand-written test stays, and says why: a derived fixture proves each alternative matches, and the concrete one proves the real call shapes do, with the arguments SwiftUI actually puts after the string (`ContentUnavailableView("…", systemImage: …)`). --- xtask/src/i18n_guard.rs | 63 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/xtask/src/i18n_guard.rs b/xtask/src/i18n_guard.rs index 7bf4228a..86373bfd 100644 --- a/xtask/src/i18n_guard.rs +++ b/xtask/src/i18n_guard.rs @@ -1254,8 +1254,71 @@ mod tests { assert_eq!(texts, Vec::::new()); } + /// The watched positions, one per alternative of the shared list. + fn swift_text_positions() -> Vec<&'static str> { + SWIFT_TEXT_POSITIONS.split('|').collect() + } + + #[test] + fn every_watched_api_position_is_a_bare_identifier() { + // The shared list is spliced into two regexes, so a stray space, an empty + // alternative or a regex metacharacter in it would silently widen or break both. + let positions = swift_text_positions(); + assert!(positions.len() >= 15, "{positions:?}"); + for position in &positions { + assert!( + !position.is_empty() + && position + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_'), + "`{position}` is not a bare identifier" + ); + } + let mut unique = positions.clone(); + unique.sort_unstable(); + unique.dedup(); + assert_eq!(unique.len(), positions.len(), "a position is listed twice"); + } + + #[test] + fn every_watched_api_position_is_caught_in_both_regexes() { + // The fixture is *derived* from the shared list rather than written out, so a + // position cannot be added without a case: `confirmationDialog` sat in the + // interpolation regex and not the literal one for two slices (#394) precisely + // because the two lists were maintained by hand. `searchable` and `tabItem` had + // no fixture at all until this test, so a broken alternative would have been + // silent. + for position in swift_text_positions() { + let literal = format!("Watched {position} text"); + let interpolated = format!("Watched {position} \\(count)"); + let src = format!( + "view\n {position}(\"{literal}\")\n {position}(\"{interpolated}\")\n" + ); + let findings = swift_findings(&src); + let found = texts(&findings); + assert!( + found.contains(&literal.as_str()), + "{position}: the plain literal was not caught, got {found:?}" + ); + assert!( + found.contains(&interpolated.as_str()), + "{position}: the interpolated literal was not caught, got {found:?}" + ); + // And the word boundary still holds, for both regexes: a helper that merely + // ends in a watched name is not a watched position. + let helper = format!("view\n my{position}(\"{literal}\")\n"); + assert_eq!( + swift_findings(&helper), + Vec::new(), + "{position}: the word boundary was lost" + ); + } + } + #[test] fn swift_watches_every_api_position_in_both_regexes() { + // The derived fixture above proves each alternative matches; this one proves the + // real call shapes do, with the arguments SwiftUI actually puts after the string. // `confirmationDialog` was in the interpolation regex and not the literal one; // `help`, `accessibilityValue`, `ContentUnavailableView` and `tabItem` were in // neither. One shared list now, so the two cannot disagree again. From 718bc820284fae5294a6c0dbb51232b2811d9a18 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 22:11:47 -0400 Subject: [PATCH 039/243] fix(core): correct the S-C50 claim and keep hash_file off the wasm surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four defects an adversarial read of this branch turned up. No behaviour change beyond the `hash_file` gate, which removes a call that could only ever fail. **`S-C50` does not implement the privacy strip.** `SLICES.md:280` records it as a **docs** slice, already `done`: it settled *where* the boundary-crossing strip belongs (the issuing client) and left the mandatory, no-opt-out rule binding that client. It writes no code. `capsule-docs/.../metadata.md` and `capsule-server/src/share/mod.rs` both pointed future implementation work at it. They now say what is true: the strip is the issuing client's obligation, nothing implements it, and no slice owns writing the real one. The same status note also claimed the export surface "v1 ships" while calling it unimplemented two sentences earlier; it now separates what is specified from what is built — the server's containment half ships, the client-side strip does not. **`crypto::hash::hash_file` is now `native`-gated.** It replaced `utils::hash::get_file_hash`, and `utils` is `native`; `crypto::hash` is deliberately not, because it is part of the `--no-default-features` wasm32 sealing surface. An ungated `hash_file` therefore compiled for `wasm32-unknown-unknown` — `std::fs` is stubbed there, not absent — and would have failed at runtime on every call. Both callers are `native` anyway, so the gate costs nothing and keeps the browser build's public surface free of a filesystem API. **Two doc-accuracy fixes.** `StreamingOptions` said both replaced entry points carried eight arguments; the twin carried nine. The new `capsule-wasm` exhaustiveness test asserted `!code.is_empty()` on functions that return non-empty `&'static str` consts — an assertion that cannot fail. It now pins both codes to the declared `err::` set, so a newly added variant cannot answer with an ad-hoc literal the viewer has no catalog key for, and checks that the hand-written variant list does not repeat itself. --- capsule-core/src/crypto/hash.rs | 14 ++++-- capsule-core/src/import/streaming.rs | 2 +- .../src/content/docs/design/metadata.md | 2 +- capsule-server/src/share/mod.rs | 7 +-- capsule-wasm/src/lib.rs | 44 ++++++++++++++++--- 5 files changed, 54 insertions(+), 15 deletions(-) diff --git a/capsule-core/src/crypto/hash.rs b/capsule-core/src/crypto/hash.rs index 086f9011..f3626ae9 100644 --- a/capsule-core/src/crypto/hash.rs +++ b/capsule-core/src/crypto/hash.rs @@ -12,9 +12,7 @@ //! //! [Cryptography — Primitives § Cryptographic Hash]: https://docs/design/cryptography/primitives/#cryptographic-hash -use std::fs::File; use std::io::{self, Read}; -use std::path::Path; use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use sha2::{Digest, Sha256}; @@ -165,8 +163,14 @@ pub fn hash_reader(mut reader: R) -> io::Result { /// /// Opens `path` and feeds it through [`hash_reader`] rather than reading the whole file /// into memory, so arbitrarily large originals hash with bounded memory. -pub fn hash_file(path: &Path) -> io::Result { - let file = File::open(path)?; +/// +/// `native`-gated, unlike the rest of this module: the browser sealing build +/// (`--no-default-features`, `wasm32-unknown-unknown`) has no filesystem, so an ungated +/// `hash_file` would compile there and then fail at runtime on every call. Its two callers +/// — the import planner and the file-metadata reader — are `native` anyway. +#[cfg(feature = "native")] +pub fn hash_file(path: &std::path::Path) -> io::Result { + let file = std::fs::File::open(path)?; hash_reader(io::BufReader::new(file)) } @@ -212,6 +216,7 @@ mod tests { assert_eq!(hash_reader(&data[..]).unwrap(), one_shot); } + #[cfg(feature = "native")] #[test] fn hash_file_matches_one_shot() { let dir = tempfile::tempdir().unwrap(); @@ -223,6 +228,7 @@ mod tests { assert_eq!(hash_file(&path).unwrap(), hash_bytes(&data)); } + #[cfg(feature = "native")] #[test] fn hash_file_reports_a_missing_path() { let dir = tempfile::tempdir().unwrap(); diff --git a/capsule-core/src/import/streaming.rs b/capsule-core/src/import/streaming.rs index 1ffe6807..6a36f308 100644 --- a/capsule-core/src/import/streaming.rs +++ b/capsule-core/src/import/streaming.rs @@ -231,7 +231,7 @@ pub enum StreamingEvent { /// Everything the streaming window needs beyond the plan, the workspace, and the event sink. /// /// One struct rather than six positionals: the two entry points this replaced differed only in -/// `source`, and both carried eight arguments behind a +/// `source` and carried eight and nine arguments respectively, both behind a /// `#[allow(clippy::too_many_arguments)]`. A caller that wants no source-adapter enrichment /// passes `&SourceMetadataIndex::empty()`, which is exactly what the thinner of the two /// entry points did for it. diff --git a/capsule-docs/src/content/docs/design/metadata.md b/capsule-docs/src/content/docs/design/metadata.md index 056e33d2..11bb4e6a 100644 --- a/capsule-docs/src/content/docs/design/metadata.md +++ b/capsule-docs/src/content/docs/design/metadata.md @@ -156,7 +156,7 @@ Stripping happens at the moment of export — the encrypted sidecar inside the u Capsule's *own* devices syncing the *same user's* library do **not** trigger this redaction — that is intra-trust, not a boundary crossing. -**Status note.** The strip table is **not implemented yet**. When it lands it applies **client-side, at the moment a share link is issued** — which is the only place it can be applied, since the server holds no key to the metadata a share serves. An export-policy module once existed in `capsule-core` but had zero call sites; it was removed rather than left standing as a documented control nothing enforced. `S-C50` is the slice that implements it. The server's complementary guarantee is containment: a share link serves only the content addresses its own record enumerates, so a stripped share cannot be walked sideways into the unstripped blob (slices `S-C4`, `S-C50`). Together these are the one export surface v1 ships, mandatory and with no opt-out. The external-backup handoff crossing waits on a client file-export command, which is post-v1; federated peers receive ciphertext, so their strip applies when a share boundary is crossed, not on the pull itself. +**Status note.** Stripping is the **issuing client's** obligation, applied at the moment a share link is issued — the only place it can be applied, since the server holds no key to the metadata a share serves. `S-C50` settled *where*; the mandatory, no-opt-out rule survives unchanged and binds the issuing client. **No strip is implemented today.** An export-policy module once existed in the core crate but had zero call sites, and was removed rather than left standing as a documented control nothing enforced; no slice currently owns writing the real one. The server's complementary half **is** built and is containment: a share link serves only the content addresses its own record enumerates, so a stripped share cannot be walked sideways into the unstripped blob (`S-C4`). Together these are the one export surface v1 specifies — mandatory, with no opt-out — of which the containment half ships and the client-side strip does not. The external-backup handoff crossing waits on a client file-export command, which is post-v1; federated peers receive ciphertext, so their strip applies when a share boundary is crossed, not on the pull itself. ## Collaborative Metadata diff --git a/capsule-server/src/share/mod.rs b/capsule-server/src/share/mod.rs index 3ccc653e..330e3f77 100644 --- a/capsule-server/src/share/mod.rs +++ b/capsule-server/src/share/mod.rs @@ -13,9 +13,10 @@ //! strip"*. **A key-free server cannot.** The metadata a share serves is ciphertext sealed //! under material the server does not hold — the fragment secret never leaves the client — so //! there is nothing here to read, let alone redact. design/metadata.md is the one that is -//! consistent with the architecture: *"Stripping happens at the moment of export"* — -//! client-side, in the issuing client. No strip table is implemented yet in `capsule-core`; -//! `S-C50` is where it lands. +//! consistent with the architecture: *"Stripping happens at the moment of export"* — in the +//! issuing client, which is what `S-C50` settled. No strip is implemented anywhere yet: the +//! core module that claimed to be one had no callers and is gone, and no slice owns writing +//! the real one. //! //! So the strip is the **issuing client's**, and what this server enforces is the property that //! makes it stick: a link serves **only the addresses its own record enumerates**, never the diff --git a/capsule-wasm/src/lib.rs b/capsule-wasm/src/lib.rs index 531c924a..1fb7cb66 100644 --- a/capsule-wasm/src/lib.rs +++ b/capsule-wasm/src/lib.rs @@ -423,11 +423,25 @@ mod tests { assert_eq!(open_code(&SharingError::NotFound), err::MALFORMED); } - /// A `SharingError` variant added upstream without a boundary code must fail the build - /// here rather than reach the viewer as an unmapped string: the match is exhaustive. + /// Every code the boundary can emit. The viewer maps exactly this set to catalog keys, so + /// a code outside it reaches the UI as an unmapped string. + const DECLARED_CODES: [&str; 6] = [ + err::MALFORMED, + err::PASSPHRASE_REQUIRED, + err::WRONG_SECRET, + err::SCOPE_UNAVAILABLE, + err::TAMPERED, + err::SEAL_FAILED, + ]; + + /// Two guards on adding a `SharingError` variant upstream. The match is exhaustive, so a + /// new variant fails the build here rather than reaching the viewer unmapped; and both + /// codes must be one of [`DECLARED_CODES`], so the arm you are forced to add cannot answer + /// with an ad-hoc literal the viewer has no catalog key for. #[test] - fn every_variant_carries_a_non_empty_code_on_both_paths() { - for e in &every_sharing_error() { + fn every_sharing_error_variant_maps_to_a_declared_code() { + let all = every_sharing_error(); + for e in &all { match e { SharingError::ScopeUnavailable | SharingError::NotFound @@ -435,9 +449,27 @@ mod tests { | SharingError::WrongPassphrase | SharingError::Crypto(_) => {} } - assert!(!sharing_code(e).is_empty(), "no sharing code for {e:?}"); - assert!(!open_code(e).is_empty(), "no open code for {e:?}"); + assert!( + DECLARED_CODES.contains(&sharing_code(e)), + "sharing_code({e:?}) = {:?} is not a declared boundary code", + sharing_code(e) + ); + assert!( + DECLARED_CODES.contains(&open_code(e)), + "open_code({e:?}) = {:?} is not a declared boundary code", + open_code(e) + ); } + + // `every_sharing_error` is hand-written, so guard it against silently listing the same + // variant twice and thereby covering one fewer than the array length claims. + let mut kinds: Vec<_> = all.iter().map(std::mem::discriminant).collect(); + kinds.dedup(); + assert_eq!( + kinds.len(), + all.len(), + "every_sharing_error repeats a variant" + ); } #[test] From 09b95705da40f91ec2c3d4456888e6c5c4e2e04a Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 22:16:51 -0400 Subject: [PATCH 040/243] docs(slices): shrink the ACTIVE-legend repair to the two words it needs The cell sat one line above the `RETIRED` cell that #423 rewrites when the `core-import-media` bucket goes, and it had grown a forward reference into exactly the text that PR replaces. Deleting `exif` from the list is the whole of the repair; the cross-reference added a semantic coupling between two adjacent table rows two different branches are editing, for no gain. --- SLICES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SLICES.md b/SLICES.md index 12ef2280..a081a78e 100644 --- a/SLICES.md +++ b/SLICES.md @@ -53,7 +53,7 @@ an **Area**. Read `Status` through `Area`, never on its own. | Area | Meaning | | --- | --- | -| `ACTIVE` | The whole surface survives the teardown (`capsule-core` minus its `media` tree — `exif` stayed, see the `RETIRED` row below — `capsule-core-ffi`/`-swift`/`-kotlin`, the apps, `capsule-cli` local paths, `capsule-web` local paths, `locales/`, `xtask`, the docs site). Implementable against the live workspace today and unaffected by the Kynos rebuild. | +| `ACTIVE` | The whole surface survives the teardown (`capsule-core` minus its `media` tree, `capsule-core-ffi`/`-swift`/`-kotlin`, the apps, `capsule-cli` local paths, `capsule-web` local paths, `locales/`, `xtask`, the docs site). Implementable against the live workspace today and unaffected by the Kynos rebuild. | | `RETIRED` | The target sits in a `legacy-review/` bucket — `server-salvo` (the whole Salvo tree), `sdk-progenitor`, or `media-pipeline` (`capsule_core::media` and its lifecycle adapter). The deliverable must be re-landed on the replacement: Kynos for the server, the Rawshift-backed pipeline for media, the spargen SDK for the client. **`capsule_core::exif` and `import/{executor_cancellation, progress}.rs` are not on this list**, against the original teardown: this branch rebuilt them and they are live, and the `core-import-media` bucket beside them is a stale twin awaiting deletion ([#423](https://github.com/Capsulsaurus/Capsule/issues/423)), not a quarantine (`S-C59`). | | `MIXED` | Both: a surviving `capsule-core`/client/app half that ships and stays, and a server, SDK-wire, or media half that must be re-landed. | From 393d78d627d36302c0b6fb9e9d1245e8ed330bbf Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 22:18:20 -0400 Subject: [PATCH 041/243] docs(slices): correct the S-C27 call-site count to 40 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Landed note said 41 `salvo_responses!` call sites. The count that produced it was taken with no `--include`, so it swept the same change's new `REVIEW.md` bullet, which names the macro in prose. Restricted to `.rs` outside the moved crate the tree has 40 invocations — which is what the salvo→kynos register row already says — plus two `use capsule_wire::…` lines, giving the 42 `capsule_wire` references measured separately. --- SLICES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SLICES.md b/SLICES.md index 9820b458..f2bd6d03 100644 --- a/SLICES.md +++ b/SLICES.md @@ -2312,7 +2312,7 @@ working on a surface written after it. live home is `capsule-server`'s `problem`, `limits` and `body` modules, where the status is part of the return type and `tests/conformance.rs` asserts both directions of the agreement this extraction existed to keep. `capsule-wire` itself moved to `legacy-review/server-salvo/wire/` - beside the 41 `salvo_responses!` call sites that are its only consumers, its manifest disabled, + beside the 40 `salvo_responses!` call sites that are its only consumers, its manifest disabled, and `architecture-check` lists it as a retired dependency so a member cannot declare it again (ADR-0004). From 8736edb99f9c303918d674de8b383e1563094623 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 22:32:15 -0400 Subject: [PATCH 042/243] fix(ci): a dead path filter would have greened the whole gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `changes` was in `required`'s `needs` and in neither of its checks. Every filtered job's `if:` reads `needs.changes.outputs.*`, so when that job dies — a checkout hiccup, a `dorny/paths-filter` error — those outputs are empty, all eleven gates evaluate false and report `skipped`, and `if: always()` runs the loop anyway. A loop that only rejects `failure` and `cancelled` then passes, and `CI / required` certifies a merge having executed zero toolchain gates. Assert `needs.changes.result == 'success'` before the loop, not merely "not failure": `changes` carries no `if:`, so unlike `commit-lint` on push events it can never be legitimately skipped, and a skip there is exactly the symptom. The header comment claimed every `needs` entry was verified; now it is, and the comment says which rule covers which. --- .github/workflows/ci.yml | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f183bc3f..bd321cf1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -483,9 +483,11 @@ jobs: # Single aggregated status check. Mark THIS ("CI / required") as the required # check in branch protection — path-filtered jobs report "skipped", which this - # treats as a pass. Every job in `needs` is verified below, `rust-test` - # included: it became blocking with `S-C59` (see the note on that job), and a - # job listed in `needs` but absent from the loop is a gate that cannot fail. + # treats as a pass. Every job in `needs` is verified below: the loop covers the + # eleven gates (`rust-test` included — it became blocking with `S-C59`, see the + # note on that job), and `changes` is checked ahead of it under a stricter rule, + # because "skipped" is only trustworthy when the filter that skipped it ran. + # A job in `needs` but absent from both checks is a gate that cannot fail. required: name: required if: always() @@ -494,6 +496,19 @@ jobs: steps: - name: Verify required jobs succeeded run: | + # `changes` must have SUCCEEDED, not merely "not failed". Every filtered + # job's `if:` reads needs.changes.outputs.*; if that job dies (a checkout + # hiccup, a paths-filter error) those outputs are empty, so every gate + # evaluates false and reports "skipped" — and `if: always()` still runs + # this one. Treating those skips as passes would green-light a merge with + # zero toolchain gates executed, which is the failure mode this whole job + # exists to prevent. It is the one `needs` entry that cannot be skipped: + # unlike `commit-lint` (push events) or the filtered gates, it has no `if:`. + if [ "${{ needs.changes.result }}" != "success" ]; then + echo "The path-filter job did not succeed (result: ${{ needs.changes.result }})." + echo "Every filtered gate would report skipped, so this aggregate proves nothing." + exit 1 + fi for r in "${{ needs.commit-lint.result }}" "${{ needs.rust.result }}" \ "${{ needs.rust-test.result }}" "${{ needs.rust-cross.result }}" \ "${{ needs.web.result }}" \ From 9dd69f984e900d476988eb80e237b47a7d9da980 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 22:32:27 -0400 Subject: [PATCH 043/243] fix(ci): the i18n gate was narrower in CI than on the developer's machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `i18n-check` compares `locales/` against four generated trees, and it fails on drift whichever side moved — a hand-edited generated catalog is precisely what it exists to catch. It runs in exactly one place, `check-rust`, which exactly one job invokes, so the `rust` paths filter is the whole of its CI reachability. That filter gained `locales/**` two commits ago but none of the generated targets, which left the perverse arrangement of `hk.pkl`'s pre-push step catching a generated-catalog edit that CI would skip. Add the web messages, the Swift `.xcstrings` and the Android `strings.xml` set, so the CI gate is at least as wide as the local one. --- .github/workflows/ci.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd321cf1..c7fb645d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,7 +63,21 @@ jobs: # `i18n-guard` are `cargo run -p xtask -- i18n …` over `locales/`, # so a catalog-only change must still pay the Rust gate. The # `swift` filter already lists it for the same reason. + # + # `i18n-check` compares `locales/` against its *generated* outputs, + # and drift is drift whichever side moved — a hand-edit to a + # generated catalog is the case it exists to catch. `check-rust` is + # the only task that runs it and the `rust` job is the only job that + # runs `check-rust`, so these four globs are what makes the CI gate + # at least as wide as the local one — hk.pkl's `i18n-check` step + # lists the same generated targets (the two remaining entries of + # that glob, `capsule-i18n/src/**` and `xtask/src/i18n.rs`, are + # already covered here by `capsule-i18n/**` and `xtask/**`). Keep + # the two in step. - 'locales/**' + - 'capsule-web/src/i18n/messages/**' + - 'capsule-swift/Generated/**' + - 'capsule-android/src/androidMain/res/values*/strings.xml' - '.github/workflows/ci.yml' web: - 'capsule-web/**' From 278fc15703976ffb5b90d43e7fc950e2b7e64c1c Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 22:33:25 -0400 Subject: [PATCH 044/243] ci(hooks): mirror the manifest globs pre-push, and date the roadmap comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two loose ends from the `docs-truth` filter widening. `hk.pkl`'s `check-docs-truth` step says it "Mirrors the `docs-truth` paths filter in ci.yml", which stopped being true the moment eight manifest globs went into ci.yml alone — so the same package-addition that now fires the CI job still slipped past pre-push. Both lists carry the eight. The ci.yml comment also described the `roadmap` check in the present tense, but neither that check nor the ROADMAP.md it reads exists at this commit; both arrive with `#422`. Say so, and say why the globs are correct to land first. --- .github/workflows/ci.yml | 14 +++++++++----- hk.pkl | 7 +++++-- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7fb645d..296a4678 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,11 +107,15 @@ jobs: - '**/*.mdx' - 'LICENSE' - 'NOTICE' - # The `roadmap` check resolves ROADMAP.md's rows against the - # manifests that declare what exists, so a pull request that adds - # or removes a package without touching Markdown must still run - # it — otherwise the gate is advisory for exactly the change it - # exists to catch. + # For the `roadmap` check, which arrives with `#422` together with + # the ROADMAP.md it reads — neither is in the tree at this commit. + # It will resolve that file's rows against the manifests below, + # which declare what actually exists, so a pull request that adds + # or removes a package without touching Markdown must still run the + # job — otherwise the gate would be advisory for exactly the change + # it exists to catch. Listed ahead of the check so the filter is + # correct the moment `#422` lands; until then these globs only + # widen when `docs-truth` runs, which is harmless. - 'Cargo.toml' - 'settings.gradle.kts' - 'capsule-swift/Project.swift' diff --git a/hk.pkl b/hk.pkl index f90508ad..f561a910 100644 --- a/hk.pkl +++ b/hk.pkl @@ -76,8 +76,11 @@ hooks { // Verify-only (no fixer), so pre-push rather than pre-commit. Mirrors the // `docs-truth` paths filter in ci.yml — astro.config.mjs is in it because - // that is where the published origin comes from. - ["check-docs-truth"] { glob = List("**/*.md", "**/*.mdx", "LICENSE", "NOTICE", "capsule-docs/scripts/**", "capsule-docs/astro.config.mjs", "capsule-docs/endpoint-census-allowlist.txt", "capsule-docs/planned-modules.txt", "capsule-server/openapi.json", "capsule-*/src/**"); check = "mise run check-docs-truth" } + // that is where the published origin comes from, and the eight manifest + // entries after it are for the `roadmap` check arriving with `#422`, which + // resolves ROADMAP.md's rows against the files that declare what exists. + // The claim to mirror ci.yml is load-bearing: keep the two lists in step. + ["check-docs-truth"] { glob = List("**/*.md", "**/*.mdx", "LICENSE", "NOTICE", "capsule-docs/scripts/**", "capsule-docs/astro.config.mjs", "capsule-docs/endpoint-census-allowlist.txt", "capsule-docs/planned-modules.txt", "capsule-server/openapi.json", "capsule-*/src/**", "Cargo.toml", "settings.gradle.kts", "capsule-swift/Project.swift", "**/Package.swift", "**/package.json", "**/pyproject.toml", ".gitmodules", "legacy-review/**"); check = "mise run check-docs-truth" } } } From b3c2c754ca2e55f550f6417011f0a178bb5ec9a8 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 22:34:28 -0400 Subject: [PATCH 045/243] docs(contributing): the local-gate list was still missing five of its ten entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two remaining overclaims in the paragraph this branch rewrote. "Every toolchain's format/lint checks" was not true: `lint-check-ffi` runs in `check-rust` and in no hook, so it is the one lint pre-push never performs. Said plainly, and named in the list of what CI is left to do. The entrypoint list offered five commands as "exactly what CI runs" while CI has ten jobs with a local equivalent. `test-rust` was the glaring omission — this same branch made that job blocking, and it is not part of `check-rust` — along with `check-docs-truth`, `check-vision` and `check-md`. Listed as a block with what each covers, and `rust-cross` called out as the one job with no local entrypoint rather than silently absent. --- CONTRIBUTING.md | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 81ac02d7..42be811d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,16 +24,34 @@ hk install # wires up the git hooks Run tasks with `mise run ` — `mise tasks` lists them all (plain name = auto-fix, `-check` suffix = verify-only). The pre-commit hook auto-formats and **stages** your changes; `convco` validates every commit message as a -[Conventional Commit](https://www.conventionalcommits.org); and pre-push runs every -toolchain's format/lint checks, the Rust and web test suites, and the cheap boundary -gates — `i18n-check`, `i18n-guard`, `architecture-check` and `license-check`. +[Conventional Commit](https://www.conventionalcommits.org); and pre-push runs the +format/lint checks for every toolchain except the FFI crate's, the Rust and web test +suites, and the cheap boundary gates — `i18n-check`, `i18n-guard`, `architecture-check` +and `license-check`. Pre-push is a fast subset of CI, not the whole of it. What it leaves to CI: the Kotlin, -Swift and docs test suites, and the gates that cost minutes of fresh compilation — -`openapi-check-kynos`, `translate-readme-check`, the `build-*` steps, `gen-bindings` and -`verify-examples`. To run exactly what CI runs before you open a pull request, use the -per-toolchain entrypoints: `mise run check-rust`, `check-web`, `check-docs`, -`check-kotlin`, `check-swift`. +Swift and docs test suites; `lint-check-ffi`, the one lint no hook runs; and the gates +that cost minutes of fresh compilation — `openapi-check-kynos`, `translate-readme-check`, +the `build-*` steps, `gen-bindings` and `verify-examples`. + +To run what CI runs before you open a pull request, use the per-toolchain entrypoints, +each of which maps 1:1 to a CI job: + +```sh +mise run check-rust # fmt, clippy, i18n, boundaries, licences, builds, bindings +mise run test-rust # the test job — blocking, and not part of check-rust +mise run check-web # format, lint, bun test, bundle +mise run check-docs # format, lint, test, build +mise run check-docs-truth # every name the docs claim resolves in the tree +mise run check-kotlin # ktlint + detekt +mise run check-swift # format, lint, unit tests, iPhone UI sweep (macOS only) +mise run check-vision # format + lint +mise run check-md # markdownlint +``` + +The one CI job with no local entrypoint is `rust-cross`: it cross-compiles for Android, +Windows and linux-arm64 on their own runners, so reproducing it locally means the +individual `build-*` recipes and the matching toolchains. > **Coming from the old `just` + `lefthook` setup?** Re-run `mise install && hk install` > (hk overwrites the stale `.git/hooks` that called lefthook). The `justfile` is gone — From 3b4ae08c3eaa19e738ccd91cc2908d39b4a9ea18 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 22:44:55 -0400 Subject: [PATCH 046/243] feat(core): add capsule-core::notify alert classes and predicates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `notifications.md` closes the alert class list at five classes and places them, with their trigger predicates, in `capsule-core::notify` so every platform evaluates one shared decision function instead of reimplementing the taxonomy. Nothing implemented it: the module did not exist. `evaluate(&NotifyInput, now) -> Vec` reports the classes true at an instant; `next_deadline(&NotifyInput, now) -> Option` returns the one instant an OS timer must be armed for. Both are pure — `now` is an argument, nothing is read from a clock, a socket, or SQLite — so the whole surface is table-driven under a mocked clock, the same discipline as the recovery cadence whose projection it consumes. Every predicate input is caller-supplied because this crate holds none of the trigger state: there is no persisted `last_completed_sync`, no client-side quota type (quota is server-held and only as current as the last `GET /v1/quota`), and no quarantine table — a refused sync entry is a per-entry verdict. `NotifyInput` therefore carries counts and instants only: no album id, no title, no asset id, nothing a server could author. `next_deadline` is deliberately narrower than `evaluate`. An armed notification fires from the OS timer with the app not running, so it cannot be re-checked on arrival; a deadline is returned only when the alert is certain to be true when it gets there. That withholds one from the three server-state classes (no device-computable deadline), from a suppressed or already-passed one, and from the two that would arrive as a badge rather than a notification. Suppression is an input field rather than a state machine this crate owns: the bounded-snooze-then-badge mechanic already has one owner in the recovery cadence, and a second copy here would be two owners of one mechanic before the client half exists to say which shape it needs. `native`-gated: an alert is composed from decrypted device state, and the un-gated surface is the key-free guest sealing path, which holds none of it. `BTreeMap` params plus a fixed emission order make two runs on equal input byte-equal through serde. --- capsule-core/src/lib.rs | 9 + capsule-core/src/notify/class.rs | 238 ++++++++++ capsule-core/src/notify/evaluate.rs | 672 ++++++++++++++++++++++++++++ capsule-core/src/notify/input.rs | 204 +++++++++ capsule-core/src/notify/mod.rs | 71 +++ 5 files changed, 1194 insertions(+) create mode 100644 capsule-core/src/notify/class.rs create mode 100644 capsule-core/src/notify/evaluate.rs create mode 100644 capsule-core/src/notify/input.rs create mode 100644 capsule-core/src/notify/mod.rs diff --git a/capsule-core/src/lib.rs b/capsule-core/src/lib.rs index 620daf1e..f6f43fdf 100644 --- a/capsule-core/src/lib.rs +++ b/capsule-core/src/lib.rs @@ -58,6 +58,15 @@ pub mod lifecycle; pub mod metadata; #[cfg(feature = "native")] pub mod ml; +// `notify` — alert classes and their trigger predicates (`S-D29`): one shared decision +// function every platform evaluates, so the taxonomy is not reimplemented per client. +// `native`-gated because an alert is composed from *decrypted device state*, and the +// un-gated surface here is the key-free guest sealing path, which holds none of it. The +// rationale lives as a plain comment rather than a doc comment because rustdoc merges an +// outer `mod` doc with the module's own `//!` block and then resolves the whole thing at +// the declaration site — which would break every short intra-doc link in `notify/mod.rs`. +#[cfg(feature = "native")] +pub mod notify; #[cfg(feature = "native")] pub mod sidecar; #[cfg(feature = "native")] diff --git a/capsule-core/src/notify/class.rs b/capsule-core/src/notify/class.rs new file mode 100644 index 00000000..e79a1bac --- /dev/null +++ b/capsule-core/src/notify/class.rs @@ -0,0 +1,238 @@ +//! The closed alert-class enum, its severity, and the [`Alert`] record the predicate emits. +//! +//! SSoT: [Notifications — Alert Classes]. The class list is closed here; each class's *trigger +//! predicate and thresholds* stay owned by the doc that defines the condition, and are +//! implemented in [`super::evaluate()`] against those citations. +//! +//! [Notifications — Alert Classes]: https://docs/design/notifications/#alert-classes + +use std::collections::BTreeMap; + +use jiff::Timestamp; +use serde::{Deserialize, Serialize}; + +/// Every alert Capsule can raise. **A closed enum**: an unknown wire value is a structural +/// error, like every other closed enum in the schema rules — which is what the `serde` derive +/// without `#[serde(other)]` gives. +/// +/// The variant order is the delivery order [`super::evaluate()`] emits in, and matches the +/// SSoT's own table so a reader can diff the two. +/// +/// `Ord` is derived because the suppression map ([`super::NotifyInput::suppressed`]) is keyed on +/// this type and must iterate deterministically. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AlertClass { + /// Two weeks without a completed sync while changes remain un-synced. Trigger owner: + /// [Download & Sync — Notifications](https://docs/design/import/download-sync/#notifications). + SyncStale, + /// A recovery-secret verification check is due. Trigger owner: + /// [Backup — Schedule and Triggers](https://docs/design/backup-recovery/#schedule-and-triggers). + RecoveryCheckDue, + /// Storage crossed the soft limit and is below the hard limit. Trigger owner: + /// [Quota — Thresholds and States](https://docs/design/quota/#thresholds-and-states). + QuotaSoft, + /// Storage is over the hard limit — the grace window is counting, or has closed. Trigger + /// owner: [Quota — Thresholds and States](https://docs/design/quota/#thresholds-and-states). + QuotaGraceExpiring, + /// Items are sitting on a quarantine surface awaiting a human. Trigger owner: + /// [Threat Model — Quarantine Surfaces](https://docs/design/threat-model/scenarios/#quarantine-surfaces). + QuarantinePending, + /// Guest drops are awaiting review and adoption. Trigger owner: + /// [Web Upload — Drop and Adoption Lifecycle](https://docs/design/web-upload/#drop-and-adoption-lifecycle). + DropPending, +} + +impl AlertClass { + /// Every class, in delivery order. Iterating this rather than a hand-written list is what + /// makes "the enum is closed" a property a reader can check: adding a variant without + /// extending this array fails to compile, because the array's length is declared. + pub const ALL: [Self; 6] = [ + Self::SyncStale, + Self::RecoveryCheckDue, + Self::QuotaSoft, + Self::QuotaGraceExpiring, + Self::QuarantinePending, + Self::DropPending, + ]; + + /// The stable wire name — identical to the `serde` representation, so a log line, a + /// [`Alert::params`] value, and the serialized form never disagree. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::SyncStale => "sync_stale", + Self::RecoveryCheckDue => "recovery_check_due", + Self::QuotaSoft => "quota_soft", + Self::QuotaGraceExpiring => "quota_grace_expiring", + Self::QuarantinePending => "quarantine_pending", + Self::DropPending => "drop_pending", + } + } + + /// How loudly a client should present this class. A property of the class, not of the + /// instant it fired, so it lives here rather than being decided per-[`Alert`]. + #[must_use] + pub const fn severity(self) -> AlertSeverity { + match self { + // The library is silently falling out of date — the case the alert exists for. + Self::SyncStale + // Writes beyond the originals are now being refused. + | Self::QuotaGraceExpiring + // Items are neither applied nor dropped; they are waiting on a human. + | Self::QuarantinePending => AlertSeverity::Warning, + // Advisory: nothing is failing yet. + Self::RecoveryCheckDue | Self::QuotaSoft | Self::DropPending => AlertSeverity::Advisory, + } + } + + /// Whether this class's trigger is a deadline the device can compute alone, and can + /// therefore be pre-armed as a scheduled local notification. + /// + /// `false` for the three server-state classes (`quota_*`, `quarantine_pending`, + /// `drop_pending`): their condition lives on the server, so on a device that never runs + /// they cannot fire at all. That gap is real and v1 accepts it — those three surface at + /// next app launch. Closing it is what the post-v1 wake tier is for. + #[must_use] + pub const fn pre_armable(self) -> bool { + matches!(self, Self::SyncStale | Self::RecoveryCheckDue) + } +} + +/// How prominently a client presents an alert. +/// +/// Two variants deliberately, and **neither gates anything** — see +/// [`Alert::blocks_critical_flow`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AlertSeverity { + /// Informational: nothing is failing, and nothing is about to. + Advisory, + /// Something is already degraded or is being refused. + Warning, +} + +/// One alert that is true at an instant. +/// +/// Pure data: a class, its severity, the deadline that produced it (for the pre-armable classes), +/// and the parameters a client interpolates into its own catalog string. **Never a localized +/// string** — the copy is a `notification.*` catalog key the client owns. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Alert { + /// Which alert this is. + pub class: AlertClass, + /// [`AlertClass::severity`] for [`class`](Self::class), carried so a consumer that only + /// deserializes the record does not have to re-derive it. + pub severity: AlertSeverity, + /// The instant whose passing made this alert true, for the pre-armable classes; `None` for + /// the three whose condition is server-held and has no device-computable deadline. + pub deadline: Option, + /// Parameters for the client's catalog string — plain strings, deterministically ordered. + /// + /// The keys in use are `count` (`sync_stale`, `quarantine_pending`, `drop_pending`), + /// `days_behind` (`sync_stale`), `grace` (`quota_grace_expiring`) and `snooze_budget` + /// (`recovery_check_due`). Each is documented at the predicate that sets it. + pub params: BTreeMap, +} + +impl Alert { + /// An alert of `class` with its severity filled in, no deadline, and no parameters. + pub(crate) fn new(class: AlertClass) -> Self { + Self { + class, + severity: class.severity(), + deadline: None, + params: BTreeMap::new(), + } + } + + /// Attach the deadline whose passing made this alert true. + pub(crate) fn with_deadline(mut self, deadline: Timestamp) -> Self { + self.deadline = Some(deadline); + self + } + + /// Attach one catalog parameter. + pub(crate) fn with_param(mut self, key: &str, value: impl Into) -> Self { + self.params.insert(key.to_owned(), value.into()); + self + } + + /// Alerts are advisory by construction: **no** alert blocks sync, unlock, upload, or any + /// critical flow. `const false` for every alert, so the "never blocks" rule is a + /// compile-time property of the type rather than a convention reviewers have to police — + /// the same trick + /// [`VerificationState::blocks_critical_flow`](https://docs/design/backup-recovery/) uses + /// for the recovery prompt. + #[must_use] + pub const fn blocks_critical_flow(&self) -> bool { + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The enum is closed and its wire names are stable: a rename would break every client's + /// persisted suppression map and its catalog keys at once. + #[test] + fn wire_names_match_serde_and_are_stable() { + let expected = [ + "sync_stale", + "recovery_check_due", + "quota_soft", + "quota_grace_expiring", + "quarantine_pending", + "drop_pending", + ]; + for (class, name) in AlertClass::ALL.into_iter().zip(expected) { + assert_eq!(class.as_str(), name); + assert_eq!( + serde_json::to_string(&class).unwrap(), + format!("\"{name}\"") + ); + assert_eq!( + serde_json::from_str::(&format!("\"{name}\"")).unwrap(), + class + ); + } + } + + /// SSoT: unknown classes are rejected as structural errors. + #[test] + fn unknown_class_is_a_structural_error() { + assert!(serde_json::from_str::("\"telemetry_ready\"").is_err()); + assert!(serde_json::from_str::("\"critical\"").is_err()); + } + + /// SSoT: only the two device-computable deadlines are pre-armable. + #[test] + fn pre_armable_is_exactly_the_two_device_computable_classes() { + let armable: Vec<_> = AlertClass::ALL + .into_iter() + .filter(|c| c.pre_armable()) + .collect(); + assert_eq!( + armable, + vec![AlertClass::SyncStale, AlertClass::RecoveryCheckDue] + ); + } + + /// SSoT: "No alert ever blocks." Asserted for every class so a new variant cannot opt out. + #[test] + fn no_alert_blocks_a_critical_flow() { + for class in AlertClass::ALL { + assert!(!Alert::new(class).blocks_critical_flow()); + } + } + + /// The severity carried on the record is the class's own, never an independent field a + /// caller can desynchronize. + #[test] + fn severity_is_derived_from_the_class() { + for class in AlertClass::ALL { + assert_eq!(Alert::new(class).severity, class.severity()); + } + } +} diff --git a/capsule-core/src/notify/evaluate.rs b/capsule-core/src/notify/evaluate.rs new file mode 100644 index 00000000..1dff272f --- /dev/null +++ b/capsule-core/src/notify/evaluate.rs @@ -0,0 +1,672 @@ +//! The trigger predicates: [`evaluate()`] (which classes are true now) and [`next_deadline()`] +//! (the one instant to arm an OS timer for). +//! +//! Each predicate cites the doc that owns its threshold. This module owns none of them — it owns +//! only the *composition*, which is the thing that must not be reimplemented per platform. +//! +//! # Boundary convention +//! +//! Every threshold in this module **fires at the boundary instant**: `now >= deadline`, never +//! `>`. That matches the recovery cadence's own `now >= next_due`, so a client that renders both +//! never sees them disagree by a second. Suppression is the mirror image and is exclusive +//! (`until > now`), so a class snoozed to exactly `now` is due at `now`. + +use jiff::Timestamp; + +use super::class::{Alert, AlertClass}; +use super::input::{NotifyInput, QuotaAdvisory, RecoveryFacts, SyncFacts}; + +/// One day, in seconds — the unit the staleness threshold is expressed in. +pub const DAY_SECS: i64 = 86_400; + +/// The staleness threshold: **two weeks** without a completed sync while changes remain +/// un-synced. From [Download & Sync — Notifications], which owns the predicate; this module owns +/// only its evaluation. +/// +/// [Download & Sync — Notifications]: https://docs/design/import/download-sync/#notifications +pub const SYNC_STALE_SECS: i64 = 14 * DAY_SECS; + +/// Every alert that is true at `now`, in [`AlertClass::ALL`] order. +/// +/// Pure: `now` is an argument, nothing is read from the environment, and equal inputs give +/// equal outputs. A suppressed class is skipped before its predicate runs, so suppression can +/// never be observed as "fired but hidden". +/// +/// Absent facts emit nothing: `None` sync facts, `None` quota facts and zero counts are all +/// silence, not an error. +#[must_use] +pub fn evaluate(input: &NotifyInput, now: Timestamp) -> Vec { + let mut alerts = Vec::new(); + for class in AlertClass::ALL { + if input.is_suppressed(class, now) { + continue; + } + if let Some(alert) = evaluate_class(input, class, now) { + alerts.push(alert); + } + } + // The whole point of one shared decision function is that a field report says which + // classes a device decided were true, without the device having to explain itself. + tracing::debug!( + now = %now, + classes = ?alerts.iter().map(|a| a.class.as_str()).collect::>(), + "notify: evaluated the alert classes" + ); + alerts +} + +/// The next instant an OS timer should be armed for, or `None` when there is nothing to arm — +/// in which case the client cancels its timer, which is the cancel half of the +/// arm / re-arm / cancel rule. +/// +/// This is deliberately **narrower** than [`evaluate()`]. An armed notification fires from the +/// OS's own timer with the app not running, so it cannot be re-checked when it arrives: a +/// deadline is only returned when the alert is certain to be true on arrival. Three things +/// therefore withhold one: +/// +/// - the class is not [pre-armable](AlertClass::pre_armable) — its condition is server-held; +/// - the class is suppressed at `now`, or its deadline is not strictly after `now` (already +/// passed, so there is nothing left to schedule); +/// - the class would arrive as a *badge* rather than a notification: `sync_stale` with nothing +/// un-synced (only a sync can change that, and a sync re-arms), and `recovery_check_due` with +/// its snooze budget spent. +/// +/// The result is a single value, so recomputing after any state change and cancel-then-arming +/// on a change is the whole client-side protocol — and two live timers for one class is +/// structurally impossible. +#[must_use] +pub fn next_deadline(input: &NotifyInput, now: Timestamp) -> Option { + let deadline = AlertClass::ALL + .into_iter() + .filter(|class| class.pre_armable() && !input.is_suppressed(*class, now)) + .filter_map(|class| pre_arm_deadline(input, class, now)) + .filter(|deadline| *deadline > now) + .min(); + // A timer that was armed for the wrong instant, or cancelled when it should not have been, + // is otherwise invisible until an alert fails to arrive weeks later. + tracing::debug!( + now = %now, + deadline = ?deadline.map(|d| d.to_string()), + "notify: computed the next pre-arm deadline" + ); + deadline +} + +/// The predicate for one class. Separated per class rather than per fact so the emission order +/// is the enum's and the two quota classes stay independently suppressible. +fn evaluate_class(input: &NotifyInput, class: AlertClass, now: Timestamp) -> Option { + match class { + AlertClass::SyncStale => sync_stale(input.sync.as_ref()?, now), + AlertClass::RecoveryCheckDue => recovery_check_due(input.recovery.as_ref()?, now), + AlertClass::QuotaSoft => { + (input.quota?.state == QuotaAdvisory::SoftWarning).then(|| Alert::new(class)) + } + AlertClass::QuotaGraceExpiring => quota_grace_expiring(input.quota?.state), + AlertClass::QuarantinePending => counted(class, input.quarantine_pending), + AlertClass::DropPending => counted(class, input.drops_pending), + } +} + +/// A class that fires on any non-zero count and carries it as the `count` parameter. +/// +/// A quarantined item — and a pending drop is one — is never silently dropped and never +/// silently applied, so any non-zero count is reported. +fn counted(class: AlertClass, count: u64) -> Option { + (count > 0).then(|| Alert::new(class).with_param("count", count.to_string())) +} + +/// "After two weeks without a completed sync *while changes remain un-synced*." +/// +/// Both halves are load-bearing: with nothing un-synced the library is not behind, so a device +/// that simply has not needed to sync raises nothing. +fn sync_stale(facts: &SyncFacts, now: Timestamp) -> Option { + if facts.unsynced_changes == 0 { + return None; + } + let deadline = sync_stale_deadline(facts); + if now < deadline { + return None; + } + // Clock skew backwards is not a fire: `now < deadline` already returned above, so the + // subtraction here cannot go negative — but it saturates regardless rather than trusting it. + let days_behind = now + .as_second() + .saturating_sub(facts.last_completed_sync.as_second()) + / DAY_SECS; + Some( + Alert::new(AlertClass::SyncStale) + .with_deadline(deadline) + .with_param("count", facts.unsynced_changes.to_string()) + .with_param("days_behind", days_behind.to_string()), + ) +} + +/// When the staleness alert becomes true, given the sync epoch. +fn sync_stale_deadline(facts: &SyncFacts) -> Timestamp { + add_secs(facts.last_completed_sync, SYNC_STALE_SECS) +} + +/// The verification prompt is due, and no active snooze is holding it back. +/// +/// The cadence ladder and the snooze accounting stay with the scheduler; this consumes +/// `next_due` as given. `snooze_budget_spent` does not change *whether* the class is reported — +/// a client cannot render a badge for a condition it was not told about — only how, which is +/// delivery and therefore the client's. It is carried as the `snooze_budget` parameter. +fn recovery_check_due(facts: &RecoveryFacts, now: Timestamp) -> Option { + if facts.snoozed_until.is_some_and(|until| until > now) { + return None; + } + if now < facts.next_due { + return None; + } + Some( + Alert::new(AlertClass::RecoveryCheckDue) + .with_deadline(facts.next_due) + .with_param( + "snooze_budget", + if facts.snooze_budget_spent { + "spent" + } else { + "available" + }, + ), + ) +} + +/// "Entering the grace window raises `quota_grace_expiring`." +/// +/// `GraceExpired` raises the same class with `grace = "expired"` rather than going silent: it is +/// strictly additive to `HardExceeded` (metadata-growth writes are now refused too), so a device +/// that first polls after the window closed must still hear about it, and the class set is closed +/// — there is no `quota_grace_expired` to escalate into. +fn quota_grace_expiring(state: QuotaAdvisory) -> Option { + let grace = match state { + QuotaAdvisory::HardExceeded => "counting", + QuotaAdvisory::GraceExpired => "expired", + // `Ok`/`SoftWarning` are below the hard limit; `Suspended` is an admin or billing + // action owned by moderation, not a quota threshold. + QuotaAdvisory::Ok | QuotaAdvisory::SoftWarning | QuotaAdvisory::Suspended => return None, + }; + Some(Alert::new(AlertClass::QuotaGraceExpiring).with_param("grace", grace)) +} + +/// The deadline to arm for one pre-armable class, if it has one that will certainly fire. +fn pre_arm_deadline(input: &NotifyInput, class: AlertClass, now: Timestamp) -> Option { + match class { + AlertClass::SyncStale => input + .sync + .as_ref() + .filter(|facts| facts.unsynced_changes > 0) + .map(sync_stale_deadline), + AlertClass::RecoveryCheckDue => input + .recovery + .as_ref() + .filter(|facts| !facts.snooze_budget_spent) + .map(|facts| match facts.snoozed_until { + // While snoozed the deadline is the snooze's end, not the original due date. + Some(until) if until > now => until, + _ => facts.next_due, + }), + // Not pre-armable; `next_deadline` filters these out before asking. + AlertClass::QuotaSoft + | AlertClass::QuotaGraceExpiring + | AlertClass::QuarantinePending + | AlertClass::DropPending => None, + } +} + +/// Add a signed second offset to a timestamp, saturating at the representable bounds. Nothing +/// here operates near them; saturation just keeps the arithmetic total, so a disabled class +/// pinned at [`Timestamp::MAX`] can never panic the predicate. +fn add_secs(base: Timestamp, secs: i64) -> Timestamp { + let target = base.as_second().saturating_add(secs); + Timestamp::from_second(target).unwrap_or(if target < 0 { + Timestamp::MIN + } else { + Timestamp::MAX + }) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::super::class::AlertSeverity; + use super::super::input::QuotaFacts; + use super::*; + + /// A fixed, round base instant well away from the timestamp bounds. + const BASE: i64 = 1_700_000_000; + + fn ts(secs: i64) -> Timestamp { + Timestamp::from_second(secs).unwrap() + } + + /// The classes `evaluate` reported, in order. + fn classes(input: &NotifyInput, now: Timestamp) -> Vec { + evaluate(input, now).into_iter().map(|a| a.class).collect() + } + + /// The `params` of the single alert of `class`, or `None` if it did not fire. + fn params_of( + input: &NotifyInput, + class: AlertClass, + now: Timestamp, + ) -> Option> { + evaluate(input, now) + .into_iter() + .find(|a| a.class == class) + .map(|a| a.params) + } + + fn with_sync(last_completed_sync: i64, unsynced_changes: u64) -> NotifyInput { + NotifyInput { + sync: Some(SyncFacts { + last_completed_sync: ts(last_completed_sync), + unsynced_changes, + }), + ..NotifyInput::default() + } + } + + fn with_recovery( + next_due: i64, + snoozed_until: Option, + snooze_budget_spent: bool, + ) -> NotifyInput { + NotifyInput { + recovery: Some(RecoveryFacts { + next_due: ts(next_due), + snoozed_until: snoozed_until.map(ts), + snooze_budget_spent, + }), + ..NotifyInput::default() + } + } + + fn with_quota(state: QuotaAdvisory) -> NotifyInput { + NotifyInput { + quota: Some(QuotaFacts { state }), + ..NotifyInput::default() + } + } + + // ── sync_stale ────────────────────────────────────────────────────────── + + /// SSoT: "After two weeks without a completed sync *while changes remain un-synced*." + /// Table-driven over the boundary instant and the un-synced half. + #[test] + fn sync_stale_fires_at_two_weeks_and_not_before() { + let due = BASE + SYNC_STALE_SECS; + let cases: &[(i64, u64, bool, &str)] = &[ + (due - 1, 1, false, "one second before the threshold"), + (due, 1, true, "exactly at the threshold"), + (due + DAY_SECS, 1, true, "a day past the threshold"), + (due, 0, false, "at the threshold with nothing un-synced"), + ( + due + 365 * DAY_SECS, + 0, + false, + "a year past, with nothing un-synced", + ), + (BASE, 5, false, "the instant the sync completed"), + (BASE - DAY_SECS, 5, false, "clock skewed behind the epoch"), + ]; + for &(now, unsynced, expected, why) in cases { + let input = with_sync(BASE, unsynced); + assert_eq!( + classes(&input, ts(now)).contains(&AlertClass::SyncStale), + expected, + "{why}" + ); + } + } + + /// A device that has never completed a sync raises nothing: the alert is about a *stale* + /// sync, not a missing one. + #[test] + fn sync_stale_needs_a_completed_sync_to_be_stale_from() { + let input = NotifyInput::default(); + assert!(evaluate(&input, ts(BASE + 10 * SYNC_STALE_SECS)).is_empty()); + assert_eq!(next_deadline(&input, ts(BASE)), None); + } + + /// The alert carries the deadline that produced it and the two catalog parameters. + #[test] + fn sync_stale_carries_deadline_and_params() { + let input = with_sync(BASE, 42); + let now = ts(BASE + SYNC_STALE_SECS + 3 * DAY_SECS); + let alert = evaluate(&input, now) + .into_iter() + .find(|a| a.class == AlertClass::SyncStale) + .expect("stale after 17 days with changes pending"); + + assert_eq!(alert.severity, AlertSeverity::Warning); + assert_eq!(alert.deadline, Some(ts(BASE + SYNC_STALE_SECS))); + assert_eq!(alert.params["count"], "42"); + assert_eq!(alert.params["days_behind"], "17"); + } + + // ── recovery_check_due ────────────────────────────────────────────────── + + /// Due at the boundary; an active snooze holds it back; a snooze that has expired does not. + #[test] + fn recovery_check_due_boundaries() { + let due = BASE + 7 * DAY_SECS; + let cases: &[(i64, Option, bool, &str)] = &[ + (due - 1, None, false, "one second before due"), + (due, None, true, "exactly at due"), + (due + 1, None, true, "one second after due"), + (due, Some(due + 1), false, "snoozed one second past now"), + (due, Some(due), true, "snooze expiring exactly at now"), + (due, Some(due - 1), true, "snooze already expired"), + (due - 1, Some(due + 1), false, "snoozed and not yet due"), + ]; + for &(now, snoozed, expected, why) in cases { + let input = with_recovery(due, snoozed, false); + assert_eq!( + classes(&input, ts(now)).contains(&AlertClass::RecoveryCheckDue), + expected, + "{why}" + ); + } + } + + /// A spent snooze budget is reported, not silenced — the client needs the fact to render a + /// badge — and it is carried as a parameter rather than a class of its own. + #[test] + fn recovery_check_due_reports_the_snooze_budget() { + let due = BASE + 7 * DAY_SECS; + for (spent, expected) in [(false, "available"), (true, "spent")] { + let input = with_recovery(due, None, spent); + let params = params_of(&input, AlertClass::RecoveryCheckDue, ts(due)) + .expect("due at the boundary regardless of the budget"); + assert_eq!(params["snooze_budget"], expected); + } + } + + // ── quota ─────────────────────────────────────────────────────────────── + + /// Each quota state maps to exactly the classes the SSoT's table names, and no more. + #[test] + fn quota_states_map_to_their_classes() { + let cases: &[(QuotaAdvisory, &[AlertClass], Option<&str>)] = &[ + (QuotaAdvisory::Ok, &[], None), + (QuotaAdvisory::SoftWarning, &[AlertClass::QuotaSoft], None), + ( + QuotaAdvisory::HardExceeded, + &[AlertClass::QuotaGraceExpiring], + Some("counting"), + ), + ( + QuotaAdvisory::GraceExpired, + &[AlertClass::QuotaGraceExpiring], + Some("expired"), + ), + (QuotaAdvisory::Suspended, &[], None), + ]; + for &(state, expected, grace) in cases { + let input = with_quota(state); + assert_eq!(classes(&input, ts(BASE)), expected, "{state:?}"); + if let Some(grace) = grace { + let params = params_of(&input, AlertClass::QuotaGraceExpiring, ts(BASE)) + .expect("the grace class fired"); + assert_eq!(params["grace"], grace, "{state:?}"); + } + } + } + + /// Quota alerts carry no deadline: the wire response has no `over_since`, so the device + /// cannot compute one, which is why the class is not pre-armable. + #[test] + fn quota_alerts_are_not_pre_armable() { + for state in [QuotaAdvisory::SoftWarning, QuotaAdvisory::GraceExpired] { + let input = with_quota(state); + for alert in evaluate(&input, ts(BASE)) { + assert_eq!(alert.deadline, None, "{state:?}"); + } + assert_eq!(next_deadline(&input, ts(BASE)), None, "{state:?}"); + } + } + + // ── counted classes ───────────────────────────────────────────────────── + + /// A quarantined item is never silently dropped and never silently applied: any non-zero + /// count is reported, with the count as a parameter. + #[test] + fn counted_classes_fire_on_any_non_zero_count() { + let cases: &[(u64, u64, &[AlertClass])] = &[ + (0, 0, &[]), + (1, 0, &[AlertClass::QuarantinePending]), + (0, 1, &[AlertClass::DropPending]), + ( + 3, + 7, + &[AlertClass::QuarantinePending, AlertClass::DropPending], + ), + ]; + for &(quarantine, drops, expected) in cases { + let input = NotifyInput { + quarantine_pending: quarantine, + drops_pending: drops, + ..NotifyInput::default() + }; + assert_eq!(classes(&input, ts(BASE)), expected); + } + + let input = NotifyInput { + quarantine_pending: 3, + drops_pending: 7, + ..NotifyInput::default() + }; + assert_eq!( + params_of(&input, AlertClass::QuarantinePending, ts(BASE)).unwrap()["count"], + "3" + ); + assert_eq!( + params_of(&input, AlertClass::DropPending, ts(BASE)).unwrap()["count"], + "7" + ); + } + + // ── suppression ───────────────────────────────────────────────────────── + + /// Suppression is per class, exclusive at the instant, and removes the class from both the + /// report and the arm decision. + #[test] + fn suppression_boundaries_apply_to_alerts_and_deadlines() { + let due = BASE + SYNC_STALE_SECS; + let cases: &[(i64, bool, &str)] = &[ + (due + 1, false, "suppressed past now"), + (due, true, "suppression expiring exactly at now"), + (due - 1, true, "suppression already expired"), + ]; + for &(until, expected, why) in cases { + let mut input = with_sync(BASE, 1); + input.suppressed.insert(AlertClass::SyncStale, ts(until)); + assert_eq!( + classes(&input, ts(due)).contains(&AlertClass::SyncStale), + expected, + "{why}" + ); + } + + // A class suppressed while its deadline is still in the future contributes no deadline. + let mut input = with_sync(BASE, 1); + input + .suppressed + .insert(AlertClass::SyncStale, Timestamp::MAX); + assert_eq!(next_deadline(&input, ts(BASE)), None); + assert!(evaluate(&input, ts(due)).is_empty()); + } + + /// Disabling suppresses the warning, never the behavior — so the *other* classes are + /// untouched by one class's disable. + #[test] + fn suppression_does_not_leak_across_classes() { + let mut input = with_sync(BASE, 1); + input.drops_pending = 2; + input + .suppressed + .insert(AlertClass::SyncStale, Timestamp::MAX); + assert_eq!( + classes(&input, ts(BASE + SYNC_STALE_SECS)), + [AlertClass::DropPending] + ); + } + + // ── next_deadline ─────────────────────────────────────────────────────── + + /// The earlier of the two pre-armable deadlines wins, and only future ones count. + #[test] + fn next_deadline_is_the_earliest_future_pre_armable_instant() { + let sync_due = BASE + SYNC_STALE_SECS; // BASE + 14 d + let recovery_due = BASE + 7 * DAY_SECS; // earlier + + let mut input = with_sync(BASE, 1); + input.recovery = Some(RecoveryFacts { + next_due: ts(recovery_due), + snoozed_until: None, + snooze_budget_spent: false, + }); + + // Both future → the earlier. + assert_eq!(next_deadline(&input, ts(BASE)), Some(ts(recovery_due))); + // The recovery deadline has passed → the staleness one. + assert_eq!( + next_deadline(&input, ts(recovery_due)), + Some(ts(sync_due)), + "a deadline exactly at now has already fired and is not re-armed" + ); + // Both passed → nothing to arm; the client cancels its timer. + assert_eq!(next_deadline(&input, ts(sync_due)), None); + assert_eq!(next_deadline(&input, ts(sync_due + DAY_SECS)), None); + } + + /// While snoozed, the armed instant is the snooze's end rather than the original due date. + #[test] + fn snooze_moves_the_armed_instant() { + let due = BASE + 7 * DAY_SECS; + let until = due + 2 * DAY_SECS; + let input = with_recovery(due, Some(until), false); + assert_eq!(next_deadline(&input, ts(BASE)), Some(ts(until))); + assert_eq!(next_deadline(&input, ts(due)), Some(ts(until))); + // Once the snooze has expired the class is due, so there is nothing left to arm. + assert_eq!(next_deadline(&input, ts(until)), None); + } + + /// An armed notification cannot be re-checked when it fires, so nothing is armed that would + /// arrive as a badge or as no alert at all. + #[test] + fn nothing_is_armed_that_would_arrive_empty() { + // Nothing un-synced: only a sync can change that, and a sync re-arms. + assert_eq!(next_deadline(&with_sync(BASE, 0), ts(BASE)), None); + // Snooze budget spent: the class has degraded to a badge, which is in-app, not a timer. + let due = BASE + 7 * DAY_SECS; + assert_eq!( + next_deadline(&with_recovery(due, None, true), ts(BASE)), + None + ); + // ...while the same facts with budget left do arm. + assert_eq!( + next_deadline(&with_recovery(due, None, false), ts(BASE)), + Some(ts(due)) + ); + } + + // ── whole-surface properties ──────────────────────────────────────────── + + /// A client that has learned nothing reports nothing and arms nothing. + #[test] + fn default_input_is_silent() { + let input = NotifyInput::default(); + assert!(evaluate(&input, ts(BASE)).is_empty()); + assert_eq!(next_deadline(&input, ts(BASE)), None); + } + + /// Emission order is the enum's, and every class can be true at once. + #[test] + fn every_class_can_fire_together_in_enum_order() { + let due = BASE + SYNC_STALE_SECS; + let input = NotifyInput { + sync: Some(SyncFacts { + last_completed_sync: ts(BASE), + unsynced_changes: 1, + }), + recovery: Some(RecoveryFacts { + next_due: ts(BASE), + snoozed_until: None, + snooze_budget_spent: false, + }), + // `SoftWarning` and `HardExceeded` are mutually exclusive states, so the two quota + // classes cannot both be true; this asserts the ordering of the five that can. + quota: Some(QuotaFacts { + state: QuotaAdvisory::HardExceeded, + }), + quarantine_pending: 1, + drops_pending: 1, + suppressed: BTreeMap::new(), + }; + assert_eq!( + classes(&input, ts(due)), + [ + AlertClass::SyncStale, + AlertClass::RecoveryCheckDue, + AlertClass::QuotaGraceExpiring, + AlertClass::QuarantinePending, + AlertClass::DropPending, + ] + ); + } + + /// Determinism: equal input gives byte-equal output through `serde`, which is what + /// `BTreeMap` params and a fixed emission order buy. + #[test] + fn evaluation_is_deterministic_through_serde() { + let mut input = with_sync(BASE, 9); + input.quarantine_pending = 2; + input.drops_pending = 4; + input + .suppressed + .insert(AlertClass::RecoveryCheckDue, Timestamp::MAX); + let now = ts(BASE + SYNC_STALE_SECS); + + let first = serde_json::to_string(&evaluate(&input, now)).unwrap(); + let second = serde_json::to_string(&evaluate(&input, now)).unwrap(); + assert_eq!(first, second); + + // And the input itself round-trips, so a persisted snapshot re-evaluates identically. + let round_tripped: NotifyInput = + serde_json::from_str(&serde_json::to_string(&input).unwrap()).unwrap(); + assert_eq!(round_tripped, input); + assert_eq!(evaluate(&round_tripped, now), evaluate(&input, now)); + } + + /// Saturating arithmetic: a sync epoch pinned at the far end of the range neither panics + /// nor wraps into the past. + #[test] + fn deadlines_saturate_at_the_representable_bounds() { + let input = NotifyInput { + sync: Some(SyncFacts { + last_completed_sync: Timestamp::MAX, + unsynced_changes: 1, + }), + ..NotifyInput::default() + }; + assert!(evaluate(&input, ts(BASE)).is_empty()); + assert_eq!(next_deadline(&input, ts(BASE)), Some(Timestamp::MAX)); + + let ancient = NotifyInput { + sync: Some(SyncFacts { + last_completed_sync: Timestamp::MIN, + unsynced_changes: 1, + }), + ..NotifyInput::default() + }; + assert!( + classes(&ancient, ts(BASE)).contains(&AlertClass::SyncStale), + "an epoch at the far past is long stale" + ); + assert_eq!(next_deadline(&ancient, ts(BASE)), None); + } +} diff --git a/capsule-core/src/notify/input.rs b/capsule-core/src/notify/input.rs new file mode 100644 index 00000000..fb4fd5fb --- /dev/null +++ b/capsule-core/src/notify/input.rs @@ -0,0 +1,204 @@ +//! [`NotifyInput`] — the snapshot of device-held state the predicate is evaluated against. +//! +//! Every field is caller-supplied and `Option`/zero where the client has not learned it yet. +//! Nothing here reads a clock, a socket, or SQLite: this crate holds none of the trigger state +//! (see the [module docs](super) for why), so the only honest shape is a struct the client +//! fills. +//! +//! The security boundary is the shape itself: counts and instants only. No album id, no title, +//! no asset id, nothing a server could author. + +use std::collections::BTreeMap; + +use jiff::Timestamp; +use serde::{Deserialize, Serialize}; + +use super::class::AlertClass; + +/// The state [`super::evaluate()`] and [`super::next_deadline()`] decide from. +/// +/// [`Default`] is the "a client that has just installed and learned nothing" input, and it +/// yields no alerts and no deadline. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct NotifyInput { + /// Sync progress, persisted by the client at the end of each successful sync. `None` on a + /// device that has never completed one — which raises no `sync_stale`, because the alert is + /// about a *stale* sync and not a missing one. + pub sync: Option, + /// The recovery-verification cadence, projected into flat facts. `None` before recovery is + /// set up. + pub recovery: Option, + /// The last quota response the client received. `None` before the first + /// `GET /v1/quota` — quota state is server-held, so it is only ever as current as that call. + pub quota: Option, + /// How many items sit on the client's quarantine surfaces awaiting a human. + pub quarantine_pending: u64, + /// How many guest drops are awaiting review and adoption. + pub drops_pending: u64, + /// Per-class suppression: a class whose entry is **strictly after** `now` emits nothing and + /// contributes no deadline. + /// + /// This is how a client applies snooze and disable without this crate owning that state + /// machine — the bounded-snooze-then-badge mechanic has one owner already + /// ([`RecoveryCadence`](https://docs/design/backup-recovery/#recovery-verification-cadence)), + /// and a second copy here would be two owners of one mechanic. A *disabled* class is an + /// entry of [`Timestamp::MAX`]; suppressing the warning never suppresses the behavior — + /// turning off `sync_stale` does not turn off auto-sync. + pub suppressed: BTreeMap, +} + +impl NotifyInput { + /// Whether `class` is snoozed or disabled at `now`. + /// + /// An entry exactly at `now` has expired: suppression is `until`, exclusive, so a class + /// snoozed to `now` is due again at `now` — the same boundary convention as every other + /// threshold in this module. + #[must_use] + pub fn is_suppressed(&self, class: AlertClass, now: Timestamp) -> bool { + self.suppressed + .get(&class) + .is_some_and(|until| *until > now) + } +} + +/// What the client knows about its own sync progress. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct SyncFacts { + /// When the last **completed** sync finished. This is the epoch the two-week deadline is + /// measured from, and the instant at which a client re-arms the timer. + pub last_completed_sync: Timestamp, + /// Changes still waiting to reach the server — including originals still pending under a + /// staged upload policy. Zero means nothing is behind, so nothing is stale. + pub unsynced_changes: u64, +} + +/// The recovery-verification cadence, flattened to the three facts the predicate needs. +/// +/// The 7 d → 90 d → 180 d ladder, its re-arm triggers, and its snooze accounting stay owned by +/// the cadence scheduler; this module consumes the already-computed `next_due` and never +/// recomputes the ladder. `capsule-sdk` depends on this crate and not the reverse, so the +/// scheduler cannot be named here — the SDK projects into this struct instead. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecoveryFacts { + /// When the next verification prompt becomes due. + pub next_due: Timestamp, + /// When an active snooze expires, if one is active. + pub snoozed_until: Option, + /// Whether the consecutive-snooze budget is spent. When it is, the class has degraded to a + /// persistent, non-blocking badge: it is still reported (a client cannot render a badge for + /// a condition it was not told about), but it is no longer pre-armed as a notification — + /// the badge never escalates back into an alert on its own. + pub snooze_budget_spent: bool, +} + +/// The last quota answer the client holds. +/// +/// A one-field struct on purpose: the wire response carries no `over_since` and no grace +/// deadline, which is exactly why the quota classes are not pre-armable. When it grows one, the +/// deadline field lands here without changing [`NotifyInput`]'s shape. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuotaFacts { + /// The state the server reported. + pub state: QuotaAdvisory, +} + +/// The quota state, as `GET /v1/quota` reports it. +/// +/// Mirrors the SSoT's state table one-for-one rather than collapsing it, so a client can hand +/// the server's answer straight through. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum QuotaAdvisory { + /// `used < soft_limit`. All uploads succeed normally. + Ok, + /// `soft_limit <= used < hard_limit`. Uploads succeed; the client warns. + SoftWarning, + /// `used >= hard_limit`. New uploads are rejected at session creation; the grace window is + /// counting. + HardExceeded, + /// Over the hard limit for longer than the grace window. Additive to + /// [`HardExceeded`](Self::HardExceeded): metadata-growth writes are refused too. + GraceExpired, + /// An admin or billing action, not a threshold. Server-defined and owned by moderation, so + /// it raises no quota alert class here. + Suspended, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ts(secs: i64) -> Timestamp { + Timestamp::from_second(secs).unwrap() + } + + /// A blank client has learned nothing, and nothing is suppressed. + #[test] + fn default_input_suppresses_nothing() { + let input = NotifyInput::default(); + assert_eq!(input.sync, None); + assert_eq!(input.recovery, None); + assert_eq!(input.quota, None); + assert_eq!(input.quarantine_pending, 0); + assert_eq!(input.drops_pending, 0); + for class in AlertClass::ALL { + assert!(!input.is_suppressed(class, ts(0))); + } + } + + /// Suppression is `until`, exclusive: before / at / after the instant. + #[test] + fn suppression_boundary_is_exclusive() { + let mut input = NotifyInput::default(); + input.suppressed.insert(AlertClass::SyncStale, ts(1_000)); + + assert!(input.is_suppressed(AlertClass::SyncStale, ts(999))); + assert!(!input.is_suppressed(AlertClass::SyncStale, ts(1_000))); + assert!(!input.is_suppressed(AlertClass::SyncStale, ts(1_001))); + // Suppression is per class and never leaks to a neighbour. + assert!(!input.is_suppressed(AlertClass::RecoveryCheckDue, ts(999))); + } + + /// A disabled class is an entry at the far end of the representable range. + #[test] + fn disabled_is_suppressed_forever() { + let mut input = NotifyInput::default(); + input + .suppressed + .insert(AlertClass::DropPending, Timestamp::MAX); + assert!(input.is_suppressed(AlertClass::DropPending, ts(1_700_000_000))); + } + + /// `#[serde(default)]` means a client may send only the fields it has. + #[test] + fn partial_json_deserializes_to_defaults() { + let input: NotifyInput = serde_json::from_str(r#"{"drops_pending":3}"#).unwrap(); + assert_eq!(input.drops_pending, 3); + assert_eq!( + input, + NotifyInput { + drops_pending: 3, + ..NotifyInput::default() + } + ); + } + + /// The quota states are a closed enum with stable wire names. + #[test] + fn quota_advisory_wire_names() { + for (state, name) in [ + (QuotaAdvisory::Ok, "ok"), + (QuotaAdvisory::SoftWarning, "soft_warning"), + (QuotaAdvisory::HardExceeded, "hard_exceeded"), + (QuotaAdvisory::GraceExpired, "grace_expired"), + (QuotaAdvisory::Suspended, "suspended"), + ] { + assert_eq!( + serde_json::to_string(&state).unwrap(), + format!("\"{name}\"") + ); + } + assert!(serde_json::from_str::("\"over\"").is_err()); + } +} diff --git a/capsule-core/src/notify/mod.rs b/capsule-core/src/notify/mod.rs new file mode 100644 index 00000000..2693c42d --- /dev/null +++ b/capsule-core/src/notify/mod.rs @@ -0,0 +1,71 @@ +//! Alert classes and their trigger predicates — the **one shared decision function** every +//! platform evaluates instead of reimplementing the taxonomy (slice `S-D29`, core half; SSoT: +//! [Notifications]). +//! +//! # The surface +//! +//! [`evaluate()`] turns a snapshot of device-held state ([`NotifyInput`]) into the [`Alert`]s +//! that are true at an instant. [`next_deadline()`] returns the single instant an OS timer must +//! be armed for, or `None` when there is nothing to arm. +//! +//! Both are **pure**: no clock read, no socket, no SQLite, no `unsafe`, and no allocation beyond +//! the returned vector. `now` is always an argument, so the whole surface is driven by a mocked +//! clock in tests with no sleeps and no I/O — the same discipline as the recovery cadence whose +//! projection it consumes ([`RecoveryFacts`]). +//! +//! # Why every input is caller-supplied +//! +//! Nothing in this crate holds the trigger state. There is no `last_completed_sync` column, no +//! client-side quota type (server-held, and only as current as the last `GET /v1/quota`), and no +//! persisted quarantine table — a refused sync entry is a per-entry verdict +//! ([`crate::lifecycle::SyncApplyOutcome`]), not a row. Pending drops live in the provisioning +//! user's server-side inbox. So the predicate cannot read its own inputs; it takes them, which +//! is also what keeps it pure. +//! +//! [`NotifyInput`] therefore carries **counts and instants only** — no album ids, no titles, no +//! asset ids, nothing a server could author. That is forced rather than chosen: alert text is +//! composed on-device from decrypted state, and a key-free server has no plaintext to compose +//! from. +//! +//! # Delivery is not here +//! +//! This module decides *which classes are true* and *when to arm*. Presentation — a scheduled +//! `UNCalendarNotificationTrigger`, a `NotificationManagerCompat` post, an in-app badge — is +//! native per client, as is the permission prompt (asked the first time a class has something to +//! say, never at launch). The `notification.*` catalog keys land with the implementing client +//! slice, because the i18n guard requires a live consumer; this module emits a class and its +//! parameters, never a string a user reads. +//! +//! # Pre-arming, and what it costs +//! +//! An alert whose trigger is a deadline the device can compute MUST be pre-armed at the moment +//! that deadline becomes known, not evaluated when it expires — otherwise the staleness alert is +//! starved by the very absence of background windows it exists to report. +//! +//! The consequence for this module is the reason [`next_deadline()`] is narrower than +//! [`evaluate()`]: an armed OS notification fires **without the app running**, so it cannot be +//! re-checked at fire time. A deadline is therefore only returned when the alert is certain to be +//! true when it arrives — see [`AlertClass::pre_armable`] for which classes can be armed at all, +//! and [`next_deadline()`] for the two conditions that withhold a deadline from a pre-armable +//! class. +//! +//! Because the answer is a pure function of state, "re-arm on every state change that moves the +//! deadline" reduces on the client to: recompute after any state change, then cancel-and-arm if +//! the value changed. One deadline per class from one function is also why two live timers for +//! one class is structurally impossible. +//! +//! # Determinism +//! +//! [`Alert::params`] is a [`BTreeMap`](std::collections::BTreeMap), and [`evaluate()`] emits in +//! [`AlertClass::ALL`] order. Two calls on equal input are equal, byte-for-byte, through +//! `serde`. +//! +//! [Notifications]: https://docs/design/notifications/ + +pub(crate) mod class; +pub(crate) mod evaluate; +pub(crate) mod input; + +pub use class::{Alert, AlertClass, AlertSeverity}; +pub use evaluate::{DAY_SECS, SYNC_STALE_SECS, evaluate, next_deadline}; +pub use input::{NotifyInput, QuotaAdvisory, QuotaFacts, RecoveryFacts, SyncFacts}; From 1a265659230bdc3d4648f40df2e98f97d44f6eee Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 22:49:15 -0400 Subject: [PATCH 047/243] fix(sdk): reach the escrow the contract serves, through the generated client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `capsule_sdk::recovery` built `{api_root}/backup/escrow` from a `const` and sent it with hand-written `reqwest` calls. The committed Kynos document serves `GET`/`PUT /v1/auth/escrow`, so every networked recovery flow — enroll, the stale-cache refresh, and the guided re-wrap's escrow replace — failed against a real server while S-D12 read `done`. The route is not fixed by editing the constant. Both operations are `application/octet-stream` in each direction, which is a media type spargen lowers, so both are already generated and neither is narrowed out in `build.rs`; `AGENTS.md` requires that everything which parses or serializes is generated, the byte-serving endpoints included. `RecoveryClient` now holds one `AuthenticatedClient` and orchestrates `fetch_escrow`/`store_escrow`, so the path is a function of the document and cannot drift again. What the move changes: - `RecoveryClient::new` is fallible (`RecoveryError::InvalidBaseUrl`) — the generated client parses its base once at construction rather than per call. The two FFI callers each grow a `?`. - `RecoveryError` drops `Body(reqwest::Error)` and `Auth(AuthError)`, which nothing can construct once the reqwest path is gone, and gains `Transport`, `Unauthorized`, `Malformed` and `InvalidBaseUrl`, plus an `error_code()` returning the stable `error.escrow.*`/`error.auth.*` code a client localizes. - A refused credential keeps its auth identity across the FFI boundary: `Unauthorized` maps to `FfiError::Auth`, where a failed refresh used to arrive as `RecoveryError::Auth`. A request-construction failure maps there too — these operations take no parameters and their base URL is already parsed, so the only way either fails before a byte leaves is the bearer provider, and a dead session must reach a caller as one rather than as a transport blip. Both in-repo mocks were answering whichever path they were handed, which is why the wrong route survived. They now route on `/v1/auth/escrow` and answer `501` elsewhere, so a route regression fails loudly instead of reading as "no escrow stored", and their refusals carry real RFC 9457 bodies because a generated operation decodes them. The proof the old tests could not give is a new case in `capsule-server/tests/sdk_client.rs`: the SDK stores and fetches a real wrap over a socket against the assembled router, and asserts the bytes come back byte-identical and still open under the recovery secret. Refs #408 --- capsule-sdk/src/ffi.rs | 28 +- capsule-sdk/src/ffi/tests.rs | 37 ++- capsule-sdk/src/recovery/mod.rs | 486 +++++++++++++++++++++++------ capsule-server/tests/sdk_client.rs | 56 ++++ 4 files changed, 497 insertions(+), 110 deletions(-) diff --git a/capsule-sdk/src/ffi.rs b/capsule-sdk/src/ffi.rs index a3cd9a63..7ba6c04d 100644 --- a/capsule-sdk/src/ffi.rs +++ b/capsule-sdk/src/ffi.rs @@ -131,10 +131,22 @@ impl From for FfiError { impl From for FfiError { fn from(err: RecoveryError) -> Self { - // An auth failure under an escrow call keeps its auth identity so callers can trigger - // interactive re-authentication, exactly as the upload mapping does. - if let RecoveryError::Auth(auth) = err { - return auth.into(); + // A refused credential under an escrow call keeps its auth identity so callers can + // trigger interactive re-authentication, exactly as the upload mapping does. Before + // the escrow calls moved onto the generated client this arrived as + // `RecoveryError::Auth`; routing `Unauthorized` here is what stops the move from + // silently downgrading "sign in again" into "escrow failed". + if let RecoveryError::Unauthorized { code, detail } = err { + return Self::Auth { + code, + message: detail, + }; + } + // A malformed argument is the caller's, not the escrow surface's. + if let RecoveryError::InvalidBaseUrl { .. } = err { + return Self::InvalidArgument { + message: err.to_string(), + }; } Self::Escrow { message: err.to_string(), @@ -758,7 +770,7 @@ impl FfiSession { Ok(page.into()) } - /// Store or replace this account's **master-key escrow blob** (`PUT /backup/escrow`). + /// Store or replace this account's **master-key escrow blob** (`PUT /v1/auth/escrow`). /// `blob` is the opaque canonical CBOR /// [`FfiWorkspace::escrow_blob`](FfiWorkspace::escrow_blob) minted — the master key itself /// never crosses this boundary in either direction. @@ -773,17 +785,17 @@ impl FfiSession { capsule_core::cbor::from_slice(&blob).map_err(|e| FfiError::InvalidArgument { message: format!("escrow blob is not a canonical WrappedSecret: {e}"), })?; - RecoveryClient::new(self.session.clone(), &api_base_url) + RecoveryClient::new(self.session.clone(), &api_base_url)? .store_escrow(&blob) .await?; Ok(()) } - /// Fetch this account's escrow blob (`GET /backup/escrow`) as opaque canonical CBOR — the + /// Fetch this account's escrow blob (`GET /v1/auth/escrow`) as opaque canonical CBOR — the /// bytes [`FfiWorkspace::verify_escrow_blob`](FfiWorkspace::verify_escrow_blob) checks and /// a recovery flow unwraps. Fails with an `Escrow` error when no escrow is enrolled yet. pub async fn escrow_get(&self, api_base_url: String) -> Result, FfiError> { - let cache = RecoveryClient::new(self.session.clone(), &api_base_url) + let cache = RecoveryClient::new(self.session.clone(), &api_base_url)? .fetch_escrow() .await?; capsule_core::cbor::to_canonical_vec(cache.blob()).map_err(|e| FfiError::Escrow { diff --git a/capsule-sdk/src/ffi/tests.rs b/capsule-sdk/src/ffi/tests.rs index 4cf8612f..316a0bb6 100644 --- a/capsule-sdk/src/ffi/tests.rs +++ b/capsule-sdk/src/ffi/tests.rs @@ -294,6 +294,12 @@ fn enroll(root: &std::path::Path) -> Arc { /// The escrow endpoints are **stateful** — a `PUT` stores the bytes verbatim and a `GET` /// serves them back, exactly as the single-active-escrow contract says — so `escrow_put` /// and `escrow_get` can be asserted as a real round trip rather than two isolated calls. +/// +/// They are served on `/v1/auth/escrow` under the API root, which is the path the committed +/// document declares and the generated client therefore requests. The `PUT` answers a JSON +/// `StoreEscrowResponse` and the empty-escrow `GET` answers an RFC 9457 problem, because a +/// generated operation decodes both — a bare `204` or a body-less `404` would arrive as a +/// decode failure rather than as the typed outcome this test is asserting. async fn flow_server() -> MockServer { let escrow: Arc>> = Arc::new(std::sync::Mutex::new(Vec::new())); MockServer::start( @@ -310,17 +316,36 @@ async fn flow_server() -> MockServer { .to_string(), ), ("PATCH", "/upload/sess-1") => MockResponse::new(200, "OK"), - ("PUT", "/api/backup/escrow") => { - if let Ok(mut stored) = escrow.lock() { + ("PUT", "/api/v1/auth/escrow") => { + let replaced = if let Ok(mut stored) = escrow.lock() { + let replaced = !stored.is_empty(); stored.clone_from(&req.body); - } - MockResponse::new(204, "No Content") + replaced + } else { + false + }; + MockResponse::new(200, "OK").json_body( + serde_json::json!({ + "stored_at": "2026-01-01T00:00:00Z", + "replaced": replaced, + }) + .to_string(), + ) } - ("GET", "/api/backup/escrow") => { + ("GET", "/api/v1/auth/escrow") => { let stored = escrow.lock().map(|s| s.clone()).unwrap_or_default(); if stored.is_empty() { // Nothing enrolled yet — the typed `NotEnrolled` path. - MockResponse::new(404, "Not Found") + MockResponse::new(404, "Not Found").json_body( + serde_json::json!({ + "type": "about:blank", + "title": "Not found", + "status": 404, + "detail": "no escrow has been stored for this account", + "code": "error.escrow.not_stored", + }) + .to_string(), + ) } else { let mut response = MockResponse::new(200, "OK") .header("Content-Type", "application/octet-stream"); diff --git a/capsule-sdk/src/recovery/mod.rs b/capsule-sdk/src/recovery/mod.rs index 8ae4c232..1f106df0 100644 --- a/capsule-sdk/src/recovery/mod.rs +++ b/capsule-sdk/src/recovery/mod.rs @@ -3,7 +3,7 @@ //! Re-Wrap]). //! //! This module owns the **client half** of the master-key recovery story that the -//! server escrow surface (slice `S-C12`, `PUT`/`GET /backup/escrow`) and the core +//! server escrow surface (slice `S-C12`, `PUT`/`GET /v1/auth/escrow`) and the core //! crypto ([`capsule_core::backup`]) make possible. It has two cohesive halves: //! //! - **[`cadence`]** — the pure, network-free scheduler and prompt state machine @@ -19,11 +19,27 @@ //! [`WrappedSecret`] — byte-identical to what the server stores verbatim and to what the //! core restore path unwraps. //! +//! # The wire is the generated client, not a path this module builds +//! +//! Both escrow operations are `application/octet-stream` in each direction, which is a media +//! type `spargen` lowers, so both are **generated** and neither is narrowed out in +//! `build.rs`. [`RecoveryClient`] therefore orchestrates +//! [`AuthenticatedClient::fetch_escrow`](crate::rest::Client::fetch_escrow) and +//! [`store_escrow`](crate::rest::Client::store_escrow) and hand-writes no request. That is +//! not a preference: `AGENTS.md` requires that everything which parses or serializes is +//! generated, *including* the byte-serving endpoints, and the reason is this module's own +//! history. It used to build `{api_root}/backup/escrow` from a `const` — the Salvo document's +//! path — and when the contract was re-sourced from Kynos to `/v1/auth/escrow` nothing +//! noticed, because a route in a string constant is checked by no gate and this module's own +//! mock answered whichever path it was handed. +//! //! [Backup — Recovery Verification Cadence]: https://docs/design/backup-recovery/#recovery-verification-cadence //! [§ On Repeated Failure: Guided Re-Wrap]: https://docs/design/backup-recovery/#on-repeated-failure-guided-re-wrap pub mod cadence; +use std::sync::Arc; + pub use cadence::{ BACKOFF_INTERVAL_SECS, CAP_INTERVAL_SECS, INITIAL_INTERVAL_SECS, MAX_CONSECUTIVE_SNOOZES, REWRAP_FAILURE_THRESHOLD, REWRAP_MIN_SESSIONS, RearmTrigger, RecoveryCadence, SnoozeDuration, @@ -33,26 +49,53 @@ use capsule_core::backup::{VerifyOutcome, split_seed_2of3, verify_recovery_secre use capsule_core::crypto::primitives::{Argon2Params, DeviceTier}; use capsule_core::crypto::pwkdf::{self, WrappedSecret}; use capsule_core::crypto::rng; +use capsule_i18n::error_codes; use tracing::instrument; -use crate::auth::{AuthError, Session}; - -/// The escrow endpoint path, appended to the caller's API base. -const ESCROW_PATH: &str = "backup/escrow"; +use crate::auth::Session; +use crate::client::{AuthenticatedClient, ClientError}; +use crate::rest; /// Everything the networked recovery flows can fail with. Callers switch on the typed -/// variant, never a bare HTTP status. +/// variant (or its stable `error.*` code), never a bare HTTP status. #[derive(Debug, thiserror::Error)] pub enum RecoveryError { - /// The authenticated request itself failed (transport, session expiry, refresh). - #[error(transparent)] - Auth(#[from] AuthError), - /// Reading the escrow response body off the wire failed. - #[error("reading escrow response body failed: {0}")] - Body(#[source] reqwest::Error), + // There is deliberately no `Auth(AuthError)` variant any more. The session used to build + // these requests itself, so a dead session surfaced as its own typed `AuthError`; the + // generated client attaches the bearer through a token-provider seam that flattens our + // `AuthError` to a string, so the same event now arrives as `Unauthorized` — which is + // where the FFI's `Auth` mapping reads it from. An unconstructible variant would be a + // promise no code path can keep. + /// The API root is not a URL the generated client can hang operation paths off. + #[error("invalid base URL {url:?}: {reason}")] + InvalidBaseUrl { + /// The offending URL. + url: String, + /// Why the generated client rejected it. + reason: String, + }, + /// The call did not complete: DNS, TLS, timeout, a malformed response, or the store + /// answering `500`. Transient — the cadence's next tick tries again. + #[error("the escrow endpoint could not be reached: {0}")] + Transport(String), + /// The credential was refused (`401`/`403`) and a refresh did not recover it. The stable + /// code distinguishes an expired session from the outage the revocation ledger also + /// renders as `401`, so a client can tell "sign in again" from "try later". + #[error("the escrow endpoint refused the credential: {detail}")] + Unauthorized { + /// The stable `error.*` catalog code the problem body carried, when it had one. + code: Option, + /// English detail from the problem body. + detail: String, + }, /// The caller has no escrow stored yet (server returned `404`). Enroll one first. #[error("no escrow stored for this account")] NotEnrolled, + /// The server refused the blob as one that cannot be an escrow at any version — empty, + /// past the coarse ceiling (`400`), or not the declared media type (`415`). Retrying the + /// same bytes changes nothing. + #[error("the server rejected the escrow blob as malformed: {0}")] + Malformed(String), /// The escrow bytes could not be (de)serialized as the canonical `WrappedSecret`. #[error("escrow blob codec error: {0}")] Codec(String), @@ -67,6 +110,20 @@ pub enum RecoveryError { }, } +impl RecoveryError { + /// The stable `error.*` catalog code a client localizes, when one applies. The English + /// [`Display`](std::fmt::Display) form stays the developer/log detail. + #[must_use] + pub fn error_code(&self) -> Option<&str> { + match self { + Self::Unauthorized { code, .. } => code.as_deref(), + Self::NotEnrolled => Some(error_codes::ESCROW_NOT_STORED), + Self::Malformed(_) => Some(error_codes::ESCROW_MALFORMED), + _ => None, + } + } +} + /// A client-side cached copy of the server escrow blob. /// /// Fetched at enrollment and refreshed opportunistically (SSoT § Local Verification); @@ -208,68 +265,75 @@ pub struct GuidedRewrap { } /// The networked recovery client: escrow cache/refresh, stale-cache-aware local -/// verification, and the guided re-wrap. It borrows an authenticated [`Session`], so -/// every call rides the SDK's bearer/refresh machinery. +/// verification, and the guided re-wrap. +/// +/// It holds one [`AuthenticatedClient`], so every call rides the generated operation paths +/// and the SDK's bearer/refresh machinery, and this module states no route of its own. The +/// client is behind an [`Arc`] only so [`RecoveryClient`] stays [`Clone`] — the cadence hands +/// one client to several prompts. #[derive(Clone)] pub struct RecoveryClient { - session: Session, - escrow_url: String, + client: Arc, } impl RecoveryClient { - /// Build a recovery client against the API base URL (the same base the auth session - /// authenticates against, e.g. `https://api.example.com`). - #[must_use] - pub fn new(session: Session, api_base_url: &str) -> Self { - let escrow_url = format!("{}/{ESCROW_PATH}", api_base_url.trim_end_matches('/')); - Self { - session, - escrow_url, - } + /// Build a recovery client against the **API root** — the origin the generated operation + /// paths hang off (e.g. `https://api.example.com`), which is the same base + /// [`AuthenticatedClient`] and [`crate::sync::SyncConsumer`] take. + /// + /// # Errors + /// + /// [`RecoveryError::InvalidBaseUrl`] when `api_base_url` is not a URL operation paths can + /// hang off. Fallible where the old hand-written `format!` was not, because the generated + /// client parses the base once at construction rather than per call. + pub fn new(session: Session, api_base_url: &str) -> Result { + let client = + AuthenticatedClient::new(api_base_url, session).map_err(|error| match error { + ClientError::InvalidBaseUrl { url, reason } => { + RecoveryError::InvalidBaseUrl { url, reason } + } + })?; + Ok(Self { + client: Arc::new(client), + }) } - /// Fetch the current escrow blob from the server (`GET /backup/escrow`) into a fresh + /// Fetch the current escrow blob from the server (`GET /v1/auth/escrow`) into a fresh /// [`EscrowCache`]. `404` maps to [`RecoveryError::NotEnrolled`]. #[instrument(skip_all)] pub async fn fetch_escrow(&self) -> Result { - let response = self.session.execute(|c| c.get(&self.escrow_url)).await?; - let status = response.status(); - match status { - reqwest::StatusCode::OK => { - let bytes = response.bytes().await.map_err(RecoveryError::Body)?; - tracing::debug!(len = bytes.len(), "fetched escrow blob"); - EscrowCache::from_wire(&bytes) - } - reqwest::StatusCode::NOT_FOUND => Err(RecoveryError::NotEnrolled), - other => Err(RecoveryError::Unexpected { - status: other.as_u16(), - }), - } + let bytes = self + .client + .fetch_escrow() + .await + .map_err(fetch_escrow_error)? + .into_inner(); + tracing::debug!(len = bytes.len(), "fetched escrow blob"); + EscrowCache::from_wire(&bytes) } - /// Store or replace the caller's escrow blob (`PUT /backup/escrow`). Single active + /// Store or replace the caller's escrow blob (`PUT /v1/auth/escrow`). Single active /// escrow: the server overwrites any prior blob in the same transaction (S-C12). + /// + /// The canonical CBOR goes on the wire verbatim; `replaced` and `stored_at` are logged + /// rather than returned, because no caller has asked for them yet and a return type is + /// harder to widen than a log line. #[instrument(skip_all)] pub async fn store_escrow(&self, blob: &WrappedSecret) -> Result<(), RecoveryError> { let body = capsule_core::cbor::to_canonical_vec(blob) .map_err(|e| RecoveryError::Codec(e.to_string()))?; - let response = self - .session - .execute(|c| { - c.put(&self.escrow_url) - .header(reqwest::header::CONTENT_TYPE, "application/octet-stream") - .body(body.clone()) - }) - .await?; - let status = response.status(); - if status.is_success() { - tracing::info!("escrow blob stored (single active escrow: any prior blob replaced)"); - Ok(()) - } else { - Err(RecoveryError::Unexpected { - status: status.as_u16(), - }) - } + let stored = self + .client + .store_escrow(&rest::types::RequestBody::from(body)) + .await + .map_err(store_escrow_error)? + .into_inner(); + tracing::info!( + stored_at = %stored.stored_at, + replaced = stored.replaced, + "escrow blob stored (single active escrow: any prior blob replaced)" + ); + Ok(()) } /// Local recovery-secret verification with the **stale-cache rule** (SSoT § Local @@ -367,6 +431,111 @@ impl RecoveryClient { } } +/// Map a `GET /v1/auth/escrow` refusal onto its typed variant. +/// +/// Kept as one readable status table rather than a match buried in the request path, and kept +/// exhaustive over the generated enum so a status the document gains cannot be silently +/// swallowed — adding one stops the build here. +fn fetch_escrow_error(error: rest::Error) -> RecoveryError { + match error { + rest::Error::Api(response) => match response.into_inner() { + rest::FetchEscrowError::Status404(_) => RecoveryError::NotEnrolled, + rest::FetchEscrowError::Status401(problem) + | rest::FetchEscrowError::Status403(problem) => refused(&problem), + rest::FetchEscrowError::Status500(problem) => transport(&problem), + // Declared by the transport backstop and unreachable on a body-less `GET`; kept + // honest rather than folded into a class it does not belong to. + rest::FetchEscrowError::Status413 => RecoveryError::Unexpected { status: 413 }, + }, + other => wire_error(&other), + } +} + +/// Map a `PUT /v1/auth/escrow` refusal onto its typed variant. +fn store_escrow_error(error: rest::Error) -> RecoveryError { + match error { + rest::Error::Api(response) => match response.into_inner() { + // `400` and `415` are the same answer to the caller: these bytes are not an + // escrow, and sending them again will not help. + rest::StoreEscrowError::Status400(problem) + | rest::StoreEscrowError::Status415(problem) => { + RecoveryError::Malformed(detail(&problem)) + } + rest::StoreEscrowError::Status401(problem) + | rest::StoreEscrowError::Status403(problem) => refused(&problem), + rest::StoreEscrowError::Status500(problem) => transport(&problem), + // The body-size backstop carries no problem body at all, so the message is ours. + rest::StoreEscrowError::Status413 => RecoveryError::Malformed( + "the escrow blob exceeds the server's request-body limit".to_owned(), + ), + }, + other => wire_error(&other), + } +} + +/// A refused credential, carrying the problem body's stable code. +fn refused(problem: &rest::types::CodedProblem) -> RecoveryError { + RecoveryError::Unauthorized { + code: Some(problem.code.clone()), + detail: detail(problem), + } +} + +/// The store could not answer — transient, and the caller's cadence retries. +fn transport(problem: &rest::types::CodedProblem) -> RecoveryError { + RecoveryError::Transport(detail(problem)) +} + +/// The problem body's English detail, or its code when the server sent no detail. +fn detail(problem: &rest::types::CodedProblem) -> String { + problem + .detail + .clone() + .unwrap_or_else(|| problem.code.clone()) +} + +/// Map the generated client's non-`Api` taxonomy classes: an undocumented status keeps its +/// number, and everything else is a wire failure with its source chain preserved. +fn wire_error(error: &rest::Error) -> RecoveryError +where + E: std::error::Error + 'static, +{ + match error { + rest::Error::UnexpectedStatus { status, .. } => RecoveryError::Unexpected { + status: status.as_u16(), + }, + // Both escrow operations take no path parameter, no query parameter and (for the + // store) a body that cannot fail to serialize, and the base URL was parsed when the + // client was built. So the *only* way either can fail before a byte leaves is the + // bearer credential's async provider — a session that cannot produce a token. That is + // the same event the server answers `401` for, and it must reach a caller as one: + // reporting a dead session as a transport blip would tell a client to retry where it + // needs to re-authenticate. + rest::Error::RequestConstruction(_) => RecoveryError::Unauthorized { + code: None, + detail: describe(error), + }, + other => RecoveryError::Transport(describe(other)), + } +} + +/// Render a generated-client failure together with its source chain. The taxonomy's own +/// `Display` is a one-word class name (`"transport failed"`), which on its own tells a log +/// reader nothing about *what* failed. +fn describe(error: &rest::Error) -> String +where + E: std::error::Error + 'static, +{ + let mut rendered = error.to_string(); + let mut source = std::error::Error::source(error); + while let Some(cause) = source { + rendered.push_str(": "); + rendered.push_str(&cause.to_string()); + source = cause.source(); + } + rendered +} + #[cfg(test)] mod tests { use std::future::Future; @@ -392,9 +561,19 @@ mod tests { // ── Binary-capable mock escrow server ───────────────────────────────────── // - // The escrow surface is `application/octet-stream` in both directions, so unlike the - // auth mock (JSON strings) this one stores and serves raw bytes. A single shared - // slot models the server's single-active-escrow row. + // The escrow surface is `application/octet-stream` on the way out, so unlike the auth + // mock (JSON strings) this one stores and serves raw bytes. A single shared slot models + // the server's single-active-escrow row. + // + // Two things it must now do that it did not have to when this module built its own URL. + // It **routes on the path**, answering `501` to anything that is not `/v1/auth/escrow`, + // because a mock that replies to whatever it is handed is exactly why a wrong route + // survived here. And its refusals carry a real RFC 9457 problem body: the generated + // client decodes a documented non-success status into `CodedProblem`, so a bare status + // with an empty body would arrive as a decode failure rather than the typed variant. + + /// The one path the escrow operations are served on, per the committed document. + const ESCROW_ROUTE: &str = "/v1/auth/escrow"; #[derive(Clone, Default)] struct EscrowStore { @@ -403,14 +582,54 @@ mod tests { struct MockRequest { method: String, + path: String, body: Vec, } struct MockResponse { status: u16, + content_type: &'static str, body: Vec, } + impl MockResponse { + /// An `application/octet-stream` payload — the escrow itself. + fn bytes(status: u16, body: Vec) -> Self { + Self { + status, + content_type: "application/octet-stream", + body, + } + } + + /// A JSON payload — `StoreEscrowResponse`, which the generated client decodes. + fn json(status: u16, body: serde_json::Value) -> Self { + Self { + status, + content_type: "application/json", + body: body.to_string().into_bytes(), + } + } + + /// An RFC 9457 problem, shaped as `CodedProblem` so the generated client can parse it + /// into the operation's typed error. + fn problem(status: u16, code: &str, detail: &str) -> Self { + Self { + status, + content_type: "application/problem+json", + body: serde_json::json!({ + "type": "about:blank", + "title": "Refused", + "status": status, + "detail": detail, + "code": code, + }) + .to_string() + .into_bytes(), + } + } + } + type BoxFut = Pin + Send>>; type Handler = Arc BoxFut + Send + Sync>; @@ -452,11 +671,9 @@ mod tests { let head = String::from_utf8_lossy(&buf[..header_end]).to_string(); let mut lines = head.split("\r\n"); let request_line = lines.next().unwrap_or_default(); - let method = request_line - .split_whitespace() - .next() - .unwrap_or_default() - .to_string(); + let mut request_parts = request_line.split_whitespace(); + let method = request_parts.next().unwrap_or_default().to_string(); + let path = request_parts.next().unwrap_or_default().to_string(); let mut content_length = 0usize; let mut authorized = false; @@ -484,17 +701,15 @@ mod tests { // Every escrow call is owner-scoped: reject anything without a bearer token. let response = if authorized { - handler(MockRequest { method, body }).await + handler(MockRequest { method, path, body }).await } else { - MockResponse { - status: 401, - body: Vec::new(), - } + MockResponse::problem(401, "error.auth.unauthorized", "no bearer credential") }; let payload = format!( - "HTTP/1.1 {} STATUS\r\ncontent-type: application/octet-stream\r\ncontent-length: {}\r\nconnection: close\r\n\r\n", + "HTTP/1.1 {} STATUS\r\ncontent-type: {}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n", response.status, + response.content_type, response.body.len() ); let mut out = payload.into_bytes(); @@ -505,33 +720,42 @@ mod tests { } /// A handler backed by the shared single-active-escrow slot: `PUT` overwrites it, - /// `GET` serves it verbatim (or 404). + /// `GET` serves it verbatim (or `404`). + /// + /// Anything off `/v1/auth/escrow` is `501`, which arrives as + /// [`RecoveryError::Unexpected`] rather than as a plausible-looking `NotEnrolled` — so a + /// route regression fails loudly here instead of reading as "no escrow stored". fn escrow_handler(store: EscrowStore) -> Handler { Arc::new(move |req| { let store = store.clone(); Box::pin(async move { + if req.path != ESCROW_ROUTE { + return MockResponse::bytes(501, Vec::new()); + } match req.method.as_str() { "PUT" => { - *store.blob.lock().unwrap() = Some(req.body); - MockResponse { - status: 204, - body: Vec::new(), - } + let replaced = store.blob.lock().unwrap().replace(req.body).is_some(); + MockResponse::json( + 200, + serde_json::json!({ + "stored_at": "2026-01-01T00:00:00Z", + "replaced": replaced, + }), + ) } "GET" => match store.blob.lock().unwrap().clone() { - Some(bytes) => MockResponse { - status: 200, - body: bytes, - }, - None => MockResponse { - status: 404, - body: Vec::new(), - }, - }, - _ => MockResponse { - status: 405, - body: Vec::new(), + Some(bytes) => MockResponse::bytes(200, bytes), + None => MockResponse::problem( + 404, + error_codes::ESCROW_NOT_STORED, + "no escrow has been stored for this account", + ), }, + _ => MockResponse::problem( + 405, + error_codes::ESCROW_MALFORMED, + "the escrow surface serves GET and PUT", + ), } }) }) @@ -560,7 +784,7 @@ mod tests { async fn escrow_store_fetch_round_trip() { let store = EscrowStore::default(); let base = start_mock(escrow_handler(store)).await; - let client = RecoveryClient::new(session_for(&base), &base); + let client = RecoveryClient::new(session_for(&base), &base).unwrap(); let master = [0x11u8; 32]; let blob = wrap(&master, b"correct horse battery staple"); @@ -578,7 +802,7 @@ mod tests { #[tokio::test] async fn fetch_without_escrow_is_not_enrolled() { let base = start_mock(escrow_handler(EscrowStore::default())).await; - let client = RecoveryClient::new(session_for(&base), &base); + let client = RecoveryClient::new(session_for(&base), &base).unwrap(); assert!(matches!( client.fetch_escrow().await, Err(RecoveryError::NotEnrolled) @@ -591,7 +815,7 @@ mod tests { async fn verify_correct_secret_against_cache() { let store = EscrowStore::default(); let base = start_mock(escrow_handler(store)).await; - let client = RecoveryClient::new(session_for(&base), &base); + let client = RecoveryClient::new(session_for(&base), &base).unwrap(); let master = [0x22u8; 32]; let blob = wrap(&master, b"the-right-secret"); @@ -614,7 +838,7 @@ mod tests { async fn verify_refreshes_stale_cache_then_passes() { let store = EscrowStore::default(); let base = start_mock(escrow_handler(store.clone())).await; - let client = RecoveryClient::new(session_for(&base), &base); + let client = RecoveryClient::new(session_for(&base), &base).unwrap(); let master = [0x33u8; 32]; // Enroll and cache the OLD wrap. @@ -646,7 +870,7 @@ mod tests { async fn verify_wrong_secret_fails_after_refresh() { let store = EscrowStore::default(); let base = start_mock(escrow_handler(store)).await; - let client = RecoveryClient::new(session_for(&base), &base); + let client = RecoveryClient::new(session_for(&base), &base).unwrap(); let master = [0x44u8; 32]; let blob = wrap(&master, b"real-secret"); @@ -679,7 +903,7 @@ mod tests { let store = EscrowStore::default(); let base = start_mock(escrow_handler(store)).await; - let client = RecoveryClient::new(session_for(&base), &base); + let client = RecoveryClient::new(session_for(&base), &base).unwrap(); let master = [0x55u8; 32]; let old_secret = b"the-old-lost-secret"; @@ -741,7 +965,7 @@ mod tests { #[tokio::test] async fn guided_rewrap_no_shamir_when_not_enrolled() { let base = start_mock(escrow_handler(EscrowStore::default())).await; - let client = RecoveryClient::new(session_for(&base), &base); + let client = RecoveryClient::new(session_for(&base), &base).unwrap(); let master = [0x66u8; 32]; client.store_escrow(&wrap(&master, b"old")).await.unwrap(); @@ -752,6 +976,76 @@ mod tests { assert!(rewrap.shamir.is_none()); } + /// The mock's route guard is live: a client pointed one segment off the documented path + /// gets a loud `Unexpected`, not a plausible-looking `NotEnrolled`. + /// + /// This is the regression this slice exists for, asserted as a property of the *test + /// harness*: without it the mock would answer any path at all, and a wrong route would + /// once again read as "this account has escrowed nothing" — which is what let + /// `backup/escrow` survive a contract re-source. + #[tokio::test] + async fn a_call_off_the_documented_route_is_not_mistaken_for_an_empty_escrow() { + let base = start_mock(escrow_handler(EscrowStore::default())).await; + let client = + RecoveryClient::new(session_for(&base), &format!("{base}/not-the-contract")).unwrap(); + let error = client + .fetch_escrow() + .await + .expect_err("a path the server does not serve is not an empty escrow"); + assert!( + matches!(error, RecoveryError::Unexpected { status: 501 }), + "got {error:?}" + ); + } + + /// A `400` refusal becomes the typed `Malformed` and carries the code a client localizes + /// — not the `Unexpected { status }` the hand-written path used to collapse it into. + #[tokio::test] + async fn a_refused_blob_is_malformed_with_its_catalog_code() { + let handler: Handler = Arc::new(|_req| { + Box::pin(async move { + MockResponse::problem( + 400, + error_codes::ESCROW_MALFORMED, + "the escrow blob is empty", + ) + }) + }); + let base = start_mock(handler).await; + let client = RecoveryClient::new(session_for(&base), &base).unwrap(); + let error = client + .store_escrow(&wrap(&[0x77u8; 32], b"whatever")) + .await + .expect_err("the server refused the blob"); + assert!( + matches!(error, RecoveryError::Malformed(_)), + "got {error:?}" + ); + assert_eq!(error.error_code(), Some(error_codes::ESCROW_MALFORMED)); + } + + /// A refused credential keeps the problem body's `error.auth.*` code, so a client can + /// tell an expired session from the outage the revocation ledger also renders as `401`. + #[tokio::test] + async fn a_refused_credential_keeps_the_problem_code() { + // No bearer reaches the mock's handler at all: it answers `401` at the door, which is + // precisely the shape a revoked token produces. + let handler: Handler = Arc::new(|_req| { + Box::pin(async move { MockResponse::problem(401, "error.auth.expired", "expired") }) + }); + let base = start_mock(handler).await; + let client = RecoveryClient::new(session_for(&base), &base).unwrap(); + let error = client + .fetch_escrow() + .await + .expect_err("the credential was refused"); + assert!( + matches!(error, RecoveryError::Unauthorized { .. }), + "got {error:?}" + ); + assert_eq!(error.error_code(), Some("error.auth.expired")); + } + /// The minted secret clears the ≥128-bit entropy floor (256-bit) and never prints /// its material. #[test] diff --git a/capsule-server/tests/sdk_client.rs b/capsule-server/tests/sdk_client.rs index 8984f968..0b81a566 100644 --- a/capsule-server/tests/sdk_client.rs +++ b/capsule-server/tests/sdk_client.rs @@ -287,3 +287,59 @@ async fn the_sdk_completes_a_real_second_factor_over_a_socket() { .expect("the code completes the sign-in"); assert!(session.is_authenticated().await); } + +/// The escrow round trip, over a socket, against the router that actually serves it. +/// +/// This is the case the slice was missing. `capsule_sdk::recovery` used to build +/// `{api_root}/backup/escrow` by hand — the Salvo document's path — and its own in-module mock +/// answered whatever path it was handed, so every escrow test passed while no real server had +/// that route. Only a client pointed at the router can tell the difference, and the bytes are +/// the ones a KDF runs against: a wrap that comes back re-encoded is a lost master key. +#[tokio::test] +async fn the_sdk_stores_and_fetches_an_escrow_over_a_socket() { + use capsule_core::crypto::primitives::Argon2Params; + use capsule_core::crypto::pwkdf; + use capsule_sdk::recovery::{RecoveryClient, RecoveryError}; + + // Fast Argon2id params: the crypto is `capsule-core`'s and proven there; what is under test + // is the wire. + let params = Argon2Params { + mem_kib: 64, + t_cost: 1, + p_cost: 1, + }; + + let fixture = Fixture::working(); + let base_url = serve(&fixture).await; + let client = + RecoveryClient::new(session(&base_url).await, &base_url).expect("an API root parses"); + + // Nothing stored yet: the typed refusal a cadence reads as "enroll first", carrying the + // code a client localizes. + let missing = client + .fetch_escrow() + .await + .expect_err("a fresh account has escrowed nothing"); + assert!( + matches!(missing, RecoveryError::NotEnrolled), + "got {missing:?}" + ); + assert_eq!(missing.error_code(), Some("error.escrow.not_stored")); + + let master = [0x5Au8; 32]; + let blob = pwkdf::wrap_with(&master, b"correct horse battery staple", params) + .expect("the master key wraps"); + client.store_escrow(&blob).await.expect("the escrow stores"); + + let cache = client.fetch_escrow().await.expect("and comes back"); + assert_eq!( + cache.blob(), + &blob, + "the escrow is ciphertext served verbatim; a re-encoded wrap no longer opens" + ); + assert_eq!( + capsule_core::backup::recover_master_key(cache.blob(), b"correct horse battery staple") + .expect("the fetched wrap opens"), + master, + ); +} From 4d51bd88c3bf85755ed8aa360bc9af3b48742e75 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 22:49:18 -0400 Subject: [PATCH 048/243] feat(server): add the configuration and the composition root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `capsule-server` has never been assembled outside its own test fixture, so "every port has an adapter" and "the router builds from real ones" were claims rather than assertions. `config` reads the operator's settings once — command line over environment over default — and reports **every** fault in one message, because an operator otherwise restarts the process once per variable. What a subcommand requires is a parameter: `gc`/`purge`/`scrub` demand a blob root and deliberately no key material, so a maintenance host never needs the production token-signing key. `--config PATH` is accepted and refused, which keeps a configuration-file crate out of a domain the dependencies doc has no row for while leaving the precedence slot named. `boot::assemble` is the one composition root and the only place adapters are chosen. Selection is a two-arm match on `Backends`: `--memory` takes every deterministic in-crate adapter over a real filesystem blob store, and anything else refuses. That makes two sentences `store/mod.rs` has carried since `S-C29` true for the first time — Valkey is required, and the in-memory adapters are not a deployment profile — because until now there was no boot path to enforce either. The account ports and the second factor had no adapter at all, which would have left `register` and `login` answering their declared refusal on a development server. `auth::credential` is the Argon2id helper the Postgres adapter (#402) reuses; `auth::accounts_memory` is a real directory over it — PHC strings, the timing-equalized miss, a lockout that a password change clears — and `auth::totp` gains the deterministic store its port's three properties are all expressible over. None is the permissive credential double `tests/support/mod.rs` warns must never be linkable by a server. Refs #401, #402, #403 --- Cargo.lock | 3 + capsule-server/Cargo.toml | 38 +- capsule-server/src/auth/accounts_memory.rs | 616 ++++++++++++++ capsule-server/src/auth/credential.rs | 257 ++++++ capsule-server/src/auth/mod.rs | 30 +- capsule-server/src/auth/totp.rs | 250 +++++- capsule-server/src/boot.rs | 601 +++++++++++++ capsule-server/src/config.rs | 939 +++++++++++++++++++++ capsule-server/src/lib.rs | 10 + 9 files changed, 2727 insertions(+), 17 deletions(-) create mode 100644 capsule-server/src/auth/accounts_memory.rs create mode 100644 capsule-server/src/auth/credential.rs create mode 100644 capsule-server/src/boot.rs create mode 100644 capsule-server/src/config.rs diff --git a/Cargo.lock b/Cargo.lock index af416a67..575bda5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -748,6 +748,7 @@ dependencies = [ name = "capsule-server" version = "0.1.0" dependencies = [ + "argon2", "base64", "bytes", "capsule-core", @@ -765,10 +766,12 @@ dependencies = [ "serde", "serde_json", "subtle", + "tempfile", "thiserror 2.0.20", "tokio", "totp-rs", "tracing", + "tracing-subscriber", "uuid", ] diff --git a/capsule-server/Cargo.toml b/capsule-server/Cargo.toml index a79b4def..c60c08da 100644 --- a/capsule-server/Cargo.toml +++ b/capsule-server/Cargo.toml @@ -73,7 +73,18 @@ jsonwebtoken = { workspace = true } # the three features a ranged read and a durable append need, and nothing more. Already the # workspace's async runtime (design/dependencies.md, "Async runtime") and already in this # crate's tree through kynos — this promotes it from a dev-dependency, it does not add a crate. -tokio = { workspace = true, features = ["fs", "io-util", "rt"] } +# `rt-multi-thread` and `macros` on top of those three for the binary: `serve` runs an accept +# loop that has to make progress while a request is awaiting the disk, and `#[tokio::main]` is +# the attribute that starts it. Kynos's `server` feature already unifies `net`/`signal`/`rt` in; +# these two are named here rather than relied on through it, so a Kynos feature change cannot +# silently take the binary's runtime away. +tokio = { workspace = true, features = [ + "fs", + "io-util", + "rt", + "rt-multi-thread", + "macros", +] } # The `.reason.json` a quarantined blob keeps beside it (design/filesystem/server.md). The # encoding belongs to the filesystem *adapter*, not to the port's record — which derives no # serde traits, exactly as the state ports' records do not — so this is used in one file. @@ -106,13 +117,32 @@ totp-rs = { workspace = true } # design/dependencies.md. subtle = { workspace = true } -# `gen_openapi` only. Both are already in the lock file via `capsule-api`'s equivalent binary, -# and this mirrors it deliberately: two committed documents, two identical drift guards, so the -# changeover at parity is a re-point rather than a new mechanism. +# Argon2id for the development profile's account adapter (`auth::credential`, +# `auth::accounts_memory`). Already a workspace dependency (consumed by `capsule-core` for the +# escrow key wrap) and already pinned by the Argon2id row in design/cryptography/primitives.md, +# so this adds no crate and opens no new domain. It is the *server-side password verification* +# parameter set rather than the escrow KDF's tiered one — see `auth::credential` for why those +# are unrelated numbers. The Postgres adapter (#402) uses the same helper. +argon2 = { workspace = true } +# The `capsule-server` binary: subcommand parsing, and the error report a startup failure prints. +# `clap` and `color-eyre` were already here for the `gen_openapi` binary this replaces; the +# binary is now one `capsule-server` with `serve | gc | purge | scrub | gen-openapi` +# subcommands, for the reason `capsule-cli` is one binary — four executables would each carry +# their own copy of the config loader and the adapter seam. clap = { version = "4.6.1", features = ["derive"] } color-eyre = "0.6.5" +# The log stream the binary installs (`cli::install_tracing`). `env-filter` for `RUST_LOG` and +# `json` for the one-object-per-event rendering a log shipper wants; both are on in the workspace +# pin, and the crate already has a row in design/dependencies.md. Written to **stderr**, so +# `gen-openapi` and the operator commands keep a parseable stdout. +tracing-subscriber = { workspace = true } [dev-dependencies] +# `tests/binary.rs` gives the spawned server a blob root of its own, and deletes it afterwards. +# Already the workspace's scratch-directory crate (`capsule-core`, `capsule-sdk`, +# `capsule-core-ffi` all dev-depend on it) and already in the lock file. +tempfile = "3" + # `test-util` carries `kynos::test::TestClient`, which drives a built `Service` in-process — # no socket, no port, no runtime flavour — and the two conformance assertions the suite is # built around. `server` is on top of it for exactly one test: `tests/sdk_client.rs` binds the diff --git a/capsule-server/src/auth/accounts_memory.rs b/capsule-server/src/auth/accounts_memory.rs new file mode 100644 index 00000000..b2551b77 --- /dev/null +++ b/capsule-server/src/auth/accounts_memory.rs @@ -0,0 +1,616 @@ +//! [`InMemoryAccounts`] — the deterministic account store the development profile runs on. +//! +//! # Why this exists in `src/` when three port modules say it would not +//! +//! [`directory`](super::directory), [`registry`](super::registry) and +//! [`profile`](super::profile) each record the same reason for having no adapter: *"the real one +//! is Postgres, the test one is a double, and a double in `src/` is a fake credential directory +//! shipped inside the server binary."* That reasoning is about a **double** — specifically +//! `tests/support/mod.rs`'s, which "accepts whatever password it was told to accept" and +//! therefore must never be linkable by a server. +//! +//! This is not that. It verifies with the same Argon2id helper the Postgres adapter will use +//! ([`credential`](super::credential)), it stores PHC strings and no plaintext, it takes the +//! timing-equalized miss, and it locks an account out after enough failures. What it is missing +//! is **durability**, which is what makes it a development profile rather than a deployment: +//! every account registered against it is gone when the process exits. `capsule-server serve` +//! reaches it only through `--memory`, which is an explicit operator act +//! ([`Backends::Memory`](crate::config::Backends)). +//! +//! The alternative was a fail-closed stub: four ports that answer `Unavailable`, so +//! `POST /v1/auth/register` and `POST /v1/auth/login` return their declared refusal until #402 +//! lands. That was rejected once the cost was measured — `argon2` is already a workspace +//! dependency with a design-doc row, and the credential helper this needs is the one #402 +//! reuses, so nothing is written twice — and the gain is large: a `mise run serve-memory` you +//! can actually sign in to is the difference between a server a client developer can point at +//! and a surface they can only read. +//! +//! # Where the hashing happens relative to the lock +//! +//! Argon2id is deliberately expensive — tens of milliseconds — and this adapter holds a +//! `Mutex`. Every operation therefore computes or checks its hash **outside** the critical +//! section and touches the map only to read a snapshot or to write a result. Holding the lock +//! across a hash would serialize every account operation in the process behind the slowest +//! primitive in it. +//! +//! The cost of that is a read-modify-write gap in the failed-attempt counter, so two +//! simultaneous wrong passwords can be recorded as one. That is the right trade for a +//! development adapter and it is *not* the trade a Postgres adapter should make: there the +//! increment is one statement, which is why the port asks the adapter for the bookkeeping rather +//! than describing how to do it. + +use std::collections::BTreeMap; +use std::sync::{Mutex, MutexGuard, PoisonError}; + +use jiff::Timestamp; + +use super::credential::{CredentialError, Credentials}; +use super::directory::{AccountDirectory, Authentication, DirectoryError, DirectoryFuture}; +use super::profile::{ + AccountProfiles, PasswordChange, PasswordChanged, ProfileRecord, ProfileUpdate, +}; +use super::registry::{AccountRegistry, Registration}; +use crate::store::UserId; + +/// How many consecutive failures put an account into [`Authentication::Locked`]. +/// +/// Ten, and it is a **lockout** rather than a rate limit: the port is explicit that `Locked` is +/// account state the adapter owns, and that rate limiting is a counter with no port anywhere in +/// this crate. Cleared by a success and by a password change, which the port requires — leaving +/// it behind would bar somebody from an account they just proved they own. +pub const MAX_FAILED_ATTEMPTS: u32 = 10; + +/// One account, as this adapter holds it. +#[derive(Debug, Clone)] +struct Account { + /// The address it signs in with, verbatim as it was registered. + email: String, + /// The id every session and every manifest names. + user_id: UserId, + /// The Argon2id PHC string. Never a password. + stored: String, + /// The name it chose to be shown as. + display_name: Option, + /// When it was created. + created_at: Timestamp, + /// Consecutive failed credential presentations. + failures: u32, +} + +impl Account { + /// Whether enough failures have accumulated to refuse a correct password. + fn locked(&self) -> bool { + self.failures >= MAX_FAILED_ATTEMPTS + } + + /// The profile view of this account. + fn profile(&self) -> ProfileRecord { + ProfileRecord { + user_id: self.user_id.clone(), + email: self.email.clone(), + display_name: self.display_name.clone(), + created_at: self.created_at, + } + } +} + +/// Accounts held in this process, keyed by the address they registered with. +/// +/// Addresses are compared **verbatim**, exactly as the suite's double compares them. Case +/// folding would be a normalization policy this port does not describe, and a policy invented +/// here is a policy the Postgres adapter would have to guess at: `Foo@example.test` and +/// `foo@example.test` are two accounts until a slice says otherwise, and saying otherwise is a +/// decision about identity rather than about storage. +#[derive(Debug)] +pub struct InMemoryAccounts { + credentials: Credentials, + accounts: Mutex>, +} + +impl InMemoryAccounts { + /// An empty directory over `credentials`. + /// + /// The verifier is passed in rather than constructed here because building one costs an + /// Argon2id hash (the decoy), and a composition root that builds several adapters should pay + /// that once. + pub fn new(credentials: Credentials) -> Self { + Self { + credentials, + accounts: Mutex::new(BTreeMap::new()), + } + } + + /// How many accounts are held, for a caller that logs the profile it came up on. + pub fn len(&self) -> usize { + self.accounts().len() + } + + /// Whether no account has been registered yet. + pub fn is_empty(&self) -> bool { + self.accounts().is_empty() + } + + /// Take the lock, recovering rather than propagating a poisoned one. + /// + /// The same choice [`crate::store::memory`] makes: a panic in one request must not turn + /// every later account lookup into a second panic, and the invariant this map holds is a + /// `BTreeMap`'s own rather than one a half-finished write could break. + fn accounts(&self) -> MutexGuard<'_, BTreeMap> { + self.accounts.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// A snapshot of the account `email` names, if there is one. + fn by_email(&self, email: &str) -> Option { + self.accounts().get(email).cloned() + } + + /// A snapshot of the account `user` names, if there is one. + fn by_id(&self, user: &UserId) -> Option { + self.accounts() + .values() + .find(|held| &held.user_id == user) + .cloned() + } + + /// Record the outcome of a credential presentation against `email`. + /// + /// One place, so the reset-on-success half cannot be forgotten at one of the two call sites. + fn record(&self, email: &str, granted: bool) { + if let Some(held) = self.accounts().get_mut(email) { + if granted { + held.failures = 0; + } else { + held.failures = held.failures.saturating_add(1); + if held.failures == MAX_FAILED_ATTEMPTS { + tracing::warn!( + user = %held.user_id, + failures = held.failures, + "an account reached the failed-attempt ceiling and is locked out" + ); + } + } + } + } + + /// Decide `password` against a snapshot, and record what happened. + /// + /// The shared body of the two [`AccountDirectory`] methods: they differ only in how they + /// find the account, and that is exactly the difference the port wants them to have. + fn decide( + &self, + held: Option, + password: &str, + ) -> Result { + let Some(held) = held else { + // The timing-equalized miss. Refusing here without doing the work would leak the + // difference between an unknown address and a wrong password in the response time, + // whatever the body said. + self.credentials.absorb_miss(password); + return Ok(Authentication::Refused); + }; + if held.locked() { + // Still absorbed: a locked account that returned instantly would tell an attacker + // which addresses they have already spent attempts on. + self.credentials.absorb_miss(password); + return Ok(Authentication::Locked); + } + let granted = self + .credentials + .verify(password, &held.stored) + .map_err(unavailable)?; + self.record(&held.email, granted); + if granted { + Ok(Authentication::Granted(held.user_id)) + } else { + Ok(Authentication::Refused) + } + } +} + +/// A credential fault is a directory fault: no decision was reached. +/// +/// It is [`DirectoryError::Unavailable`] rather than a refusal because a stored hash this server +/// cannot read is a broken row, and answering "your password is wrong" would send somebody round +/// a loop that cannot succeed. +fn unavailable(error: CredentialError) -> DirectoryError { + tracing::error!(%error, "a stored credential could not be processed"); + DirectoryError::Unavailable { + detail: error.to_string(), + } +} + +impl AccountDirectory for InMemoryAccounts { + fn authenticate<'a>( + &'a self, + email: &'a str, + password: &'a str, + ) -> DirectoryFuture<'a, Authentication> { + Box::pin(async move { self.decide(self.by_email(email), password) }) + } + + fn authenticate_user<'a>( + &'a self, + user: &'a UserId, + password: &'a str, + ) -> DirectoryFuture<'a, Authentication> { + Box::pin(async move { self.decide(self.by_id(user), password) }) + } +} + +impl AccountRegistry for InMemoryAccounts { + fn create<'a>( + &'a self, + email: &'a str, + password: &'a str, + user: &'a UserId, + at: Timestamp, + ) -> DirectoryFuture<'a, Registration> { + Box::pin(async move { + // Hashed before the lock, so the check-and-write below is short. The cost of that + // ordering is a hash computed for an address that turns out to be taken, which is + // the cheap direction to be wrong in. + let stored = self.credentials.hash(password).map_err(unavailable)?; + // One critical section, as the port requires: a caller that read, saw nothing and + // then wrote has a window in which a second registration for the same address + // lands, and both would believe they own it. + let mut accounts = self.accounts(); + if accounts.contains_key(email) { + return Ok(Registration::AlreadyExists); + } + accounts.insert( + email.to_owned(), + Account { + email: email.to_owned(), + user_id: user.clone(), + stored, + display_name: None, + created_at: at, + failures: 0, + }, + ); + tracing::info!(%user, "an account was created in the in-memory directory"); + Ok(Registration::Created(user.clone())) + }) + } +} + +impl AccountProfiles for InMemoryAccounts { + fn read<'a>(&'a self, user: &'a UserId) -> DirectoryFuture<'a, Option> { + Box::pin(async move { Ok(self.by_id(user).as_ref().map(Account::profile)) }) + } + + fn update<'a>( + &'a self, + user: &'a UserId, + update: &'a ProfileUpdate, + ) -> DirectoryFuture<'a, Option> { + Box::pin(async move { + // One critical section, as the port requires: a read-modify-write a caller could + // interleave is an edit from another device silently clobbered. + let mut accounts = self.accounts(); + let Some(held) = accounts.values_mut().find(|held| &held.user_id == user) else { + return Ok(None); + }; + if let Some(display_name) = update.display_name.clone() { + held.display_name = display_name; + } + Ok(Some(held.profile())) + }) + } +} + +impl PasswordChange for InMemoryAccounts { + fn set_password<'a>( + &'a self, + user: &'a UserId, + password: &'a str, + _at: Timestamp, + ) -> DirectoryFuture<'a, PasswordChanged> { + Box::pin(async move { + let stored = self.credentials.hash(password).map_err(unavailable)?; + let mut accounts = self.accounts(); + let Some(held) = accounts.values_mut().find(|held| &held.user_id == user) else { + return Ok(PasswordChanged::NoSuchAccount); + }; + held.stored = stored; + // The port requires it: a change is a successful credential presentation, and + // leaving the lockout behind would bar somebody from an account they just proved + // they own. + held.failures = 0; + tracing::info!(%user, "an account's password was replaced"); + Ok(PasswordChanged::Yes) + }) + } +} + +#[cfg(test)] +mod tests { + use jiff::Timestamp; + + use super::{Credentials, InMemoryAccounts, MAX_FAILED_ATTEMPTS}; + use crate::auth::directory::{AccountDirectory, Authentication}; + use crate::auth::profile::{AccountProfiles, PasswordChange, PasswordChanged, ProfileUpdate}; + use crate::auth::registry::{AccountRegistry, Registration}; + use crate::store::UserId; + + const EMAIL: &str = "somebody@example.test"; + const PASSWORD: &str = "correct horse battery staple"; + + fn user() -> UserId { + UserId::new("018f3f1e-4b7a-7c9d-8e2f-1a2b3c4d5e6f") + } + + /// A directory with one registered account. + async fn seeded() -> InMemoryAccounts { + let accounts = InMemoryAccounts::new(Credentials::new().expect("the platform hashes")); + assert_eq!( + accounts + .create(EMAIL, PASSWORD, &user(), Timestamp::UNIX_EPOCH) + .await + .expect("it writes"), + Registration::Created(user()) + ); + accounts + } + + #[tokio::test] + async fn registering_then_signing_in_works() { + // The whole reason this adapter exists rather than a fail-closed stub. + let accounts = seeded().await; + assert_eq!( + accounts + .authenticate(EMAIL, PASSWORD) + .await + .expect("it answers"), + Authentication::Granted(user()) + ); + } + + #[tokio::test] + async fn no_plaintext_password_is_retained() { + // The property the port is built on: the credential never rises above the adapter, and + // it is not sitting in the adapter either. + let accounts = seeded().await; + let held = accounts.by_email(EMAIL).expect("it is held"); + assert!(held.stored.starts_with("$argon2id$"), "{}", held.stored); + assert!(!held.stored.contains(PASSWORD)); + } + + #[tokio::test] + async fn a_taken_address_is_reported_and_nothing_is_written() { + let accounts = seeded().await; + let other = UserId::new("018f3f1e-4b7a-7c9d-8e2f-1a2b3c4d5e70"); + assert_eq!( + accounts + .create( + EMAIL, + "a different password entirely", + &other, + Timestamp::UNIX_EPOCH + ) + .await + .expect("it answers"), + Registration::AlreadyExists + ); + // The first account's credential still works, so nothing was overwritten. + assert_eq!( + accounts + .authenticate(EMAIL, PASSWORD) + .await + .expect("it answers"), + Authentication::Granted(user()) + ); + } + + #[tokio::test] + async fn an_unknown_address_and_a_wrong_password_are_one_answer() { + // The port collapses them into one value on purpose, so no caller *can* tell them apart. + let accounts = seeded().await; + assert_eq!( + accounts + .authenticate("nobody@example.test", PASSWORD) + .await + .expect("it answers"), + Authentication::Refused + ); + assert_eq!( + accounts + .authenticate(EMAIL, "the wrong password") + .await + .expect("it answers"), + Authentication::Refused + ); + } + + #[tokio::test] + async fn enough_failures_lock_the_account_and_a_correct_password_is_told_so() { + // `Locked` is the one refusal a *correct* password also receives, which is why it is a + // separate value: a client showing "wrong password" here would send somebody round a + // loop that cannot succeed. + let accounts = seeded().await; + for _ in 0..MAX_FAILED_ATTEMPTS { + assert_eq!( + accounts + .authenticate(EMAIL, "wrong") + .await + .expect("it answers"), + Authentication::Refused + ); + } + assert_eq!( + accounts + .authenticate(EMAIL, PASSWORD) + .await + .expect("it answers"), + Authentication::Locked + ); + } + + #[tokio::test] + async fn a_success_before_the_ceiling_clears_the_count() { + let accounts = seeded().await; + for _ in 0..MAX_FAILED_ATTEMPTS - 1 { + let _ = accounts.authenticate(EMAIL, "wrong").await; + } + assert_eq!( + accounts + .authenticate(EMAIL, PASSWORD) + .await + .expect("it answers"), + Authentication::Granted(user()) + ); + // Back to zero: the next wrong password does not tip an already-full counter over. + let _ = accounts.authenticate(EMAIL, "wrong").await; + assert_eq!( + accounts + .authenticate(EMAIL, PASSWORD) + .await + .expect("it answers"), + Authentication::Granted(user()) + ); + } + + #[tokio::test] + async fn a_password_change_clears_a_lockout() { + // The port requires it: a change is a successful credential presentation. + let accounts = seeded().await; + for _ in 0..MAX_FAILED_ATTEMPTS { + let _ = accounts.authenticate(EMAIL, "wrong").await; + } + assert_eq!( + accounts + .set_password(&user(), "a brand new password", Timestamp::UNIX_EPOCH) + .await + .expect("it writes"), + PasswordChanged::Yes + ); + assert_eq!( + accounts + .authenticate(EMAIL, "a brand new password") + .await + .expect("it answers"), + Authentication::Granted(user()) + ); + assert_eq!( + accounts + .authenticate(EMAIL, PASSWORD) + .await + .expect("it answers"), + Authentication::Refused + ); + } + + #[tokio::test] + async fn re_authentication_takes_the_account_from_the_credential_and_not_the_request() { + let accounts = seeded().await; + assert_eq!( + accounts + .authenticate_user(&user(), PASSWORD) + .await + .expect("it answers"), + Authentication::Granted(user()) + ); + let stranger = UserId::new("018f3f1e-4b7a-7c9d-8e2f-1a2b3c4d5e71"); + assert_eq!( + accounts + .authenticate_user(&stranger, PASSWORD) + .await + .expect("it answers"), + Authentication::Refused + ); + } + + #[tokio::test] + async fn changing_a_password_for_an_absent_account_writes_nothing() { + let accounts = seeded().await; + let stranger = UserId::new("018f3f1e-4b7a-7c9d-8e2f-1a2b3c4d5e72"); + assert_eq!( + accounts + .set_password(&stranger, "irrelevant", Timestamp::UNIX_EPOCH) + .await + .expect("it answers"), + PasswordChanged::NoSuchAccount + ); + } + + #[tokio::test] + async fn a_profile_reads_back_what_registration_wrote_and_takes_an_edit() { + let accounts = seeded().await; + let profile = accounts + .read(&user()) + .await + .expect("it answers") + .expect("the account exists"); + assert_eq!(profile.email, EMAIL); + assert_eq!(profile.display_name, None); + assert_eq!(profile.created_at, Timestamp::UNIX_EPOCH); + + let updated = accounts + .update( + &user(), + &ProfileUpdate { + display_name: Some(Some("Ada Lovelace".to_owned())), + }, + ) + .await + .expect("it answers") + .expect("the account exists"); + assert_eq!(updated.display_name.as_deref(), Some("Ada Lovelace")); + + // An absent field leaves the name alone; `Some(None)` clears it. + let untouched = accounts + .update(&user(), &ProfileUpdate::default()) + .await + .expect("it answers") + .expect("the account exists"); + assert_eq!(untouched.display_name.as_deref(), Some("Ada Lovelace")); + + let cleared = accounts + .update( + &user(), + &ProfileUpdate { + display_name: Some(None), + }, + ) + .await + .expect("it answers") + .expect("the account exists"); + assert_eq!(cleared.display_name, None); + } + + #[tokio::test] + async fn a_profile_for_an_absent_account_is_absent_and_not_an_error() { + // Reachable with a perfectly valid credential: a session outlives the account row it + // names if the account is deleted while a token is live. + let accounts = seeded().await; + let stranger = UserId::new("018f3f1e-4b7a-7c9d-8e2f-1a2b3c4d5e73"); + assert!( + accounts + .read(&stranger) + .await + .expect("it answers") + .is_none() + ); + assert!( + accounts + .update(&stranger, &ProfileUpdate::default()) + .await + .expect("it answers") + .is_none() + ); + } + + #[tokio::test] + async fn addresses_are_compared_verbatim() { + // Recorded as a test rather than left implicit: case folding is a normalization policy + // this port does not describe, and #402's adapter has to make the same choice. + let accounts = seeded().await; + assert_eq!( + accounts + .authenticate("Somebody@Example.test", PASSWORD) + .await + .expect("it answers"), + Authentication::Refused + ); + } +} diff --git a/capsule-server/src/auth/credential.rs b/capsule-server/src/auth/credential.rs new file mode 100644 index 00000000..ddf906e5 --- /dev/null +++ b/capsule-server/src/auth/credential.rs @@ -0,0 +1,257 @@ +//! [`Credentials`] — the one place this server hashes and checks a password. +//! +//! # Why a helper and not a method on each adapter +//! +//! Three ports oblige their adapter to own credential verification end to end: +//! [`AccountDirectory`](super::AccountDirectory) verifies, +//! [`AccountRegistry`](super::AccountRegistry) hashes, and +//! [`PasswordChange`](super::PasswordChange) re-hashes. Their docs say why — a password hash +//! that crossed the port boundary would be a secret in a type that does not know it is one, and +//! it would put Argon2id's parameters in the routing layer, where a second call site can get +//! them subtly wrong. +//! +//! What that leaves is an obligation each *adapter* has to discharge identically. The in-memory +//! adapter beside this file discharges it, and the Postgres adapter (#402) discharges the same +//! one; written twice they would be two answers to a question with one right answer, and the +//! first divergence would be a parameter set — the thing that is invisible until somebody's +//! password is cheap to crack. So the algorithm is here, once, and an adapter owns *where the +//! hash is kept* rather than *what a hash is*. +//! +//! # Argon2id, at the crate's own defaults +//! +//! `Argon2::default()` is Argon2id, version 0x13, m=19456 KiB, t=2, p=1 — the parameter set the +//! RustCrypto crate publishes as its recommendation. Not the tiered parameters +//! [`capsule_core::crypto::pwkdf`] uses: those describe a *key derivation* that has to run on +//! the weakest device that will ever unwrap the blob, and this is a server-side verification +//! whose cost is paid on the server. The two are deliberately unrelated numbers, and the +//! parameters ride inside every PHC string this writes, so raising them is not a flag day. +//! +//! # The timing-equalized miss +//! +//! [`AccountDirectory`](super::AccountDirectory)'s contract is that no caller can tell an +//! unknown account from a wrong password. Returning early for an unknown address would leak +//! that difference in the response *time* whatever the body said, so an adapter must still do +//! the work — [`Credentials::absorb_miss`] is that work, verifying against a decoy hash +//! computed once at construction and discarding the answer. + +use std::fmt; + +use argon2::Argon2; +use argon2::password_hash::{PasswordHash, PasswordHasher as _, PasswordVerifier as _, SaltString}; + +/// How many bytes of salt every hash carries. +/// +/// Sixteen, which is what the PHC specification recommends and what `Argon2::default()` would +/// have generated. Longer buys nothing: the salt is a uniqueness device, not a secret. +const SALT_LEN: usize = 16; + +/// The password the decoy hash is built over. +/// +/// A constant, and it does not matter what it is: [`Credentials::absorb_miss`] never compares +/// against it successfully, and the only property required of the decoy is that verifying +/// against it costs what verifying against a real hash costs. +const DECOY_PASSWORD: &[u8] = b"capsule/decoy/there-is-no-such-account"; + +/// Something went wrong hashing or reading a credential. +/// +/// Never carries the password, the hash, or any part of either: this error is logged, and a +/// library that put a PHC string in a log line would put every account's salt there with it. +#[derive(Debug, thiserror::Error)] +#[error("a stored credential could not be processed: {detail}")] +pub struct CredentialError { + /// The algorithm's own description of the failure. + pub detail: String, +} + +/// Hashing and checking passwords, at one parameter set. +/// +/// `Debug` is hand-written and names the algorithm rather than the state, because the state +/// includes a hash. +#[derive(Clone)] +pub struct Credentials { + argon: Argon2<'static>, + /// A real Argon2id hash of a password nobody has, so a lookup that found nothing can cost + /// what a lookup that found something costs. + decoy: String, +} + +impl fmt::Debug for Credentials { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Credentials") + .field("algorithm", &"argon2id") + .finish_non_exhaustive() + } +} + +impl Credentials { + /// A verifier at the crate's default Argon2id parameters. + /// + /// Pays for one hash — the decoy — so that no request ever has to. + /// + /// # Errors + /// + /// Returns [`CredentialError`] if the platform cannot produce a hash at all, which is a + /// startup failure rather than a request failure: a server that cannot hash a password + /// cannot authenticate anybody and must refuse to start. + pub fn new() -> Result { + let argon = Argon2::default(); + let decoy = hash_with(&argon, DECOY_PASSWORD)?; + Ok(Self { argon, decoy }) + } + + /// The PHC string to store for `password`. + /// + /// A fresh random salt every time, so two accounts with the same password have different + /// stored hashes and a stolen table cannot be attacked once for both. + /// + /// # Errors + /// + /// Returns [`CredentialError`] if the hash cannot be computed. + pub fn hash(&self, password: &str) -> Result { + hash_with(&self.argon, password.as_bytes()) + } + + /// Whether `password` is the one `stored` was made from. + /// + /// A wrong password is `Ok(false)`, not an error: a refused credential is a normal answer to + /// a normal question, and modelling it as a failure is what leads to a `?` that turns a + /// sign-in rejection into a 500. + /// + /// # Errors + /// + /// Returns [`CredentialError`] only when `stored` is not a PHC string this server can read — + /// a corrupted row, not a wrong password. + pub fn verify(&self, password: &str, stored: &str) -> Result { + let parsed = PasswordHash::new(stored).map_err(|error| CredentialError { + detail: format!("the stored hash is not a readable PHC string ({error})"), + })?; + match self.argon.verify_password(password.as_bytes(), &parsed) { + Ok(()) => Ok(true), + Err(argon2::password_hash::Error::Password) => Ok(false), + Err(error) => Err(CredentialError { + detail: error.to_string(), + }), + } + } + + /// Spend what a verification costs, having found no account to verify against. + /// + /// The timing-equalized miss; see the module docs. The result is deliberately discarded — + /// there is nothing to learn from it, and a caller that branched on it would be branching + /// on whether a decoy password happens to be somebody's. + pub fn absorb_miss(&self, password: &str) { + let _ = self.verify(password, &self.decoy); + } +} + +/// Hash `password` under `argon` with a fresh salt. +fn hash_with(argon: &Argon2<'static>, password: &[u8]) -> Result { + let mut bytes = [0u8; SALT_LEN]; + // `ring`'s CSPRNG rather than `password_hash`'s optional `rand` feature: it is already this + // crate's source of randomness and its key generator, so one binary has one CSPRNG. + ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut bytes).map_err( + |error| CredentialError { + detail: format!("the platform could not produce a salt ({error})"), + }, + )?; + let salt = SaltString::encode_b64(&bytes).map_err(|error| CredentialError { + detail: format!("the salt could not be encoded ({error})"), + })?; + Ok(argon + .hash_password(password, &salt) + .map_err(|error| CredentialError { + detail: error.to_string(), + })? + .to_string()) +} + +#[cfg(test)] +mod tests { + use super::Credentials; + + /// One instance for the whole module: `Credentials::new` pays for an Argon2id hash, and + /// paying for it once per test is the difference between a fast suite and a slow one. + fn credentials() -> Credentials { + Credentials::new().expect("the platform hashes") + } + + #[test] + fn the_password_it_hashed_is_the_password_it_accepts() { + let credentials = credentials(); + let stored = credentials + .hash("correct horse battery staple") + .expect("it hashes"); + assert!( + credentials + .verify("correct horse battery staple", &stored) + .expect("it reads") + ); + } + + #[test] + fn a_wrong_password_is_a_refusal_and_not_an_error() { + // The distinction the port is built on: a refused credential is an answer, so a route + // cannot accidentally `?` it into a 500. + let credentials = credentials(); + let stored = credentials + .hash("correct horse battery staple") + .expect("it hashes"); + assert!( + !credentials + .verify("Correct Horse Battery Staple", &stored) + .expect("it reads") + ); + } + + #[test] + fn the_stored_hash_is_a_phc_string_naming_argon2id_and_never_the_password() { + let credentials = credentials(); + let stored = credentials + .hash("a password worth protecting") + .expect("it hashes"); + assert!(stored.starts_with("$argon2id$"), "{stored}"); + assert!(!stored.contains("a password worth protecting"), "{stored}"); + } + + #[test] + fn two_accounts_with_one_password_do_not_share_a_hash() { + // A fresh salt per hash, which is what stops one offline attack from covering both. + let credentials = credentials(); + let first = credentials.hash("shared").expect("it hashes"); + let second = credentials.hash("shared").expect("it hashes"); + assert_ne!(first, second); + assert!(credentials.verify("shared", &first).expect("it reads")); + assert!(credentials.verify("shared", &second).expect("it reads")); + } + + #[test] + fn a_corrupted_stored_hash_is_a_fault_and_not_a_refusal() { + // It must not read as "your password is wrong", which would send somebody round a loop + // that cannot succeed. + let credentials = credentials(); + assert!(credentials.verify("anything", "not a PHC string").is_err()); + } + + #[test] + fn absorbing_a_miss_costs_what_a_verification_costs() { + // Not a timing assertion — those are flaky by nature. What is asserted is that the + // decoy is a real hash the verifier reads, so the work actually happens: a decoy that + // failed to parse would return in microseconds and leak the account oracle the port + // exists to close. + let credentials = credentials(); + credentials.absorb_miss("anything at all"); + assert!( + !credentials + .verify("anything at all", &credentials.decoy) + .expect("the decoy parses") + ); + } + + #[test] + fn debug_names_the_algorithm_and_prints_no_hash() { + let credentials = credentials(); + let rendered = format!("{credentials:?}"); + assert!(rendered.contains("argon2id"), "{rendered}"); + assert!(!rendered.contains('$'), "{rendered}"); + } +} diff --git a/capsule-server/src/auth/mod.rs b/capsule-server/src/auth/mod.rs index dad5adb2..564b8010 100644 --- a/capsule-server/src/auth/mod.rs +++ b/capsule-server/src/auth/mod.rs @@ -28,14 +28,26 @@ //! constructor that will eventually be got wrong positionally, and two `Arc` swapped at a //! call site is a compile error only by luck. //! -//! # Adapters this slice does not write +//! # Adapters, and the one that is still owed //! -//! There is no [`AccountDirectory`] implementation in `src/`, and that is deliberate rather than -//! unfinished: the real one is Postgres, the test one is a double, and a double in `src/` is a -//! fake credential directory shipped inside the server binary. The suite's doubles live in -//! `tests/support/`. Same reasoning, one step further than `S-C29` took it for the session -//! store, and it is why [`SessionTokens`] is not a trait at all. +//! [`InMemoryAccounts`] implements all four account ports over a map, verifying with the +//! [`credential`] helper's Argon2id — so the development profile can register an account and +//! sign in to it — and [`InMemoryTotp`] implements the second factor's. Neither is durable, and +//! neither is reachable without `--memory` +//! ([`Backends::Memory`](crate::config::Backends)), which is an explicit operator act. +//! +//! What is deliberately **not** here is a permissive one. `tests/support/mod.rs` holds a +//! credential directory that "accepts whatever password it was told to accept", and its own docs +//! say why that "belongs in a test binary and nowhere a server could link it". The distinction +//! the port modules were drawing is between a double and an implementation, not between +//! Postgres and everything else. +//! +//! The Postgres adapters are owed (#402), and they are written against these ports and the +//! suites over them. [`SessionTokens`] is not a trait at all, for the reason `credential` +//! records: it is a pure function of a key and a clock. +pub mod accounts_memory; +pub mod credential; pub mod directory; pub mod profile; pub mod registry; @@ -45,6 +57,8 @@ pub mod totp; use std::sync::Arc; +pub use self::accounts_memory::InMemoryAccounts; +pub use self::credential::{CredentialError, Credentials}; pub use self::directory::{AccountDirectory, Authentication, DirectoryError, DirectoryFuture}; pub use self::profile::{ AccountProfiles, MAX_DISPLAY_NAME_CHARS, MalformedProfile, PasswordChange, PasswordChanged, @@ -57,8 +71,8 @@ pub use self::tokens::{ TokenError, TokenKind, VerifiedChallenge, VerifiedToken, }; pub use self::totp::{ - ActivateOutcome, BeginOutcome, CHALLENGE_TTL, ConsumeOutcome, EnrollmentState, TotpCodes, - TotpContext, TotpEnrollment, TotpSecret, TotpStore, UnusableSecret, + ActivateOutcome, BeginOutcome, CHALLENGE_TTL, ConsumeOutcome, EnrollmentState, InMemoryTotp, + TotpCodes, TotpContext, TotpEnrollment, TotpSecret, TotpStore, UnusableSecret, }; use crate::store::{AuthStateStore, Clock}; diff --git a/capsule-server/src/auth/totp.rs b/capsule-server/src/auth/totp.rs index 26a9f703..0b891f40 100644 --- a/capsule-server/src/auth/totp.rs +++ b/capsule-server/src/auth/totp.rs @@ -38,14 +38,23 @@ //! would otherwise turn off the control that exists to make a stolen access token insufficient. //! The retired surface got this right and it is preserved deliberately. //! -//! # No adapter here +//! # The one adapter that belongs in `src/` //! -//! Same reason [`AccountDirectory`](super::AccountDirectory) has none: the real one is Postgres, -//! and a shared-secret store in `src/` is a fake credential store shipped inside the server -//! binary. The suite's lives in `tests/support/`. +//! [`InMemoryTotp`] is here, and the account ports' "a double in `src/` is a fake credential +//! store shipped inside the server binary" reasoning does not reach it. A TOTP secret is +//! **server-generated**: nothing a caller presents is ever stored, so there is no credential to +//! be permissive about, and the three properties the port promises — the check-and-write in +//! [`TotpStore::begin`], the pending-only [`TotpStore::activate`], and the compare-and-set in +//! [`TotpStore::consume`] — are all expressible over a map without fudging any of them. What it +//! is missing is durability, which is what makes it the development profile +//! ([`Backends::Memory`](crate::config::Backends)) rather than a deployment. +//! +//! The Postgres adapter is still owed (#402), and it is written against this contract and the +//! suite over it rather than against this type. +use std::collections::BTreeMap; use std::fmt; -use std::sync::Arc; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use jiff::{SignedDuration, Timestamp}; use subtle::ConstantTimeEq as _; @@ -357,6 +366,98 @@ impl TotpContext { } } +/// The second-factor enrollments this process holds. +/// +/// A real implementation of the port's contract rather than a stub; see the module docs for why +/// this one belongs in `src/` when the account ports' adapters do not. +#[derive(Debug, Default)] +pub struct InMemoryTotp { + held: Mutex>, +} + +impl InMemoryTotp { + /// An empty store. + pub fn new() -> Self { + Self::default() + } + + /// Take the lock, recovering rather than propagating a poisoned one. + /// + /// The same choice [`crate::store::memory`] makes: a panic in one request must not turn + /// every later second-factor check into a second panic. + fn enrollments(&self) -> MutexGuard<'_, BTreeMap> { + self.held.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +impl TotpStore for InMemoryTotp { + fn begin(&self, record: TotpEnrollment) -> DirectoryFuture<'_, BeginOutcome> { + Box::pin(async move { + // One critical section, as the port requires: a caller that read, saw no active + // enrollment and then wrote has a window in which a confirmation lands, and the + // confirmed factor is then silently replaced. + let mut held = self.enrollments(); + if held + .get(&record.user_id) + .is_some_and(|existing| existing.state == EnrollmentState::Active) + { + return Ok(BeginOutcome::AlreadyActive); + } + // A *pending* enrollment is replaced without ceremony: somebody who abandoned a QR + // code and started again is the ordinary case, and nothing is protecting an + // unconfirmed secret. + held.insert(record.user_id.clone(), record); + Ok(BeginOutcome::Started) + }) + } + + fn read<'a>(&'a self, user: &'a UserId) -> DirectoryFuture<'a, Option> { + Box::pin(async move { Ok(self.enrollments().get(user).cloned()) }) + } + + fn activate<'a>( + &'a self, + user: &'a UserId, + step: u64, + at: Timestamp, + ) -> DirectoryFuture<'a, ActivateOutcome> { + Box::pin(async move { + let mut held = self.enrollments(); + let Some(record) = held.get_mut(user) else { + return Ok(ActivateOutcome::NotPending); + }; + if record.state != EnrollmentState::Pending { + return Ok(ActivateOutcome::NotPending); + } + record.state = EnrollmentState::Active; + record.activated_at = Some(at); + // The confirming code is spent, so it cannot also complete a sign-in. + record.last_step = Some(step); + Ok(ActivateOutcome::Activated) + }) + } + + fn consume<'a>(&'a self, user: &'a UserId, step: u64) -> DirectoryFuture<'a, ConsumeOutcome> { + Box::pin(async move { + // Compare-and-set inside one critical section, not a read followed by a write: two + // sign-ins racing on the same six digits is exactly the case a read-then-write + // loses, and losing it accepts a replay. + let mut held = self.enrollments(); + let Some(record) = held.get_mut(user) else { + return Ok(ConsumeOutcome::NotEnrolled); + }; + if record.last_step.is_some_and(|last| step <= last) { + return Ok(ConsumeOutcome::Replayed); + } + record.last_step = Some(step); + Ok(ConsumeOutcome::Fresh) + }) + } + + fn disable<'a>(&'a self, user: &'a UserId) -> DirectoryFuture<'a, bool> { + Box::pin(async move { Ok(self.enrollments().remove(user).is_some()) }) + } +} #[cfg(test)] mod tests { use jiff::Timestamp; @@ -501,3 +602,142 @@ mod tests { assert!(!format!("{secret:?}").contains("JBSWY")); } } +#[cfg(test)] +mod memory_tests { + use jiff::Timestamp; + + use super::{ + ActivateOutcome, BeginOutcome, ConsumeOutcome, EnrollmentState, InMemoryTotp, TotpCodes, + TotpEnrollment, TotpStore, + }; + use crate::store::UserId; + + fn user() -> UserId { + UserId::new("018f3f1e-4b7a-7c9d-8e2f-1a2b3c4d5e6f") + } + + fn pending() -> TotpEnrollment { + TotpEnrollment { + user_id: user(), + secret: TotpCodes::generate_secret(), + state: EnrollmentState::Pending, + last_step: None, + enrolled_at: Timestamp::UNIX_EPOCH, + activated_at: None, + } + } + + #[tokio::test] + async fn an_abandoned_pending_enrollment_is_replaced_without_ceremony() { + let store = InMemoryTotp::new(); + assert_eq!( + store.begin(pending()).await.expect("it writes"), + BeginOutcome::Started + ); + let second = pending(); + let expected = second.secret.clone(); + assert_eq!( + store.begin(second).await.expect("it writes"), + BeginOutcome::Started + ); + let held = store + .read(&user()) + .await + .expect("it answers") + .expect("it is held"); + assert_eq!(held.secret, expected); + } + + #[tokio::test] + async fn an_active_enrollment_is_never_silently_replaced() { + // The one refusal `begin` makes, and the reason it exists: an enroll that overwrote an + // active secret would let a stolen session swap the factor for one the attacker holds + // without ever presenting a code. + let store = InMemoryTotp::new(); + store.begin(pending()).await.expect("it writes"); + store + .activate(&user(), 7, Timestamp::UNIX_EPOCH) + .await + .expect("it writes"); + assert_eq!( + store.begin(pending()).await.expect("it answers"), + BeginOutcome::AlreadyActive + ); + } + + #[tokio::test] + async fn only_a_pending_enrollment_activates_and_the_confirming_code_is_spent() { + let store = InMemoryTotp::new(); + assert_eq!( + store + .activate(&user(), 7, Timestamp::UNIX_EPOCH) + .await + .expect("it answers"), + ActivateOutcome::NotPending, + "there is nothing to confirm" + ); + store.begin(pending()).await.expect("it writes"); + assert_eq!( + store + .activate(&user(), 7, Timestamp::UNIX_EPOCH) + .await + .expect("it writes"), + ActivateOutcome::Activated + ); + assert_eq!( + store + .activate(&user(), 8, Timestamp::UNIX_EPOCH) + .await + .expect("it answers"), + ActivateOutcome::NotPending, + "an active enrollment is not pending" + ); + // The code that confirmed the enrollment must not also complete a sign-in. + assert_eq!( + store.consume(&user(), 7).await.expect("it answers"), + ConsumeOutcome::Replayed + ); + } + + #[tokio::test] + async fn a_step_at_or_below_the_last_used_one_is_a_replay() { + // RFC 6238 §5.2: a code is accepted at most once, and a code is valid for three steps + // with drift — so re-typing it twelve seconds later is a real attack. + let store = InMemoryTotp::new(); + store.begin(pending()).await.expect("it writes"); + assert_eq!( + store.consume(&user(), 10).await.expect("it answers"), + ConsumeOutcome::Fresh + ); + assert_eq!( + store.consume(&user(), 10).await.expect("it answers"), + ConsumeOutcome::Replayed + ); + assert_eq!( + store.consume(&user(), 9).await.expect("it answers"), + ConsumeOutcome::Replayed + ); + assert_eq!( + store.consume(&user(), 11).await.expect("it answers"), + ConsumeOutcome::Fresh + ); + } + + #[tokio::test] + async fn consuming_against_nothing_says_so_rather_than_accepting() { + let store = InMemoryTotp::new(); + assert_eq!( + store.consume(&user(), 1).await.expect("it answers"), + ConsumeOutcome::NotEnrolled + ); + } + + #[tokio::test] + async fn disabling_reports_whether_there_was_anything_to_disable() { + let store = InMemoryTotp::new(); + assert!(!store.disable(&user()).await.expect("it answers")); + store.begin(pending()).await.expect("it writes"); + assert!(store.disable(&user()).await.expect("it answers")); + assert!(store.read(&user()).await.expect("it answers").is_none()); + } +} diff --git a/capsule-server/src/boot.rs b/capsule-server/src/boot.rs new file mode 100644 index 00000000..8e2dbe7e --- /dev/null +++ b/capsule-server/src/boot.rs @@ -0,0 +1,601 @@ +//! [`assemble`] — the one composition root, and the only place adapters are chosen. +//! +//! # Why this is a library module and not `main` +//! +//! Until this slice the only composition root in the tree was `tests/support/mod.rs`, which +//! assembles seventeen module contexts out of test doubles. Nothing assembled the server. A +//! composition root that lives in `main` is a composition root nothing tests: "the router +//! builds", "every port has an adapter" and "the published signing key is the one the tokens +//! verify under" are all properties of *this function*, and they are asserted below rather than +//! discovered on a deployment. +//! +//! # The seam, and what #402 and #403 change +//! +//! Selection is a two-arm `match` on [`Backends`] and not a trait. The `Arc` fields in +//! [`Modules`] already **are** the abstraction; a second one over the top would abstract the +//! composition root from itself. When the Postgres (#402) and Valkey (#403) adapters land they +//! fill the [`Backends::Durable`] arm, and nothing else here moves. +//! +//! Today that arm refuses. `store/mod.rs` has said since `S-C29` that *"Valkey is required; the +//! server refuses to boot without `VALKEY_URL`"* and that the in-memory adapters are a test +//! double rather than a deployment profile — and nothing enforced either sentence, because there +//! was no boot path to enforce it in. Now there is: no `VALKEY_URL` and no `--memory` is a +//! configuration fault naming `VALKEY_URL` ([`Config::load`]), and `VALKEY_URL` set is +//! [`BootError::AdapterUnavailable`] naming the issue that will honour it. Neither ever silently +//! becomes an in-memory server. +//! +//! # What the memory profile is, precisely +//! +//! Every deterministic in-crate adapter, over a **real** [`FilesystemBlobStore`] and a real +//! [`SystemClock`]. Two consequences worth stating because an operator will meet both: +//! +//! - **The blobs survive a restart and the index does not.** That is not a bug to route around, +//! it is the shape of a profile whose durable half is exactly the one adapter that has been +//! written. It also makes the profile useful to `scrub`, which compares those two halves and +//! will honestly report every blob as an orphan. +//! - **The collector's marks do not survive either.** [`crate::gc::collect`] marks a blob on one +//! pass and sweeps it on a later pass once the grace window has passed, so a fresh process can +//! only ever mark. Sweeping needs the durable mark store #402 brings. + +use std::sync::Arc; + +use jiff::Timestamp; + +use crate::album::authority::ProvisionedAuthority; +use crate::album::{AlbumContext, InMemoryAlbums}; +use crate::app::{App, Modules}; +use crate::attestation::{AttestationContext, InMemoryReceipts, LocalAttestationKey}; +use crate::auth::{ + AuthCollaborators, AuthContext, Credentials, InMemoryAccounts, InMemoryTotp, SessionTokens, + TotpCodes, TotpContext, +}; +use crate::blob::FilesystemBlobStore; +use crate::config::{Backends, Config}; +use crate::counter::{CounterContext, InMemoryCounters}; +use crate::directory::{DeviceDirectoryContext, InMemoryDeviceDirectory}; +use crate::discovery::revocation::InMemoryRevocations; +use crate::discovery::{DiscoveryContext, ProtocolWindow, ServerInfo}; +use crate::drop::{DropContext, InMemoryDrops}; +use crate::enrollment::EnrollmentContext; +use crate::escrow::{EscrowContext, InMemoryEscrow}; +use crate::gc::CollectionContext; +use crate::gc::memory::InMemoryCollection; +use crate::index::memory::InMemoryAssetIndex; +use crate::moderation::{InMemoryModeration, ModerationContext}; +use crate::quota::{InMemoryQuota, QuotaContext, QuotaLimits}; +use crate::scrub::ScrubContext; +use crate::serve::ServeContext; +use crate::share::{InMemoryShares, ShareContext}; +use crate::store::SystemClock; +use crate::store::memory::{ + InMemoryAuthState, InMemoryChallenges, InMemoryChannels, InMemoryCohorts, InMemoryEnrollments, + InMemoryUploadSessions, +}; +use crate::sync::{CursorCodec, SyncContext}; +use crate::upload::{UploadContext, UploadPolicy}; +use crate::verify::VerifyContext; + +/// Why a process could not be assembled. +/// +/// Every variant is a **startup** failure. There is deliberately no variant for a degraded boot: +/// a server that came up with one port missing would answer some requests and 500 on others, +/// which is harder to diagnose than a process that refused to start and said why. +#[derive(Debug, thiserror::Error)] +pub enum BootError { + /// The configuration itself is not usable. + #[error(transparent)] + Configuration(#[from] crate::config::ConfigError), + /// A setting [`Config::load`] treats as optional is required by this path. + /// + /// The backstop behind [`crate::config::Demands`], which is the aggregating front door an + /// operator reads. This variant fires only when the two disagree — a subcommand asking for + /// more than it declared — which is a programming error rather than a deployment one, and + /// failing loudly beats an `expect`. + #[error("{key} is required to assemble this server and is not set")] + Missing { + /// The setting. + key: &'static str, + }, + /// The blob root could not be opened. + /// + /// Refused rather than deferred: a store that cannot be created now is a store every upload + /// will fail against at write time, and a server that accepts bytes it cannot keep is worse + /// than one that does not start. + #[error("the blob store at {root} could not be opened: {detail}")] + BlobRoot { + /// The path that was tried. + root: String, + /// The filesystem's own description. + detail: String, + }, + /// The token-signing key could not be read. + #[error("the server's token-signing key could not be loaded: {detail}")] + SigningKey { + /// What was wrong with it. Never the key. + detail: String, + }, + /// The credential verifier could not be built. + #[error("the credential verifier could not be built: {detail}")] + Credentials { + /// The algorithm's own description. + detail: String, + }, + /// A durable backend was selected and its adapter is not written yet. + /// + /// Named with the issue that will honour it, because "not implemented" without a pointer is + /// a dead end for whoever reads it. + #[error("{key} selects a durable adapter that is not implemented yet (see {issue})")] + AdapterUnavailable { + /// The setting that selected it. + key: &'static str, + /// Where the work is tracked. + issue: &'static str, + }, + /// The router's own types do not describe a buildable server. + /// + /// Unreachable in practice — the conformance suite builds the same router on every test run + /// — and kept because the alternative is an `expect` in the composition root. + #[error("the router could not be built: {detail}")] + Router { + /// Kynos's own description. + detail: String, + }, +} + +/// A server, ready to serve or to sweep. +/// +/// The three things a subcommand can want out of one assembly: the application the router is +/// built with, and the two operator workers, which have no wire surface at all and therefore +/// cannot be reached through it. +#[derive(Debug)] +pub struct Assembled { + /// The application context every operation resolves its dependencies from. + pub app: App, + /// The collector's collaborators (`gc`, `purge`). + pub collection: CollectionContext, + /// The integrity scrub's collaborators (`scrub`). + pub scrub: ScrubContext, +} + +impl Assembled { + /// Build the service the listener drives. + /// + /// # Errors + /// + /// Returns [`BootError::Router`] if the router's types do not describe a buildable server. + pub fn service(&self) -> Result, BootError> { + crate::service(self.app.clone()).map_err(|error| BootError::Router { + detail: error.to_string(), + }) + } +} + +/// Assemble a server from `config`. +/// +/// # Errors +/// +/// Returns [`BootError`] for any of the startup failures above. Nothing is left half-built: the +/// blob root is the only side effect, and it is idempotent. +pub async fn assemble(config: &Config) -> Result { + match config.backends { + Backends::Memory => memory(config).await, + // The refusal `store/mod.rs` documents. `Config::load` already turned "no `VALKEY_URL` + // and no `--memory`" into a configuration fault naming the variable, so reaching here + // means the operator *did* set it — and the honest answer is that nothing reads it yet. + Backends::Durable => Err(BootError::AdapterUnavailable { + key: "VALKEY_URL", + issue: "#403 (Valkey) and #402 (Postgres)", + }), + } +} + +/// The development profile: every in-crate adapter, over a real blob store and a real clock. +#[allow( + clippy::too_many_lines, + reason = "seventeen module contexts, named once each; splitting it would hide the shape" +)] +async fn memory(config: &Config) -> Result { + let root = config + .blob_root + .as_ref() + .ok_or(BootError::Missing { key: "BLOB_ROOT" })?; + let der = config.signing_key_der.as_ref().ok_or(BootError::Missing { + key: "JWT_ED25519_DER", + })?; + let cursor_key = config.sync_cursor_mac_key.ok_or(BootError::Missing { + key: "SYNC_CURSOR_MAC_KEY", + })?; + let seed = config.attestation_key_seed.ok_or(BootError::Missing { + key: "ATTESTATION_KEY_SEED", + })?; + + let clock = Arc::new(SystemClock); + let blobs = + Arc::new( + FilesystemBlobStore::open(root) + .await + .map_err(|error| BootError::BlobRoot { + root: root.display().to_string(), + detail: error.to_string(), + })?, + ); + + // The signer is built from the private key alone and derives its own public half, which is + // what lets `ServerInfo` below publish the key tokens actually verify under rather than one + // an operator pasted beside it. + let tokens = Arc::new( + SessionTokens::from_pkcs8(der.expose(), clock.clone()).map_err(|error| { + BootError::SigningKey { + detail: error.detail, + } + })?, + ); + + // One verifier, shared: building it costs an Argon2id hash (the timing-equalized miss's + // decoy), and that is a startup cost rather than a per-request one. + let credentials = Credentials::new().map_err(|error| BootError::Credentials { + detail: error.detail, + })?; + let accounts = Arc::new(InMemoryAccounts::new(credentials)); + + let index = Arc::new(InMemoryAssetIndex::new()); + let uploads = Arc::new(InMemoryUploadSessions::with_default_ttl(clock.clone())); + let albums = Arc::new(InMemoryAlbums::new()); + let directories = Arc::new(InMemoryDeviceDirectory::new()); + // The production write authority (`S-C19`/`S-C20`), not a permissive double: it reads the + // album's own pin and the account's published device directory, so invariants 6 and 7 mean + // what they say even in the development profile. + let authority = Arc::new(ProvisionedAuthority::new( + albums.clone(), + directories.clone(), + clock.clone(), + )); + let quotas = Arc::new(InMemoryQuota::new()); + let marks = Arc::new(InMemoryCollection::new()); + let receipts = Arc::new(InMemoryReceipts::new()); + // Distinct from the token signer, as the design requires: a receipt that verified under the + // operational key would let anything holding that key manufacture custody evidence. The + // separation is structural here — the seed is a different HKDF `info` over the same input, + // so an operator cannot accidentally configure one key for both. + let attestation_key = Arc::new(LocalAttestationKey::new( + config.server_domain.clone(), + capsule_core::crypto::keys::HybridSigningKey::from_seed64(&seed), + )); + + let server_info = Arc::new(ServerInfo::new( + config.server_domain.clone(), + config.api_base_url.clone(), + ProtocolWindow { + min: config.protocol_min.clone(), + max: config.protocol_max.clone(), + }, + tokens.public_key().to_vec(), + )); + + let app = App::new(Modules { + auth: AuthContext::new(AuthCollaborators { + sessions: Arc::new(InMemoryAuthState::with_default_ttl(clock.clone())), + accounts: accounts.clone(), + registry: accounts.clone(), + profiles: accounts.clone(), + passwords: accounts.clone(), + challenges: Arc::new(InMemoryChallenges::with_default_ttl(clock.clone())), + cohorts: Arc::new(InMemoryCohorts::new()), + tokens: tokens.clone(), + clock: clock.clone(), + }), + totp: TotpContext::new( + Arc::new(InMemoryTotp::new()), + // The issuer is what an authenticator app shows beside the code, so it is this + // deployment's own name rather than a constant every deployment shares. + Arc::new(TotpCodes::new(config.server_domain.clone())), + ), + upload: UploadContext::new( + uploads.clone(), + blobs.clone(), + index.clone(), + authority.clone(), + clock.clone(), + UploadPolicy::default(), + ), + sync: SyncContext::new( + index.clone(), + blobs.clone(), + Arc::new(CursorCodec::new(&cursor_key)), + ), + serve: ServeContext::new( + index.clone(), + blobs.clone(), + marks.clone(), + uploads.clone(), + crate::serve::owned_assets(), + ), + verify: VerifyContext::new(index.clone(), blobs.clone(), marks.clone(), clock.clone()), + directories: DeviceDirectoryContext::new(directories.clone(), clock.clone()), + albums: AlbumContext::new(albums.clone(), clock.clone()), + // Unlimited, which is what a self-hosted deployment runs. A configurable ceiling is a + // quota policy this slice does not own; `QuotaLimits` already takes one. + quota: QuotaContext::new(quotas.clone(), clock.clone(), QuotaLimits::unlimited()), + attestation: AttestationContext::new( + receipts.clone(), + attestation_key, + // The published key has been active since the epoch, because the seed is derived + // deterministically and has therefore never *not* been this deployment's key. + // Publishing a rotation history is `ATTESTATION_KEY_HISTORY`'s job and nobody's yet. + Timestamp::UNIX_EPOCH, + ), + discovery: DiscoveryContext::new( + server_info, + Arc::new(InMemoryRevocations::new(clock.clone())), + ), + escrow: EscrowContext::new(Arc::new(InMemoryEscrow::new()), clock.clone()), + enrollment: EnrollmentContext::new( + Arc::new(InMemoryEnrollments::with_default_ttl(clock.clone())), + Arc::new(InMemoryChannels::with_default_ttl(clock.clone())), + clock.clone(), + ), + moderation: ModerationContext::new(Arc::new(InMemoryModeration::new())), + share: ShareContext::new( + Arc::new(InMemoryShares::new()), + blobs.clone(), + clock.clone(), + ), + drops: DropContext::new( + Arc::new(InMemoryDrops::new()), + uploads.clone(), + blobs.clone(), + clock.clone(), + ), + counters: CounterContext::new(Arc::new(InMemoryCounters::new()), clock.clone()), + }); + + tracing::info!( + blob_root = %root.display(), + server_id = %config.server_domain, + protocol_min = %config.protocol_min, + protocol_max = %config.protocol_max, + "assembled a server on the in-memory adapters" + ); + + Ok(Assembled { + // One index, one blob store and one mark store behind all three, which is what makes + // "upload it, then let the collector see it" a property of the server rather than of + // three disconnected assemblies. + collection: CollectionContext::new( + index.clone(), + blobs.clone(), + marks, + quotas, + clock, + config.grace_window, + ), + scrub: ScrubContext::new(index, blobs, uploads), + app, + }) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::{BootError, assemble}; + use crate::config::{Config, Demands, Overrides}; + + /// A PKCS#8 v1 Ed25519 key, base64. Signs nothing; see `config`'s own tests. + const EXAMPLE_DER: &str = "MC4CAQAwBQYDK2VwBCIEIN6eTvXEL7xMZWHY8rTk7VbQSGSuRkle5MVfiiYUStLF"; + + fn memory_config(root: &std::path::Path) -> Config { + let environment: BTreeMap = [ + ("BLOB_ROOT".to_owned(), root.display().to_string()), + ("JWT_ED25519_DER".to_owned(), EXAMPLE_DER.to_owned()), + ] + .into_iter() + .collect(); + let overrides = Overrides { + memory: true, + ..Overrides::default() + }; + Config::load(&environment, &overrides, Demands::Serve).expect("the configuration loads") + } + + #[tokio::test] + async fn the_memory_profile_assembles_a_server_whose_router_builds() { + // The property nothing in this crate asserted before: seventeen module contexts, every + // port filled, and a router Kynos will build out of them. + let root = tempfile::tempdir().expect("a scratch directory"); + let assembled = assemble(&memory_config(root.path())) + .await + .expect("it assembles"); + assembled.service().expect("the router builds"); + } + + #[tokio::test] + async fn the_blob_root_is_created_rather_than_required_to_exist() { + let parent = tempfile::tempdir().expect("a scratch directory"); + let root = parent.path().join("does/not/exist/yet"); + let assembled = assemble(&memory_config(&root)).await.expect("it assembles"); + assert!(root.join("blobs").is_dir(), "the store's tree is created"); + drop(assembled); + } + + #[tokio::test] + async fn a_signing_key_that_is_not_ed25519_refuses_the_boot() { + // `SigningKeyError` has always been documented as a startup failure. This is the startup + // it fails. + let root = tempfile::tempdir().expect("a scratch directory"); + let environment: BTreeMap = [ + ("BLOB_ROOT".to_owned(), root.path().display().to_string()), + ( + "JWT_ED25519_DER".to_owned(), + base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + b"not a PKCS#8 document", + ), + ), + ] + .into_iter() + .collect(); + let overrides = Overrides { + memory: true, + ..Overrides::default() + }; + let config = + Config::load(&environment, &overrides, Demands::Serve).expect("it is well-formed"); + let error = assemble(&config).await.expect_err("it refuses"); + assert!(matches!(error, BootError::SigningKey { .. }), "{error:?}"); + } + + #[tokio::test] + async fn a_durable_backend_refuses_by_name_rather_than_falling_back() { + // The half of `store/mod.rs`'s claim that `Config::load` cannot make: the operator did + // set `VALKEY_URL`, and nothing reads it yet. Falling back to the in-memory adapters + // here is the one thing that must never happen. + let root = tempfile::tempdir().expect("a scratch directory"); + let environment: BTreeMap = [ + ("BLOB_ROOT".to_owned(), root.path().display().to_string()), + ("JWT_ED25519_DER".to_owned(), EXAMPLE_DER.to_owned()), + ("VALKEY_URL".to_owned(), "redis://127.0.0.1:6379".to_owned()), + ] + .into_iter() + .collect(); + let config = Config::load(&environment, &Overrides::default(), Demands::Serve) + .expect("it is well-formed"); + let error = assemble(&config).await.expect_err("it refuses"); + assert!( + matches!( + error, + BootError::AdapterUnavailable { + key: "VALKEY_URL", + .. + } + ), + "{error:?}" + ); + assert!(format!("{error}").contains("#403"), "{error}"); + } + + #[tokio::test] + async fn the_published_signing_key_is_the_one_the_tokens_verify_under() { + // Not a coincidence to be re-checked at every deployment: `ServerInfo` is built from + // `tokens.public_key()`, so there is no second copy for an operator to paste wrongly. + use crate::auth::SessionTokens; + use crate::store::SystemClock; + + let root = tempfile::tempdir().expect("a scratch directory"); + let config = memory_config(root.path()); + let assembled = assemble(&config).await.expect("it assembles"); + let expected = SessionTokens::from_pkcs8( + config + .signing_key_der + .as_ref() + .expect("the key is configured") + .expose(), + std::sync::Arc::new(SystemClock), + ) + .expect("the key parses") + .public_key() + .to_vec(); + + // Read back the way a client would, through the surface rather than through a field. + let client = kynos::test::TestClient::new(assembled.service().expect("the router builds")); + let body: serde_json::Value = client + .get("/.well-known/capsule/server-info") + .header("accept", "application/json") + .send() + .await + .assert_status(kynos::http::StatusCode::OK) + .json(); + let published = body["signing_key"].as_str().expect("it is published"); + assert_eq!( + published, + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &expected) + ); + } + + #[tokio::test] + async fn the_published_protocol_window_is_the_configured_one() { + let root = tempfile::tempdir().expect("a scratch directory"); + let config = memory_config(root.path()); + let assembled = assemble(&config).await.expect("it assembles"); + let client = kynos::test::TestClient::new(assembled.service().expect("the router builds")); + let body: serde_json::Value = client + .get("/.well-known/capsule/server-info") + .header("accept", "application/json") + .send() + .await + .assert_status(kynos::http::StatusCode::OK) + .json(); + assert_eq!(body["protocol_version"]["min"], config.protocol_min); + assert_eq!(body["protocol_version"]["max"], config.protocol_max); + assert_eq!(body["server_id"], config.server_domain); + assert_eq!(body["api_base_url"], config.api_base_url); + } + + #[tokio::test] + async fn an_account_can_be_registered_and_signed_in_to() { + // The whole point of the amended deliverable boundary: `mise run serve-memory` is a + // server a client developer can point at, not a surface they can only read. + let root = tempfile::tempdir().expect("a scratch directory"); + let assembled = assemble(&memory_config(root.path())) + .await + .expect("it assembles"); + let client = kynos::test::TestClient::new(assembled.service().expect("the router builds")); + + let registered: serde_json::Value = client + .post("/v1/auth/register") + .header("accept", "application/json") + .json(&serde_json::json!({ + "email": "somebody@example.test", + "password": "correct horse battery staple", + })) + .send() + .await + .assert_status(kynos::http::StatusCode::OK) + .json(); + assert!(registered["access_token"].is_string(), "{registered}"); + + let signed_in: serde_json::Value = client + .post("/v1/auth/login") + .header("accept", "application/json") + .json(&serde_json::json!({ + "email": "somebody@example.test", + "password": "correct horse battery staple", + })) + .send() + .await + .assert_status(kynos::http::StatusCode::OK) + .json(); + assert!(signed_in["access_token"].is_string(), "{signed_in}"); + } + + #[tokio::test] + async fn a_wrong_password_is_refused_rather_than_granted() { + // The property that makes the adapter real rather than permissive: the credential + // double `tests/support/mod.rs` warns about would accept this. + let root = tempfile::tempdir().expect("a scratch directory"); + let assembled = assemble(&memory_config(root.path())) + .await + .expect("it assembles"); + let client = kynos::test::TestClient::new(assembled.service().expect("the router builds")); + client + .post("/v1/auth/register") + .header("accept", "application/json") + .json(&serde_json::json!({ + "email": "somebody@example.test", + "password": "correct horse battery staple", + })) + .send() + .await + .assert_status(kynos::http::StatusCode::OK); + client + .post("/v1/auth/login") + .header("accept", "application/json") + .json(&serde_json::json!({ + "email": "somebody@example.test", + "password": "the wrong password entirely", + })) + .send() + .await + .assert_status(kynos::http::StatusCode::UNAUTHORIZED); + } +} diff --git a/capsule-server/src/config.rs b/capsule-server/src/config.rs new file mode 100644 index 00000000..7a587477 --- /dev/null +++ b/capsule-server/src/config.rs @@ -0,0 +1,939 @@ +//! [`Config`] — everything an operator gets to decide, read once at startup. +//! +//! # Environment only, and why there is no file +//! +//! `--config PATH` is accepted on the command line and **refused**: a configuration-file crate +//! would be a new dependency in a domain `design/dependencies.md` has no row for, and the +//! server this replaces was environment-only plus `dotenvy` +//! (`legacy-review/server-salvo/environment/`), so an operator loses nothing familiar. The flag +//! exists rather than being absent so the refusal is a sentence rather than clap's "unexpected +//! argument", and so the precedence table below already names the slot a file layer would sit +//! in. +//! +//! Precedence, highest first: **command line → process environment → built-in default.** +//! +//! # Every fault, once +//! +//! [`Config::load`] reports **all** of them ([`ConfigError`] holds a list) instead of failing on +//! the first. An operator bringing a deployment up otherwise restarts the process once per +//! variable, learning one missing key at a time from a server that already knew about four. +//! +//! # What is required depends on the subcommand +//! +//! `gc`, `purge` and `scrub` need a blob root and **no key material** — demanding a signing key +//! to sweep a directory would be a reason to keep a production key on a maintenance host. So +//! the requirement set is a parameter ([`Demands`]) rather than a property of the type. +//! +//! # Secrets +//! +//! [`SecretBytes`] redacts itself in `Debug`, the way +//! [`SessionTokens`](crate::auth::SessionTokens) does by hand: `Config` is logged at startup, +//! and a `Debug` that printed the token-signing key is how one reaches a log file. + +use std::collections::BTreeMap; +use std::fmt; +use std::net::{IpAddr, SocketAddr}; +use std::num::NonZeroUsize; +use std::path::PathBuf; + +use base64::Engine as _; +use base64::engine::general_purpose::{STANDARD as BASE64, STANDARD_NO_PAD as BASE64_NO_PAD}; +use jiff::SignedDuration; + +use crate::sync::CURSOR_KEY_LEN; + +/// The bind address a deployment gets without saying anything. +const DEFAULT_LISTEN: &str = "0.0.0.0:3000"; + +/// The port half of [`DEFAULT_LISTEN`], for composing `SERVER_HOST` with no `SERVER_PORT`. +const DEFAULT_PORT: u16 = 3000; + +/// The domain a deployment gets without saying anything. +const DEFAULT_DOMAIN: &str = "localhost"; + +/// The drain deadline, matching Kynos's own default — under the usual 30-second orchestrator +/// termination window, which is the whole reason that number is what it is. +const DEFAULT_SHUTDOWN_TIMEOUT: u64 = 25; + +/// The accepted-connection ceiling, matching Kynos's own default. +const DEFAULT_MAX_CONNECTIONS: usize = 10_000; + +/// The seed [`HybridSigningKey`](capsule_core::crypto::keys::HybridSigningKey) is built from. +const ATTESTATION_SEED_LEN: usize = 64; + +/// HKDF `info` for the sync-cursor MAC key derived from the token-signing key. +const CURSOR_KEY_INFO: &[u8] = b"capsule/sync-cursor-mac/v1"; + +/// HKDF `info` for the attestation seed derived from the token-signing key. +const ATTESTATION_SEED_INFO: &[u8] = b"capsule/attestation-seed/v1"; + +/// Where a setting is read from. +/// +/// A trait rather than [`std::env::var`] directly so the precedence table is a unit test rather +/// than a claim: a test builds the environment it wants and asserts what came out, with no +/// process-global state two concurrent tests would fight over. +pub trait Environment: fmt::Debug { + /// The value of `key`, or `None` when it is unset **or set to the empty string**. + /// + /// Empty is absent on purpose. `FOO=` in a compose file or a `.env` is how an operator + /// writes "I did not set this", and a server that read it as a zero-length signing key + /// would fail somewhere much less obvious than here. + fn var(&self, key: &str) -> Option; +} + +/// The real process environment. +#[derive(Debug, Clone, Copy)] +pub struct ProcessEnvironment; + +impl Environment for ProcessEnvironment { + fn var(&self, key: &str) -> Option { + std::env::var(key).ok().filter(|value| !value.is_empty()) + } +} + +impl Environment for BTreeMap { + fn var(&self, key: &str) -> Option { + self.get(key).filter(|value| !value.is_empty()).cloned() + } +} + +/// Bytes that must not be printed. +#[derive(Clone, PartialEq, Eq)] +pub struct SecretBytes(Vec); + +impl SecretBytes { + /// Hold `bytes` as a secret. + pub fn new(bytes: Vec) -> Self { + Self(bytes) + } + + /// The bytes, for the one caller that has to use them. + pub fn expose(&self) -> &[u8] { + &self.0 + } +} + +impl fmt::Debug for SecretBytes { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("SecretBytes()") + } +} + +/// Which family of adapters a process runs on. +/// +/// Two arms, and the second one is not implemented yet — see +/// [`assemble`](crate::boot::assemble). It is an enum rather than a trait because the +/// `Arc` fields in [`Modules`](crate::app::Modules) already **are** the abstraction; +/// a second one over the top would abstract the composition root from itself. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Backends { + /// Every deterministic in-crate adapter, over a real filesystem blob store. + /// + /// An explicit operator act (`--memory`, or `CAPSULE_PROFILE=memory`) and **never** a + /// fallback: a deployment that forgets `VALKEY_URL` must fail closed rather than come up + /// holding state it will lose on the next restart. + Memory, + /// Postgres and Valkey, selected by `DATABASE_URL` and `VALKEY_URL`. + Durable, +} + +/// How the log stream is rendered. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LogFormat { + /// One JSON object per event — what a log shipper wants. + Json, + /// Multi-line, coloured, human-first — what a developer wants. + Pretty, +} + +/// What a subcommand needs before it can do anything. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Demands { + /// `serve`: a blob root, a token-signing key, and a chosen backend family. + Serve, + /// `gc` / `purge` / `scrub`: a blob root, and deliberately no key material. + Maintenance, + /// `gen-openapi`: nothing at all. The document is a property of the router's types. + Nothing, +} + +/// One thing wrong with the configuration. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum ConfigFault { + /// A required setting is absent. + #[error("{key} is required and is not set")] + Missing { + /// The environment variable or flag an operator has to set. + key: &'static str, + }, + /// A setting is present and cannot be used. + /// + /// `detail` never quotes the value: `JWT_ED25519_DER` is a private key, and a startup error + /// is the most-copied line in any incident channel. + #[error("{key} is not usable: {detail}")] + Invalid { + /// The setting. + key: &'static str, + /// What is wrong with it — never the value itself. + detail: String, + }, + /// A setting is understood, accepted at the boundary, and not implemented. + #[error("{key} is not supported yet: {detail}")] + Unsupported { + /// The setting. + key: &'static str, + /// What to do instead. + detail: String, + }, +} + +/// Everything wrong with the configuration, in one message. +/// +/// `Display` is hand-written rather than `thiserror`-generated because the message *is* a list; +/// a derived one-line format would put five faults on one line, which is the shape an operator +/// reads worst. The variants themselves are `thiserror` as the repository requires. +#[derive(Debug, PartialEq, Eq)] +pub struct ConfigError { + faults: Vec, +} + +impl ConfigError { + /// Every fault found, in the order the fields are read. + pub fn faults(&self) -> &[ConfigFault] { + &self.faults + } + + /// Whether `key` is among the faults, for a test that asserts one was reported. + pub fn names(&self, key: &str) -> bool { + self.faults.iter().any(|fault| match fault { + ConfigFault::Missing { key: named } + | ConfigFault::Invalid { key: named, .. } + | ConfigFault::Unsupported { key: named, .. } => *named == key, + }) + } +} + +impl fmt::Display for ConfigError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "the server configuration is not usable ({} problem{})", + self.faults.len(), + if self.faults.len() == 1 { "" } else { "s" } + )?; + for fault in &self.faults { + write!(f, "\n - {fault}")?; + } + Ok(()) + } +} + +impl std::error::Error for ConfigError {} + +/// The command line's say, applied over the environment. +/// +/// Every field is an `Option` (or a `bool` that is only ever set) so "the operator did not pass +/// this flag" and "the operator passed this flag with the default value" are different states — +/// which is what makes the precedence table implementable at all. +#[derive(Debug, Clone, Default)] +pub struct Overrides { + /// `--config PATH`. Refused; see the module docs. + pub config_file: Option, + /// `--listen HOST:PORT`. + pub listen: Option, + /// `--blob-root PATH`. + pub blob_root: Option, + /// `--memory`. + pub memory: bool, + /// `--grace-window-hours N`. + pub grace_window_hours: Option, +} + +/// Everything an operator gets to decide. +#[derive(Debug, Clone)] +pub struct Config { + /// Where the process accepts connections. + pub listen: SocketAddr, + /// This deployment's canonical origin — the `server_id` every published record carries. + pub server_domain: String, + /// The absolute base URL clients reach the versioned API at. + pub api_base_url: String, + /// The filesystem tree ciphertext blobs are written to. There is no object store. + pub blob_root: Option, + /// The Postgres URL, once an adapter reads it (#402). + pub database_url: Option, + /// The Valkey URL, once an adapter reads it (#403). + pub valkey_url: Option, + /// The PKCS#8 Ed25519 private key access and refresh tokens are signed with. + pub signing_key_der: Option, + /// The HMAC key sync cursors are authenticated under. + pub sync_cursor_mac_key: Option<[u8; CURSOR_KEY_LEN]>, + /// The seed the attestation signing key is built from. + pub attestation_key_seed: Option<[u8; ATTESTATION_SEED_LEN]>, + /// The oldest `protocol_version` accepted for writes. + pub protocol_min: String, + /// The newest `protocol_version` this server speaks. + pub protocol_max: String, + /// How long a blob sits at zero references before the collector may sweep it. + pub grace_window: SignedDuration, + /// How long a shutdown may take to drain. + pub shutdown_timeout: std::time::Duration, + /// The accepted-connection ceiling. + pub max_connections: NonZeroUsize, + /// How the log stream is rendered. + pub log_format: LogFormat, + /// Which family of adapters to run on. + pub backends: Backends, +} + +impl Config { + /// Read the configuration for a subcommand that demands `demands`. + /// + /// # Errors + /// + /// Returns [`ConfigError`] carrying **every** fault found, never only the first. + #[allow( + clippy::too_many_lines, + reason = "one pass over the settings table, in the order the table is written" + )] + pub fn load( + env: &dyn Environment, + overrides: &Overrides, + demands: Demands, + ) -> Result { + let mut faults = Vec::new(); + + if let Some(path) = &overrides.config_file { + faults.push(ConfigFault::Unsupported { + key: "--config", + detail: format!( + "config files are not supported yet; every setting is read from the \ + environment, so drop `--config {}` and export the variables instead", + path.display() + ), + }); + } + + // ── Listener ──────────────────────────────────────────────────────────────────── + let port = parse_number::(env, "SERVER_PORT", &mut faults).unwrap_or(DEFAULT_PORT); + let listen = overrides.listen.or_else(|| { + let host = env.var("SERVER_HOST")?; + match host.parse::() { + Ok(address) => Some(SocketAddr::new(address, port)), + Err(error) => { + faults.push(ConfigFault::Invalid { + key: "SERVER_HOST", + // The value is an address, not a secret, and a typo is the whole point. + detail: format!("`{host}` is not an IP address to bind ({error})"), + }); + None + } + } + }); + let listen = listen.unwrap_or_else(|| { + let default: SocketAddr = DEFAULT_LISTEN + .parse() + .expect("the built-in default listener parses"); + SocketAddr::new(default.ip(), port) + }); + + // ── Identity ──────────────────────────────────────────────────────────────────── + let server_domain = env + .var("SERVER_DOMAIN") + .unwrap_or_else(|| DEFAULT_DOMAIN.to_owned()); + // `/v1` included: `ServerInfo` derives the published auth endpoints by appending to this, + // so a base URL without the version prefix publishes `http://host/auth/login`, which no + // route serves. The default is what a developer reaches on their own machine. + let api_base_url = env + .var("API_BASE_URL") + .unwrap_or_else(|| format!("http://{server_domain}:{}/v1", listen.port())); + + // ── Storage ───────────────────────────────────────────────────────────────────── + // `UPLOAD_DIR` is the name the retired deployment used, accepted so an operator's + // existing environment keeps working, and warned about so it does not become the name. + let blob_root = overrides + .blob_root + .clone() + .or_else(|| env.var("BLOB_ROOT").map(PathBuf::from)) + .or_else(|| { + let legacy = env.var("UPLOAD_DIR").map(PathBuf::from)?; + tracing::warn!( + "UPLOAD_DIR is the retired name for BLOB_ROOT and is still honoured; \ + rename it" + ); + Some(legacy) + }); + let database_url = env.var("DATABASE_URL"); + let valkey_url = env.var("VALKEY_URL"); + + // ── Backend family ────────────────────────────────────────────────────────────── + let backends = if overrides.memory + || env + .var("CAPSULE_PROFILE") + .is_some_and(|profile| profile.eq_ignore_ascii_case("memory")) + { + Backends::Memory + } else { + Backends::Durable + }; + + // ── Key material ──────────────────────────────────────────────────────────────── + let signing_key_der = + decode_base64(env, "JWT_ED25519_DER", &mut faults).map(SecretBytes::new); + let sync_cursor_mac_key = + decode_fixed::(env, "SYNC_CURSOR_MAC_KEY", &mut faults); + let attestation_key_seed = decode_seed(env, &mut faults); + + // Both are HKDF-derived from the token-signing key when unset, which is the right + // default for a single-server deployment and the reason the two variables are optional: + // an operator sets them explicitly only to rotate one independently of the token key, or + // to share an attestation identity across replicas. + let (sync_cursor_mac_key, attestation_key_seed) = match &signing_key_der { + Some(der) => ( + sync_cursor_mac_key + .or_else(|| derive::(der.expose(), CURSOR_KEY_INFO)), + attestation_key_seed.or_else(|| { + derive::(der.expose(), ATTESTATION_SEED_INFO) + }), + ), + None => (sync_cursor_mac_key, attestation_key_seed), + }; + + // ── Protocol window ───────────────────────────────────────────────────────────── + let protocol_max = env + .var("PROTOCOL_MAX") + .unwrap_or_else(|| capsule_core::crypto::PROTOCOL_VERSION.to_owned()); + let protocol_min = env + .var("PROTOCOL_MIN") + .unwrap_or_else(|| capsule_core::crypto::PROTOCOL_VERSION.to_owned()); + if protocol_min > protocol_max { + faults.push(ConfigFault::Invalid { + key: "PROTOCOL_MIN", + detail: format!("`{protocol_min}` is newer than PROTOCOL_MAX `{protocol_max}`"), + }); + } + + // ── Operational knobs ─────────────────────────────────────────────────────────── + let grace_window = overrides + .grace_window_hours + .or_else(|| parse_number::(env, "GC_GRACE_WINDOW_HOURS", &mut faults)) + .map_or(crate::gc::DEFAULT_GRACE_WINDOW, |hours| { + SignedDuration::from_hours(i64::try_from(hours).unwrap_or(i64::MAX)) + }); + let shutdown_timeout = std::time::Duration::from_secs( + parse_number::(env, "SHUTDOWN_TIMEOUT_SECONDS", &mut faults) + .unwrap_or(DEFAULT_SHUTDOWN_TIMEOUT), + ); + let max_connections = parse_number::(env, "MAX_CONNECTIONS", &mut faults) + .and_then(|limit| { + NonZeroUsize::new(limit).or_else(|| { + faults.push(ConfigFault::Invalid { + key: "MAX_CONNECTIONS", + detail: "zero would accept nothing at all".to_owned(), + }); + None + }) + }) + .unwrap_or_else(|| { + NonZeroUsize::new(DEFAULT_MAX_CONNECTIONS) + .expect("the built-in connection ceiling is non-zero") + }); + let log_format = match env.var("LOG_FORMAT") { + None => { + // JSON in release because the reader is a log shipper; pretty in debug because + // the reader is a person with the source open. + if cfg!(debug_assertions) { + LogFormat::Pretty + } else { + LogFormat::Json + } + } + Some(format) if format.eq_ignore_ascii_case("json") => LogFormat::Json, + Some(format) if format.eq_ignore_ascii_case("pretty") => LogFormat::Pretty, + Some(format) => { + faults.push(ConfigFault::Invalid { + key: "LOG_FORMAT", + detail: format!("`{format}` is neither `json` nor `pretty`"), + }); + LogFormat::Json + } + }; + + // ── What the subcommand demands ───────────────────────────────────────────────── + // + // Last, and after every parse, so one message carries both "this is malformed" and + // "that is missing" rather than a restart between them. + match demands { + Demands::Nothing => {} + Demands::Maintenance => { + require(blob_root.is_some(), "BLOB_ROOT", &mut faults); + } + Demands::Serve => { + require(blob_root.is_some(), "BLOB_ROOT", &mut faults); + require(signing_key_der.is_some(), "JWT_ED25519_DER", &mut faults); + // The refusal `store/mod.rs` has always documented and nothing has ever + // enforced: Valkey is required, and the in-memory adapters are a development + // profile an operator opts into rather than something to fall back on. + if backends == Backends::Durable && valkey_url.is_none() { + faults.push(ConfigFault::Missing { key: "VALKEY_URL" }); + } + } + } + + if faults.is_empty() { + Ok(Self { + listen, + server_domain, + api_base_url, + blob_root, + database_url, + valkey_url, + signing_key_der, + sync_cursor_mac_key, + attestation_key_seed, + protocol_min, + protocol_max, + grace_window, + shutdown_timeout, + max_connections, + log_format, + backends, + }) + } else { + Err(ConfigError { faults }) + } + } +} + +/// Record a missing required setting. +fn require(present: bool, key: &'static str, faults: &mut Vec) { + if !present { + faults.push(ConfigFault::Missing { key }); + } +} + +/// Parse `key` as `T`, recording a fault rather than failing the whole read. +fn parse_number( + env: &dyn Environment, + key: &'static str, + faults: &mut Vec, +) -> Option +where + T: std::str::FromStr, + T::Err: fmt::Display, +{ + let raw = env.var(key)?; + match raw.trim().parse::() { + Ok(value) => Some(value), + Err(error) => { + faults.push(ConfigFault::Invalid { + key, + detail: format!("`{raw}` is not a number this setting accepts ({error})"), + }); + None + } + } +} + +/// Decode `key` from base64, accepting padded and unpadded input. +/// +/// Two alphabets rather than one because the documented way to produce `JWT_ED25519_DER` is +/// `openssl genpkey … | base64 -w 0`, and a shell pipeline that strips the padding is common +/// enough that refusing it would be a support question rather than a security property. +fn decode_base64( + env: &dyn Environment, + key: &'static str, + faults: &mut Vec, +) -> Option> { + let raw = env.var(key)?; + let trimmed = raw.trim(); + if let Ok(bytes) = BASE64.decode(trimmed) { + return Some(bytes); + } + match BASE64_NO_PAD.decode(trimmed) { + Ok(bytes) => Some(bytes), + Err(error) => { + faults.push(ConfigFault::Invalid { + key, + // The error names a position, never the bytes: this value is a private key. + detail: format!("it is not base64 ({error})"), + }); + None + } + } +} + +/// Decode `key` from base64 and require exactly `N` bytes. +fn decode_fixed( + env: &dyn Environment, + key: &'static str, + faults: &mut Vec, +) -> Option<[u8; N]> { + let bytes = decode_base64(env, key, faults)?; + let found = bytes.len(); + <[u8; N]>::try_from(bytes.as_slice()).ok().or_else(|| { + faults.push(ConfigFault::Invalid { + key, + detail: format!("it decodes to {found} bytes and must be exactly {N}"), + }); + None + }) +} + +/// Decode `ATTESTATION_KEY_SEED`, accepting 32 bytes and expanding them to 64. +/// +/// Thirty-two is what every general-purpose "generate a seed" instruction produces, and the +/// hybrid signing key needs sixty-four; expanding rather than refusing means an operator's +/// `openssl rand -base64 32` works, and the expansion is domain-separated so the two halves are +/// not the same 32 bytes twice. +fn decode_seed( + env: &dyn Environment, + faults: &mut Vec, +) -> Option<[u8; ATTESTATION_SEED_LEN]> { + let bytes = decode_base64(env, "ATTESTATION_KEY_SEED", faults)?; + match bytes.len() { + ATTESTATION_SEED_LEN => <[u8; ATTESTATION_SEED_LEN]>::try_from(bytes.as_slice()).ok(), + 32 => derive::(&bytes, ATTESTATION_SEED_INFO), + found => { + faults.push(ConfigFault::Invalid { + key: "ATTESTATION_KEY_SEED", + detail: format!( + "it decodes to {found} bytes and must be 32 or {ATTESTATION_SEED_LEN}" + ), + }); + None + } + } +} + +/// HKDF-SHA256 `secret` into `N` bytes under `info`. +/// +/// `ring` rather than a second HKDF implementation: it is already this crate's HMAC for the sync +/// cursor, so the derived key and the key it authenticates come from one primitive. +fn derive(secret: &[u8], info: &[u8]) -> Option<[u8; N]> { + /// The output length, as `ring`'s key-type trait wants it. + #[derive(Debug, Clone, Copy)] + struct Len(usize); + + impl ring::hkdf::KeyType for Len { + fn len(&self) -> usize { + self.0 + } + } + + // An empty salt is HKDF's documented default and the right one here: the derivation is + // domain-separated by `info`, and a salt would have to be configured — one more variable an + // operator can get wrong for no gain, because the input is already a private key. + let prk = ring::hkdf::Salt::new(ring::hkdf::HKDF_SHA256, &[]).extract(secret); + let mut out = [0u8; N]; + prk.expand(&[info], Len(N)).ok()?.fill(&mut out).ok()?; + Some(out) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::{Backends, Config, Demands, LogFormat, Overrides}; + + /// A PKCS#8 v1 Ed25519 key, base64, from the retired deployment's own `.env.example`. + /// + /// A committed *example* key rather than a generated one, because these tests assert + /// **derivation is deterministic**, and a fresh key per run would make that unassertable. + /// It signs nothing: no deployment ever used it, and any that did published the fact. + const EXAMPLE_DER: &str = "MC4CAQAwBQYDK2VwBCIEIN6eTvXEL7xMZWHY8rTk7VbQSGSuRkle5MVfiiYUStLF"; + + fn env(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) + .collect() + } + + /// The environment a `serve --memory` needs and nothing more. + fn serveable() -> BTreeMap { + env(&[ + ("BLOB_ROOT", "/var/lib/capsule/blobs"), + ("JWT_ED25519_DER", EXAMPLE_DER), + ]) + } + + fn memory() -> Overrides { + Overrides { + memory: true, + ..Overrides::default() + } + } + + #[test] + fn the_defaults_are_a_complete_configuration() { + let config = Config::load(&serveable(), &memory(), Demands::Serve).expect("it loads"); + assert_eq!(config.listen.to_string(), "0.0.0.0:3000"); + assert_eq!(config.server_domain, "localhost"); + assert_eq!(config.api_base_url, "http://localhost:3000/v1"); + assert_eq!(config.backends, Backends::Memory); + assert_eq!(config.protocol_min, config.protocol_max); + } + + #[test] + fn a_flag_beats_the_environment_which_beats_the_default() { + // The whole precedence table in one case: the environment moves the port off the + // built-in default, and the flag moves it off the environment. + let mut environment = serveable(); + environment.insert("SERVER_PORT".to_owned(), "5000".to_owned()); + + let from_env = Config::load(&environment, &memory(), Demands::Serve).expect("it loads"); + assert_eq!(from_env.listen.to_string(), "0.0.0.0:5000"); + + let overridden = Overrides { + listen: Some("127.0.0.1:6000".parse().expect("a literal address parses")), + ..memory() + }; + let from_flag = Config::load(&environment, &overridden, Demands::Serve).expect("it loads"); + assert_eq!(from_flag.listen.to_string(), "127.0.0.1:6000"); + } + + #[test] + fn server_host_and_server_port_compose_into_one_listener() { + let environment = env(&[ + ("BLOB_ROOT", "/blobs"), + ("JWT_ED25519_DER", EXAMPLE_DER), + ("SERVER_HOST", "127.0.0.1"), + ("SERVER_PORT", "8080"), + ]); + let config = Config::load(&environment, &memory(), Demands::Serve).expect("it loads"); + assert_eq!(config.listen.to_string(), "127.0.0.1:8080"); + } + + #[test] + fn every_fault_is_reported_in_one_pass() { + // The property the aggregate exists for: an operator bringing a deployment up learns + // about all four at once rather than restarting four times. + let environment = env(&[ + ("SERVER_PORT", "not-a-port"), + ("LOG_FORMAT", "yaml"), + ("MAX_CONNECTIONS", "0"), + ]); + let error = Config::load(&environment, &memory(), Demands::Serve).expect_err("it refuses"); + assert!(error.names("SERVER_PORT"), "{error}"); + assert!(error.names("LOG_FORMAT"), "{error}"); + assert!(error.names("MAX_CONNECTIONS"), "{error}"); + assert!(error.names("BLOB_ROOT"), "{error}"); + assert!(error.names("JWT_ED25519_DER"), "{error}"); + } + + #[test] + fn serving_without_valkey_and_without_the_memory_profile_is_refused_by_name() { + // `store/mod.rs` has documented this refusal since `S-C29` and nothing enforced it. + let error = Config::load(&serveable(), &Overrides::default(), Demands::Serve) + .expect_err("it refuses"); + assert!(error.names("VALKEY_URL"), "{error}"); + } + + #[test] + fn the_memory_profile_is_also_reachable_from_the_environment() { + let mut environment = serveable(); + environment.insert("CAPSULE_PROFILE".to_owned(), "Memory".to_owned()); + let config = + Config::load(&environment, &Overrides::default(), Demands::Serve).expect("it loads"); + assert_eq!(config.backends, Backends::Memory); + } + + #[test] + fn maintenance_needs_a_blob_root_and_no_key_material() { + // A maintenance host that had to hold the production token-signing key to sweep a + // directory would be a reason to put the key on a maintenance host. + let config = Config::load( + &env(&[("BLOB_ROOT", "/blobs")]), + &Overrides::default(), + Demands::Maintenance, + ) + .expect("it loads"); + assert!(config.signing_key_der.is_none()); + + let error = Config::load( + &env(&[("JWT_ED25519_DER", EXAMPLE_DER)]), + &Overrides::default(), + Demands::Maintenance, + ) + .expect_err("it refuses"); + assert!(error.names("BLOB_ROOT"), "{error}"); + } + + #[test] + fn describing_the_router_needs_nothing() { + let config = Config::load(&BTreeMap::new(), &Overrides::default(), Demands::Nothing) + .expect("it loads"); + assert!(config.blob_root.is_none()); + } + + #[test] + fn the_cursor_key_and_the_attestation_seed_are_derived_from_the_signing_key() { + let first = Config::load(&serveable(), &memory(), Demands::Serve).expect("it loads"); + let second = Config::load(&serveable(), &memory(), Demands::Serve).expect("it loads"); + + let cursor = first.sync_cursor_mac_key.expect("it is derived"); + let seed = first.attestation_key_seed.expect("it is derived"); + assert_eq!( + Some(cursor), + second.sync_cursor_mac_key, + "derivation is stable" + ); + assert_eq!( + Some(seed), + second.attestation_key_seed, + "derivation is stable" + ); + // Domain separation: the two derivations of one key must not be the same bytes. + assert_ne!( + &seed[..32], + &cursor[..], + "the two infos separate the outputs" + ); + } + + #[test] + fn an_explicit_cursor_key_wins_over_the_derived_one() { + let mut environment = serveable(); + let explicit = base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + [0x5C_u8; super::CURSOR_KEY_LEN], + ); + environment.insert("SYNC_CURSOR_MAC_KEY".to_owned(), explicit); + let config = Config::load(&environment, &memory(), Demands::Serve).expect("it loads"); + assert_eq!( + config.sync_cursor_mac_key, + Some([0x5C; super::CURSOR_KEY_LEN]) + ); + } + + #[test] + fn a_cursor_key_of_the_wrong_length_is_refused_by_length_and_not_by_value() { + let mut environment = serveable(); + environment.insert( + "SYNC_CURSOR_MAC_KEY".to_owned(), + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, [7_u8; 16]), + ); + let error = Config::load(&environment, &memory(), Demands::Serve).expect_err("it refuses"); + assert!(error.names("SYNC_CURSOR_MAC_KEY"), "{error}"); + assert!(format!("{error}").contains("16 bytes"), "{error}"); + } + + #[test] + fn a_thirty_two_byte_attestation_seed_is_expanded_rather_than_refused() { + let mut environment = serveable(); + environment.insert( + "ATTESTATION_KEY_SEED".to_owned(), + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, [3_u8; 32]), + ); + let config = Config::load(&environment, &memory(), Demands::Serve).expect("it loads"); + let seed = config.attestation_key_seed.expect("it is expanded"); + // Expanded, not repeated: the two halves of the hybrid key must not share a seed. + assert_ne!(&seed[..32], &seed[32..]); + } + + #[test] + fn a_signing_key_that_is_not_base64_is_refused_without_quoting_it() { + let mut environment = serveable(); + environment.insert( + "JWT_ED25519_DER".to_owned(), + "not base64 at all!!".to_owned(), + ); + let error = Config::load(&environment, &memory(), Demands::Serve).expect_err("it refuses"); + let message = format!("{error}"); + assert!(error.names("JWT_ED25519_DER"), "{message}"); + assert!( + !message.contains("not base64 at all"), + "a startup error must not echo the key material: {message}" + ); + } + + #[test] + fn an_unpadded_signing_key_loads() { + // `openssl genpkey … | base64` piped through anything that strips `=` is common enough + // that refusing it would be a support question rather than a security property. + let mut environment = serveable(); + environment.insert( + "JWT_ED25519_DER".to_owned(), + EXAMPLE_DER.trim_end_matches('=').to_owned(), + ); + assert!(Config::load(&environment, &memory(), Demands::Serve).is_ok()); + } + + #[test] + fn an_empty_variable_is_an_absent_one() { + // `FOO=` in a compose file means "I did not set this", and reading it as a zero-length + // signing key would fail somewhere much less obvious than here. + let mut environment = serveable(); + environment.insert("JWT_ED25519_DER".to_owned(), String::new()); + let error = Config::load(&environment, &memory(), Demands::Serve).expect_err("it refuses"); + assert!(error.names("JWT_ED25519_DER"), "{error}"); + } + + #[test] + fn the_retired_upload_dir_name_is_still_honoured() { + let environment = env(&[ + ("UPLOAD_DIR", "/legacy/uploads"), + ("JWT_ED25519_DER", EXAMPLE_DER), + ]); + let config = Config::load(&environment, &memory(), Demands::Serve).expect("it loads"); + assert_eq!( + config.blob_root.as_deref(), + Some(std::path::Path::new("/legacy/uploads")) + ); + } + + #[test] + fn a_config_file_is_refused_with_what_to_do_instead() { + let overrides = Overrides { + config_file: Some("/etc/capsule/server.toml".into()), + ..memory() + }; + let error = Config::load(&serveable(), &overrides, Demands::Serve).expect_err("it refuses"); + assert!(error.names("--config"), "{error}"); + assert!(format!("{error}").contains("environment"), "{error}"); + } + + #[test] + fn an_inverted_protocol_window_is_refused() { + let mut environment = serveable(); + environment.insert("PROTOCOL_MIN".to_owned(), "2099-01-01".to_owned()); + environment.insert("PROTOCOL_MAX".to_owned(), "2025-01-01".to_owned()); + let error = Config::load(&environment, &memory(), Demands::Serve).expect_err("it refuses"); + assert!(error.names("PROTOCOL_MIN"), "{error}"); + } + + #[test] + fn the_log_format_follows_the_build_profile_when_unset() { + let config = Config::load(&serveable(), &memory(), Demands::Serve).expect("it loads"); + let expected = if cfg!(debug_assertions) { + LogFormat::Pretty + } else { + LogFormat::Json + }; + assert_eq!(config.log_format, expected); + } + + #[test] + fn the_grace_window_is_hours_and_the_flag_beats_the_environment() { + let mut environment = serveable(); + environment.insert("GC_GRACE_WINDOW_HOURS".to_owned(), "72".to_owned()); + let config = Config::load(&environment, &memory(), Demands::Serve).expect("it loads"); + assert_eq!(config.grace_window, jiff::SignedDuration::from_hours(72)); + + let overrides = Overrides { + grace_window_hours: Some(1), + ..memory() + }; + let config = Config::load(&environment, &overrides, Demands::Serve).expect("it loads"); + assert_eq!(config.grace_window, jiff::SignedDuration::from_hours(1)); + } + + #[test] + fn a_secret_is_not_printed_by_debug() { + let config = Config::load(&serveable(), &memory(), Demands::Serve).expect("it loads"); + let rendered = format!("{config:?}"); + assert!(rendered.contains(""), "{rendered}"); + assert!(!rendered.contains("MC4CAQAwBQYDK2Vw"), "{rendered}"); + } +} diff --git a/capsule-server/src/lib.rs b/capsule-server/src/lib.rs index b021f1f8..3eda14bf 100644 --- a/capsule-server/src/lib.rs +++ b/capsule-server/src/lib.rs @@ -32,6 +32,14 @@ //! [`verify`] — is framework-free and testable without a router, which is why the operator //! workers ([`gc`], [`scrub`]) have no wire surface at all and cost nothing to exercise. //! +//! # How a process is assembled +//! +//! [`config`] reads what an operator decided; [`boot`] turns it into the one [`App`] the router +//! is built with. Both are library modules rather than binary code, because a composition root +//! that lives in `main` is a composition root nothing tests: `boot::assemble` is driven by unit +//! tests here and by the binary identically, so "the server can be built at all" is an +//! assertion rather than something discovered on a deployment. +//! //! # Every adapter is in-memory //! //! Every port in this crate has a deterministic in-memory adapter and a conformance suite, and @@ -47,6 +55,8 @@ pub mod attestation; pub mod auth; pub mod blob; pub mod body; +pub mod boot; +pub mod config; pub mod counter; pub mod directory; pub mod discovery; From 0ee91e35fe0049d48eba55b022d88784ec5e3474 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 22:53:58 -0400 Subject: [PATCH 049/243] refactor(server)!: replace the gen_openapi bin with a capsule-server binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gen_openapi` was this crate's only executable, and the Salvo tree it replaces shipped four — `capsule-gc`, `capsule-scrub`, `capsule-keygen` and its own document dump. Four executables would each carry their own copy of the configuration loader and the composition root, which is the duplication `boot` exists to prevent, so this is one `capsule-server` binary with subcommands, as `capsule-cli` already is. `main.rs` installs error reporting and dispatches; the parsing, the log stream and every subcommand body live in `cli`, in the library, so a test asserts against the same code the binary runs. Logs go to stderr. `gen-openapi` writes a path to stdout and the operator commands will write a report there, and a subscriber sharing that stream is how a pipeline ends up parsing a log line — the failure `capsule-cli/tests/cull_round_trip.rs` works around with `RUST_LOG=off`. `mise run openapi-kynos` and `openapi-check-kynos` re-point at the subcommand. The committed `openapi.json` is untouched and `openapi-check-kynos` passes against it, which is the strongest available evidence the port preserved the document byte for byte. BREAKING CHANGE: `cargo run -p capsule-server --bin gen_openapi` is now `cargo run -p capsule-server -- gen-openapi`. Refs #401 --- capsule-server/src/bin/gen_openapi.rs | 81 -------- capsule-server/src/cli.rs | 258 ++++++++++++++++++++++++++ capsule-server/src/lib.rs | 1 + capsule-server/src/main.rs | 19 ++ mise.toml | 4 +- 5 files changed, 280 insertions(+), 83 deletions(-) delete mode 100644 capsule-server/src/bin/gen_openapi.rs create mode 100644 capsule-server/src/cli.rs create mode 100644 capsule-server/src/main.rs diff --git a/capsule-server/src/bin/gen_openapi.rs b/capsule-server/src/bin/gen_openapi.rs deleted file mode 100644 index 11a40402..00000000 --- a/capsule-server/src/bin/gen_openapi.rs +++ /dev/null @@ -1,81 +0,0 @@ -//! Deterministic OpenAPI **3.2** document dump for the Kynos server (slice `S-C34`). -//! -//! Serializes [`capsule_server::openapi()`] to `capsule-server/openapi.json` and, with -//! `--check`, fails when the committed copy is stale. It is the drift guard for the rebuild's -//! central claim: that the description is derived from the types and cannot disagree with them. -//! -//! That claim is already enforced *inside* the crate — `assert_conformance` catches a response -//! the document did not predict, and `assert_declared_responses_covered` catches a promise no -//! test produced. Neither helps a **client**. A surface can be ported, the emitted document can -//! change shape, and nothing outside the crate notices until someone regenerates by hand. This -//! binary is what makes such a change fail. -//! -//! It needs no database, no Valkey, no key material, no disk and no network: `openapi()` builds -//! the router purely to describe it. That is what lets `--check` run in the Rust check gate, -//! exactly as `i18n-check` and the Salvo `openapi-check` do. -//! -//! **This is not yet the SDK's contract.** `capsule-sdk` still generates from -//! `capsule-sdk/openapi.json`, the Salvo document, and the two are deliberately gated -//! separately while the port proceeds — committing both as *the* contract at once would leave -//! no way to say which one a client should believe. The changeover is its own step: it also -//! drops the four `spargen::OmitRule` narrowings, which exist only because the Salvo document is -//! structurally invalid in ways Kynos cannot express. -//! -//! Usage: -//! - `gen_openapi [FILE]` writes the document (default `capsule-server/openapi.json`). -//! - `gen_openapi --check [FILE]` fails if the committed document is stale, writing nothing. - -use std::path::PathBuf; - -use clap::Parser; -use color_eyre::eyre::{Context, Result, bail}; - -#[derive(Parser)] -#[command(author, version, about, long_about = None)] -struct Cli { - /// Output path for the OpenAPI 3.2 document (relative to the repo root). - #[arg(value_name = "FILE", default_value = "capsule-server/openapi.json")] - output: PathBuf, - - /// Verify the committed document is up to date instead of writing it (CI drift gate). - #[arg(long)] - check: bool, -} - -fn main() -> Result<()> { - color_eyre::install()?; - let cli = Cli::parse(); - - let document = capsule_server::openapi() - .map_err(|e| color_eyre::eyre::eyre!("describing the router: {e}"))?; - // `to_json` is already pretty-printed; the trailing newline matches the Salvo dump so both - // committed documents are ordinary text files rather than one-line blobs in a diff. - let mut json = document - .to_json() - .wrap_err("serializing the OpenAPI document to JSON")?; - json.push('\n'); - - if cli.check { - let committed = std::fs::read_to_string(&cli.output).wrap_err_with(|| { - format!("cannot read committed document at {}", cli.output.display()) - })?; - if committed != json { - bail!( - "OpenAPI document at {} is out of sync with the server; run \ - `mise run openapi-kynos` and commit the result", - cli.output.display() - ); - } - println!("OpenAPI document is up to date: {}", cli.output.display()); - } else { - if let Some(parent) = cli.output.parent() { - std::fs::create_dir_all(parent) - .wrap_err_with(|| format!("creating {}", parent.display()))?; - } - std::fs::write(&cli.output, &json) - .wrap_err_with(|| format!("writing {}", cli.output.display()))?; - println!("Wrote {}", cli.output.display()); - } - - Ok(()) -} diff --git a/capsule-server/src/cli.rs b/capsule-server/src/cli.rs new file mode 100644 index 00000000..cc47e259 --- /dev/null +++ b/capsule-server/src/cli.rs @@ -0,0 +1,258 @@ +//! The `capsule-server` command line: one binary, several subcommands. +//! +//! # One binary and not four +//! +//! The Salvo tree shipped `capsule-gc`, `capsule-scrub`, `capsule-keygen` and `gen_openapi` as +//! separate `[[bin]]`s. Four executables would each need their own copy of the configuration +//! loader and the adapter seam — which is the duplication [`crate::boot`] exists to prevent — +//! and `design/filesystem/maintenance.md` calls the scrub "an operator-invoked command, +//! schedulable as a job" rather than a distinct executable. `capsule-cli` already sets the +//! one-binary-many-subcommands precedent. +//! +//! # Why the bodies live here and not in `main` +//! +//! `main.rs` installs error reporting and dispatches; everything a subcommand actually does is +//! in this module, in the library. That is what lets `tests/binary.rs` assert against the same +//! code the binary runs, and it is the shape `capsule-cli/src/main.rs` already has. +//! +//! # stdout is a data channel +//! +//! Every log line goes to **stderr**. `gen-openapi` writes a path to stdout and the operator +//! commands write a report there, and a subscriber sharing that stream is how a pipeline ends up +//! parsing a log line. `capsule-cli/tests/cull_round_trip.rs` has to set `RUST_LOG=off` to keep +//! stdout parseable, which is the failure mode being avoided here. + +use std::path::PathBuf; +use std::process::ExitCode; + +use clap::{Parser, Subcommand}; +use color_eyre::eyre::{Context as _, Result, bail}; +use tracing_subscriber::prelude::*; +use tracing_subscriber::{EnvFilter, fmt}; + +use crate::config::{Config, Demands, Environment, LogFormat, Overrides, ProcessEnvironment}; + +/// The exit code a configuration refusal produces. +/// +/// Two, and distinct from [`EXIT_FINDINGS`] on purpose: a wrapper script has to be able to tell +/// "you configured this wrongly" from "the store is not clean", and both being `1` would make a +/// misconfigured cron job look like a corrupted store. It is also the code clap itself uses for +/// a usage error, so the two kinds of "the invocation was wrong" agree. +pub const EXIT_MISCONFIGURED: u8 = 2; + +/// The exit code a read-only check produces when it found something. +/// +/// One. `design/filesystem/maintenance.md` requires that the scrub "exits non-zero, and mutates +/// nothing", which is what makes it usable as a monitoring probe. +pub const EXIT_FINDINGS: u8 = 1; + +/// The Capsule server. +#[derive(Debug, Parser)] +#[command(name = "capsule-server", author, version, about, long_about = None)] +pub struct Cli { + /// Read settings from a configuration file. + /// + /// Reserved and **not implemented**: every setting is read from the environment. The flag + /// exists so the refusal is a sentence rather than clap's "unexpected argument". + #[arg(long, value_name = "PATH", global = true)] + pub config: Option, + + /// What to do. + #[command(subcommand)] + pub command: Command, +} + +/// The things this binary does. +#[derive(Debug, Subcommand)] +pub enum Command { + /// Emit the OpenAPI 3.2 document the SDK's client is generated from. + /// + /// Needs no database, no Valkey, no key material, no disk and no network: the router is + /// built purely to describe it, which is what lets `--check` run in the Rust check gate. + GenOpenapi { + /// Output path for the document, relative to the repo root. + #[arg(value_name = "FILE", default_value = "capsule-server/openapi.json")] + output: PathBuf, + + /// Verify the committed document is up to date instead of writing it (CI drift gate). + #[arg(long)] + check: bool, + }, +} + +impl Command { + /// What this subcommand needs from the configuration. + fn demands(&self) -> Demands { + match self { + Self::GenOpenapi { .. } => Demands::Nothing, + } + } +} + +/// Parse the command line, install the log stream, and do what was asked. +/// +/// # Errors +/// +/// Returns whatever the subcommand could not finish. A configuration refusal is **not** an +/// error here — it is [`EXIT_MISCONFIGURED`] with its own report on stderr, because +/// `ConfigError` already renders the full list of faults and an error chain around it would bury +/// them. +pub async fn run() -> Result { + let cli = Cli::parse(); + let environment = ProcessEnvironment; + let overrides = Overrides { + config_file: cli.config.clone(), + ..Overrides::default() + }; + + install_tracing(&environment, &overrides); + + let config = match Config::load(&environment, &overrides, cli.command.demands()) { + Ok(config) => config, + Err(error) => { + // Straight to stderr rather than through `tracing`: a startup refusal has to be + // visible whatever `RUST_LOG` says, and this is the one message an operator who + // mis-typed a variable needs to read. + eprintln!("capsule-server: {error}"); + return Ok(ExitCode::from(EXIT_MISCONFIGURED)); + } + }; + + match cli.command { + // The document is a property of the router's types, so the configuration is loaded only + // to refuse `--config` and is deliberately not logged: `mise run openapi-check-kynos` is + // a check gate, and a settings dump on its stderr is noise in every CI log that runs it. + Command::GenOpenapi { output, check } => { + drop(config); + gen_openapi(&output, check) + } + } +} + +/// Install the log stream, on stderr. +/// +/// The format is read best-effort — [`Demands::Nothing`] never fails on a missing setting, and a +/// malformed one falls back to the build profile's default — because the configuration error +/// this cannot read has to be *reported*, and reporting it needs a subscriber. +fn install_tracing(environment: &dyn Environment, overrides: &Overrides) { + let format = Config::load(environment, overrides, Demands::Nothing).map_or_else( + |_| { + if cfg!(debug_assertions) { + LogFormat::Pretty + } else { + LogFormat::Json + } + }, + |config| config.log_format, + ); + + let filter = EnvFilter::try_from_default_env() + .or_else(|_| { + if cfg!(debug_assertions) { + EnvFilter::try_new("debug") + } else { + EnvFilter::try_new("info") + } + }) + .expect("built-in log filter directives are valid"); + + let registry = tracing_subscriber::registry().with(filter); + match format { + LogFormat::Json => registry + .with( + fmt::layer() + .json() + .flatten_event(true) + .with_writer(std::io::stderr), + ) + .init(), + LogFormat::Pretty => registry + .with( + fmt::layer() + .pretty() + .with_file(true) + .with_line_number(true) + .with_writer(std::io::stderr), + ) + .init(), + } +} + +/// Write, or verify, the committed OpenAPI 3.2 document (slice `S-C34`). +/// +/// The drift guard for the rebuild's central claim: that the description is derived from the +/// types and cannot disagree with them. That claim is already enforced *inside* the crate — +/// `assert_conformance` catches a response the document did not predict, and +/// `assert_declared_responses_covered` catches a promise no test produced. Neither helps a +/// **client**: a surface can be ported, the emitted document can change shape, and nothing +/// outside the crate notices until somebody regenerates by hand. This is what makes such a +/// change fail. +fn gen_openapi(output: &PathBuf, check: bool) -> Result { + let document = + crate::openapi().map_err(|e| color_eyre::eyre::eyre!("describing the router: {e}"))?; + // `to_json` is already pretty-printed; the trailing newline keeps the committed document an + // ordinary text file rather than a one-line blob in a diff. + let mut json = document + .to_json() + .wrap_err("serializing the OpenAPI document to JSON")?; + json.push('\n'); + + if check { + let committed = std::fs::read_to_string(output) + .wrap_err_with(|| format!("cannot read committed document at {}", output.display()))?; + if committed != json { + bail!( + "OpenAPI document at {} is out of sync with the server; run \ + `mise run openapi-kynos` and commit the result", + output.display() + ); + } + println!("OpenAPI document is up to date: {}", output.display()); + } else { + if let Some(parent) = output.parent() { + std::fs::create_dir_all(parent) + .wrap_err_with(|| format!("creating {}", parent.display()))?; + } + std::fs::write(output, &json).wrap_err_with(|| format!("writing {}", output.display()))?; + println!("Wrote {}", output.display()); + } + + Ok(ExitCode::SUCCESS) +} + +#[cfg(test)] +mod tests { + use clap::{CommandFactory as _, Parser as _}; + + use super::{Cli, Command}; + + #[test] + fn the_command_line_is_well_formed() { + // clap's own consistency check: a duplicate long flag, a subcommand with two positional + // arguments in the wrong order, or an argument whose value name collides is a panic + // here rather than a report from the first operator to run `--help`. + Cli::command().debug_assert(); + } + + #[test] + fn gen_openapi_defaults_to_the_committed_document() { + let cli = Cli::parse_from(["capsule-server", "gen-openapi"]); + let Command::GenOpenapi { output, check } = cli.command; + assert_eq!(output, std::path::Path::new("capsule-server/openapi.json")); + assert!(!check, "writing is the default; checking is opt-in"); + } + + #[test] + fn a_config_path_is_accepted_by_the_parser_so_it_can_be_refused_by_the_loader() { + let cli = Cli::parse_from([ + "capsule-server", + "--config", + "/etc/capsule.toml", + "gen-openapi", + ]); + assert_eq!( + cli.config.as_deref(), + Some(std::path::Path::new("/etc/capsule.toml")) + ); + } +} diff --git a/capsule-server/src/lib.rs b/capsule-server/src/lib.rs index 3eda14bf..5d018f32 100644 --- a/capsule-server/src/lib.rs +++ b/capsule-server/src/lib.rs @@ -56,6 +56,7 @@ pub mod auth; pub mod blob; pub mod body; pub mod boot; +pub mod cli; pub mod config; pub mod counter; pub mod directory; diff --git a/capsule-server/src/main.rs b/capsule-server/src/main.rs new file mode 100644 index 00000000..4b8e4513 --- /dev/null +++ b/capsule-server/src/main.rs @@ -0,0 +1,19 @@ +//! The `capsule-server` binary: a thin shim over the [`capsule_server`] library, which owns the +//! subcommand implementations. +//! +//! This binary installs error reporting and dispatches, in the shape `capsule-cli/src/main.rs` +//! already has. Everything else — parsing, the log stream, the configuration, the composition +//! root and each subcommand's body — is in the library, so `tests/binary.rs` asserts against the +//! same code this runs and a unit test can drive `boot::assemble` without a subprocess. +//! +//! Replaces the `gen_openapi` `[[bin]]`, which was this crate's only executable: the document +//! dump is now `capsule-server gen-openapi`, one subcommand of the one binary. See +//! [`capsule_server::cli`] for why four executables were rejected. + +use color_eyre::eyre::Result; + +#[tokio::main] +async fn main() -> Result { + color_eyre::install()?; + capsule_server::cli::run().await +} diff --git a/mise.toml b/mise.toml index 369913c9..4fe3ee4e 100644 --- a/mise.toml +++ b/mise.toml @@ -226,11 +226,11 @@ run = "cargo deny --all-features check licenses" # to point at each stage. `S-C59` retired the Salvo one; this is now the contract, singular. [tasks.openapi-kynos] description = "Dump the Kynos OpenAPI 3.2 document to capsule-server/openapi.json" -run = "cargo run -q -p capsule-server --bin gen_openapi" +run = "cargo run -q -p capsule-server -- gen-openapi" [tasks.openapi-check-kynos] description = "Verify the committed Kynos OpenAPI 3.2 document matches the server" -run = "cargo run -q -p capsule-server --bin gen_openapi -- --check" +run = "cargo run -q -p capsule-server -- gen-openapi --check" # Regenerate the translated README..md files from README.md and the committed # per-locale translation data (xtask/translations/readme/). See xtask/src/translate_readme.rs. From 91a056139cd7d8f93910cd56280b25ee6f41eafa Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 22:57:21 -0400 Subject: [PATCH 050/243] feat(sdk): export the shared alert predicate through the uniffi FFI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `capsule_core::notify` decides the alert classes; the apps had no way to call it. This carries the surface across the boundary and wires the SDK's own recovery scheduler into it. `evaluate_alerts(input, now)` and `next_alert_deadline(input, now)` are free `#[uniffi::export]` functions rather than `FfiWorkspace` methods: the workspace holds none of the predicate's inputs — no persisted last-sync instant, no client-side quota type, no quarantine table — so a method would take the same `FfiNotifyInput` and then lock a mutex it never reads. `FfiNotifyInput` is flat. uniffi records nest, but a foreign caller assembling five optional sub-records to ask one question is worse than a struct whose fields are each independently absent, and presence is explicit: `last_completed_sync` present means the sync facts are known, and so on. Timestamps cross as RFC 3339 strings per the existing `changed_at` precedent, since Kotlin and Swift each have their own instant type. Nothing is parsed leniently. A malformed instant, or a `suppressed_until` key that is not one of the six class names, is `FfiError::InvalidArgument` and never a default — a mistyped instant that silently became "never" would suppress an alert forever, which is the failure this surface exists to prevent. `AlertClass::from_wire` gives the boundary one table to parse against instead of its own copy. `RecoveryCadence::notify_facts(now)` projects the scheduler into `RecoveryFacts`. It is derived from `state(now)` rather than from the fields, so the alert and the prompt the UX renders can never disagree about whether a check is due; `Badge` reports the spent snooze budget (reported, not pre-armed) and `RewrapDue` is due now whatever the ladder says, because repeated failure is not a scheduled check and the closed class set has only `recovery_check_due` to carry it. The projection lives here because capsule-sdk depends on capsule-core and never the reverse. --- capsule-core/src/notify/class.rs | 29 ++ capsule-sdk/src/ffi.rs | 10 + capsule-sdk/src/ffi/notify.rs | 480 ++++++++++++++++++++++++++++ capsule-sdk/src/recovery/cadence.rs | 144 +++++++++ 4 files changed, 663 insertions(+) create mode 100644 capsule-sdk/src/ffi/notify.rs diff --git a/capsule-core/src/notify/class.rs b/capsule-core/src/notify/class.rs index e79a1bac..caaa1bf4 100644 --- a/capsule-core/src/notify/class.rs +++ b/capsule-core/src/notify/class.rs @@ -70,6 +70,18 @@ impl AlertClass { } } + /// The inverse of [`as_str`](Self::as_str): parse a wire name, rejecting anything that is + /// not one of the six. + /// + /// The enum is closed, so an unrecognized name is `None` — a structural error for the + /// caller to report, never a class that is silently dropped. This exists so a boundary that + /// carries the name as a plain string (the uniffi FFI's suppression map, a persisted + /// client preference) has one table to parse against rather than its own copy. + #[must_use] + pub fn from_wire(name: &str) -> Option { + Self::ALL.into_iter().find(|class| class.as_str() == name) + } + /// How loudly a client should present this class. A property of the class, not of the /// instant it fired, so it lives here rather than being decided per-[`Alert`]. #[must_use] @@ -199,6 +211,23 @@ mod tests { } } + /// `from_wire` is exactly the inverse of `as_str`, and rejects everything else. + #[test] + fn from_wire_round_trips_and_rejects_the_rest() { + for class in AlertClass::ALL { + assert_eq!(AlertClass::from_wire(class.as_str()), Some(class)); + } + for name in [ + "", + "SyncStale", + "sync-stale", + "sync_stale ", + "telemetry_ready", + ] { + assert_eq!(AlertClass::from_wire(name), None, "{name:?}"); + } + } + /// SSoT: unknown classes are rejected as structural errors. #[test] fn unknown_class_is_a_structural_error() { diff --git a/capsule-sdk/src/ffi.rs b/capsule-sdk/src/ffi.rs index a3cd9a63..6839e2f4 100644 --- a/capsule-sdk/src/ffi.rs +++ b/capsule-sdk/src/ffi.rs @@ -57,6 +57,16 @@ pub use workspace::{ FfiWorkspace, }; +/// The local-alert surface (`S-D29`): the shared `capsule_core::notify` predicate, flattened. +/// Separated from both blocks above because it touches neither a transport nor a workspace — +/// it is a pure function of caller-supplied device state, and the two free exports say so. +mod notify; + +pub use notify::{ + FfiAlert, FfiAlertClass, FfiAlertSeverity, FfiNotifyInput, FfiQuotaAdvisory, evaluate_alerts, + next_alert_deadline, +}; + // ─── Errors ────────────────────────────────────────────────────────────────── /// Every failure the FFI flows can surface, flattened by originating layer. Each diff --git a/capsule-sdk/src/ffi/notify.rs b/capsule-sdk/src/ffi/notify.rs new file mode 100644 index 00000000..234697fa --- /dev/null +++ b/capsule-sdk/src/ffi/notify.rs @@ -0,0 +1,480 @@ +//! The local-alert surface across the FFI boundary (slice `S-D29`, core half): the flattened +//! mirror of [`capsule_core::notify`] plus the two functions the apps call. +//! +//! # Why free functions +//! +//! [`evaluate_alerts`] and [`next_alert_deadline`] are free `#[uniffi::export]` functions rather +//! than methods on +//! [`FfiWorkspace`](crate::ffi::FfiWorkspace). The workspace holds none of the predicate's +//! inputs — there is no persisted last-sync instant, no client-side quota type, and no +//! quarantine table — so a method would take the same [`FfiNotifyInput`] and then lock a mutex +//! it never reads. The predicate is a pure function of caller-supplied state, and the boundary +//! says so. +//! +//! # Shape +//! +//! [`FfiNotifyInput`] is **flat**: uniffi records nest, but a foreign caller assembling five +//! optional sub-records to ask one question is worse than a struct whose fields are each +//! independently `None`. Presence is explicit — `last_completed_sync` present means the sync +//! facts are known, `recovery_next_due` present means recovery is set up, `quota_state` present +//! means a quota response has been seen. +//! +//! Timestamps cross as **RFC 3339 strings**, following the `changed_at` precedent on +//! [`FfiSyncApplyOutcome`](crate::ffi::FfiSyncApplyOutcome): Kotlin and Swift each have their own +//! instant type and no shared integer convention worth guessing at. A string that does not parse +//! is [`FfiError::InvalidArgument`], never a panic. + +use std::collections::HashMap; + +use capsule_core::notify::{ + self, Alert, AlertClass, AlertSeverity, NotifyInput, QuotaAdvisory, QuotaFacts, RecoveryFacts, + SyncFacts, +}; +use jiff::Timestamp; + +use super::FfiError; + +/// Every alert class, flattened for the bindings. A closed enum on both sides of the boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum FfiAlertClass { + /// Two weeks without a completed sync while changes remain un-synced. + SyncStale, + /// A recovery-secret verification check is due. + RecoveryCheckDue, + /// Storage crossed the soft limit and is below the hard limit. + QuotaSoft, + /// Storage is over the hard limit — the grace window is counting, or has closed. + QuotaGraceExpiring, + /// Items are sitting on a quarantine surface awaiting a human. + QuarantinePending, + /// Guest drops are awaiting review and adoption. + DropPending, +} + +impl From for FfiAlertClass { + fn from(class: AlertClass) -> Self { + match class { + AlertClass::SyncStale => Self::SyncStale, + AlertClass::RecoveryCheckDue => Self::RecoveryCheckDue, + AlertClass::QuotaSoft => Self::QuotaSoft, + AlertClass::QuotaGraceExpiring => Self::QuotaGraceExpiring, + AlertClass::QuarantinePending => Self::QuarantinePending, + AlertClass::DropPending => Self::DropPending, + } + } +} + +/// How prominently a client presents an alert. **Neither variant gates anything** — no alert +/// blocks sync, unlock, upload, or any critical flow. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum FfiAlertSeverity { + /// Informational: nothing is failing, and nothing is about to. + Advisory, + /// Something is already degraded or is being refused. + Warning, +} + +impl From for FfiAlertSeverity { + fn from(severity: AlertSeverity) -> Self { + match severity { + AlertSeverity::Advisory => Self::Advisory, + AlertSeverity::Warning => Self::Warning, + } + } +} + +/// The quota state, as `GET /v1/quota` reports it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum FfiQuotaAdvisory { + /// Below the soft limit. All uploads succeed normally. + Ok, + /// At or above the soft limit and below the hard limit. Uploads succeed; the client warns. + SoftWarning, + /// At or above the hard limit. New uploads are rejected; the grace window is counting. + HardExceeded, + /// Over the hard limit for longer than the grace window; metadata-growth writes are + /// refused too. + GraceExpired, + /// An admin or billing action rather than a threshold. Raises no quota alert. + Suspended, +} + +impl From for QuotaAdvisory { + fn from(state: FfiQuotaAdvisory) -> Self { + match state { + FfiQuotaAdvisory::Ok => Self::Ok, + FfiQuotaAdvisory::SoftWarning => Self::SoftWarning, + FfiQuotaAdvisory::HardExceeded => Self::HardExceeded, + FfiQuotaAdvisory::GraceExpired => Self::GraceExpired, + FfiQuotaAdvisory::Suspended => Self::Suspended, + } + } +} + +/// One alert that is true at an instant. +/// +/// Never a localized string: `class` selects the app's own `notification.*` catalog key and +/// `params` are interpolated into it, so a server can neither supply nor influence the words a +/// user reads. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct FfiAlert { + /// Which alert this is. + pub class: FfiAlertClass, + /// How prominently to present it. + pub severity: FfiAlertSeverity, + /// The instant whose passing made this alert true (RFC 3339), for the two pre-armable + /// classes; `None` for the three whose condition is server-held. + pub deadline: Option, + /// Catalog parameters — `count`, `days_behind`, `grace`, `snooze_budget`. + pub params: HashMap, +} + +impl From for FfiAlert { + fn from(alert: Alert) -> Self { + Self { + class: alert.class.into(), + severity: alert.severity.into(), + deadline: alert.deadline.map(|d| d.to_string()), + params: alert.params.into_iter().collect(), + } + } +} + +/// The device-held state the predicate decides from, flattened for the bindings. +/// +/// Counts and instants only — no album id, no title, no asset id. An all-default value (every +/// `Option` `None`, every count `0`, an empty map) is the "just installed, learned nothing" +/// input, and yields no alerts and no deadline. +#[derive(Debug, Clone, Default, uniffi::Record)] +pub struct FfiNotifyInput { + /// When the last **completed** sync finished (RFC 3339). `None` on a device that has never + /// completed one — which raises no `sync_stale`, because the alert is about a *stale* sync + /// and not a missing one. When `None`, `unsynced_changes` is ignored. + pub last_completed_sync: Option, + /// Changes still waiting to reach the server, including originals still pending under a + /// staged upload policy. + #[uniffi(default = 0)] + pub unsynced_changes: u64, + /// When the next recovery-verification prompt becomes due (RFC 3339). Project it from the + /// scheduler with + /// [`RecoveryCadence::notify_facts`](crate::recovery::RecoveryCadence::notify_facts) rather + /// than computing it here. `None` before recovery is set up, which ignores the other two + /// `recovery_*` fields. + pub recovery_next_due: Option, + /// When an active snooze on the recovery prompt expires (RFC 3339), if one is active. + pub recovery_snoozed_until: Option, + /// Whether the consecutive-snooze budget is spent — the class has degraded to a persistent, + /// non-blocking badge: still reported, no longer pre-armed. + #[uniffi(default = false)] + pub recovery_snooze_budget_spent: bool, + /// The state from the last `GET /v1/quota`. `None` before the first one. + pub quota_state: Option, + /// How many items sit on the client's quarantine surfaces awaiting a human. + #[uniffi(default = 0)] + pub quarantine_pending: u64, + /// How many guest drops are awaiting review and adoption. + #[uniffi(default = 0)] + pub drops_pending: u64, + /// Per-class suppression: class wire name (`sync_stale`, …) to the RFC 3339 instant the + /// snooze or disable runs until, exclusive. A class suppressed past `now` reports nothing + /// and arms nothing. Disabling a class is a far-future instant; it suppresses the warning + /// and never the behavior. An unrecognized class name is an + /// [`FfiError::InvalidArgument`]. + pub suppressed_until: HashMap, +} + +impl FfiNotifyInput { + /// Parse the foreign record into the core input, rejecting every malformed field rather + /// than defaulting past it: a mistyped instant that silently became "never" would suppress + /// an alert forever, which is exactly the failure this alert surface exists to prevent. + fn parse(self) -> Result { + let sync = self + .last_completed_sync + .map(|raw| { + Ok::<_, FfiError>(SyncFacts { + last_completed_sync: parse_instant(&raw, "last_completed_sync")?, + unsynced_changes: self.unsynced_changes, + }) + }) + .transpose()?; + + let recovery = self + .recovery_next_due + .map(|raw| { + Ok::<_, FfiError>(RecoveryFacts { + next_due: parse_instant(&raw, "recovery_next_due")?, + snoozed_until: self + .recovery_snoozed_until + .as_deref() + .map(|raw| parse_instant(raw, "recovery_snoozed_until")) + .transpose()?, + snooze_budget_spent: self.recovery_snooze_budget_spent, + }) + }) + .transpose()?; + + let mut suppressed = std::collections::BTreeMap::new(); + for (name, raw) in self.suppressed_until { + let class = AlertClass::from_wire(&name).ok_or_else(|| FfiError::InvalidArgument { + message: format!("suppressed_until: `{name}` is not an alert class"), + })?; + suppressed.insert(class, parse_instant(&raw, "suppressed_until")?); + } + + Ok(NotifyInput { + sync, + recovery, + quota: self.quota_state.map(|state| QuotaFacts { + state: state.into(), + }), + quarantine_pending: self.quarantine_pending, + drops_pending: self.drops_pending, + suppressed, + }) + } +} + +/// Parse one RFC 3339 instant, naming the field so a foreign caller can find its own bug. +fn parse_instant(raw: &str, field: &str) -> Result { + raw.parse::() + .map_err(|err| FfiError::InvalidArgument { + message: format!("{field}: `{raw}` is not an RFC 3339 timestamp: {err}"), + }) +} + +/// Every alert that is true at `now`, in delivery order. +/// +/// Pure and offline: no network call, no library open, no clock read — `now` is the caller's, so +/// the same input always gives the same answer and an app can drive it from a test clock. +/// +/// # Errors +/// +/// [`FfiError::InvalidArgument`] if `now` or any instant in `input` is not RFC 3339, or if +/// `suppressed_until` names something that is not an alert class. +#[uniffi::export] +pub fn evaluate_alerts(input: FfiNotifyInput, now: String) -> Result, FfiError> { + let now = parse_instant(&now, "now")?; + Ok(notify::evaluate(&input.parse()?, now) + .into_iter() + .map(FfiAlert::from) + .collect()) +} + +/// The next instant to arm a local notification for (RFC 3339), or `None` when there is nothing +/// to arm — in which case cancel any timer this class holds. +/// +/// Recompute this after **any** state change and cancel-then-arm if the value moved. Only the +/// two classes whose deadline a device can compute alone are ever returned; the other three +/// depend on server state and surface at next app launch. +/// +/// # Errors +/// +/// As [`evaluate_alerts`]. +#[uniffi::export] +pub fn next_alert_deadline(input: FfiNotifyInput, now: String) -> Result, FfiError> { + let now = parse_instant(&now, "now")?; + Ok(notify::next_deadline(&input.parse()?, now).map(|deadline| deadline.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A fixed base instant, and the two-week threshold expressed against it. + const BASE: &str = "2023-11-14T22:13:20Z"; + const BASE_PLUS_14D: &str = "2023-11-28T22:13:20Z"; + + fn stale_input() -> FfiNotifyInput { + FfiNotifyInput { + last_completed_sync: Some(BASE.to_owned()), + unsynced_changes: 3, + ..FfiNotifyInput::default() + } + } + + /// The whole round trip: a foreign record in, alert classes and an RFC 3339 deadline out. + #[test] + fn evaluate_alerts_round_trips_the_boundary() { + let alerts = evaluate_alerts(stale_input(), BASE_PLUS_14D.to_owned()).unwrap(); + assert_eq!(alerts.len(), 1); + assert_eq!(alerts[0].class, FfiAlertClass::SyncStale); + assert_eq!(alerts[0].severity, FfiAlertSeverity::Warning); + assert_eq!(alerts[0].deadline.as_deref(), Some(BASE_PLUS_14D)); + assert_eq!(alerts[0].params["count"], "3"); + assert_eq!(alerts[0].params["days_behind"], "14"); + } + + /// One second before the threshold nothing fires, and the deadline to arm is the threshold. + #[test] + fn next_alert_deadline_arms_the_threshold() { + let before = "2023-11-28T22:13:19Z".to_owned(); + assert!( + evaluate_alerts(stale_input(), before.clone()) + .unwrap() + .is_empty() + ); + assert_eq!( + next_alert_deadline(stale_input(), before) + .unwrap() + .as_deref(), + Some(BASE_PLUS_14D) + ); + // Once it has fired there is nothing left to schedule. + assert_eq!( + next_alert_deadline(stale_input(), BASE_PLUS_14D.to_owned()).unwrap(), + None + ); + } + + /// The default record is the "learned nothing" input on both functions. + #[test] + fn default_input_is_silent_across_the_boundary() { + assert!( + evaluate_alerts(FfiNotifyInput::default(), BASE.to_owned()) + .unwrap() + .is_empty() + ); + assert_eq!( + next_alert_deadline(FfiNotifyInput::default(), BASE.to_owned()).unwrap(), + None + ); + } + + /// Quota and count classes cross with their parameters and without a deadline. + #[test] + fn quota_and_count_classes_cross_the_boundary() { + let input = FfiNotifyInput { + quota_state: Some(FfiQuotaAdvisory::GraceExpired), + quarantine_pending: 2, + drops_pending: 1, + ..FfiNotifyInput::default() + }; + let alerts = evaluate_alerts(input, BASE.to_owned()).unwrap(); + let classes: Vec<_> = alerts.iter().map(|a| a.class).collect(); + assert_eq!( + classes, + [ + FfiAlertClass::QuotaGraceExpiring, + FfiAlertClass::QuarantinePending, + FfiAlertClass::DropPending, + ] + ); + for alert in &alerts { + assert_eq!(alert.deadline, None); + } + assert_eq!(alerts[0].params["grace"], "expired"); + assert_eq!(alerts[1].params["count"], "2"); + assert_eq!(alerts[2].params["count"], "1"); + } + + /// A suppressed class crosses as a wire name and removes the class from both answers. + #[test] + fn suppression_crosses_as_a_wire_name() { + let mut input = stale_input(); + input + .suppressed_until + .insert("sync_stale".to_owned(), "2999-01-01T00:00:00Z".to_owned()); + assert!( + evaluate_alerts(input.clone(), BASE_PLUS_14D.to_owned()) + .unwrap() + .is_empty() + ); + assert_eq!( + next_alert_deadline(input, BASE_PLUS_14D.to_owned()).unwrap(), + None + ); + } + + /// Every malformed input is a typed `InvalidArgument`, never a panic and never a default. + #[test] + fn malformed_input_is_invalid_argument() { + let cases: Vec<(FfiNotifyInput, String, &str)> = vec![ + (FfiNotifyInput::default(), "not-a-time".to_owned(), "now"), + ( + FfiNotifyInput { + last_completed_sync: Some("yesterday".to_owned()), + ..FfiNotifyInput::default() + }, + BASE.to_owned(), + "last_completed_sync", + ), + ( + FfiNotifyInput { + recovery_next_due: Some("soon".to_owned()), + ..FfiNotifyInput::default() + }, + BASE.to_owned(), + "recovery_next_due", + ), + ( + FfiNotifyInput { + recovery_next_due: Some(BASE.to_owned()), + recovery_snoozed_until: Some("later".to_owned()), + ..FfiNotifyInput::default() + }, + BASE.to_owned(), + "recovery_snoozed_until", + ), + ( + FfiNotifyInput { + suppressed_until: HashMap::from([( + "telemetry_ready".to_owned(), + BASE.to_owned(), + )]), + ..FfiNotifyInput::default() + }, + BASE.to_owned(), + "not an alert class", + ), + ( + FfiNotifyInput { + suppressed_until: HashMap::from([( + "sync_stale".to_owned(), + "whenever".to_owned(), + )]), + ..FfiNotifyInput::default() + }, + BASE.to_owned(), + "suppressed_until", + ), + ]; + for (input, now, needle) in cases { + let err = evaluate_alerts(input.clone(), now.clone()) + .expect_err("malformed input must not be accepted"); + match &err { + FfiError::InvalidArgument { message } => { + assert!(message.contains(needle), "{message} does not name {needle}"); + } + other => panic!("expected InvalidArgument, got {other:?}"), + } + // The same rejection on the deadline function; neither is the lenient one. + assert!(matches!( + next_alert_deadline(input, now), + Err(FfiError::InvalidArgument { .. }) + )); + } + } + + /// The projection from the SDK's own scheduler composes with the exported function, which + /// is the wiring an app actually uses. + #[test] + fn recovery_cadence_projection_composes_with_the_export() { + use crate::recovery::RecoveryCadence; + + let base: Timestamp = BASE.parse().unwrap(); + let cadence = RecoveryCadence::armed_at_setup(base); + let due = cadence.next_due(); + let facts = cadence.notify_facts(due); + + let input = FfiNotifyInput { + recovery_next_due: Some(facts.next_due.to_string()), + recovery_snoozed_until: facts.snoozed_until.map(|t| t.to_string()), + recovery_snooze_budget_spent: facts.snooze_budget_spent, + ..FfiNotifyInput::default() + }; + let alerts = evaluate_alerts(input, due.to_string()).unwrap(); + assert_eq!(alerts.len(), 1); + assert_eq!(alerts[0].class, FfiAlertClass::RecoveryCheckDue); + assert_eq!(alerts[0].params["snooze_budget"], "available"); + } +} diff --git a/capsule-sdk/src/recovery/cadence.rs b/capsule-sdk/src/recovery/cadence.rs index 743a71d8..9f290850 100644 --- a/capsule-sdk/src/recovery/cadence.rs +++ b/capsule-sdk/src/recovery/cadence.rs @@ -17,6 +17,7 @@ //! //! [Backup — Recovery Verification Cadence]: https://docs/design/backup-recovery/#recovery-verification-cadence +use capsule_core::notify::RecoveryFacts; use jiff::Timestamp; use serde::{Deserialize, Serialize}; @@ -270,6 +271,60 @@ impl RecoveryCadence { pub fn declare_lost(&mut self) { self.declared_lost = true; } + + /// Project the scheduler into the flat facts the shared alert predicate consumes + /// ([`capsule_core::notify`], slice `S-D29`). + /// + /// The direction matters: `capsule-sdk` depends on `capsule-core` and never the reverse, so + /// the predicate cannot read this scheduler. It is handed a projection instead, and this is + /// the only place that mapping exists — the ladder, the re-arm triggers, and the snooze + /// accounting stay owned here, and `capsule_core::notify` never recomputes them. + /// + /// It is derived from [`state`](Self::state) rather than from the fields, so the alert and + /// the prompt the UX renders can never disagree about whether a check is due. Two mappings + /// are worth stating: + /// + /// - [`VerificationState::Badge`] reports `snooze_budget_spent`, which is what stops the + /// alert being pre-armed as a notification while leaving the condition reported — the + /// badge is persistent and non-blocking, and never escalates back into an alert. + /// - [`VerificationState::RewrapDue`] is due **now**, whatever the ladder says: repeated + /// failure or an explicit "I lost it" is not a scheduled check. The alert class set is + /// closed, so `recovery_check_due` is the only class that can carry it; the client then + /// routes into [`RecoveryClient::guided_rewrap`](crate::recovery::RecoveryClient::guided_rewrap) + /// rather than a plain verification prompt. + #[must_use] + pub fn notify_facts(&self, now: Timestamp) -> RecoveryFacts { + match self.state(now) { + VerificationState::Verified { next_due } => RecoveryFacts { + next_due, + snoozed_until: None, + snooze_budget_spent: false, + }, + VerificationState::Due => RecoveryFacts { + next_due: self.next_due, + snoozed_until: None, + snooze_budget_spent: false, + }, + VerificationState::Snoozed { + until, + snoozes_used, + } => RecoveryFacts { + next_due: self.next_due, + snoozed_until: Some(until), + snooze_budget_spent: snoozes_used >= MAX_CONSECUTIVE_SNOOZES, + }, + VerificationState::Badge => RecoveryFacts { + next_due: self.next_due, + snoozed_until: None, + snooze_budget_spent: true, + }, + VerificationState::RewrapDue => RecoveryFacts { + next_due: now, + snoozed_until: None, + snooze_budget_spent: false, + }, + } + } } /// Add a signed second offset to a timestamp, saturating at the representable bounds @@ -285,6 +340,8 @@ fn add_secs(base: Timestamp, secs: i64) -> Timestamp { #[cfg(test)] mod tests { + use capsule_core::notify::{self, AlertClass, NotifyInput}; + use super::*; /// A fixed, round base instant well away from the timestamp bounds. @@ -486,4 +543,91 @@ mod tests { let back: RecoveryCadence = serde_json::from_str(&json).unwrap(); assert_eq!(cad, back); } + + // ── `S-D29` projection ────────────────────────────────────────────────── + + /// The projection walks every [`VerificationState`], and the fact it hands the shared + /// predicate agrees with the state the UX renders at the same instant. + #[test] + fn notify_facts_projects_every_state() { + let due = BASE + INITIAL_INTERVAL_SECS; + + // Verified → the prompt is scheduled, nothing is snoozed, budget intact. + let cad = RecoveryCadence::armed_at_setup(ts(BASE)); + let facts = cad.notify_facts(ts(BASE)); + assert_eq!(facts.next_due, ts(due)); + assert_eq!(facts.snoozed_until, None); + assert!(!facts.snooze_budget_spent); + + // Due → the same `next_due`, now in the past, so the predicate fires. + let facts = cad.notify_facts(ts(due)); + assert_eq!(facts.next_due, ts(due)); + assert_eq!(facts.snoozed_until, None); + assert!(!facts.snooze_budget_spent); + + // Snoozed within budget → the snooze end is carried, the budget is not spent. + let mut cad = RecoveryCadence::armed_at_setup(ts(BASE)); + cad.snooze(ts(due), SnoozeDuration::OneDay); + let facts = cad.notify_facts(ts(due)); + assert_eq!(facts.snoozed_until, Some(ts(due + DAY_SECS))); + assert!(!facts.snooze_budget_spent); + + // The budget spends on the third consecutive snooze, and the last one still holds. + let mut at = due; + for _ in 1..MAX_CONSECUTIVE_SNOOZES { + at += DAY_SECS; + cad.snooze(ts(at), SnoozeDuration::OneDay); + } + let facts = cad.notify_facts(ts(at)); + assert_eq!(facts.snoozed_until, Some(ts(at + DAY_SECS))); + assert!(facts.snooze_budget_spent); + + // Badge → the snooze has expired with the budget spent: reported, never pre-armed. + let after = at + DAY_SECS; + assert_eq!(cad.state(ts(after)), VerificationState::Badge); + let facts = cad.notify_facts(ts(after)); + assert_eq!(facts.next_due, ts(due)); + assert_eq!(facts.snoozed_until, None); + assert!(facts.snooze_budget_spent); + } + + /// `RewrapDue` is due now, whatever the ladder says: an explicit "I lost it" is not a + /// scheduled check, and the closed class set has only `recovery_check_due` to carry it. + #[test] + fn notify_facts_reports_rewrap_due_immediately() { + let mut cad = RecoveryCadence::armed_at_setup(ts(BASE)); + cad.declare_lost(); + assert_eq!(cad.state(ts(BASE)), VerificationState::RewrapDue); + + // The ladder still says "not for 7 days"; the projection says "now". + assert_eq!(cad.next_due(), ts(BASE + INITIAL_INTERVAL_SECS)); + let facts = cad.notify_facts(ts(BASE)); + assert_eq!(facts.next_due, ts(BASE)); + assert_eq!(facts.snoozed_until, None); + assert!(!facts.snooze_budget_spent); + } + + /// The projection is the whole of the wiring: what the scheduler says and what the shared + /// predicate decides never disagree about whether a check is due. + #[test] + fn notify_facts_agrees_with_the_rendered_state() { + let mut cad = RecoveryCadence::armed_at_setup(ts(BASE)); + cad.snooze(ts(BASE + INITIAL_INTERVAL_SECS), SnoozeDuration::OneWeek); + + for offset in [0, INITIAL_INTERVAL_SECS - 1, INITIAL_INTERVAL_SECS] { + let now = ts(BASE + offset); + let input = NotifyInput { + recovery: Some(cad.notify_facts(now)), + ..NotifyInput::default() + }; + let fired = notify::evaluate(&input, now) + .iter() + .any(|a| a.class == AlertClass::RecoveryCheckDue); + let rendered_due = matches!( + cad.state(now), + VerificationState::Due | VerificationState::Badge | VerificationState::RewrapDue + ); + assert_eq!(fired, rendered_due, "at +{offset}s"); + } + } } From 0b23ae6d2671844ae74f70a399ce0447e5d4380e Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 22:58:41 -0400 Subject: [PATCH 051/243] docs(notifications): record the built core half of the alert surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `planned-modules.txt` is the only sanctioned way a design doc may name a module that is not there, and `check-docs-truth` fails on an entry whose module has since been built — so the `capsule-core::notify` row has to go in the same change that builds it, and `notifications.md` has to stop calling the module planned. The `S-D29` row keeps `ready` rather than taking `done*`. The predicate is proven and nothing on a device evaluates it yet: every input is caller-supplied because the core holds none of the trigger state, so the remainder is the client half and it is the larger half. The detail block says which parts are owed and why the `notification.*` keys cannot land before them — the i18n guard needs a live consumer, and the consumer is the client half by construction. Only the S-D29 row and its detail block change; the row-count paragraph, the gates table and the prose head are untouched. --- SLICES.md | 22 ++++++++++++++++++- capsule-docs/planned-modules.txt | 1 - .../src/content/docs/design/notifications.md | 15 +++++++++---- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/SLICES.md b/SLICES.md index ce7d3266..56b32ead 100644 --- a/SLICES.md +++ b/SLICES.md @@ -318,7 +318,7 @@ row's remainder now lives. | S-D26 | CLI drops the rotated token pair, forcing re-login | sdk/clients | — | S | MIXED | ready | fix in the REST client, not the old one | | S-D27 | The SDK test mock never shuts its listener down | sdk/clients | — | S | ACTIVE | done\* | fixed a real leak; the LEAK signal is partly noise | | S-D28 | The SDK's second transport, and the document it generates from | client SDK | S-D8, S-C2 | L | MIXED | done\* | gRPC retires; the client generates from the Kynos document; four `application/cbor` operations stay hand-written | -| S-D29 | Local alert surface (`capsule-core::notify` + native delivery) | sdk/clients | S-Z11 | M | ACTIVE | ready | | +| S-D29 | Local alert surface (`capsule-core::notify` + native delivery) | sdk/clients | S-Z11 | M | ACTIVE | ready | core half landed (`capsule-core::notify` + FFI); native delivery, `notification.*` keys, permission placement owed | | S-D20 | CLI truthfulness pass (status/register/endpoints/flags) | sdk/clients | — | M | MIXED | done | | | S-E1 | Share-link end-to-end serving | fed/sharing | S-C4 | M | MIXED | done\* | live-browser smoke → `S-Q5`; seeds → gates | | S-E2 | Federation capabilities + pulls | fed/sharing | S-C2, S-A3 | L | RETIRED | ready | capability gate on the live read method → `S-E5` | @@ -5735,6 +5735,26 @@ table hides what it would cost. to a badge, and a refused authorization degrading to a badge without error; a per-platform smoke fires a pre-armed alert with the app terminated; `i18n-guard` passes with the new namespace consumed. **Tier:** unit + smoke. +- **Core half landed — verified 2026-09-01.** `capsule-core::notify` is the whole shared + decision function: `evaluate(&NotifyInput, now)` reports the classes true at an instant and + `next_deadline(&NotifyInput, now)` reports the one instant to arm, both pure with `now` as an + argument. `capsule-sdk::ffi` exports them as free functions (`evaluate_alerts`, + `next_alert_deadline`) with RFC 3339 timestamps, and `RecoveryCadence::notify_facts` projects + the S-D12 scheduler into the recovery half of the input. The module left + `planned-modules.txt`; `notifications.md` no longer calls it planned. +- **Why `ready` and not `done*`.** Every predicate input is caller-supplied, because the core + holds none of the trigger state — no persisted last-sync instant, no client-side quota type, + no quarantine table (a refused sync entry is a per-entry verdict, not a row). So the predicate + is proven and *nothing on a device evaluates it yet*: the remainder is the client half, and it + is the larger half. `next_deadline` is deliberately narrower than `evaluate` for the same + reason the pre-arm rule exists — an armed notification fires with the app not running and + cannot be re-checked on arrival, so a deadline is returned only when the alert is certain to + be true when it gets there. +- **Owed, and where.** Native scheduling and presentation + (`UNUserNotificationCenter`/`AlarmManager`), the `notification.*` catalog keys — blocked on a + live consumer by the i18n guard, which is the client half by construction — the + permission-at-first-use placement, and the per-platform terminated-app smoke. Filed as the + S-D29 client half. ## Post-v1 Register diff --git a/capsule-docs/planned-modules.txt b/capsule-docs/planned-modules.txt index d78427f0..36b41fa4 100644 --- a/capsule-docs/planned-modules.txt +++ b/capsule-docs/planned-modules.txt @@ -13,6 +13,5 @@ # module-layer answer to "what has been designed and not built?" capsule-core::media The Capsule-side owner of decode, metadata extraction and derivative generation, which will consume Rawshift once Rawshift stabilizes. Rawshift is a pinned submodule today and is not a workspace dependency, so nothing consumes it and this module has no body to write yet. Lane B in SLICES.md. -capsule-core::notify Alert classes and their trigger predicates, so every platform evaluates one shared decision function rather than reimplementing the taxonomy. Contract: design/notifications.md. Tier 0 has no server half, so this is client-only work. capsule-core::import::camera The PTP/IP tethered-camera source adapter (S-B9). Post-v1; the contract exists so the adapter seam is fixed before anything implements it. capsule-server::federation Server-to-server federation pull. The whole surface is post-v1 — `capsule-server` has no federation route, no capability-token verifier and no per-peer budget enforcement. diff --git a/capsule-docs/src/content/docs/design/notifications.md b/capsule-docs/src/content/docs/design/notifications.md index 1a2fd2f5..b5b2ce8d 100644 --- a/capsule-docs/src/content/docs/design/notifications.md +++ b/capsule-docs/src/content/docs/design/notifications.md @@ -36,10 +36,17 @@ Three distinct things, three words. The collision this table resolves is the rea A hint is a *pull prompt between infrastructure*. A wake is a hint applied at the client edge. An alert is the only one a human ever sees, and it is never produced by a server. -**Where this lives.** Alert classes and their trigger predicates belong in `capsule-core::notify` -(**Planned**) so every platform evaluates one shared decision function; only *delivery* is native -per client. This is the [minimal-divergence split](/design/clients/#design-priorities) applied to -alerts. No server module is planned for v1 — Tier 0 has no server half. +**Where this lives.** Alert classes and their trigger predicates live in `capsule-core::notify` +so every platform evaluates one shared decision function; only *delivery* is native per client. +This is the [minimal-divergence split](/design/clients/#design-priorities) applied to alerts. No +server module is planned for v1 — Tier 0 has no server half. + +**Status.** `capsule-core::notify` is **built** (slice `S-D29`, core half): one pure decision +function returns the classes true at an instant, a second returns the instant to arm, and +`capsule-sdk::ffi` carries both to the apps. Every predicate input is caller-supplied, because +the core holds none of the trigger state. What is still owed is the *delivery* half — the +per-platform scheduling and presentation below, the `notification.*` catalog keys, and the +permission request — so no alert on this page reaches a user yet. ## Tier 0 — Local Alerts From 1dff9d62fa554a09b24d4ac41d3e816492081af0 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 23:00:54 -0400 Subject: [PATCH 052/243] feat(core): own still decode and derivatives in capsule-core::media MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `capsule-core::media` becomes the Capsule-side owner of still detection, decode, orientation, metadata normalisation and derivative generation, over `rawshift-image` 0.1.1 from crates.io (a registry dependency, not the pinned submodule) behind a new `media` feature that `native` implies and the wasm32 sealing build excludes. Rawshift owns the codecs; this module owns every decision Capsule has to make around them: - the closed sets — `StillFormat` (what counts as a still) and `DerivativeFormat` (what a signed `DerivativeManifest.format` may say, with the `original` sentinel); - detection, because the crate's own `detect_standard_format` gates its HEIC arm on the HEIC codec, so delegating would make the typed refusal for a format depend on whether it can be decoded; - a pre-decode pixel budget and an unwind boundary, because a pre-1.0 decoder is fed untrusted bytes on the import path; - tier sizing and a deterministic integer area-average downscale, since the crate has no resize and a derivative's bytes are signed; - the metadata strip: every encode passes `MetadataEmbedOptions::none()` because the crate's default embeds EXIF, GPS included. Decode covers JPEG, PNG, JXL, TIFF, GIF and WebP; encode covers WebP, which produces the 256 px q=50 thumbnail tier. HEIC, AVIF, RAW and a lossy JXL encoder each need a system library or an assembler the cross and cargo-ndk builds do not carry, so each is a typed `MediaError::UnsupportedFormat` or a recorded per-format deferral rather than a silent gap. `DerivativeCore.format` keeps its `String` type: the same field carries the `embedding/{model_id}` grammar, and a typed field would turn an unrecognised value into a parse failure before any signature is examined. The closed set is enforced at production and at verification instead. --- AGENTS.md | 2 +- Cargo.lock | 432 +++++- NOTICE | 8 +- capsule-core/Cargo.toml | 37 +- .../src/crypto/provenance/manifest.rs | 21 + capsule-core/src/lib.rs | 7 + capsule-core/src/media/decode.rs | 362 +++++ capsule-core/src/media/derivative.rs | 472 ++++++ capsule-core/src/media/detect.rs | 262 ++++ capsule-core/src/media/error.rs | 123 ++ capsule-core/src/media/mod.rs | 51 + capsule-core/src/media/resize.rs | 107 ++ capsule-core/src/media/tests.rs | 1312 +++++++++++++++++ capsule-docs/planned-modules.txt | 2 +- .../src/content/docs/design/dependencies.md | 1 + 15 files changed, 3191 insertions(+), 8 deletions(-) create mode 100644 capsule-core/src/media/decode.rs create mode 100644 capsule-core/src/media/derivative.rs create mode 100644 capsule-core/src/media/detect.rs create mode 100644 capsule-core/src/media/error.rs create mode 100644 capsule-core/src/media/mod.rs create mode 100644 capsule-core/src/media/resize.rs create mode 100644 capsule-core/src/media/tests.rs diff --git a/AGENTS.md b/AGENTS.md index ecdfc94a..ba619305 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,7 @@ - The public server surface is Kynos REST/OpenAPI only. Do not reintroduce Salvo, GraphQL, or gRPC. The served document is **OpenAPI 3.2**: enabling Kynos's `openapi32` feature does not by itself produce one — `capsule-server` pins it with `openapi_as(SpecVersion::V3_2)`. Never emit or commit a 3.1 or 3.0 contract. - Generate clients with Spargen from the checked-in Kynos OpenAPI contract. Do not use Progenitor. Everything that parses or serializes is generated — every body, every typed parameter, and the byte-serving endpoints. Only *orchestration over* generated calls is hand-written, and the resumable upload state machine (`S-D1`) is the whole of it; do not hand-write a second parser. -- Rawshift is the intended owner of media decoding, metadata extraction, and derivative generation, consumed through `capsule-core::media` once Rawshift stabilizes. Neither exists today: Rawshift is a pinned submodule and not a workspace dependency, and `capsule-core::media` has no body to write until it is one — so nothing in Capsule decodes media right now. Capsule imports **Chromahash 0.7.1** directly, never through Rawshift, and LQIP encode/decode lives in its own `capsule-core::lqip` module (slice `S-B14`) so one implementation serves the import pipeline, the FFI, and `capsule-wasm`. **ThumbHash is retired**: neither the `thumbhash` crate nor the npm `thumbhash` package may be reintroduced. Contract: [Thumbnails — LQIP](capsule-docs/src/content/docs/design/thumbnails.md#lqip). +- Rawshift owns media decoding, metadata extraction, and derivative generation, consumed through `capsule-core::media`, which now exists and is wired: `rawshift-image` **0.1.1 from crates.io** — a registry dependency, never the pinned submodule — behind the `media` feature that `native` implies, covering still detection, decode, EXIF orientation, metadata normalisation and the WebP thumbnail tier, and absent from the `wasm32-unknown-unknown` sealing build. Every format with no codec in the build is a typed `media::MediaError::UnsupportedFormat` or a recorded per-format deferral, never a silent gap: HEIC/AVIF/RAW decode, JXL and AVIF encode, the preview tier, and all video derivatives stay deferred behind the system libraries or assemblers they need. Capsule imports **Chromahash 0.7.1** directly, never through Rawshift, and LQIP encode/decode lives in its own `capsule-core::lqip` module (slice `S-B14`) so one implementation serves the import pipeline, the FFI, and `capsule-wasm`. **ThumbHash is retired**: neither the `thumbhash` crate nor the npm `thumbhash` package may be reintroduced. Contract: [Thumbnails — LQIP](capsule-docs/src/content/docs/design/thumbnails.md#lqip). - Blob storage and resumable encrypted upload remain Capsule-owned behind narrow, arbitrary-backend ports. Do not add `object_store` or generic CAS/transfer crates without revisiting the security contract. - Keep authentication state and upload-session state as separate Capsule ports with PostgreSQL, `redis-rs`, and in-memory adapters. Do not introduce a generic TTL/CAS abstraction. - `legacy-review/` is non-buildable reference material. Restore code only after defining its contract and automated tests against the decisions above. diff --git a/Cargo.lock b/Cargo.lock index af416a67..f965c410 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -98,6 +98,21 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -343,7 +358,7 @@ dependencies = [ "addr2line", "cfg-if", "libc", - "miniz_oxide", + "miniz_oxide 0.8.9", "object", "rustc-demangle", "windows-link 0.2.1", @@ -515,6 +530,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bstr" version = "1.12.1" @@ -559,6 +595,12 @@ version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + [[package]] name = "byteorder" version = "1.5.0" @@ -668,6 +710,7 @@ dependencies = [ "openmls_memory_storage 0.5.0", "openmls_traits 0.5.0", "p256", + "rawshift-image", "rusqlite", "rustix", "serde", @@ -1002,6 +1045,12 @@ dependencies = [ "tracing-error", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.5" @@ -1107,6 +1156,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-deque" version = "0.8.7" @@ -1619,6 +1677,12 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "ff" version = "0.13.1" @@ -1657,6 +1721,17 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + [[package]] name = "fluent-uri" version = "0.4.1" @@ -1912,6 +1987,16 @@ dependencies = [ "polyval", ] +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gimli" version = "0.32.3" @@ -2495,6 +2580,17 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "img-parts" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19734e3c43b2a850f5889c077056e47c874095f2d87e853c7c41214ae67375f0" +dependencies = [ + "bytes", + "crc32fast", + "miniz_oxide 0.8.9", +] + [[package]] name = "indenter" version = "0.3.4" @@ -2612,6 +2708,12 @@ dependencies = [ "libc", ] +[[package]] +name = "jpeg-encoder" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0370574b86f7eca156b9f298392b5e69a23f8c86f3f865add60bbc2e79467a6" + [[package]] name = "js-sys" version = "0.3.77" @@ -2692,6 +2794,184 @@ dependencies = [ "zeroize", ] +[[package]] +name = "jxl-bitstream" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b480e752277e29eb4054f69546887a9b84656fe78c08f54ba5850ced98a378fe" +dependencies = [ + "tracing", +] + +[[package]] +name = "jxl-coding" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd972bcd125e776f1eb241ac50e39f956095a1c2770c64736c968f8946bd9a3c" +dependencies = [ + "jxl-bitstream", + "tracing", +] + +[[package]] +name = "jxl-color" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f316b1358c1711755b3ee8e8cb5c4a1dad12e796233088a7a513440782de80b2" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-grid", + "jxl-image", + "jxl-oxide-common", + "jxl-threadpool", + "tracing", +] + +[[package]] +name = "jxl-frame" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d967c6fd669c7c01060b5022d8835fa82fd46b06ffc98b549f17600a097c2b3" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-grid", + "jxl-image", + "jxl-modular", + "jxl-oxide-common", + "jxl-threadpool", + "jxl-vardct", + "tracing", +] + +[[package]] +name = "jxl-grid" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01671307879a033bfa52e6e8784b941aca770b3f3a7d33830b455b6844f793fb" +dependencies = [ + "tracing", +] + +[[package]] +name = "jxl-image" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5f752d62577c702a94dbbce4045caf08cb58639e8a4d56464b40ecf33ffe565" +dependencies = [ + "jxl-bitstream", + "jxl-grid", + "jxl-oxide-common", + "tracing", +] + +[[package]] +name = "jxl-jbr" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35d032bcec660647828527ff42c6f5776d2fd44b8357f9f6d9ac6dc07218e46" +dependencies = [ + "brotli-decompressor", + "jxl-bitstream", + "jxl-frame", + "jxl-grid", + "jxl-image", + "jxl-modular", + "jxl-oxide-common", + "jxl-threadpool", + "jxl-vardct", + "tracing", +] + +[[package]] +name = "jxl-modular" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2f045b24c738dd91d482be385512b512721ae08a671bd4b27bf1c47f215235" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-grid", + "jxl-oxide-common", + "jxl-threadpool", + "tracing", +] + +[[package]] +name = "jxl-oxide" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d36c662923f47586880211f3bc7c0d83fb3a9b410d278c7bde93450748abeef3" +dependencies = [ + "brotli-decompressor", + "jxl-bitstream", + "jxl-color", + "jxl-frame", + "jxl-grid", + "jxl-image", + "jxl-jbr", + "jxl-oxide-common", + "jxl-render", + "jxl-threadpool", + "tracing", +] + +[[package]] +name = "jxl-oxide-common" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62394c5021b3a9e7e0dbb2d639d555d019090c9946c39f6d3b09d390db4157b" +dependencies = [ + "jxl-bitstream", +] + +[[package]] +name = "jxl-render" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d34386bfdb6a19b5a30cc9beb4d475d537422c31ae8c39bb69640fcce3fcaf19" +dependencies = [ + "bytemuck", + "jxl-bitstream", + "jxl-coding", + "jxl-color", + "jxl-frame", + "jxl-grid", + "jxl-image", + "jxl-modular", + "jxl-oxide-common", + "jxl-threadpool", + "jxl-vardct", + "tracing", +] + +[[package]] +name = "jxl-threadpool" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f15eb830aa77a7f21148d72e153562a26bfe570139bd4922eab1908dd499d3" +dependencies = [ + "rayon", + "rayon-core", + "tracing", +] + +[[package]] +name = "jxl-vardct" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce72a18c6d3a47172ab6c479be2bdb56f22066b5d7092663f03b4490820b4511" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-grid", + "jxl-modular", + "jxl-oxide-common", + "jxl-threadpool", + "tracing", +] + [[package]] name = "k256" version = "0.13.4" @@ -3103,6 +3383,17 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libwebp-sys" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b3a87b44e34d17161e4f17d92a463d596cb13825dcd1758ed18fd3a721e189c" +dependencies = [ + "cc", + "glob", + "pkg-config", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -3115,6 +3406,20 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "little_exif" +version = "0.6.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21eeb58b22d31be8dc5c625004fcd4b9b385cd3c05df575f523bcca382c51122" +dependencies = [ + "brotli", + "crc", + "log", + "miniz_oxide 0.8.9", + "paste", + "quick-xml", +] + [[package]] name = "lock_api" version = "0.4.14" @@ -3234,6 +3539,16 @@ dependencies = [ "adler2", ] +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.1" @@ -3802,6 +4117,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pastey" version = "0.2.3" @@ -4142,6 +4463,21 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" @@ -4309,6 +4645,34 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rawshift-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d93e32ed40baedf3ad0a731690f6af5b58b6067af097c9fd3deb6e03080be9b" + +[[package]] +name = "rawshift-image" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471a16d2c1b56ee8288ba90f675e96c7513839aadac88f440c1643b815e227c3" +dependencies = [ + "gif", + "img-parts", + "jpeg-encoder", + "jxl-oxide", + "libwebp-sys", + "little_exif", + "rawshift-core", + "rayon", + "thiserror 2.0.20", + "tiff", + "tracing", + "zune-core", + "zune-jpeg", + "zune-png", +] + [[package]] name = "rayon" version = "1.12.0" @@ -5157,6 +5521,12 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "simdutf8" version = "0.1.5" @@ -5708,6 +6078,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.47" @@ -6657,6 +7041,12 @@ dependencies = [ "nom", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "whoami" version = "1.6.1" @@ -7385,8 +7775,48 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zune-png" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a321146329f7617ba0a5b26982cba45b7ce78163135aba78be25850aefcea80" +dependencies = [ + "zune-core", + "zune-inflate", +] diff --git a/NOTICE b/NOTICE index 8f65dff5..5ea11538 100644 --- a/NOTICE +++ b/NOTICE @@ -96,10 +96,10 @@ their published upstream releases. Source for each is available from crates.io and from the upstream repository named in its own package metadata. base64urlsafedata, colored, hpke-rs, hpke-rs-crypto, hpke-rs-libcrux, - hpke-rs-rust-crypto, option-ext, uniffi, uniffi_bindgen, uniffi_core, - uniffi_internal_macros, uniffi_macros, uniffi_meta, uniffi_pipeline, - uniffi_udl, webauthn-attestation-ca, webauthn-rs, webauthn-rs-core, - webauthn-rs-proto + hpke-rs-rust-crypto, option-ext, rawshift-core, rawshift-image, uniffi, + uniffi_bindgen, uniffi_core, uniffi_internal_macros, uniffi_macros, + uniffi_meta, uniffi_pipeline, uniffi_udl, webauthn-attestation-ca, + webauthn-rs, webauthn-rs-core, webauthn-rs-proto -------------------------------------------------------------------------------- Conjunctive-license components diff --git a/capsule-core/Cargo.toml b/capsule-core/Cargo.toml index 6d086b2f..ffb9a37d 100644 --- a/capsule-core/Cargo.toml +++ b/capsule-core/Cargo.toml @@ -25,7 +25,14 @@ required-features = ["ffi-bindgen"] # `wasm32-unknown-unknown` for the guest web-upload client (see `drop::seal_drop`). Build: # cargo build -p capsule-core --target wasm32-unknown-unknown --no-default-features default = ["native"] -native = ["dep:rusqlite", "dep:sqlite-vec", "mls"] +native = ["dep:rusqlite", "dep:sqlite-vec", "mls", "media"] +# `media` links the still-image decode/encode stack (`capsule_core::media`, slices `S-B1`/`S-B13`) +# over `rawshift-image`. Implied by `native`, so the CLI, the tests and the mobile FFI all carry +# decoders; **excluded** from the `wasm32-unknown-unknown` sealing build (`--no-default-features`) +# because the WebP backend is a vendored C library built through `cc` and the whole stack is +# irrelevant to sealing. `capsule_core::lqip` stays unconditional and is NOT behind this feature — +# a placeholder must not depend on which client imported the photo (slice `S-B14`). +media = ["dep:rawshift-image"] # `mls` links the live OpenMLS group backend (`crypto::authority::OpenMlsAuthority`, slice # S-X1) pinned to the X-Wing PQ ciphersuite `MLS_256_XWING_CHACHA20POLY1305_SHA256_Ed25519` # (`0x004D`) via its formally-verified libcrux provider. Implied by `native` so the default @@ -77,6 +84,34 @@ chromahash = { version = "0.7.1", default-features = false, features = ["simd"] indexmap = { workspace = true } jiff = { workspace = true } kamadak-exif = "0.5" +# Still-image decode/encode for `capsule_core::media` (`media` feature). A **registry** +# dependency, not the pinned `rawshift/` submodule, which is an uninitialised newer +# v1-in-progress tree and is not a workspace member. Depended on directly rather than through +# the `rawshift` facade because only the per-crate dependency gives per-format Cargo control +# (`rawshift-image`'s own docs say so), and the format set is a licence + build-host decision: +# +# - `jpeg` / `png` — pure-Rust zune decode **and** encode; the two formats every library holds. +# - `jxl-decode` — jxl-oxide, pure Rust. Decode only: the pure-Rust encoder backend is +# `zune-jpegxl`'s lossless `JxlSimpleEncoder`, so a q=50 thumbnail is not +# expressible without C libjxl (`bindgen` + `pkg-config`). +# - `tiff-decode` / `gif-decode` — pure Rust, no encoder needed. +# - `webp` — the derivative encoder that ships first (`libwebp-sys` 0.14.4, MIT, +# vendored static libwebp through `cc` with pre-generated bindings — the same +# class of C build `rusqlite/bundled` already performs). +# +# Deliberately absent: `heic` (system libheif), `avif` (image 0.25's `avif-native` → system +# libdav1d for decode; `ravif` → `rav1e/asm` → nasm on every x86_64 build host for encode), `svg` +# and the RAW families (`experimental`; CR3 pixel decode unimplemented upstream). Each is a typed +# `media::MediaError::UnsupportedFormat` today rather than a silent gap. MPL-2.0, already +# allow-listed in `deny.toml`; see the Media row in design/dependencies.md. +rawshift-image = { version = "0.1.1", default-features = false, features = [ + "jpeg", + "png", + "jxl-decode", + "tiff-decode", + "gif-decode", + "webp", +], optional = true } # Bundled SQLite (C) — the on-device library index. Optional + gated by `native` because it # cannot target `wasm32-unknown-unknown`; the WASM sealing build drops it. rusqlite = { version = "0.32", features = ["bundled"], optional = true } diff --git a/capsule-core/src/crypto/provenance/manifest.rs b/capsule-core/src/crypto/provenance/manifest.rs index b859603a..f58e9c01 100644 --- a/capsule-core/src/crypto/provenance/manifest.rs +++ b/capsule-core/src/crypto/provenance/manifest.rs @@ -246,6 +246,27 @@ pub struct DerivativeCore { /// Which kind of derivative. pub role: DerivativeRole, /// MIME/format string, e.g. `image/avif` or `embedding/mobileclip-b`. + /// + /// **A `String`, and deliberately so, even though the still formats are a closed set.** + /// Two reasons, and both are about keeping a *policy* rejection from becoming a *parse* + /// failure: + /// + /// - the same field carries the embedding-role grammar `embedding/{model_id}` + /// ([`crate::ml`]), which no still-format enum can model, so a single typed field would + /// have to be an enum over both grammars; + /// - a `#[serde(try_from = "String")]` newtype would make an *older* manifest naming a + /// future codec fail at deserialisation — before its signature is examined at all — + /// turning "this receiver does not recognise that format" into "this manifest is + /// unreadable". + /// + /// The closed set is enforced at the two boundaries instead: production, because + /// `media::generate_still_derivatives` only ever writes + /// `media::DerivativeFormat::mime`; and verification, via `media::verify_still_format`, + /// which rejects a still-role manifest whose value is outside the set and leaves the + /// embedding-role grammar alone. Both live behind the `media` feature, which is where the + /// tier table's format column belongs; this field stays feature-independent because + /// `capsule-server` and `capsule-wasm` must be able to *read* a manifest without linking a + /// codec. SSoT: [Thumbnails](https://docs/design/thumbnails/). pub format: String, /// Content-address digest over the derivative ciphertext. pub ciphertext_hash: Hash32, diff --git a/capsule-core/src/lib.rs b/capsule-core/src/lib.rs index 620daf1e..82defce2 100644 --- a/capsule-core/src/lib.rs +++ b/capsule-core/src/lib.rs @@ -54,6 +54,13 @@ pub mod import; pub mod library; #[cfg(feature = "native")] pub mod lifecycle; +/// Still decode, orientation, metadata normalisation and derivative generation over +/// `rawshift-image` (`media` feature, implied by `native`; slices `S-B1`/`S-B13`). Feature-gated +/// rather than `native`-gated so the codec stack is one manifest edit away from being dropped +/// from a size-constrained build, and so the `wasm32-unknown-unknown` sealing surface provably +/// does not link it. See [`media`]. +#[cfg(feature = "media")] +pub mod media; #[cfg(feature = "native")] pub mod metadata; #[cfg(feature = "native")] diff --git a/capsule-core/src/media/decode.rs b/capsule-core/src/media/decode.rs new file mode 100644 index 00000000..d7dbbb00 --- /dev/null +++ b/capsule-core/src/media/decode.rs @@ -0,0 +1,362 @@ +//! The decode seam: bytes in, orientation-applied RGBA8 out. +//! +//! # The pipeline, and why each step is here +//! +//! 1. **Identify** ([`StillFormat::detect`]) — header first, so a `.jpg` that is really a HEIC +//! is classified as HEIC. +//! 2. **Gate** ([`StillFormat::is_decodable`]) — refuse with a typed +//! [`MediaError::UnsupportedFormat`] *before* touching a decoder, so a HEIC never reaches a +//! stub that would return a less informative error. +//! 3. **Budget** ([`MAX_DECODE_PIXELS`]) — a header-only +//! [`probe`](Decoder::probe) refuses an oversized frame before the decoder allocates. It has +//! to be pre-decode: `rawshift-image` decodes to interleaved RGB `u16`, so the bomb is +//! inside the decoder, not in Capsule's copy of the result. +//! 4. **Decode**, then **apply the EXIF orientation** to the pixels, so the frame this module +//! returns is always upright and its dimensions are the ones a viewer shows. +//! 5. **Normalise** to packed RGBA8, which is what [`crate::lqip`] and the downscale both take. +//! +//! # Two lossy edges, both deliberate and both asserted +//! +//! - **Alpha is lost.** `rawshift-image`'s decode target is interleaved RGB `u16` with no alpha +//! channel, and `decode_png` drops the alpha channel outright. Every frame this module +//! returns is therefore opaque. Nothing downstream needs alpha — the LQIP is an opaque +//! placeholder and the thumbnail is composited onto a grid — but a caller must not *assume* +//! transparency survived, so a test pins the flattening rather than leaving it to be +//! discovered. +//! - **16-bit is narrowed to 8.** `(sample >> 8) as u8` is the exact inverse of the crate's own +//! `u8_to_u16` widening (`v * 257`), so an 8-bit source round-trips bit-exactly and only a +//! genuinely deeper source loses its low byte. That is the same narrowing every encode +//! backend in the crate performs anyway. +//! +//! # The panic guard +//! +//! [`decode_guarded`] wraps a [`Decoder`] call in [`std::panic::catch_unwind`]. It is a free +//! function over the trait rather than a detail inside [`RawshiftDecoder`] for one reason: a +//! test has to be able to prove the guard holds, and it can only do that by injecting a +//! [`Decoder`] that panics. + +use std::panic::{AssertUnwindSafe, catch_unwind}; + +use rawshift_image::core::ColorSpace; +use rawshift_image::core::image::RgbImage; +use rawshift_image::formats::{ + StandardFormat, decode_standard_image, probe_standard_image, read_standard_image_metadata, +}; +use rawshift_image::transforms::orientation::apply_orientation; + +use super::detect::{MAX_DECODE_PIXELS, StillFormat}; +use super::error::{FormatOp, MediaError}; +use crate::lqip::{Gamut, RgbaImage}; + +/// The EXIF orientation value meaning "already upright". +const ORIENTATION_NORMAL: u16 = 1; + +/// What a header-only probe can say about a still, normalised onto Capsule's own types. +/// +/// This is the "metadata normalisation" half of the module: `rawshift-image` reports a format, +/// a size, an optional bit depth and a colour space, plus (from a separate EXIF read) an +/// orientation. None of those types may appear in Capsule's public surface — they belong to a +/// pre-1.0 dependency — so each is mapped onto a Capsule type here, once. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MediaMetadata { + /// The identified format. + pub format: StillFormat, + /// Dimensions **as stored**, i.e. before the EXIF orientation is applied. A probe reads the + /// codec header, which knows nothing about the orientation tag; the upright dimensions are + /// what [`DecodedImage`] carries. + pub stored_dimensions: (u32, u32), + /// The EXIF orientation tag (1..=8), where the format carries one and it was readable. + pub orientation: Option, + /// Bits per channel, where the header exposes it cheaply. + pub bit_depth: Option, + /// The source colour space, mapped onto the gamut [`crate::lqip::Lqip::encode`] takes. + pub gamut: Gamut, +} + +impl MediaMetadata { + /// The dimensions a viewer shows: the stored ones, transposed when the orientation tag is + /// one of the four quarter-turns (5, 6, 7, 8). + pub const fn upright_dimensions(&self) -> (u32, u32) { + let (width, height) = self.stored_dimensions; + match self.orientation { + Some(5 | 6 | 7 | 8) => (height, width), + _ => (width, height), + } + } +} + +/// A decoded still: upright, opaque, packed RGBA8. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DecodedImage { + /// The pixels, `width * height * 4` bytes, alpha uniformly `255`. + /// + /// [`crate::lqip::RgbaImage`] rather than a new buffer type: it is already the shape + /// [`crate::lqip::Lqip::encode`] and [`downscale_rgba8`](super::downscale_rgba8) take, and + /// it is unconditional, so no `media`-only type reaches the LQIP contract. + pub image: RgbaImage, + /// The source colour space this frame's samples are in. + pub gamut: Gamut, + /// The EXIF orientation value that was **consumed** — the transform is already applied to + /// `image`, so a renderer that rotates again is double-applying. `1` when the source + /// carried no tag. + pub orientation_applied: u16, + /// The format the pixels came out of. + pub format: StillFormat, +} + +impl DecodedImage { + /// Frame width in pixels, upright. + pub const fn width(&self) -> u32 { + self.image.width + } + + /// Frame height in pixels, upright. + pub const fn height(&self) -> u32 { + self.image.height + } +} + +/// The still-decode seam. +/// +/// A trait with exactly one production implementation ([`RawshiftDecoder`]), and that is the +/// point: the failure modes worth testing — a panicking decoder, a decoder that reports +/// dimensions its buffer does not match — cannot be produced from real bytes on demand, so they +/// are injected. +pub trait Decoder { + /// Read the format, dimensions and orientation from the header without decoding pixels. + /// + /// # Errors + /// [`MediaError::NotAStillImage`] when nothing recognisable is there, + /// [`MediaError::UnsupportedFormat`] when the format has no decoder in this build, and + /// [`MediaError::PixelBudgetExceeded`] when the header claims more than + /// [`MAX_DECODE_PIXELS`]. + fn probe(&self, bytes: &[u8], ext: &str) -> Result; + + /// Decode to upright, packed RGBA8. Probes first, so every [`probe`](Self::probe) error is + /// also a `decode` error. + /// + /// # Errors + /// As [`probe`](Self::probe), plus [`MediaError::Decode`] when a supported format's bytes + /// do not decode and [`MediaError::BufferLengthMismatch`] / [`MediaError::ZeroDimension`] + /// when the decoder's own output is inconsistent. + fn decode(&self, bytes: &[u8], ext: &str) -> Result; +} + +/// The `rawshift-image`-backed [`Decoder`] — the only production implementation. +#[derive(Debug, Clone, Copy, Default)] +pub struct RawshiftDecoder; + +impl Decoder for RawshiftDecoder { + #[tracing::instrument(level = "debug", skip_all, fields(bytes = bytes.len(), ext))] + fn probe(&self, bytes: &[u8], ext: &str) -> Result { + let format = gate(bytes, ext, FormatOp::Decode)?; + let probe = probe_standard_image(bytes).map_err(|e| MediaError::Decode { + format, + detail: format!("header probe: {e}"), + })?; + let (width, height) = (probe.size.width, probe.size.height); + if width == 0 || height == 0 { + return Err(MediaError::ZeroDimension { width, height }); + } + let pixels = u64::from(width) * u64::from(height); + if pixels > MAX_DECODE_PIXELS { + tracing::warn!( + %format, + width, + height, + pixels, + limit = MAX_DECODE_PIXELS, + "media: refusing an oversized still before the decoder allocates" + ); + return Err(MediaError::PixelBudgetExceeded { + pixels, + limit: MAX_DECODE_PIXELS, + }); + } + let metadata = MediaMetadata { + format, + stored_dimensions: (width, height), + orientation: orientation_of(bytes, format), + bit_depth: probe.bit_depth, + gamut: gamut_of(probe.color_space), + }; + tracing::debug!( + %format, + width, + height, + orientation = ?metadata.orientation, + bit_depth = ?metadata.bit_depth, + gamut = ?metadata.gamut, + "media: probed a still" + ); + Ok(metadata) + } + + #[tracing::instrument(level = "debug", skip_all, fields(bytes = bytes.len(), ext))] + fn decode(&self, bytes: &[u8], ext: &str) -> Result { + let probed = self.probe(bytes, ext)?; + let format = probed.format; + let mut rgb = decode_standard_image(bytes, standard_format(format)).map_err(|e| { + MediaError::Decode { + format, + detail: e.to_string(), + } + })?; + check_rgb_buffer(&rgb, format)?; + + let orientation = probed.orientation.unwrap_or(ORIENTATION_NORMAL); + if orientation != ORIENTATION_NORMAL { + apply_orientation(&mut rgb, orientation); + check_rgb_buffer(&rgb, format)?; + } + + let image = to_rgba8(&rgb); + tracing::debug!( + %format, + width = image.width, + height = image.height, + orientation, + "media: decoded a still" + ); + Ok(DecodedImage { + image, + gamut: probed.gamut, + orientation_applied: orientation, + format, + }) + } +} + +/// Run `decoder.decode` with the unwind boundary an import needs. +/// +/// A pre-1.0 decoder fed untrusted bytes is exactly where a panic is plausible, and a missing +/// thumbnail must never be able to abort an import that has already written signed, encrypted +/// bytes. A caught unwind becomes [`MediaError::DecoderPanic`] — reported, not swallowed. +pub fn decode_guarded( + decoder: &dyn Decoder, + bytes: &[u8], + ext: &str, +) -> Result { + match catch_unwind(AssertUnwindSafe(|| decoder.decode(bytes, ext))) { + Ok(result) => result, + Err(_) => { + tracing::warn!( + bytes = bytes.len(), + ext, + "media: a decoder panicked; the original is imported without a derivative" + ); + Err(MediaError::DecoderPanic) + } + } +} + +/// Identify a still and refuse anything this build has no codec for, before any decoder runs. +fn gate(bytes: &[u8], ext: &str, op: FormatOp) -> Result { + let Some(format) = StillFormat::detect(bytes, ext) else { + return Err(MediaError::NotAStillImage); + }; + if !format.is_decodable() { + return Err(MediaError::UnsupportedFormat { format, op }); + } + Ok(format) +} + +/// Map Capsule's format onto the crate's. Total by construction over the decodable set, which +/// is the only set that reaches here — [`gate`] rejects the rest, and every non-decodable +/// variant is a format the crate either cannot name without a feature (HEIC) or cannot decode +/// as a standard image at all (the RAW families). +fn standard_format(format: StillFormat) -> StandardFormat { + match format { + StillFormat::Jpeg => StandardFormat::Jpeg, + StillFormat::Png => StandardFormat::Png, + StillFormat::WebP => StandardFormat::WebP, + StillFormat::Jxl => StandardFormat::Jxl, + StillFormat::Tiff => StandardFormat::Tiff, + StillFormat::Gif => StandardFormat::Gif, + StillFormat::Ppm => StandardFormat::Ppm, + // Unreachable through `gate`. Mapped to the container the bytes actually are rather + // than panicking, so a future `is_decodable` widening that forgets this table degrades + // to a decode error instead of aborting an import. + StillFormat::Avif => StandardFormat::Avif, + StillFormat::Heic => StandardFormat::Heic, + StillFormat::Cr3 => StandardFormat::Heic, + StillFormat::Arw + | StillFormat::Cr2 + | StillFormat::Crw + | StillFormat::Dng + | StillFormat::Nef + | StillFormat::Raf => StandardFormat::Tiff, + } +} + +/// The EXIF orientation tag, where the format carries one. Only JPEG, TIFF, WebP, PNG and AVIF +/// have an EXIF block the crate's parser reads; the others return `None` rather than guessing. +fn orientation_of(bytes: &[u8], format: StillFormat) -> Option { + let metadata = read_standard_image_metadata(bytes, standard_format(format)); + // Only the eight defined values are honoured. `apply_orientation` warns and no-ops on + // anything else, which would leave `orientation_applied` claiming a transform that never + // happened — so an out-of-range tag is dropped here instead. + metadata.image.orientation.filter(|o| (1..=8).contains(o)) +} + +/// Map the crate's colour space onto the gamut the LQIP encoder takes. +/// +/// `LinearSrgb` and `Unknown` both become [`Gamut::Srgb`]: `Linear` names a transfer function +/// rather than a gamut, and sRGB primaries are the only safe assumption for an untagged source +/// (over-saturating is worse than under-saturating — the resolution slice `S-B14` recorded). +fn gamut_of(color_space: ColorSpace) -> Gamut { + match color_space { + ColorSpace::DisplayP3 => Gamut::DisplayP3, + ColorSpace::AdobeRgb => Gamut::AdobeRgb, + ColorSpace::Rec2020 => Gamut::Bt2020, + ColorSpace::ProPhotoRgb => Gamut::ProPhotoRgb, + ColorSpace::Srgb | ColorSpace::LinearSrgb | ColorSpace::Unknown => Gamut::Srgb, + // `ColorSpace` is `#[non_exhaustive]`, so a future wide-gamut variant must land here + // rather than fail the build. sRGB is the conservative default: under-saturating a + // wide-gamut source is a smaller defect than over-saturating a narrow one. + _ => Gamut::Srgb, + } +} + +/// Refuse a decoder result whose buffer does not match the dimensions it reports. +/// +/// `RgbImage::new` performs no validation and `set_size` is public, so a decoder bug (or a +/// transform bug) can produce an inconsistent value. Checked here because the very next thing +/// Capsule does is index that buffer by those dimensions. +fn check_rgb_buffer(rgb: &RgbImage, format: StillFormat) -> Result<(), MediaError> { + let (width, height) = (rgb.width(), rgb.height()); + if width == 0 || height == 0 { + return Err(MediaError::ZeroDimension { width, height }); + } + let expected = u128::from(width) * u128::from(height) * 3; + let actual = rgb.data.len() as u128; + if expected != actual { + return Err(MediaError::BufferLengthMismatch { + format, + width, + height, + expected, + actual, + }); + } + Ok(()) +} + +/// Narrow interleaved RGB `u16` to packed, opaque RGBA8. +/// +/// `sample >> 8` is the exact inverse of the crate's `v * 257` widening, so an 8-bit source is +/// reproduced bit-for-bit. +fn to_rgba8(rgb: &RgbImage) -> RgbaImage { + let mut rgba = Vec::with_capacity(rgb.data.len() / 3 * 4); + for px in rgb.data.chunks_exact(3) { + rgba.push((px[0] >> 8) as u8); + rgba.push((px[1] >> 8) as u8); + rgba.push((px[2] >> 8) as u8); + rgba.push(u8::MAX); + } + RgbaImage { + width: rgb.width(), + height: rgb.height(), + rgba, + } +} diff --git a/capsule-core/src/media/derivative.rs b/capsule-core/src/media/derivative.rs new file mode 100644 index 00000000..3828a65c --- /dev/null +++ b/capsule-core/src/media/derivative.rs @@ -0,0 +1,472 @@ +//! Still-derivative tiers, the closed format set, and the signed manifests over them. +//! +//! SSoT for the tiers and the formats: [Thumbnails and Previews](https://docs/design/thumbnails/). +//! This module owns the Capsule-side half — sizing, the closed enum, and building + signing a +//! [`DerivativeManifest`] through the same two-signature path assets use +//! ([`DerivativeCore::sign`]) — while `rawshift-image` owns the byte encode. +//! +//! # The closed format set, and where it is enforced +//! +//! [`DerivativeFormat`] is the tier table's format column as a closed enum. `format` is a +//! `String` in the signed struct and **stays** one, deliberately: the same field carries +//! `embedding/{model_id}` for embedding-role manifests +//! ([`crate::ml`]), so a still-only enum cannot be its type; and a `try_from` newtype would make +//! an *older* manifest carrying a future codec fail at deserialisation, turning a policy +//! rejection into a parse error before any signature is examined. The closed set is therefore +//! enforced at the two boundaries the contract names: +//! +//! - **production** — [`generate_still_derivatives`] only ever writes +//! [`DerivativeFormat::mime`], so no other value can be authored here; +//! - **verification** — [`verify_still_format`] rejects a still-role manifest whose `format` +//! does not parse, which is the structural rejection the tier table specifies. +//! +//! # What this build encodes +//! +//! WebP only. [`DerivativeFormat::STILL_DELIVERY_ORDER`] still lists JXL and AVIF because they +//! are the committed master and delivery formats; each is recorded as a per-`(tier, format)` +//! deferral on [`StillDerivatives::deferred`] and warned once, so the gap is countable rather +//! than invisible. JXL needs C libjxl for a lossy encode (the pure-Rust backend is +//! `zune-jpegxl`'s lossless simple encoder) and AVIF needs `nasm` on every x86_64 build host; +//! neither is a decision this module can take on its own. +//! +//! [`DerivativeManifest`]: crate::crypto::provenance::DerivativeManifest +//! [`DerivativeCore::sign`]: crate::crypto::provenance::manifest::DerivativeCore::sign + +use std::fmt; + +use rawshift_image::core::image::RgbImage; +use rawshift_image::core::metadata::ImageMetadata; +use rawshift_image::core::{BitDepth, ColorSpace, MetadataEmbedOptions}; +use rawshift_image::formats::encode_rgb_image_to_vec; +use rawshift_image::formats::export::{ + CommonEncodeOptions, EncodeOptions, LibwebpEncodeConfig, WebPMode, +}; +use uuid::Uuid; + +use super::decode::DecodedImage; +use super::error::{FormatOp, MediaError}; +use super::resize::downscale_rgba8; +use crate::cbor; +use crate::crypto::CryptoError; +use crate::crypto::hash::{self, Hash32}; +use crate::crypto::keys::{AmkVersion, Signer}; +use crate::crypto::provenance::manifest::{DERIVATIVE_MANIFEST_VERSION, DerivativeCore}; +use crate::crypto::provenance::{DerivativeManifest, DerivativeRole}; +use crate::lqip::RgbaImage; + +/// The closed set of committed still-derivative formats — the tier table's format column. +/// +/// The wire value is [`mime`](Self::mime), carried in `DerivativeManifest.format`. A value +/// outside this set is a structural rejection, never a "future format to ignore". +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DerivativeFormat { + /// **JPEG XL** — the committed primary/master still codec. Not encodable in this build. + Jxl, + /// **AVIF** — the universal delivery format for clients without a JXL decoder. Not + /// encodable in this build. + Avif, + /// **WebP** — the last-resort delivery fallback, and the one format this build encodes. + WebP, + /// The recognised `format = "original"` sentinel: the tier references the original asset + /// rather than generating a redundant derivative, because the source is not larger than the + /// tier's cap. **Distinct from an absent derivative** — this is an explicit, signed marker, + /// where absence means "rebuildable from the original". + Original, +} + +impl DerivativeFormat { + /// The committed still formats per tier, in delivery-preference order: the JXL master, then + /// the AVIF -> WebP delivery variants. + pub const STILL_DELIVERY_ORDER: [Self; 3] = [Self::Jxl, Self::Avif, Self::WebP]; + + /// The exact wire string for `DerivativeManifest.format`. + pub const fn mime(self) -> &'static str { + match self { + Self::Jxl => "image/jxl", + Self::Avif => "image/avif", + Self::WebP => "image/webp", + Self::Original => "original", + } + } + + /// The on-disk file extension for a persisted derivative of this format. `Original` has + /// none of its own — it reuses the source asset's. + pub const fn extension(self) -> Option<&'static str> { + match self { + Self::Jxl => Some("jxl"), + Self::Avif => Some("avif"), + Self::WebP => Some("webp"), + Self::Original => None, + } + } + + /// Parse a `DerivativeManifest.format` value against the closed set. `None` **is** the + /// structural rejection. + pub fn parse(s: &str) -> Option { + match s { + "image/jxl" => Some(Self::Jxl), + "image/avif" => Some(Self::Avif), + "image/webp" => Some(Self::WebP), + "original" => Some(Self::Original), + _ => None, + } + } + + /// Whether a `format` string names a currently-recognised still-derivative format — the + /// exact check a receiver runs. + pub fn is_recognized(s: &str) -> bool { + Self::parse(s).is_some() + } + + /// Whether this build can produce bytes in this format. + pub const fn is_encodable(self) -> bool { + matches!(self, Self::WebP | Self::Original) + } +} + +impl fmt::Display for DerivativeFormat { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.mime()) + } +} + +/// A derivative tier from the tier table. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DerivativeTier { + /// Grid display: long edge capped at 256 px, q=50. + Thumbnail, + /// Lightbox / single-asset view: source resolution, q=70. **Not generated by this build** + /// — a source-resolution derivative is only worth its bytes in the master codec, and the + /// master codec is the half that is still blocked on a toolchain. Kept in the enum because + /// the tier table commits to it and the sizing rule is the contract. + Preview, +} + +impl DerivativeTier { + /// The tiers this build actually generates. + pub const GENERATED: [Self; 1] = [Self::Thumbnail]; + + /// The provenance role this tier records. + pub const fn role(self) -> DerivativeRole { + match self { + Self::Thumbnail => DerivativeRole::Thumbnail, + Self::Preview => DerivativeRole::Preview, + } + } + + /// The role's on-disk name — mirrors + /// [`derivative_role_name`](crate::lifecycle) in the upload bundle reader, which finds a + /// derivative's bytes by this prefix. + pub const fn role_name(self) -> &'static str { + match self { + Self::Thumbnail => "thumbnail", + Self::Preview => "preview", + } + } + + /// Long-edge cap in pixels, or `None` to keep the source resolution. The 1080p cap in the + /// tier table governs the *video* preview transcode (slice `S-B5`), not the still preview. + pub const fn max_long_edge(self) -> Option { + match self { + Self::Thumbnail => Some(256), + Self::Preview => None, + } + } + + /// Lossy encoder quality for this tier, on the 0..=100 scale every backend here uses. + pub const fn quality(self) -> f32 { + match self { + Self::Thumbnail => 50.0, + Self::Preview => 70.0, + } + } +} + +impl fmt::Display for DerivativeTier { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.role_name()) + } +} + +/// Everything the manifest signer needs that the pixels do not carry: the asset identity, the +/// epoch/authorisation context, and the two signing keys that produce the manifest's two hybrid +/// signatures. +pub struct DerivativeContext<'a> { + /// The asset the derivatives are generated from. + pub source_asset_id: Uuid, + /// Primitive bundle in force. + pub crypto_suite_id: u16, + /// Date-based wire protocol version (matches the album pin). + pub protocol_version: String, + /// The AMK epoch whose write-tier key signs the manifests. + pub amk_version: AmkVersion, + /// Device that generated the derivatives. + pub generated_by_device: Uuid, + /// Generating client version string. + pub generated_by_client: String, + /// RFC 3339 generation time (audit-only). + pub generated_at: String, + /// The device DSK (provenance signature); may be hardware-backed. + pub device_signer: &'a dyn Signer, + /// The per-epoch write-tier key (authorisation signature). + pub write_tier_signer: &'a dyn Signer, +} + +/// One generated derivative: the encoded bytes plus its signed manifest. +#[derive(Debug, Clone)] +pub struct GeneratedDerivative { + /// Which tier this is. + pub tier: DerivativeTier, + /// Which committed format, or the `Original` sentinel. + pub format: DerivativeFormat, + /// The derivative bytes — the encoder output, or the original for `Original`. + pub bytes: Vec, + /// The signed manifest binding `hash(bytes)`, the role and the format. + pub manifest: DerivativeManifest, +} + +/// The outcome of one asset's still-derivative generation. +/// +/// `deferred` is the per-`(tier, format)` gap S-B13 asks for: a pair with no encoder in this +/// build is *recorded*, not collapsed into the asset-level status. The asset is still +/// `DerivativeStatus::Decoded` — the decode succeeded and a renderable derivative exists — +/// which is why the two live in different places. +#[derive(Debug, Clone, Default)] +pub struct StillDerivatives { + /// The derivatives that were produced, in generation order. + pub generated: Vec, + /// The `(tier, format)` pairs the tier table commits to and this build cannot encode. + pub deferred: Vec<(DerivativeTier, DerivativeFormat)>, +} + +/// Generate the committed still derivatives for `decoded` across `tiers`, signing a +/// [`DerivativeManifest`] for each. +/// +/// Per tier: +/// - if the tier caps the long edge and the source is **not larger** than the cap, a single +/// `format = "original"` manifest is signed over `original_bytes` — the redundant-derivative +/// sentinel from the contract, never a re-encode; +/// - otherwise the frame is downscaled to the tier and encoded to each encodable format of +/// [`DerivativeFormat::STILL_DELIVERY_ORDER`], with the rest recorded as deferrals. +/// +/// Manifests of the same role are hash-chained in generation order, so a role's derivative +/// provenance is append-only exactly like the asset's. +/// +/// # Errors +/// [`MediaError::Encode`] when a codec refuses the frame, and [`MediaError::ZeroDimension`] for +/// an empty source. A signing failure (a hardware device signer refusing) surfaces as +/// [`MediaError::Encode`] too, carrying the crypto error's message. +#[tracing::instrument( + level = "debug", + skip_all, + fields(asset_id = %ctx.source_asset_id, tiers = tiers.len()) +)] +pub fn generate_still_derivatives( + decoded: &DecodedImage, + original_bytes: &[u8], + tiers: &[DerivativeTier], + ctx: &DerivativeContext<'_>, +) -> Result { + if decoded.width() == 0 || decoded.height() == 0 { + return Err(MediaError::ZeroDimension { + width: decoded.width(), + height: decoded.height(), + }); + } + + let mut out = StillDerivatives::default(); + let source_long_edge = decoded.width().max(decoded.height()); + + for &tier in tiers { + // Each tier records a distinct role, so its manifests form their own chain. + let mut prior: Option = None; + + if let Some(cap) = tier.max_long_edge() + && source_long_edge <= cap + { + tracing::debug!( + asset_id = %ctx.source_asset_id, + %tier, + source_long_edge, + cap, + "media: source is within the tier cap; signing the `original` sentinel" + ); + out.generated.push(sign_derivative( + ctx, + tier, + DerivativeFormat::Original, + original_bytes, + &mut prior, + )?); + continue; + } + + let work = match tier.max_long_edge() { + Some(cap) => downscale_rgba8(&decoded.image, cap), + None => decoded.image.clone(), + }; + for format in DerivativeFormat::STILL_DELIVERY_ORDER { + if !format.is_encodable() { + tracing::warn!( + asset_id = %ctx.source_asset_id, + %tier, + %format, + "media: no encoder for this (tier, format) in this build; the tier still \ + ships its encodable variants and this pair is backfillable (S-B1 remainder)" + ); + out.deferred.push((tier, format)); + continue; + } + let bytes = encode(&work, format, tier)?; + out.generated + .push(sign_derivative(ctx, tier, format, &bytes, &mut prior)?); + } + } + + tracing::debug!( + asset_id = %ctx.source_asset_id, + generated = out.generated.len(), + deferred = out.deferred.len(), + "media: still derivatives generated" + ); + Ok(out) +} + +/// The closed-set check a receiver runs on a still-role derivative manifest. +/// +/// Returns the parsed format for a `thumbnail` or `preview` manifest whose `format` is in the +/// closed set. An embedding-role manifest is **not** rejected: its `format` is +/// `embedding/{model_id}`, which this set deliberately does not model, so it is reported as +/// [`None`] rather than as a violation. +/// +/// # Errors +/// [`MediaError::UnsupportedFormat`] — carrying the still format Capsule *would* have needed — +/// is not what an unrecognised value produces, because there is no [`super::StillFormat`] to +/// name. An unrecognised still-role format is `Err(format.to_string())`. +pub fn verify_still_format( + manifest: &DerivativeManifest, +) -> Result, String> { + let core = &manifest.core; + match core.role { + DerivativeRole::Thumbnail | DerivativeRole::Preview => { + DerivativeFormat::parse(&core.format) + .map(Some) + .ok_or_else(|| core.format.clone()) + } + // Not a still. The embedding-role format grammar belongs to `crate::ml`. + DerivativeRole::Embedding => Ok(None), + } +} + +/// Encode a tier-sized RGBA8 frame to `format`. +/// +/// **Every encode passes [`MetadataEmbedOptions::none`]**, and that is load-bearing rather than +/// tidy: the crate's own default is `all()`, so a default-configured encode copies the source's +/// EXIF — GPS fix included — into the derivative bytes. A thumbnail is the derivative most +/// likely to be served widest, so leaking a home address into it would be the worst possible +/// place for that default to win. A test asserts the absence rather than trusting this comment. +fn encode( + frame: &RgbaImage, + format: DerivativeFormat, + tier: DerivativeTier, +) -> Result, MediaError> { + let options = match format { + DerivativeFormat::WebP => EncodeOptions::WebpLibwebp(LibwebpEncodeConfig { + common: CommonEncodeOptions { + metadata: MetadataEmbedOptions::none(), + bit_depth: BitDepth::Eight, + }, + mode: WebPMode::Lossy, + quality: tier.quality(), + // The libwebp compression method, 0 (fast) to 6 (slowest, best). 4 is the crate's + // own default and the usual trade; a thumbnail is small enough that the slower + // methods buy little. + method: 4, + // Lossless-only knob; 100 means off. + near_lossless: 100, + }), + // Unreachable: `is_encodable` gates the call. Kept as a typed refusal rather than a + // panic so a future widening that forgets an arm degrades to a deferral. + DerivativeFormat::Jxl | DerivativeFormat::Avif | DerivativeFormat::Original => { + return Err(MediaError::UnsupportedFormat { + format: super::StillFormat::WebP, + op: FormatOp::Encode, + }); + } + }; + + let rgb = to_rgb_u16(frame); + encode_rgb_image_to_vec(&rgb, &ImageMetadata::default(), &options).map_err(|e| { + MediaError::Encode { + format, + detail: e.to_string(), + } + }) +} + +/// Widen packed RGBA8 to the interleaved RGB `u16` the encoders take, dropping alpha. +/// +/// `v * 257` is the crate's own widening, and the encoders narrow it back with `v >> 8`, so an +/// 8-bit frame reaches the codec bit-for-bit. Alpha is dropped because the decode path already +/// flattened it — every frame here is opaque. +fn to_rgb_u16(frame: &RgbaImage) -> RgbImage { + let mut data = Vec::with_capacity(frame.rgba.len() / 4 * 3); + for px in frame.rgba.chunks_exact(4) { + data.push(u16::from(px[0]) * 257); + data.push(u16::from(px[1]) * 257); + data.push(u16::from(px[2]) * 257); + } + RgbImage::with_color_space(frame.width, frame.height, data, ColorSpace::Srgb) +} + +/// Build, sign and chain one derivative manifest over `bytes`. +/// +/// `pub(super)` so the module's tests can exercise the chaining directly. That is not test +/// convenience for its own sake: today exactly one still format is encodable, so a single call +/// to [`generate_still_derivatives`] produces one manifest per role and the multi-link case — +/// the part of the chain that can actually be wrong — is unreachable through the public entry +/// point until a second encoder lands. +pub(super) fn sign_derivative( + ctx: &DerivativeContext<'_>, + tier: DerivativeTier, + format: DerivativeFormat, + bytes: &[u8], + prior: &mut Option, +) -> Result { + let core = DerivativeCore { + version: DERIVATIVE_MANIFEST_VERSION.into(), + crypto_suite_id: ctx.crypto_suite_id, + protocol_version: Some(ctx.protocol_version.clone()), + amk_version: Some(ctx.amk_version), + source_asset_id: ctx.source_asset_id, + role: tier.role(), + format: format.mime().into(), + ciphertext_hash: hash::hash_bytes(bytes), + generated_by_device: ctx.generated_by_device, + generated_by_client: ctx.generated_by_client.clone(), + model_id: None, + model_version: None, + generated_at: ctx.generated_at.clone(), + prior_provenance_hash: *prior, + }; + let manifest = core + .sign(ctx.device_signer, ctx.write_tier_signer) + .map_err(|e: CryptoError| MediaError::Encode { + format, + detail: format!("signing the derivative manifest: {e}"), + })?; + // The next manifest of this role chains to this one: SHA-256 over its canonical CBOR, + // signatures included — the same content-hash link the asset provenance chain uses. + *prior = Some(hash::hash_bytes( + &cbor::to_canonical_vec(&manifest).map_err(|e| MediaError::Encode { + format, + detail: format!("serialising the derivative manifest: {e}"), + })?, + )); + Ok(GeneratedDerivative { + tier, + format, + bytes: bytes.to_vec(), + manifest, + }) +} diff --git a/capsule-core/src/media/detect.rs b/capsule-core/src/media/detect.rs new file mode 100644 index 00000000..496946c7 --- /dev/null +++ b/capsule-core/src/media/detect.rs @@ -0,0 +1,262 @@ +//! Capsule's closed still-format set, its magic-byte table, and the codec-coverage predicate. +//! +//! # Why Capsule sniffs rather than delegating +//! +//! `rawshift-image` ships `detect_standard_format`, and Capsule deliberately does not use it as +//! the primary table: its HEIC arm is `#[cfg(feature = "heic-decode")]`, so a build without the +//! HEIC codec cannot *recognise* HEIC either. That would make the typed refusal for exactly the +//! formats this build cannot decode depend on whether it can decode them — a HEIC would arrive +//! as "not a still image" instead of "a still image with no codec here", which is the difference +//! between a reportable, backfillable gap and an apparent non-image. Capsule's reference library +//! is HEIC end to end, so that distinction is the whole point of slice `S-B13`. +//! +//! The two tables are held together by a test rather than by hope: +//! `capsule-core`'s `still_format_agrees_with_rawshift_detection` asserts that for every format +//! both sides define unconditionally, Capsule's sniff and `detect_standard_format` name the same +//! thing. +//! +//! # Bytes first, extension second +//! +//! Detection is by header, so a `.jpg` that is really a HEIC is classified as HEIC. The +//! extension is consulted in exactly two places, both of them cases a header genuinely cannot +//! settle: +//! +//! 1. **RAW refinement.** ARW, CR2, DNG and NEF are TIFF containers and CR3 is ISO-BMFF; their +//! headers are their container's, so only the extension distinguishes a Sony ARW from a +//! scanner's TIFF. A misrefinement costs a deferral, never wrong pixels, because no RAW +//! family decodes in this build. +//! 2. **Fallback.** When the header sniffs to nothing at all. + +use std::fmt; + +/// The decode budget in pixels, refused **before** the decoder allocates. +/// +/// 256 Mpx sits well above a 100 Mpx medium-format frame and well below an allocation bomb: +/// `rawshift-image` decodes to interleaved RGB `u16`, so this ceiling caps the decoder's own +/// buffer at ~1.5 GB and Capsule's RGBA8 copy at ~1 GB. +pub const MAX_DECODE_PIXELS: u64 = 256_000_000; + +/// The closed set of still-image formats Capsule models. +/// +/// Closed on purpose: a still Capsule cannot name is not a still it silently ignores, it is a +/// [`MediaError::NotAStillImage`](super::MediaError::NotAStillImage). Membership here says +/// "Capsule knows this is a photo"; [`is_decodable`](Self::is_decodable) says whether *this +/// build* can read its pixels. The two are deliberately separate — that gap is what +/// `DerivativeStatus::DeferredNoCodec` reports. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum StillFormat { + /// JPEG / JFIF. Decoded by `zune-jpeg`. + Jpeg, + /// PNG. Decoded by `zune-png`; alpha is flattened at the decode boundary. + Png, + /// WebP. Decoded and encoded by `libwebp`; the derivative format this build produces. + WebP, + /// JPEG XL. Decoded by `jxl-oxide`. No lossy encoder without C libjxl. + Jxl, + /// TIFF. Decoded by the `tiff` crate. Also the container of most RAW families. + Tiff, + /// GIF. Decoded by `gif`; the first frame only. + Gif, + /// Netpbm (P5/P6/P7/PFM). Recognised, not decoded: the `ppm-decode` backend is deliberately + /// not enabled. Netpbm is an intermediate and test-fixture format rather than something a + /// photo library holds, so recognising it and deferring is the honest outcome — and it costs + /// one fewer dependency than a codec nobody's library needs. + Ppm, + /// AVIF. Recognised, not decoded — the backend needs system libdav1d. + Avif, + /// HEIC / HEIF. Recognised, not decoded — the backend needs system libheif. + Heic, + /// Sony ARW. + Arw, + /// Canon CR2. + Cr2, + /// Canon CR3. + Cr3, + /// Canon CRW. + Crw, + /// Adobe DNG (including Apple ProRAW). + Dng, + /// Nikon NEF. + Nef, + /// Fujifilm RAF. + Raf, +} + +/// Every format this build can read pixels out of — the codec-coverage table, in one place. +/// +/// Logged verbatim on a deferral so the gap is legible in the field rather than only in a doc. +pub const SUPPORTED_STILL_FORMATS: &[StillFormat] = &[ + StillFormat::Jpeg, + StillFormat::Png, + StillFormat::WebP, + StillFormat::Jxl, + StillFormat::Tiff, + StillFormat::Gif, +]; + +/// The RAW families, all of them recognised and none of them decodable here. +const RAW_FORMATS: &[StillFormat] = &[ + StillFormat::Arw, + StillFormat::Cr2, + StillFormat::Cr3, + StillFormat::Crw, + StillFormat::Dng, + StillFormat::Nef, + StillFormat::Raf, +]; + +impl StillFormat { + /// Whether *this build* can read pixels out of the format — the single codec-coverage + /// predicate, and the gate the decode path checks before touching a decoder. + pub fn is_decodable(self) -> bool { + SUPPORTED_STILL_FORMATS.contains(&self) + } + + /// Whether the format is one of the RAW families. RAW is recognised, never decoded here; + /// its container is TIFF or ISO-BMFF, so the extension is what names the family. + pub fn is_raw(self) -> bool { + RAW_FORMATS.contains(&self) + } + + /// The canonical media type — what the sidecar's `content_type` carries when detection + /// succeeded. + pub const fn mime(self) -> &'static str { + match self { + Self::Jpeg => "image/jpeg", + Self::Png => "image/png", + Self::WebP => "image/webp", + Self::Jxl => "image/jxl", + Self::Tiff => "image/tiff", + Self::Gif => "image/gif", + Self::Ppm => "image/x-portable-anymap", + Self::Avif => "image/avif", + Self::Heic => "image/heic", + Self::Arw => "image/x-sony-arw", + Self::Cr2 => "image/x-canon-cr2", + Self::Cr3 => "image/x-canon-cr3", + Self::Crw => "image/x-canon-crw", + Self::Dng => "image/x-adobe-dng", + Self::Nef => "image/x-nikon-nef", + Self::Raf => "image/x-fuji-raf", + } + } + + /// The lowercase extension table — the *fallback*, used only where a header cannot settle + /// the question (see the module docs). Extensions arrive lowercased. + pub fn from_extension(ext: &str) -> Option { + Some(match ext { + "jpg" | "jpeg" | "jpe" | "jfif" => Self::Jpeg, + "png" => Self::Png, + "webp" => Self::WebP, + "jxl" => Self::Jxl, + "tif" | "tiff" => Self::Tiff, + "gif" => Self::Gif, + "ppm" | "pgm" | "pnm" | "pfm" => Self::Ppm, + "avif" | "avifs" => Self::Avif, + "heic" | "heif" | "hif" => Self::Heic, + "arw" => Self::Arw, + "cr2" => Self::Cr2, + "cr3" => Self::Cr3, + "crw" => Self::Crw, + "dng" => Self::Dng, + "nef" | "nrw" => Self::Nef, + "raf" => Self::Raf, + _ => return None, + }) + } + + /// Sniff the format from the file header alone. + /// + /// `None` means "no still image Capsule models starts like this" — a video, an SVG, an XMP + /// sidecar, or noise. A RAW file sniffs to its *container* here ([`Tiff`](Self::Tiff), or + /// `None` for CR3's unrecognised `ftyp` brand); [`detect`](Self::detect) is what refines it. + /// + /// Each signature carries **its own** length requirement rather than sharing one floor: a + /// Netpbm header is eleven bytes and a JPEG's SOI three, so a blanket minimum would report + /// a perfectly well-formed short file as "not an image". + pub fn from_bytes(bytes: &[u8]) -> Option { + let at = |start: usize, needle: &[u8]| -> bool { + bytes + .get(start..start + needle.len()) + .is_some_and(|window| window == needle) + }; + + if at(0, b"GIF87a") || at(0, b"GIF89a") { + return Some(Self::Gif); + } + if at(0, b"\xFF\xD8\xFF") { + return Some(Self::Jpeg); + } + if at(0, b"\x89PNG\r\n\x1a\n") { + return Some(Self::Png); + } + if at(0, b"RIFF") && at(8, b"WEBP") { + return Some(Self::WebP); + } + // Bare JXL codestream, then the ISO-BMFF-wrapped form. + if at(0, b"\xFF\x0A") { + return Some(Self::Jxl); + } + if at(4, b"JXL ") { + return Some(Self::Jxl); + } + if at(0, b"II\x2A\x00") || at(0, b"MM\x00\x2A") { + return Some(Self::Tiff); + } + if at(4, b"ftyp") + && let Some(brand) = bytes.get(8..12) + { + return Self::from_isobmff_brand(brand); + } + // Netpbm: 'P' + a binary version + whitespace. P1-P4 are excluded because the + // `zune-ppm` backend does not decode them, matching `rawshift-image`. + if let Some(&[b'P', version, space]) = bytes.get(..3) + && matches!(version, b'5' | b'6' | b'7' | b'F' | b'f') + && space.is_ascii_whitespace() + { + return Some(Self::Ppm); + } + None + } + + /// The ISO-BMFF `ftyp` brand table. + /// + /// `mif1` is the generic HEIF brand and is claimed in practice by both HEIC and AVIF + /// writers. Capsule reads it as HEIC because the reference library is HEIC end to end; + /// `rawshift-image` reads it as AVIF. The divergence is cosmetic while neither decodes — + /// both produce the same + /// [`UnsupportedFormat`](super::MediaError::UnsupportedFormat) refusal and the same + /// `DeferredNoCodec` status — and it is a log-label difference, never a pixel difference. + fn from_isobmff_brand(brand: &[u8]) -> Option { + Some(match brand { + b"avif" | b"avis" => Self::Avif, + b"heic" | b"heix" | b"heis" | b"hevc" | b"hevx" | b"msf1" | b"mif1" => Self::Heic, + b"crx " => Self::Cr3, + _ => return None, + }) + } + + /// Identify a still: header first, extension only where a header cannot settle it. + /// + /// `ext` is the source file's lowercase extension without the dot (`""` when it has none). + /// The two extension-consulting cases are documented on the module. + pub fn detect(bytes: &[u8], ext: &str) -> Option { + match Self::from_bytes(bytes) { + // A TIFF header is also every TIFF-based RAW's header. Refine on the extension, and + // only ever *into* a RAW family — a `.tif` stays TIFF. + Some(Self::Tiff) => match Self::from_extension(ext) { + Some(raw) if raw.is_raw() => Some(raw), + _ => Some(Self::Tiff), + }, + Some(format) => Some(format), + None => Self::from_extension(ext), + } + } +} + +impl fmt::Display for StillFormat { + /// The format's media type — what a log line and an error message both want. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.mime()) + } +} diff --git a/capsule-core/src/media/error.rs b/capsule-core/src/media/error.rs new file mode 100644 index 00000000..f290501d --- /dev/null +++ b/capsule-core/src/media/error.rs @@ -0,0 +1,123 @@ +//! The typed failure set of the still pipeline (slice `S-B13`). +//! +//! Every one of these is a *report*, never a rejection: Capsule is a backup tool, so an original +//! whose pixels cannot be read is still imported as a signed, encrypted, `verify_asset`-accepting +//! asset. What varies is only whether a placeholder and a thumbnail could be produced beside it. +//! The point of the enum is that the reasons stay apart — a missing codec is a known, deferred +//! gap, while a *supported* format that fails to decode is a defect somebody should look at. + +use thiserror::Error; + +use super::derivative::DerivativeFormat; +use super::detect::StillFormat; + +/// Which direction of a codec a format was needed for. A build can decode a format it cannot +/// encode (every format here except WebP) and the message has to say which half is missing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum FormatOp { + /// Reading pixels out of the format. + Decode, + /// Writing pixels into the format. + Encode, +} + +impl FormatOp { + /// The lowercase word used in log fields and messages. + pub const fn as_str(self) -> &'static str { + match self { + Self::Decode => "decode", + Self::Encode => "encode", + } + } +} + +/// Why the still pipeline could not produce what was asked of it. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum MediaError { + /// The format was identified and this build links no codec for it in that direction — + /// HEIC, AVIF and the RAW families for decode; everything but WebP for encode. The + /// **expected** gap: the format table is honest about it and + /// [`StillFormat::is_decodable`](super::StillFormat::is_decodable) is the same predicate the + /// pipeline gates on, so this is reached only when a caller bypasses that gate. + #[error("this build links no {} codec for {format}", op.as_str())] + UnsupportedFormat { + /// The format that was identified. + format: StillFormat, + /// Which half of the codec was missing. + op: FormatOp, + }, + /// The bytes are not any still image Capsule models — a video, an XMP sidecar, an SVG, or + /// noise. Distinct from [`UnsupportedFormat`](Self::UnsupportedFormat): there is nothing to + /// defer, because there is no still here to decode later either. + #[error("not a still image Capsule models")] + NotAStillImage, + /// A format this build *does* decode did not decode these particular bytes — truncation, + /// corruption, or a decoder bug. `detail` is the underlying decoder's message, flattened to + /// a `String` (rather than held as a `#[source]`) so this type stays `Clone + PartialEq` and + /// so no pre-1.0 dependency type appears in Capsule's public error shape. + #[error("decoding {format} failed: {detail}")] + Decode { + /// The format that was being decoded. + format: StillFormat, + /// The decoder's own message. + detail: String, + }, + /// A derivative encode failed. Same shape and same reasoning as + /// [`Decode`](Self::Decode). + #[error("encoding {format} failed: {detail}")] + Encode { + /// The derivative format that was being written. + format: DerivativeFormat, + /// The encoder's own message. + detail: String, + }, + /// The decoded frame has a zero dimension. Guarded rather than trusted because + /// [`crate::lqip::Lqip::encode`] and the downscale both need a non-empty frame, and a + /// hand-crafted header can claim one. + #[error("decoded frame has a zero dimension ({width}x{height})")] + ZeroDimension { + /// The width the decoder reported. + width: u32, + /// The height the decoder reported. + height: u32, + }, + /// The header claims more pixels than [`MAX_DECODE_PIXELS`](super::MAX_DECODE_PIXELS) + /// allows, and the decode was refused **before** allocating. + /// + /// This is the decode-bomb guard, and it has to be a pre-decode check rather than a + /// post-decode sanity assert: `rawshift-image` decodes to interleaved RGB `u16`, i.e. six + /// bytes per pixel, so a 60000x60000 PNG asks for ~21 GB inside the decoder before Capsule + /// ever sees a buffer. + #[error("{pixels} pixels exceeds the {limit}-pixel decode budget")] + PixelBudgetExceeded { + /// The pixel count the header claims. + pixels: u64, + /// The budget in force. + limit: u64, + }, + /// The decoder's buffer length does not match the dimensions it reported. A `RgbImage` is + /// constructible from mismatched parts (`RgbImage::new` validates nothing), so this is + /// checked at the boundary rather than assumed. + #[error( + "{format} decoder returned {actual} samples for {width}x{height} (expected {expected})" + )] + BufferLengthMismatch { + /// The format that was decoded. + format: StillFormat, + /// The width the decoder reported. + width: u32, + /// The height the decoder reported. + height: u32, + /// The sample count the dimensions imply. + expected: u128, + /// The sample count the decoder actually returned. + actual: u128, + }, + /// A third-party decoder panicked and the unwind was caught at the pipeline boundary. + /// + /// A pre-1.0 decoder fed untrusted bytes is exactly the place a panic is plausible, and an + /// import must never abort over a thumbnail. Reported as a decode failure rather than + /// swallowed, because a panic is a defect worth seeing. + #[error("the decoder panicked")] + DecoderPanic, +} diff --git a/capsule-core/src/media/mod.rs b/capsule-core/src/media/mod.rs new file mode 100644 index 00000000..23882e6a --- /dev/null +++ b/capsule-core/src/media/mod.rs @@ -0,0 +1,51 @@ +//! Still-image decode, orientation, metadata normalisation and derivative generation — the +//! Capsule-side owner of the media pipeline (slices `S-B1`, `S-B13`). +//! +//! SSoT: [Thumbnails and Previews](https://docs/design/thumbnails/). +//! +//! # Boundaries +//! +//! Rawshift owns codecs; this module owns everything Capsule decides. Concretely, +//! [`rawshift-image`] performs format sniffing, pixel decode, and the byte encode, while this +//! module owns: +//! +//! - **the closed format sets** — [`StillFormat`] (what Capsule models as a still) and +//! [`DerivativeFormat`] (what a signed `DerivativeManifest.format` may say); +//! - **the pixel budget and the panic guard**, because a third-party pre-1.0 decoder is fed +//! untrusted bytes on the import path; +//! - **tier sizing and the downscale**, because `rawshift-image` has no resize and because a +//! derivative's bytes are signed, so the resample must be deterministic; +//! - **the metadata strip**, because the crate's own default embeds EXIF (GPS included) into +//! every encode. +//! +//! LQIP is *not* here: it lives in the unconditional [`crate::lqip`] module so the import +//! pipeline, the uniffi FFI and `capsule-wasm` share one implementation (slice `S-B14`). This +//! module produces the pixels [`crate::lqip::Lqip::encode`] consumes. +//! +//! # What this build can and cannot do +//! +//! Every gap is a typed [`MediaError::UnsupportedFormat`] or a recorded per-format deferral, +//! never a silent absence and never a panic (slice `S-B13`). Decode covers JPEG, PNG, JXL, +//! TIFF, GIF and WebP; encode covers WebP alone. HEIC, AVIF and the RAW families sniff +//! correctly and refuse to decode, because their backends need system libraries (libheif, +//! libdav1d) or an assembler (nasm) that the cross and cargo-ndk builds do not have. +//! +//! [`rawshift-image`]: https://docs.rs/rawshift-image + +mod decode; +mod derivative; +mod detect; +mod error; +mod resize; + +pub use self::decode::{DecodedImage, Decoder, MediaMetadata, RawshiftDecoder, decode_guarded}; +pub use self::derivative::{ + DerivativeContext, DerivativeFormat, DerivativeTier, GeneratedDerivative, StillDerivatives, + generate_still_derivatives, verify_still_format, +}; +pub use self::detect::{MAX_DECODE_PIXELS, SUPPORTED_STILL_FORMATS, StillFormat}; +pub use self::error::{FormatOp, MediaError}; +pub use self::resize::{capped_dimensions, downscale_rgba8}; + +#[cfg(test)] +mod tests; diff --git a/capsule-core/src/media/resize.rs b/capsule-core/src/media/resize.rs new file mode 100644 index 00000000..b12d796f --- /dev/null +++ b/capsule-core/src/media/resize.rs @@ -0,0 +1,107 @@ +//! Capsule-owned downscale for the derivative tiers. +//! +//! `rawshift-image` has no resize — it offers crop, flips, rotations, blur and lens correction, +//! and nothing that changes the sample grid — so tier sizing is Capsule's, not the codec's. +//! +//! # Why integer area-averaging, specifically +//! +//! A derivative's bytes are content-addressed by a **signed** `DerivativeManifest`, so two runs +//! over the same source must produce the same bytes. That rules out floating-point accumulation +//! whose order or width could differ between builds and targets, and it rules out any resampler +//! with platform-tuned SIMD paths that are not required to be bit-identical. What is left is a +//! box filter accumulated in `u32` and divided by an exact sample count: deterministic on every +//! target, and the right filter for a large downscale anyway (a box average over the full source +//! rect is alias-free, where a bilinear tap would ignore most of the source pixels). +//! +//! Upscaling is not a thing this performs: a tier only ever caps a long edge, and a source +//! already inside the cap takes the `format = "original"` sentinel path instead +//! ([`DerivativeTier`](super::DerivativeTier)). + +use crate::lqip::RgbaImage; + +/// The dimensions a `width` x `height` frame takes when its long edge is capped at +/// `max_long_edge`, preserving aspect ratio and never returning a zero dimension. +/// +/// Returns the input unchanged when it already fits, so a caller can compare and skip. +pub fn capped_dimensions(width: u32, height: u32, max_long_edge: u32) -> (u32, u32) { + let cap = max_long_edge.max(1); + let long_edge = width.max(height); + if long_edge <= cap { + return (width, height); + } + // Rounded rather than truncated so a 3:2 frame keeps its ratio as closely as an integer + // grid allows; `.max(1)` because a very lopsided frame (e.g. 8000x3) would otherwise round + // its short edge to zero and produce an empty buffer. + let scale = |edge: u32| -> u32 { + let numerator = u64::from(edge) * u64::from(cap); + let denominator = u64::from(long_edge); + (((numerator + denominator / 2) / denominator) as u32).max(1) + }; + (scale(width), scale(height)) +} + +/// Downscale packed RGBA8 so its long edge is at most `max_long_edge`. +/// +/// A frame already within the cap is returned unchanged (cloned), which is what makes this safe +/// to call unconditionally. Deterministic: identical input yields byte-identical output on every +/// target. +pub fn downscale_rgba8(source: &RgbaImage, max_long_edge: u32) -> RgbaImage { + let (dst_w, dst_h) = capped_dimensions(source.width, source.height, max_long_edge); + if (dst_w, dst_h) == (source.width, source.height) { + return source.clone(); + } + + let (src_w, src_h) = (source.width as usize, source.height as usize); + if source.rgba.len() != src_w * src_h * 4 { + // Defensive: this is a `pub` entry point and the very next thing it does is index the + // buffer by those dimensions. Every in-tree caller passes a `DecodedImage`, whose + // invariant this is, so a mismatch is a bug in a *new* caller — reported and returned + // unchanged rather than turned into a panic inside an import. + tracing::error!( + width = source.width, + height = source.height, + len = source.rgba.len(), + "media: downscale refused a buffer that does not match its dimensions" + ); + return source.clone(); + } + let (dw, dh) = (dst_w as usize, dst_h as usize); + let mut out = Vec::with_capacity(dw * dh * 4); + + for y in 0..dh { + // The source rows this destination row averages. Floor boundaries, so the destination + // grid is an exact partition of the source grid — every source pixel contributes to + // exactly one output pixel. Widened to at least one row because a lopsided cap can put + // two destination rows inside one source row, and an empty rect would divide by zero. + let y0 = y * src_h / dh; + let y1 = ((y + 1) * src_h / dh).max(y0 + 1).min(src_h); + for x in 0..dw { + let x0 = x * src_w / dw; + let x1 = ((x + 1) * src_w / dw).max(x0 + 1).min(src_w); + + let mut acc = [0u32; 4]; + let count = ((y1 - y0) * (x1 - x0)) as u32; + for sy in y0..y1 { + let row = sy * src_w * 4; + for sx in x0..x1 { + let i = row + sx * 4; + acc[0] += u32::from(source.rgba[i]); + acc[1] += u32::from(source.rgba[i + 1]); + acc[2] += u32::from(source.rgba[i + 2]); + acc[3] += u32::from(source.rgba[i + 3]); + } + } + // Round-half-up on the mean, so a uniform region reproduces its own value exactly + // rather than drifting down by up to one level per reduction. + for channel in acc { + out.push(((channel + count / 2) / count) as u8); + } + } + } + + RgbaImage { + width: dst_w, + height: dst_h, + rgba: out, + } +} diff --git a/capsule-core/src/media/tests.rs b/capsule-core/src/media/tests.rs new file mode 100644 index 00000000..14520bae --- /dev/null +++ b/capsule-core/src/media/tests.rs @@ -0,0 +1,1312 @@ +//! Unit coverage for the still pipeline (slices `S-B1`, `S-B13`). +//! +//! # Fixtures are built, never committed +//! +//! The repository carries no binary image fixtures and this suite adds none. Three kinds of +//! input appear below, and the mix is deliberate: +//! +//! 1. **Procedural frames** ([`quadrants`], [`gradient`]) encoded in-test by +//! `rawshift-image`'s own JPEG/PNG/WebP encoders. Self-consistent by construction, which is +//! exactly what makes them right for the *orientation* and *EXIF* cases: the crate writes the +//! EXIF block and Capsule reads it back, so the fixture cannot drift from the parser. +//! 2. **A hand-built PNG** ([`hand_written_png`]) — IHDR/IDAT/IEND assembled byte by byte with +//! a local CRC-32 and an uncompressed-deflate zlib stream, so at least one decode case is +//! fed an input no part of the code under test produced. Without it the suite could pass +//! against an encoder and decoder that agree with each other and with nothing else. +//! 3. **Bare magic-byte headers** for the formats this build cannot decode. There is nothing to +//! decode there and nothing to fake: the assertion is that they are *recognised* and refused +//! with a typed error. + +use rawshift_image::core::metadata::{ImageInfo, ImageMetadata, URational}; +use rawshift_image::core::{BitDepth, MetadataEmbedOptions}; +use rawshift_image::formats::encode_rgb_image_to_vec; +use rawshift_image::formats::export::{ + CommonEncodeOptions, EncodeOptions, JpegEncEncodeConfig, LibwebpEncodeConfig, WebPMode, + ZunePngEncodeConfig, +}; +use uuid::Uuid; + +use super::decode::{Decoder, RawshiftDecoder, decode_guarded}; +use super::derivative::{ + DerivativeContext, DerivativeFormat, DerivativeTier, StillDerivatives, + generate_still_derivatives, verify_still_format, +}; +use super::detect::{MAX_DECODE_PIXELS, SUPPORTED_STILL_FORMATS, StillFormat}; +use super::error::{FormatOp, MediaError}; +use super::resize::{capped_dimensions, downscale_rgba8}; +use crate::crypto::keys::{AmkVersion, HybridSigningKey}; +use crate::crypto::primitives::{CRYPTO_SUITE_ID, PROTOCOL_VERSION}; +use crate::crypto::provenance::manifest::{DERIVATIVE_MANIFEST_VERSION, DerivativeCore}; +use crate::crypto::provenance::{DerivativeManifest, DerivativeRole}; +use crate::lqip::{Gamut, Lqip, RgbaImage}; + +// ── Procedural fixtures ────────────────────────────────────────────────────── + +/// A frame of four flat quadrants at distinct luminances — TL 0, TR 85, BL 170, BR 255. +/// +/// Flat regions rather than a gradient because these fixtures go through a *lossy* JPEG: +/// sampling the middle of a flat quadrant is stable to within a couple of levels at q=90, while +/// a gradient's corner is not. Four distinct values make all eight EXIF orientations +/// distinguishable from one another. +fn quadrants(width: u32, height: u32) -> RgbaImage { + let (w, h) = (width as usize, height as usize); + let mut rgba = Vec::with_capacity(w * h * 4); + for y in 0..h { + for x in 0..w { + let v = match (x < w / 2, y < h / 2) { + (true, true) => 0, + (false, true) => 85, + (true, false) => 170, + (false, false) => 255, + }; + rgba.extend_from_slice(&[v, v, v, 255]); + } + } + RgbaImage { + width, + height, + rgba, + } +} + +/// A deterministic RGB gradient — the general-purpose frame for size and encode cases. +fn gradient(width: u32, height: u32) -> RgbaImage { + let (w, h) = (width as usize, height as usize); + let mut rgba = Vec::with_capacity(w * h * 4); + for y in 0..h { + for x in 0..w { + rgba.extend_from_slice(&[ + (x * 255 / w.max(1)) as u8, + (y * 255 / h.max(1)) as u8, + ((x + y) * 255 / (w + h).max(1)) as u8, + 255, + ]); + } + } + RgbaImage { + width, + height, + rgba, + } +} + +/// Mean of each quadrant's inner half, as `(TL, TR, BL, BR)`. +/// +/// The inner half avoids the quadrant boundaries, where JPEG's 8x8 blocks and chroma +/// subsampling smear one region into the next. +fn quadrant_means(image: &RgbaImage) -> (u32, u32, u32, u32) { + let (w, h) = (image.width as usize, image.height as usize); + let mean = |xs: std::ops::Range, ys: std::ops::Range| -> u32 { + let mut sum = 0u64; + let mut n = 0u64; + for y in ys.clone() { + for x in xs.clone() { + sum += u64::from(image.rgba[(y * w + x) * 4]); + n += 1; + } + } + (sum / n.max(1)) as u32 + }; + let (qw, qh) = (w / 2, h / 2); + let (ix, iy) = (qw / 4, qh / 4); + ( + mean(ix..qw - ix, iy..qh - iy), + mean(qw + ix..w - ix, iy..qh - iy), + mean(ix..qw - ix, qh + iy..h - iy), + mean(qw + ix..w - ix, qh + iy..h - iy), + ) +} + +/// Widen packed RGBA8 into the interleaved RGB `u16` the encoders take. +fn to_rgb_u16(frame: &RgbaImage) -> rawshift_image::core::image::RgbImage { + let mut data = Vec::with_capacity(frame.rgba.len() / 4 * 3); + for px in frame.rgba.chunks_exact(4) { + data.push(u16::from(px[0]) * 257); + data.push(u16::from(px[1]) * 257); + data.push(u16::from(px[2]) * 257); + } + rawshift_image::core::image::RgbImage::with_color_space( + frame.width, + frame.height, + data, + rawshift_image::core::ColorSpace::Srgb, + ) +} + +/// A `CommonEncodeOptions` embedding whatever `metadata` asks for, at 8 bits. +fn common(metadata: MetadataEmbedOptions) -> CommonEncodeOptions { + CommonEncodeOptions { + metadata, + bit_depth: BitDepth::Eight, + } +} + +/// Encode a frame as a baseline JPEG, optionally embedding `metadata`. +/// +/// Quality 90 rather than the tier's 50: this is a *source* fixture, and a decode test should +/// not have to absorb the tier's own quality budget as well. +fn jpeg_bytes(frame: &RgbaImage, metadata: Option<&ImageMetadata>) -> Vec { + let embed = if metadata.is_some() { + MetadataEmbedOptions { + embed_exif: true, + embed_icc: false, + embed_xmp: false, + } + } else { + MetadataEmbedOptions::none() + }; + let options = EncodeOptions::JpegJpegEnc(JpegEncEncodeConfig { + common: common(embed), + quality: 90, + }); + let empty = ImageMetadata::default(); + encode_rgb_image_to_vec(&to_rgb_u16(frame), metadata.unwrap_or(&empty), &options) + .expect("the fixture JPEG encodes") +} + +/// Encode a frame as a PNG with no metadata at all. +fn png_bytes(frame: &RgbaImage) -> Vec { + let options = EncodeOptions::PngZune(ZunePngEncodeConfig { + common: common(MetadataEmbedOptions::none()), + ..ZunePngEncodeConfig::default() + }); + encode_rgb_image_to_vec(&to_rgb_u16(frame), &ImageMetadata::default(), &options) + .expect("the fixture PNG encodes") +} + +/// Encode a frame as a lossless WebP with no metadata. +fn webp_bytes(frame: &RgbaImage) -> Vec { + let options = EncodeOptions::WebpLibwebp(LibwebpEncodeConfig { + common: common(MetadataEmbedOptions::none()), + mode: WebPMode::Lossless, + quality: 100.0, + method: 4, + near_lossless: 100, + }); + encode_rgb_image_to_vec(&to_rgb_u16(frame), &ImageMetadata::default(), &options) + .expect("the fixture WebP encodes") +} + +/// An `ImageMetadata` carrying only an EXIF orientation tag. +fn oriented(orientation: u16) -> ImageMetadata { + ImageMetadata { + image: ImageInfo { + orientation: Some(orientation), + ..ImageInfo::default() + }, + ..ImageMetadata::default() + } +} + +/// The GPS degrees/minutes/seconds triple used by the privacy cases — a distinctive fix whose +/// rationals are searchable as raw bytes. +const GPS_LAT_DMS: [u32; 3] = [51, 30, 26]; +const GPS_LON_DMS: [u32; 3] = [0, 7, 39]; + +/// An `ImageMetadata` carrying a GPS fix — the metadata a thumbnail must never inherit. +fn located() -> ImageMetadata { + let dms = |v: [u32; 3]| { + v.map(|n| URational { + numerator: n, + denominator: 1, + }) + }; + let mut metadata = ImageMetadata::default(); + metadata.gps.latitude = Some(dms(GPS_LAT_DMS)); + metadata.gps.latitude_ref = Some('N'); + metadata.gps.longitude = Some(dms(GPS_LON_DMS)); + metadata.gps.longitude_ref = Some('W'); + metadata +} + +// ── The independently-constructed PNG ──────────────────────────────────────── + +/// CRC-32 (IEEE 802.3), computed here so the PNG fixture owes nothing to a dependency. +fn crc32(bytes: &[u8]) -> u32 { + let mut crc = 0xFFFF_FFFFu32; + for &byte in bytes { + crc ^= u32::from(byte); + for _ in 0..8 { + crc = if crc & 1 == 1 { + (crc >> 1) ^ 0xEDB8_8320 + } else { + crc >> 1 + }; + } + } + !crc +} + +/// Adler-32, the zlib stream checksum. +fn adler32(bytes: &[u8]) -> u32 { + let mut a = 1u32; + let mut b = 0u32; + for &byte in bytes { + a = (a + u32::from(byte)) % 65_521; + b = (b + a) % 65_521; + } + (b << 16) | a +} + +/// One PNG chunk: length, type, payload, CRC over type+payload. +fn png_chunk(kind: &[u8; 4], payload: &[u8]) -> Vec { + let mut chunk = Vec::with_capacity(payload.len() + 12); + chunk.extend_from_slice(&(payload.len() as u32).to_be_bytes()); + chunk.extend_from_slice(kind); + chunk.extend_from_slice(payload); + let mut crc_input = kind.to_vec(); + crc_input.extend_from_slice(payload); + chunk.extend_from_slice(&crc32(&crc_input).to_be_bytes()); + chunk +} + +/// A 2x2 8-bit RGB PNG built byte by byte: signature, IHDR, a stored-deflate IDAT, IEND. +/// +/// `deflate` here is a single final *stored* block (BTYPE 00), so no compressor is involved — +/// the point of this fixture is that nothing under test, and no encoder it shares code with, +/// produced it. +fn hand_written_png() -> Vec { + // Four pixels: red, green, blue, white — each row prefixed by filter type 0 (None). + let raw: Vec = vec![ + 0, 255, 0, 0, 0, 255, 0, // row 0: filter, red, green + 0, 0, 0, 255, 255, 255, 255, // row 1: filter, blue, white + ]; + + let mut zlib = vec![0x78, 0x01]; // CM=8, CINFO=7, FLEVEL=0, FCHECK making it a multiple of 31 + zlib.push(0x01); // final stored block + zlib.extend_from_slice(&(raw.len() as u16).to_le_bytes()); + zlib.extend_from_slice(&(!(raw.len() as u16)).to_le_bytes()); + zlib.extend_from_slice(&raw); + zlib.extend_from_slice(&adler32(&raw).to_be_bytes()); + + let mut ihdr = Vec::new(); + ihdr.extend_from_slice(&2u32.to_be_bytes()); // width + ihdr.extend_from_slice(&2u32.to_be_bytes()); // height + ihdr.extend_from_slice(&[8, 2, 0, 0, 0]); // 8-bit, colour type 2 (RGB), deflate, no filter/interlace + + let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); + png.extend(png_chunk(b"IHDR", &ihdr)); + png.extend(png_chunk(b"IDAT", &zlib)); + png.extend(png_chunk(b"IEND", &[])); + png +} + +// ── Detection ──────────────────────────────────────────────────────────────── + +/// A 12-byte header for each format Capsule recognises but cannot decode. Twelve bytes is +/// exactly what [`StillFormat::from_bytes`] requires, so these also pin the minimum length. +fn isobmff(brand: &[u8; 4]) -> Vec { + let mut bytes = vec![0, 0, 0, 0x20]; + bytes.extend_from_slice(b"ftyp"); + bytes.extend_from_slice(brand); + bytes +} + +/// Real encodes on one side, bare headers on the other: every variant of the closed set is +/// reachable from bytes, and the sniff names the right one. +#[test] +fn every_still_format_is_reachable_from_its_header() { + let frame = gradient(8, 8); + let cases: &[(Vec, &str, StillFormat)] = &[ + (jpeg_bytes(&frame, None), "jpg", StillFormat::Jpeg), + (png_bytes(&frame), "png", StillFormat::Png), + (webp_bytes(&frame), "webp", StillFormat::WebP), + (hand_written_png(), "png", StillFormat::Png), + ( + b"GIF89a\x08\x00\x08\x00\x00\x00".to_vec(), + "gif", + StillFormat::Gif, + ), + ( + b"\xFF\x0A\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00".to_vec(), + "jxl", + StillFormat::Jxl, + ), + ( + b"II\x2A\x00\x08\x00\x00\x00\x00\x00\x00\x00".to_vec(), + "tif", + StillFormat::Tiff, + ), + ( + b"MM\x00\x2A\x00\x00\x00\x08\x00\x00\x00\x00".to_vec(), + "tiff", + StillFormat::Tiff, + ), + (b"P6\n8 8\n255\n".to_vec(), "ppm", StillFormat::Ppm), + (isobmff(b"avif"), "avif", StillFormat::Avif), + (isobmff(b"heic"), "heic", StillFormat::Heic), + (isobmff(b"mif1"), "heic", StillFormat::Heic), + (isobmff(b"crx "), "cr3", StillFormat::Cr3), + // TIFF-container RAW: the header says TIFF and only the extension names the family. + ( + b"II\x2A\x00\x08\x00\x00\x00\x00\x00\x00\x00".to_vec(), + "arw", + StillFormat::Arw, + ), + ( + b"II\x2A\x00\x08\x00\x00\x00\x00\x00\x00\x00".to_vec(), + "cr2", + StillFormat::Cr2, + ), + ( + b"II\x2A\x00\x08\x00\x00\x00\x00\x00\x00\x00".to_vec(), + "dng", + StillFormat::Dng, + ), + ( + b"MM\x00\x2A\x00\x00\x00\x08\x00\x00\x00\x00".to_vec(), + "nef", + StillFormat::Nef, + ), + ]; + for (bytes, ext, expected) in cases { + assert_eq!( + StillFormat::detect(bytes, ext), + Some(*expected), + "detecting a .{ext} fixture" + ); + } + + // CRW and RAF have no in-tree header fixture; they are extension-only entries, and the + // point of asserting them is that the fallback table covers the whole RAW set. + assert_eq!(StillFormat::detect(b"", "crw"), Some(StillFormat::Crw)); + assert_eq!(StillFormat::detect(b"", "raf"), Some(StillFormat::Raf)); +} + +/// Bytes win over the extension. A HEIC named `.jpg` must not be handed to the JPEG decoder — +/// that is the difference between a typed "no codec for HEIC" deferral and a decode failure +/// blamed on JPEG. +#[test] +fn the_header_beats_a_lying_extension() { + assert_eq!( + StillFormat::detect(&isobmff(b"heic"), "jpg"), + Some(StillFormat::Heic) + ); + let png = png_bytes(&gradient(4, 4)); + assert_eq!(StillFormat::detect(&png, "jpeg"), Some(StillFormat::Png)); + + // The one refinement that runs the other way is *into* a RAW family, and only from a TIFF + // header — a real `.tif` stays TIFF. + let tiff_header = b"II\x2A\x00\x08\x00\x00\x00\x00\x00\x00\x00"; + assert_eq!( + StillFormat::detect(tiff_header, "tif"), + Some(StillFormat::Tiff) + ); +} + +/// Nothing recognisable, and never a panic. `detect` is the first thing untrusted bytes touch. +#[test] +fn unrecognisable_bytes_are_not_a_still() { + let cases: &[&[u8]] = &[ + b"", + b"\x00", + b"\xFF\xD8", // a JPEG SOI truncated below the 12-byte floor + b"noise-x!", // 8 bytes, under the floor + b"not an image at all, really", // long enough, no signature + b"\x00\x00\x00\x20ftypqt ", // ISO-BMFF with an unmodelled brand (QuickTime) + b"", + b"\x1AE\xDF\xA3\x01\x00\x00\x00\x00\x00\x00\x23", // Matroska/WebM + ]; + for bytes in cases { + assert_eq!( + StillFormat::detect(bytes, ""), + None, + "these bytes are not a still Capsule models: {:?}", + &bytes[..bytes.len().min(12)] + ); + } +} + +/// The Capsule table and `rawshift-image`'s own `detect_standard_format` must not drift where +/// both define an answer. +/// +/// Scoped to the formats whose signature is unconditional on both sides. The ISO-BMFF brands are +/// excluded on purpose: the crate's HEIC arm is feature-gated (so it cannot recognise HEIC in +/// this build — the reason Capsule sniffs at all), and it reads the generic `mif1` brand as AVIF +/// where Capsule reads it as HEIC. Neither decodes here, so that divergence is a log label, not +/// a pixel. +#[test] +fn still_format_agrees_with_rawshift_detection() { + use rawshift_image::formats::{StandardFormat, detect_standard_format}; + + let frame = gradient(8, 8); + let cases: &[(Vec, StandardFormat, StillFormat)] = &[ + ( + jpeg_bytes(&frame, None), + StandardFormat::Jpeg, + StillFormat::Jpeg, + ), + (png_bytes(&frame), StandardFormat::Png, StillFormat::Png), + (webp_bytes(&frame), StandardFormat::WebP, StillFormat::WebP), + (hand_written_png(), StandardFormat::Png, StillFormat::Png), + ( + b"GIF89a\x08\x00\x08\x00\x00\x00".to_vec(), + StandardFormat::Gif, + StillFormat::Gif, + ), + ( + b"\xFF\x0A\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00".to_vec(), + StandardFormat::Jxl, + StillFormat::Jxl, + ), + ( + b"II\x2A\x00\x08\x00\x00\x00\x00\x00\x00\x00".to_vec(), + StandardFormat::Tiff, + StillFormat::Tiff, + ), + ( + b"P6\n8 8\n255\n".to_vec(), + StandardFormat::Ppm, + StillFormat::Ppm, + ), + // A JPEG SOI is three bytes and a Netpbm header eleven, so both sides recognise a file + // far shorter than a blanket minimum-length floor would admit. + (isobmff(b"avif"), StandardFormat::Avif, StillFormat::Avif), + ]; + for (bytes, theirs, ours) in cases { + assert_eq!(detect_standard_format(bytes), Some(*theirs)); + assert_eq!(StillFormat::from_bytes(bytes), Some(*ours)); + } +} + +/// The coverage table is the single answer to "can this build read these pixels?", and it has +/// to agree with what the decoder actually does. +#[test] +fn is_decodable_matches_the_supported_table() { + for format in SUPPORTED_STILL_FORMATS { + assert!(format.is_decodable(), "{format} is in the supported table"); + assert!(!format.is_raw(), "no RAW family decodes in this build"); + } + for format in [ + StillFormat::Ppm, + StillFormat::Avif, + StillFormat::Heic, + StillFormat::Arw, + StillFormat::Cr2, + StillFormat::Cr3, + StillFormat::Crw, + StillFormat::Dng, + StillFormat::Nef, + StillFormat::Raf, + ] { + assert!(!format.is_decodable(), "{format} has no decoder here"); + } + // Every mime is distinct, so a `content_type` cannot silently collide. + let mut mimes: Vec<&str> = SUPPORTED_STILL_FORMATS.iter().map(|f| f.mime()).collect(); + mimes.sort_unstable(); + let count = mimes.len(); + mimes.dedup(); + assert_eq!(mimes.len(), count, "each format has its own media type"); +} + +// ── Decode ─────────────────────────────────────────────────────────────────── + +/// Every decodable format round-trips to the dimensions it was built with, with a full RGBA8 +/// buffer and uniform opaque alpha. +#[test] +fn decodes_every_supported_container_to_opaque_rgba8() { + let frame = gradient(6, 4); + let cases: &[(Vec, &str, StillFormat, u32, u32)] = &[ + (jpeg_bytes(&frame, None), "jpg", StillFormat::Jpeg, 6, 4), + (png_bytes(&frame), "png", StillFormat::Png, 6, 4), + (webp_bytes(&frame), "webp", StillFormat::WebP, 6, 4), + (hand_written_png(), "png", StillFormat::Png, 2, 2), + ]; + for (bytes, ext, format, width, height) in cases { + let decoded = RawshiftDecoder + .decode(bytes, ext) + .unwrap_or_else(|e| panic!("decoding the .{ext} fixture: {e}")); + assert_eq!(decoded.format, *format); + assert_eq!((decoded.width(), decoded.height()), (*width, *height)); + assert_eq!( + decoded.image.rgba.len() as u32, + width * height * 4, + "the buffer is exactly w*h*4" + ); + assert!( + decoded.image.rgba.chunks_exact(4).all(|px| px[3] == 255), + "every decoded frame is opaque" + ); + assert_eq!(decoded.orientation_applied, 1, "no tag, no transform"); + } +} + +/// The hand-built PNG's actual pixels, checked against the bytes that were written into it. +/// +/// This is the one place the suite is not self-consistent: the input owes nothing to the encoder +/// the other cases use, so agreement here is evidence about the decoder rather than about a +/// matched pair. +#[test] +fn the_hand_written_png_decodes_to_the_pixels_it_declares() { + let decoded = RawshiftDecoder + .decode(&hand_written_png(), "png") + .expect("a hand-built 2x2 RGB PNG decodes"); + assert_eq!((decoded.width(), decoded.height()), (2, 2)); + assert_eq!( + decoded.image.rgba, + vec![ + 255, 0, 0, 255, // red + 0, 255, 0, 255, // green + 0, 0, 255, 255, // blue + 255, 255, 255, 255, // white + ] + ); +} + +/// PNG alpha is **flattened**, not preserved — `rawshift-image` decodes to RGB with no alpha +/// channel. Asserted so the loss cannot regress into an "alpha survives" assumption somewhere +/// downstream. +#[test] +fn png_alpha_is_flattened_to_opaque() { + // A 1x1 fully transparent RGBA PNG, hand-built for the same reason as the fixture above. + let raw = vec![0u8, 0, 0, 0, 0]; // filter 0 + RGBA(0,0,0,0) + let mut zlib = vec![0x78, 0x01, 0x01]; + zlib.extend_from_slice(&(raw.len() as u16).to_le_bytes()); + zlib.extend_from_slice(&(!(raw.len() as u16)).to_le_bytes()); + zlib.extend_from_slice(&raw); + zlib.extend_from_slice(&adler32(&raw).to_be_bytes()); + let mut ihdr = Vec::new(); + ihdr.extend_from_slice(&1u32.to_be_bytes()); + ihdr.extend_from_slice(&1u32.to_be_bytes()); + ihdr.extend_from_slice(&[8, 6, 0, 0, 0]); // colour type 6 = RGBA + let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); + png.extend(png_chunk(b"IHDR", &ihdr)); + png.extend(png_chunk(b"IDAT", &zlib)); + png.extend(png_chunk(b"IEND", &[])); + + let decoded = RawshiftDecoder + .decode(&png, "png") + .expect("RGBA PNG decodes"); + assert_eq!(decoded.image.rgba, vec![0, 0, 0, 255], "alpha is dropped"); +} + +/// A recognised format with no codec refuses **before** any decoder runs, and says which half +/// of the codec is missing. This is the S-B13 contract at the seam. +#[test] +fn a_format_with_no_codec_refuses_with_a_typed_error() { + let cases: &[(Vec, &str, StillFormat)] = &[ + (isobmff(b"heic"), "heic", StillFormat::Heic), + (isobmff(b"avif"), "avif", StillFormat::Avif), + (isobmff(b"crx "), "cr3", StillFormat::Cr3), + ( + b"II\x2A\x00\x08\x00\x00\x00\x00\x00\x00\x00".to_vec(), + "arw", + StillFormat::Arw, + ), + (b"".to_vec(), "raf", StillFormat::Raf), + (b"P6\n8 8\n255\n".to_vec(), "ppm", StillFormat::Ppm), + ]; + for (bytes, ext, format) in cases { + assert_eq!( + RawshiftDecoder.decode(bytes, ext), + Err(MediaError::UnsupportedFormat { + format: *format, + op: FormatOp::Decode, + }), + "a .{ext} must defer rather than fail" + ); + assert_eq!( + RawshiftDecoder.probe(bytes, ext), + Err(MediaError::UnsupportedFormat { + format: *format, + op: FormatOp::Decode, + }), + ); + } +} + +/// Bytes that are no still at all are a distinct outcome from a still with no codec: there is +/// nothing to backfill later. +#[test] +fn non_still_bytes_are_not_a_deferral() { + assert_eq!( + RawshiftDecoder.decode(b"\x1AE\xDF\xA3 a webm, not a photo", "webm"), + Err(MediaError::NotAStillImage) + ); +} + +/// A supported format whose bytes are broken is a **decode failure**, not a deferral — the +/// distinction the run summary reports and the one worth investigating. +#[test] +fn corrupt_bytes_of_a_supported_format_are_a_decode_failure() { + let mut jpeg = jpeg_bytes(&gradient(16, 16), None); + jpeg.truncate(jpeg.len() / 2); + match RawshiftDecoder.decode(&jpeg, "jpg") { + Err(MediaError::Decode { format, .. }) => assert_eq!(format, StillFormat::Jpeg), + other => panic!("a truncated JPEG must be a decode failure, got {other:?}"), + } + + // Not even a header: the extension is the only evidence, and it says a format we decode. + match RawshiftDecoder.decode(b"this is definitely not a jpeg", "jpeg") { + Err(MediaError::Decode { format, .. }) => assert_eq!(format, StillFormat::Jpeg), + other => panic!("garbage under a .jpeg name must be a decode failure, got {other:?}"), + } +} + +/// The decode-bomb guard: a header claiming more than the budget is refused before the decoder +/// allocates. Built as a PNG header alone, because the *point* is that no pixel data is needed +/// to trigger it — a 33-byte file must not be able to ask for 21 GB. +#[test] +fn an_oversized_header_is_refused_before_decoding() { + let mut ihdr = Vec::new(); + ihdr.extend_from_slice(&30_000u32.to_be_bytes()); + ihdr.extend_from_slice(&30_000u32.to_be_bytes()); + ihdr.extend_from_slice(&[8, 2, 0, 0, 0]); + let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); + png.extend(png_chunk(b"IHDR", &ihdr)); + + assert_eq!( + RawshiftDecoder.probe(&png, "png"), + Err(MediaError::PixelBudgetExceeded { + pixels: 900_000_000, + limit: MAX_DECODE_PIXELS, + }), + ); + assert_eq!( + RawshiftDecoder.decode(&png, "png"), + Err(MediaError::PixelBudgetExceeded { + pixels: 900_000_000, + limit: MAX_DECODE_PIXELS, + }), + "decode probes first, so the guard covers it too" + ); + // The budget is a ceiling on the frame, not on the file: a small image is unaffected. + assert!( + RawshiftDecoder + .probe(&png_bytes(&gradient(4, 4)), "png") + .is_ok() + ); +} + +/// A probe reports the header's stored dimensions and the EXIF orientation without decoding, +/// and knows which pairs are transposed on display. +#[test] +fn a_probe_reports_stored_dimensions_and_the_upright_pair() { + let frame = gradient(12, 6); + let upright = RawshiftDecoder + .probe(&jpeg_bytes(&frame, Some(&oriented(1))), "jpg") + .expect("probe"); + assert_eq!(upright.stored_dimensions, (12, 6)); + assert_eq!(upright.upright_dimensions(), (12, 6)); + assert_eq!(upright.orientation, Some(1)); + assert_eq!(upright.gamut, Gamut::Srgb); + + let rotated = RawshiftDecoder + .probe(&jpeg_bytes(&frame, Some(&oriented(6))), "jpg") + .expect("probe"); + assert_eq!(rotated.stored_dimensions, (12, 6)); + assert_eq!( + rotated.upright_dimensions(), + (6, 12), + "a quarter-turn transposes what a viewer shows" + ); +} + +/// All eight EXIF orientations, as a permutation of four marked quadrants plus the dimension +/// pair. This is the table an upright frame depends on, and it is hand-derived from the EXIF +/// definitions rather than from the transform code. +#[test] +fn every_exif_orientation_lands_upright() { + // (orientation, transposed?, (TL, TR, BL, BR) after the transform) + let table: &[(u16, bool, (u32, u32, u32, u32))] = &[ + (1, false, (0, 85, 170, 255)), // identity + (2, false, (85, 0, 255, 170)), // mirror horizontal + (3, false, (255, 170, 85, 0)), // rotate 180 + (4, false, (170, 255, 0, 85)), // mirror vertical + (5, true, (0, 170, 85, 255)), // transpose + (6, true, (170, 0, 255, 85)), // rotate 90 CW + (7, true, (255, 85, 170, 0)), // transverse + (8, true, (85, 255, 0, 170)), // rotate 90 CCW + ]; + let (width, height) = (64u32, 32u32); + let frame = quadrants(width, height); + + for &(orientation, transposed, expected) in table { + let bytes = jpeg_bytes(&frame, Some(&oriented(orientation))); + let decoded = RawshiftDecoder + .decode(&bytes, "jpg") + .unwrap_or_else(|e| panic!("orientation {orientation}: {e}")); + + assert_eq!( + decoded.orientation_applied, orientation, + "the consumed tag is recorded so a renderer does not rotate again" + ); + let expected_dims = if transposed { + (height, width) + } else { + (width, height) + }; + assert_eq!( + (decoded.width(), decoded.height()), + expected_dims, + "orientation {orientation} dimensions" + ); + + let got = quadrant_means(&decoded.image); + let close = |a: u32, b: u32| a.abs_diff(b) <= 20; + assert!( + close(got.0, expected.0) + && close(got.1, expected.1) + && close(got.2, expected.2) + && close(got.3, expected.3), + "orientation {orientation}: quadrants {got:?} are not {expected:?}" + ); + } +} + +/// An orientation value outside 1..=8 is dropped rather than recorded. `apply_orientation` +/// warns and no-ops on one, so honouring it would leave `orientation_applied` claiming a +/// transform that never happened. +#[test] +fn an_out_of_range_orientation_tag_is_ignored() { + let bytes = jpeg_bytes(&gradient(8, 8), Some(&oriented(42))); + let probed = RawshiftDecoder.probe(&bytes, "jpg").expect("probe"); + assert_eq!(probed.orientation, None); + let decoded = RawshiftDecoder.decode(&bytes, "jpg").expect("decode"); + assert_eq!(decoded.orientation_applied, 1); +} + +// ── The panic guard ────────────────────────────────────────────────────────── + +/// A [`Decoder`] that panics, and one that lies about its buffer — the two failures real bytes +/// cannot be relied on to produce. +struct HostileDecoder { + panic: bool, +} + +impl Decoder for HostileDecoder { + fn probe(&self, _bytes: &[u8], _ext: &str) -> Result { + Err(MediaError::NotAStillImage) + } + + fn decode(&self, _bytes: &[u8], _ext: &str) -> Result { + assert!( + !self.panic, + "a third-party decoder panicking on untrusted bytes" + ); + Err(MediaError::NotAStillImage) + } +} + +/// A panicking decoder becomes a reported error, never an aborted import. +#[test] +fn a_panicking_decoder_is_caught_at_the_boundary() { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(|_| {})); + let caught = decode_guarded(&HostileDecoder { panic: true }, b"whatever", "jpg"); + std::panic::set_hook(previous); + assert_eq!(caught, Err(MediaError::DecoderPanic)); + + // The guard is transparent when nothing panics. + assert_eq!( + decode_guarded(&HostileDecoder { panic: false }, b"whatever", "jpg"), + Err(MediaError::NotAStillImage) + ); + assert!(decode_guarded(&RawshiftDecoder, &png_bytes(&gradient(4, 4)), "png").is_ok()); +} + +// ── Resize ─────────────────────────────────────────────────────────────────── + +/// The sizing rule: cap the long edge, keep the aspect ratio, never return a zero edge, and +/// leave a frame that already fits exactly as it was. +#[test] +fn capped_dimensions_preserves_aspect_and_never_returns_zero() { + assert_eq!(capped_dimensions(512, 384, 256), (256, 192)); + assert_eq!(capped_dimensions(384, 512, 256), (192, 256)); + assert_eq!(capped_dimensions(1000, 1000, 256), (256, 256)); + assert_eq!(capped_dimensions(4032, 3024, 256), (256, 192)); + // Already inside the cap: untouched, which is what lets a caller compare and skip. + assert_eq!(capped_dimensions(128, 96, 256), (128, 96)); + assert_eq!(capped_dimensions(256, 256, 256), (256, 256)); + // Extreme ratios round the short edge to 1 rather than to an empty buffer. + assert_eq!(capped_dimensions(8000, 3, 256), (256, 1)); + assert_eq!(capped_dimensions(3, 8000, 256), (1, 256)); + // A zero cap is raised to 1 rather than producing an empty frame. + assert_eq!(capped_dimensions(100, 50, 0), (1, 1)); +} + +/// The downscale is deterministic and its output is exactly `w*h*4`. Determinism is a +/// requirement, not a nicety: the derivative's bytes are content-addressed by a signed manifest. +#[test] +fn the_downscale_is_deterministic_and_correctly_sized() { + let source = gradient(512, 384); + let first = downscale_rgba8(&source, 256); + let second = downscale_rgba8(&source, 256); + assert_eq!((first.width, first.height), (256, 192)); + assert_eq!(first.rgba.len(), 256 * 192 * 4); + assert_eq!(first.rgba, second.rgba, "identical input, identical bytes"); + + // A frame within the cap comes back untouched. + let small = gradient(100, 80); + assert_eq!(downscale_rgba8(&small, 256), small); +} + +/// A box average over a flat region reproduces that region's own value exactly — the property +/// that keeps a downscaled thumbnail from drifting darker with every reduction. +#[test] +fn the_downscale_preserves_flat_regions_and_stays_opaque() { + let uniform = RgbaImage { + width: 64, + height: 64, + rgba: vec![137, 42, 200, 255].repeat(64 * 64), + }; + let out = downscale_rgba8(&uniform, 16); + assert_eq!((out.width, out.height), (16, 16)); + assert!( + out.rgba.chunks_exact(4).all(|px| px == [137, 42, 200, 255]), + "a flat region survives an area average exactly" + ); + + // The quadrant markers survive a 4x reduction, i.e. the filter is not smearing regions into + // one another beyond their own boundary. + let reduced = downscale_rgba8(&quadrants(128, 128), 32); + let means = quadrant_means(&reduced); + assert_eq!(means, (0, 85, 170, 255)); +} + +// ── Derivatives ────────────────────────────────────────────────────────────── + +/// Two signing keys and a fixed context — the epoch/authorisation material a manifest needs +/// that pixels do not carry. +fn signers() -> (HybridSigningKey, HybridSigningKey) { + ( + HybridSigningKey::from_seed_bytes(&[7; 32], &[8; 32]), + HybridSigningKey::from_seed_bytes(&[9; 32], &[10; 32]), + ) +} + +fn context<'a>( + device: &'a HybridSigningKey, + write_tier: &'a HybridSigningKey, + asset_id: Uuid, +) -> DerivativeContext<'a> { + DerivativeContext { + source_asset_id: asset_id, + crypto_suite_id: CRYPTO_SUITE_ID, + protocol_version: PROTOCOL_VERSION.into(), + amk_version: AmkVersion(1), + generated_by_device: Uuid::from_u128(0xD1), + generated_by_client: "capsule-core/test".into(), + generated_at: "2026-09-01T00:00:00Z".into(), + device_signer: device, + write_tier_signer: write_tier, + } +} + +fn generate(frame: &RgbaImage, original: &[u8]) -> StillDerivatives { + let (device, write_tier) = signers(); + let ctx = context(&device, &write_tier, Uuid::from_u128(0xB1)); + let decoded = RawshiftDecoder + .decode(original, "png") + .expect("the fixture decodes"); + assert_eq!( + (decoded.width(), decoded.height()), + (frame.width, frame.height) + ); + generate_still_derivatives(&decoded, original, &DerivativeTier::GENERATED, &ctx) + .expect("generation succeeds") +} + +/// The thumbnail tier over a source larger than the cap: real WebP bytes, a signed manifest +/// binding their hash, and the two formats this build cannot encode recorded as deferrals +/// rather than silently omitted. +#[test] +fn the_thumbnail_tier_encodes_webp_and_defers_the_rest() { + let frame = gradient(512, 384); + let original = png_bytes(&frame); + let result = generate(&frame, &original); + + assert_eq!(result.generated.len(), 1, "one encodable format today"); + let thumb = &result.generated[0]; + assert_eq!(thumb.tier, DerivativeTier::Thumbnail); + assert_eq!(thumb.format, DerivativeFormat::WebP); + assert_eq!(thumb.manifest.core.format, "image/webp"); + assert_eq!(thumb.manifest.core.role, DerivativeRole::Thumbnail); + assert_eq!( + thumb.manifest.core.ciphertext_hash, + crate::crypto::hash::hash_bytes(&thumb.bytes), + "the manifest binds the bytes it is signed over" + ); + assert_eq!(thumb.manifest.core.version, DERIVATIVE_MANIFEST_VERSION); + assert!( + thumb.manifest.core.prior_provenance_hash.is_none(), + "first of its role" + ); + + // The bytes are a real WebP of the tier's size. + assert_eq!( + StillFormat::from_bytes(&thumb.bytes), + Some(StillFormat::WebP) + ); + let back = RawshiftDecoder + .decode(&thumb.bytes, "webp") + .expect("the thumbnail decodes"); + assert_eq!((back.width(), back.height()), (256, 192)); + assert!( + thumb.bytes.len() < original.len(), + "a 256 px q=50 thumbnail is smaller than a 512 px lossless original" + ); + + // The gap is per (tier, format), recorded rather than collapsed. + assert_eq!( + result.deferred, + vec![ + (DerivativeTier::Thumbnail, DerivativeFormat::Jxl), + (DerivativeTier::Thumbnail, DerivativeFormat::Avif), + ] + ); + + // Both signatures verify over the canonical core. + let (device, write_tier) = signers(); + let bytes = thumb.manifest.core.signing_bytes(); + assert!( + device + .verifying_key() + .verify(&bytes, &thumb.manifest.device_sig) + ); + assert!( + write_tier + .verifying_key() + .verify(&bytes, &thumb.manifest.write_sig) + ); +} + +/// A source no larger than the tier's cap takes the signed `original` sentinel — an explicit +/// marker, distinct from an absent derivative, and never a redundant re-encode. +#[test] +fn a_source_within_the_cap_signs_the_original_sentinel() { + let frame = gradient(128, 96); + let original = png_bytes(&frame); + let result = generate(&frame, &original); + + assert_eq!(result.generated.len(), 1); + let only = &result.generated[0]; + assert_eq!(only.format, DerivativeFormat::Original); + assert_eq!(only.manifest.core.format, "original"); + assert_eq!( + only.bytes, original, + "the sentinel references the original bytes" + ); + assert_eq!( + only.manifest.core.ciphertext_hash, + crate::crypto::hash::hash_bytes(&original) + ); + assert!( + result.deferred.is_empty(), + "nothing was deferred: the tier is satisfied by the original, not by a missing encoder" + ); + assert_eq!(DerivativeFormat::Original.extension(), None); +} + +/// Manifests of the same role chain by content hash over the previous one's canonical CBOR, +/// signatures included — the same append-only link the asset provenance chain uses. +/// +/// Exercised through [`sign_derivative`](super::derivative::sign_derivative) rather than +/// through [`generate_still_derivatives`], and deliberately: only WebP is encodable today, so a +/// single call produces one manifest per role and the multi-link case — the half that can +/// actually be wrong — is unreachable from the public entry point until a second encoder lands +/// (the filed `S-B1` remainder). +#[test] +fn manifests_of_one_role_form_an_append_only_chain() { + let (device, write_tier) = signers(); + let ctx = context(&device, &write_tier, Uuid::from_u128(0xB2)); + let mut prior = None; + + let first = super::derivative::sign_derivative( + &ctx, + DerivativeTier::Thumbnail, + DerivativeFormat::WebP, + b"first generation bytes", + &mut prior, + ) + .expect("signing the first manifest"); + assert!( + first.manifest.core.prior_provenance_hash.is_none(), + "the first manifest of a role starts that role's chain" + ); + + let expected_link = crate::crypto::hash::hash_bytes( + &crate::cbor::to_canonical_vec(&first.manifest).expect("canonical CBOR"), + ); + assert_eq!( + prior, + Some(expected_link), + "the cursor advances to this manifest" + ); + + let second = super::derivative::sign_derivative( + &ctx, + DerivativeTier::Thumbnail, + DerivativeFormat::WebP, + b"second generation bytes", + &mut prior, + ) + .expect("signing the second manifest"); + assert_eq!( + second.manifest.core.prior_provenance_hash, + Some(expected_link), + "the second manifest chains to the first by content hash" + ); + + // Breaking the link is detectable: the hash covers the signatures, so any edit to the first + // manifest moves the value the second one has to carry. + let mut tampered = first.manifest.clone(); + tampered.core.generated_at = "2026-09-02T00:00:00Z".into(); + let tampered_link = crate::crypto::hash::hash_bytes( + &crate::cbor::to_canonical_vec(&tampered).expect("canonical CBOR"), + ); + assert_ne!( + tampered_link, expected_link, + "a rewritten predecessor no longer matches the link its successor signed" + ); +} + +/// Each tier records its own role, so each role is its own chain rather than one interleaved +/// sequence. +#[test] +fn each_tier_starts_its_own_role_chain() { + let frame = gradient(512, 384); + let original = png_bytes(&frame); + let (device, write_tier) = signers(); + let decoded = RawshiftDecoder.decode(&original, "png").expect("decode"); + + let both = generate_still_derivatives( + &decoded, + &original, + &[DerivativeTier::Thumbnail, DerivativeTier::Preview], + &context(&device, &write_tier, Uuid::from_u128(0xB5)), + ) + .expect("both tiers"); + + let roles: Vec = both + .generated + .iter() + .map(|d| d.manifest.core.role) + .collect(); + assert_eq!( + roles, + vec![DerivativeRole::Thumbnail, DerivativeRole::Preview] + ); + for derivative in &both.generated { + assert!( + derivative.manifest.core.prior_provenance_hash.is_none(), + "the first manifest of each role starts that role's chain" + ); + } + + // The preview tier keeps the source resolution; only the thumbnail caps a long edge. + let previewed = both + .generated + .iter() + .find(|d| d.tier == DerivativeTier::Preview) + .expect("a preview was generated"); + let back = RawshiftDecoder + .decode(&previewed.bytes, "webp") + .expect("the preview decodes"); + assert_eq!((back.width(), back.height()), (512, 384)); +} + +/// **The privacy case.** A thumbnail must not inherit the source's EXIF, and above all not its +/// GPS fix. +/// +/// The control half is what makes this a real test: the crate's *default* is to embed +/// everything, so the same frame encoded the way `MetadataEmbedOptions::default()` would encode +/// it does carry the fix. Capsule's derivative does not. +#[test] +fn a_thumbnail_carries_no_exif_and_no_gps() { + let frame = gradient(512, 384); + let located_metadata = located(); + let source = jpeg_bytes(&frame, Some(&located_metadata)); + + // The source really does carry the fix — otherwise the assertion below proves nothing. + assert!( + contains(&source, b"Exif\0\0"), + "the fixture JPEG must carry an EXIF APP1 segment" + ); + assert!( + gps_rationals_present(&source), + "the fixture JPEG must carry the GPS rationals" + ); + + // The control: encoding a WebP the way the crate's own default would. + let leaky = encode_rgb_image_to_vec( + &to_rgb_u16(&frame), + &located_metadata, + &EncodeOptions::WebpLibwebp(LibwebpEncodeConfig { + common: CommonEncodeOptions { + metadata: MetadataEmbedOptions::all(), + bit_depth: BitDepth::Eight, + }, + mode: WebPMode::Lossy, + quality: 50.0, + method: 4, + near_lossless: 100, + }), + ) + .expect("the control WebP encodes"); + assert!( + contains(&leaky, b"EXIF") && gps_rationals_present(&leaky), + "the control must leak, or this test is not testing the strip" + ); + + // Capsule's derivative, over the same GPS-bearing source. + let (device, write_tier) = signers(); + let decoded = RawshiftDecoder.decode(&source, "jpg").expect("decode"); + let result = generate_still_derivatives( + &decoded, + &source, + &DerivativeTier::GENERATED, + &context(&device, &write_tier, Uuid::from_u128(0xB3)), + ) + .expect("generation"); + let thumb = &result.generated[0].bytes; + assert!(!thumb.is_empty(), "the thumbnail has bytes to inspect"); + assert!(!contains(thumb, b"EXIF"), "no EXIF chunk in the thumbnail"); + assert!(!contains(thumb, b"Exif\0\0"), "no APP1 EXIF payload either"); + assert!(!contains(thumb, b"XMP "), "no XMP chunk"); + assert!(!contains(thumb, b"ICCP"), "no ICC profile"); + assert!( + !gps_rationals_present(thumb), + "the GPS rationals must not survive into a thumbnail" + ); +} + +/// Whether `needle` appears anywhere in `haystack`. +fn contains(haystack: &[u8], needle: &[u8]) -> bool { + haystack.windows(needle.len()).any(|w| w == needle) +} + +/// Whether the fixture's GPS degrees/minutes/seconds appear as EXIF rationals — a +/// `numerator/denominator` pair per component, in either byte order. +fn gps_rationals_present(bytes: &[u8]) -> bool { + let rational = |value: u32| -> [Vec; 2] { + let mut be = value.to_be_bytes().to_vec(); + be.extend_from_slice(&1u32.to_be_bytes()); + let mut le = value.to_le_bytes().to_vec(); + le.extend_from_slice(&1u32.to_le_bytes()); + [be, le] + }; + // The seconds component of each coordinate is the most distinctive value; requiring both + // keeps an incidental byte match from reading as a leak. + let has = |value: u32| rational(value).iter().any(|n| contains(bytes, n)); + has(GPS_LAT_DMS[2]) && has(GPS_LON_DMS[2]) +} + +// ── The closed format set ──────────────────────────────────────────────────── + +/// `mime` and `parse` are inverses over the whole closed set, and nothing outside it parses. +#[test] +fn the_closed_format_set_round_trips_and_admits_nothing_else() { + for format in [ + DerivativeFormat::Jxl, + DerivativeFormat::Avif, + DerivativeFormat::WebP, + DerivativeFormat::Original, + ] { + assert_eq!(DerivativeFormat::parse(format.mime()), Some(format)); + assert!(DerivativeFormat::is_recognized(format.mime())); + } + for rejected in [ + "image/future-codec", + "image/jpeg", + "image/png", + "IMAGE/WEBP", + "original ", + "", + "embedding/mobileclip-b", + ] { + assert!( + !DerivativeFormat::is_recognized(rejected), + "{rejected:?} is outside the closed set" + ); + } + // Only WebP and the sentinel can be produced here; the master and delivery formats are + // committed but blocked on a toolchain. + assert!(DerivativeFormat::WebP.is_encodable()); + assert!(DerivativeFormat::Original.is_encodable()); + assert!(!DerivativeFormat::Jxl.is_encodable()); + assert!(!DerivativeFormat::Avif.is_encodable()); +} + +/// A signed still-role manifest whose `format` is outside the closed set is rejected at +/// verification, and an embedding-role manifest — which writes `embedding/{model_id}` into the +/// same field — is not caught in the crossfire. +#[test] +fn verification_rejects_an_unrecognised_still_format() { + let (device, write_tier) = signers(); + let sign = |role: DerivativeRole, format: &str| -> DerivativeManifest { + DerivativeCore { + version: DERIVATIVE_MANIFEST_VERSION.into(), + crypto_suite_id: CRYPTO_SUITE_ID, + protocol_version: Some(PROTOCOL_VERSION.into()), + amk_version: Some(AmkVersion(1)), + source_asset_id: Uuid::from_u128(0xB4), + role, + format: format.into(), + ciphertext_hash: crate::crypto::hash::hash_bytes(b"bytes"), + generated_by_device: Uuid::from_u128(0xD1), + generated_by_client: "capsule-core/test".into(), + model_id: None, + model_version: None, + generated_at: "2026-09-01T00:00:00Z".into(), + prior_provenance_hash: None, + } + .sign(&device, &write_tier) + .expect("signing") + }; + + assert_eq!( + verify_still_format(&sign(DerivativeRole::Thumbnail, "image/webp")), + Ok(Some(DerivativeFormat::WebP)) + ); + assert_eq!( + verify_still_format(&sign(DerivativeRole::Preview, "original")), + Ok(Some(DerivativeFormat::Original)) + ); + assert_eq!( + verify_still_format(&sign(DerivativeRole::Thumbnail, "image/future-codec")), + Err("image/future-codec".to_string()), + "an unrecognised still format is a structural rejection" + ); + assert_eq!( + verify_still_format(&sign(DerivativeRole::Embedding, "embedding/mobileclip-b")), + Ok(None), + "the embedding-role grammar is not this set's business" + ); +} + +// ── The LQIP producer ──────────────────────────────────────────────────────── + +/// A decoded frame is exactly what the unconditional LQIP encoder takes — the reason +/// [`DecodedImage`](super::DecodedImage) carries a [`RgbaImage`] rather than its own buffer +/// type. +#[test] +fn a_decoded_frame_encodes_an_lqip_at_the_committed_width() { + let frame = gradient(200, 150); + let decoded = RawshiftDecoder + .decode(&png_bytes(&frame), "png") + .expect("decode"); + let lqip = Lqip::encode( + decoded.width(), + decoded.height(), + &decoded.image.rgba, + decoded.gamut, + ) + .expect("a decoded frame is a valid LQIP source"); + assert_eq!(lqip.as_bytes().len(), 32, "DEFAULT_TIER is 32 bytes"); + assert_eq!( + lqip.to_sidecar().format_version, + crate::lqip::LQIP_FORMAT_V1 + ); + + // The placeholder is computed from the full-resolution frame, not from the thumbnail: + // chromahash band-limits on the read side, so pre-resizing would cap fidelity. + let thumb = downscale_rgba8(&decoded.image, 256); + let from_thumb = Lqip::encode(thumb.width, thumb.height, &thumb.rgba, decoded.gamut) + .expect("a downscaled frame also encodes"); + assert_eq!( + from_thumb.as_bytes().len(), + 32, + "the tier is fixed regardless of the source size" + ); +} diff --git a/capsule-docs/planned-modules.txt b/capsule-docs/planned-modules.txt index d78427f0..f162da0f 100644 --- a/capsule-docs/planned-modules.txt +++ b/capsule-docs/planned-modules.txt @@ -12,7 +12,7 @@ # there. Everything here is a commitment nobody has met yet — read it as the # module-layer answer to "what has been designed and not built?" -capsule-core::media The Capsule-side owner of decode, metadata extraction and derivative generation, which will consume Rawshift once Rawshift stabilizes. Rawshift is a pinned submodule today and is not a workspace dependency, so nothing consumes it and this module has no body to write yet. Lane B in SLICES.md. +capsule-core::media::video The video half of the media module: first-frame still extraction and the H.264 baseline preview transcode. The still half now exists (`capsule-core::media`, slice S-B1 on rawshift-image 0.1.1); this does not, because `rawshift-video` is unpublished and the transcode toolchain touches nothing the still path does. Contract: design/thumbnails.md § Video Previews. Slice S-B5 in SLICES.md. capsule-core::notify Alert classes and their trigger predicates, so every platform evaluates one shared decision function rather than reimplementing the taxonomy. Contract: design/notifications.md. Tier 0 has no server half, so this is client-only work. capsule-core::import::camera The PTP/IP tethered-camera source adapter (S-B9). Post-v1; the contract exists so the adapter seam is fixed before anything implements it. capsule-server::federation Server-to-server federation pull. The whole surface is post-v1 — `capsule-server` has no federation route, no capability-token verifier and no per-peer budget enforcement. diff --git a/capsule-docs/src/content/docs/design/dependencies.md b/capsule-docs/src/content/docs/design/dependencies.md index 548962bc..517116c2 100644 --- a/capsule-docs/src/content/docs/design/dependencies.md +++ b/capsule-docs/src/content/docs/design/dependencies.md @@ -40,6 +40,7 @@ Mechanically, every Rust version is pinned once in the root `Cargo.toml` `[works | ORM | `sea-orm` (`sqlx-postgres` on the server, `sqlx-sqlite` in the CLI) | The rebuildable index databases only — sidecars stay canonical per [Principles](/design/principles/). | — | | Embedded SQLite | `rusqlite` (`bundled`) | `capsule-core`'s `library.sqlite`. | — | | Vector index | `sqlite-vec` (`vec0`) | The client-local embedding index in `capsule-core`'s `library.sqlite` — per-task `vec0` virtual tables under the [embedding-provenance](/design/ai/#embedding-provenance) invariant. Optional + `native`-gated alongside `rusqlite` (registers as a SQLite auto-extension; not `wasm32`). | Server-side vector-DB idioms (pgvector/HNSW) do not apply — the index is client-local SQLite by design. | +| Still decode / encode | `rawshift-image` **0.1.1** (`default-features = false`, features `jpeg`, `png`, `jxl-decode`, `tiff-decode`, `gif-decode`, `webp`) | `capsule-core::media` behind the `media` feature, which `native` implies (slices `S-B1`, `S-B13`) — format sniffing, pixel decode, EXIF orientation and the derivative byte encode. A **registry** dependency, not the pinned `rawshift/` submodule: that tree is an uninitialised newer v1-in-progress checkout and not a workspace member. Depended on directly rather than through the `rawshift` facade because only the per-crate dependency gives per-format Cargo control, which the crate's own docs recommend and which this row needs — the format set is a licence and build-host decision, not a convenience. Decode is pure Rust for JPEG, PNG, JXL, TIFF, GIF and Netpbm (the zune family, `jxl-oxide`, `tiff`, `gif`); WebP adds `libwebp-sys` 0.14.4 (MIT), a vendored static libwebp built through `cc` with **pre-generated** bindings — the same class of C build `rusqlite/bundled` already performs, and the encoder that produces the thumbnail tier's bytes. MPL-2.0 (with `rawshift-core`), already allow-listed in `deny.toml`; both are named in the root `NOTICE` MPL list. `jpeg-encoder`'s conjunctive IJG arm was already excepted and is matched again by this row. Tiers, quality and the closed format set are the contract at [Thumbnails](/design/thumbnails/); this row owns the pin. | **Deliberately absent, each a toolchain rather than a design gap:** `heic` (system libheif), `avif` (`image`'s `avif-native` -> system libdav1d for decode; `ravif` -> `rav1e/asm` -> `nasm` on every x86_64 build host for encode), `svg` (resvg), and the RAW families (`experimental`/`raw-stabilizing`; Canon CR3 pixel decode is unimplemented upstream). Also absent: a lossy JXL encoder, because the pure-Rust backend is `zune-jpegxl`'s lossless `JxlSimpleEncoder` and a q=50 encode needs C libjxl (`bindgen` + `pkg-config`). Every one of these is a typed `media::MediaError::UnsupportedFormat` or a recorded per-format deferral, never a silent gap. **Not** on the wasm32 sealing surface: `media` is absent from the `--no-default-features` build, so `cargo tree --target wasm32-unknown-unknown -i rawshift-image` is empty. Rawshift must never wrap Chromahash (`AGENTS.md`); see the LQIP row below. | | LQIP placeholder codec | `chromahash` **0.7.1** | `capsule-core::lqip` (slice `S-B14`) — the only encoder/decoder for the signed sidecar `lqip` field. Imported **directly**, never through Rawshift (`AGENTS.md`), and deliberately outside `capsule-core::media` — the Rawshift-consuming module — so one implementation serves the import pipeline, the uniffi FFI, and `capsule-wasm`. The tier, byte width and versioned fallback are the contract at [Thumbnails — LQIP](/design/thumbnails/#lqip); this row owns only the pin. The `AGENTS.md` gate that read "after its v1 release" is **amended to 0.7.1** — the release the project accepts as ready — and `xtask`'s architecture check stopped forbidding the crate in `2f8beeb`, because a check that forbids an approved dependency has stopped describing a decision and started blocking one. | **`thumbhash` is retired, not excepted.** The Rust crate behind `capsule-core`'s `media` feature and the npm package in `capsule-web` both go; `thumbhash` stays in the architecture check's retired-dependency list so it cannot return. BlurHash was never adopted. | | Free-space probe | `rustix` (Unix, `fs`) + `windows-sys` (Windows, `Win32_Storage_FileSystem`) | `capsule-core::library::available_bytes` — the streaming-import free-space probe (`statvfs` / `GetDiskFreeSpaceEx`). Host-only, behind the `native` feature; the wasm32 sealing build links neither. | — | | Windows TPM (TBS) | `windows-sys` (Windows, `Win32_System_TpmBaseServices`) | `capsule-core::crypto::keys::tbs` — the Windows device-key `HardwareSigner` (slice S-F4). The raw TPM 2.0 command channel (`Tbsi_Context_Create` / `Tbsip_Submit_Command`) the tss-esapi reference (`crypto::keys::tpm`, Linux) wraps; links `tbs.dll` via raw-dylib, so no new crate — an extra feature on the existing `windows-sys` row. `#[cfg(windows)]`-gated; the pure wire codec + mock tests run on any host. | Not tss-esapi on Windows: TBS is native and avoids the `libtss2`/bindgen build. | From c67292ad5bf24bcc6fc636bd3c4e7700fcfb20e4 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 23:02:56 -0400 Subject: [PATCH 053/243] feat(core): wire the LQIP producer and thumbnails into signed import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `lifecycle/import.rs` hard-coded `(exif dimensions, None, DeferredNoCodec)` for every still, so `capsule_core::lqip` — fully tested since `S-B14` — had no production caller and `DerivativeStatus` had one reachable value. `Workspace::prepare_still` replaces the constant triple with one decode pass that yields the header-derived `content_type`, pixel `dimensions`, the chromahash `lqip`, and the signed thumbnail derivatives; `persist_derivatives` writes them under `derivatives/` at the layout the upload-bundle reader already looks for. Both run inside the existing signed write path, so nothing about the sealing order moves. Pixel dimensions win over EXIF because they are post-orientation: a quarter-turned JPEG's `PixelXDimension` is its *stored* width, which is transposed relative to what a viewer shows. Derivatives are persisted **after** the asset's own files are durable, and a write failure is logged rather than returned: a derivative is regenerable and must never fail an import whose signed original is already committed. Nothing here can fail an import over unreadable pixels — every path degrades to "signed, encrypted, verifiable original, without a placeholder" and records which reason applied. `ImportOutcome::Imported` gains `deferred_formats`, summarised by `ImportExecutionSummary::deferred_format_count()`. It counts *format variants* missing from assets that do have a thumbnail, where `deferred_derivative_count()` counts *assets* with none — a decoded JPEG reports two (the JXL master, the AVIF delivery variant), which is the number that falls to zero as the encoders land. The `S-B13` distinction the executor test lost to `S-C59` is observable again, and now rests on the bytes rather than the extension: a HEIC is `DeferredNoCodec` (recognised, no codec here, backfillable) while a `.jpg` that is not a JPEG is `DecodeFailed` (a format we do decode, failing on these bytes). Both still land as signed, self-verifying backups. --- capsule-cli/tests/import_round_trip.rs | 57 +- capsule-core/src/import/executor.rs | 64 ++- capsule-core/src/import/progress.rs | 33 +- capsule-core/src/lifecycle/derivatives.rs | 665 ++++++++++++++++++++++ capsule-core/src/lifecycle/import.rs | 66 ++- capsule-core/src/lifecycle/mod.rs | 36 +- 6 files changed, 850 insertions(+), 71 deletions(-) create mode 100644 capsule-core/src/lifecycle/derivatives.rs diff --git a/capsule-cli/tests/import_round_trip.rs b/capsule-cli/tests/import_round_trip.rs index b63b155c..9cfdf6f6 100644 --- a/capsule-cli/tests/import_round_trip.rs +++ b/capsule-cli/tests/import_round_trip.rs @@ -16,13 +16,18 @@ //! by [`capsule_list_reports_the_sync_feed_not_the_library`] rather than papered over. //! //! **The fixture image.** A synthesized 8×8 baseline JPEG (see [`synthetic_jpeg`]) carrying a -//! real EXIF APP1 segment — no committed binary. The CLI links `capsule-core` **without** the -//! `media` feature, so nothing on this path decodes pixels (every still reports -//! `DerivativeStatus::DeferredNoCodec`, slice `S-B13`); the decode the importer genuinely -//! performs is EXIF, through `extract_exif`. The assertions therefore land on values that can -//! only have come from parsing the segment: the sidecar's 8×8 dimensions and its GPS fix. The -//! bytes are a real, decodable JPEG all the same, so the fixture stays honest if a build that -//! *does* carry a codec ever runs this path. +//! real EXIF APP1 segment — no committed binary. The CLI links `capsule-core` with default +//! features, and `native` implies `media`, so this path **does** decode pixels: the import +//! reports `DerivativeStatus::Decoded`, writes a chromahash `lqip` into the signed sidecar, and +//! signs a thumbnail-tier derivative (slices `S-B1`, `S-B13`, `S-B14`). At 8×8 the source is +//! well inside the 256 px thumbnail cap, so that derivative is the signed `format = "original"` +//! sentinel rather than a re-encode — which is exactly the contract's redundant-derivative rule +//! and worth pinning end to end. +//! +//! The EXIF assertions are still the load-bearing ones, because neither the GPS fix nor the +//! capture time exists anywhere but inside the APP1 segment `synthetic_jpeg` wrote. The 8×8 +//! dimensions now come from decoded pixels *and* agree with the EXIF tags, which is the +//! stronger statement: the two independent readings of the fixture match. //! //! **Argon2id.** `capsule library init` does not create the account — the first `capsule import` //! does, at `DeviceTier::Normal` (256 MiB, t=3), which costs ~5 s per unlock in a debug build and @@ -319,6 +324,29 @@ fn an_import_is_reconstructed_by_a_later_process_from_disk_alone() { "the stored original must be the bytes that were imported" ); + // ── The signed derivatives, in `media/{YYYY}/{YYYY-MM}/derivatives/`. ── + // + // The fixture is 8×8, well inside the thumbnail tier's 256 px cap, so the tier is satisfied + // by the signed `format = "original"` sentinel over the source bytes — the contract's + // redundant-derivative rule — under the source's own extension. + let derivatives = bucket.join("derivatives"); + let sentinel = derivatives.join(format!("{simple}.thumbnail.jpg")); + assert!( + sentinel.is_file(), + "a thumbnail-tier derivative must exist in {}", + derivatives.display() + ); + assert_eq!( + std::fs::read(&sentinel).expect("read the thumbnail-tier derivative"), + fx.image, + "the `original` sentinel references the source bytes rather than re-encoding them" + ); + let bundle = derivatives.join(format!("{simple}.derivatives.cbor")); + assert!( + bundle.is_file(), + "the derivative bytes are unusable without their signed manifest bundle" + ); + // ── The signed sidecar, decoded from disk. ── let bytes = std::fs::read(bucket.join(format!("{simple}.cbor"))).expect("read the sidecar"); let sidecar = @@ -340,8 +368,21 @@ fn an_import_is_reconstructed_by_a_later_process_from_disk_alone() { let dimensions = sidecar .dimensions .as_ref() - .expect("dimensions come from the EXIF PixelXDimension/PixelYDimension tags"); + .expect("dimensions come from the decoded pixels, and agree with the EXIF tags"); assert_eq!((dimensions.width, dimensions.height), (8, 8)); + + // The LQIP producer ran on the decoded pixels: a 32-byte chromahash payload at + // `LQIP_FORMAT_V1`, inside the *signed* sidecar (slice `S-B14`). + let lqip = sidecar + .lqip + .as_ref() + .expect("a decodable still carries a chromahash placeholder"); + assert_eq!( + lqip.chromahash.len(), + 32, + "chromahash's DEFAULT_TIER is exactly 32 bytes" + ); + assert_eq!(lqip.format_version, 1, "the sidecar LQIP format version"); let gps = sidecar.gps.as_ref().expect("the EXIF GPS fix"); assert!( (gps.lat - EXIF_LAT).abs() < 1e-6 && (gps.lon - EXIF_LON).abs() < 1e-6, diff --git a/capsule-core/src/import/executor.rs b/capsule-core/src/import/executor.rs index 48ef0888..9560d827 100644 --- a/capsule-core/src/import/executor.rs +++ b/capsule-core/src/import/executor.rs @@ -4,10 +4,11 @@ //! [`Workspace::import_asset_with`](crate::lifecycle::Workspace::import_asset_with): every member //! becomes a signed [`SidecarV1`](crate::sidecar::SidecarV1) + signed manifest + //! append-only provenance, self-verified through -//! [`verify_asset`](crate::crypto::verify_asset::verify_asset), and — when a still encoder is -//! attached to the workspace — with signed thumbnail/preview derivatives + an LQIP in the -//! sidecar. No still encoder exists in this build: the media stack is retired to -//! `legacy-review/` and restoring it is `S-B1`. +//! [`verify_asset`](crate::crypto::verify_asset::verify_asset), and — when the still decodes — +//! with a chromahash `lqip` in the sidecar and signed thumbnail derivatives on disk +//! ([`capsule_core::media`](crate::media), slices `S-B1`/`S-B13`/`S-B14`). A format with no +//! codec in this build still imports, as a signed original with the gap recorded rather than +//! hidden. //! //! This retired the legacy unsigned `AssetSidecar` write path from the executor; the production //! write path itself is now gone (`S-G4`) — no code writes unsigned sidecars anymore. Only the @@ -247,6 +248,7 @@ fn execute_candidate( path.clone(), ImportOutcome::Imported { derivatives: receipt.derivatives, + deferred_formats: receipt.deferred_formats, }, )); } @@ -420,15 +422,15 @@ mod tests { /// **The S-B13 contract (slice `S-B13`).** An original whose format has no codec in this /// build is imported as a signed, encrypted, verifiable asset — it simply arrives without a - /// thumbnail/preview, and the run summary says so. + /// thumbnail, and the run summary says so. /// - /// **`S-C59` narrowed what this can assert.** It used to pin the distinction the logs must - /// preserve: `iphone.heic` an *expected* deferral, `snap.jpg` a *genuine* decode failure of a - /// format we do support. With `capsule_core::media` retired there is no decoder for any - /// format, so both are deferrals and the distinction is unobservable — it comes back with - /// Rawshift. What survives is the half that matters most and would be the worst to lose - /// silently: **an undecodable original is still a signed, encrypted, self-verifying backup**, - /// and both files land. + /// **The distinction is observable again.** `S-C59` retired the decoder and collapsed both + /// files below into deferrals, which made the logs' most useful property untestable. With + /// `capsule_core::media` on `rawshift-image` the two are apart once more, and they are apart + /// for the reason that matters rather than by extension: `iphone.heic` is a format Capsule + /// *recognises and cannot decode* (an expected, backfillable gap), while `snap.jpg` is a + /// format it can decode whose bytes are not a JPEG (a real problem). Both still land as + /// signed, encrypted, self-verifying backups, which is the half that would be worst to lose. #[test] fn originals_with_no_codec_are_still_imported_and_signed() { use crate::lifecycle::DerivativeStatus; @@ -453,28 +455,32 @@ mod tests { assert_eq!(summary.imported_count(), 2, "both originals are backed up"); assert_eq!( summary.deferred_derivative_count(), - 2, - "with no decoder in the build, every still is a codec deferral" + 1, + "the HEIC is an expected codec deferral" ); assert_eq!( summary.decode_failed_count(), + 1, + "the .jpg is a format we do decode, failing on these bytes — a real problem" + ); + assert_eq!( + summary.deferred_format_count(), 0, - "nothing is *attempted*, so nothing can fail to decode — the distinction returns \ - with Rawshift" + "nothing decoded, so no per-format variant was even attempted" ); - // Reported per file rather than only in aggregate, so the shape a caller reads is - // pinned even while there is one reason rather than two. + // Reported per file rather than only in aggregate, so a caller reads the reason for the + // file in front of it. for (path, outcome) in &summary.outcomes { - let ImportOutcome::Imported { derivatives } = outcome else { + let ImportOutcome::Imported { derivatives, .. } = outcome else { panic!("{} should have imported, got {outcome:?}", path.display()); }; - assert_eq!( - *derivatives, - DerivativeStatus::DeferredNoCodec, - "for {}", - path.display() - ); + let expected = if path.extension().is_some_and(|e| e == "heic") { + DerivativeStatus::DeferredNoCodec + } else { + DerivativeStatus::DecodeFailed + }; + assert_eq!(*derivatives, expected, "for {}", path.display()); } // Both land on the signed path and self-verify — a missing thumbnail is not a missing @@ -489,6 +495,11 @@ mod tests { /// A RAW-only candidate — no same-stem JPEG to fall back on — still lands as a signed, /// self-verifying original. RAW has no decoder in this build, which is exactly why this /// needs pinning: the archive is the whole point, the derivative is a bonus (slice `S-B13`). + /// + /// A Sony ARW is a TIFF container, so its *header* says TIFF and only the extension names + /// the family. This fixture is not a real ARW, so the classification here rests on the + /// extension fallback — which is the path a real one would also take for the family, and + /// either way the outcome is the same expected deferral. #[test] fn raw_only_candidate_lands_as_a_signed_original() { use crate::lifecycle::DerivativeStatus; @@ -513,7 +524,8 @@ mod tests { assert!(matches!( summary.outcomes[0].1, ImportOutcome::Imported { - derivatives: DerivativeStatus::DeferredNoCodec + derivatives: DerivativeStatus::DeferredNoCodec, + deferred_formats: 0, } )); diff --git a/capsule-core/src/import/progress.rs b/capsule-core/src/import/progress.rs index 47097288..e8f7bc0a 100644 --- a/capsule-core/src/import/progress.rs +++ b/capsule-core/src/import/progress.rs @@ -12,6 +12,11 @@ pub enum ImportOutcome { /// fully successful import that happens to be missing its derivative (slice `S-B13`). Imported { derivatives: DerivativeStatus, + /// How many `(tier, format)` pairs the tier table commits to and this build cannot + /// encode. Orthogonal to `derivatives`: a `Decoded` asset with a renderable WebP + /// thumbnail still reports the JXL master and the AVIF delivery variant as deferred, and + /// that count is how the gap shrinks visibly as codecs land rather than silently. + deferred_formats: u32, }, DuplicateSkipped { existing_uuid: String, @@ -77,13 +82,36 @@ impl ImportExecutionSummary { matches!( o, ImportOutcome::Imported { - derivatives: DerivativeStatus::DeferredNoCodec + derivatives: DerivativeStatus::DeferredNoCodec, + .. } ) }) .count() } + /// The total number of `(tier, format)` pairs across the run that the tier table commits to + /// and this build cannot encode (slice `S-B13`). + /// + /// **Not a failure count, and not comparable to + /// [`deferred_derivative_count`](Self::deferred_derivative_count).** That one counts *assets* + /// with no thumbnail at all; this one counts *format variants* missing from assets that do + /// have one. A library of decodable JPEGs reports zero deferred derivatives and two deferred + /// formats per asset — the JXL master and the AVIF delivery variant — which is exactly the + /// number that should fall to zero as the encoders land, and the reason it is reported rather + /// than left implicit in a doc. + pub fn deferred_format_count(&self) -> usize { + self.outcomes + .iter() + .map(|(_, o)| match o { + ImportOutcome::Imported { + deferred_formats, .. + } => *deferred_formats as usize, + _ => 0, + }) + .sum() + } + /// How many imported assets are in a format this build *does* support but whose bytes did /// not decode — unlike [`deferred_derivative_count`](Self::deferred_derivative_count) this /// is a real problem worth surfacing, not an expected gap. The original is still imported. @@ -94,7 +122,8 @@ impl ImportExecutionSummary { matches!( o, ImportOutcome::Imported { - derivatives: DerivativeStatus::DecodeFailed + derivatives: DerivativeStatus::DecodeFailed, + .. } ) }) diff --git a/capsule-core/src/lifecycle/derivatives.rs b/capsule-core/src/lifecycle/derivatives.rs new file mode 100644 index 00000000..2b0fb9af --- /dev/null +++ b/capsule-core/src/lifecycle/derivatives.rs @@ -0,0 +1,665 @@ +//! The one `lifecycle` file that reaches [`crate::media`]: decode a still once at import and +//! derive everything that needs pixels (slices `S-B1`, `S-B13`). +//! +//! # Why this is not feature-gated +//! +//! `capsule-core`'s `native` feature *implies* `media`, and the whole `lifecycle` module is +//! `native`-gated, so a build that compiles this file always has the codec stack. The two builds +//! that drop `media` — `capsule-server` and `capsule-wasm`, both +//! `default-features = false` — drop `lifecycle` with it. A `#[cfg(feature = "media")]` here +//! would therefore guard nothing while making every signature read as optional; if the +//! implication is ever removed, this file fails to compile, which is the right way for that +//! decision to surface. +//! +//! # Never fails the import +//! +//! Capsule is a backup tool. Every path below degrades to "the original is imported signed, +//! encrypted and `verify_asset`-accepting, without a placeholder or a thumbnail" and records +//! **why** in the returned [`DerivativeStatus`]. The only errors that propagate are the ones +//! that mean the *workspace* is broken — a missing album, a signer that refused — not the ones +//! that mean the pixels were unreadable. + +use std::fs; +use std::path::Path; + +use uuid::Uuid; + +use super::{AssetState, DerivativeStatus, LifecycleError, Result, Workspace, media_dir}; +use crate::cbor; +use crate::crypto::keys::AmkVersion; +use crate::crypto::primitives::{CRYPTO_SUITE_ID, PROTOCOL_VERSION}; +use crate::exif::extract::ExifExtract; +use crate::lqip::Lqip; +use crate::media::{ + DecodedImage, DerivativeContext, DerivativeTier, GeneratedDerivative, MediaError, + RawshiftDecoder, StillFormat, decode_guarded, generate_still_derivatives, +}; +use crate::sidecar::sidecar_v1::{Dimensions, Lqip as SidecarLqip}; + +/// Everything one still yields in a single decode pass: the sidecar fields, the signed +/// derivatives to persist after the durable commit, and the reason for anything missing. +pub(super) struct PreparedStill { + /// The format detection identified, if the bytes are a still Capsule models. Drives the + /// sidecar's `content_type` from the *header* rather than from the file name. + pub(super) format: Option, + /// Pixel dimensions when the still decoded, EXIF dimensions otherwise, `None` if neither. + /// + /// Decoded pixels win over EXIF because they are post-orientation: a quarter-turned JPEG's + /// EXIF `PixelXDimension` is its *stored* width, which is transposed relative to what a + /// viewer shows. + pub(super) dimensions: Option, + /// The sidecar LQIP, present only when the still decoded. + pub(super) lqip: Option, + /// Signed thumbnail derivatives, to be written after the asset's own files. + pub(super) derivatives: Vec, + /// How many `(tier, format)` pairs the tier table commits to and this build cannot encode. + pub(super) deferred_formats: usize, + /// Whether derivatives were generated, and if not, why. + pub(super) status: DerivativeStatus, +} + +impl PreparedStill { + /// The outcome for bytes that yielded no pixels: EXIF dimensions only, no LQIP, no + /// derivatives, and `status` carrying which of the reasons it was. + fn undecoded( + format: Option, + exif_dimensions: Option, + status: DerivativeStatus, + ) -> Self { + Self { + format, + dimensions: exif_dimensions, + lqip: None, + derivatives: Vec::new(), + deferred_formats: 0, + status, + } + } +} + +/// Map a decode failure onto the status the run summary counts, logging the distinction that +/// makes the two reasons useful (slice `S-B13`). +fn classify(error: &MediaError, src: &Path, format: Option) -> DerivativeStatus { + match error { + MediaError::UnsupportedFormat { format, op } => { + tracing::warn!( + path = %src.display(), + %format, + op = op.as_str(), + supported = ?crate::media::SUPPORTED_STILL_FORMATS, + "derivatives: no codec for this format in this build; the original is imported \ + signed and encrypted, but without a thumbnail or LQIP until the codec lands. \ + Derivatives are backfillable from the stored original (S-B13)" + ); + DerivativeStatus::DeferredNoCodec + } + MediaError::NotAStillImage => { + tracing::debug!( + path = %src.display(), + "derivatives: not a still image Capsule models; nothing to decode" + ); + DerivativeStatus::NotAKnownStill + } + // Everything else is a format we *do* support failing on these particular bytes — a + // real problem worth investigating, not an expected gap. + error => { + tracing::warn!( + path = %src.display(), + ?format, + %error, + "derivatives: a supported format failed to decode; the original is imported \ + signed and encrypted, but without a thumbnail or LQIP" + ); + DerivativeStatus::DecodeFailed + } + } +} + +/// Compute the sidecar LQIP from a decoded frame. +/// +/// From the **full-resolution, orientation-applied** frame, never from the thumbnail: +/// chromahash consumes the whole frame and band-limits on the read side via `decode_capped`, so +/// pre-resizing would silently cap fidelity the format can carry ([`crate::lqip`]). +fn lqip_from(decoded: &DecodedImage, src: &Path) -> Option { + match Lqip::encode( + decoded.width(), + decoded.height(), + &decoded.image.rgba, + decoded.gamut, + ) { + Ok(lqip) => Some(lqip.to_sidecar()), + Err(error) => { + // A decoded frame satisfies both of `encode`'s preconditions by construction, so + // this is unreachable rather than expected — logged as such, and never fatal. + tracing::warn!( + path = %src.display(), + %error, + "derivatives: a decoded frame was rejected by the LQIP encoder; importing \ + without a placeholder" + ); + None + } + } +} + +impl Workspace { + /// Decode the still once and derive: the `content_type`, pixel `dimensions`, the sidecar + /// `lqip`, and the signed thumbnail derivatives. All are attached before the sidecar is + /// sealed, per the pipeline's Execute step. + /// + /// **Never fails over unreadable pixels.** A still this build cannot decode still commits as + /// a signed, encrypted original — it falls back to EXIF dimensions with no LQIP and no + /// derivatives, and the returned [`DerivativeStatus`] says which reason applied so the + /// caller can report the gap instead of it being invisible. + #[tracing::instrument( + level = "debug", + skip_all, + fields(asset_id = %asset_id, src = %src.display(), bytes = plaintext.len()) + )] + pub(super) fn prepare_still( + &self, + plaintext: &[u8], + ext: &str, + src: &Path, + exif: &ExifExtract, + asset_id: Uuid, + album_id: Uuid, + ) -> Result { + let exif_dimensions = exif + .width + .zip(exif.height) + .map(|(width, height)| Dimensions { width, height }); + // Detected here as well as inside the decoder so the sidecar's `content_type` is + // header-derived even for a format with no codec: a HEIC is `image/heic` in the sidecar + // whether or not this build can read its pixels. + let sniffed = StillFormat::detect(plaintext, ext); + + let decoded = match decode_guarded(&RawshiftDecoder, plaintext, ext) { + Ok(decoded) => decoded, + Err(error) => { + let status = classify(&error, src, sniffed); + return Ok(PreparedStill::undecoded(sniffed, exif_dimensions, status)); + } + }; + + let dimensions = Some(Dimensions { + width: decoded.width(), + height: decoded.height(), + }); + if let (Some(pixels), Some(exif_dims)) = (dimensions.as_ref(), exif_dimensions.as_ref()) + && pixels != exif_dims + { + // Not an error: EXIF dimensions are pre-orientation and are frequently stale after + // an edit. Logged because a surprising sidecar dimension is otherwise unexplainable + // after the fact. + tracing::debug!( + asset_id = %asset_id, + pixel_width = pixels.width, + pixel_height = pixels.height, + exif_width = exif_dims.width, + exif_height = exif_dims.height, + orientation = decoded.orientation_applied, + "derivatives: decoded dimensions differ from EXIF; the pixels are authoritative" + ); + } + let lqip = lqip_from(&decoded, src); + + let album = self.album(&album_id)?; + let ctx = DerivativeContext { + source_asset_id: asset_id, + crypto_suite_id: CRYPTO_SUITE_ID, + protocol_version: PROTOCOL_VERSION.into(), + amk_version: AmkVersion(album.current_epoch), + generated_by_device: self.account.device.device_id, + generated_by_client: self.client_version.clone(), + generated_at: super::now_rfc3339(), + device_signer: self.device_signer.as_ref(), + write_tier_signer: album.write_tier_signer()?, + }; + let derivatives = + generate_still_derivatives(&decoded, plaintext, &DerivativeTier::GENERATED, &ctx) + .map_err(|e| LifecycleError::Io(format!("derivative generation: {e}")))?; + + Ok(PreparedStill { + format: Some(decoded.format), + dimensions, + lqip, + deferred_formats: derivatives.deferred.len(), + derivatives: derivatives.generated, + status: DerivativeStatus::Decoded, + }) + } + + /// Write the generated derivative bytes plus their signed manifest bundle under the asset's + /// media directory: `derivatives/{uuid}.{role}.{ext}` and `{uuid}.derivatives.cbor`. + /// + /// The layout is the one the upload bundle reader already looks for + /// ([`Workspace::upload_bundle`](Workspace::upload_bundle) finds a derivative's bytes by the + /// `{uuid}.{role}.` prefix), so persisting here needs no change on the read side. + /// + /// Called **after** the asset's own files are durable: a derivative is regenerable and must + /// never be able to fail an import that has already committed. A write error is therefore + /// logged and swallowed rather than propagated. + pub(super) fn persist_derivatives( + &self, + asset: &AssetState, + derivatives: &[GeneratedDerivative], + ) { + if derivatives.is_empty() { + return; + } + let dir = media_dir(&self.root, asset.capture_utc).join("derivatives"); + if let Err(error) = fs::create_dir_all(&dir) { + tracing::warn!( + asset_id = %asset.asset_id, + dir = %dir.display(), + %error, + "derivatives: could not create the derivative directory; the asset is committed \ + and its derivatives are regenerable" + ); + return; + } + let stem = asset.asset_id.simple(); + + let mut manifests = Vec::with_capacity(derivatives.len()); + for derivative in derivatives { + // The `original` sentinel references the source asset, so its bytes carry the + // source's own extension. + let format_ext = derivative + .format + .extension() + .unwrap_or_else(|| asset.ext.as_str()); + let path = dir.join(format!( + "{stem}.{}.{format_ext}", + derivative.tier.role_name() + )); + if let Err(error) = fs::write(&path, &derivative.bytes) { + tracing::warn!( + asset_id = %asset.asset_id, + path = %path.display(), + %error, + "derivatives: could not write a derivative; skipping it" + ); + continue; + } + manifests.push(derivative.manifest.clone()); + } + + if manifests.is_empty() { + return; + } + match cbor::to_canonical_vec(&manifests) { + Ok(bundle) => { + let path = dir.join(format!("{stem}.derivatives.cbor")); + if let Err(error) = fs::write(&path, bundle) { + tracing::warn!( + asset_id = %asset.asset_id, + path = %path.display(), + %error, + "derivatives: could not write the manifest bundle; the bytes on disk are \ + unusable without it and will be regenerated" + ); + return; + } + tracing::debug!( + asset_id = %asset.asset_id, + count = manifests.len(), + dir = %dir.display(), + "derivatives: persisted with their signed manifest bundle" + ); + } + Err(error) => tracing::warn!( + asset_id = %asset.asset_id, + %error, + "derivatives: the manifest bundle did not serialise; skipping persistence" + ), + } + } +} + +#[cfg(test)] +mod tests { + use rawshift_image::core::metadata::{ImageInfo, ImageMetadata}; + use rawshift_image::core::{BitDepth, MetadataEmbedOptions}; + use rawshift_image::formats::encode_rgb_image_to_vec; + use rawshift_image::formats::export::{ + CommonEncodeOptions, EncodeOptions, JpegEncEncodeConfig, ZunePngEncodeConfig, + }; + use tempfile::TempDir; + + use super::super::{DerivativeStatus, SignedImportOptions, Workspace, fast_workspace}; + use super::*; + use crate::crypto::hash; + use crate::crypto::provenance::DerivativeManifest; + use crate::media::{Decoder as _, DerivativeFormat, verify_still_format}; + use crate::sidecar::sidecar_v1::{SIDECAR_SCHEMA_V1, SidecarV1}; + + /// A deterministic RGB gradient, `width` x `height`, as interleaved RGB `u16`. + fn frame(width: u32, height: u32) -> rawshift_image::core::image::RgbImage { + let (w, h) = (width as usize, height as usize); + let mut data = Vec::with_capacity(w * h * 3); + for y in 0..h { + for x in 0..w { + data.push(((x * 255 / w) as u16) * 257); + data.push(((y * 255 / h) as u16) * 257); + data.push((((x + y) * 255 / (w + h)) as u16) * 257); + } + } + rawshift_image::core::image::RgbImage::with_color_space( + width, + height, + data, + rawshift_image::core::ColorSpace::Srgb, + ) + } + + fn common(metadata: MetadataEmbedOptions) -> CommonEncodeOptions { + CommonEncodeOptions { + metadata, + bit_depth: BitDepth::Eight, + } + } + + /// A JPEG carrying an EXIF orientation tag, so the decode path has a transform to apply and + /// the sidecar's dimensions have to disagree with the stored ones. + fn jpeg(width: u32, height: u32, orientation: Option) -> Vec { + let metadata = ImageMetadata { + image: ImageInfo { + orientation, + ..ImageInfo::default() + }, + ..ImageMetadata::default() + }; + let embed = MetadataEmbedOptions { + embed_exif: orientation.is_some(), + embed_icc: false, + embed_xmp: false, + }; + encode_rgb_image_to_vec( + &frame(width, height), + &metadata, + &EncodeOptions::JpegJpegEnc(JpegEncEncodeConfig { + common: common(embed), + quality: 90, + }), + ) + .expect("the fixture JPEG encodes") + } + + fn png(width: u32, height: u32) -> Vec { + encode_rgb_image_to_vec( + &frame(width, height), + &ImageMetadata::default(), + &EncodeOptions::PngZune(ZunePngEncodeConfig { + common: common(MetadataEmbedOptions::none()), + ..ZunePngEncodeConfig::default() + }), + ) + .expect("the fixture PNG encodes") + } + + /// A workspace with fast Argon2 params and its default album created. + fn workspace(dir: &Path) -> (Workspace, Uuid) { + let mut ws = fast_workspace(dir); + let album = ws.default_album_id(); + ws.create_album_with_id(album, "Imports").unwrap(); + (ws, album) + } + + /// Write `bytes` into `dir` under `name` and import it, returning the receipt. + fn import( + ws: &mut Workspace, + album: Uuid, + dir: &Path, + name: &str, + bytes: &[u8], + ) -> super::super::SignedImport { + let path = dir.join(name); + fs::write(&path, bytes).unwrap(); + ws.import_asset_with(album, &path, &SignedImportOptions::default()) + .expect("the import commits") + } + + /// Read back the signed sidecar an import wrote, from the library directory alone. + fn sidecar_of(root: &Path, asset_id: Uuid) -> SidecarV1 { + let mut stack = vec![root.join("media")]; + let name = format!("{}.cbor", asset_id.simple()); + while let Some(dir) = stack.pop() { + for entry in fs::read_dir(&dir).unwrap().flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else if entry.file_name() == std::ffi::OsString::from(&name) { + let bytes = fs::read(&path).unwrap(); + return SidecarV1::from_canonical_slice(&bytes, SIDECAR_SCHEMA_V1) + .expect("the sidecar decodes"); + } + } + } + panic!("no sidecar for {asset_id}"); + } + + /// The derivatives directory for the bucket holding `asset_id`'s files. + fn derivatives_dir(root: &Path, asset_id: Uuid) -> std::path::PathBuf { + let mut stack = vec![root.join("media")]; + let name = format!("{}.cbor", asset_id.simple()); + while let Some(dir) = stack.pop() { + for entry in fs::read_dir(&dir).unwrap().flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else if entry.file_name() == std::ffi::OsString::from(&name) { + return dir.join("derivatives"); + } + } + } + panic!("no bucket for {asset_id}"); + } + + /// **The `S-B14` acceptance case, and the first production caller of `capsule_core::lqip`.** + /// + /// A decodable still imports with real pixel dimensions and a 32-byte chromahash placeholder + /// inside the *signed* sidecar, and the sidecar still verifies — the placeholder is + /// signature-covered, so producing it is a signature-visible change and has to be checked as + /// one. + #[test] + fn a_decodable_still_imports_with_pixel_dimensions_and_an_lqip() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (mut ws, album) = workspace(lib.path()); + + let receipt = import( + &mut ws, + album, + src.path(), + "photo.jpg", + &jpeg(320, 240, None), + ); + assert_eq!(receipt.derivatives, DerivativeStatus::Decoded); + assert_eq!( + receipt.deferred_formats, 2, + "the JXL master and the AVIF delivery variant have no encoder in this build" + ); + + let sidecar = sidecar_of(lib.path(), receipt.asset_id); + let dimensions = sidecar + .dimensions + .as_ref() + .expect("dimensions from decoded pixels"); + assert_eq!((dimensions.width, dimensions.height), (320, 240)); + assert_eq!( + sidecar.content_type, "image/jpeg", + "the content type is header-derived" + ); + + let lqip = sidecar.lqip.as_ref().expect("the LQIP producer ran"); + assert_eq!(lqip.chromahash.len(), 32, "DEFAULT_TIER is 32 bytes"); + assert_eq!(lqip.format_version, crate::lqip::LQIP_FORMAT_V1); + assert!( + Lqip::from_bytes(&lqip.chromahash).is_ok(), + "the stored payload is a structurally valid chromahash" + ); + + assert!( + sidecar.verify(&ws.user_ik_public()), + "the sidecar signature covers the placeholder it now carries" + ); + } + + /// The sidecar's dimensions are the **upright** ones. A quarter-turned JPEG's stored width + /// is its EXIF `PixelXDimension`, transposed relative to what a viewer shows, so taking the + /// decoded pixels rather than the tag is the whole point. + #[test] + fn a_rotated_still_records_upright_dimensions() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (mut ws, album) = workspace(lib.path()); + + let receipt = import( + &mut ws, + album, + src.path(), + "portrait.jpg", + &jpeg(320, 240, Some(6)), + ); + assert_eq!(receipt.derivatives, DerivativeStatus::Decoded); + + let sidecar = sidecar_of(lib.path(), receipt.asset_id); + let dimensions = sidecar.dimensions.as_ref().expect("dimensions"); + assert_eq!( + (dimensions.width, dimensions.height), + (240, 320), + "orientation 6 is a quarter-turn, so the sidecar records the transposed pair" + ); + } + + /// Thumbnail bytes and a signed manifest bundle land on disk, at the layout the upload + /// bundle reader already looks for, and the bundle re-verifies against the bytes beside it. + #[test] + fn an_import_persists_thumbnail_bytes_and_a_verifying_manifest_bundle() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (mut ws, album) = workspace(lib.path()); + + let receipt = import(&mut ws, album, src.path(), "big.png", &png(512, 384)); + assert_eq!(receipt.derivatives, DerivativeStatus::Decoded); + + let dir = derivatives_dir(lib.path(), receipt.asset_id); + let stem = receipt.asset_id.simple().to_string(); + let thumb = dir.join(format!("{stem}.thumbnail.webp")); + let bundle_path = dir.join(format!("{stem}.derivatives.cbor")); + assert!(thumb.is_file(), "thumbnail bytes at {}", thumb.display()); + assert!(bundle_path.is_file(), "a manifest bundle beside them"); + + let bytes = fs::read(&thumb).unwrap(); + let manifests: Vec = + cbor::from_slice(&fs::read(&bundle_path).unwrap()).expect("the bundle decodes"); + assert_eq!(manifests.len(), 1); + let core = &manifests[0].core; + assert_eq!( + core.ciphertext_hash, + hash::hash_bytes(&bytes), + "the signed manifest content-addresses the bytes on disk" + ); + assert_eq!(core.source_asset_id, receipt.asset_id); + assert_eq!( + verify_still_format(&manifests[0]), + Ok(Some(DerivativeFormat::WebP)), + "the persisted format is inside the closed set" + ); + + // The bytes really are a 256 px WebP. + let decoded = crate::media::RawshiftDecoder + .decode(&bytes, "webp") + .expect("the persisted thumbnail decodes"); + assert_eq!((decoded.width(), decoded.height()), (256, 192)); + } + + /// A still already inside the tier cap gets the signed `original` sentinel: the manifest + /// says `original` and the persisted bytes are the source's own, under the source's + /// extension. Distinct from an absent derivative, which means "rebuild me". + #[test] + fn a_small_still_persists_the_original_sentinel() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (mut ws, album) = workspace(lib.path()); + + let original = png(128, 96); + let receipt = import(&mut ws, album, src.path(), "small.png", &original); + assert_eq!(receipt.derivatives, DerivativeStatus::Decoded); + assert_eq!( + receipt.deferred_formats, 0, + "the sentinel satisfies the tier, so nothing was deferred" + ); + + let dir = derivatives_dir(lib.path(), receipt.asset_id); + let stem = receipt.asset_id.simple().to_string(); + let sentinel = dir.join(format!("{stem}.thumbnail.png")); + assert!( + sentinel.is_file(), + "the sentinel reuses the source extension" + ); + assert_eq!(fs::read(&sentinel).unwrap(), original); + + let manifests: Vec = + cbor::from_slice(&fs::read(dir.join(format!("{stem}.derivatives.cbor"))).unwrap()) + .expect("the bundle decodes"); + assert_eq!(manifests[0].core.format, "original"); + } + + /// A format with no codec here, and bytes that are no still at all: both import as signed, + /// verifiable originals, with EXIF-or-nothing dimensions, no placeholder, no derivative + /// files, and the reason recorded (slice `S-B13`). + #[test] + fn an_undecodable_original_still_imports_with_the_reason_recorded() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (mut ws, album) = workspace(lib.path()); + + // A real HEIC header (ISO-BMFF `ftyp heic`) with no payload: recognised, no codec. + let mut heic = vec![0, 0, 0, 0x20]; + heic.extend_from_slice(b"ftypheic"); + heic.extend_from_slice(&[0; 16]); + let deferred = import(&mut ws, album, src.path(), "shot.heic", &heic); + assert_eq!(deferred.derivatives, DerivativeStatus::DeferredNoCodec); + assert_eq!(deferred.deferred_formats, 0, "nothing was attempted"); + + let sidecar = sidecar_of(lib.path(), deferred.asset_id); + assert!(sidecar.lqip.is_none(), "no pixels, no placeholder"); + assert!( + sidecar.dimensions.is_none(), + "no pixels and no EXIF dimensions" + ); + assert_eq!( + sidecar.content_type, "image/heic", + "the header still names the format, codec or not" + ); + assert!( + !derivatives_dir(lib.path(), deferred.asset_id).exists(), + "no derivative directory is created for an asset with no derivatives" + ); + + // Not a still at all. + let video = import( + &mut ws, + album, + src.path(), + "clip.mp4", + b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42isom", + ); + assert_eq!(video.derivatives, DerivativeStatus::NotAKnownStill); + assert_eq!( + sidecar_of(lib.path(), video.asset_id).content_type, + "video/mp4", + "the extension table still types a video" + ); + + // Both are signed, encrypted, self-verifying backups regardless. + for id in [deferred.asset_id, video.asset_id] { + assert_eq!( + ws.verify(&id).unwrap(), + crate::crypto::verify_asset::VerifyOutcome::Accept + ); + } + } +} diff --git a/capsule-core/src/lifecycle/import.rs b/capsule-core/src/lifecycle/import.rs index 216a95ed..0ce4668b 100644 --- a/capsule-core/src/lifecycle/import.rs +++ b/capsule-core/src/lifecycle/import.rs @@ -8,6 +8,7 @@ use std::path::Path; use jiff::Timestamp; use uuid::Uuid; +use super::derivatives::PreparedStill; use super::{ AssetState, LifecycleError, Result, SidecarEnrichment, SignedImport, SignedImportOptions, StackPlacement, StreamedImport, Workspace, asset_is_deleted, media_dir, now_rfc3339, @@ -97,13 +98,19 @@ fn folded_gps(embedded: Option, folded: Option<&Gps>) -> Option { embedded.or_else(|| folded.cloned()) } +/// The sidecar `content_type` for a file whose bytes named no still image Capsule models. +/// +/// The **fallback only**: [`Workspace::prepare_still`] sniffs the header first, so a still's +/// media type comes from [`StillFormat::mime`](crate::media::StillFormat::mime) and a `.jpg` +/// that is really a HEIC is typed `image/heic`. What is left for this table is the non-still +/// suffixes — video above all, which has no detection path until slice `S-B5`. fn content_type_for(ext: &str) -> String { match ext { - "jpg" | "jpeg" => "image/jpeg", - "png" => "image/png", - "heic" => "image/heic", - "webp" => "image/webp", - "mp4" => "video/mp4", + "mp4" | "m4v" => "video/mp4", + "mov" | "qt" => "video/quicktime", + "mkv" => "video/x-matroska", + "webm" => "video/webm", + "avi" => "video/x-msvideo", _ => "application/octet-stream", } .to_string() @@ -303,12 +310,13 @@ impl Workspace { /// As [`import_asset`](Self::import_asset) but with executor-supplied [`SignedImportOptions`] /// (Move-mode source release + stack placement). This is the single signed write path the /// import executor drives (S-B2): every imported member lands as a signed `SidecarV1` + - /// manifest + append-only provenance, self-verified through [`verify_asset`], and — when a - /// still encoder is attached — with signed thumbnail/preview derivatives + an LQIP in the - /// sidecar. No still encoder exists in this build; the media stack is retired (`S-B1`). + /// manifest + append-only provenance, self-verified through [`verify_asset`], and — when the + /// still decodes — with a chromahash `lqip` in the sidecar and signed thumbnail derivatives + /// on disk ([`prepare_still`](Self::prepare_still), slices `S-B1`/`S-B14`). /// - /// Returns a [`SignedImport`]: the asset id, plus the [`DerivativeStatus`](super::DerivativeStatus) - /// saying whether derivatives were generated and, if not, why. A format this build has no + /// Returns a [`SignedImport`]: the asset id, the + /// [`DerivativeStatus`](super::DerivativeStatus) saying whether derivatives were generated + /// and, if not, why, and the per-format deferral count. A format this build has no /// codec for **still imports** — the original is the backup, the thumbnail is a bonus — so /// the status is a report, never a rejection (slice `S-B13`). #[tracing::instrument(skip_all, fields(album_id = %album_id, src = %src.display()))] @@ -382,19 +390,22 @@ impl Workspace { "import: sidecar metadata resolved" ); - // Still-derived sidecar metadata. Dimensions come from EXIF; there is **no decoder in - // this build** since `S-C59` retired `capsule_core::media`, so no still is decoded, no - // LQIP is computed and no derivatives are generated. The import proceeds regardless: the - // original is still backed up as a signed, encrypted blob, and `derivative_status` - // records the gap so it is reportable rather than silent (`S-B13`). Rawshift's - // replacement is what closes it. - let (dimensions, lqip, derivative_status) = ( - exif.width - .zip(exif.height) - .map(|(width, height)| Dimensions { width, height }), - None::, - super::DerivativeStatus::DeferredNoCodec, - ); + // Still-derived sidecar metadata, from one decode pass over the plaintext: the + // header-derived `content_type`, pixel `dimensions`, the chromahash `lqip`, and the + // signed thumbnail derivatives to persist once the asset's own files are durable. + // + // Never fatal. A still this build cannot decode — or cannot decode *these bytes* of — + // commits exactly as before: EXIF dimensions, no LQIP, no derivatives, and a + // `DerivativeStatus` recording which reason applied so the gap is reportable rather + // than silent (`S-B13`). + let PreparedStill { + format, + dimensions, + lqip, + derivatives, + deferred_formats, + status: derivative_status, + } = self.prepare_still(&plaintext, &ext, src, &exif, asset_id, album_id)?; let album = self.album(&album_id)?; let epoch = album.current_epoch; @@ -412,7 +423,9 @@ impl Workspace { hash: hash::hash_bytes(&plaintext), capture_timestamp: capture_rfc3339(capture_utc), import_timestamp: now_rfc3339(), - content_type: content_type_for(&ext), + // Header-derived wherever the bytes name a still Capsule models; the extension + // table is the fallback for everything else (video, unknown suffixes). + content_type: format.map_or_else(|| content_type_for(&ext), |f| f.mime().to_string()), dimensions, lqip, tags_user, @@ -512,6 +525,10 @@ impl Workspace { stack: opts.stack.as_ref().map(StackPlacement::from_membership), }; self.write_asset_files(&asset, &plaintext)?; + // After the asset's own files, and deliberately: a derivative is regenerable, so a + // failure to write one must never fail an import whose signed original is already + // durable. `persist_derivatives` logs and continues rather than returning. + self.persist_derivatives(&asset, &derivatives); self.index_asset_row(&asset)?; self.index_original_representation(&asset, plaintext.len())?; @@ -526,6 +543,7 @@ impl Workspace { Ok(SignedImport { asset_id, derivatives: derivative_status, + deferred_formats: deferred_formats as u32, }) } diff --git a/capsule-core/src/lifecycle/mod.rs b/capsule-core/src/lifecycle/mod.rs index 70dc741c..c610a4aa 100644 --- a/capsule-core/src/lifecycle/mod.rs +++ b/capsule-core/src/lifecycle/mod.rs @@ -26,6 +26,7 @@ mod album; mod backup; +mod derivatives; mod drops; mod groups; mod import; @@ -295,26 +296,34 @@ pub struct SignedImportOptions { /// is a real problem someone should look at. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum DerivativeStatus { - /// The still decoded: dimensions and LQIP came from real pixels, and signed derivatives - /// were generated if a still encoder is attached to the workspace. + /// The still decoded: `dimensions` and `lqip` came from real pixels, and the derivatives + /// this build can encode were generated and signed. **Independent of how many *formats* + /// deferred** — a decoded still whose JXL and AVIF variants have no encoder here is still + /// `Decoded`, because it has a renderable thumbnail. The per-format gap is counted + /// separately by + /// [`ImportExecutionSummary::deferred_format_count`](crate::import::ImportExecutionSummary::deferred_format_count). Decoded, - /// **Expected deferral.** This build links no codec for the asset's format — the - /// supported-image-format table lives in the retired media stack. The original is safely - /// backed up; dimensions fall back to EXIF and there is no LQIP or preview until the codec - /// lands, at which point derivatives can be backfilled from the stored original. Counted by + /// **Expected deferral.** This build links no codec for the asset's format — see + /// [`SUPPORTED_STILL_FORMATS`](crate::media::SUPPORTED_STILL_FORMATS) for what it does + /// link, and [`StillFormat`](crate::media::StillFormat) for what it recognises. The + /// original is safely backed up; dimensions fall back to EXIF and there is no LQIP or + /// thumbnail until the codec lands, at which point derivatives can be backfilled from the + /// stored original. Counted by /// [`ImportExecutionSummary::deferred_derivative_count`](crate::import::ImportExecutionSummary::deferred_derivative_count). /// - /// This build links no codecs at all — the media stack is retired to `legacy-review/` - /// (`S-B1`) — so every still it imports reports this. + /// What reaches here today: HEIC, AVIF and the RAW families, each needing a system library + /// (libheif, libdav1d) or an assembler the cross and cargo-ndk builds do not carry. DeferredNoCodec, /// **A real problem.** The format *is* one this build can decode, but these particular /// bytes did not decode — truncation, corruption, or a decoder bug. The original is still /// imported (the bytes are backed up verbatim, whatever they are), but this is worth /// investigating rather than shrugging at. DecodeFailed, - /// Nothing to decode: the extension names no still image this build models — a video, an - /// XMP sidecar, an unknown suffix, or an exotic RAW flavour the raw-image-format table has - /// no variant for. Video derivatives are generated on their own path. + /// Nothing to decode: neither the bytes' header nor the extension names a still image + /// Capsule models — a video, an XMP sidecar, an SVG, or an unknown suffix. Distinct from + /// [`DeferredNoCodec`](Self::DeferredNoCodec), which is a still whose codec is merely + /// absent and whose derivatives are therefore backfillable. Video derivatives are generated + /// on their own path (slice `S-B5`). NotAKnownStill, } @@ -339,6 +348,11 @@ pub struct SignedImport { pub asset_id: Uuid, /// Whether thumbnail/preview derivatives were generated, and if not, why. pub derivatives: DerivativeStatus, + /// How many `(tier, format)` pairs the tier table commits to and this build cannot encode + /// — the per-format half of the `S-B13` gap, which is orthogonal to + /// [`derivatives`](Self::derivatives): a `Decoded` asset can still carry deferred formats. + /// Zero when the still did not decode at all, because nothing was attempted. + pub deferred_formats: u32, } /// A streamed import: everything the [streaming window](crate::import::execute_streaming) needs From d968878a01af21af93ceba19669ecce23b181f95 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 23:05:35 -0400 Subject: [PATCH 054/243] feat(server): add the serve subcommand and the binary smoke test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `capsule-server serve` binds, says where it landed, and drains on a termination signal. The bound address is logged at INFO and also written to stdout as one `listening on ` line. That is not a duplicate: `--listen 127.0.0.1:0` is a request for the operating system to choose a port, and a caller that asked for that has no other way to learn which one it got — making them parse a log format `LOG_FORMAT` can change under them would be a contract nobody wrote down. `Shutdown::signals()` covers SIGINT and SIGTERM with a second one forcing, and the drain deadline defaults to Kynos's own 25 seconds, under the usual 30-second orchestrator window. TLS stays off: `failure-modes.md` is explicit that application servers do not terminate it, so Kynos's `tls` feature is not enabled and a certificate cannot be configured by accident. `tests/binary.rs` drives the process, which is the only way to assert what a binary does. It proves the four properties an in-process client cannot: the server binds and reports its port; `capsule-sdk`'s **generated** client reaches it over TCP and reads a `server-info` record whose published signing key is the one derived from the configured private key; an account registers and signs in while a wrong password is refused; and SIGTERM drains to exit 0. The three refusal cases assert the non-zero code and the message — no `VALKEY_URL` without `--memory` names the variable, `VALKEY_URL` set names Refs #401 --- capsule-server/src/cli.rs | 162 +++++++++++++++- capsule-server/tests/binary.rs | 338 +++++++++++++++++++++++++++++++++ 2 files changed, 493 insertions(+), 7 deletions(-) create mode 100644 capsule-server/tests/binary.rs diff --git a/capsule-server/src/cli.rs b/capsule-server/src/cli.rs index cc47e259..3550ab00 100644 --- a/capsule-server/src/cli.rs +++ b/capsule-server/src/cli.rs @@ -22,14 +22,18 @@ //! parsing a log line. `capsule-cli/tests/cull_round_trip.rs` has to set `RUST_LOG=off` to keep //! stdout parseable, which is the failure mode being avoided here. +use std::net::SocketAddr; use std::path::PathBuf; use std::process::ExitCode; -use clap::{Parser, Subcommand}; -use color_eyre::eyre::{Context as _, Result, bail}; +use clap::{Args, Parser, Subcommand}; +use color_eyre::eyre::{Context as _, Result, bail, eyre}; +use kynos::server::Server; +use kynos::server::shutdown::Shutdown; use tracing_subscriber::prelude::*; use tracing_subscriber::{EnvFilter, fmt}; +use crate::boot::{self, Assembled}; use crate::config::{Config, Demands, Environment, LogFormat, Overrides, ProcessEnvironment}; /// The exit code a configuration refusal produces. @@ -62,9 +66,41 @@ pub struct Cli { pub command: Command, } +/// Where a subcommand's state lives. +/// +/// Flattened into every subcommand that touches a store rather than declared once on [`Cli`], +/// because `gen-openapi` touches none and a global `--blob-root` would advertise otherwise. +#[derive(Debug, Args)] +pub struct BackendArgs { + /// The filesystem tree ciphertext blobs are written to (`BLOB_ROOT`). + #[arg(long, value_name = "PATH")] + pub blob_root: Option, + + /// Run on the in-memory adapters instead of Postgres and Valkey. + /// + /// A development profile, and an explicit act: a deployment that merely forgot `VALKEY_URL` + /// must fail closed rather than come up holding state it loses on the next restart. + #[arg(long)] + pub memory: bool, +} + /// The things this binary does. #[derive(Debug, Subcommand)] pub enum Command { + /// Accept requests until a termination signal, then drain. + Serve { + /// The address to bind (`SERVER_HOST`/`SERVER_PORT`, default `0.0.0.0:3000`). + /// + /// Port `0` asks the operating system to choose one, and the chosen address is written + /// to stdout — see [`serve`]. + #[arg(long, value_name = "HOST:PORT")] + listen: Option, + + /// Where state lives. + #[command(flatten)] + backend: BackendArgs, + }, + /// Emit the OpenAPI 3.2 document the SDK's client is generated from. /// /// Needs no database, no Valkey, no key material, no disk and no network: the router is @@ -84,9 +120,27 @@ impl Command { /// What this subcommand needs from the configuration. fn demands(&self) -> Demands { match self { + Self::Serve { .. } => Demands::Serve, Self::GenOpenapi { .. } => Demands::Nothing, } } + + /// What this subcommand overrides on the command line. + fn overrides(&self, config_file: Option) -> Overrides { + let mut overrides = Overrides { + config_file, + ..Overrides::default() + }; + match self { + Self::Serve { listen, backend } => { + overrides.listen = *listen; + overrides.blob_root.clone_from(&backend.blob_root); + overrides.memory = backend.memory; + } + Self::GenOpenapi { .. } => {} + } + overrides + } } /// Parse the command line, install the log stream, and do what was asked. @@ -100,10 +154,7 @@ impl Command { pub async fn run() -> Result { let cli = Cli::parse(); let environment = ProcessEnvironment; - let overrides = Overrides { - config_file: cli.config.clone(), - ..Overrides::default() - }; + let overrides = cli.command.overrides(cli.config.clone()); install_tracing(&environment, &overrides); @@ -119,6 +170,7 @@ pub async fn run() -> Result { }; match cli.command { + Command::Serve { .. } => serve(&config).await, // The document is a property of the router's types, so the configuration is loaded only // to refuse `--config` and is deliberately not logged: `mise run openapi-check-kynos` is // a check gate, and a settings dump on its stderr is noise in every CI log that runs it. @@ -129,6 +181,59 @@ pub async fn run() -> Result { } } +/// Assemble the server from `config`, logging what it came up on. +/// +/// Shared by every subcommand that needs a store, so the settings dump an operator reads after +/// an incident is written once and says the same thing whichever command produced it. +async fn assemble(config: &Config) -> Result { + tracing::debug!(?config, "loaded the configuration"); + Ok(boot::assemble(config).await?) +} + +/// Accept requests until a termination signal, then drain. +/// +/// # The bound address goes to stdout +/// +/// It is logged at `INFO` **and** written to stdout as one `listening on ` line. That is +/// not a duplicate: `--listen 127.0.0.1:0` is a request for the operating system to choose a +/// port, and a caller that asked for that has no other way to learn which one it got. Making +/// them parse a log format — which `LOG_FORMAT` can change under them — would be a contract +/// nobody wrote down. Rust's stdout is line-buffered, so the line is readable the moment it is +/// written. +/// +/// # No TLS +/// +/// `design/cryptography/failure-modes.md` is explicit that "application servers do not terminate +/// TLS", and scopes in-code TLS to the SDK client, LAN peering and server-to-server egress — +/// none of which is this listener. Kynos's `tls` feature stays off, so a certificate cannot be +/// configured by accident. +async fn serve(config: &Config) -> Result { + let assembled = assemble(config).await?; + let bound = Server::new(assembled.service()?) + .bind(config.listen) + // SIGINT and SIGTERM, with a second one forcing. Kynos keeps its listeners alive + // through the drain, so an impatient operator's second Ctrl-C is honoured rather than + // ignored. + .graceful_shutdown(Shutdown::signals()) + .shutdown_timeout(config.shutdown_timeout) + .max_connections(config.max_connections) + .prepare() + .await + .map_err(|error| eyre!("binding {}: {error}", config.listen))?; + + for address in bound.local_addrs() { + tracing::info!(%address, "listening"); + println!("listening on http://{address}"); + } + + bound + .serve() + .await + .map_err(|error| eyre!("serving: {error}"))?; + tracing::info!("drained and stopped"); + Ok(ExitCode::SUCCESS) +} + /// Install the log stream, on stderr. /// /// The format is read best-effort — [`Demands::Nothing`] never fails on a missing setting, and a @@ -237,11 +342,54 @@ mod tests { #[test] fn gen_openapi_defaults_to_the_committed_document() { let cli = Cli::parse_from(["capsule-server", "gen-openapi"]); - let Command::GenOpenapi { output, check } = cli.command; + let Command::GenOpenapi { output, check } = cli.command else { + panic!("that is the subcommand that was parsed") + }; assert_eq!(output, std::path::Path::new("capsule-server/openapi.json")); assert!(!check, "writing is the default; checking is opt-in"); } + #[test] + fn serve_carries_its_flags_into_the_overrides_the_loader_reads() { + // The command line's half of the precedence table. A flag that parsed but never reached + // `Config::load` would be a flag that silently does nothing. + let cli = Cli::parse_from([ + "capsule-server", + "serve", + "--memory", + "--listen", + "127.0.0.1:6000", + "--blob-root", + "/var/lib/capsule/blobs", + ]); + let overrides = cli.command.overrides(None); + assert!(overrides.memory); + assert_eq!( + overrides.listen, + Some("127.0.0.1:6000".parse().expect("a literal address parses")) + ); + assert_eq!( + overrides.blob_root.as_deref(), + Some(std::path::Path::new("/var/lib/capsule/blobs")) + ); + } + + #[test] + fn serving_demands_a_key_and_describing_the_router_demands_nothing() { + assert_eq!( + Cli::parse_from(["capsule-server", "serve"]) + .command + .demands(), + crate::config::Demands::Serve + ); + assert_eq!( + Cli::parse_from(["capsule-server", "gen-openapi"]) + .command + .demands(), + crate::config::Demands::Nothing + ); + } + #[test] fn a_config_path_is_accepted_by_the_parser_so_it_can_be_refused_by_the_loader() { let cli = Cli::parse_from([ diff --git a/capsule-server/tests/binary.rs b/capsule-server/tests/binary.rs new file mode 100644 index 00000000..78ac3098 --- /dev/null +++ b/capsule-server/tests/binary.rs @@ -0,0 +1,338 @@ +//! The `capsule-server` binary, driven as a process (issue #401). +//! +//! # Why a subprocess when everything else here is in-process +//! +//! Every other case in this suite drives a built `Service` through +//! `kynos::test::TestClient` — no socket, no port, nothing to flake — and that is the right +//! shape for asserting what the server *decides*. It cannot assert anything about the +//! **binary**, and the binary is what this issue delivers. Four properties only a process has: +//! +//! - it **binds**, and on `--listen 127.0.0.1:0` it says which port it got, so a caller that +//! asked the operating system to choose one can find it; +//! - it **drains on SIGTERM and exits 0**, which is the contract an orchestrator's termination +//! window is written against; +//! - it **refuses to start** on a bad configuration, with a non-zero code and every fault named +//! once — the aggregate report exists for an operator reading a crash loop's logs; +//! - and a real client reaches it over TCP. The client is `capsule-sdk`'s, generated from the +//! committed `openapi.json`, which makes this the round trip `tests/sdk_client.rs` proves for +//! the router proved for the *binary*: the document, the generated client, the socket and the +//! composition root all agreeing at once. +//! +//! # Sending the signal +//! +//! `kill -TERM` through a subprocess rather than `libc::kill`. `libc` is not a dependency of +//! this crate and adding one so a test can send a signal would be a dependency in the binary's +//! own tree; `Child::kill` is `SIGKILL`, which is the one signal that proves nothing about a +//! graceful drain. The signal cases are `#[cfg(unix)]`; Windows's console-event equivalent is +//! not something a test can raise in a child process. + +#![cfg(unix)] + +use std::io::{BufRead as _, BufReader}; +use std::process::{Child, Command, Stdio}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use capsule_server::auth::SessionTokens; +use capsule_server::store::SystemClock; + +/// A PKCS#8 v1 Ed25519 key, base64. +/// +/// The retired deployment's own `.env.example` value, and it signs nothing anywhere: no +/// deployment ever used it. A committed key rather than a generated one because these cases +/// assert the **published** key is the one the tokens verify under, which needs a key both +/// sides can name. +const EXAMPLE_DER: &str = "MC4CAQAwBQYDK2VwBCIEIN6eTvXEL7xMZWHY8rTk7VbQSGSuRkle5MVfiiYUStLF"; + +/// The address the operating system will pick a port under. +const EPHEMERAL: &str = "127.0.0.1:0"; + +/// How long to wait for a spawned server to say where it is listening. +/// +/// Generous: a debug-build first run pays for `Credentials::new`'s Argon2id decoy hash before it +/// binds, and a loaded CI machine can take a while over it. A timeout here fails the test with +/// the child's own output rather than hanging the suite. +const BIND_TIMEOUT: Duration = Duration::from_secs(60); + +/// How long to wait for a signalled server to exit. +const EXIT_TIMEOUT: Duration = Duration::from_secs(30); + +/// A `capsule-server` invocation with a clean environment. +/// +/// Every setting this binary reads is removed before anything is set, because the test runner's +/// own environment is not this test's to trust: a developer with `DATABASE_URL` exported would +/// otherwise see a different server from CI. +fn server(args: &[&str]) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_capsule-server")); + for key in [ + "BLOB_ROOT", + "UPLOAD_DIR", + "DATABASE_URL", + "VALKEY_URL", + "JWT_ED25519_DER", + "SYNC_CURSOR_MAC_KEY", + "ATTESTATION_KEY_SEED", + "SERVER_HOST", + "SERVER_PORT", + "SERVER_DOMAIN", + "API_BASE_URL", + "CAPSULE_PROFILE", + "PROTOCOL_MIN", + "PROTOCOL_MAX", + "GC_GRACE_WINDOW_HOURS", + "SHUTDOWN_TIMEOUT_SECONDS", + "MAX_CONNECTIONS", + "LOG_FORMAT", + ] { + command.env_remove(key); + } + // Errors and the bind line only. A debug-build default of `debug` would put a few hundred + // lines of module chatter into the failure output of every case here. + command.env("RUST_LOG", "warn"); + command.args(args); + command +} + +/// Run `args` to completion and return `(exit code, stdout, stderr)`. +fn run(command: &mut Command) -> (Option, String, String) { + let output = command + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .expect("the binary runs"); + ( + output.status.code(), + String::from_utf8_lossy(&output.stdout).into_owned(), + String::from_utf8_lossy(&output.stderr).into_owned(), + ) +} + +/// A running server, and the base URL it answers on. +struct Serving { + child: Child, + base_url: String, +} + +impl Serving { + /// Spawn `serve --memory` on an ephemeral port and wait for it to say where it landed. + fn spawn(blob_root: &std::path::Path) -> Self { + let mut child = server(&[ + "serve", + "--memory", + "--listen", + EPHEMERAL, + "--blob-root", + &blob_root.display().to_string(), + ]) + .env("JWT_ED25519_DER", EXAMPLE_DER) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("the binary spawns"); + + let stdout = child.stdout.take().expect("stdout is piped"); + let mut lines = BufReader::new(stdout).lines(); + let deadline = Instant::now() + BIND_TIMEOUT; + // A blocking read on the child's pipe. The runtime this test is on has nothing else to + // do until the address arrives, and the child is a separate process. + let address = loop { + assert!( + Instant::now() < deadline, + "the server did not report a bound address within {BIND_TIMEOUT:?}" + ); + let line = lines + .next() + .expect("the server closed stdout without reporting an address") + .expect("the line is readable"); + if let Some(address) = line.strip_prefix("listening on ") { + break address.to_owned(); + } + }; + + Self { + child, + base_url: address, + } + } + + /// Ask it to stop the way an orchestrator does, and return its exit code. + fn terminate(mut self) -> Option { + let status = Command::new("kill") + .args(["-TERM", &self.child.id().to_string()]) + .status() + .expect("kill runs"); + assert!(status.success(), "the signal was delivered"); + + let deadline = Instant::now() + EXIT_TIMEOUT; + loop { + match self.child.try_wait().expect("the child is waitable") { + Some(status) => return status.code(), + None => { + assert!( + Instant::now() < deadline, + "the server did not exit within {EXIT_TIMEOUT:?} of SIGTERM" + ); + std::thread::sleep(Duration::from_millis(25)); + } + } + } + } +} + +impl Drop for Serving { + fn drop(&mut self) { + // A case that panicked before `terminate` must not leave a listener behind for the next + // one. `SIGKILL` is right here: the assertion has already failed and a drain would only + // delay the report. + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +#[tokio::test] +async fn it_binds_serves_the_generated_client_and_drains_on_sigterm() { + let root = tempfile::tempdir().expect("a scratch directory"); + let serving = Serving::spawn(root.path()); + + // The generated client, over reqwest, over TCP, against the binary. Nothing in this call is + // hand-written: the path, the response shape and the decoding all come from the committed + // `openapi.json`. + let client = capsule_sdk::rest::Client::new(&serving.base_url).expect("a base url"); + let published = client + .server_info() + .await + .expect("the record is served") + .into_inner(); + + // The signing key it publishes is derived from the configured private key, so an operator + // cannot publish one the tokens do not verify under. + let expected = SessionTokens::from_pkcs8( + &base64::Engine::decode(&base64::engine::general_purpose::STANDARD, EXAMPLE_DER) + .expect("the example key is base64"), + Arc::new(SystemClock), + ) + .expect("the example key parses") + .public_key() + .to_vec(); + assert_eq!( + published.signing_key, + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &expected) + ); + // The published login endpoint is one this server actually serves, which is the property + // `api_base_url` exists to keep: it is composed from the configuration, not pasted. + assert!( + published.auth.login.ends_with("/v1/auth/login"), + "{}", + published.auth.login + ); + + assert_eq!( + serving.terminate(), + Some(0), + "a drained shutdown is a successful one" + ); +} + +#[tokio::test] +async fn an_account_registers_and_signs_in_against_the_running_binary() { + // The whole reason the development profile ships a real account adapter rather than a + // fail-closed stub: `mise run serve-memory` is a server a client can be pointed at. + let root = tempfile::tempdir().expect("a scratch directory"); + let serving = Serving::spawn(root.path()); + + let auth = capsule_sdk::auth::AuthClient::new(&format!("{}/v1/auth", serving.base_url)) + .expect("a base url"); + auth.register("somebody@example.test", "correct horse battery staple") + .await + .expect("registration succeeds"); + let signed_in = auth + .login("somebody@example.test", "correct horse battery staple") + .await + .expect("the account signs in") + .into_session(); + assert!( + signed_in.is_ok(), + "a fresh account has no second factor to answer" + ); + + // And the credential is actually checked — this is not the permissive double. + let refused = auth + .login("somebody@example.test", "the wrong password entirely") + .await; + assert!(refused.is_err(), "a wrong password is refused"); + + assert_eq!(serving.terminate(), Some(0)); +} + +#[test] +fn serving_without_valkey_and_without_the_memory_profile_refuses_by_name() { + // `store/mod.rs` has documented this refusal since `S-C29` and nothing enforced it, because + // there was no boot path to enforce it in. + let root = tempfile::tempdir().expect("a scratch directory"); + let (code, _, stderr) = run(&mut server(&[ + "serve", + "--listen", + EPHEMERAL, + "--blob-root", + &root.path().display().to_string(), + ]) + .env("JWT_ED25519_DER", EXAMPLE_DER)); + assert_eq!(code, Some(2), "{stderr}"); + assert!(stderr.contains("VALKEY_URL"), "{stderr}"); +} + +#[test] +fn a_durable_backend_refuses_with_the_issue_that_will_honour_it() { + // The other half: the operator *did* set `VALKEY_URL`, and nothing reads it yet. Falling + // back to the in-memory adapters here is the one thing that must never happen. + let root = tempfile::tempdir().expect("a scratch directory"); + let (code, _, stderr) = run(&mut server(&[ + "serve", + "--listen", + EPHEMERAL, + "--blob-root", + &root.path().display().to_string(), + ]) + .env("JWT_ED25519_DER", EXAMPLE_DER) + .env("VALKEY_URL", "redis://127.0.0.1:6379")); + assert_ne!(code, Some(0), "{stderr}"); + assert!(stderr.contains("#403"), "{stderr}"); +} + +#[test] +fn every_missing_setting_is_named_in_one_message() { + // An operator reading a crash loop's logs learns about both at once rather than restarting + // the process to discover the second. + let (code, _, stderr) = run(&mut server(&["serve", "--memory", "--listen", EPHEMERAL])); + assert_eq!(code, Some(2), "{stderr}"); + assert!(stderr.contains("BLOB_ROOT"), "{stderr}"); + assert!(stderr.contains("JWT_ED25519_DER"), "{stderr}"); +} + +#[test] +fn a_config_file_is_refused_with_what_to_do_instead() { + let (code, _, stderr) = run(&mut server(&[ + "--config", + "/etc/capsule/server.toml", + "gen-openapi", + "--check", + ])); + assert_eq!(code, Some(2), "{stderr}"); + assert!(stderr.contains("not supported yet"), "{stderr}"); + assert!(stderr.contains("environment"), "{stderr}"); +} + +#[test] +fn the_committed_openapi_document_is_reproduced_byte_for_byte() { + // The gate `mise run openapi-check-kynos` runs, asserted here too so a change to the + // subcommand's own plumbing cannot quietly stop checking anything. `CARGO_MANIFEST_DIR` is + // `capsule-server/`, and the default output path is relative to the repo root. + let committed = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("openapi.json"); + let (code, stdout, stderr) = run(&mut server(&[ + "gen-openapi", + &committed.display().to_string(), + "--check", + ])); + assert_eq!(code, Some(0), "stdout: {stdout}\nstderr: {stderr}"); + assert!(stdout.contains("up to date"), "{stdout}"); +} From 49d208490a2ea2b8ca5349dc5825c5e3c42569a3 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 23:08:04 -0400 Subject: [PATCH 055/243] fix(sdk): classify escrow failures by what they are, and assert the route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects an adversarial read of the previous commit turned up. **An unreachable server was reported as an expired session.** reqwest builds every failure of the request it executes with `error::request(..)`, so `is_request()` is true for connection-refused, DNS and TLS failures, and spargen's taxonomy files all of them under `RequestConstruction` next to the genuine pre-flight ones. Mapping that class to `Unauthorized` therefore told an offline device to sign in again — the one remedy that cannot work without a network. The source discriminates them instead: a bearer provider that could not mint a token is boxed as the generated runtime's own `AuthError`, and nothing else on this path is. A closed port is now `Transport`, with a test that binds a socket, drops it, and points the client at the address. **`error_code()` guessed where it should have read.** A `400` reported `error.escrow.malformed` even when the server said otherwise; a `413` reported it too, so a client localizing the code would tell a user their recovery blob was corrupt when it was merely too large; and the `500`'s `error.escrow.unavailable` — the one code that route bothers to set — was thrown away into a transport string. `Malformed` now carries the server's own code, `413` carries `error.request.too_large`, and `500` is its own `Unavailable` variant. The mocks stop inventing `error.*` strings that exist in no catalog and use `capsule_i18n::error_codes` throughout. `FfiError::Escrow` gains the `code` the enum's own doc already promised every variant carries, so `error.escrow.not_stored` reaches a native client — the distinction between "set up a recovery key" and "we could not read the one you have". **The route was pinned by accident.** The socket test relied on a wrong path producing something other than `NotEnrolled`, which held only because Kynos's unmatched-route `404` carries no code and therefore fails to parse. It now asserts the route directly through the fixture's in-process client: what the SDK stored is read back at `/v1/auth/escrow`, and a rotation seeded at that path is what the SDK fetches next. A client on any other path satisfies neither. Relatedly, an uncoded `404` is deliberately *not* read as `NotEnrolled` any more. Reading every `404` as "this account has escrowed nothing" is precisely what let a wrong route look like an empty escrow for a whole slice; an intermediary answering `404 text/html` is a broken path, not an enrollment state. Refs #408 --- capsule-sdk/src/ffi.rs | 20 ++- capsule-sdk/src/recovery/mod.rs | 261 +++++++++++++++++++++++------ capsule-server/tests/sdk_client.rs | 62 ++++++- 3 files changed, 281 insertions(+), 62 deletions(-) diff --git a/capsule-sdk/src/ffi.rs b/capsule-sdk/src/ffi.rs index 7ba6c04d..9519bf66 100644 --- a/capsule-sdk/src/ffi.rs +++ b/capsule-sdk/src/ffi.rs @@ -99,8 +99,12 @@ pub enum FfiError { message: String, }, /// A master-key escrow flow (store/fetch) failed. - #[error("escrow failed: {message}")] + #[error("escrow failed ({code:?}): {message}")] Escrow { + /// Stable `error.*` catalog code, when the server supplied one — `error.escrow.*` + /// separates "you have no recovery backup" (a setup prompt) from "we could not read + /// it" (a retry), which is the whole reason the catalog distinguishes them. + code: Option, /// English detail (developer/log message). message: String, }, @@ -149,6 +153,7 @@ impl From for FfiError { }; } Self::Escrow { + code: err.error_code().map(str::to_owned), message: err.to_string(), } } @@ -779,7 +784,10 @@ impl FfiSession { /// secret a rotation retired unwraps nothing. /// /// `api_base_url` is the API root the session authenticates against (the per-call endpoint - /// convention this surface already uses for `sync_pull`). + /// convention this surface already uses for `sync_pull`). A URL operation paths cannot hang + /// off is [`FfiError::InvalidArgument`]; a refused credential is [`FfiError::Auth`], so a + /// caller re-authenticates rather than retrying; everything else is + /// [`FfiError::Escrow`] with the server's `error.escrow.*` code when it sent one. pub async fn escrow_put(&self, api_base_url: String, blob: Vec) -> Result<(), FfiError> { let blob = capsule_core::cbor::from_slice(&blob).map_err(|e| FfiError::InvalidArgument { @@ -793,12 +801,18 @@ impl FfiSession { /// Fetch this account's escrow blob (`GET /v1/auth/escrow`) as opaque canonical CBOR — the /// bytes [`FfiWorkspace::verify_escrow_blob`](FfiWorkspace::verify_escrow_blob) checks and - /// a recovery flow unwraps. Fails with an `Escrow` error when no escrow is enrolled yet. + /// a recovery flow unwraps. + /// + /// No escrow enrolled yet is [`FfiError::Escrow`] carrying `error.escrow.not_stored` — the + /// code that separates "set up a recovery key" from "we could not read the one you have". + /// A refused credential is [`FfiError::Auth`]. pub async fn escrow_get(&self, api_base_url: String) -> Result, FfiError> { let cache = RecoveryClient::new(self.session.clone(), &api_base_url)? .fetch_escrow() .await?; capsule_core::cbor::to_canonical_vec(cache.blob()).map_err(|e| FfiError::Escrow { + // A local encode failure is ours, not the server's: no catalog code applies. + code: None, message: format!("encoding the fetched escrow blob failed: {e}"), }) } diff --git a/capsule-sdk/src/recovery/mod.rs b/capsule-sdk/src/recovery/mod.rs index 1f106df0..8b5b8526 100644 --- a/capsule-sdk/src/recovery/mod.rs +++ b/capsule-sdk/src/recovery/mod.rs @@ -74,28 +74,54 @@ pub enum RecoveryError { /// Why the generated client rejected it. reason: String, }, - /// The call did not complete: DNS, TLS, timeout, a malformed response, or the store - /// answering `500`. Transient — the cadence's next tick tries again. + /// The call never reached a server answer: DNS, connection refused, a TLS handshake, a + /// timeout, a reset mid-body, or a refusal this client could not parse. Transient — the + /// cadence's next tick tries again. + /// + /// Note that reqwest classifies *every* failure of the request it executes as a request + /// error, so the generated taxonomy's `RequestConstruction` class carries connection + /// failures as well as genuine pre-flight ones; both land here. #[error("the escrow endpoint could not be reached: {0}")] Transport(String), - /// The credential was refused (`401`/`403`) and a refresh did not recover it. The stable - /// code distinguishes an expired session from the outage the revocation ledger also - /// renders as `401`, so a client can tell "sign in again" from "try later". + /// The credential was refused (`401`/`403`) and a refresh did not recover it — the user + /// must re-authenticate. + /// + /// `code` is whatever the server stamped on the problem body. Today Capsule stamps one + /// code (`error.request.unauthenticated`) on every `401` it renders, so this does not yet + /// separate an expired token from an unreadable revocation ledger; the field carries the + /// code so that it will the moment the server distinguishes them. #[error("the escrow endpoint refused the credential: {detail}")] Unauthorized { - /// The stable `error.*` catalog code the problem body carried, when it had one. + /// The stable `error.*` catalog code from the problem body, when the failure came + /// with one. `None` when the credential could not be produced at all — there was no + /// server answer to carry a code. code: Option, /// English detail from the problem body. detail: String, }, - /// The caller has no escrow stored yet (server returned `404`). Enroll one first. + /// The caller has no escrow stored yet (server returned a coded `404`). Enroll one first. #[error("no escrow stored for this account")] NotEnrolled, - /// The server refused the blob as one that cannot be an escrow at any version — empty, - /// past the coarse ceiling (`400`), or not the declared media type (`415`). Retrying the - /// same bytes changes nothing. - #[error("the server rejected the escrow blob as malformed: {0}")] - Malformed(String), + /// The server refused the blob as one that cannot be an escrow at any version — empty or + /// past the coarse ceiling (`400`), not the declared media type (`415`), or past the + /// transport's body limit (`413`). Retrying the same bytes changes nothing. + #[error("the server rejected the escrow blob: {detail}")] + Malformed { + /// The stable `error.*` catalog code the refusal carried. + code: Option, + /// English detail from the problem body. + detail: String, + }, + /// The escrow store could not answer (`500`). Transient, and coded + /// `error.escrow.unavailable` — which is why it is not folded into + /// [`Transport`](RecoveryError::Transport): a caller that localizes codes has one to show. + #[error("the escrow store could not answer: {detail}")] + Unavailable { + /// The stable `error.*` catalog code the refusal carried. + code: Option, + /// English detail from the problem body. + detail: String, + }, /// The escrow bytes could not be (de)serialized as the canonical `WrappedSecret`. #[error("escrow blob codec error: {0}")] Codec(String), @@ -116,9 +142,13 @@ impl RecoveryError { #[must_use] pub fn error_code(&self) -> Option<&str> { match self { - Self::Unauthorized { code, .. } => code.as_deref(), + Self::Unauthorized { code, .. } + | Self::Malformed { code, .. } + | Self::Unavailable { code, .. } => code.as_deref(), + // The one code this module states rather than reads. `NotEnrolled` is a *state* + // ("this account has escrowed nothing"), not a message, and the server's own code + // for that state is this constant — see `capsule-server/src/routes/escrow.rs`. Self::NotEnrolled => Some(error_codes::ESCROW_NOT_STORED), - Self::Malformed(_) => Some(error_codes::ESCROW_MALFORMED), _ => None, } } @@ -270,7 +300,10 @@ pub struct GuidedRewrap { /// It holds one [`AuthenticatedClient`], so every call rides the generated operation paths /// and the SDK's bearer/refresh machinery, and this module states no route of its own. The /// client is behind an [`Arc`] only so [`RecoveryClient`] stays [`Clone`] — the cadence hands -/// one client to several prompts. +/// one client to several prompts. There is deliberately no repoint/session-swap accessor: +/// `AuthenticatedClient`'s own take `&mut self` and are unreachable through the `Arc`, and an +/// escrow client that changed origin mid-cadence would be a way to pull one account's escrow +/// into another's cache. Build a new one instead. #[derive(Clone)] pub struct RecoveryClient { client: Arc, @@ -433,16 +466,17 @@ impl RecoveryClient { /// Map a `GET /v1/auth/escrow` refusal onto its typed variant. /// -/// Kept as one readable status table rather than a match buried in the request path, and kept -/// exhaustive over the generated enum so a status the document gains cannot be silently -/// swallowed — adding one stops the build here. +/// One readable status table rather than a match buried in the request path. The *inner* +/// match is exhaustive over the generated enum, so a status the document adds to this +/// operation stops the build here; a new `rest::Error` **class** still falls through to +/// [`wire_error`]'s catch-all. fn fetch_escrow_error(error: rest::Error) -> RecoveryError { match error { rest::Error::Api(response) => match response.into_inner() { rest::FetchEscrowError::Status404(_) => RecoveryError::NotEnrolled, rest::FetchEscrowError::Status401(problem) | rest::FetchEscrowError::Status403(problem) => refused(&problem), - rest::FetchEscrowError::Status500(problem) => transport(&problem), + rest::FetchEscrowError::Status500(problem) => unavailable(&problem), // Declared by the transport backstop and unreachable on a body-less `GET`; kept // honest rather than folded into a class it does not belong to. rest::FetchEscrowError::Status413 => RecoveryError::Unexpected { status: 413 }, @@ -458,22 +492,28 @@ fn store_escrow_error(error: rest::Error) -> RecoveryErr // `400` and `415` are the same answer to the caller: these bytes are not an // escrow, and sending them again will not help. rest::StoreEscrowError::Status400(problem) - | rest::StoreEscrowError::Status415(problem) => { - RecoveryError::Malformed(detail(&problem)) - } + | rest::StoreEscrowError::Status415(problem) => RecoveryError::Malformed { + code: Some(problem.code.clone()), + detail: detail(&problem), + }, rest::StoreEscrowError::Status401(problem) | rest::StoreEscrowError::Status403(problem) => refused(&problem), - rest::StoreEscrowError::Status500(problem) => transport(&problem), - // The body-size backstop carries no problem body at all, so the message is ours. - rest::StoreEscrowError::Status413 => RecoveryError::Malformed( - "the escrow blob exceeds the server's request-body limit".to_owned(), - ), + rest::StoreEscrowError::Status500(problem) => unavailable(&problem), + // The body-size backstop carries no problem body at all, so both the code and the + // message are ours. It is `error.request.too_large` and not + // `error.escrow.malformed`: a client localizing the latter would tell the user + // their recovery blob is corrupt when it is merely too big. + rest::StoreEscrowError::Status413 => RecoveryError::Malformed { + code: Some(error_codes::REQUEST_TOO_LARGE.to_owned()), + detail: "the escrow blob exceeds the server's request-body limit".to_owned(), + }, }, other => wire_error(&other), } } -/// A refused credential, carrying the problem body's stable code. +/// A refused credential, carrying the problem body's stable code. `CodedProblem.code` is a +/// required member, so a refusal that parsed always has one. fn refused(problem: &rest::types::CodedProblem) -> RecoveryError { RecoveryError::Unauthorized { code: Some(problem.code.clone()), @@ -482,8 +522,11 @@ fn refused(problem: &rest::types::CodedProblem) -> RecoveryError { } /// The store could not answer — transient, and the caller's cadence retries. -fn transport(problem: &rest::types::CodedProblem) -> RecoveryError { - RecoveryError::Transport(detail(problem)) +fn unavailable(problem: &rest::types::CodedProblem) -> RecoveryError { + RecoveryError::Unavailable { + code: Some(problem.code.clone()), + detail: detail(problem), + } } /// The problem body's English detail, or its code when the server sent no detail. @@ -504,24 +547,47 @@ where rest::Error::UnexpectedStatus { status, .. } => RecoveryError::Unexpected { status: status.as_u16(), }, - // Both escrow operations take no path parameter, no query parameter and (for the - // store) a body that cannot fail to serialize, and the base URL was parsed when the - // client was built. So the *only* way either can fail before a byte leaves is the - // bearer credential's async provider — a session that cannot produce a token. That is - // the same event the server answers `401` for, and it must reach a caller as one: - // reporting a dead session as a transport blip would tell a client to retry where it - // needs to re-authenticate. - rest::Error::RequestConstruction(_) => RecoveryError::Unauthorized { - code: None, - detail: describe(error), - }, + // `RequestConstruction` is **not** a pre-flight-only class. reqwest builds every + // failure of the request it executes with `error::request(..)`, so `is_request()` is + // true for connection-refused, DNS and TLS failures too, and the generated taxonomy + // routes all of them here alongside the genuine pre-flight ones. Splitting them by + // class alone would report an unreachable server as "sign in again", which on an + // offline device is the one remedy that cannot work. + // + // The *source* discriminates them: a bearer provider that could not produce a token is + // boxed as the generated runtime's own `AuthError`, and nothing else on this path is. + // A dead session must reach a caller as an auth failure rather than as a transport + // blip, because the two have opposite remedies. + rest::Error::RequestConstruction(inner) => { + if std::error::Error::source(inner) + .and_then(|source| source.downcast_ref::()) + .is_some() + { + RecoveryError::Unauthorized { + code: None, + detail: describe(error), + } + } else { + RecoveryError::Transport(describe(error)) + } + } + // A *declared* refusal whose body was not the coded problem the document promises. + // Deliberately a wire failure and not [`RecoveryError::NotEnrolled`], even though an + // uncoded `404` is its commonest shape: reading any `404` as "this account has + // escrowed nothing" is exactly what let a wrong route look like an empty escrow for a + // whole slice. An intermediary answering `404 text/html` is a broken path, not an + // enrollment state. + rest::Error::Decode { path, .. } => RecoveryError::Transport(format!( + "the escrow endpoint answered a refusal this client could not parse ({path})" + )), other => RecoveryError::Transport(describe(other)), } } /// Render a generated-client failure together with its source chain. The taxonomy's own -/// `Display` is a one-word class name (`"transport failed"`), which on its own tells a log -/// reader nothing about *what* failed. +/// `Display` names the class and, for the wire classes, little else (`"transport failed"`, +/// `"request construction failed"`) — the source chain is where the reqwest/hyper reason a log +/// reader needs actually lives. fn describe(error: &rest::Error) -> String where E: std::error::Error + 'static, @@ -703,7 +769,11 @@ mod tests { let response = if authorized { handler(MockRequest { method, path, body }).await } else { - MockResponse::problem(401, "error.auth.unauthorized", "no bearer credential") + MockResponse::problem( + 401, + error_codes::REQUEST_UNAUTHENTICATED, + "no bearer credential", + ) }; let payload = format!( @@ -753,7 +823,7 @@ mod tests { }, _ => MockResponse::problem( 405, - error_codes::ESCROW_MALFORMED, + error_codes::REQUEST_METHOD_NOT_ALLOWED, "the escrow surface serves GET and PUT", ), } @@ -998,10 +1068,11 @@ mod tests { ); } - /// A `400` refusal becomes the typed `Malformed` and carries the code a client localizes - /// — not the `Unexpected { status }` the hand-written path used to collapse it into. + /// A `400` refusal becomes the typed `Malformed` and carries **the server's own** code — + /// not the `Unexpected { status }` the hand-written path used to collapse it into, and not + /// a constant this module guessed. #[tokio::test] - async fn a_refused_blob_is_malformed_with_its_catalog_code() { + async fn a_refused_blob_is_malformed_with_the_servers_code() { let handler: Handler = Arc::new(|_req| { Box::pin(async move { MockResponse::problem( @@ -1018,20 +1089,101 @@ mod tests { .await .expect_err("the server refused the blob"); assert!( - matches!(error, RecoveryError::Malformed(_)), + matches!(error, RecoveryError::Malformed { .. }), "got {error:?}" ); assert_eq!(error.error_code(), Some(error_codes::ESCROW_MALFORMED)); } - /// A refused credential keeps the problem body's `error.auth.*` code, so a client can - /// tell an expired session from the outage the revocation ledger also renders as `401`. + /// The store answering `500` is its own variant carrying `error.escrow.unavailable`, not a + /// bare transport failure: a client that localizes codes has one to show, and the cadence + /// can tell "the server is unwell" from "the network is gone". + #[tokio::test] + async fn an_unavailable_store_keeps_its_catalog_code() { + let handler: Handler = Arc::new(|_req| { + Box::pin(async move { + MockResponse::problem( + 500, + error_codes::ESCROW_UNAVAILABLE, + "the escrow could not be read", + ) + }) + }); + let base = start_mock(handler).await; + let client = RecoveryClient::new(session_for(&base), &base).unwrap(); + let error = client.fetch_escrow().await.expect_err("the store is down"); + assert!( + matches!(error, RecoveryError::Unavailable { .. }), + "got {error:?}" + ); + assert_eq!(error.error_code(), Some(error_codes::ESCROW_UNAVAILABLE)); + } + + /// **An unreachable server is not an expired session.** reqwest reports a refused + /// connection as a *request* error, which the generated taxonomy files under + /// `RequestConstruction` next to the genuine pre-flight failures — so classifying that + /// whole class as an auth failure would tell an offline device to sign in again, the one + /// remedy that cannot work without a network. + #[tokio::test] + async fn an_unreachable_endpoint_is_a_transport_failure_not_an_auth_one() { + // Bind, read the port, then drop the listener: the address is now certain to refuse. + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + drop(listener); + + // The session is built against a live mock, so the session itself is healthy and the + // only thing wrong is the escrow origin. + let live = start_mock(escrow_handler(EscrowStore::default())).await; + let client = RecoveryClient::new(session_for(&live), &format!("http://{addr}")).unwrap(); + + let error = client + .fetch_escrow() + .await + .expect_err("nothing is listening there"); + assert!( + matches!(error, RecoveryError::Transport(_)), + "an unreachable endpoint must be transport, got {error:?}" + ); + assert_eq!(error.error_code(), None); + } + + /// A refusal whose body is not the coded problem the document promises — an intermediary + /// answering a bare `404`, say — is a broken path, not an empty escrow. + /// + /// This is the deliberate class change the route fix rests on: the hand-written client + /// read *any* `404` as `NotEnrolled`, which is exactly why a wrong route looked like an + /// account that had escrowed nothing. + #[tokio::test] + async fn an_uncoded_404_is_not_read_as_an_empty_escrow() { + let handler: Handler = Arc::new(|_req| { + Box::pin(async move { MockResponse::bytes(404, b"not found".to_vec()) }) + }); + let base = start_mock(handler).await; + let client = RecoveryClient::new(session_for(&base), &base).unwrap(); + let error = client + .fetch_escrow() + .await + .expect_err("an unparseable refusal is not an enrollment state"); + assert!( + matches!(error, RecoveryError::Transport(_)), + "got {error:?}" + ); + } + + /// A refused credential carries the problem body's code straight through, so a client + /// localizes what the *server* said rather than what this module assumed. #[tokio::test] async fn a_refused_credential_keeps_the_problem_code() { // No bearer reaches the mock's handler at all: it answers `401` at the door, which is // precisely the shape a revoked token produces. let handler: Handler = Arc::new(|_req| { - Box::pin(async move { MockResponse::problem(401, "error.auth.expired", "expired") }) + Box::pin(async move { + MockResponse::problem( + 401, + error_codes::REQUEST_UNAUTHENTICATED, + "the access token was refused", + ) + }) }); let base = start_mock(handler).await; let client = RecoveryClient::new(session_for(&base), &base).unwrap(); @@ -1043,7 +1195,10 @@ mod tests { matches!(error, RecoveryError::Unauthorized { .. }), "got {error:?}" ); - assert_eq!(error.error_code(), Some("error.auth.expired")); + assert_eq!( + error.error_code(), + Some(error_codes::REQUEST_UNAUTHENTICATED) + ); } /// The minted secret clears the ≥128-bit entropy floor (256-bit) and never prints diff --git a/capsule-server/tests/sdk_client.rs b/capsule-server/tests/sdk_client.rs index 0b81a566..a634e6e4 100644 --- a/capsule-server/tests/sdk_client.rs +++ b/capsule-server/tests/sdk_client.rs @@ -295,6 +295,12 @@ async fn the_sdk_completes_a_real_second_factor_over_a_socket() { /// answered whatever path it was handed, so every escrow test passed while no real server had /// that route. Only a client pointed at the router can tell the difference, and the bytes are /// the ones a KDF runs against: a wrap that comes back re-encoded is a lost master key. +/// +/// **The route is asserted, not inferred.** Both halves are cross-checked against +/// `/v1/auth/escrow` through the fixture's own in-process client: what the SDK stored is read +/// back at that path, and what was seeded at that path is what the SDK fetches. A client +/// talking to some other path could satisfy neither, so this does not depend on how the +/// router happens to render a `404` for a path it does not serve. #[tokio::test] async fn the_sdk_stores_and_fetches_an_escrow_over_a_socket() { use capsule_core::crypto::primitives::Argon2Params; @@ -308,14 +314,15 @@ async fn the_sdk_stores_and_fetches_an_escrow_over_a_socket() { t_cost: 1, p_cost: 1, }; + const SECRET: &[u8] = b"correct horse battery staple"; let fixture = Fixture::working(); + let bearer = fixture.bearer().await; let base_url = serve(&fixture).await; let client = RecoveryClient::new(session(&base_url).await, &base_url).expect("an API root parses"); - // Nothing stored yet: the typed refusal a cadence reads as "enroll first", carrying the - // code a client localizes. + // Nothing stored yet: the typed refusal a cadence reads as "enroll first". let missing = client .fetch_escrow() .await @@ -324,13 +331,29 @@ async fn the_sdk_stores_and_fetches_an_escrow_over_a_socket() { matches!(missing, RecoveryError::NotEnrolled), "got {missing:?}" ); - assert_eq!(missing.error_code(), Some("error.escrow.not_stored")); + // ── The SDK writes; the contract's route is where it landed ─────────────────────────── let master = [0x5Au8; 32]; - let blob = pwkdf::wrap_with(&master, b"correct horse battery staple", params) - .expect("the master key wraps"); + let blob = pwkdf::wrap_with(&master, SECRET, params).expect("the master key wraps"); client.store_escrow(&blob).await.expect("the escrow stores"); + let response = fixture + .client + .get("/v1/auth/escrow") + .header("authorization", &bearer) + .send() + .await; + let seen = response.assert_status(kynos::http::StatusCode::OK).bytes(); + assert_eq!( + seen.as_ref(), + capsule_core::cbor::to_canonical_vec(&blob) + .expect("the wrap encodes") + .as_slice(), + "the bytes the SDK stored must be readable at `/v1/auth/escrow` — the path the \ + committed document declares, which is the assertion the old tests could not make" + ); + + // ── The SDK reads back what that route holds, byte for byte ─────────────────────────── let cache = client.fetch_escrow().await.expect("and comes back"); assert_eq!( cache.blob(), @@ -338,8 +361,35 @@ async fn the_sdk_stores_and_fetches_an_escrow_over_a_socket() { "the escrow is ciphertext served verbatim; a re-encoded wrap no longer opens" ); assert_eq!( - capsule_core::backup::recover_master_key(cache.blob(), b"correct horse battery staple") + capsule_core::backup::recover_master_key(cache.blob(), SECRET) .expect("the fetched wrap opens"), master, ); + + // A rotation seeded at the contract's route is the one the SDK sees next — the read half + // pinned to the same path, without relying on a refusal to prove it. + let rotated = pwkdf::wrap_with(&master, b"a different secret entirely", params) + .expect("the master key re-wraps"); + fixture + .client + .put("/v1/auth/escrow") + .header("authorization", &bearer) + .header("accept", "application/json") + .body( + "application/octet-stream", + capsule_core::cbor::to_canonical_vec(&rotated).expect("the wrap encodes"), + ) + .send() + .await + .assert_status(kynos::http::StatusCode::OK); + assert_eq!( + client + .fetch_escrow() + .await + .expect("the rotated escrow comes back") + .blob(), + &rotated, + "the SDK reads the resource `/v1/auth/escrow` addresses, not some other path that \ + happens to answer" + ); } From 247a4f093673877b88098ba71e73c6662dd36aae Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 23:10:02 -0400 Subject: [PATCH 056/243] feat(ffi): export LQIP placeholder decode to the browser and native apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `capsule_core::lqip` compiled identically on all three surfaces and was reachable from one: the import pipeline now encodes a placeholder, so the readers need an entry point or the module's whole reason for living at the crate root goes unexercised. - `capsule-wasm`: `decodeLqip` returns `WasmLqipImage` — packed RGBA the share viewer hands to `putImageData`, band-limited to the box being painted rather than decoded at a fixed size. The whole of the logic lives in a pure helper and the boundary is a `map`/`ok_or_else`, because `JsError` cannot be constructed off-wasm: a host test reaching the error arm through the exported function aborts the test binary instead of failing an assertion. - `capsule-core-ffi`: `render_lqip` → `LqipPlaceholder`. A free function rather than a `Catalog` method, deliberately: the `assets` table's `chromahash`/`dominant_color` columns are NULL and must stay so until `library::rebuild` projects them identically, or a rebuilt index would disagree with a freshly written one. So it takes the record the caller already holds from the decrypted sidecar rather than pretending the index has it. Both are infallible over a malformed record — an unknown version or a payload the parser rejects paints the `dominant_color` fill — because a reader must never misrender a placeholder and a gallery must never fail to draw a cell over one. The wasm boundary throws only on a `dominant_color` that is not three bytes, where there is no colour to fall back to; the FFI paints black, the conventional empty cell. Both are asserted byte-identical to `Lqip::decode_capped`, which is the `S-B14` cross-surface criterion at the two boundaries where a second implementation could have crept in. --- capsule-core-ffi/src/catalog.rs | 110 +++++++++++++++ capsule-core-ffi/src/lib.rs | 5 +- capsule-core/src/lifecycle/import.rs | 2 +- capsule-wasm/src/lib.rs | 191 +++++++++++++++++++++++++++ 4 files changed, 306 insertions(+), 2 deletions(-) diff --git a/capsule-core-ffi/src/catalog.rs b/capsule-core-ffi/src/catalog.rs index 82549c5c..b1e7051f 100644 --- a/capsule-core-ffi/src/catalog.rs +++ b/capsule-core-ffi/src/catalog.rs @@ -322,6 +322,64 @@ impl Catalog { } } +// ── LQIP placeholder rendering (slice S-B14) ──────────────────────────────── + +/// A decoded LQIP placeholder handed to the native clients: packed RGBA8, ready for a +/// `CGImage` / `Bitmap`. +/// +/// A record rather than raw bytes because the caller cannot know the dimensions in advance: +/// `decode_capped` returns the largest frame that fits *inside* the requested box while +/// preserving the source aspect ratio. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct LqipPlaceholder { + /// Frame width in pixels — at most the requested `max_width`. + pub width: u32, + /// Frame height in pixels — at most the requested `max_height`. + pub height: u32, + /// Packed RGBA8 samples, `width * height * 4` bytes long. + pub rgba: Vec, +} + +/// Render the three fields of a sidecar `lqip` record to a paintable placeholder, band-limited +/// to the box being painted. +/// +/// The mirror of `capsule-wasm`'s `decodeLqip`, over the same +/// [`capsule_core::lqip::render`] the import pipeline encodes against — one implementation, so +/// a photo's placeholder does not depend on which client is painting it (slice `S-B14`). +/// +/// **A free function rather than a [`Catalog`] method, deliberately.** The `assets` table has +/// `chromahash` and `dominant_color` columns, and they are NULL: the signed sidecar is the +/// placeholder's home, and projecting it onto the index would have to be done identically by +/// `capsule_core::library::rebuild` or a rebuilt index would disagree with a freshly written +/// one. Until both sides move together, an accessor keyed on an asset id could only ever +/// return nothing — so this takes the record the caller already holds from the decrypted +/// sidecar instead of pretending the index has it. +/// +/// **Infallible.** An unrecognised `format_version`, a payload the parser rejects, or a +/// `dominant_color` that is not three bytes all yield the 1x1 solid fill: a reader must never +/// misrender a placeholder, and a gallery must never fail to draw a cell over one. +#[uniffi::export] +#[must_use] +pub fn render_lqip( + format_version: u16, + chromahash: Vec, + dominant_color: Vec, + max_width: u32, + max_height: u32, +) -> LqipPlaceholder { + // A malformed fill is the one input `capsule_core::lqip::render` cannot take, and unlike the + // wasm boundary there is nothing useful to throw across the FFI for it: black is the + // conventional empty-cell fill and is what a caller would paint anyway. + let fill: [u8; 3] = dominant_color.try_into().unwrap_or([0, 0, 0]); + let image = + capsule_core::lqip::render(format_version, &chromahash, fill, max_width, max_height); + LqipPlaceholder { + width: image.width, + height: image.height, + rgba: image.rgba, + } +} + #[cfg(test)] mod tests { use std::sync::atomic::{AtomicU32, Ordering}; @@ -625,6 +683,58 @@ mod tests { assert!(cat.find_by_uuid("hidden".to_string()).unwrap().is_some()); } + // ── LQIP placeholder rendering ────────────────────────────────────────── + + /// The native surface decodes the same bytes the import pipeline encoded, to the same + /// pixels `capsule_core::lqip` produces — the `S-B14` cross-surface criterion at the FFI + /// boundary. + #[test] + fn render_lqip_matches_the_core_decoder() { + use capsule_core::lqip::{Gamut, LQIP_FORMAT_V1, Lqip}; + + let (w, h) = (120u32, 90u32); + let mut rgba = Vec::with_capacity((w * h * 4) as usize); + for y in 0..h { + for x in 0..w { + rgba.extend_from_slice(&[(x * 2) as u8, (y * 2) as u8, 128, 255]); + } + } + let lqip = Lqip::encode(w, h, &rgba, Gamut::Srgb).expect("encode"); + assert_eq!(lqip.as_bytes().len(), 32, "the committed tier is 32 bytes"); + + let expected = lqip.decode_capped(48, 48); + let got = render_lqip( + LQIP_FORMAT_V1, + lqip.as_bytes().to_vec(), + lqip.dominant_color().to_vec(), + 48, + 48, + ); + assert_eq!((got.width, got.height), (expected.width, expected.height)); + assert_eq!(got.rgba, expected.rgba, "byte-identical to the core"); + assert_eq!(got.rgba.len() as u32, got.width * got.height * 4); + } + + /// Every malformed input paints the fallback rather than failing: an unknown version, a + /// corrupt payload, and a `dominant_color` that is not three bytes. + #[test] + fn render_lqip_never_fails_on_a_malformed_record() { + use capsule_core::lqip::LQIP_FORMAT_V1; + + let fill = vec![9u8, 8, 7]; + let unknown = render_lqip(LQIP_FORMAT_V1 + 42, vec![1, 2, 3], fill.clone(), 32, 32); + assert_eq!((unknown.width, unknown.height), (1, 1)); + assert_eq!(unknown.rgba, vec![9, 8, 7, 255]); + + let corrupt = render_lqip(LQIP_FORMAT_V1, vec![0xDE, 0xAD], fill, 32, 32); + assert_eq!(corrupt.rgba, vec![9, 8, 7, 255]); + + // No usable fill: black, the conventional empty-cell colour. + let no_fill = render_lqip(LQIP_FORMAT_V1, vec![0xDE, 0xAD], vec![1, 2], 32, 32); + assert_eq!((no_fill.width, no_fill.height), (1, 1)); + assert_eq!(no_fill.rgba, vec![0, 0, 0, 255]); + } + /// The retention sweep is deliberately ungated (it runs unattended) — pinned here so /// a future change to that decision is a deliberate one, not an accident. #[test] diff --git a/capsule-core-ffi/src/lib.rs b/capsule-core-ffi/src/lib.rs index 544b2a2c..fc7e803d 100644 --- a/capsule-core-ffi/src/lib.rs +++ b/capsule-core-ffi/src/lib.rs @@ -19,6 +19,9 @@ //! Rust ↔ Swift contract: //! //! - [`Catalog`] — a thread-safe handle over the SQLite catalog. +//! - [`render_lqip`] / [`LqipPlaceholder`] — the sidecar `lqip` record rendered to packed RGBA8 +//! through `capsule_core::lqip`, the same implementation the import pipeline encodes with and +//! `capsule-wasm` decodes with (slice `S-B14`). //! - [`AssetRecord`], [`AssetStackRecord`], [`StackMemberRecord`], //! [`AlbumRecord`] — catalog row mirrors. //! - [`AssetSidecarRecord`] / [`serialize_sidecar`] / [`deserialize_sidecar`] — @@ -43,7 +46,7 @@ mod gate; mod records; mod sidecar; -pub use catalog::Catalog; +pub use catalog::{Catalog, LqipPlaceholder, render_lqip}; pub use error::CatalogError; pub use gate::{GatedView, LocalAuthError, LocalAuthGate}; pub use records::{AlbumRecord, AssetRecord, AssetStackRecord, StackMemberRecord}; diff --git a/capsule-core/src/lifecycle/import.rs b/capsule-core/src/lifecycle/import.rs index 0ce4668b..604590f1 100644 --- a/capsule-core/src/lifecycle/import.rs +++ b/capsule-core/src/lifecycle/import.rs @@ -29,7 +29,7 @@ use crate::exif::extract::extract_exif; use crate::exif::timezone::resolve_timezone; use crate::metadata::crdt::{Lww, OrSet}; use crate::sidecar::sidecar_v1::{ - Dimensions, Gps, GpsSource, SIDECAR_SCHEMA_V1, SidecarV1, StackMembership, StackRole, + Gps, GpsSource, SIDECAR_SCHEMA_V1, SidecarV1, StackMembership, StackRole, }; /// Render a Unix-second capture time as the sidecar's RFC 3339 `capture_timestamp`. diff --git a/capsule-wasm/src/lib.rs b/capsule-wasm/src/lib.rs index 1fb7cb66..56b0919e 100644 --- a/capsule-wasm/src/lib.rs +++ b/capsule-wasm/src/lib.rs @@ -30,6 +30,17 @@ //! fragment. Byte-identical to the server's stored verifier, so the guest proves possession //! without transmitting the passphrase (SSoT: [Web Upload] — Optional passphrase abuse gate). //! +//! The crate additionally carries the **LQIP decode** entry point (slices `S-B14`/`S-B1`), which +//! is the one surface here that is not about crypto: +//! +//! 6. [`decode_lqip`] (`decodeLqip`) — render a sidecar `lqip` record to packed RGBA the viewer +//! can hand straight to `CanvasRenderingContext2D.putImageData`. The placeholder lives inside +//! the *encrypted* metadata blob, so the browser only ever holds it after opening a share +//! link — which is why this belongs in the same crate as the open path rather than beside a +//! server route. It is the same [`capsule_core::lqip`] implementation the import pipeline +//! encodes with and the native apps decode with, so a photo's placeholder does not depend on +//! which client is painting it. +//! //! The drop surface is deliberately **contribute-only**: there is no open/decapsulate/decrypt //! entry point for drops (only the provisioning user's *native* client, holding the Drop Key //! private half, can adopt). Keep new browser entry points here behind the same thin-glue @@ -51,6 +62,7 @@ use capsule_core::crypto::encryption::stream::{NONCE_PREFIX_LEN, decrypt_asset_v use capsule_core::crypto::primitives::Argon2Params; use capsule_core::crypto::pwkdf; use capsule_core::drop::{SealedDrop, seal_drop, seal_drop_derand}; +use capsule_core::lqip::{RgbaImage, render as lqip_render}; use capsule_core::sharing::{ LINK_SECRET_LEN, OPAQUE_ID_LEN, ScopeMaterial, SharingError, WrappedScope, open_scope, }; @@ -372,6 +384,102 @@ pub fn drop_passphrase_proof( // `JsValue`. The wasm-side behaviour of the `#[wasm_bindgen]` entry points is covered by // `capsule-web`'s bun KATs. +// ─────────────────────────── LQIP placeholder decode (slice S-B14) ────────────────────────── + +/// A decoded LQIP placeholder: packed RGBA8, ready for `putImageData`. +/// +/// A struct rather than a bare `Vec` because the caller cannot know the dimensions in +/// advance: `decode_capped` returns the largest frame that fits *inside* the requested box while +/// preserving the source aspect ratio, so the answer is `(width, height, rgba)` or nothing. +#[wasm_bindgen] +pub struct WasmLqipImage { + image: RgbaImage, +} + +#[wasm_bindgen] +impl WasmLqipImage { + /// Frame width in pixels — at most the `maxWidth` that was requested. + #[wasm_bindgen(getter)] + #[must_use] + pub fn width(&self) -> u32 { + self.image.width + } + + /// Frame height in pixels — at most the `maxHeight` that was requested. + #[wasm_bindgen(getter)] + #[must_use] + pub fn height(&self) -> u32 { + self.image.height + } + + /// Packed RGBA8 samples, `width * height * 4` bytes — the exact layout + /// `new ImageData(rgba, width, height)` takes. + #[wasm_bindgen(getter)] + #[must_use] + pub fn rgba(&self) -> Vec { + self.image.rgba.clone() + } +} + +/// Render a sidecar `lqip` record to a paintable placeholder, band-limited to the box being +/// painted. +/// +/// - `format_version` — the record's `lqip.format_version`. +/// - `chromahash` — the record's `lqip.chromahash` payload (32 bytes at the committed tier). +/// - `dominant_color` — the record's `lqip.dominant_color`, exactly 3 bytes (opaque RGB). +/// - `max_width` / `max_height` — the box the caller is about to paint. Decoding to the box +/// rather than to a fixed size is the point of `decode_capped`: a grid cell never scales down +/// a larger decode. +/// +/// **Infallible by design, except on a malformed `dominant_color`.** An unrecognised +/// `format_version` or a payload the parser rejects yields the 1x1 solid fallback fill rather +/// than a throw, because a reader must never misrender a payload it does not understand and a +/// missing placeholder is not an error worth failing a gallery over. Only a `dominant_color` +/// that is not three bytes throws `malformed` — there is no colour to fall back *to*, so +/// guessing one would invent pixels. +#[wasm_bindgen(js_name = decodeLqip)] +pub fn decode_lqip( + format_version: u16, + chromahash: &[u8], + dominant_color: &[u8], + max_width: u32, + max_height: u32, +) -> Result { + render_lqip_record( + format_version, + chromahash, + dominant_color, + max_width, + max_height, + ) + .map(|image| WasmLqipImage { image }) + .ok_or_else(|| JsError::new(err::MALFORMED)) +} + +/// [`decode_lqip`] without the JS boundary — the whole of its logic, so the host unit tests can +/// exercise it. +/// +/// The split is not ceremony: `JsError` cannot be *constructed* off-wasm (its host shim aborts), +/// so a test that reached the error arm through the exported function would abort the test +/// binary rather than fail an assertion. Keeping the boundary to a `map`/`ok_or_else` is also +/// the thin-glue discipline the module docs ask for. +fn render_lqip_record( + format_version: u16, + chromahash: &[u8], + dominant_color: &[u8], + max_width: u32, + max_height: u32, +) -> Option { + let fill: [u8; 3] = dominant_color.try_into().ok()?; + Some(lqip_render( + format_version, + chromahash, + fill, + max_width, + max_height, + )) +} + #[cfg(test)] mod tests { use super::*; @@ -484,6 +592,89 @@ mod tests { assert_eq!(decoded, bytes); } + // ── LQIP decode (slice S-B14) ─────────────────────────────────────────── + + /// A gradient frame, the shape `Lqip::encode` takes. + fn gradient(width: u32, height: u32) -> Vec { + let (w, h) = (width as usize, height as usize); + let mut rgba = Vec::with_capacity(w * h * 4); + for y in 0..h { + for x in 0..w { + rgba.extend_from_slice(&[ + (x * 255 / w) as u8, + (y * 255 / h) as u8, + ((x + y) * 255 / (w + h)) as u8, + 255, + ]); + } + } + rgba + } + + /// The browser decodes the same bytes the import pipeline encoded, to the same pixels the + /// core `decode_capped` produces — the `S-B14` cross-surface criterion, at the one boundary + /// where a second implementation could have crept in. + #[test] + fn decode_lqip_matches_the_core_decoder_for_a_real_payload() { + use capsule_core::lqip::{Gamut, LQIP_FORMAT_V1, Lqip}; + + let lqip = Lqip::encode(200, 150, &gradient(200, 150), Gamut::Srgb).expect("encode"); + let payload = lqip.as_bytes(); + assert_eq!(payload.len(), 32, "the committed tier is 32 bytes"); + + let decoded = render_lqip_record(LQIP_FORMAT_V1, payload, &lqip.dominant_color(), 64, 64) + .expect("a well-formed record decodes"); + assert_eq!( + decoded, + lqip.decode_capped(64, 64), + "byte-identical to the core decoder" + ); + assert_eq!( + decoded.rgba.len() as u32, + decoded.width * decoded.height * 4, + "the buffer is exactly what `new ImageData(rgba, w, h)` requires" + ); + assert!( + decoded.width <= 64 && decoded.height <= 64, + "the decode is capped to the box being painted, not to a fixed size" + ); + } + + /// An unrecognised version and an undecodable payload both paint the stored fallback colour + /// rather than throwing: a reader must never misrender, and a missing placeholder is not + /// worth failing a gallery over. + #[test] + fn decode_lqip_falls_back_to_the_dominant_colour_instead_of_throwing() { + use capsule_core::lqip::LQIP_FORMAT_V1; + + let fill = [12u8, 34, 56]; + for (version, payload) in [ + (LQIP_FORMAT_V1 + 999, vec![0xDE, 0xAD, 0xBE, 0xEF]), + (LQIP_FORMAT_V1, vec![0xDE, 0xAD, 0xBE, 0xEF]), + (LQIP_FORMAT_V1, Vec::new()), + ] { + let decoded = render_lqip_record(version, &payload, &fill, 32, 32) + .expect("the fallback never fails"); + assert_eq!((decoded.width, decoded.height), (1, 1)); + assert_eq!(decoded.rgba, vec![12, 34, 56, 255]); + } + } + + /// The one throwing case: there is no colour to fall back *to*, so guessing one would invent + /// pixels. + #[test] + fn decode_lqip_rejects_a_malformed_dominant_colour() { + use capsule_core::lqip::LQIP_FORMAT_V1; + + for fill in [&[][..], &[1][..], &[1, 2][..], &[1, 2, 3, 4][..]] { + assert!( + render_lqip_record(LQIP_FORMAT_V1, &[0; 32], fill, 16, 16).is_none(), + "a {}-byte dominant_color is malformed", + fill.len() + ); + } + } + #[test] fn decode_wrapped_round_trips_a_canonical_wrapped_scope() { let wrapped = WrappedScope::LinkOnly { From 1dacc07688f63c1a0bc1ffcd8d0babacfdc3df01 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 23:10:15 -0400 Subject: [PATCH 057/243] docs: record what the media pipeline ships and what it still owes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SLICES.md` had S-B1, S-B5 and S-B13 as `RETIRED`/`ready` and S-B14 owing a wasm entry point. Three of the four moved: - **S-B1** — re-landed on `rawshift-image`; the injected `StillEncoder` seam is gone, because it existed only to work around core linking no codec. `done*`, owing the JXL master, the AVIF delivery variant, the preview tier and HEIC/RAW decode to #437, each blocked on a system library or an assembler rather than on a design question. - **S-B5** — `ACTIVE` and still unimplemented: `rawshift-video` is unpublished and the transcode toolchain shares nothing with the still path. Owed to #438, with the licensing gate named up front. - **S-B13** — `done`. There are no stubs to make uninhabited any more: the coverage table is a gate checked before any decoder runs, and the two-reason distinction is observable again — and now rests on the bytes rather than the extension. - **S-B14** — the owed wasm entry point exists, and so does the FFI one. `thumbnails.md` gains an implementation-status note under the tier table. The table stays the contract; the note says what is generated today, names the toolchain blocking each missing cell, and records that the distance between the two is a number the import run reports rather than something a reader has to infer. The "Where LQIP Lives" rationale is restated on the ground that outlived the teardown: `media` is `native`-only wherever it exists, so a placeholder every client needs cannot live inside it and still reach the browser. --- SLICES.md | 111 +++++++++++++++--- .../src/content/docs/design/thumbnails.md | 23 +++- 2 files changed, 117 insertions(+), 17 deletions(-) diff --git a/SLICES.md b/SLICES.md index ce7d3266..3d53dbf6 100644 --- a/SLICES.md +++ b/SLICES.md @@ -210,11 +210,11 @@ row's remainder now lives. | S-A9 | Add-id counter reseed at `Workspace` open | core-crypto | — | S | ACTIVE | done | | | S-A10 | Durable album-key persistence + library open plumbing | core-crypto | — | L | ACTIVE | done | | | S-A11 | Publish the DEK in the device directory | core-crypto | — | M | ACTIVE | done | | -| S-B1 | Thumbnail/LQIP generation | media/import | — | L | RETIRED | ready | | +| S-B1 | Thumbnail/LQIP generation | media/import | — | L | ACTIVE | done\* | JXL/AVIF encode, the preview tier, HEIC/RAW decode → #437 | | S-B2 | Signed-path import-executor rewrite | media/import | S-B1 | L | MIXED | done\* | durable album keys → `S-A10` | | S-B3 | Streaming import (probe, `total_size`, drive mode) | media/import | S-D1, S-D4 | L | MIXED | done | | | S-B4 | Staged uploads (low-data tier ladder) | media/import | S-C1, S-C2, S-D1 | M | MIXED | done | | -| S-B5 | Video derivatives (first-frame still + H.264 preview) | media/import | S-B1 | M | RETIRED | ready | | +| S-B5 | Video derivatives (first-frame still + H.264 preview) | media/import | S-B1 | M | ACTIVE | ready | `rawshift-video` unpublished → #438 | | S-B6 | Google Takeout importer | media/import | S-B2 | M | MIXED | done\* | sidecar-enrichment write → `S-B10` | | S-B7 | iCloud export importer | media/import | S-B6 | M | MIXED | post-v1 | | | S-B8 | Immich importer | media/import | S-B6 | M | MIXED | post-v1 | | @@ -223,8 +223,8 @@ row's remainder now lives. | S-B11 | CLI `import --provider takeout` + real-archive run | media/import | S-B10 | S | ACTIVE | done\* | synthesized archive only; real export owed | | S-B18 | No CLI surface shows what the importer actually wrote | media/import | S-B10 | S | ACTIVE | ready | users cannot verify enrichment | | S-B12 | Base default-album resolution (`resolve_default_album`) | media/import | — | M | ACTIVE | done | scope-override + source-kind rows → post-v1 | -| S-B13 | Codec stubs → typed `UnsupportedFormat` (no panics) | media/import | — | M | RETIRED | ready | | -| S-B14 | LQIP on Chromahash 0.7.1 in `capsule-core::lqip` | media/import | — | M | ACTIVE | done | wasm entry point owed to the browser-`lqip` slice | +| S-B13 | Codec stubs → typed `UnsupportedFormat` (no panics) | media/import | — | M | ACTIVE | done | | +| S-B14 | LQIP on Chromahash 0.7.1 in `capsule-core::lqip` | media/import | — | M | ACTIVE | done | | | S-B15 | Importer-formed stacks exist only in the index | media/import | S-D21 | M | ACTIVE | done | rebuild guard kept as pre-`S-B15` compatibility | | S-B16 | Every import stamped by import time, not capture time | media/import | — | S | ACTIVE | done | found by the CLI round-trip test | | S-B17 | Repair capture timestamps written before `S-B16` | media/import | S-B16 | M | ACTIVE | ready | the wrong value is in *signed* bytes | @@ -695,10 +695,36 @@ workspace at all**, so every still import is a `DeferredNoCodec` until Rawshift - **Tier:** Unit + Smoke. **Blocks:** S-B2, S-B5. - **Landed in retired code, and retired 2026-09-01 by `S-C59`:** generation shipped over injected per-platform encoder seams and was green in this workspace, but it lived in - `capsule_core::media` — review material. It is now in `legacy-review/media-pipeline/`, together - with `lifecycle/derivatives.rs`, its only caller. **Re-scoped:** re-land on the Rawshift-backed - pipeline. The signed `DerivativeManifest` chain and the sidecar `lqip` field are `ACTIVE` and - stay — the field stays here, its producer moves to `S-B14`. + `capsule_core::media` — review material. It went to `legacy-review/media-pipeline/`, together + with `lifecycle/derivatives.rs`, its only caller. +- **Re-landed 2026-09-02 on the Rawshift-backed pipeline (issue #410).** `capsule-core::media` + exists again, over **`rawshift-image` 0.1.1 from crates.io** (a registry dependency, not the + pinned submodule) behind a `media` feature that `native` implies and the wasm32 sealing build + excludes. The injected `StillEncoder` seam is **gone**: the per-platform encoder was there + because core linked no codec, and it no longer needs to. What ships: + - `media::{detect,decode,resize,derivative,error}` as private submodules behind one barrel — + the closed `StillFormat` set with a Capsule-owned magic-byte table, the `Decoder` seam with + a pre-decode 256 Mpx budget and an unwind boundary, a deterministic integer area-average + downscale (the crate has no resize, and a derivative's bytes are signed), the closed + `DerivativeFormat` set with the `original` sentinel, and `MediaError`. + - the **thumbnail tier** at 256 px / q=50 as **WebP**, signed and hash-chained through the same + two-signature `DerivativeCore::sign` path assets use, and persisted at the layout the upload + bundle reader already reads. + - **Detection is Capsule's, not the crate's.** `rawshift-image`'s own `detect_standard_format` + gates its HEIC arm on `heic-decode`, so delegating would make the typed refusal for a format + depend on whether it can be decoded — a HEIC would arrive as "not a still" instead of "a + still whose derivatives are backfillable". A test pins agreement between the two tables for + every format both define unconditionally. + - **Every encode passes `MetadataEmbedOptions::none()`.** `rawshift-core`'s default is `all()`, + so a default-configured encode copies the source's EXIF — GPS included — into the thumbnail. + A test demonstrates the leak with the crate's own default and then asserts Capsule's + derivative carries no `EXIF`/`XMP`/`ICCP` chunk and none of the source's GPS rationals. +- **Owed → #437.** The JXL master and the AVIF delivery variant, the preview tier, and HEIC/RAW + decode. Each is blocked on a toolchain, not a design: a lossy JXL needs C libjxl (the pure-Rust + backend is `zune-jpegxl`'s lossless simple encoder), AVIF encode needs `nasm` on every x86_64 + build host, and HEIC/AVIF decode need system libheif/libdav1d. All four are visible today as + typed `MediaError::UnsupportedFormat` or as per-`(tier, format)` deferrals counted by + `ImportExecutionSummary::deferred_format_count()`, never as silent absence. ### S-B2 — Signed-path import-executor rewrite @@ -761,8 +787,22 @@ workspace at all**, so every still import is a `DeferredNoCodec` until Rawshift - **Done when:** a fixture video yields both tiers with signed manifests; the closed-format rejection covers the video rows of the tier table. - **Tier:** Unit + Smoke. -- **Landed in retired code:** ships today behind the injected encoder seam; the transcode - half is `capsule-core::media` and re-scopes onto the Rawshift-backed pipeline. +- **Landed in retired code, and re-scoped 2026-09-02 (issue #410 → #438).** It shipped behind + the injected encoder seam; that seam is gone with `S-B1`'s re-land, so this slice now sits on + the live `capsule-core::media` pipeline and is `ACTIVE` — and still unimplemented. +- **Why it is not just another format.** `rawshift-video` is **not published on crates.io** (the + `rawshift` facade's `video` feature points at an unreleased crate) and the transcode toolchain + — demux, video decode, H.264/AAC encode — touches nothing the still path does. That is the + split from `S-B1`, restated: a distinct dependency decision with its own licence surface, which + `design/licensing.md` already names as the most likely route by which copyleft enters Capsule. +- **What happens today:** a video's bytes sniff to no `media::StillFormat`, so every video import + reports `DerivativeStatus::NotAKnownStill` and carries no thumbnail, preview or LQIP. The + original is still imported signed, encrypted and `verify_asset`-accepting, so this is a + cosmetic gap, not data loss. `content_type` stays extension-derived for video, because + detection has no video half yet. +- **`capsule-core::media::video` is the one remaining `planned-modules.txt` row** for this lane + (`#410` narrowed the `capsule-core::media` row to it), which is what keeps `check-docs-truth` + honest about `capsule-core::media::video::derivative` in `design/licensing.md`. ### S-B6 — Google Takeout importer @@ -937,10 +977,32 @@ workspace at all**, so every still import is a `DeferredNoCodec` until Rawshift pins that HEIC and RAW-only originals import and self-verify **without** derivatives, and a planner test guards that undecodable stills are never skipped at plan time; `mise run check-rust` green. **Tier:** Unit. -- **Landed in retired code:** shipped and green on this branch, but the whole surface is - `capsule-core::media`. **Re-scoped:** the uninhabited-stub discipline and the - `is_decodable`/`from_extension` coverage table are the contract the Rawshift-backed - rebuild inherits; `DerivativeStatus` on `ImportOutcome` is `ACTIVE` and stays. +- **Landed in retired code:** shipped and green on this branch, but the whole surface was + `capsule-core::media`. The uninhabited-stub discipline and the `is_decodable`/`from_extension` + coverage table were the contract the Rawshift-backed rebuild had to inherit. +- **Re-landed 2026-09-02 (issue #410), and the shape it inherited changed for the better.** + There are no stubs at all now — uninhabited or otherwise — because there is nothing to stub: + `rawshift-image` either has a codec or it does not, and the coverage table + (`StillFormat::is_decodable` over `SUPPORTED_STILL_FORMATS`) is a *gate checked before any + decoder runs* rather than a property of a type nobody can construct. `rg 'unimplemented!\(|todo!\(' + capsule-core/src/media` is empty by construction. + - `MediaError::UnsupportedFormat { format, op }` carries the `FormatOp` — a build can decode a + format it cannot encode, and the message has to say which half is missing. + - **The two-reason distinction is observable again**, and now rests on the bytes rather than the + extension: a HEIC is `DeferredNoCodec` (recognised, no codec here, backfillable) while a + `.jpg` that is not a JPEG is `DecodeFailed` (a format we do decode, failing on these bytes). + `S-C59` had collapsed both into deferrals and the executor test said so; it asserts the + distinction again. + - **A third reason joined them, per format rather than per asset.** + `StillDerivatives::deferred` records each `(tier, format)` pair with no encoder, and + `ImportExecutionSummary::deferred_format_count()` sums them. A decoded JPEG is `Decoded` with + one generated thumbnail and two deferred formats — the number that falls to zero as #437 + lands, rather than a gap only a doc mentions. + - **No panic can reach an import.** Untrusted bytes go through a pre-decode pixel budget + (`MAX_DECODE_PIXELS`, 256 Mpx — the bomb is inside the decoder, which works in RGB `u16`) and + a `catch_unwind` boundary that maps a third-party decoder's panic to `DecodeFailed`. Both are + tested, the panic case through an injected `Decoder`. +- **Originals always import**, unchanged: codec coverage gates *derivatives*, never *admission*. ### S-B14 — LQIP on Chromahash 0.7.1, in its own module @@ -1001,8 +1063,25 @@ workspace at all**, so every still import is a `DeferredNoCodec` until Rawshift 21 bytes — `COMPACT_TIER`'s length — which is the concrete proof that byte length cannot discriminate a stale payload. Both are rejected by `from_bytes` and render as the solid dominant-colour fill, never noise. -- **Owed:** no `wasm_bindgen` export exists. wasm links and compiles the identical encoder, but the - browser has no decrypted `lqip` to decode yet, so the entry point belongs to that slice. +- **The producer and the exports landed 2026-09-02 (issue #410), and the module has callers on + all three surfaces.** `lqip` had been fully tested and entirely unreachable: nothing decoded, so + nothing encoded a placeholder. + - **Producer:** `Workspace::prepare_still` encodes from the **full-resolution, + orientation-applied** frame — not from the thumbnail, because chromahash band-limits on the + read side via `decode_capped` and pre-resizing would silently cap fidelity the format can + carry. The signed sidecar now carries a real 32-byte payload at `format_version` 1. + - **Browser:** `capsule-wasm`'s `decodeLqip` (`WasmLqipImage`) returns packed RGBA the viewer + hands to `putImageData`. The JS boundary is a `map`/`ok_or_else` over a pure helper, because + `JsError` cannot be constructed off-wasm and a host test reaching the error arm would abort + the test binary rather than fail an assertion. + - **Native:** `capsule-core-ffi`'s `render_lqip` → `LqipPlaceholder`. A free function rather + than a `Catalog` method: the `assets` table's `chromahash`/`dominant_color` columns are NULL + and must stay so until `library::rebuild` projects them identically, or a rebuilt index would + disagree with a freshly written one — so it takes the record the caller already holds from the + decrypted sidecar rather than pretending the index has it. + - The cross-surface criterion is asserted rather than assumed: both exports are checked + byte-identical to `Lqip::decode_capped` for a real payload, and both paint the + `dominant_color` fill for an unknown version or a payload `from_bytes` rejects. ### S-B15 — Importer-formed stacks exist only in the index diff --git a/capsule-docs/src/content/docs/design/thumbnails.md b/capsule-docs/src/content/docs/design/thumbnails.md index 55c211f2..ebd0f059 100644 --- a/capsule-docs/src/content/docs/design/thumbnails.md +++ b/capsule-docs/src/content/docs/design/thumbnails.md @@ -29,6 +29,27 @@ Two derivative tiers per photo asset and one preview tier for video assets: - **WebP** is the last-resort fallback for the rare client lacking AVIF. We deliberately do not fall back to JPEG — WebP covers everything JPEG would. - **H.264 baseline** for video previews — universally decodable, cheap to decode on every platform. AV1 was considered but mobile encode cost is still high in 2026. +:::note[Implementation status — what ships today] +The table above is the **contract**, not an inventory of what is built. As of `#410`, +`capsule-core::media` (on `rawshift-image` 0.1.1, behind the `media` feature that `native` +implies) generates the **thumbnail tier as WebP at q=50** and nothing else. Concretely: + +| Tier | Photo formats generated | Missing, and why | +| --- | --- | --- | +| Thumbnail | **WebP** q=50, 256 px long edge; or the `original` sentinel when the source is already inside the cap | **JXL** needs C libjxl for a lossy encode — the pure-Rust backend is `zune-jpegxl`'s *lossless* simple encoder. **AVIF** needs `nasm` on every x86_64 build host (`ravif` → `rav1e/asm`). | +| Preview | — | Blocked with the master codec: a source-resolution *lossless* still would rival the original in size, so the tier is only worth its bytes once a lossy master is available. | +| Video (either tier) | — | `rawshift-video` is unpublished; slice `S-B5`. | + +Decode is JPEG, PNG, JXL, TIFF, GIF and WebP. **HEIC, AVIF and the RAW families are recognised +and refused**, because their backends need system libheif / libdav1d. + +None of this is silent. A format with no codec is a typed +`media::MediaError::UnsupportedFormat { format, op }`, and a `(tier, format)` pair with no encoder +is recorded on `media::StillDerivatives::deferred` and counted by +`ImportExecutionSummary::deferred_format_count()` — so the distance between this table and the +build is a number the import run reports. The remainder is tracked as the `S-B1` follow-up. +::: + ### Video Previews The table above stays the SSoT for the video formats; this section only names the implementation seam. Video derivative generation — the first-frame still and the H.264 baseline preview transcode — is its own implementation slice (`S-B5` in the repo-root `SLICES.md`), split from still-image generation (`S-B1`) because transcode brings a distinct toolchain (demux, video decode, H.264/AAC encode) the still path never touches. Both slices sign their outputs identically through the [`DerivativeManifest`](#derivative-provenance) path. @@ -56,7 +77,7 @@ Four calls carry the whole contract, and the module uses no more than these: ### Where LQIP Lives -`capsule-core::lqip` — a dedicated module, slice `S-B14` in the repo-root `SLICES.md`. It is deliberately **not** in `capsule-core::media`, which retires to `legacy-review/` with the rest of the decode/encode stack: a placeholder scheme every client depends on cannot live inside something scheduled for teardown. It is equally not in Rawshift — `AGENTS.md` is explicit that Rawshift owns media decoding but must not wrap Chromahash, which Capsule imports directly. +`capsule-core::lqip` — a dedicated module, slice `S-B14` in the repo-root `SLICES.md`. It is deliberately **not** in `capsule-core::media`, and the reason outlived the teardown that first prompted it: `media` is `native`-only wherever it exists (it links codecs, and since `#410` a vendored C one), so a placeholder scheme every client depends on cannot live inside it and still reach the browser. It is equally not in Rawshift — `AGENTS.md` is explicit that Rawshift owns media decoding but must not wrap Chromahash, which Capsule imports directly. `media` is the module that *produces* the pixels this one hashes; it never owns the hash. A small Capsule-owned module outside the retiring stack satisfies both constraints at once, and is reachable from all three places a placeholder is produced or consumed: the import pipeline, the native apps through the uniffi FFI, and the browser through `capsule-wasm`. That is the point of a single home — one implementation for every surface, so a photo's placeholder does not depend on which client happened to import it. From 16fb5f5effb58f973f79754636f1950cacbe3ec9 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 23:15:03 -0400 Subject: [PATCH 058/243] feat(sdk): retry a 401 once on the typed REST path (S-D17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated client had only the proactive half of the refresh contract: the token provider refreshes when the stored token is within its skew of expiry, before the request leaves. That cannot cover a token the server stops honouring early — a revocation mid-flight, or a clock the two ends disagree about — and the hand-written clients closed that race years ago while the typed path did not. `RefreshOn401` is an `rest::HttpBackend` wrapping `ReqwestBackend`, installed by `AuthenticatedClient::build_client` through `Client::with_backend`. On a `401` it refreshes once through a new `pub(crate) Session::refresh_rejected` and replays the request once. It touches no generated code and covers every generated operation at once, so there is no per-call retry loop to keep in step and nothing to redo when the document is re-sourced. Why the transport seam and not spargen's `Middleware`: `Next::run` takes `self` by value, and `Next` is neither `Clone` nor constructible outside the generated runtime, so a middleware physically cannot send twice. `RetryBackend` is the precedent this follows, including its rule that a request whose `try_clone()` is `None` — a one-shot streaming body — is executed once and never replayed. `Session::refresh_rejected` wraps `ensure_refreshed(RefreshTrigger::Rejected(stale))` rather than reusing `Session::refresh`, because `refresh` re-reads the *current* token and would refresh again on top of a concurrent rotation, spending a single-use refresh token the server had already closed. Passing the exact token the server refused is what lets the existing single-flight gate coalesce. Exactly once, and by construction: the replay is straight-line code, not a loop with a counter. Four properties are pinned as unit tests — one refresh and one replay carrying the rotated token; a persistent `401` surfaced after exactly two upstream requests; a request with no bearer never retried; and a refresh that itself fails surfacing the **server's** `401` rather than a synthesized transport error, so the typed `Status401` mapping still fires and the caller reads the `error.*` code that separates an expired token from an unreadable revocation ledger. Over a socket, `a_token_the_server_stopped_honouring_is_refreshed_and_the_call_replayed` reproduces the race against the real router: the server validates `exp` against its injected clock, so advancing the fixture past `ACCESS_TOKEN_TTL` revokes the access token for real while the refresh token lives, and the client is handed the same pair with a far-future deadline. The pre-flight half cannot fire, so the call only succeeds through the reactive layer. Both new tests were confirmed to fail with the backend uninstalled. `reqwest_client()` becomes one shared client for the process. It owns a connection pool, and the FFI's escrow verbs build a fresh `AuthenticatedClient` per call because the API root is a per-call argument — so a per-client transport meant a fresh TLS handshake for every escrow read. Nothing here is configured per instance, so there is nothing to vary. Refs #408 --- capsule-sdk/src/auth.rs | 27 +- capsule-sdk/src/client.rs | 398 ++++++++++++++++++++++++++++- capsule-server/tests/sdk_client.rs | 67 +++++ 3 files changed, 479 insertions(+), 13 deletions(-) diff --git a/capsule-sdk/src/auth.rs b/capsule-sdk/src/auth.rs index 6c7db931..d63c1f1f 100644 --- a/capsule-sdk/src/auth.rs +++ b/capsule-sdk/src/auth.rs @@ -671,6 +671,23 @@ impl Session { Ok(()) } + /// Refresh because the server **rejected** `stale`, and return the token that replaced + /// it (single-flight). + /// + /// The reactive counterpart to [`bearer`](Session::bearer)'s pre-flight refresh, and the + /// primitive [`crate::client::AuthenticatedClient`]'s `401` layer drives. Passing the + /// exact token the server refused is the whole point: `ensure_refreshed`'s `Rejected` + /// arm compares it against the store, so a caller whose stale token has *already* been + /// rotated by a concurrent refresh gets the fresh token back with no second network + /// call. [`refresh`](Session::refresh) cannot serve this — it re-reads the *current* + /// token and would therefore refresh again on top of that rotation, spending a + /// single-use refresh token the server has already closed. + #[instrument(skip_all)] + pub(crate) async fn refresh_rejected(&self, stale: &str) -> Result { + self.ensure_refreshed(RefreshTrigger::Rejected(stale.to_owned())) + .await + } + /// Revoke the session server-side and clear the local store. Idempotent: a /// server that no longer honors the token (or an already-empty store) still /// resolves to a cleared, logged-out session. @@ -702,11 +719,11 @@ impl Session { } /// A currently-valid **bearer access token** for injecting into a request the - /// SDK does not build with [`Session::execute`] — notably the sync feed's gRPC - /// call metadata, where the token rides `authorization` metadata rather than a - /// `reqwest` header. Pre-flight-refreshes exactly like [`Session::execute`]; - /// callers that get an `Unauthenticated`/`401` back re-[`refresh`](Session::refresh) - /// and read a fresh token once. + /// SDK does not build with [`Session::execute`] — notably the generated REST client's + /// token-provider seam ([`crate::client::AuthenticatedClient`]), where the token is + /// attached by the client rather than by this module. Pre-flight-refreshes exactly like + /// [`Session::execute`]; the reactive half — a `401` the pre-flight check could not + /// foresee — is [`refresh_rejected`](Session::refresh_rejected)'s. #[instrument(skip_all)] pub async fn bearer(&self) -> Result { self.valid_access_token().await diff --git a/capsule-sdk/src/client.rs b/capsule-sdk/src/client.rs index ca9f6971..926eb8a0 100644 --- a/capsule-sdk/src/client.rs +++ b/capsule-sdk/src/client.rs @@ -11,14 +11,33 @@ //! touch a raw token. The refresh/expiry/single-flight logic is reused wholesale from //! [`crate::auth`]; nothing is duplicated here. //! +//! # Both halves of the refresh contract (slice `S-D17`) +//! +//! The token provider is the **proactive** half: it refreshes when the stored token is within +//! its skew of expiry, before the request leaves. That cannot cover a token the server stops +//! honouring early — a revocation mid-flight, or a clock the two ends disagree about — so +//! [`RefreshOn401`] is the **reactive** half: an [`rest::HttpBackend`] wrapping +//! [`rest::ReqwestBackend`] that, on a `401`, refreshes once through the session and replays +//! the request exactly once. The two are complementary and neither duplicates the other; the +//! refresh itself is still `auth`'s single-flight gate. +//! +//! It sits at the transport seam rather than in each caller, so **every** generated operation +//! is covered by one layer that survives regeneration — no generated code is touched, and +//! there is no per-call retry loop to keep in step. +//! //! Scope: this covers the plain request/response REST surfaces the OpenAPI schema declares //! (auth/session, quota, storage-verify, receipts, devices, escrow, …). The stateful upload -//! protocol ([`crate::upload`]) and the gRPC sync feed ([`crate::sync`]) stay hand-written — -//! they are deliberately *not* routed through the generated client. +//! protocol ([`crate::upload`]) stays hand-written. The sync feed ([`crate::sync`]) *is* a +//! generated operation (`GET /v1/sync`), but [`crate::sync::SyncConsumer`] drives it under its +//! own cursor/anti-rewind state machine and builds its own client, because it also serves a +//! static-token mode that has no session to refresh. use std::ops::Deref; use std::sync::Arc; +use reqwest::header::{AUTHORIZATION, HeaderValue}; +use secrecy::ExposeSecret; + use crate::auth::Session; use crate::rest::{self, Client, Credential}; @@ -101,11 +120,113 @@ impl Deref for AuthenticatedClient { } } +/// The bearer prefix the `Authorization` header carries, and the only credential shape +/// [`RefreshOn401`] recognises as a token it can refresh. +const BEARER_PREFIX: &str = "Bearer "; + +/// The reactive half of the refresh contract (slice `S-D17`): an [`rest::HttpBackend`] that, +/// on a `401`, refreshes the session once and replays the request exactly once. +/// +/// # Why the transport seam and not [`rest::Middleware`] +/// +/// A middleware receives a `Next`, and `Next::run` takes `self` by value while `Next` is +/// neither `Clone` nor constructible outside the generated runtime. A middleware therefore +/// *cannot* send a second time, which is the one thing this layer must do. The backend seam +/// has no such constraint, and spargen's own `RetryBackend` is the precedent — including the +/// `Request::try_clone()`-returns-`None` rule for one-shot bodies. +/// +/// # Exactly once, by construction +/// +/// The replay is straight-line code, not a loop with a counter: one send, one refresh, one +/// replay, and whatever the replay answers is returned as-is. A second `401` is surfaced. +struct RefreshOn401 { + inner: Arc, + session: Session, +} + +// `Session` is not `Debug` (it holds token material), and `HttpBackend` requires `Debug` so the +// generated `ClientCore` stays printable. The manual impl names the layer and the backend under +// it, and shows nothing of the session. +impl std::fmt::Debug for RefreshOn401 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RefreshOn401") + .field("inner", &self.inner) + .finish_non_exhaustive() + } +} + +impl rest::HttpBackend for RefreshOn401 { + fn execute(&self, request: reqwest::Request) -> rest::ExecuteFuture<'_> { + // Own clones of the `Arc`/`Session` so the returned future is self-contained, matching + // the seam's expectations (and `RetryBackend`'s shape). + let inner = self.inner.clone(); + let session = self.session.clone(); + Box::pin(async move { + // A one-shot streaming body cannot be resent intact. Execute the original once and + // return: half a body on the wire twice is worse than a `401` the caller can see. + let Some(mut replay) = request.try_clone() else { + tracing::debug!("request body is not replayable; a 401 will not be retried"); + return inner.execute(request).await; + }; + + let response = inner.execute(request).await?; + if response.status() != reqwest::StatusCode::UNAUTHORIZED { + return Ok(response); + } + + // An operation carrying no bearer has nothing to refresh — that `401` is the + // server's answer about the request, not about a stale token. + let Some(stale) = bearer_of(&replay) else { + tracing::debug!("a 401 arrived on a request carrying no bearer; not retrying"); + return Ok(response); + }; + + let fresh = match session.refresh_rejected(&stale).await { + Ok(fresh) => fresh, + // Deliberately the *original* `401`, not a synthetic transport error: the + // generated operation then maps it to its typed `Status401` and the caller + // reads the server's own `error.*` code — which matters because an unreadable + // revocation ledger is also rendered as `401`, and only that code separates an + // outage from an expiry. + Err(error) => { + tracing::warn!(%error, "a 401 could not be recovered; surfacing it"); + return Ok(response); + } + }; + let Ok(header) = + HeaderValue::from_str(&format!("{BEARER_PREFIX}{}", fresh.expose_secret())) + else { + tracing::warn!( + "the refreshed token is not a valid header value; surfacing the 401" + ); + return Ok(response); + }; + replay.headers_mut().insert(AUTHORIZATION, header); + tracing::info!("the typed client's 401 was refreshed; replaying once"); + inner.execute(replay).await + }) + } +} + +/// The bearer token a prepared request carries, if any. `None` for an unauthenticated +/// operation, and for any credential shape this layer cannot refresh. +fn bearer_of(request: &reqwest::Request) -> Option { + request + .headers() + .get(AUTHORIZATION)? + .to_str() + .ok()? + .strip_prefix(BEARER_PREFIX) + .map(str::to_owned) +} + /// Wire a generated client to `base_url` with a bearer credential that pulls a fresh access -/// token from `session` on demand (pre-flight refresh + single-flight live in the session). +/// token from `session` on demand (the proactive half), executing through [`RefreshOn401`] +/// (the reactive half). fn build_client(base_url: &str, session: Session) -> Result { + let provider_session = session.clone(); let provider: rest::TokenProvider = Arc::new(move || { - let session = session.clone(); + let session = provider_session.clone(); // The session yields a currently-valid bearer, refreshing pre-flight if the stored // token is within its refresh skew of expiry; the failure is mapped into spargen's // provider-error type so a dead session is a request-construction error, not a 401. @@ -117,7 +238,14 @@ fn build_client(base_url: &str, session: Session) -> Result }) }); - let client = Client::with_client(reqwest_client(), base_url) + // `with_backend` builds requests on a default `reqwest::Client` and *executes* them + // through the backend, so the executing client — and with it the TLS stack, the redirect + // policy and the timeouts — is still `reqwest_client()`, one layer down. + let backend: Arc = Arc::new(RefreshOn401 { + inner: Arc::new(rest::ReqwestBackend::new(reqwest_client())), + session, + }); + let client = Client::with_backend(backend, base_url) .map_err(|e| ClientError::InvalidBaseUrl { url: base_url.to_string(), reason: e.to_string(), @@ -128,10 +256,22 @@ fn build_client(base_url: &str, session: Session) -> Result /// The generated client's transport: rustls only (the SDK's `reqwest` has no default features /// and only `rustls-tls`), matching the rest of the SDK's network stack. +/// +/// **One per process, shared.** A `reqwest::Client` owns a connection pool, and cloning it +/// shares that pool; building a new one throws the pool away. The FFI's escrow verbs construct +/// a fresh [`AuthenticatedClient`] per call (the API root is a per-call argument), so a +/// per-client transport would mean a fresh TLS handshake for every escrow read on a device +/// that does several during one cadence prompt. Nothing here is configured per instance, so +/// there is nothing to vary: the same client serves them all. fn reqwest_client() -> reqwest::Client { - reqwest::Client::builder() - .build() - .expect("a default rustls reqwest client is always constructible") + static SHARED: std::sync::OnceLock = std::sync::OnceLock::new(); + SHARED + .get_or_init(|| { + reqwest::Client::builder() + .build() + .expect("a default rustls reqwest client is always constructible") + }) + .clone() } #[cfg(test)] @@ -358,6 +498,248 @@ mod tests { ); } + /// An RFC 9457 problem body shaped as the generated `CodedProblem`, so a documented + /// non-success status parses into the operation's typed error rather than a decode + /// failure. + fn problem(status: u16, code: &str) -> String { + serde_json::json!({ + "type": "about:blank", + "title": "Unauthorized", + "status": status, + "detail": "the access token was refused", + "code": code, + }) + .to_string() + } + + /// How many requests the mock saw for `path`. + fn hits(server: &MockServer, path: &str) -> usize { + server + .requests + .lock() + .unwrap() + .iter() + .filter(|r| r.path == path) + .count() + } + + /// The bearer each request for `path` carried, in order. + fn bearers(server: &MockServer, path: &str) -> Vec> { + server + .requests + .lock() + .unwrap() + .iter() + .filter(|r| r.path == path) + .map(|r| r.authorization.clone()) + .collect() + } + + // ── S-D17: the reactive half ──────────────────────────────────────────────────────── + + /// **The slice's Done-when.** A token that is valid as far as the *client* can tell and + /// refused by the server — the race the pre-flight check cannot close — is refreshed once + /// and the call replayed once, and the replay carries the rotated token. + #[tokio::test] + async fn a_401_is_refreshed_once_and_the_call_replayed() { + let seen = Arc::new(AtomicUsize::new(0)); + let quota_calls = seen.clone(); + let handler: Handler = Arc::new(move |path| { + let quota_calls = quota_calls.clone(); + Box::pin(async move { + match path.as_str() { + "/refresh" => MockResponse { + status: 200, + body: token_json("access-2", "refresh-2", far_future()), + }, + // The first attempt is refused; the replay is honoured. A server that + // revoked the session mid-flight looks exactly like this. + "/v1/quota" => { + if quota_calls.fetch_add(1, Ordering::SeqCst) == 0 { + MockResponse { + status: 401, + body: problem( + 401, + capsule_i18n::error_codes::REQUEST_UNAUTHENTICATED, + ), + } + } else { + MockResponse { + status: 200, + body: r#"{"state":"ok","used":5}"#.to_string(), + } + } + } + _ => MockResponse { + status: 404, + body: "{}".to_string(), + }, + } + }) + }); + let server = start_mock(handler).await; + // Far-future expiry: the pre-flight check is satisfied, so the *only* thing that can + // rescue this call is the reactive layer. + let session = session_with(&server.base_url, "access-1", "refresh-1", far_future()); + let client = AuthenticatedClient::new(&server.base_url, session).unwrap(); + + let quota = client.get_quota().await.unwrap().into_inner(); + assert_eq!( + quota.used, 5, + "the replayed call is the one the caller sees" + ); + + assert_eq!(hits(&server, "/refresh"), 1, "exactly one refresh"); + assert_eq!( + hits(&server, "/v1/quota"), + 2, + "one attempt, one replay — no loop" + ); + assert_eq!( + bearers(&server, "/v1/quota"), + vec![ + Some("Bearer access-1".to_string()), + Some("Bearer access-2".to_string()), + ], + "the replay must carry the rotated token, not the one the server just refused" + ); + } + + /// A server that refuses every credential is answered with exactly one replay, and the + /// second `401` reaches the caller as the operation's typed error carrying the server's + /// own `error.*` code. `exactly once` is the property: not twice, not a loop. + #[tokio::test] + async fn a_persistent_401_is_surfaced_after_exactly_one_replay() { + let handler: Handler = Arc::new(|path| { + Box::pin(async move { + match path.as_str() { + "/refresh" => MockResponse { + status: 200, + body: token_json("access-2", "refresh-2", far_future()), + }, + "/v1/quota" => MockResponse { + status: 401, + body: problem(401, capsule_i18n::error_codes::REQUEST_UNAUTHENTICATED), + }, + _ => MockResponse { + status: 404, + body: "{}".to_string(), + }, + } + }) + }); + let server = start_mock(handler).await; + let session = session_with(&server.base_url, "access-1", "refresh-1", far_future()); + let client = AuthenticatedClient::new(&server.base_url, session).unwrap(); + + let error = client + .get_quota() + .await + .expect_err("a credential the server never honours must fail"); + let rest::Error::Api(response) = &error else { + panic!("expected the operation's typed API error, got {error:?}"); + }; + let rest::GetQuotaError::Status401(problem) = response.inner() else { + panic!("expected a typed 401, got {:?}", response.inner()); + }; + assert_eq!( + problem.code, + capsule_i18n::error_codes::REQUEST_UNAUTHENTICATED + ); + + assert_eq!(hits(&server, "/refresh"), 1); + assert_eq!( + hits(&server, "/v1/quota"), + 2, + "exactly one replay — a retry loop would keep going" + ); + } + + /// When the refresh itself fails, the caller gets the **server's** `401` back rather than + /// a synthesized transport error — so the typed `Status401` mapping still fires and the + /// `error.*` code survives. That code is the only thing separating an expired token from + /// an unreadable revocation ledger, which the server also renders as `401`. + #[tokio::test] + async fn a_401_whose_refresh_fails_keeps_the_servers_own_401() { + let handler: Handler = Arc::new(|path| { + Box::pin(async move { + match path.as_str() { + // The refresh token is gone too: nothing here can be rescued. + "/refresh" => MockResponse { + status: 401, + body: problem(401, capsule_i18n::error_codes::AUTH_SESSION_EXPIRED), + }, + "/v1/quota" => MockResponse { + status: 401, + body: problem(401, capsule_i18n::error_codes::AUTH_UNAVAILABLE), + }, + _ => MockResponse { + status: 404, + body: "{}".to_string(), + }, + } + }) + }); + let server = start_mock(handler).await; + let session = session_with(&server.base_url, "access-1", "refresh-1", far_future()); + let client = AuthenticatedClient::new(&server.base_url, session).unwrap(); + + let error = client + .get_quota() + .await + .expect_err("nothing can rescue this"); + let rest::Error::Api(response) = &error else { + panic!("a failed refresh must not mask the 401 as a transport error: {error:?}"); + }; + let rest::GetQuotaError::Status401(problem) = response.inner() else { + panic!("expected a typed 401, got {:?}", response.inner()); + }; + assert_eq!( + problem.code, + capsule_i18n::error_codes::AUTH_UNAVAILABLE, + "the code the caller reads is the one the *operation* answered, not the refresh's" + ); + assert_eq!( + hits(&server, "/v1/quota"), + 1, + "a refresh that failed produces nothing worth replaying" + ); + } + + /// An unauthenticated operation's `401` is the server's answer about the request, not + /// about a stale token: there is no bearer to refresh, so nothing is refreshed and + /// nothing is replayed. + #[tokio::test] + async fn an_unauthenticated_401_is_never_retried() { + let handler: Handler = Arc::new(|path| { + Box::pin(async move { + match path.as_str() { + "/refresh" => MockResponse { + status: 200, + body: token_json("access-2", "refresh-2", far_future()), + }, + _ => MockResponse { + status: 401, + body: problem(401, capsule_i18n::error_codes::REQUEST_UNAUTHENTICATED), + }, + } + }) + }); + let server = start_mock(handler).await; + let session = session_with(&server.base_url, "access-1", "refresh-1", far_future()); + let client = AuthenticatedClient::new(&server.base_url, session).unwrap(); + + // `get_version` declares no security requirement, so the generated client attaches no + // credential at all. + client + .get_version() + .await + .expect_err("the mock refuses everything"); + assert_eq!(hits(&server, "/v1/version"), 1, "one request, no replay"); + assert_eq!(hits(&server, "/refresh"), 0, "and no refresh"); + assert_eq!(bearers(&server, "/v1/version"), vec![None]); + } + /// The session's pre-flight refresh fires *through the revived client*: with a stored /// access token already past expiry, the first typed call refreshes once (via the token /// provider), and the API request carries the rotated token — not the stale one. diff --git a/capsule-server/tests/sdk_client.rs b/capsule-server/tests/sdk_client.rs index a634e6e4..effe2bf2 100644 --- a/capsule-server/tests/sdk_client.rs +++ b/capsule-server/tests/sdk_client.rs @@ -393,3 +393,70 @@ async fn the_sdk_stores_and_fetches_an_escrow_over_a_socket() { happens to answer" ); } + +/// **`S-D17`'s Done-when, against the server that decides.** A token the client still believes +/// in and the server has stopped honouring is refreshed once and the call replayed once. +/// +/// The unit tests cover the layer against a mock; this covers the one thing a mock cannot rule +/// out — that the two ends disagree about when an access token dies. The server validates `exp` +/// against its **injected** clock (`capsule_server::auth::tokens`, deliberately, so a test can +/// walk over an expiry), so advancing the fixture's clock past `ACCESS_TOKEN_TTL` revokes the +/// access token for real while the session and its refresh token remain live. +/// +/// The client is then handed the same token pair with a far-future expiry, which is exactly the +/// state a client is in whenever it trusted a server-supplied deadline and the server changed +/// its mind first — a revocation, a clock skew, a rotated signing key. Because the client sees +/// no reason to refresh, the pre-flight half cannot fire, so a call that succeeds here succeeded +/// through the reactive layer and nothing else. +#[tokio::test] +async fn a_token_the_server_stopped_honouring_is_refreshed_and_the_call_replayed() { + use capsule_sdk::auth::PersistedSession; + use capsule_sdk::client::AuthenticatedClient; + use secrecy::ExposeSecret as _; + + let fixture = Fixture::working(); + let base_url = serve(&fixture).await; + let signed_in = session(&base_url).await; + let pair = signed_in.export().await.expect("a live session exports"); + let stale_access = pair.access_token.expose_secret().to_owned(); + + // Past the access token's life, well inside the session's. The refresh token still works; + // the access token does not. + fixture.clock.advance( + capsule_server::auth::ACCESS_TOKEN_TTL + .checked_add(jiff::SignedDuration::from_secs(60)) + .expect("a representable instant"), + ); + + // The same pair, with a deadline the client has no reason to doubt. + let session = AuthClient::new(&format!("{base_url}/v1/auth")) + .expect("a base url") + .resume(PersistedSession { + access_token: stale_access.clone().into(), + refresh_token: pair.refresh_token, + access_expires_at_unix: Timestamp::now().as_second() + 3600, + }) + .expect("a session resumes from any pair"); + let client = AuthenticatedClient::new(&base_url, session).expect("an API root parses"); + + // A generated operation, called straight through the Deref — nothing about this call site + // knows a retry layer exists, which is the point of putting it at the transport seam. + let quota = client + .get_quota() + .await + .expect("the 401 is recovered and the call replayed") + .into_inner(); + assert_eq!(quota.state.as_str(), "ok"); + + let after = client + .session() + .export() + .await + .expect("the session is still live"); + assert_ne!( + after.access_token.expose_secret(), + stale_access.as_str(), + "the replay must have ridden a rotated token; an unchanged one would mean the server \ + accepted a token it had already stopped honouring" + ); +} From 72e59217b61a823255b38d2573612d534f0a641b Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 23:16:42 -0400 Subject: [PATCH 059/243] fix(core): widen the downscale's integer arithmetic past 32-bit overflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two products in `downscale_rgba8` were computed at widths that a reachable input overflows, both found by re-reading the diff rather than by a failing test: - the destination-to-source boundary `(y + 1) * src_h` reaches `dst_edge * src_edge`. A 1 x 300000 frame reduced to a 256 px long edge makes that 7.7e10, past a 32-bit `usize` — and `armv7-linux-androideabi` and `i686-linux-android` are both CI-gated targets; - the per-channel accumulator was `u32` and reaches `count * 255`, where `count` is the whole frame when the function is called with a cap of 1. `downscale_rgba8` is a `pub` entry point, so that cap is reachable even though the tier table only ever passes 256. A debug build panics on either; a release build wraps into wrong pixels or an out-of-bounds index — inside a derivative whose bytes are signed. Both are now `u64`, with a test at each shape. Also merges the identical `match` arms clippy's `match_same_arms` flagged (`standard_format`'s container mapping, `gamut_of`'s sRGB default) and drops two other lint-level nits. The merged arms lose nothing: the RAW families map to the container `rawshift-image` actually sees, which is the same TIFF for all of them, and one wildcard is honester than an explicit list beside a catch-all with the same body. --- capsule-core/src/lifecycle/derivatives.rs | 5 +-- capsule-core/src/media/decode.rs | 45 ++++++++++++----------- capsule-core/src/media/resize.rs | 36 +++++++++++++----- capsule-core/src/media/tests.rs | 42 +++++++++++++++++++++ 4 files changed, 92 insertions(+), 36 deletions(-) diff --git a/capsule-core/src/lifecycle/derivatives.rs b/capsule-core/src/lifecycle/derivatives.rs index 2b0fb9af..1670e4e8 100644 --- a/capsule-core/src/lifecycle/derivatives.rs +++ b/capsule-core/src/lifecycle/derivatives.rs @@ -265,10 +265,7 @@ impl Workspace { for derivative in derivatives { // The `original` sentinel references the source asset, so its bytes carry the // source's own extension. - let format_ext = derivative - .format - .extension() - .unwrap_or_else(|| asset.ext.as_str()); + let format_ext = derivative.format.extension().unwrap_or(asset.ext.as_str()); let path = dir.join(format!( "{stem}.{}.{format_ext}", derivative.tier.role_name() diff --git a/capsule-core/src/media/decode.rs b/capsule-core/src/media/decode.rs index d7dbbb00..65a4dce7 100644 --- a/capsule-core/src/media/decode.rs +++ b/capsule-core/src/media/decode.rs @@ -79,7 +79,7 @@ impl MediaMetadata { pub const fn upright_dimensions(&self) -> (u32, u32) { let (width, height) = self.stored_dimensions; match self.orientation { - Some(5 | 6 | 7 | 8) => (height, width), + Some(5..=8) => (height, width), _ => (width, height), } } @@ -237,17 +237,15 @@ pub fn decode_guarded( bytes: &[u8], ext: &str, ) -> Result { - match catch_unwind(AssertUnwindSafe(|| decoder.decode(bytes, ext))) { - Ok(result) => result, - Err(_) => { - tracing::warn!( - bytes = bytes.len(), - ext, - "media: a decoder panicked; the original is imported without a derivative" - ); - Err(MediaError::DecoderPanic) - } + if let Ok(result) = catch_unwind(AssertUnwindSafe(|| decoder.decode(bytes, ext))) { + return result; } + tracing::warn!( + bytes = bytes.len(), + ext, + "media: a decoder panicked; the original is imported without a derivative" + ); + Err(MediaError::DecoderPanic) } /// Identify a still and refuse anything this build has no codec for, before any decoder runs. @@ -271,21 +269,23 @@ fn standard_format(format: StillFormat) -> StandardFormat { StillFormat::Png => StandardFormat::Png, StillFormat::WebP => StandardFormat::WebP, StillFormat::Jxl => StandardFormat::Jxl, - StillFormat::Tiff => StandardFormat::Tiff, StillFormat::Gif => StandardFormat::Gif, StillFormat::Ppm => StandardFormat::Ppm, - // Unreachable through `gate`. Mapped to the container the bytes actually are rather - // than panicking, so a future `is_decodable` widening that forgets this table degrades - // to a decode error instead of aborting an import. StillFormat::Avif => StandardFormat::Avif, - StillFormat::Heic => StandardFormat::Heic, - StillFormat::Cr3 => StandardFormat::Heic, - StillFormat::Arw + // The container, for the formats whose container is all `rawshift-image` models: the + // TIFF-based RAW families are a TIFF to it, and Canon's CR3 is an ISO-BMFF file it can + // only reach through its HEIC arm. Every one of these is unreachable through `gate`, + // which refuses a non-decodable format before this runs. Mapped to the truth rather + // than panicking so a future `is_decodable` widening that forgets this table degrades + // to a decode error instead of aborting an import. + StillFormat::Tiff + | StillFormat::Arw | StillFormat::Cr2 | StillFormat::Crw | StillFormat::Dng | StillFormat::Nef | StillFormat::Raf => StandardFormat::Tiff, + StillFormat::Heic | StillFormat::Cr3 => StandardFormat::Heic, } } @@ -310,10 +310,11 @@ fn gamut_of(color_space: ColorSpace) -> Gamut { ColorSpace::AdobeRgb => Gamut::AdobeRgb, ColorSpace::Rec2020 => Gamut::Bt2020, ColorSpace::ProPhotoRgb => Gamut::ProPhotoRgb, - ColorSpace::Srgb | ColorSpace::LinearSrgb | ColorSpace::Unknown => Gamut::Srgb, - // `ColorSpace` is `#[non_exhaustive]`, so a future wide-gamut variant must land here - // rather than fail the build. sRGB is the conservative default: under-saturating a - // wide-gamut source is a smaller defect than over-saturating a narrow one. + // `Srgb`, `LinearSrgb`, `Unknown`, and — because `ColorSpace` is `#[non_exhaustive]` — + // any variant a future release adds. One wildcard rather than an explicit list plus a + // catch-all, since the answer is the same and two arms with one body only look like a + // distinction. sRGB is the conservative default: under-saturating a wide-gamut source + // is a smaller defect than over-saturating a narrow one. _ => Gamut::Srgb, } } diff --git a/capsule-core/src/media/resize.rs b/capsule-core/src/media/resize.rs index b12d796f..fc69c107 100644 --- a/capsule-core/src/media/resize.rs +++ b/capsule-core/src/media/resize.rs @@ -68,27 +68,43 @@ pub fn downscale_rgba8(source: &RgbaImage, max_long_edge: u32) -> RgbaImage { let (dw, dh) = (dst_w as usize, dst_h as usize); let mut out = Vec::with_capacity(dw * dh * 4); + // Boundary products and the channel accumulator are `u64`, not `usize`/`u32`, and both + // widths are load-bearing rather than defensive habit: + // + // - `(y + 1) * src_h` reaches `dst_edge * src_edge`. For a 1 x 256M frame reduced to a + // 256 px long edge that is 6.5e10, which overflows a 32-bit `usize` — and two of the + // CI-gated targets (`armv7-linux-androideabi`, `i686-linux-android`) are 32-bit. + // - the per-channel sum reaches `count * 255`, and `count` is the whole frame when this is + // called with a cap of 1 (a `pub` entry point, so that is reachable), i.e. 6.5e10 again — + // past `u32::MAX`. + // + // Neither is hypothetical-only: a debug build panics on the overflow and a release build + // wraps into wrong pixels or an out-of-bounds index. for y in 0..dh { // The source rows this destination row averages. Floor boundaries, so the destination // grid is an exact partition of the source grid — every source pixel contributes to // exactly one output pixel. Widened to at least one row because a lopsided cap can put // two destination rows inside one source row, and an empty rect would divide by zero. - let y0 = y * src_h / dh; - let y1 = ((y + 1) * src_h / dh).max(y0 + 1).min(src_h); + let y0 = (y as u64 * src_h as u64 / dh as u64) as usize; + let y1 = (((y as u64 + 1) * src_h as u64 / dh as u64) as usize) + .max(y0 + 1) + .min(src_h); for x in 0..dw { - let x0 = x * src_w / dw; - let x1 = ((x + 1) * src_w / dw).max(x0 + 1).min(src_w); + let x0 = (x as u64 * src_w as u64 / dw as u64) as usize; + let x1 = (((x as u64 + 1) * src_w as u64 / dw as u64) as usize) + .max(x0 + 1) + .min(src_w); - let mut acc = [0u32; 4]; - let count = ((y1 - y0) * (x1 - x0)) as u32; + let mut acc = [0u64; 4]; + let count = ((y1 - y0) * (x1 - x0)) as u64; for sy in y0..y1 { let row = sy * src_w * 4; for sx in x0..x1 { let i = row + sx * 4; - acc[0] += u32::from(source.rgba[i]); - acc[1] += u32::from(source.rgba[i + 1]); - acc[2] += u32::from(source.rgba[i + 2]); - acc[3] += u32::from(source.rgba[i + 3]); + acc[0] += u64::from(source.rgba[i]); + acc[1] += u64::from(source.rgba[i + 1]); + acc[2] += u64::from(source.rgba[i + 2]); + acc[3] += u64::from(source.rgba[i + 3]); } } // Round-half-up on the mean, so a uniform region reproduces its own value exactly diff --git a/capsule-core/src/media/tests.rs b/capsule-core/src/media/tests.rs index 14520bae..06e71dbe 100644 --- a/capsule-core/src/media/tests.rs +++ b/capsule-core/src/media/tests.rs @@ -1310,3 +1310,45 @@ fn a_decoded_frame_encodes_an_lqip_at_the_committed_width() { "the tier is fixed regardless of the source size" ); } + +/// The two integer widths the downscale depends on, exercised at the shapes that would overflow +/// a narrower one. +/// +/// A 1 x 300000 frame reduced to a 256 px long edge makes `(y + 1) * src_h` reach 7.7e10, past a +/// 32-bit `usize` — and `armv7-linux-androideabi` and `i686-linux-android` are both CI-gated +/// targets. Reducing a frame to a **cap of 1** makes the per-channel accumulator reach +/// `w * h * 255`, past `u32::MAX` for a frame of any size; `downscale_rgba8` is a `pub` entry +/// point, so that cap is reachable even though the tier table only ever passes 256. +#[test] +fn the_downscale_survives_the_shapes_that_overflow_narrow_arithmetic() { + // A tall, one-pixel-wide frame: every destination row averages a large run of source rows. + let tall = RgbaImage { + width: 1, + height: 300_000, + rgba: vec![200, 100, 50, 255].repeat(300_000), + }; + let reduced = downscale_rgba8(&tall, 256); + assert_eq!((reduced.width, reduced.height), (1, 256)); + assert_eq!(reduced.rgba.len(), 256 * 4); + assert!( + reduced + .rgba + .chunks_exact(4) + .all(|px| px == [200, 100, 50, 255]), + "a flat frame survives a 1172x row reduction exactly" + ); + + // A cap of 1: one destination pixel accumulates the entire frame. + let wide = RgbaImage { + width: 600, + height: 400, + rgba: vec![255, 255, 255, 255].repeat(600 * 400), + }; + let single = downscale_rgba8(&wide, 1); + assert_eq!((single.width, single.height), (1, 1)); + assert_eq!( + single.rgba, + vec![255, 255, 255, 255], + "240000 samples at 255 each sum past u32::MAX and must still average to 255" + ); +} From 2605cc3e4a92de1e9036167805b83a0c17d679ed Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 23:20:46 -0400 Subject: [PATCH 060/243] feat(server): add the gc, purge and scrub operator commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `filesystem/maintenance.md` has described these as operator-invoked commands, schedulable as jobs, since before there was a binary to invoke them from. They existed only as library functions with no entry point. Dry run is the default for the two that write, because the first thing an operator does with a collector is find out what it thinks; `--apply` opts in and the report says which posture produced it. `scrub` exits 1 on a non-empty report and mutates nothing, which is what makes it usable as a monitoring probe — and a truncated deep pass is reported as truncated, because a clean report from a pass that stopped early is the one answer a scrub must never give. Reports name what they found rather than counting it. `CollectionReport`'s own docs are why: "a count tells an operator that something happened without telling them what to look at." A scrub's findings print through their own `Debug`, so a variant added later renders as itself instead of as nothing. `boot` splits into `assemble` and `assemble_maintenance`. That is not tidiness: `config` claims `gc`/`purge`/`scrub` need no key material, and the way to make that true is for the assembly they use to have none in scope, not for it to build a token signer it then ignores. A maintenance host that had to hold the production signing key to sweep a directory would be a reason to put the key on a maintenance host. Both entry points build the same `Stores`, so the application and the workers never disagree about what is in the index. `tests/binary.rs` covers all three against a seeded blob root: the dry run names the unreferenced blob and leaves it, `--apply` marks it (the sweep is a later pass, and this profile's mark store does not outlive the process), the scrub exits 1 with the store byte-identical afterwards, `--deep` finds the byte mismatch a structural pass cannot see, and none of them is given a signing key. Refs #401 --- capsule-server/src/boot.rs | 185 +++++++++---- capsule-server/src/cli.rs | 456 ++++++++++++++++++++++++++++++++- capsule-server/tests/binary.rs | 180 ++++++++++++- 3 files changed, 759 insertions(+), 62 deletions(-) diff --git a/capsule-server/src/boot.rs b/capsule-server/src/boot.rs index 8e2dbe7e..a46ae8f1 100644 --- a/capsule-server/src/boot.rs +++ b/capsule-server/src/boot.rs @@ -142,21 +142,37 @@ pub enum BootError { }, } -/// A server, ready to serve or to sweep. +/// The two operator workers' collaborators. /// -/// The three things a subcommand can want out of one assembly: the application the router is -/// built with, and the two operator workers, which have no wire surface at all and therefore -/// cannot be reached through it. +/// Assembled **without any key material**, which is what makes `config`'s claim that +/// `gc`/`purge`/`scrub` need none structural rather than a promise: there is no signing key in +/// scope here to accidentally require. A maintenance host that had to hold the production +/// token-signing key to sweep a directory would be a reason to put the key on a maintenance +/// host. +/// +/// Neither worker has a wire surface, so neither is reachable through the router — which is why +/// they are a separate assembly rather than fields on [`App`]. #[derive(Debug)] -pub struct Assembled { - /// The application context every operation resolves its dependencies from. - pub app: App, +pub struct Maintenance { /// The collector's collaborators (`gc`, `purge`). pub collection: CollectionContext, /// The integrity scrub's collaborators (`scrub`). pub scrub: ScrubContext, } +/// A server, ready to serve. +/// +/// Carries [`Maintenance`] as well, over the **same** stores: one index, one blob store and one +/// mark store behind all three, which is what makes "upload it, then let the collector see it" a +/// property of the server rather than of three disconnected assemblies. +#[derive(Debug)] +pub struct Assembled { + /// The application context every operation resolves its dependencies from. + pub app: App, + /// The operator workers, over the same stores. + pub maintenance: Maintenance, +} + impl Assembled { /// Build the service the listener drives. /// @@ -177,28 +193,118 @@ impl Assembled { /// Returns [`BootError`] for any of the startup failures above. Nothing is left half-built: the /// blob root is the only side effect, and it is idempotent. pub async fn assemble(config: &Config) -> Result { + let stores = stores(config).await?; match config.backends { - Backends::Memory => memory(config).await, - // The refusal `store/mod.rs` documents. `Config::load` already turned "no `VALKEY_URL` - // and no `--memory`" into a configuration fault naming the variable, so reaching here - // means the operator *did* set it — and the honest answer is that nothing reads it yet. - Backends::Durable => Err(BootError::AdapterUnavailable { - key: "VALKEY_URL", - issue: "#403 (Valkey) and #402 (Postgres)", - }), + Backends::Memory => memory(config, stores), + Backends::Durable => Err(durable()), + } +} + +/// Assemble only what `gc`, `purge` and `scrub` read. +/// +/// # Errors +/// +/// Returns [`BootError`] for the blob root or an unimplemented durable backend. It cannot fail +/// on key material, because it asks for none. +pub async fn assemble_maintenance(config: &Config) -> Result { + let stores = stores(config).await?; + match config.backends { + Backends::Memory => { + let maintenance = stores.maintenance(config.grace_window); + tracing::info!( + blob_root = %stores.root.display(), + grace_window = %config.grace_window, + "assembled the operator workers on the in-memory adapters" + ); + Ok(maintenance) + } + Backends::Durable => Err(durable()), + } +} + +/// The refusal `store/mod.rs` documents. +/// +/// `Config::load` already turned "no `VALKEY_URL` and no `--memory`" into a configuration fault +/// naming the variable, so reaching here means the operator *did* set it — and the honest answer +/// is that nothing reads it yet. +fn durable() -> BootError { + BootError::AdapterUnavailable { + key: "VALKEY_URL", + issue: "#403 (Valkey) and #402 (Postgres)", + } +} + +/// The stores every subcommand shares, and the only one of them that is durable. +/// +/// A struct rather than six locals because [`assemble`] and [`assemble_maintenance`] must build +/// the *same* stores: two functions each opening their own index is two servers that disagree +/// about what is in it. +#[derive(Debug)] +struct Stores { + root: std::path::PathBuf, + clock: Arc, + blobs: Arc, + index: Arc, + uploads: Arc, + marks: Arc, + quotas: Arc, +} + +impl Stores { + /// The operator workers over these stores. + fn maintenance(&self, grace_window: jiff::SignedDuration) -> Maintenance { + Maintenance { + collection: CollectionContext::new( + self.index.clone(), + self.blobs.clone(), + self.marks.clone(), + self.quotas.clone(), + self.clock.clone(), + grace_window, + ), + scrub: ScrubContext::new(self.index.clone(), self.blobs.clone(), self.uploads.clone()), + } } } +/// Open the blob root and build the stores over it. +/// +/// The blob root is the one thing on this path that touches the filesystem, and it is refused +/// rather than deferred: a store that cannot be created now is a store every upload will fail +/// against at write time, and a server that accepts bytes it cannot keep is worse than one that +/// does not start. +async fn stores(config: &Config) -> Result { + let root = config + .blob_root + .clone() + .ok_or(BootError::Missing { key: "BLOB_ROOT" })?; + let clock = Arc::new(SystemClock); + let blobs = + Arc::new( + FilesystemBlobStore::open(&root) + .await + .map_err(|error| BootError::BlobRoot { + root: root.display().to_string(), + detail: error.to_string(), + })?, + ); + Ok(Stores { + root, + index: Arc::new(InMemoryAssetIndex::new()), + uploads: Arc::new(InMemoryUploadSessions::with_default_ttl(clock.clone())), + marks: Arc::new(InMemoryCollection::new()), + quotas: Arc::new(InMemoryQuota::new()), + blobs, + clock, + }) +} + /// The development profile: every in-crate adapter, over a real blob store and a real clock. #[allow( clippy::too_many_lines, reason = "seventeen module contexts, named once each; splitting it would hide the shape" )] -async fn memory(config: &Config) -> Result { - let root = config - .blob_root - .as_ref() - .ok_or(BootError::Missing { key: "BLOB_ROOT" })?; +fn memory(config: &Config, stores: Stores) -> Result { let der = config.signing_key_der.as_ref().ok_or(BootError::Missing { key: "JWT_ED25519_DER", })?; @@ -209,16 +315,16 @@ async fn memory(config: &Config) -> Result { key: "ATTESTATION_KEY_SEED", })?; - let clock = Arc::new(SystemClock); - let blobs = - Arc::new( - FilesystemBlobStore::open(root) - .await - .map_err(|error| BootError::BlobRoot { - root: root.display().to_string(), - detail: error.to_string(), - })?, - ); + // Cloned rather than moved: `stores` is handed to `Stores::maintenance` at the end, so the + // application and the two operator workers are built over the *same* stores. Every clone + // here is an `Arc` refcount bump. + let root = stores.root.clone(); + let clock = stores.clock.clone(); + let blobs = stores.blobs.clone(); + let index = stores.index.clone(); + let uploads = stores.uploads.clone(); + let marks = stores.marks.clone(); + let quotas = stores.quotas.clone(); // The signer is built from the private key alone and derives its own public half, which is // what lets `ServerInfo` below publish the key tokens actually verify under rather than one @@ -238,8 +344,6 @@ async fn memory(config: &Config) -> Result { })?; let accounts = Arc::new(InMemoryAccounts::new(credentials)); - let index = Arc::new(InMemoryAssetIndex::new()); - let uploads = Arc::new(InMemoryUploadSessions::with_default_ttl(clock.clone())); let albums = Arc::new(InMemoryAlbums::new()); let directories = Arc::new(InMemoryDeviceDirectory::new()); // The production write authority (`S-C19`/`S-C20`), not a permissive double: it reads the @@ -250,8 +354,6 @@ async fn memory(config: &Config) -> Result { directories.clone(), clock.clone(), )); - let quotas = Arc::new(InMemoryQuota::new()); - let marks = Arc::new(InMemoryCollection::new()); let receipts = Arc::new(InMemoryReceipts::new()); // Distinct from the token signer, as the design requires: a receipt that verified under the // operational key would let anything holding that key manufacture custody evidence. The @@ -358,19 +460,10 @@ async fn memory(config: &Config) -> Result { ); Ok(Assembled { - // One index, one blob store and one mark store behind all three, which is what makes - // "upload it, then let the collector see it" a property of the server rather than of - // three disconnected assemblies. - collection: CollectionContext::new( - index.clone(), - blobs.clone(), - marks, - quotas, - clock, - config.grace_window, - ), - scrub: ScrubContext::new(index, blobs, uploads), app, + // The same stores, so "upload it, then let the collector see it" is a property of the + // server rather than of two disconnected assemblies. + maintenance: stores.maintenance(config.grace_window), }) } diff --git a/capsule-server/src/cli.rs b/capsule-server/src/cli.rs index 3550ab00..ffd496e5 100644 --- a/capsule-server/src/cli.rs +++ b/capsule-server/src/cli.rs @@ -31,10 +31,12 @@ use color_eyre::eyre::{Context as _, Result, bail, eyre}; use kynos::server::Server; use kynos::server::shutdown::Shutdown; use tracing_subscriber::prelude::*; -use tracing_subscriber::{EnvFilter, fmt}; +use tracing_subscriber::{EnvFilter, fmt as log_fmt}; -use crate::boot::{self, Assembled}; +use crate::boot::{self, Assembled, Maintenance}; use crate::config::{Config, Demands, Environment, LogFormat, Overrides, ProcessEnvironment}; +use crate::gc::{CollectionReport, Mode, PurgeReport}; +use crate::scrub::{Depth, ScrubReport}; /// The exit code a configuration refusal produces. /// @@ -50,6 +52,21 @@ pub const EXIT_MISCONFIGURED: u8 = 2; /// nothing", which is what makes it usable as a monitoring probe. pub const EXIT_FINDINGS: u8 = 1; +/// How many tombstoned assets one `purge` pass considers. +/// +/// A bound rather than a policy: the pass walks the index and a retention sweep on a large +/// deployment should be a job that finishes, not one that holds a read for an hour. An operator +/// who wants more runs it again. +const DEFAULT_PURGE_LIMIT: usize = 1_000; + +/// How many bytes one `scrub --deep` pass will read when no budget is given. +/// +/// One gibibyte. `Depth::Deep` carries a budget precisely because re-hashing every blob is +/// heavy I/O by definition, and "a scrub that saturates the disk is a scrub an operator turns +/// off". A truncated pass says so in its report, so the default cannot silently pass a store it +/// did not finish looking at. +const DEFAULT_SCRUB_BUDGET: u64 = 1024 * 1024 * 1024; + /// The Capsule server. #[derive(Debug, Parser)] #[command(name = "capsule-server", author, version, about, long_about = None)] @@ -101,6 +118,65 @@ pub enum Command { backend: BackendArgs, }, + /// Sweep blobs nothing references any more. + /// + /// Two passes, by design: a blob that reaches zero references is *marked*, and a later pass + /// sweeps it once the grace window has passed and the count is still zero. That is what + /// gives an in-flight finalization retry time to re-reference it. + Gc { + /// Carry it out. Without this nothing is marked, unmarked or swept. + /// + /// Dry run is the default for the two subcommands that write, because the first thing an + /// operator does with a collector is find out what it thinks. + #[arg(long)] + apply: bool, + + /// How long a blob must sit at zero references before it may be swept + /// (`GC_GRACE_WINDOW_HOURS`, default 24). + #[arg(long, value_name = "HOURS")] + grace_window_hours: Option, + + /// Where state lives. + #[command(flatten)] + backend: BackendArgs, + }, + + /// Drop the blob references of tombstoned assets whose retention window has passed. + /// + /// The tombstone itself stays: a client that has not synced since the delete still has to + /// learn about it, and removing the row would make the deletion invisible rather than final. + Purge { + /// Carry it out. Without this nothing is dropped. + #[arg(long)] + apply: bool, + + /// How many tombstoned assets to consider in this pass. + #[arg(long, value_name = "N", default_value_t = DEFAULT_PURGE_LIMIT)] + limit: usize, + + /// Where state lives. + #[command(flatten)] + backend: BackendArgs, + }, + + /// Compare the index against the store and report every disagreement. + /// + /// Mutates nothing, by construction, and exits non-zero on a non-empty report — which is + /// what makes it usable as a monitoring probe. + Scrub { + /// Also re-hash every blob's bytes: the bit-rot check. + #[arg(long)] + deep: bool, + + /// The most bytes a deep pass will read. Blobs past it are left for the next run. + #[arg(long, value_name = "BYTES", default_value_t = DEFAULT_SCRUB_BUDGET)] + budget: u64, + + /// Where state lives. + #[command(flatten)] + backend: BackendArgs, + }, + /// Emit the OpenAPI 3.2 document the SDK's client is generated from. /// /// Needs no database, no Valkey, no key material, no disk and no network: the router is @@ -121,6 +197,9 @@ impl Command { fn demands(&self) -> Demands { match self { Self::Serve { .. } => Demands::Serve, + // No key material. A maintenance host that had to hold the production + // token-signing key to sweep a directory would be a reason to put the key there. + Self::Gc { .. } | Self::Purge { .. } | Self::Scrub { .. } => Demands::Maintenance, Self::GenOpenapi { .. } => Demands::Nothing, } } @@ -137,6 +216,19 @@ impl Command { overrides.blob_root.clone_from(&backend.blob_root); overrides.memory = backend.memory; } + Self::Gc { + grace_window_hours, + backend, + .. + } => { + overrides.grace_window_hours = *grace_window_hours; + overrides.blob_root.clone_from(&backend.blob_root); + overrides.memory = backend.memory; + } + Self::Purge { backend, .. } | Self::Scrub { backend, .. } => { + overrides.blob_root.clone_from(&backend.blob_root); + overrides.memory = backend.memory; + } Self::GenOpenapi { .. } => {} } overrides @@ -171,6 +263,16 @@ pub async fn run() -> Result { match cli.command { Command::Serve { .. } => serve(&config).await, + Command::Gc { apply, .. } => collect(&config, mode(apply)).await, + Command::Purge { apply, limit, .. } => purge(&config, mode(apply), limit).await, + Command::Scrub { deep, budget, .. } => { + let depth = if deep { + Depth::Deep { budget } + } else { + Depth::Structural + }; + scrub(&config, depth).await + } // The document is a property of the router's types, so the configuration is loaded only // to refuse `--config` and is deliberately not logged: `mise run openapi-check-kynos` is // a check gate, and a settings dump on its stderr is noise in every CI log that runs it. @@ -190,6 +292,16 @@ async fn assemble(config: &Config) -> Result { Ok(boot::assemble(config).await?) } +/// Assemble only what the operator workers read. +/// +/// A separate entry point rather than reaching into [`Assembled`], because `gc`, `purge` and +/// `scrub` need **no key material** and the way to make that true is for the assembly they use +/// to have none in scope — not for it to build a token signer and then not use it. +async fn maintenance(config: &Config) -> Result { + tracing::debug!(?config, "loaded the configuration"); + Ok(boot::assemble_maintenance(config).await?) +} + /// Accept requests until a termination signal, then drain. /// /// # The bound address goes to stdout @@ -265,7 +377,7 @@ fn install_tracing(environment: &dyn Environment, overrides: &Overrides) { match format { LogFormat::Json => registry .with( - fmt::layer() + log_fmt::layer() .json() .flatten_event(true) .with_writer(std::io::stderr), @@ -273,7 +385,7 @@ fn install_tracing(environment: &dyn Environment, overrides: &Overrides) { .init(), LogFormat::Pretty => registry .with( - fmt::layer() + log_fmt::layer() .pretty() .with_file(true) .with_line_number(true) @@ -283,6 +395,199 @@ fn install_tracing(environment: &dyn Environment, overrides: &Overrides) { } } +/// Whether `--apply` was passed. +/// +/// A free function rather than a `From` on [`Mode`]: the boolean is a command-line flag, +/// and a blanket conversion would let any `bool` in the crate become a write mode. +fn mode(apply: bool) -> Mode { + if apply { Mode::Apply } else { Mode::DryRun } +} + +/// Sweep blobs nothing references any more. +/// +/// The partial report is printed **before** the error when a pass fails part-way. `collect` +/// propagates the first store failure having applied whatever it did before it, which is safe +/// in both directions by the module's own argument — a mark is reversible and a sweep only ever +/// removed a blob confirmed unreferenced twice — but an operator still needs to know what +/// happened before it stopped. +async fn collect(config: &Config, mode: Mode) -> Result { + let maintenance = maintenance(config).await?; + let report = crate::gc::collect(&maintenance.collection, mode).await; + if let Ok(report) = &report { + print!("{}", render_collection(report, mode)); + } + report.map_err(|error| eyre!("the collection pass could not finish: {error}"))?; + Ok(ExitCode::SUCCESS) +} + +/// Drop the blob references of tombstoned assets past their retention window. +async fn purge(config: &Config, mode: Mode, limit: usize) -> Result { + let maintenance = maintenance(config).await?; + let report = crate::gc::purge_expired(&maintenance.collection, mode, limit).await; + if let Ok(report) = &report { + print!("{}", render_purge(report, mode)); + } + report.map_err(|error| eyre!("the retention purge could not finish: {error}"))?; + Ok(ExitCode::SUCCESS) +} + +/// Compare the index against the store. +/// +/// Exits [`EXIT_FINDINGS`] on a non-empty report, which `design/filesystem/maintenance.md` +/// requires of it — and a **truncated** deep pass is not clean even with no findings, because it +/// did not finish looking. `ScrubReport::is_clean` already draws that distinction; this only has +/// to honour it. +async fn scrub(config: &Config, depth: Depth) -> Result { + let maintenance = maintenance(config).await?; + let report = crate::scrub::scrub(&maintenance.scrub, depth) + .await + .map_err(|error| eyre!("the integrity scrub could not finish: {error}"))?; + print!("{}", render_scrub(&report)); + Ok(if report.is_clean() { + ExitCode::SUCCESS + } else { + ExitCode::from(EXIT_FINDINGS) + }) +} + +/// One pass's report, rendered for a person. +/// +/// A [`std::fmt::Display`] wrapper rather than a `-> String` helper, so every line is one `writeln!` +/// into the caller's formatter: building the whole report in a `String` first meant an +/// allocation per line and a clippy lint saying so. +struct Rendered<'a, T>(&'a T, Mode); + +/// What a dry run prints above its report, so nobody reads one as an action. +fn posture(mode: Mode) -> &'static str { + match mode { + Mode::Apply => "applied", + Mode::DryRun => "dry run — nothing was changed", + } +} + +/// Render a collection pass. +/// +/// Every class names its blobs rather than counting them. [`CollectionReport`]'s own docs say +/// why: "a count tells an operator that something happened without telling them what to look +/// at." Empty classes are omitted, so a quiet pass is one short line rather than six zeroes. +fn render_collection(report: &CollectionReport, mode: Mode) -> Rendered<'_, CollectionReport> { + Rendered(report, mode) +} + +impl std::fmt::Display for Rendered<'_, CollectionReport> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self(report, mode) = *self; + writeln!(f, "garbage collection ({})", posture(mode))?; + let mut quiet = true; + for (class, addresses) in [ + ("marked", &report.marked), + ("unmarked", &report.unmarked), + ("swept", &report.swept), + ("reprieved", &report.reprieved), + ("dangling", &report.dangling), + ] { + if addresses.is_empty() { + continue; + } + quiet = false; + writeln!(f, " {class} ({})", addresses.len())?; + for address in addresses { + writeln!(f, " {address}")?; + } + } + if !report.credited.is_empty() { + quiet = false; + let total: u64 = report.credited.iter().map(|(_, bytes)| *bytes).sum(); + writeln!( + f, + " credited ({} accounts, {total} bytes)", + report.credited.len() + )?; + for (user, bytes) in &report.credited { + writeln!(f, " {user} {bytes}")?; + } + } + if quiet { + writeln!(f, " nothing to do")?; + } + Ok(()) + } +} + +/// Render a retention purge. +/// +/// `retained` is reported as well as `purged`, because "why has this not gone yet" is exactly +/// the question a dry run is run to answer. +fn render_purge(report: &PurgeReport, mode: Mode) -> Rendered<'_, PurgeReport> { + Rendered(report, mode) +} + +impl std::fmt::Display for Rendered<'_, PurgeReport> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self(report, mode) = *self; + writeln!(f, "retention purge ({})", posture(mode))?; + let mut quiet = true; + for (class, assets) in [("purged", &report.purged), ("retained", &report.retained)] { + if assets.is_empty() { + continue; + } + quiet = false; + writeln!(f, " {class} ({})", assets.len())?; + for asset in assets { + writeln!(f, " {asset}")?; + } + } + if quiet { + writeln!(f, " nothing to do")?; + } + Ok(()) + } +} + +/// Render an integrity scrub. +/// +/// A scrub never writes, so it has no posture; the mode is carried and ignored. +fn render_scrub(report: &ScrubReport) -> Rendered<'_, ScrubReport> { + Rendered(report, Mode::DryRun) +} + +/// Grouped by the class an operator alerts on, and each finding printed through its **own** +/// `Debug`. Not a hand-written line per variant: every `Finding` variant already carries both +/// sides' evidence, a second rendering would be a second place for the two to disagree, and a +/// variant added later would otherwise render as nothing at all — which is the one failure mode +/// a report must not have. +impl std::fmt::Display for Rendered<'_, ScrubReport> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let report = self.0; + writeln!( + f, + "integrity scrub ({} finding{}, {} bytes hashed{})", + report.findings.len(), + if report.findings.len() == 1 { "" } else { "s" }, + report.bytes_hashed, + if report.budget_exhausted { + ", budget exhausted — the pass did not finish looking" + } else { + "" + } + )?; + for (class, count) in report.counts() { + writeln!(f, " {class} ({count})")?; + for finding in report + .findings + .iter() + .filter(|finding| finding.class() == class) + { + writeln!(f, " {finding:?}")?; + } + } + if report.is_clean() { + writeln!(f, " the index and the store agree")?; + } + Ok(()) + } +} + /// Write, or verify, the committed OpenAPI 3.2 document (slice `S-C34`). /// /// The drift guard for the rebuild's central claim: that the description is derived from the @@ -329,7 +634,10 @@ fn gen_openapi(output: &PathBuf, check: bool) -> Result { mod tests { use clap::{CommandFactory as _, Parser as _}; - use super::{Cli, Command}; + use super::{ + Cli, CollectionReport, Command, Mode, PurgeReport, ScrubReport, mode, render_collection, + render_purge, render_scrub, + }; #[test] fn the_command_line_is_well_formed() { @@ -403,4 +711,142 @@ mod tests { Some(std::path::Path::new("/etc/capsule.toml")) ); } + + /// A well-formed content address, distinguished by `seed`. + fn address(seed: u8) -> crate::blob::ContentAddress { + let hex: String = std::iter::repeat_n(format!("{seed:02x}"), 32).collect(); + crate::blob::ContentAddress::parse(&hex).expect("an address") + } + + #[test] + fn a_quiet_collection_pass_is_one_short_line() { + // Six zeroes would be six lines an operator learns to skip, and the whole point of the + // report is that they read it. + let rendered = render_collection(&CollectionReport::default(), Mode::DryRun).to_string(); + assert!(rendered.contains("dry run"), "{rendered}"); + assert!(rendered.contains("nothing to do"), "{rendered}"); + } + + #[test] + fn a_collection_pass_names_the_blobs_rather_than_counting_them() { + // `CollectionReport`'s own docs: "a count tells an operator that something happened + // without telling them what to look at." + let report = CollectionReport { + marked: vec![address(0xAA), address(0xBB)], + swept: vec![address(0xCC)], + credited: vec![(crate::store::UserId::new("a-user"), 4096)], + ..CollectionReport::default() + }; + let rendered = render_collection(&report, Mode::Apply).to_string(); + assert!(rendered.contains("applied"), "{rendered}"); + assert!(rendered.contains("marked (2)"), "{rendered}"); + assert!(rendered.contains(&address(0xAA).to_string()), "{rendered}"); + assert!(rendered.contains(&address(0xBB).to_string()), "{rendered}"); + assert!(rendered.contains("swept (1)"), "{rendered}"); + assert!( + rendered.contains("credited (1 accounts, 4096 bytes)"), + "{rendered}" + ); + // Classes with nothing in them are omitted rather than printed as zero. + assert!(!rendered.contains("unmarked"), "{rendered}"); + assert!(!rendered.contains("nothing to do"), "{rendered}"); + } + + #[test] + fn a_purge_reports_what_is_still_waiting() { + // "Why has this not gone yet" is exactly the question a dry run is run to answer. + let report = PurgeReport { + purged: vec![crate::store::AssetId::new("gone")], + retained: vec![crate::store::AssetId::new("waiting")], + }; + let rendered = render_purge(&report, Mode::DryRun).to_string(); + assert!(rendered.contains("purged (1)"), "{rendered}"); + assert!(rendered.contains("gone"), "{rendered}"); + assert!(rendered.contains("retained (1)"), "{rendered}"); + assert!(rendered.contains("waiting"), "{rendered}"); + } + + #[test] + fn a_clean_scrub_says_the_two_sides_agree() { + let rendered = render_scrub(&ScrubReport::default()).to_string(); + assert!(rendered.contains("0 findings"), "{rendered}"); + assert!(rendered.contains("agree"), "{rendered}"); + } + + #[test] + fn a_scrub_groups_findings_by_the_class_an_operator_alerts_on() { + let report = ScrubReport { + findings: vec![ + crate::scrub::Finding::Orphan { + address: address(0xAA), + }, + crate::scrub::Finding::Orphan { + address: address(0xBB), + }, + crate::scrub::Finding::Debris { + path: "blobs/aa/not-a-blob".to_owned(), + }, + ], + bytes_hashed: 0, + budget_exhausted: false, + }; + let rendered = render_scrub(&report).to_string(); + assert!(rendered.contains("3 findings"), "{rendered}"); + assert!(rendered.contains("orphan (2)"), "{rendered}"); + assert!(rendered.contains("debris (1)"), "{rendered}"); + assert!(rendered.contains("not-a-blob"), "{rendered}"); + assert!(!rendered.contains("agree"), "{rendered}"); + } + + #[test] + fn a_truncated_deep_pass_is_not_a_clean_one() { + // A clean report from a pass that stopped early is the one answer a scrub must never + // give, so the rendering says so out loud as well. + let report = ScrubReport { + findings: Vec::new(), + bytes_hashed: 1024, + budget_exhausted: true, + }; + let rendered = render_scrub(&report).to_string(); + assert!(rendered.contains("budget exhausted"), "{rendered}"); + assert!(!rendered.contains("agree"), "{rendered}"); + } + + #[test] + fn dry_run_is_the_default_for_the_two_subcommands_that_write() { + // The first thing an operator does with a collector is find out what it thinks. + let gc = Cli::parse_from(["capsule-server", "gc"]).command; + assert!(matches!(gc, Command::Gc { apply: false, .. })); + let purge = Cli::parse_from(["capsule-server", "purge"]).command; + assert!(matches!(purge, Command::Purge { apply: false, .. })); + assert_eq!(mode(false), Mode::DryRun); + assert_eq!(mode(true), Mode::Apply); + } + + #[test] + fn a_structural_scrub_is_the_default_and_deep_carries_a_budget() { + let shallow = Cli::parse_from(["capsule-server", "scrub"]).command; + assert!(matches!(shallow, Command::Scrub { deep: false, .. })); + let deep = Cli::parse_from(["capsule-server", "scrub", "--deep"]).command; + let Command::Scrub { deep, budget, .. } = deep else { + panic!("that is the subcommand that was parsed") + }; + assert!(deep); + assert_eq!(budget, super::DEFAULT_SCRUB_BUDGET); + } + + #[test] + fn the_operator_commands_demand_a_blob_root_and_no_key_material() { + for argv in [ + ["capsule-server", "gc"], + ["capsule-server", "purge"], + ["capsule-server", "scrub"], + ] { + assert_eq!( + Cli::parse_from(argv).command.demands(), + crate::config::Demands::Maintenance, + "{argv:?}" + ); + } + } } diff --git a/capsule-server/tests/binary.rs b/capsule-server/tests/binary.rs index 78ac3098..d7de660e 100644 --- a/capsule-server/tests/binary.rs +++ b/capsule-server/tests/binary.rs @@ -165,16 +165,14 @@ impl Serving { let deadline = Instant::now() + EXIT_TIMEOUT; loop { - match self.child.try_wait().expect("the child is waitable") { - Some(status) => return status.code(), - None => { - assert!( - Instant::now() < deadline, - "the server did not exit within {EXIT_TIMEOUT:?} of SIGTERM" - ); - std::thread::sleep(Duration::from_millis(25)); - } + if let Some(status) = self.child.try_wait().expect("the child is waitable") { + return status.code(); } + assert!( + Instant::now() < deadline, + "the server did not exit within {EXIT_TIMEOUT:?} of SIGTERM" + ); + std::thread::sleep(Duration::from_millis(25)); } } } @@ -269,7 +267,7 @@ fn serving_without_valkey_and_without_the_memory_profile_refuses_by_name() { // `store/mod.rs` has documented this refusal since `S-C29` and nothing enforced it, because // there was no boot path to enforce it in. let root = tempfile::tempdir().expect("a scratch directory"); - let (code, _, stderr) = run(&mut server(&[ + let (code, _, stderr) = run(server(&[ "serve", "--listen", EPHEMERAL, @@ -286,7 +284,7 @@ fn a_durable_backend_refuses_with_the_issue_that_will_honour_it() { // The other half: the operator *did* set `VALKEY_URL`, and nothing reads it yet. Falling // back to the in-memory adapters here is the one thing that must never happen. let root = tempfile::tempdir().expect("a scratch directory"); - let (code, _, stderr) = run(&mut server(&[ + let (code, _, stderr) = run(server(&[ "serve", "--listen", EPHEMERAL, @@ -336,3 +334,163 @@ fn the_committed_openapi_document_is_reproduced_byte_for_byte() { assert_eq!(code, Some(0), "stdout: {stdout}\nstderr: {stderr}"); assert!(stdout.contains("up to date"), "{stdout}"); } +// =========================================================================================== +// The operator commands +// =========================================================================================== + +/// A blob root holding one file under `blobs/`, shaped the way the store shards them. +/// +/// `blobs/aa/aa/<64 a's>.bin`: the two shard segments are the address's own first four hex +/// characters and the suffix is `ContentAddress::file_name`'s, because a file the enumeration +/// walk cannot turn back into an address is *debris* rather than a blob — which would be a +/// different finding from the one each case here is about. +/// +/// Written directly rather than uploaded, because the point is a store the *index* knows +/// nothing about: in the `--memory` profile the index is empty on every invocation, so every +/// blob on disk is genuinely unreferenced and both the collector and the scrub have something +/// true to say about it. +fn seeded_root() -> (tempfile::TempDir, String) { + let root = tempfile::tempdir().expect("a scratch directory"); + let address = "a".repeat(64); + let shard = root.path().join("blobs").join("aa").join("aa"); + std::fs::create_dir_all(&shard).expect("the shard is created"); + std::fs::write( + shard.join(format!("{address}.bin")), + b"unreferenced ciphertext", + ) + .expect("the blob is written"); + (root, address) +} + +/// The path a seeded blob occupies, for asserting it is still there. +fn seeded_blob(root: &std::path::Path, address: &str) -> std::path::PathBuf { + root.join("blobs") + .join("aa") + .join("aa") + .join(format!("{address}.bin")) +} + +/// An operator command over `root`, in the development profile. +fn operator(subcommand: &str, root: &std::path::Path, extra: &[&str]) -> Command { + let mut args = vec![subcommand, "--memory", "--blob-root"]; + let root = root.display().to_string(); + args.push(&root); + args.extend_from_slice(extra); + let mut command = server(&args); + command.env_remove("JWT_ED25519_DER"); + command +} + +#[test] +fn a_collection_dry_run_names_the_unreferenced_blob_and_changes_nothing() { + let (root, address) = seeded_root(); + let blob = seeded_blob(root.path(), &address); + + let (code, stdout, stderr) = run(&mut operator("gc", root.path(), &[])); + assert_eq!(code, Some(0), "stdout: {stdout}\nstderr: {stderr}"); + assert!(stdout.contains("dry run"), "{stdout}"); + assert!(stdout.contains("marked (1)"), "{stdout}"); + assert!(stdout.contains(&address), "{stdout}"); + assert!(blob.is_file(), "a dry run does not touch the store"); +} + +#[test] +fn an_applied_collection_pass_marks_rather_than_sweeps_on_its_first_look() { + // Two passes by design: a blob that reaches zero references is marked, and swept only on a + // later pass once the grace window has passed. In this profile the mark store does not + // survive the process, so a fresh invocation can only ever mark — which is stated in + // `boot`'s docs and asserted here rather than left as a surprise. The cross-invocation + // sweep needs the durable mark store #402 brings; `gc`'s own unit tests prove the + // mark-then-sweep sequence in process. + let (root, address) = seeded_root(); + let blob = seeded_blob(root.path(), &address); + + let (code, stdout, stderr) = run(&mut operator("gc", root.path(), &["--apply"])); + assert_eq!(code, Some(0), "stdout: {stdout}\nstderr: {stderr}"); + assert!(stdout.contains("applied"), "{stdout}"); + assert!(stdout.contains("marked (1)"), "{stdout}"); + assert!(!stdout.contains("swept"), "{stdout}"); + assert!( + blob.is_file(), + "nothing has waited out its grace window yet" + ); +} + +#[test] +fn a_collection_pass_over_an_empty_store_has_nothing_to_do() { + let root = tempfile::tempdir().expect("a scratch directory"); + let (code, stdout, stderr) = run(&mut operator("gc", root.path(), &[])); + assert_eq!(code, Some(0), "stdout: {stdout}\nstderr: {stderr}"); + assert!(stdout.contains("nothing to do"), "{stdout}"); +} + +#[test] +fn a_retention_purge_runs_and_reports_an_empty_pass() { + // The index is empty in this profile, so there is no tombstone to purge. What is asserted + // is that the command runs, reports, and does not invent work. + let root = tempfile::tempdir().expect("a scratch directory"); + let (code, stdout, stderr) = run(&mut operator("purge", root.path(), &[])); + assert_eq!(code, Some(0), "stdout: {stdout}\nstderr: {stderr}"); + assert!(stdout.contains("retention purge"), "{stdout}"); + assert!(stdout.contains("nothing to do"), "{stdout}"); +} + +#[test] +fn a_scrub_exits_non_zero_on_a_finding_and_mutates_nothing() { + // `design/filesystem/maintenance.md`: it "exits non-zero, and mutates nothing". + let (root, address) = seeded_root(); + let blob = seeded_blob(root.path(), &address); + let before = std::fs::read(&blob).expect("the blob is readable"); + + let (code, stdout, stderr) = run(&mut operator("scrub", root.path(), &[])); + assert_eq!(code, Some(1), "stdout: {stdout}\nstderr: {stderr}"); + assert!(stdout.contains("orphan (1)"), "{stdout}"); + assert!(stdout.contains(&address), "{stdout}"); + assert_eq!( + std::fs::read(&blob).expect("the blob is still readable"), + before, + "the store is byte-identical afterwards" + ); +} + +#[test] +fn a_scrub_over_a_clean_store_exits_zero() { + let root = tempfile::tempdir().expect("a scratch directory"); + let (code, stdout, stderr) = run(&mut operator("scrub", root.path(), &[])); + assert_eq!(code, Some(0), "stdout: {stdout}\nstderr: {stderr}"); + assert!(stdout.contains("agree"), "{stdout}"); +} + +#[test] +fn a_deep_scrub_re_hashes_the_bytes_it_reads() { + // The bit-rot check. The seeded file's name is not its own hash, so a deep pass finds the + // mismatch a structural one cannot see — and reports how many bytes it read. + let (root, _) = seeded_root(); + let (code, stdout, stderr) = run(&mut operator("scrub", root.path(), &["--deep"])); + assert_eq!(code, Some(1), "stdout: {stdout}\nstderr: {stderr}"); + assert!(stdout.contains("byte_mismatch"), "{stdout}"); + assert!(!stdout.contains("0 bytes hashed"), "{stdout}"); +} + +#[test] +fn the_operator_commands_need_no_key_material() { + // A maintenance host that had to hold the production token-signing key to sweep a directory + // would be a reason to put the key on a maintenance host. `operator` removes it, so every + // case above already asserts this — this one says so on purpose. + let root = tempfile::tempdir().expect("a scratch directory"); + for subcommand in ["gc", "purge", "scrub"] { + let (code, stdout, stderr) = run(&mut operator(subcommand, root.path(), &[])); + assert_eq!(code, Some(0), "{subcommand}: {stdout}{stderr}"); + assert!( + !stderr.contains("JWT_ED25519_DER"), + "{subcommand}: {stderr}" + ); + } +} + +#[test] +fn an_operator_command_without_a_blob_root_refuses_by_name() { + let (code, _, stderr) = run(&mut server(&["scrub", "--memory"])); + assert_eq!(code, Some(2), "{stderr}"); + assert!(stderr.contains("BLOB_ROOT"), "{stderr}"); +} From 9143e74cd0daa4ed8adeb7e7b7dbc74b46a1beeb Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 23:22:01 -0400 Subject: [PATCH 061/243] feat(sdk): a client for the album-upgrade proposal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `POST /v1/albums/{album_id}/upgrade` had no client. It is one of the four `application/cbor` operations `build.rs` narrows out of the generated client — spargen 0.4's `classify_media` does not know that media type — and it was the only one of the four with nothing hand-written behind it, so the SDK could not start the ceremony at all. `capsule_sdk::upgrade::UpgradeClient::begin` posts the signed intent **verbatim**. The bytes are the canonical CBOR `capsule_core::crypto::upgrade` signed, and the server verifies that signature against the proposing device's DSK in the account's published directory; re-encoding them here would detach them from the signature and the failure would look like a forged proposal. Every refusal keeps its own identity and the code the *server* stamped, because these are the refusals an admin reads: `409 error.album.upgrade_in_flight` carries the live `intent_id`, `403 error.album.upgrade_proposer` means the signing device is not published, and a client that flattened either into "malformed" would have someone re-signing intents forever. The `413` body backstop carries no problem body at all, so its code is the client's — `error.request.too_large`, not the intent-malformed code. The phase decodes into typed ids and a `jiff::Timestamp`, so a caller compares instants: the deadline is the one field in this ceremony where a string comparison would be a correctness bug rather than an inconvenience. An unparseable deadline is a malformed response, never a silent `None`, which would tell a client the ceremony never expires. `GET` and `DELETE` on the same path are plain JSON and *are* generated; the module doc says so and deliberately does not duplicate them. Proven over a socket in `the_sdk_proposes_an_album_upgrade_over_a_socket`, which is the only shape that can prove anything here: the directory is anchored, the album provisioned, and the intent signed with the same `capsule-core` types the server verifies with, so what the test asserts is that the bytes the SDK put on the wire are the bytes that verify. A mock answering `200` would have proven only that the client can post. Refs #408 --- capsule-sdk/src/lib.rs | 5 + capsule-sdk/src/upgrade.rs | 587 +++++++++++++++++++++++++++++ capsule-server/tests/sdk_client.rs | 101 +++++ 3 files changed, 693 insertions(+) create mode 100644 capsule-sdk/src/upgrade.rs diff --git a/capsule-sdk/src/lib.rs b/capsule-sdk/src/lib.rs index be738cd2..5f9fbf6f 100644 --- a/capsule-sdk/src/lib.rs +++ b/capsule-sdk/src/lib.rs @@ -25,6 +25,11 @@ pub mod push; pub mod recovery; pub mod staged; pub mod sync; +/// The album-upgrade proposal client (`S-C24`). Hand-written for one reason only: its request +/// body is `application/cbor`, which `spargen` 0.4 cannot lower, so `build.rs` narrows the +/// operation out of the generated client. The `GET` and `DELETE` on the same path are JSON and +/// *are* generated — reach them through [`client::AuthenticatedClient`]. +pub mod upgrade; pub mod upload; pub mod verify; diff --git a/capsule-sdk/src/upgrade.rs b/capsule-sdk/src/upgrade.rs new file mode 100644 index 00000000..8a947d82 --- /dev/null +++ b/capsule-sdk/src/upgrade.rs @@ -0,0 +1,587 @@ +//! Proposing an **album upgrade** — the client half of the ceremony's one server-side step +//! (slice `S-C24`, over the `S-D28` wire). +//! +//! [Versioning — Album Upgrade Ceremony] is a client ceremony carried on MLS application +//! messages the server cannot read. Four of its steps are the server's, and the first is the +//! one this module drives: `POST /v1/albums/{album_id}/upgrade` hands the server a **signed +//! `UpgradeIntent`**, which quiesces the album (a v_old client that never saw the proposal is +//! precisely the party that will not stop writing on its own) and starts the deadline on the +//! server's own clock (so a skewed member clock can neither extend nor shorten the window). +//! +//! Two rules shape this module, and both are the directory client's rules for the same reason: +//! +//! - **The signed bytes travel verbatim.** `intent_cbor` is the canonical CBOR +//! `capsule_core::crypto::upgrade::SignedUpgradeIntent` produced, and it is written to the +//! body unchanged. Re-encoding it here would detach it from the signature the server checks +//! against the proposing device's DSK in the account's published directory, and the failure +//! would look like a forged proposal. +//! - **Nothing cryptographic happens here.** The intent is built and signed in `capsule-core`; +//! this module is the wire and its refusals. +//! +//! # Why hand-written, and what would retire it +//! +//! The request body is `application/cbor`, and `spargen` 0.4's `classify_media` does not know +//! that media type, so `capsule-sdk/build.rs` narrows the operation out of the generated client +//! (`S-D28`) — the *surface* is narrowed, the document is never mutilated. This module is +//! therefore the orchestration `AGENTS.md` permits over a wire it cannot generate, and it is +//! the fourth and last such client: `capsule_sdk::directory` hand-writes two and +//! [`crate::verify::StorageVerifyClient::fetch_receipt`] the third. Teaching spargen the media +//! type retires all four; nothing in this repository can. +//! +//! The **other two** operations on this path — `GET` (read the phase) and `DELETE` (end the +//! ceremony) — are plain JSON and *are* generated. Call them through +//! [`crate::client::AuthenticatedClient`]; this module deliberately does not duplicate them. +//! +//! [Versioning — Album Upgrade Ceremony]: https://docs/design/versioning/#album-upgrade-ceremony + +use capsule_i18n::error_codes; +use jiff::Timestamp; +use serde::Deserialize; +use tracing::instrument; +use uuid::Uuid; + +use crate::auth::{AuthError, Session}; + +/// The media type the intent is *signed* in, and therefore the only one it may be sent as. +const CBOR: &str = "application/cbor"; + +/// The phase response's media type — the answer is a plain JSON document. +const JSON: &str = "application/json"; + +// ─── Errors ─────────────────────────────────────────────────────────────────── + +/// Everything a proposal can fail with. Callers switch on the typed variant, or on its stable +/// `error.*` code, and never on a bare status. +/// +/// Every refusal carries the code the **server** stamped rather than one this module inferred +/// from the status, because the ceremony's refusals are the ones a user actually reads: "the +/// album is already upgrading" and "your device is not an admin" are different sentences. +#[derive(Debug, thiserror::Error)] +pub enum UpgradeError { + /// The authenticated request itself failed (transport, session expiry, refresh). + #[error(transparent)] + Auth(#[from] AuthError), + /// The server refused the body as one it cannot read as a signed intent (`400`), refused + /// the media type (`415`), or refused its size (`413`). Retrying the same bytes changes + /// nothing — rebuild and re-sign the intent. + #[error("the server rejected the upgrade intent: {detail}")] + Malformed { + /// The stable `error.*` catalog code the refusal carried. + code: Option, + /// English detail from the problem body. + detail: String, + }, + /// The credential was refused (`401`). + #[error("the upgrade surface refused the credential: {detail}")] + Unauthorized { + /// The stable `error.*` catalog code the refusal carried. + code: Option, + /// English detail from the problem body. + detail: String, + }, + /// The intent is not signed by a device in the caller's **published** directory (`403`), + /// so the server cannot tell that an admin device really asked for this. Publish the + /// directory holding the proposing device first ([`crate::directory`]). + #[error("the upgrade intent's proposer could not be verified: {detail}")] + NotProposer { + /// The stable `error.*` catalog code the refusal carried. + code: Option, + /// English detail from the problem body. + detail: String, + }, + /// No such album, or not this caller's (`404`) — one answer for both, deliberately, so the + /// surface discloses nothing about albums the caller does not own. + #[error("no such album: {detail}")] + NotFound { + /// The stable `error.*` catalog code the refusal carried. + code: Option, + /// English detail from the problem body. + detail: String, + }, + /// A different ceremony already holds this album (`409`), and only one may. Read the phase + /// (the generated `GET` on the same path) and either join that ceremony or wait for its + /// deadline; a fresh proposal *replaces* an expired one rather than conflicting with it. + #[error("album is already upgrading under {intent_id:?}: {detail}")] + InFlight { + /// The ceremony that holds the album, as the server reported it. + intent_id: Option, + /// The stable `error.*` catalog code the refusal carried. + code: Option, + /// English detail from the problem body. + detail: String, + }, + /// A collaborator could not answer (`500`). Transient. + #[error("the upgrade could not be recorded: {detail}")] + Unavailable { + /// The stable `error.*` catalog code the refusal carried. + code: Option, + /// English detail from the problem body. + detail: String, + }, + /// The response body was not the phase document the contract declares. + #[error("malformed upgrade phase response: {0}")] + MalformedResponse(String), + /// The server returned an unmodeled status. + #[error("unexpected {status} response from the album-upgrade endpoint")] + Unexpected { + /// The HTTP status code the server returned. + status: u16, + }, +} + +impl UpgradeError { + /// The stable `error.*` catalog code a client localizes, when one applies. The English + /// [`Display`](std::fmt::Display) form stays the developer/log detail. + #[must_use] + pub fn error_code(&self) -> Option<&str> { + match self { + Self::Auth(auth) => auth.error_code(), + Self::Malformed { code, .. } + | Self::Unauthorized { code, .. } + | Self::NotProposer { code, .. } + | Self::NotFound { code, .. } + | Self::InFlight { code, .. } + | Self::Unavailable { code, .. } => code.as_deref(), + _ => None, + } + } +} + +// ─── Wire DTOs (mirror the server's transport JSON) ─────────────────────────── + +/// `UpgradePhaseResponse`, as the server serializes it. The three optional members are absent +/// when no ceremony is in flight — which also covers *expired*, because the deadline passing +/// aborts the upgrade and leaves nothing to be in. +#[derive(Debug, Deserialize)] +struct UpgradePhaseWire { + album_id: String, + #[serde(default)] + intent_id: Option, + #[serde(default)] + to_protocol_version: Option, + #[serde(default)] + expires_at: Option, + in_flight: u64, +} + +/// The members this module reads off an RFC 9457 problem body. `intent_id` is the `409`'s +/// extension; the rest are the coded-problem shape every Capsule refusal renders. +#[derive(Debug, Default, Deserialize)] +struct ProblemWire { + #[serde(default)] + code: Option, + #[serde(default)] + detail: Option, + #[serde(default)] + intent_id: Option, +} + +/// The ceremony an album is in, as the proposal answered it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpgradePhase { + /// The album, echoed by the server. + pub album_id: Uuid, + /// The ceremony now in flight, or `None` when the album is in normal operation. + pub intent_id: Option, + /// The protocol version the fork will be pinned to, when a ceremony is in flight. + pub to_protocol_version: Option, + /// When the window closes, on the **server's** clock — never the client's. + pub expires_at: Option, + /// How many upload sessions are still in flight against this album. + /// + /// The drain signal of the ceremony's step 3: the proposer waits for zero. A count and not + /// a listing, because the proposer needs to know *whether* to wait and has no business + /// seeing other members' upload identifiers to find out. + pub in_flight: u64, +} + +// ─── Client ─────────────────────────────────────────────────────────────────── + +/// The album-upgrade proposal client. Borrows an authenticated [`Session`], so every call +/// rides the SDK's bearer/refresh machinery and no token is handled here. +#[derive(Clone)] +pub struct UpgradeClient { + session: Session, + base_url: String, +} + +impl UpgradeClient { + /// Build a client against the **API root** — the origin the operation paths hang off (e.g. + /// `https://api.example.com`), the same base [`crate::client::AuthenticatedClient`], + /// [`crate::sync::SyncConsumer`] and [`crate::recovery::RecoveryClient`] take. + /// + /// Note that [`crate::directory`] and [`crate::verify`] take a *deeper* base instead. That + /// divergence is real and is noticed in `capsule-server/tests/sdk_client.rs`; it is not + /// this module's to close, and a new module choosing the root is how it narrows. + #[must_use] + pub fn new(session: Session, api_base_url: &str) -> Self { + Self { + session, + base_url: api_base_url.trim_end_matches('/').to_owned(), + } + } + + /// Propose the upgrade `intent_cbor` describes for `album_id`, returning the ceremony the + /// server now holds. + /// + /// `intent_cbor` is the canonical CBOR of a signed `UpgradeIntent` and is sent **verbatim** + /// — this method never re-encodes it, because the signature is over exactly those bytes. + /// + /// # Errors + /// + /// [`UpgradeError::InFlight`] when another ceremony already holds the album (only one may), + /// [`UpgradeError::NotProposer`] when the signing device is not in the published directory, + /// and the rest of [`UpgradeError`] for the remaining refusals. + #[instrument(skip(self, intent_cbor), fields(album_id = %album_id, bytes = intent_cbor.len()))] + pub async fn begin( + &self, + album_id: Uuid, + intent_cbor: &[u8], + ) -> Result { + let url = format!( + "{}/v1/albums/{}/upgrade", + self.base_url, + album_id.hyphenated() + ); + let body = intent_cbor.to_vec(); + let response = self + .session + .execute(|http| { + http.post(&url) + .header(reqwest::header::CONTENT_TYPE, CBOR) + .header(reqwest::header::ACCEPT, JSON) + .body(body.clone()) + }) + .await?; + + let status = response.status(); + if !status.is_success() { + let problem = response.json::().await.unwrap_or_default(); + let error = refusal(status.as_u16(), problem); + tracing::warn!( + status = status.as_u16(), + code = ?error.error_code(), + "album-upgrade proposal refused" + ); + return Err(error); + } + + let wire: UpgradePhaseWire = response + .json() + .await + .map_err(|e| UpgradeError::MalformedResponse(e.to_string()))?; + let phase = decode_phase(wire)?; + tracing::info!( + intent_id = ?phase.intent_id, + in_flight = phase.in_flight, + expires_at = ?phase.expires_at, + "album upgrade proposed; the album is quiesced" + ); + Ok(phase) + } +} + +/// Map a refusal onto its typed variant, keeping the code the server stamped. +/// +/// One readable status table rather than a match buried in the request path. `413` is the +/// transport's body backstop and carries no problem body at all, so its code is ours — and it +/// is `error.request.too_large` rather than the intent-malformed code, because a client +/// localizing the latter would tell an admin their signed intent is corrupt when it is +/// merely too big. +fn refusal(status: u16, problem: ProblemWire) -> UpgradeError { + let ProblemWire { + code, + detail, + intent_id, + } = problem; + let detail = detail.unwrap_or_default(); + match status { + 400 | 415 => UpgradeError::Malformed { code, detail }, + 401 => UpgradeError::Unauthorized { code, detail }, + 403 => UpgradeError::NotProposer { code, detail }, + 404 => UpgradeError::NotFound { code, detail }, + 409 => UpgradeError::InFlight { + intent_id, + code, + detail, + }, + 413 => UpgradeError::Malformed { + code: Some(error_codes::REQUEST_TOO_LARGE.to_owned()), + detail: "the signed upgrade intent exceeds the server's body limit".to_owned(), + }, + 500 => UpgradeError::Unavailable { code, detail }, + other => UpgradeError::Unexpected { status: other }, + } +} + +/// Parse the phase document into its typed shape. +/// +/// The ids become [`Uuid`]s and the deadline a [`jiff::Timestamp`], so a caller compares +/// instants rather than strings — the deadline is the one field in this ceremony where a +/// string comparison would be a correctness bug rather than an inconvenience. +fn decode_phase(wire: UpgradePhaseWire) -> Result { + let album_id = Uuid::parse_str(&wire.album_id) + .map_err(|e| UpgradeError::MalformedResponse(format!("response album_id: {e}")))?; + let intent_id = wire + .intent_id + .as_deref() + .map(Uuid::parse_str) + .transpose() + .map_err(|e| UpgradeError::MalformedResponse(format!("response intent_id: {e}")))?; + let expires_at = wire + .expires_at + .as_deref() + .map(str::parse::) + .transpose() + .map_err(|e| UpgradeError::MalformedResponse(format!("response expires_at: {e}")))?; + Ok(UpgradePhase { + album_id, + intent_id, + to_protocol_version: wire.to_protocol_version, + expires_at, + in_flight: wire.in_flight, + }) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + use crate::auth::{AuthClient, PersistedSession}; + use crate::testmock::{MockRequest, MockResponse, MockServer}; + + const ALBUM: &str = "018f3f1e-4b7a-7c9d-8e2f-1a2b3c4d5e60"; + const INTENT: &str = "019a0000-0000-7000-8000-00000000cafe"; + + /// The bytes a real client would hand over: opaque, and deliberately not valid UTF-8 so a + /// re-encoding anywhere on the path would show up. + fn signed_intent() -> Vec { + let mut bytes = b"signed-upgrade-intent".to_vec(); + bytes.extend_from_slice(&[0x00, 0xff, 0xa5]); + bytes + } + + fn album() -> Uuid { + Uuid::parse_str(ALBUM).expect("the literal is a uuid") + } + + /// A session over `base` with a far-future token, so no refresh ever fires and the mock + /// needs no `/refresh` endpoint. + fn session_for(base: &str) -> Session { + AuthClient::new(base) + .expect("a base url") + .resume(PersistedSession { + access_token: "test-access".to_string().into(), + refresh_token: "test-refresh".to_string().into(), + access_expires_at_unix: jiff::Timestamp::now().as_second() + 3_600, + }) + .expect("a session resumes from any pair") + } + + fn phase_json() -> String { + serde_json::json!({ + "album_id": ALBUM, + "intent_id": INTENT, + "to_protocol_version": "2030-01-01", + "expires_at": "2030-01-01T00:05:00Z", + "in_flight": 0, + }) + .to_string() + } + + /// An RFC 9457 problem, as `capsule-server`'s interceptor renders one. + fn problem(status: u16, reason: &str, code: &str, detail: &str) -> MockResponse { + MockResponse::new(status, reason).json_body( + serde_json::json!({ + "type": "about:blank", + "title": reason, + "status": status, + "detail": detail, + "code": code, + }) + .to_string(), + ) + } + + /// The signed bytes reach the documented path, in the documented media type, unchanged — + /// and the phase decodes into typed ids and a real instant. + #[tokio::test] + async fn a_proposal_sends_the_signed_bytes_verbatim_and_decodes_the_phase() { + let intent = signed_intent(); + let expected = intent.clone(); + let seen = Arc::new(AtomicUsize::new(0)); + let counter = seen.clone(); + let server = MockServer::start(move |req: &MockRequest| { + counter.fetch_add(1, Ordering::SeqCst); + assert_eq!(req.method, "POST"); + assert_eq!(req.path, format!("/v1/albums/{ALBUM}/upgrade")); + assert_eq!( + req.header("content-type"), + Some(CBOR), + "the intent must be sent in the media type it was signed in" + ); + assert_eq!( + req.body, expected, + "a re-encoded intent no longer verifies under the proposer's DSK" + ); + assert!( + req.header("authorization") + .is_some_and(|v| v.starts_with("Bearer ")), + "the proposal is owner-scoped" + ); + MockResponse::new(200, "OK").json_body(phase_json()) + }) + .await; + + let client = UpgradeClient::new(session_for(&server.base_url()), &server.base_url()); + let phase = client + .begin(album(), &intent) + .await + .expect("the proposal is accepted"); + + assert_eq!(seen.load(Ordering::SeqCst), 1, "exactly one request"); + assert_eq!(phase.album_id, album()); + assert_eq!( + phase.intent_id, + Some(Uuid::parse_str(INTENT).expect("a uuid")) + ); + assert_eq!(phase.to_protocol_version.as_deref(), Some("2030-01-01")); + assert_eq!( + phase.expires_at, + Some("2030-01-01T00:05:00Z".parse().expect("an instant")) + ); + assert_eq!(phase.in_flight, 0); + } + + /// A second ceremony is refused with the live `intent_id` and the code a client localizes + /// — the refusal an admin actually reads, so neither may be flattened into a status. + #[tokio::test] + async fn a_second_proposal_is_refused_with_the_live_ceremony() { + let server = MockServer::start(move |_req: &MockRequest| { + MockResponse::new(409, "Conflict").json_body( + serde_json::json!({ + "type": "about:blank", + "title": "Upgrade in flight", + "status": 409, + "detail": format!("album is already upgrading under {INTENT}"), + "code": error_codes::ALBUM_UPGRADE_IN_FLIGHT, + "intent_id": INTENT, + }) + .to_string(), + ) + }) + .await; + + let client = UpgradeClient::new(session_for(&server.base_url()), &server.base_url()); + let error = client + .begin(album(), &signed_intent()) + .await + .expect_err("only one ceremony may hold an album"); + let UpgradeError::InFlight { intent_id, .. } = &error else { + panic!("expected an in-flight refusal, got {error:?}"); + }; + assert_eq!(intent_id.as_deref(), Some(INTENT)); + assert_eq!( + error.error_code(), + Some(error_codes::ALBUM_UPGRADE_IN_FLIGHT) + ); + } + + /// Every refusal the operation declares maps to its own variant and keeps the server's + /// code. A status collapsed into the wrong variant would tell an admin to fix the wrong + /// thing — re-sign an intent that was fine, or wait out a ceremony that does not exist. + #[tokio::test] + async fn each_declared_refusal_keeps_its_own_identity() { + for (status, reason, code) in [ + (400u16, "Bad Request", error_codes::ALBUM_UPGRADE_MALFORMED), + (403, "Forbidden", error_codes::ALBUM_UPGRADE_PROPOSER), + (404, "Not Found", error_codes::ALBUM_UPGRADE_NOT_FOUND), + (500, "Internal Server Error", error_codes::ALBUM_UNAVAILABLE), + ] { + let server = + MockServer::start(move |_req: &MockRequest| problem(status, reason, code, "no")) + .await; + let client = UpgradeClient::new(session_for(&server.base_url()), &server.base_url()); + let error = client + .begin(album(), &signed_intent()) + .await + .expect_err("the server refused"); + assert_eq!(error.error_code(), Some(code), "status {status}: {error:?}"); + let matched = match status { + 400 => matches!(error, UpgradeError::Malformed { .. }), + 403 => matches!(error, UpgradeError::NotProposer { .. }), + 404 => matches!(error, UpgradeError::NotFound { .. }), + 500 => matches!(error, UpgradeError::Unavailable { .. }), + _ => false, + }; + assert!(matched, "status {status} took the wrong variant: {error:?}"); + } + } + + /// The body-size backstop carries no problem body, so the client supplies both the variant + /// and a code that says what actually happened. + #[tokio::test] + async fn a_body_too_large_is_not_reported_as_a_corrupt_intent() { + let server = MockServer::start(move |_req: &MockRequest| { + MockResponse::new(413, "Payload Too Large") + }) + .await; + let client = UpgradeClient::new(session_for(&server.base_url()), &server.base_url()); + let error = client + .begin(album(), &signed_intent()) + .await + .expect_err("the server refused the size"); + assert!( + matches!(error, UpgradeError::Malformed { .. }), + "got {error:?}" + ); + assert_eq!(error.error_code(), Some(error_codes::REQUEST_TOO_LARGE)); + } + + /// An undeclared status is surfaced as itself rather than guessed at. + #[tokio::test] + async fn an_undeclared_status_is_surfaced_as_unexpected() { + let server = + MockServer::start(move |_req: &MockRequest| MockResponse::new(418, "I'm a teapot")) + .await; + let client = UpgradeClient::new(session_for(&server.base_url()), &server.base_url()); + let error = client + .begin(album(), &signed_intent()) + .await + .expect_err("418 is not in the contract"); + assert!( + matches!(error, UpgradeError::Unexpected { status: 418 }), + "got {error:?}" + ); + assert_eq!(error.error_code(), None); + } + + /// A phase document whose deadline is not an instant is a malformed *response*, not a + /// silent `None` — a dropped deadline would make a client think the ceremony never expires. + #[tokio::test] + async fn an_unparseable_deadline_is_a_malformed_response() { + let server = MockServer::start(move |_req: &MockRequest| { + MockResponse::new(200, "OK").json_body( + serde_json::json!({ + "album_id": ALBUM, + "intent_id": INTENT, + "expires_at": "next tuesday", + "in_flight": 0, + }) + .to_string(), + ) + }) + .await; + let client = UpgradeClient::new(session_for(&server.base_url()), &server.base_url()); + let error = client + .begin(album(), &signed_intent()) + .await + .expect_err("a deadline that is not an instant is not a deadline"); + assert!( + matches!(error, UpgradeError::MalformedResponse(_)), + "got {error:?}" + ); + } +} diff --git a/capsule-server/tests/sdk_client.rs b/capsule-server/tests/sdk_client.rs index effe2bf2..432a1c52 100644 --- a/capsule-server/tests/sdk_client.rs +++ b/capsule-server/tests/sdk_client.rs @@ -460,3 +460,104 @@ async fn a_token_the_server_stopped_honouring_is_refreshed_and_the_call_replayed accepted a token it had already stopped honouring" ); } + +/// The album-upgrade proposal, over a socket, against the server that verifies the signature. +/// +/// This one cannot be proven against a mock at all. The intent is signed with the proposing +/// device's DSK and verified against the account's **published** device directory, so a mock +/// that answered `200` would prove only that the client can post bytes. Here the directory is +/// anchored, the album provisioned, and the intent signed by `capsule-core` with the *same* +/// types the server verifies with — so what is asserted is that the bytes the SDK put on the +/// wire are the bytes that verify. +/// +/// The `409` half matters as much: only one ceremony may hold an album, and a client that read +/// that refusal as "malformed, re-sign" would have an admin re-signing intents forever. +#[tokio::test] +async fn the_sdk_proposes_an_album_upgrade_over_a_socket() { + use capsule_sdk::upgrade::{UpgradeClient, UpgradeError}; + use support::{ + device, identity_header, identity_key, signed_directory_with_device, signed_upgrade_intent, + }; + use uuid::Uuid; + + let intent_id = Uuid::parse_str("019a0000-0000-7000-8000-00000000cafe").expect("a uuid"); + let fixture = Fixture::working(); + let bearer = fixture.bearer().await; + let identity = identity_key(); + let device_key = identity_key(); + + // Anchor the directory holding the proposing device, then provision the album. Without the + // first, every proposal is `403` however well signed — the directory *is* the trust anchor. + fixture + .client + .post("/v1/auth/devices/directory") + .header("authorization", &bearer) + .header("x-capsule-identity-key", &identity_header(&identity)) + .body( + "application/cbor", + signed_directory_with_device( + &identity, + 1, + device(), + &device_key, + "1970-01-01T00:00:00Z", + ), + ) + .send() + .await + .assert_status(kynos::http::StatusCode::OK); + fixture + .client + .post("/v1/albums") + .header("authorization", &bearer) + .header("accept", "application/json") + .json(&serde_json::json!({ "album_id": album().as_str() })) + .send() + .await + .assert_status(kynos::http::StatusCode::CREATED); + + let base_url = serve(&fixture).await; + let client = UpgradeClient::new(session(&base_url).await, &base_url); + let album_id = Uuid::parse_str(album().as_str()).expect("the seeded album id is a uuid"); + let intent = signed_upgrade_intent(&device_key, device(), intent_id, "2030-01-01", 300); + + let phase = client + .begin(album_id, &intent) + .await + .expect("a signed proposal from an anchored device is accepted"); + assert_eq!(phase.album_id, album_id); + assert_eq!( + phase.intent_id, + Some(intent_id), + "the ceremony the server now holds is the one the client proposed" + ); + assert_eq!(phase.to_protocol_version.as_deref(), Some("2030-01-01")); + assert_eq!(phase.in_flight, 0, "nothing is draining on a fresh album"); + assert!( + phase.expires_at.is_some(), + "the deadline is the server's to set, and it must reach the client as an instant" + ); + + // A second ceremony under a different id is refused with the live one — and with the code + // a client localizes, parsed out of a problem body that crossed a socket. + let second = Uuid::parse_str("019a0000-0000-7000-8000-00000000beef").expect("a uuid"); + let error = client + .begin( + album_id, + &signed_upgrade_intent(&device_key, device(), second, "2030-01-01", 300), + ) + .await + .expect_err("only one ceremony may hold an album"); + let UpgradeError::InFlight { + intent_id: live, .. + } = &error + else { + panic!("expected an in-flight refusal, got {error:?}"); + }; + assert_eq!(live.as_deref(), Some(intent_id.to_string().as_str())); + assert_eq!( + error.error_code(), + Some("error.album.upgrade_in_flight"), + "got {error:?}" + ); +} From 17f17f64a025af6d6762abd7c792162d3b4c8b35 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 23:25:55 -0400 Subject: [PATCH 062/243] docs(sdk): the crate docs and the two slice rows say what the tree holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five standing falsehoods in `capsule-sdk`'s own documentation, and the two `SLICES.md` rows this issue moves. - The document is **OpenAPI 3.2** and has been since Kynos was pinned with `openapi_as(SpecVersion::V3_2)`. `lib.rs` said 3.1 twice and `build.rs` once. - `mise run openapi` does not exist. The tasks are `openapi-kynos` and `openapi-check-kynos`. - `build.rs` said `capsule_sdk::directory` hand-writes two of the four `application/cbor` operations and "the other two have no client yet". One of those two had a client all along (`verify::StorageVerifyClient::fetch_receipt`) and the other now does (`capsule_sdk::upgrade`), so all four are named, with the one upstream change that retires all four. - The sync feed is not gRPC. `lib.rs` said `sync` stays hand-written because its protocol is too stateful for codegen, which is true of `upload` and false of `sync`: `S-D28` made the feed `GET /v1/sync`, a generated operation, and what is hand-written is the cursor and anti-rewind state machine over it. `ffi/tests.rs` still called `sync_pull` gRPC, and `FfiError`'s doc still offered foreign apps a "bare HTTP/gRPC status" to avoid. `SLICES.md`: `S-D12` records the route defect and its closure, and carries the escrow store response as an owed item pointing at #442. `S-D17` flips to `MIXED | done` — the Area corrects because the layer is live code in this workspace that does not re-scope, even though the client under it is regenerated — with the backend, the rejected `Middleware` alternative, and the socket case named, plus the reason `capsule_sdk::sync` keeps its own loop. Refs #408 --- SLICES.md | 49 +++++++++++++++++++++++++++++++++--- capsule-sdk/build.rs | 9 ++++--- capsule-sdk/src/ffi.rs | 2 +- capsule-sdk/src/ffi/tests.rs | 9 ++++--- capsule-sdk/src/lib.rs | 33 +++++++++++++++--------- 5 files changed, 78 insertions(+), 24 deletions(-) diff --git a/SLICES.md b/SLICES.md index ce7d3266..ced5281d 100644 --- a/SLICES.md +++ b/SLICES.md @@ -302,12 +302,12 @@ row's remainder now lives. | S-D9 | capsule-sdk uniffi FFI bindings | sdk/clients | S-F1, S-D7 | M | RETIRED | ready | Swift harness → `S-P8`; Kotlin harness → owed-CI | | S-D10 | Adverse-network hardening | sdk/clients | S-D1, S-D2 | M | RETIRED | ready | | | S-D11 | Client cohort emission + devices grouping UI | sdk/clients | S-C13, S-D7 | M | MIXED | done\* | iOS reader → `S-P6`; devices screen → post-v1; device_id → `S-N3` | -| S-D12 | Recovery verification cadence + guided re-wrap | sdk/clients | S-C12 | M | MIXED | done | | +| S-D12 | Recovery verification cadence + guided re-wrap | sdk/clients | S-C12 | M | MIXED | done | `store_escrow` discards `stored_at`/`replaced` → issue #442 | | S-D13 | Culling workflow client UX | sdk/clients | — | M | ACTIVE | done | | | S-D14 | Local-gallery security gates | sdk/clients | — | S | ACTIVE | done | | | S-D15 | Exact client build identification | sdk/clients | — | S | MIXED | done | | | S-D16 | Standalone `capsule cull` command | sdk/clients | S-A10 | S | ACTIVE | done | | -| S-D17 | Typed REST client reactive 401-retry-once | sdk/clients | — | S | RETIRED | ready | | +| S-D17 | Typed REST client reactive 401-retry-once | sdk/clients | — | S | MIXED | done | | | S-D18 | `capsule push` — drive `capsule_sdk::upload` from CLI | sdk/clients | S-A10 | M | MIXED | done | | | S-D19 | Hidden-view DB projection + gate wiring | sdk/clients | — | S | ACTIVE | done | rebuild un-hides → `S-D21` | | S-D21 | Index rebuild loses gated state (two sidecar shapes) | sdk/clients | S-D19 | M | ACTIVE | done | importer stacks → `S-B15`; unsigned migration → `S-D24`; no hidden writer → `S-D25` | @@ -4129,6 +4129,24 @@ Kynos server, which cannot be written until `S-C53` gives the server a way to cr - **Tier:** Unit + Smoke. - **Landed:** the cadence scheduler, the verifier, and the re-wrap are `ACTIVE` core; only the escrow store/replace calls re-scope. +- **Gap** (found 2026-09-01, issue #408): **the networked half never worked against a real + server.** `capsule-sdk/src/recovery/mod.rs` built `{api_root}/backup/escrow` from a `const` + — the Salvo document's path — and sent it with hand-written `reqwest` calls, while the Kynos + contract serves `GET`/`PUT /v1/auth/escrow`. So enroll, the stale-cache refresh and the + guided re-wrap's escrow replace all failed on a live server while this row read `done`. It + survived `S-D28`'s re-source because a route in a string constant is checked by no gate and + the module's own mock answered whichever path it was handed. +- **Closed:** the two operations are `application/octet-stream` in each direction, which + `spargen` lowers, so both were already generated and neither was narrowed in `build.rs`. + `RecoveryClient` now holds an `AuthenticatedClient` and orchestrates + `fetch_escrow`/`store_escrow`, so the path is a function of the committed document and + cannot drift again. Both in-repo mocks route on `/v1/auth/escrow` and answer `501` off it, + and `capsule-server/tests/sdk_client.rs` asserts the route against the real router from both + ends — what the SDK stored is read back at `/v1/auth/escrow`, and a rotation seeded there is + what the SDK fetches next. +- **Owed:** `store_escrow` logs `stored_at`/`replaced` instead of returning them, so the + stale-cache rule still refreshes on a failed compare rather than on a known-stale timestamp + and `guided_rewrap` cannot tell a first enrollment from a rotation → issue #442. ### S-D13 — Culling workflow client UX @@ -4196,8 +4214,31 @@ Kynos server, which cannot be written until `S-C53` gives the server a way to cr - **Done when:** a mocked-clock race test passes (expired-at-server, valid-at-client → one refresh, one retry, no loop). **Tier:** Unit. - **Note:** the layer sits above the generated client, so write it once and it survives - the schema re-source; it is `RETIRED` only because the client under it is regenerated - from Kynos. + the schema re-source. That is why the row is `MIXED` rather than `RETIRED`: the client + underneath is regenerated from Kynos, but the layer itself is live code in this workspace + and nothing about it re-scopes. +- **Landed** (2026-09-01, issue #408): `capsule_sdk::client::RefreshOn401`, an + `rest::HttpBackend` wrapping `ReqwestBackend`, installed by `AuthenticatedClient` through + `Client::with_backend`. On a `401` it refreshes once through a new `pub(crate) + Session::refresh_rejected` — `ensure_refreshed(Rejected(stale))`, so the existing + single-flight gate coalesces on the exact token the server refused — and replays the request + once. No generated code is touched and every generated operation is covered at once. + - **Not spargen's `Middleware`:** `Next::run` takes `self` by value and `Next` is neither + `Clone` nor constructible outside the generated runtime, so a middleware cannot send twice. + `RetryBackend` is the precedent followed instead, including its rule that a request whose + `try_clone()` is `None` (a one-shot streaming body) is executed once and never replayed. + - **Exactly once by construction**, not by a loop counter. A refresh that itself fails + surfaces the *server's* `401` rather than a synthesized transport error, so the typed + `Status401` mapping still fires and the caller reads the `error.*` code — which matters + because an unreadable revocation ledger is also rendered as `401`. + - **Proven** by four unit cases in `capsule-sdk/src/client.rs` and, over a socket against + the real router, by `a_token_the_server_stopped_honouring_is_refreshed_and_the_call_replayed` + in `capsule-server/tests/sdk_client.rs`: the server validates `exp` against its injected + clock, so advancing the fixture past `ACCESS_TOKEN_TTL` revokes the access token for real + while the refresh token lives. + - **`capsule_sdk::sync` keeps its own per-call `401` loop**, deliberately: it builds its own + `rest::Client`, supports a static-token mode with no session to refresh, and interleaves + the `401` path with the shared retry engine's transient class. ### S-D18 — `capsule push` diff --git a/capsule-sdk/build.rs b/capsule-sdk/build.rs index e4432c42..cc527870 100644 --- a/capsule-sdk/build.rs +++ b/capsule-sdk/build.rs @@ -21,7 +21,7 @@ fn main() { build_rest_client(); } -/// Generate the typed REST client from the committed OpenAPI 3.1 schema (slice `S-D8`). +/// Generate the typed REST client from the committed OpenAPI 3.2 schema (slice `S-D8`). fn build_rest_client() { let manifest_dir = PathBuf::from( std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by cargo"), @@ -73,8 +73,11 @@ fn build_rest_client() { // spec* — and the alternative is worse in a way worth naming: re-labelling them // `application/octet-stream` to satisfy a generator would tell every client that a document // with a schema it knows is opaque bytes, which is the thing the media type exists to deny. - // `capsule_sdk::directory` already hand-writes two of them for the old reason; the other two - // have no client yet. + // All four are hand-written now, and each one says in its own module doc that spargen is + // the only reason: `capsule_sdk::directory` covers the two device-directory operations, + // `capsule_sdk::upgrade` the proposal, and `capsule_sdk::verify`'s + // `StorageVerifyClient::fetch_receipt` the receipt. Teaching spargen the media type retires + // all four narrowings and all four clients — see the tracking issue on the generator. let omitted = [ spargen::OmitRule::operation(spargen::OmitMethod::Post, "/v1/auth/devices/directory"), spargen::OmitRule::operation( diff --git a/capsule-sdk/src/ffi.rs b/capsule-sdk/src/ffi.rs index 9519bf66..cc734170 100644 --- a/capsule-sdk/src/ffi.rs +++ b/capsule-sdk/src/ffi.rs @@ -63,7 +63,7 @@ pub use workspace::{ /// variant carries the stable `error.*` catalog `code` (when one applies — clients /// localize it) and the English detail `message` (stays English), mirroring the /// SDK's `{ error, code }` contract so foreign apps switch on the code, never a -/// bare HTTP/gRPC status. +/// bare HTTP status. #[derive(Debug, thiserror::Error, uniffi::Error)] pub enum FfiError { /// An authentication flow (login/register/refresh/logout) failed. diff --git a/capsule-sdk/src/ffi/tests.rs b/capsule-sdk/src/ffi/tests.rs index 316a0bb6..b96a9f9e 100644 --- a/capsule-sdk/src/ffi/tests.rs +++ b/capsule-sdk/src/ffi/tests.rs @@ -17,10 +17,11 @@ //! publish. Every verb reaches `capsule-core` for its crypto; what is under test here //! is the wiring, the shapes, and the verdicts. //! -//! `sync_pull` itself is gRPC and is exercised by the native harness against the real -//! server; its Rust-side shape is compiled here (the surface builds) but not -//! behaviorally driven — the sync-apply test below feeds `apply_sync_entry` the exact -//! three byte strings a feed entry carries, which is the half `S-P1` owns. +//! `sync_pull` itself rides the generated `GET /v1/sync` operation (`S-D28` retired the gRPC +//! feed) and is exercised over a socket in `capsule-server/tests/sdk_client.rs` against the +//! real router; its Rust-side shape is compiled here (the surface builds) but not behaviorally +//! driven — the sync-apply test below feeds `apply_sync_entry` the exact three byte strings a +//! feed entry carries, which is the half `S-P1` owns. use std::sync::Arc; diff --git a/capsule-sdk/src/lib.rs b/capsule-sdk/src/lib.rs index 5f9fbf6f..95417c08 100644 --- a/capsule-sdk/src/lib.rs +++ b/capsule-sdk/src/lib.rs @@ -3,15 +3,24 @@ //! //! # REST client generation (spargen; slice `S-D8` in the repo-root `SLICES.md`) //! -//! The typed REST client ([`rest`]) is generated from the server's **OpenAPI 3.1** schema by +//! The typed REST client ([`rest`]) is generated from the server's **OpenAPI 3.2** schema by //! `spargen`, our in-house generator, at build time (see `build.rs`). The previous progenitor //! pipeline is gone deliberately: progenitor consumes OpenAPI 3.0 only, which forced a lossy -//! 3.1→3.0 schema down-conversion — a standing source of drift and failures. We do not -//! downgrade schemas. [`client::AuthenticatedClient`] wraps the generated `Client`, composing -//! it with [`auth`]'s session/token store so callers issue typed calls and never juggle raw -//! tokens. The hand-written upload/sync surfaces ([`upload`], [`sync`]) stay hand-written — -//! their protocols are too stateful for request/response codegen; the generated client covers -//! the plain request/response surfaces (auth, quota, storage-verify, receipts, devices, …). +//! down-conversion of the document the server actually emits — a standing source of drift and +//! failures. We do not downgrade schemas. [`client::AuthenticatedClient`] wraps the generated +//! `Client`, composing it with [`auth`]'s session/token store so callers issue typed calls and +//! never juggle raw tokens. +//! +//! The generated client covers the plain request/response surfaces (auth, quota, +//! storage-verify, receipts, devices, escrow, the sync feed, …). What stays hand-written is +//! *orchestration*, never a second parser: +//! +//! - [`upload`] — the resumable upload state machine, whose protocol is too stateful for +//! request/response codegen; +//! - [`sync`] — the cursor and anti-rewind state machine **over** the generated `GET /v1/sync` +//! operation (`S-D28` retired the gRPC feed; the wire here is generated like every other); +//! - [`directory`], [`upgrade`], and [`verify`]'s receipt fetch — the four `application/cbor` +//! operations `spargen` 0.4 cannot lower, narrowed out in `build.rs`. pub mod albums; pub mod auth; @@ -37,14 +46,14 @@ pub mod verify; #[cfg(test)] mod testmock; -/// The typed REST client generated by `spargen` from the server's committed OpenAPI **3.1** +/// The typed REST client generated by `spargen` from the server's committed OpenAPI **3.2** /// schema (`openapi.json`), emitted into `OUT_DIR` by `build.rs` and included verbatim here /// (slice `S-D8`). One `async` method per operation, typed models, typed errors, and an /// embedded freestanding `reqwest` runtime — `spargen` never enters the runtime dependency -/// tree. Do not edit: regenerate the schema with `mise run openapi`; the client re-generates -/// on every build. The ergonomic, session-composed entry point is -/// [`client::AuthenticatedClient`]; reach for [`rest::Client`] directly only for -/// unauthenticated calls. +/// tree. Do not edit: regenerate the schema with `mise run openapi-kynos` (and +/// `mise run openapi-check-kynos` gates it); the client re-generates on every build. The +/// ergonomic, session-composed entry point is [`client::AuthenticatedClient`]; reach for +/// [`rest::Client`] directly only for unauthenticated calls. pub mod rest { #![allow( clippy::all, From 000c7509f23105f94095f4187a501678dc9318c4 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 23:26:55 -0400 Subject: [PATCH 063/243] build(server): add the compose stack, the serve tasks and the release target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mise run serve-api` and the compose stack behind it went with the Salvo tree in `S-C59`, so there has been no way to bring a server's services up and nothing to point a client at. `capsule-server/compose.yaml` is Postgres 18 and Valkey 9.0.4 — the versions dependabot is already tracking — with the retired deployment's Valkey flags carried over verbatim, because those are the flags the session and upload-session stores were sized against. Both services carry a healthcheck: `serve-deps` returns as soon as compose has started the containers, so a developer who runs `serve` immediately afterwards would otherwise race the database's own startup. There is still no object store: the filesystem `BLOB_ROOT` is the blob backend. `serve-deps` and `serve` are separate tasks, because a task that silently starts containers is a task that leaks them. `serve` supplies no environment on purpose — it refuses and names what is missing. `serve-memory` is the one that just works, and its fallback signing key is the published example: every token it mints is forgeable by anyone who has read this repository, which is exactly why that task is not `serve`. `.env.example` documents every setting, its default, and why the default is what it is. It ships in the release archive, because a release without it is a binary that refuses to start and an operator reading GitHub to find out which variables it wanted. `release.yml` builds `capsule-server` beside `capsule` and puts both in the one per-target archive: an operator wants the server and the CLI that talks to it at the same version, and two downloads is two chances to mix versions. Unix only — Windows is already best-effort for the CLI, and adding a server build to a job allowed to fail would make "did the Windows CLI ship" harder to answer. `dependabot.yml`'s three `/capsule-api` entries were watching a directory that has not existed since `S-C59`. The cargo one moves to the workspace root, the docker-compose one to `/capsule-server`, and the `docker` one goes: no Containerfile exists anywhere in the active tree, and an ecosystem pointed at an absent file is a permanent dashboard error rather than an update. Refs #401, #402, #403 --- .github/dependabot.yml | 18 ++++-- .github/workflows/release.yml | 25 ++++++-- capsule-server/.env.example | 114 ++++++++++++++++++++++++++++++++++ capsule-server/compose.yaml | 64 +++++++++++++++++++ mise.toml | 35 +++++++++++ 5 files changed, 245 insertions(+), 11 deletions(-) create mode 100644 capsule-server/.env.example create mode 100644 capsule-server/compose.yaml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 46c0e979..a10c10ac 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,16 +11,22 @@ updates: directory: "/capsule-web" schedule: interval: "weekly" + # The workspace root: `Cargo.toml` and `Cargo.lock` live here and cover every member. This + # pointed at `/capsule-api` until the Salvo tree moved to `legacy-review/` in `S-C59`, so it + # had been watching a directory that no longer exists — and therefore watching nothing. - package-ecosystem: "cargo" - directory: "/capsule-api" - schedule: - interval: "weekly" - - package-ecosystem: "docker" - directory: "/capsule-api" + directory: "/" schedule: interval: "weekly" + # The server's local service images (Postgres, Valkey). Same story: `/capsule-api/compose.yaml` + # went with the Salvo tree, and `capsule-server/compose.yaml` is the live file (issue #401). + # + # There is no `docker` entry any more. It watched `/capsule-api/Containerfile`, and no + # Containerfile exists anywhere in the active tree — an OCI image for the rebuilt server is + # not written yet, and an ecosystem pointed at an absent file is a permanent dashboard error + # rather than a dependency update. Add it back in the change that adds the Containerfile. - package-ecosystem: "docker-compose" - directory: "/capsule-api" + directory: "/capsule-server" schedule: interval: "weekly" - package-ecosystem: "github-actions" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fa3d3865..2ce7108d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,16 @@ name: Release # Fires when a release commit (`chore(release): vX.Y.Z`, produced by prepare-release.yml -# and merged via its PR) lands on master. It builds the `capsule` CLI for each target and -# publishes a GitHub Release. `gh release create` also creates the tag, so the whole -# build+publish happens in this one run — no PAT or tag-push re-trigger needed. +# and merged via its PR) lands on master. It builds the `capsule` CLI and the `capsule-server` +# binary for each target and publishes a GitHub Release. `gh release create` also creates the +# tag, so the whole build+publish happens in this one run — no PAT or tag-push re-trigger +# needed. +# +# Both binaries ride in the one per-target archive rather than two: an operator running a +# self-hosted deployment wants the server and the CLI that talks to it at the same version, and +# two downloads is two chances to mix versions. The server is Unix-only here — Windows is +# already best-effort for the CLI, and adding a server build to a job that is allowed to fail +# would make "did the Windows CLI ship" harder to answer, not easier. on: push: branches: [master] @@ -41,7 +48,7 @@ jobs: fi build: - name: Build capsule (${{ matrix.target }}) + name: Build binaries (${{ matrix.target }}) needs: detect if: ${{ needs.detect.outputs.release == 'true' }} runs-on: ${{ matrix.os }} @@ -74,8 +81,11 @@ jobs: uses: Swatinem/rust-cache@v2 with: key: release-${{ matrix.target }} - - name: Build release binary + - name: Build the CLI run: cargo build -p capsule-cli --release --target ${{ matrix.target }} + - name: Build the server + if: runner.os != 'Windows' + run: cargo build -p capsule-server --release --target ${{ matrix.target }} - name: Package (unix) if: runner.os != 'Windows' shell: bash @@ -84,6 +94,11 @@ jobs: dist="capsule-v${{ needs.detect.outputs.version }}-${{ matrix.target }}" mkdir -p "$dist" cp "target/${{ matrix.target }}/release/capsule" "$dist/" + cp "target/${{ matrix.target }}/release/capsule-server" "$dist/" + # The operator's starting point: every setting the server reads, with what it defaults + # to and why. A release without it is a binary that refuses to start and an operator + # reading GitHub to find out which variables it wanted. + cp capsule-server/.env.example "$dist/" cp README.md LICENSE NOTICE CHANGELOG.md "$dist/" tar -czf "${dist}.tar.gz" "$dist" echo "ASSET=${dist}.tar.gz" >> "$GITHUB_ENV" diff --git a/capsule-server/.env.example b/capsule-server/.env.example new file mode 100644 index 00000000..98d11518 --- /dev/null +++ b/capsule-server/.env.example @@ -0,0 +1,114 @@ +# Every setting `capsule-server` reads. Copy to `.env` and export it, or set these in your +# process manager — there is no configuration file: `--config PATH` is accepted and refused, and +# `capsule-server/src/config.rs` records why. +# +# Precedence, highest first: command-line flag, then the environment, then the built-in default. +# `FOO=` with an empty value counts as unset. +# +# A missing or malformed setting is reported with **every** other fault in one message and exit +# code 2, so bringing a deployment up is one read of one log line rather than one restart per +# variable. + +# ── Required to serve ──────────────────────────────────────────────────────────────────────── +# +# PKCS#8 v1 DER-encoded Ed25519 private key, base64. Access and refresh tokens are signed with +# it, and `.well-known/capsule/server-info` publishes the public half **derived from it** — so +# there is no second copy to paste wrongly. +# +# openssl genpkey -algorithm ed25519 -outform DER | base64 -w 0 +# +# The value below is the retired deployment's own example. It is public, it signs nothing, and a +# real deployment must replace it. +JWT_ED25519_DER=MC4CAQAwBQYDK2VwBCIEIN6eTvXEL7xMZWHY8rTk7VbQSGSuRkle5MVfiiYUStLF + +# Where ciphertext blobs are written. There is no object store: this filesystem path *is* the +# blob backend, and `capsule-server gc|purge|scrub` read the same tree. +# +# Under `target/` so it is already git-ignored and `cargo clean` takes it with it. That is the +# right trade for the development profile, whose index does not survive a restart either — a +# blob whose index row is gone is an orphan, which is exactly what `scrub` will tell you. +# A real deployment points this at durable storage. +# +# There is deliberately **no default**: a server that silently wrote blobs into the current +# directory would be worse than one that refuses to start. `UPLOAD_DIR` is the retired name and +# is still honoured, with a warning. +BLOB_ROOT=./target/capsule-server-blobs + +# ── The listener ───────────────────────────────────────────────────────────────────────────── +# +# `SERVER_HOST` must be an IP address to bind. `--listen HOST:PORT` overrides both. +# Port 0 asks the operating system to choose, and the chosen address is written to stdout. +SERVER_HOST=127.0.0.1 +SERVER_PORT=3000 + +# This deployment's canonical origin — the `server_id` every published record carries, and the +# issuer an authenticator app shows beside a second-factor code. In production, your public +# domain (e.g. api.capsule.example). +SERVER_DOMAIN=localhost + +# The absolute base URL clients reach the versioned API at. `server-info` derives the published +# auth endpoints by appending to it, so the `/v1` prefix is not optional. +# Default: http://{SERVER_DOMAIN}:{SERVER_PORT}/v1 +# API_BASE_URL=https://api.capsule.example/v1 + +# TLS is **not** terminated here. `design/cryptography/failure-modes.md` puts HTTPS on the +# ingress or reverse proxy; there is no certificate setting and Kynos's `tls` feature is off. + +# ── Backends ───────────────────────────────────────────────────────────────────────────────── +# +# Postgres and Valkey are required for a deployment, and **no adapter reads either URL yet** +# (#402, #403). Until they land: +# +# - `capsule-server serve` with `VALKEY_URL` set refuses to boot and names the issue; +# - `capsule-server serve` with neither `VALKEY_URL` nor `--memory` refuses and names the +# variable, which is the refusal `store/mod.rs` has always documented; +# - `capsule-server serve --memory` (`mise run serve-memory`) runs on the in-crate in-memory +# adapters over a real filesystem blob store. An explicit development act, never a fallback. +# +# `mise run serve-deps` brings both services up from capsule-server/compose.yaml. +DATABASE_URL=postgresql://capsule:capsule@localhost:5432/capsule +VALKEY_URL=redis://127.0.0.1:6379 + +# `memory` selects the in-memory adapters, exactly as `--memory` does. The flag is the primary +# spelling; this exists for a process manager that cannot add an argument. +# CAPSULE_PROFILE=memory + +# ── Derived key material ───────────────────────────────────────────────────────────────────── +# +# Both are HKDF-SHA256-derived from JWT_ED25519_DER under separate `info` strings when unset, +# which is the right default for a single-server deployment. Set them explicitly only to rotate +# one independently of the token-signing key, or to share an attestation identity across +# replicas. +# +# SYNC_CURSOR_MAC_KEY= # base64, exactly 32 bytes +# ATTESTATION_KEY_SEED= # base64, 32 or 64 bytes (32 is expanded, domain-separated) + +# ── The protocol window ────────────────────────────────────────────────────────────────────── +# +# Both ends inclusive, both published, and both default to the version `capsule-core` speaks. +# Widen `PROTOCOL_MIN` only with a deprecation announcement behind it. +# PROTOCOL_MIN=2026-05-31 +# PROTOCOL_MAX=2026-05-31 + +# ── Operational knobs ──────────────────────────────────────────────────────────────────────── +# +# How long a blob sits at zero references before `gc` may sweep it. The design's range is 24-72 +# hours: long enough for an in-flight finalization retry to re-reference it, short enough that an +# orphan is not held forever. `gc --grace-window-hours` overrides it. +# GC_GRACE_WINDOW_HOURS=24 + +# How long a shutdown may take to drain. 25 seconds by default, under the usual 30-second +# orchestrator termination window. +# SHUTDOWN_TIMEOUT_SECONDS=25 + +# The accepted-connection ceiling, across every listener. +# MAX_CONNECTIONS=10000 + +# `json` (one object per event, for a log shipper) or `pretty` (for a person). Defaults to +# `pretty` in a debug build and `json` in a release one. Everything is written to **stderr**, so +# stdout stays a data channel: `gen-openapi` writes a path there and the operator commands write +# their report. +# LOG_FORMAT=json + +# The usual `tracing` filter. Defaults to `debug` in a debug build and `info` in a release one. +# RUST_LOG=info diff --git a/capsule-server/compose.yaml b/capsule-server/compose.yaml new file mode 100644 index 00000000..c9e6c2d4 --- /dev/null +++ b/capsule-server/compose.yaml @@ -0,0 +1,64 @@ +# The two external services a Capsule deployment needs, for local development. +# +# Bring them up with `mise run serve-deps` and the server up with `mise run serve`. The two are +# deliberately separate tasks: a task that silently starts containers is a task that leaks them. +# +# **Nothing reads these yet.** The Postgres adapter is issue #402 and the Valkey adapter is #403; +# until they land, `capsule-server serve` refuses to boot with `VALKEY_URL` set and the way to +# run a server is `mise run serve-memory`. This file exists now because the compose stack went +# with the Salvo tree in `S-C59` and re-deriving it later is re-doing work — and because +# dependabot needs a live file to track the images against. +# +# There is deliberately **no object store**. Blobs are written to the filesystem `BLOB_ROOT`, +# which is the blob backend and not a cache in front of one (design/filesystem/server.md); a +# MinIO service used to sit here and was never wired to anything. +# +# Podman-first: the `:Z,U` volume labels relabel for SELinux and chown to the container user, and +# `docker compose` accepts both. Every image is fully qualified, because podman prompts for a +# registry otherwise. + +services: + postgres: + image: docker.io/library/postgres:18 + ports: + - "5432:5432" + environment: + # Development credentials, matching capsule-server/.env.example's DATABASE_URL. Override + # them in the environment or a .env file; nothing here is a secret worth keeping. + POSTGRES_USER: capsule + POSTGRES_PASSWORD: capsule + POSTGRES_DB: capsule + healthcheck: + # `serve-deps` returns as soon as compose has started the containers, so a developer who + # runs `mise run serve` immediately afterwards would otherwise race the database's own + # startup. `pg_isready` is what makes "up" mean "accepting connections". + test: ["CMD-SHELL", "pg_isready -U capsule -d capsule"] + interval: 5s + timeout: 3s + retries: 12 + volumes: + - postgres_data:/var/lib/postgresql/data:Z,U + + valkey: + image: docker.io/valkey/valkey:9.0.4 + ports: + - "6379:6379" + environment: + # Carried over from the retired deployment verbatim, because these are the flags the + # session and upload-session stores were sized against: `volatile-lru` so a key with a TTL + # is what gets evicted under pressure and a key without one never is, `appendonly` with + # `everysec` so a restart loses at most a second of session state rather than all of it, + # and the `lazyfree-*` set so an eviction does not block the command that triggered it. + # `protected-mode no` is a development-only concession: the port is published to localhost. + VALKEY_EXTRA_FLAGS: "--maxmemory 4G --maxmemory-policy volatile-lru --save 900 1 300 10 --appendonly yes --appendfsync everysec --no-appendfsync-on-rewrite yes --auto-aof-rewrite-percentage 100 --auto-aof-rewrite-min-size 64mb --lazyfree-lazy-eviction yes --lazyfree-lazy-expire yes --lazyfree-lazy-server-del yes --replica-lazy-flush yes --protected-mode no --tcp-keepalive 60 --loglevel notice --slowlog-log-slower-than 10000 --slowlog-max-len 128 --io-threads 4" + healthcheck: + test: ["CMD-SHELL", "valkey-cli ping | grep -q PONG"] + interval: 5s + timeout: 3s + retries: 12 + volumes: + - valkey_data:/data:Z,U + +volumes: + postgres_data: + valkey_data: diff --git a/mise.toml b/mise.toml index 4fe3ee4e..f9fff287 100644 --- a/mise.toml +++ b/mise.toml @@ -232,6 +232,41 @@ run = "cargo run -q -p capsule-server -- gen-openapi" description = "Verify the committed Kynos OpenAPI 3.2 document matches the server" run = "cargo run -q -p capsule-server -- gen-openapi --check" +# ── Running a server locally ───────────────────────────────────────────────── +# +# `serve-deps` and `serve` are deliberately separate: a task that silently starts containers is +# a task that leaks them. Bring the services down with +# `podman compose -f capsule-server/compose.yaml down`. + +[tasks.serve-deps] +description = "Start Postgres 18 + Valkey 9 for the server (podman compose)" +# `podman compose` shells out to the compose provider; `docker compose -f …` accepts the same +# file. Nothing reads these services yet — the adapters are issues #402 and #403 — so this is +# for the adapters' own development and for dependabot to have a live file to track. +run = "podman compose -f capsule-server/compose.yaml up -d" + +[tasks.serve] +description = "Run the Kynos server (needs the capsule-server/.env.example environment)" +# No environment is supplied on purpose. `serve` without `VALKEY_URL` and without `--memory` +# refuses to boot and names the variable — the refusal `capsule-server/src/store/mod.rs` has +# documented since `S-C29` — and with `VALKEY_URL` set it refuses and names #403. Copy +# `capsule-server/.env.example` and export it, or use `serve-memory` below. +run = "cargo run -p capsule-server -- serve" + +[tasks.serve-memory] +description = "Run the server on the in-memory adapters (development only)" +# A server you can point a client at: register, sign in, upload, sync. The blob store is real +# and lives under `target/`; everything else is lost when the process exits. +# +# The fallback key is the **published** example from capsule-server/.env.example. Every token +# this mints is forgeable by anyone who has read this repository, which is exactly why this task +# is `serve-memory` and not `serve` — set `JWT_ED25519_DER` yourself and it is used instead. +run = """ +JWT_ED25519_DER=${JWT_ED25519_DER:-MC4CAQAwBQYDK2VwBCIEIN6eTvXEL7xMZWHY8rTk7VbQSGSuRkle5MVfiiYUStLF} \ +BLOB_ROOT=${BLOB_ROOT:-./target/capsule-server-blobs} \ +cargo run -p capsule-server -- serve --memory +""" + # Regenerate the translated README..md files from README.md and the committed # per-locale translation data (xtask/translations/readme/). See xtask/src/translate_readme.rs. [tasks.translate-readme] From e222d143666be7295d90ec918e931dc5a2a7140c Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 23:27:16 -0400 Subject: [PATCH 064/243] feat(cli): emit the capsule command tree as a description artifact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The documentation build installs bun and nothing else, so it cannot ask cargo what `capsule --help` says. Give it a committed artifact to read instead, and a drift gate that makes a stale one fail CI (slice `S-Z8`). `capsule_cli::cli::command_tree()` walks the clap tree built from compile-time attributes and returns JSON. It is the crate's only new public surface: `Cli` stays `pub(crate)` because the parsed command is dispatch state, not API. The output is deterministic and independent of the process locale, both because the artifact is byte-compared by its own gate. Subcommands are sorted by name so reordering an enum variant cannot churn the file; arguments keep declaration order, which for a positional is its position. `Command::build` is not called, so clap's synthesized `--help` is not described sixteen times over, and a boolean flag is not documented as taking `true` or `false`. `gen_cli_surface` mirrors `gen_openapi` argument for argument — `[FILE]` default, `--check`, byte comparison, trailing newline — so the two description artifacts are one thing to remember rather than two. It adds no dependency: serde_json and clap were already here. `cli-surface-check` joins `check-rust` beside `openapi-check-kynos`. --- capsule-cli/Cargo.toml | 8 + capsule-cli/cli-surface.json | 539 +++++++++++++++++++++++++ capsule-cli/src/bin/gen_cli_surface.rs | 83 ++++ capsule-cli/src/cli/mod.rs | 369 ++++++++++++++++- mise.toml | 15 + 5 files changed, 1013 insertions(+), 1 deletion(-) create mode 100644 capsule-cli/cli-surface.json create mode 100644 capsule-cli/src/bin/gen_cli_surface.rs diff --git a/capsule-cli/Cargo.toml b/capsule-cli/Cargo.toml index 613286b2..e8bc024b 100644 --- a/capsule-cli/Cargo.toml +++ b/capsule-cli/Cargo.toml @@ -9,6 +9,14 @@ publish.workspace = true name = "capsule" path = "src/main.rs" +# The command-tree description artifact the documentation site is generated from (slice +# `S-Z8`). Declared explicitly rather than left to autodiscovery so the crate's two binaries +# are visible in one place: `capsule` ships, this one only ever runs from `mise run +# cli-surface` / `cli-surface-check`. +[[bin]] +name = "gen_cli_surface" +path = "src/bin/gen_cli_surface.rs" + [lib] name = "capsule_cli" path = "src/lib.rs" diff --git a/capsule-cli/cli-surface.json b/capsule-cli/cli-surface.json new file mode 100644 index 00000000..d54af726 --- /dev/null +++ b/capsule-cli/cli-surface.json @@ -0,0 +1,539 @@ +{ + "about": "A command line interface for Capsule - the photo management platform", + "long_about": "Capsule CLI provides tools for managing your photos and albums:\n• Authentication management\n• Sync local and remote data\n• Check status and list files\n• Manage albums and collections", + "name": "capsule", + "schema": 1, + "subcommands": [ + { + "about": "Authentication commands", + "name": "auth", + "subcommands": [ + { + "about": "Login to Capsule", + "args": [ + { + "help": "Account email (prompted when omitted)", + "id": "email", + "long": "email", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": true, + "value_names": [ + "EMAIL" + ] + }, + { + "help": "Read the password from stdin instead of prompting, so the command works in scripts and CI where there is no terminal", + "id": "password_stdin", + "long": "password-stdin", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + } + ], + "name": "login" + }, + { + "about": "Logout from Capsule", + "name": "logout" + }, + { + "about": "Create a Capsule account and sign in", + "args": [ + { + "help": "Account email (prompted when omitted)", + "id": "email", + "long": "email", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": true, + "value_names": [ + "EMAIL" + ] + }, + { + "help": "Read the password from stdin instead of prompting, so the command works in scripts and CI where there is no terminal", + "id": "password_stdin", + "long": "password-stdin", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + } + ], + "name": "register" + }, + { + "about": "Show authentication status", + "name": "status" + } + ] + }, + { + "about": "Review a local library: flag assets, filter by flag, sweep rejects to trash", + "args": [ + { + "help": "Path to the Capsule library", + "id": "library", + "long": "library", + "positional": false, + "repeatable": false, + "required": true, + "takes_value": true, + "value_names": [ + "PATH" + ] + }, + { + "help": "Read the library passphrase from stdin instead of prompting, so culling works in scripts and CI where there is no terminal", + "id": "passphrase_stdin", + "long": "passphrase-stdin", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + }, + { + "help": "Flag an asset as a keeper (repeatable)", + "id": "pick", + "long": "pick", + "positional": false, + "repeatable": true, + "required": false, + "takes_value": true, + "value_names": [ + "ASSET_ID" + ] + }, + { + "help": "Clear an asset's flag back to the never-flagged default (repeatable)", + "id": "neutral", + "long": "neutral", + "positional": false, + "repeatable": true, + "required": false, + "takes_value": true, + "value_names": [ + "ASSET_ID" + ] + }, + { + "help": "Flag an asset for rejection (repeatable)", + "id": "reject", + "long": "reject", + "positional": false, + "repeatable": true, + "required": false, + "takes_value": true, + "value_names": [ + "ASSET_ID" + ] + }, + { + "help": "List the assets carrying one flag instead of only counting them", + "id": "filter", + "long": "filter", + "positional": false, + "possible_values": [ + { + "help": "A keeper", + "name": "pick" + }, + { + "help": "Not yet culled either way — the never-flagged default", + "name": "neutral" + }, + { + "help": "Marked for rejection; the set `--sweep` moves to trash", + "name": "reject" + } + ], + "repeatable": false, + "required": false, + "takes_value": true, + "value_names": [ + "FLAG" + ] + }, + { + "help": "Move every rejected asset to trash. The only destructive step, and soft per retention — swept assets stay restorable until the window elapses", + "id": "sweep", + "long": "sweep", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + }, + { + "default_values": [ + "30" + ], + "help": "Retention window, in days, the sweep's soft delete stamps", + "id": "retain_days", + "long": "retain-days", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": true, + "value_names": [ + "DAYS" + ] + } + ], + "name": "cull" + }, + { + "about": "Run the offline end-to-end data-plane showcase (real cryptography, no network)", + "args": [ + { + "help": "Working directory for the demo libraries (a temp dir is used if omitted)", + "id": "workdir", + "long": "workdir", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": true, + "value_names": [ + "PATH" + ] + }, + { + "help": "A real image/file to import (a small synthetic file is used if omitted)", + "id": "image", + "long": "image", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": true, + "value_names": [ + "PATH" + ] + } + ], + "name": "demo" + }, + { + "about": "Import files into a local Capsule library", + "args": [ + { + "help": "Source file or directory to import. Repeatable: a split Takeout export extracted into several folders is imported by naming every part in one run, so a media file and a sidecar that landed in different parts are still paired", + "id": "paths", + "positional": true, + "repeatable": true, + "required": true, + "takes_value": true, + "value_names": [ + "PATH" + ] + }, + { + "help": "Read the source as an export from this service instead of as a plain directory tree, so its out-of-band metadata (capture time, GPS, captions, favorites, album membership) is folded into the imported assets", + "id": "provider", + "long": "provider", + "positional": false, + "possible_values": [ + { + "help": "A Google Photos export produced by Google Takeout, extracted to disk", + "name": "takeout" + } + ], + "repeatable": false, + "required": false, + "takes_value": true, + "value_names": [ + "PROVIDER" + ] + }, + { + "help": "Path to the Capsule library", + "id": "library", + "long": "library", + "positional": false, + "repeatable": false, + "required": true, + "takes_value": true, + "value_names": [ + "PATH" + ] + }, + { + "help": "Move files instead of copying them", + "id": "move", + "long": "move", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + }, + { + "help": "Re-import files even if they already exist (duplicate override)", + "id": "force", + "long": "force", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + }, + { + "help": "Read the library passphrase from stdin instead of prompting, so imports work in scripts and CI where there is no terminal", + "id": "passphrase_stdin", + "long": "passphrase-stdin", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + }, + { + "help": "Push the library to the server after importing — sugar for a `capsule push` run over the same library. The import itself stays offline", + "id": "push", + "long": "push", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + }, + { + "help": "Stage the follow-on push (`--push`) in tier order, gating the preview and original tiers on the connection class", + "id": "staged", + "long": "staged", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + } + ], + "name": "import" + }, + { + "about": "Manage the local library", + "name": "library", + "subcommands": [ + { + "about": "Show library information", + "args": [ + { + "help": "Path to the library", + "id": "path", + "positional": true, + "repeatable": false, + "required": true, + "takes_value": true, + "value_names": [ + "PATH" + ] + } + ], + "name": "info" + }, + { + "about": "Create a new Capsule library", + "args": [ + { + "help": "Directory for the new library", + "id": "path", + "positional": true, + "repeatable": false, + "required": true, + "takes_value": true, + "value_names": [ + "PATH" + ] + }, + { + "default_values": [ + "My Library" + ], + "help": "Human-readable library name", + "id": "name", + "long": "name", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": true, + "value_names": [ + "NAME" + ] + } + ], + "name": "init" + }, + { + "about": "Rebuild the SQLite index from sidecar files", + "args": [ + { + "help": "Path to the library", + "id": "path", + "positional": true, + "repeatable": false, + "required": true, + "takes_value": true, + "value_names": [ + "PATH" + ] + } + ], + "name": "rebuild" + } + ] + }, + { + "about": "List the assets the sync feed has delivered", + "args": [ + { + "help": "Include assets the server has tombstoned (deleted) as well as live ones", + "id": "include_deleted", + "long": "include-deleted", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + } + ], + "name": "list" + }, + { + "about": "Match metadata for current file", + "args": [ + { + "help": "Path to the file to match metadata for", + "id": "path", + "positional": true, + "repeatable": false, + "required": true, + "takes_value": true, + "value_names": [ + "PATH" + ] + } + ], + "name": "match" + }, + { + "about": "Upload a local Capsule library to the server", + "args": [ + { + "help": "Path to the Capsule library to push", + "id": "library", + "long": "library", + "positional": false, + "repeatable": false, + "required": true, + "takes_value": true, + "value_names": [ + "PATH" + ] + }, + { + "help": "Read the library passphrase from stdin instead of prompting, so pushes work in scripts and CI where there is no terminal", + "id": "passphrase_stdin", + "long": "passphrase-stdin", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + }, + { + "help": "Report what would be uploaded without opening a single upload session", + "id": "dry_run", + "long": "dry-run", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + }, + { + "help": "Re-drive every blob regardless of what the server already holds", + "id": "force", + "long": "force", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + }, + { + "help": "Open the tier sessions in ladder order (index → preview → original), gating the above-index tiers on the connection class, instead of opening all eagerly", + "id": "staged", + "long": "staged", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + } + ], + "name": "push" + }, + { + "about": "Reset all local CLI data", + "args": [ + { + "help": "Reset configuration", + "id": "config", + "long": "config", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + }, + { + "help": "Reset data directory", + "id": "data", + "long": "data", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + }, + { + "help": "Reset cache directory", + "id": "cache", + "long": "cache", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + }, + { + "help": "Reset all data", + "id": "all", + "long": "all", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + } + ], + "name": "reset" + }, + { + "about": "Show current status", + "name": "status" + }, + { + "about": "Sync local and remote data", + "args": [ + { + "help": "Discard the saved cursor and re-drain the feed from the start. The per-album anti-rewind floor still applies, so this cannot resurrect stale entries", + "id": "force", + "long": "force", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + }, + { + "help": "Perform a dry run without making changes", + "id": "dry_run", + "long": "dry-run", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + } + ], + "name": "sync" + } + ] +} diff --git a/capsule-cli/src/bin/gen_cli_surface.rs b/capsule-cli/src/bin/gen_cli_surface.rs new file mode 100644 index 00000000..7641ba13 --- /dev/null +++ b/capsule-cli/src/bin/gen_cli_surface.rs @@ -0,0 +1,83 @@ +//! Deterministic dump of the `capsule` command tree (slice `S-Z8`). +//! +//! Serializes [`capsule_cli::cli::command_tree`] to `capsule-cli/cli-surface.json` and, with +//! `--check`, fails when the committed copy is stale. It is the CLI's half of the rule that +//! artifacts cross the toolchain boundary rather than toolchains (`design/developer-docs.md`): +//! the documentation build installs bun and nothing else, so it cannot ask cargo what +//! `capsule --help` says. It reads this file instead, and this binary is what makes a drifted +//! file fail CI. +//! +//! Deliberately the same shape as `capsule-server/src/bin/gen_openapi.rs` — `[FILE]` default, +//! `--check`, byte comparison, trailing newline — because two description artifacts that are +//! refreshed and gated differently are two things to remember instead of one. +//! +//! It needs no database, no library, no key material, no network: `command_tree()` walks a +//! `clap::Command` built from compile-time attributes. That is what lets `--check` run in the +//! Rust check gate beside `openapi-check-kynos`. +//! +//! ## Why this binary prints no prose +//! +//! `xtask i18n-guard` scans `capsule-cli/src/**` for string literals passed to +//! `print`/`println`/`eprint`/`eprintln`/`eyre`/`bail`, and `locales/i18n-guard-allowlist.txt` +//! says in as many words not to add a CLI line to make new output pass. That rule is right for +//! the `capsule` binary, which renders prose to a user in their own language. This binary is CI +//! tooling: its audience is a developer reading a task's output, and routing a build tool's +//! status line through `locales/` would put a string no user can reach into every translation +//! catalog. So it says what it has to say with a path and an exit code — success writes the +//! path it wrote, `--check` is silent on success as `cargo fmt --check` is, and the stale-file +//! message is built with `format!` and carried by the `Result` that `color_eyre` reports. +//! +//! Usage: +//! - `gen_cli_surface [FILE]` writes the document (default `capsule-cli/cli-surface.json`). +//! - `gen_cli_surface --check [FILE]` fails if the committed document is stale, writing nothing. + +use std::path::PathBuf; + +use clap::Parser; +use color_eyre::eyre::{Context, Report, Result}; + +#[derive(Parser)] +#[command(author, version, about, long_about = None)] +struct Cli { + /// Output path for the command-tree document (relative to the repo root). + #[arg(value_name = "FILE", default_value = "capsule-cli/cli-surface.json")] + output: PathBuf, + + /// Verify the committed document is up to date instead of writing it (CI drift gate). + #[arg(long)] + check: bool, +} + +fn main() -> Result<()> { + color_eyre::install()?; + let cli = Cli::parse(); + + // Pretty-printed with a trailing newline: the artifact is reviewed as a diff, so a + // one-line blob would hide exactly the change a reviewer is there to see. + let mut json = serde_json::to_string_pretty(&capsule_cli::cli::command_tree()) + .wrap_err("serializing the command tree to JSON")?; + json.push('\n'); + + if cli.check { + let committed = std::fs::read_to_string(&cli.output).wrap_err_with(|| { + format!("cannot read committed document at {}", cli.output.display()) + })?; + if committed != json { + return Err(Report::msg(format!( + "the command-tree document at {} is out of sync with the `capsule` argument \ + surface; run `mise run cli-surface` and commit the result", + cli.output.display() + ))); + } + } else { + if let Some(parent) = cli.output.parent() { + std::fs::create_dir_all(parent) + .wrap_err_with(|| format!("creating {}", parent.display()))?; + } + std::fs::write(&cli.output, &json) + .wrap_err_with(|| format!("writing {}", cli.output.display()))?; + println!("{}", cli.output.display()); + } + + Ok(()) +} diff --git a/capsule-cli/src/cli/mod.rs b/capsule-cli/src/cli/mod.rs index e3cc16ba..669bdebe 100644 --- a/capsule-cli/src/cli/mod.rs +++ b/capsule-cli/src/cli/mod.rs @@ -1,7 +1,22 @@ +//! The `capsule` argument surface, and the machine-readable description of it that the +//! documentation site is generated from. +//! +//! [`Cli`] stays `pub(crate)`: the parsed command is dispatch state, not API. What crosses +//! the crate boundary is [`command_tree`], the description artifact behind +//! `/reference/cli/` (slice `S-Z8`). + pub(crate) mod commands; -use clap::Parser; +use clap::{Arg, ArgAction, Command, CommandFactory, Parser}; pub(crate) use commands::*; +use serde_json::{Map, Value}; + +/// Schema version of the emitted command-tree document. +/// +/// Bumped only when a consumer must change to keep reading it — adding an optional field is +/// not a bump, renaming or removing one is. `capsule-docs/scripts/gen-reference.mjs` refuses +/// a version it was not written against rather than rendering a half-understood document. +const COMMAND_TREE_SCHEMA: u32 = 1; #[derive(Parser, Debug)] #[command(name = "capsule")] @@ -13,3 +28,355 @@ pub(crate) struct Cli { #[command(subcommand)] pub command: Commands, } + +/// The `capsule` command tree as JSON — the committed description artifact +/// `capsule-cli/cli-surface.json`, emitted by the `gen_cli_surface` binary and rendered +/// into `/reference/cli/` by the documentation build (slice `S-Z8`). +/// +/// # This output is deterministic, and independent of the process locale +/// +/// Both properties are load-bearing, because the artifact is committed and drift-gated: +/// `mise run cli-surface-check` fails on any byte difference, so a value that varies by +/// machine, environment, or run would fail CI on a tree nobody changed. +/// +/// - **Deterministic.** Subcommands are sorted by name rather than emitted in +/// declaration order, so reordering an enum variant cannot churn the artifact. +/// Arguments keep declaration order, which for a `clap` derive is field order, which +/// for a positional *is* its position — sorting them would destroy that meaning. +/// Object keys come out sorted because `serde_json::Map` is a `BTreeMap` here +/// (`preserve_order` is off). Nothing is read from the clock, the filesystem, or the +/// environment. +/// - **Locale-independent.** Every string below comes from a `clap` attribute or a doc +/// comment — compile-time English `&'static str` — and `StyledStr`'s `Display` is +/// documented as colour-unaware, so no ANSI escape can leak in from a terminal that +/// supports colour. The process locale is never consulted: this function does **not** +/// call [`crate::i18n::cli_bundle`], which negotiates `LC_ALL`/`LC_MESSAGES`/`LANG`. +/// +/// **If help text is ever localized, it must be resolved here through +/// `Bundle::for_locale("en")`, never through `cli_bundle()`.** The artifact describes one +/// surface in one language; a bundle negotiated from the environment would make +/// `cli-surface-check` pass or fail according to the developer's `LANG`, and the drift +/// gate would stop meaning anything. Localizing the *rendered* help a user sees is a +/// separate concern from describing the surface. +/// +/// The tree describes the surface this crate *declares*. `clap`'s generated `--help` (and +/// `--version`, were one configured) is deliberately absent: [`Command::build`] is not +/// called, so no synthesized argument is described, and the reference page does not repeat +/// `--help` under all sixteen commands. Hidden commands and arguments are skipped for the +/// same reason they are hidden. +#[must_use] +pub fn command_tree() -> Value { + let mut root = describe_command(&Cli::command()); + root.insert( + "schema".to_owned(), + Value::from(u64::from(COMMAND_TREE_SCHEMA)), + ); + Value::Object(root) +} + +/// Describe one command and, recursively, its subcommands. +/// +/// Recursion is bounded by the derive: the tree is a finite `enum` nesting, so there is no +/// cycle to guard against and no depth limit to pick. +fn describe_command(command: &Command) -> Map { + let mut out = Map::new(); + out.insert("name".to_owned(), Value::from(command.get_name())); + + if let Some(about) = command.get_about() { + out.insert("about".to_owned(), Value::from(about.to_string())); + } + // Emitted only when it says something `about` does not, so the artifact does not carry + // the same paragraph twice for every command whose doc comment is one line long. + if let Some(long_about) = command.get_long_about() { + let long_about = long_about.to_string(); + if Some(long_about.as_str()) != command.get_about().map(ToString::to_string).as_deref() { + out.insert("long_about".to_owned(), Value::from(long_about)); + } + } + + let args: Vec = command + .get_arguments() + .filter(|arg| !arg.is_hide_set()) + .map(|arg| Value::Object(describe_arg(arg))) + .collect(); + if !args.is_empty() { + out.insert("args".to_owned(), Value::from(args)); + } + + let mut subcommands: Vec<&Command> = command + .get_subcommands() + .filter(|subcommand| !subcommand.is_hide_set()) + .collect(); + subcommands.sort_by(|a, b| a.get_name().cmp(b.get_name())); + if !subcommands.is_empty() { + let described: Vec = subcommands + .into_iter() + .map(|subcommand| Value::Object(describe_command(subcommand))) + .collect(); + out.insert("subcommands".to_owned(), Value::from(described)); + } + + out +} + +/// Describe one argument: how it is spelled, whether it takes a value, and what the help +/// says about it. +fn describe_arg(arg: &Arg) -> Map { + let mut out = Map::new(); + out.insert("id".to_owned(), Value::from(arg.get_id().as_str())); + out.insert("positional".to_owned(), Value::from(arg.is_positional())); + out.insert("required".to_owned(), Value::from(arg.is_required_set())); + out.insert("takes_value".to_owned(), Value::from(takes_value(arg))); + out.insert("repeatable".to_owned(), Value::from(is_repeatable(arg))); + + if let Some(long) = arg.get_long() { + out.insert("long".to_owned(), Value::from(long)); + } + if let Some(short) = arg.get_short() { + out.insert("short".to_owned(), Value::from(short.to_string())); + } + // Both of these are asked only of a value-taking argument, because the derive answers + // them for a flag too and both answers are internal detail rather than surface. A + // `bool` field gets a `value_name` synthesized from its identifier (`PASSWORD_STDIN`) + // that no user may type, and `get_possible_values` reports the `true`/`false` its bool + // parser accepts — rendering either would document `--password-stdin ` + // taking `true` or `false`, which is not the flag `capsule --help` offers. + if takes_value(arg) { + if let Some(names) = arg.get_value_names() { + let names: Vec = names + .iter() + .map(|name| Value::from(name.to_string())) + .collect(); + out.insert("value_names".to_owned(), Value::from(names)); + } + + let possible: Vec = arg + .get_possible_values() + .iter() + .filter(|value| !value.is_hide_set()) + .map(|value| { + let mut entry = Map::new(); + entry.insert("name".to_owned(), Value::from(value.get_name())); + if let Some(help) = value.get_help() { + entry.insert("help".to_owned(), Value::from(help.to_string())); + } + Value::Object(entry) + }) + .collect(); + if !possible.is_empty() { + out.insert("possible_values".to_owned(), Value::from(possible)); + } + } + + // `OsStr` here is `clap`'s, which derefs to the standard one. Every default in this + // surface is ASCII, so the lossy conversion is exact; a non-UTF-8 default would be + // unrepresentable in JSON either way. + let defaults: Vec = arg + .get_default_values() + .iter() + .map(|value| Value::from(value.to_string_lossy().into_owned())) + .collect(); + if !defaults.is_empty() { + out.insert("default_values".to_owned(), Value::from(defaults)); + } + + if let Some(help) = arg.get_help() { + out.insert("help".to_owned(), Value::from(help.to_string())); + } + if let Some(long_help) = arg.get_long_help() { + let long_help = long_help.to_string(); + if Some(long_help.as_str()) != arg.get_help().map(ToString::to_string).as_deref() { + out.insert("long_help".to_owned(), Value::from(long_help)); + } + } + + out +} + +/// Whether the argument consumes a value (`--library `) rather than being a flag +/// (`--force`). +/// +/// Read from the action rather than from `num_args`, which the derive leaves unset unless +/// a `#[arg(num_args = …)]` says otherwise. `ArgAction` is `#[non_exhaustive]`, so a new +/// value-taking action would arrive here as `false` — visible as a missing placeholder on +/// the reference page, never as a wrong one. +fn takes_value(arg: &Arg) -> bool { + matches!(arg.get_action(), ArgAction::Set | ArgAction::Append) +} + +/// Whether the argument may be given more than once (`--pick --pick `). +fn is_repeatable(arg: &Arg) -> bool { + matches!(arg.get_action(), ArgAction::Append | ArgAction::Count) + || arg + .get_num_args() + .is_some_and(|range| range.max_values() > 1) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn subcommand<'a>(parent: &'a Value, name: &str) -> &'a Value { + parent + .get("subcommands") + .and_then(Value::as_array) + .expect("the command has subcommands") + .iter() + .find(|entry| entry.get("name").and_then(Value::as_str) == Some(name)) + .unwrap_or_else(|| panic!("subcommand `{name}` is described")) + } + + fn arg<'a>(command: &'a Value, id: &str) -> &'a Value { + command + .get("args") + .and_then(Value::as_array) + .expect("the command has arguments") + .iter() + .find(|entry| entry.get("id").and_then(Value::as_str) == Some(id)) + .unwrap_or_else(|| panic!("argument `{id}` is described")) + } + + /// The property the committed artifact and its `--check` gate rest on. A tree that + /// varied between calls would fail `cli-surface-check` on a tree nobody changed. + #[test] + fn the_tree_is_byte_identical_across_calls() { + let first = serde_json::to_string_pretty(&command_tree()).expect("the tree serializes"); + let second = serde_json::to_string_pretty(&command_tree()).expect("the tree serializes"); + assert_eq!(first, second); + } + + #[test] + fn the_tree_carries_its_schema_version() { + assert_eq!( + command_tree().get("schema").and_then(Value::as_u64), + Some(u64::from(COMMAND_TREE_SCHEMA)) + ); + } + + /// No terminal escape may reach a committed file: it would make the artifact depend on + /// whether the emitting shell claimed colour support. + #[test] + fn the_tree_carries_no_terminal_escapes() { + let json = serde_json::to_string(&command_tree()).expect("the tree serializes"); + assert!( + !json.contains('\u{1b}'), + "an ANSI escape reached the description artifact" + ); + } + + #[test] + fn subcommands_are_sorted_by_name() { + let tree = command_tree(); + let names: Vec<&str> = tree + .get("subcommands") + .and_then(Value::as_array) + .expect("the root has subcommands") + .iter() + .filter_map(|entry| entry.get("name").and_then(Value::as_str)) + .collect(); + let mut sorted = names.clone(); + sorted.sort_unstable(); + assert_eq!(names, sorted); + assert!(names.contains(&"auth")); + assert!(names.contains(&"import")); + } + + /// Arguments keep declaration order, which for a positional is its position. Sorting + /// them would silently reorder `capsule library init --name`. + #[test] + fn arguments_keep_declaration_order_so_positionals_stay_in_position() { + let tree = command_tree(); + let init = subcommand(subcommand(&tree, "library"), "init"); + let ids: Vec<&str> = init + .get("args") + .and_then(Value::as_array) + .expect("`library init` has arguments") + .iter() + .filter_map(|entry| entry.get("id").and_then(Value::as_str)) + .collect(); + assert_eq!(ids, vec!["path", "name"]); + assert_eq!( + arg(init, "path").get("positional"), + Some(&Value::from(true)) + ); + assert_eq!( + arg(init, "name").get("positional"), + Some(&Value::from(false)) + ); + assert_eq!( + arg(init, "name").get("default_values"), + Some(&Value::from(vec![Value::from("My Library")])) + ); + } + + #[test] + fn a_flag_is_distinguished_from_a_value_taking_option() { + let tree = command_tree(); + let import = subcommand(&tree, "import"); + + let library = arg(import, "library"); + assert_eq!(library.get("takes_value"), Some(&Value::from(true))); + assert_eq!(library.get("long"), Some(&Value::from("library"))); + assert_eq!( + library.get("value_names"), + Some(&Value::from(vec![Value::from("PATH")])) + ); + + let force = arg(import, "force"); + assert_eq!(force.get("takes_value"), Some(&Value::from(false))); + assert_eq!(force.get("repeatable"), Some(&Value::from(false))); + // The derive answers `value_name` and `possible_values` for a flag as well, with + // its own internals: `FORCE` as a placeholder nobody types, and the `true`/`false` + // its bool parser accepts. Describing either would invent a surface. + assert!(force.get("value_names").is_none()); + assert!(force.get("possible_values").is_none()); + } + + #[test] + fn a_repeatable_argument_is_marked_repeatable() { + let tree = command_tree(); + let paths = arg(subcommand(&tree, "import"), "paths"); + assert_eq!(paths.get("repeatable"), Some(&Value::from(true))); + assert_eq!(paths.get("required"), Some(&Value::from(true))); + + let pick = arg(subcommand(&tree, "cull"), "pick"); + assert_eq!(pick.get("repeatable"), Some(&Value::from(true))); + } + + #[test] + fn an_enumerated_value_carries_its_variants() { + let tree = command_tree(); + let provider = arg(subcommand(&tree, "import"), "provider"); + let names: Vec<&str> = provider + .get("possible_values") + .and_then(Value::as_array) + .expect("`--provider` enumerates its values") + .iter() + .filter_map(|entry| entry.get("name").and_then(Value::as_str)) + .collect(); + assert_eq!(names, vec!["takeout"]); + + let filter = arg(subcommand(&tree, "cull"), "filter"); + let flags: Vec<&str> = filter + .get("possible_values") + .and_then(Value::as_array) + .expect("`--filter` enumerates its values") + .iter() + .filter_map(|entry| entry.get("name").and_then(Value::as_str)) + .collect(); + assert_eq!(flags, vec!["pick", "neutral", "reject"]); + } + + /// `Command::build` is deliberately not called, so the artifact describes only what + /// this crate declares — `--help` is not repeated under every command. + #[test] + fn the_tree_omits_claps_synthesized_help_argument() { + let tree = command_tree(); + assert!(subcommand(&tree, "status").get("args").is_none()); + assert!( + !serde_json::to_string(&tree) + .expect("the tree serializes") + .contains("\"id\":\"help\"") + ); + } +} diff --git a/mise.toml b/mise.toml index 369913c9..f5dfa383 100644 --- a/mise.toml +++ b/mise.toml @@ -90,6 +90,7 @@ run = [ "mise run i18n-check", "mise run i18n-guard", "mise run openapi-check-kynos", + "mise run cli-surface-check", "mise run architecture-check", "mise run license-check", "mise run translate-readme-check", @@ -232,6 +233,20 @@ run = "cargo run -q -p capsule-server --bin gen_openapi" description = "Verify the committed Kynos OpenAPI 3.2 document matches the server" run = "cargo run -q -p capsule-server --bin gen_openapi -- --check" +# Dump the `capsule` command tree to the committed capsule-cli/cli-surface.json that the +# documentation build renders into `/reference/cli/` (slice `S-Z8`). The same shape as the +# OpenAPI pair above, and for the same reason: the docs job installs bun and nothing else, so +# it reads a committed artifact rather than asking cargo what the CLI looks like. State-free +# and deterministic — `command_tree()` walks compile-time clap attributes. Re-run and commit +# after any change to the argument surface. +[tasks.cli-surface] +description = "Dump the capsule command tree to capsule-cli/cli-surface.json" +run = "cargo run -q -p capsule-cli --bin gen_cli_surface" + +[tasks.cli-surface-check] +description = "Verify the committed capsule command tree matches the CLI" +run = "cargo run -q -p capsule-cli --bin gen_cli_surface -- --check" + # Regenerate the translated README..md files from README.md and the committed # per-locale translation data (xtask/translations/readme/). See xtask/src/translate_readme.rs. [tasks.translate-readme] From 082fe9dfee560c01b2ebe91a9205ebce38df0bf0 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 23:29:06 -0400 Subject: [PATCH 065/243] docs(server): replace "there is no local server today" with how to run one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `local-development.md` said that as a known gap, and it was true. It now documents the binary, both profiles, the operator commands, where logs go and where TLS is terminated. It is deliberately explicit about what the development profile is *not*: the blob store is real and everything else lives in the process, so a restart leaves every blob an orphan the scrub will report, and `gc` can only ever mark because the collector's two-pass design needs a mark store that outlives the process. Both are consequences a developer would otherwise meet as a surprise. It is also explicit that `mise run serve` does not work yet and refuses rather than pretending, and that `serve-memory`'s fallback signing key is published — every token it mints is forgeable by anyone who has read this repository. `capsule-server/README.md`'s "no binary, no configuration loading" section becomes "Running it", and the flat "every adapter is in-memory" claim gains the qualifier it now needs: two of them live beside their ports rather than in `tests/support/`, and the distinction the port docs were drawing is between a double and an implementation. Three stale `serve-api` citations follow: `capsule-web/README.md` asserted the Kynos server "has no binary yet", and the CLI's default-endpoint comment and the Swift project's local-networking comment both named a task that retired in `S-C59`. `SLICES.md`'s three remain, and are not this change's. Refs #401 --- capsule-cli/src/remote.rs | 2 +- .../docs/development/local-development.md | 112 +++++++++++++++++- capsule-server/README.md | 52 +++++++- capsule-swift/Project.swift | 5 +- capsule-web/README.md | 6 +- 5 files changed, 163 insertions(+), 14 deletions(-) diff --git a/capsule-cli/src/remote.rs b/capsule-cli/src/remote.rs index 0fde67ca..89954e44 100644 --- a/capsule-cli/src/remote.rs +++ b/capsule-cli/src/remote.rs @@ -53,7 +53,7 @@ pub struct RemoteConfig { pub protocol_version: String, } -/// The default server origin — one host, one port, matching `mise run serve-api`. +/// The default server origin — one host, one port, matching `mise run serve-memory`. pub const DEFAULT_ENDPOINT: &str = "http://127.0.0.1:3000"; impl RemoteConfig { diff --git a/capsule-docs/src/content/docs/development/local-development.md b/capsule-docs/src/content/docs/development/local-development.md index 68850524..73a08b89 100644 --- a/capsule-docs/src/content/docs/development/local-development.md +++ b/capsule-docs/src/content/docs/development/local-development.md @@ -52,17 +52,113 @@ mise run hooks-install # installs the git hooks (hk) ## Running a server locally -**There is no local server today, and that is a known gap rather than a missing instruction.** +`capsule-server` is one binary with subcommands: + +```text +capsule-server [--config PATH] + serve [--listen HOST:PORT] [--memory] [--blob-root PATH] + gc [--apply] [--grace-window-hours N] [--memory] [--blob-root PATH] + purge [--apply] [--limit N] [--memory] [--blob-root PATH] + scrub [--deep] [--budget BYTES] [--memory] [--blob-root PATH] + gen-openapi [FILE] [--check] +``` + +### The development profile + +```bash +mise run serve-memory +``` + +That is a server you can point a client at: it binds, prints the address it bound, and answers +every operation. An account registers and signs in — the credential is checked with Argon2id +against a real in-memory account directory, so a wrong password is refused rather than accepted. + +What it is missing is durability. The blob store is a **real** filesystem store under +`target/capsule-server-blobs`; everything else — the index, sessions, albums, the device +directory, quota, the collector's marks — lives in the process and is gone when it exits. That +is not a gap to route around, it is the shape of a profile whose durable half is exactly the one +adapter that has been written. Two consequences worth knowing before they surprise you: + +- After a restart, `capsule-server scrub` will honestly report every blob still on disk as an + orphan, because the index that referenced them is gone. +- `capsule-server gc` can only ever **mark** in this profile. Collection is two passes by + design — a blob that reaches zero references is marked, and swept on a later pass once the + grace window has passed — and the mark store does not outlive the process. + +The signing key `serve-memory` falls back to is the published example in +`capsule-server/.env.example`. Every token it mints is forgeable by anyone who has read this +repository, which is why that task is `serve-memory` and not `serve`. Set `JWT_ED25519_DER` +yourself and it is used instead: + +```bash +JWT_ED25519_DER="$(openssl genpkey -algorithm ed25519 -outform DER | base64 | tr -d '\n')" \ + mise run serve-memory +``` + +### A configured server + +```bash +cp capsule-server/.env.example capsule-server/.env # then edit it +mise run serve-deps # Postgres 18 + Valkey 9 +mise run serve +``` + +`serve-deps` and `serve` are separate tasks on purpose: a task that silently starts containers is +a task that leaks them. Bring them down with +`podman compose -f capsule-server/compose.yaml down` (`docker compose` accepts the same file). + +**`mise run serve` does not work yet, and refuses rather than pretending.** The Postgres and +Valkey adapters are not written. Without `VALKEY_URL` and without `--memory` it exits 2 naming +the variable — the refusal `capsule-server/src/store/mod.rs` has documented since `S-C29` and +nothing could enforce until there was a boot path; with `VALKEY_URL` set it exits non-zero naming +the issue that will honour it. Neither ever silently falls back to the in-memory adapters, which +is the whole point: a deployment that forgot a variable must fail closed. + +Every configuration fault is reported in **one** message with exit code 2, so bringing a +deployment up is one read of one log line rather than one restart per variable. + +`capsule-server/.env.example` is the full list of settings. The precedence is command-line flag, +then the environment, then the built-in default; there is no configuration file, and `--config +PATH` is accepted and refused with a sentence saying so. + +### TLS + +The server does not terminate it. HTTPS is the ingress or reverse proxy's job — see +[Cryptography — Failure Modes](/design/cryptography/failure-modes/) — so there is no certificate +setting and Kynos's `tls` feature is off. + +### Logs and reports + +Every log line goes to **stderr**; stdout is a data channel. `serve` writes one +`listening on ` line there (which is how a `--listen 127.0.0.1:0` caller learns its port), +`gen-openapi` writes the path it wrote, and the operator commands write their report. `LOG_FORMAT` +is `pretty` in a debug build and `json` in a release one; `RUST_LOG` is the usual filter. + +### The operator commands + +`gc`, `purge` and `scrub` are the three jobs +[Filesystem — Maintenance](/design/filesystem/maintenance/) describes. They need a blob root and +deliberately **no key material**: a maintenance host that had to hold the production +token-signing key to sweep a directory would be a reason to put the key on a maintenance host. + +Dry run is the default for the two that write; `--apply` opts in, and the report says which +posture produced it. `scrub` mutates nothing at all and exits non-zero on a non-empty report, +which is what makes it usable as a monitoring probe — and a `--deep` pass that ran out of budget +says so, because a clean report from a pass that stopped early is not a clean store. -`mise run serve-api` and the compose stack behind it went with the Salvo tree in slice `S-C59`. The Kynos server that replaces it is complete as a *surface* — fifty-nine operations, a committed OpenAPI 3.2 document, and a test suite that drives the real router — and it has **no binary, no configuration loading and no Postgres or Valkey adapter**. Nothing reads `JWT_ED25519_DER`, `SYNC_CURSOR_MAC_KEY` or `ATTESTATION_KEY_SEED` yet. +### Without running anything -That ordering is deliberate: every port in `capsule-server` has a deterministic in-memory adapter and a conformance suite, because the suite is what a real adapter is written *against*, and a port with two implementations before it has one suite is a port whose implementations will disagree. Until those adapters land, the way to exercise the server is the way its own tests do — in process, with no container: +To exercise the server the way its own tests do — in process, no socket, no container: ```bash cargo nextest run -p capsule-server ``` -`kynos::test::TestClient` drives a built `Service` directly: no socket, no port, no runtime flavour. One test (`tests/sdk_client.rs`) does bind an ephemeral port, because the property it proves — that the **generated** SDK client round-trips the real router over TCP — is the one an in-process client cannot. +`kynos::test::TestClient` drives a built `Service` directly. Two test files do use a socket: +`capsule-server/tests/sdk_client.rs`, because the property it proves is that the **generated** +SDK client round-trips the real router over TCP, and `capsule-server/tests/binary.rs`, because +the properties it proves — that the binary binds, reports its port, and drains to exit 0 on +SIGTERM — belong to a process rather than to a router. To read the served contract without running anything: @@ -70,9 +166,13 @@ To read the served contract without running anything: mise run openapi-kynos # regenerate capsule-server/openapi.json ``` -### Nothing here needs a container any more +### Nothing here needs a container -The testcontainers section this page used to carry is gone with the crate that needed it. No test in the workspace starts a container, so `mise run test-rust` has no podman prerequisite and cannot leak one. (The `containers` nextest group is kept, empty, for the first real adapter — the one-thread rule it encodes was learned by watching CI flake, and that is the expensive way to learn it.) +No test in the workspace starts a container, so `mise run test-rust` has no podman prerequisite +and cannot leak one. `mise run serve-deps` is the only task that starts anything, and it is never +a dependency of another task. (The `containers` nextest group is kept, empty, for the first real +adapter — the one-thread rule it encodes was learned by watching CI flake, and that is the +expensive way to learn it.) ## Git hooks diff --git a/capsule-server/README.md b/capsule-server/README.md index 0d443cc8..0ad2b940 100644 --- a/capsule-server/README.md +++ b/capsule-server/README.md @@ -38,6 +38,14 @@ with two implementations before it has one suite is a port whose implementations It is also why this crate's whole test suite runs without a container. +Two of those adapters live beside the ports rather than in `tests/support/`: `auth::accounts_memory` +and `auth::totp`'s `InMemoryTotp`. The account ports' docs say a double in `src/` would be "a fake +credential directory shipped inside the server binary", and that reasoning is about a **double** — +`tests/support/mod.rs`'s, which accepts whatever password it was told to accept. These verify with +the same Argon2id helper (`auth::credential`) a Postgres adapter will, store PHC strings and no +plaintext, take the timing-equalized miss, and lock an account out after enough failures. What they +lack is durability, which is what makes them a development profile rather than a deployment. + ## Running the tests ```bash @@ -60,8 +68,46 @@ mise run openapi-kynos # regenerate mise run openapi-check-kynos # verify no drift ``` +## Running it + +```bash +mise run serve-memory # a server you can point a client at +``` + +One binary, several subcommands: + +```text +capsule-server [--config PATH] + serve [--listen HOST:PORT] [--memory] [--blob-root PATH] + gc [--apply] [--grace-window-hours N] [--memory] [--blob-root PATH] + purge [--apply] [--limit N] [--memory] [--blob-root PATH] + scrub [--deep] [--budget BYTES] [--memory] [--blob-root PATH] + gen-openapi [FILE] [--check] +``` + +`config` reads every setting an operator decides — command-line flag over environment over +default — and reports **every** fault in one message, because an operator otherwise restarts the +process once per variable. `capsule-server/.env.example` is the full list. There is no +configuration file; `--config PATH` is accepted and refused with a sentence saying why. + +`boot::assemble` is the one composition root. `--memory` takes every in-crate adapter over a real +filesystem blob store; anything else refuses, so a deployment that forgot `VALKEY_URL` fails +closed rather than coming up on state it loses at the next restart. + +Logs go to stderr. stdout is a data channel: `serve` writes one `listening on ` line there, +which is how a `--listen 127.0.0.1:0` caller learns its port. + +`gc`, `purge` and `scrub` need a blob root and no key material at all. Dry run is the default for +the two that write; `scrub` mutates nothing and exits non-zero on a non-empty report. + ## What is owed -There is **no binary, no configuration loading, and no Postgres or Valkey adapter.** Nothing reads -`JWT_ED25519_DER`, `SYNC_CURSOR_MAC_KEY` or `ATTESTATION_KEY_SEED` yet, so there is no way to run -this server outside its own tests. See `SLICES.md`, lane C. +**No Postgres or Valkey adapter.** `DATABASE_URL` and `VALKEY_URL` are read into the +configuration and no adapter consumes either, so `serve` without `--memory` refuses and names +the issue that will honour it: the account, album and index adapters are one issue and the +session and upload-session adapters another. + +That ordering is deliberate rather than unfinished, for the reason above: the contract and its +conformance suite are what a real adapter is written *against*. What `--memory` therefore buys is +not durability — the blob store is the only durable half — but a running surface to write those +adapters against and to point a client at. diff --git a/capsule-swift/Project.swift b/capsule-swift/Project.swift index 3a23683f..ab68d6e9 100644 --- a/capsule-swift/Project.swift +++ b/capsule-swift/Project.swift @@ -474,8 +474,9 @@ private let appTarget: Target = .target( // reachable from the Security screen in either lane, so the key belongs in both. "NSFaceIDUsageDescription": // locales/: app.infoplist.face_id_usage "Capsule uses Face ID to unlock your Hidden and Recently Deleted photos.", - // Let the simulator reach a dev server on http://127.0.0.1:3000 (`mise run serve-api`, - // slice S-P7). `NSAllowsLocalNetworking` is scoped to loopback and .local — it does + // Let the simulator reach a dev server on http://127.0.0.1:3000 + // (`mise run serve-memory`). `NSAllowsLocalNetworking` is scoped to loopback and .local + // — it does // NOT weaken ATS for real servers, unlike NSAllowsArbitraryLoads. Production // deployments are HTTPS and unaffected. "NSAppTransportSecurity": ["NSAllowsLocalNetworking": true], diff --git a/capsule-web/README.md b/capsule-web/README.md index b939b147..a79642ea 100644 --- a/capsule-web/README.md +++ b/capsule-web/README.md @@ -48,8 +48,10 @@ it is not dead code. ### Prerequisites - Install [Bun](https://bun.sh). -- A running server for anything beyond empty states. **There is not one today**: the Salvo server and its `serve-api` task retired in slice `S-C59`, and the Kynos server that replaces it has no binary yet. Every screen renders its empty state; the sync store's own tests (`bun test src/data/server/`) are what exercise the data path meanwhile. - from the repo root. There is no mock gateway to fall back on. +- A running server for anything beyond empty states. `mise run serve-memory` from the repo root + starts one on the in-memory adapters — an account registers and signs in against it, and it + loses everything but the blobs when it exits. There is no mock gateway to fall back on; the + sync store's own tests (`bun test src/data/server/`) exercise the data path without a server. With no reachable server the app still builds, runs, and renders empty states, so pure UI work needs no backend. Authenticated writes are not a web surface: the From fe1e3c97d761ecaa279ffb5278bdc7eae60da382 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 00:53:43 -0400 Subject: [PATCH 066/243] fix(core): encode the thumbnail tier as JXL; harden the still pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two repairs found after the first push, in the same files. **The thumbnail tier moves from WebP to JXL, on CI evidence.** WebP was chosen because `image/webp` is in the tier table and `libwebp` exposes exactly the q=50 knob the table specifies. It does not compile: `rawshift-image-0.1.1/src/codecs/webp.rs:164,177,190` pass `b"EXIF".as_ptr() as *const i8` to `WebPMuxSetChunk`, whose `libwebp-sys` 0.14.4 signature (`ffi.rs:881`) takes `*const core::ffi::c_char` — and `c_char` is `u8` on aarch64, so it is an E0308 on every 64-bit ARM target, which is every mobile target Capsule ships. `codecs/mod.rs:13` compiles that module under `any(webp-decode, webp-encode)`, so decode-only does not escape it either. The `webp` feature is therefore dropped and the tier encodes JXL through the pure-Rust `zune-jpegxl` backend — `image/jxl` is the table's committed *master* format, so the format that ships first is the one the table already puts first. The cost is that `JxlSimpleEncoder` is lossless, so the declared q=50 is advisory and a thumbnail costs more bytes than intended; a test asserts the losslessness rather than letting it be discovered. A `cfg(target_arch)` gate was rejected: thumbnails on desktop and none on any phone is worse than one lossless format everywhere. `StillFormat::WebP` becomes recognised-but-undecodable, which is a real user-visible gap for a common export format, so it is filed rather than absorbed. **The hardening**, from an adversarial read of the diff: - the `original` sentinel copied the whole original into `derivatives/{uuid}.thumbnail.{ext}`, putting the source's EXIF and GPS into a derivative blob and duplicating a file two directories up. The contract's word is *references*: a sentinel now carries no bytes and its manifest content-addresses the original; - a derivative-generation failure propagated and failed the whole import, trading a missing thumbnail for a missing backup. It is warned and reported as `DecodeFailed` instead; - the unwind boundary covered only `Decoder::decode` while the module claimed no codec could abort an import; `media::guarded` now wraps the chromahash placeholder and the encode too; - `capped_dimensions` divided by zero on a zero dimension, reachable through a `pub` entry point; - `MediaMetadata::gamut` claimed to carry the source colour space. `probe_standard_image` hard-codes `Srgb` for every format, so it never does — documented as the fidelity limitation it is, with `gamut_of` kept as the seam; - `MAX_DECODE_PIXELS`' note counted one buffer at a time and understated the peak 3-4x. The real peak is ~2.5 GB, and `native` implies `media`, so it lands on a phone: the budget drops to 128 Mpx, still ~25% above a 102 Mpx medium-format frame; - the HEIC-detection rationale overstated the crate's blind spot, and `encode`'s unreachable arm returned an error naming a `StillFormat` that was not at fault. Three intra-doc links from public items to private ones are also dropped, so the rustdoc gate passes under `--document-private-items`. --- Cargo.lock | 22 ++-- capsule-cli/tests/import_round_trip.rs | 27 ++-- capsule-core/Cargo.toml | 40 +++--- capsule-core/src/lifecycle/derivatives.rs | 87 +++++++++---- capsule-core/src/lifecycle/import.rs | 2 +- capsule-core/src/media/decode.rs | 62 ++++++--- capsule-core/src/media/derivative.rs | 100 +++++++++------ capsule-core/src/media/detect.rs | 42 +++++-- capsule-core/src/media/mod.rs | 10 +- capsule-core/src/media/resize.rs | 37 ++++-- capsule-core/src/media/tests.rs | 147 +++++++++++++--------- 11 files changed, 373 insertions(+), 203 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f965c410..ff219124 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3383,17 +3383,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "libwebp-sys" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b3a87b44e34d17161e4f17d92a463d596cb13825dcd1758ed18fd3a721e189c" -dependencies = [ - "cc", - "glob", - "pkg-config", -] - [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -4661,7 +4650,6 @@ dependencies = [ "img-parts", "jpeg-encoder", "jxl-oxide", - "libwebp-sys", "little_exif", "rawshift-core", "rayon", @@ -4670,6 +4658,7 @@ dependencies = [ "tracing", "zune-core", "zune-jpeg", + "zune-jpegxl", "zune-png", ] @@ -7811,6 +7800,15 @@ dependencies = [ "zune-core", ] +[[package]] +name = "zune-jpegxl" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cba11ecd07cc23351500e840ae03841b7e4997923ec8e4832ee3ae199ea0b6b4" +dependencies = [ + "zune-core", +] + [[package]] name = "zune-png" version = "0.5.2" diff --git a/capsule-cli/tests/import_round_trip.rs b/capsule-cli/tests/import_round_trip.rs index 9cfdf6f6..968678bd 100644 --- a/capsule-cli/tests/import_round_trip.rs +++ b/capsule-cli/tests/import_round_trip.rs @@ -327,24 +327,25 @@ fn an_import_is_reconstructed_by_a_later_process_from_disk_alone() { // ── The signed derivatives, in `media/{YYYY}/{YYYY-MM}/derivatives/`. ── // // The fixture is 8×8, well inside the thumbnail tier's 256 px cap, so the tier is satisfied - // by the signed `format = "original"` sentinel over the source bytes — the contract's - // redundant-derivative rule — under the source's own extension. + // by the signed `format = "original"` sentinel — the contract's redundant-derivative rule. + // A sentinel *references* the original, so what lands is its signed manifest and no bytes: + // copying them would put this fixture's EXIF, GPS fix included, into a derivative blob. let derivatives = bucket.join("derivatives"); - let sentinel = derivatives.join(format!("{simple}.thumbnail.jpg")); + let bundle = derivatives.join(format!("{simple}.derivatives.cbor")); assert!( - sentinel.is_file(), - "a thumbnail-tier derivative must exist in {}", + bundle.is_file(), + "a signed derivative-manifest bundle must exist in {}", derivatives.display() ); + let derivative_files: Vec = std::fs::read_dir(&derivatives) + .expect("read the derivatives directory") + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); assert_eq!( - std::fs::read(&sentinel).expect("read the thumbnail-tier derivative"), - fx.image, - "the `original` sentinel references the source bytes rather than re-encoding them" - ); - let bundle = derivatives.join(format!("{simple}.derivatives.cbor")); - assert!( - bundle.is_file(), - "the derivative bytes are unusable without their signed manifest bundle" + derivative_files, + vec![format!("{simple}.derivatives.cbor")], + "the sentinel writes its manifest and no derivative bytes" ); // ── The signed sidecar, decoded from disk. ── diff --git a/capsule-core/Cargo.toml b/capsule-core/Cargo.toml index ffb9a37d..c1b70a0f 100644 --- a/capsule-core/Cargo.toml +++ b/capsule-core/Cargo.toml @@ -28,10 +28,11 @@ default = ["native"] native = ["dep:rusqlite", "dep:sqlite-vec", "mls", "media"] # `media` links the still-image decode/encode stack (`capsule_core::media`, slices `S-B1`/`S-B13`) # over `rawshift-image`. Implied by `native`, so the CLI, the tests and the mobile FFI all carry -# decoders; **excluded** from the `wasm32-unknown-unknown` sealing build (`--no-default-features`) -# because the WebP backend is a vendored C library built through `cc` and the whole stack is -# irrelevant to sealing. `capsule_core::lqip` stays unconditional and is NOT behind this feature — -# a placeholder must not depend on which client imported the photo (slice `S-B14`). +# decoders; **excluded** from the `wasm32-unknown-unknown` sealing build (`--no-default-features`), +# to which the whole stack is irrelevant. Every enabled codec is pure Rust and links no C, which +# is what keeps the mobile cross-builds working — see the dependency's own comment for why WebP +# is not among them. `capsule_core::lqip` stays unconditional and is NOT behind this feature — a +# placeholder must not depend on which client imported the photo (slice `S-B14`). media = ["dep:rawshift-image"] # `mls` links the live OpenMLS group backend (`crypto::authority::OpenMlsAuthority`, slice # S-X1) pinned to the X-Wing PQ ciphersuite `MLS_256_XWING_CHACHA20POLY1305_SHA256_Ed25519` @@ -88,29 +89,36 @@ kamadak-exif = "0.5" # dependency, not the pinned `rawshift/` submodule, which is an uninitialised newer # v1-in-progress tree and is not a workspace member. Depended on directly rather than through # the `rawshift` facade because only the per-crate dependency gives per-format Cargo control -# (`rawshift-image`'s own docs say so), and the format set is a licence + build-host decision: +# (`rawshift-image`'s own docs say so), and the format set is a licence, build-host and +# *portability* decision: # # - `jpeg` / `png` — pure-Rust zune decode **and** encode; the two formats every library holds. -# - `jxl-decode` — jxl-oxide, pure Rust. Decode only: the pure-Rust encoder backend is -# `zune-jpegxl`'s lossless `JxlSimpleEncoder`, so a q=50 thumbnail is not -# expressible without C libjxl (`bindgen` + `pkg-config`). +# - `jxl` — jxl-oxide decode plus the `zune-jpegxl` encoder that produces the thumbnail +# tier. Pure Rust, and `image/jxl` is the tier table's committed *master* +# format. The backend is `JxlSimpleEncoder`, which is lossless — a thumbnail +# therefore costs more bytes than the table's q=50 intends, and closing that +# needs C libjxl (`jxl-encode-libjxl`: `bindgen` + `pkg-config`). # - `tiff-decode` / `gif-decode` — pure Rust, no encoder needed. -# - `webp` — the derivative encoder that ships first (`libwebp-sys` 0.14.4, MIT, -# vendored static libwebp through `cc` with pre-generated bindings — the same -# class of C build `rusqlite/bundled` already performs). # -# Deliberately absent: `heic` (system libheif), `avif` (image 0.25's `avif-native` → system -# libdav1d for decode; `ravif` → `rav1e/asm` → nasm on every x86_64 build host for encode), `svg` -# and the RAW families (`experimental`; CR3 pixel decode unimplemented upstream). Each is a typed +# **`webp` is deliberately absent, and it is not a preference.** It does not compile for any +# aarch64 target: `codecs/webp.rs:164,177,190` pass `b"EXIF".as_ptr() as *const i8` to +# `WebPMuxSetChunk`, whose `libwebp-sys` 0.14.4 signature (`ffi.rs:881`) takes +# `*const core::ffi::c_char` — and `c_char` is `u8` on aarch64, so the cast is an E0308. That +# module is compiled under `any(webp-decode, webp-encode)`, so decode-only does not escape it. +# Every mobile target Capsule ships is aarch64, so WebP is a recognised-but-undecodable format +# here until upstream fixes the cast. +# +# Also absent: `heic` (system libheif), `avif` (image's `avif-native` -> system libdav1d for +# decode; `ravif` -> `rav1e/asm` -> nasm on every x86_64 build host for encode), `svg`, and the +# RAW families (`experimental`; CR3 pixel decode unimplemented upstream). Each is a typed # `media::MediaError::UnsupportedFormat` today rather than a silent gap. MPL-2.0, already # allow-listed in `deny.toml`; see the Media row in design/dependencies.md. rawshift-image = { version = "0.1.1", default-features = false, features = [ "jpeg", "png", - "jxl-decode", + "jxl", "tiff-decode", "gif-decode", - "webp", ], optional = true } # Bundled SQLite (C) — the on-device library index. Optional + gated by `native` because it # cannot target `wasm32-unknown-unknown`; the WASM sealing build drops it. diff --git a/capsule-core/src/lifecycle/derivatives.rs b/capsule-core/src/lifecycle/derivatives.rs index 1670e4e8..40c945ee 100644 --- a/capsule-core/src/lifecycle/derivatives.rs +++ b/capsule-core/src/lifecycle/derivatives.rs @@ -263,21 +263,32 @@ impl Workspace { let mut manifests = Vec::with_capacity(derivatives.len()); for derivative in derivatives { - // The `original` sentinel references the source asset, so its bytes carry the - // source's own extension. - let format_ext = derivative.format.extension().unwrap_or(asset.ext.as_str()); - let path = dir.join(format!( - "{stem}.{}.{format_ext}", - derivative.tier.role_name() - )); - if let Err(error) = fs::write(&path, &derivative.bytes) { - tracing::warn!( - asset_id = %asset.asset_id, - path = %path.display(), - %error, - "derivatives: could not write a derivative; skipping it" + // The `original` sentinel has no bytes of its own: its manifest *references* the + // original, whose content address it signs. Writing a byte-for-byte copy under a + // thumbnail's name would duplicate a file two directories up and re-expose the + // original's EXIF — GPS included — as a derivative, where a re-encoded thumbnail is + // metadata-free by construction. Its manifest still goes into the bundle: that + // signed marker is the difference between "the original *is* the thumbnail" and + // "the thumbnail is missing, rebuild it". + if let Some(format_ext) = derivative.format.extension() { + let path = dir.join(format!( + "{stem}.{}.{format_ext}", + derivative.tier.role_name() + )); + if let Err(error) = fs::write(&path, &derivative.bytes) { + tracing::warn!( + asset_id = %asset.asset_id, + path = %path.display(), + %error, + "derivatives: could not write a derivative; skipping it" + ); + continue; + } + } else { + debug_assert!( + derivative.bytes.is_empty(), + "only the byte-free `original` sentinel has no extension" ); - continue; } manifests.push(derivative.manifest.clone()); } @@ -543,7 +554,7 @@ mod tests { let dir = derivatives_dir(lib.path(), receipt.asset_id); let stem = receipt.asset_id.simple().to_string(); - let thumb = dir.join(format!("{stem}.thumbnail.webp")); + let thumb = dir.join(format!("{stem}.thumbnail.jxl")); let bundle_path = dir.join(format!("{stem}.derivatives.cbor")); assert!(thumb.is_file(), "thumbnail bytes at {}", thumb.display()); assert!(bundle_path.is_file(), "a manifest bundle beside them"); @@ -561,22 +572,28 @@ mod tests { assert_eq!(core.source_asset_id, receipt.asset_id); assert_eq!( verify_still_format(&manifests[0]), - Ok(Some(DerivativeFormat::WebP)), + Ok(Some(DerivativeFormat::Jxl)), "the persisted format is inside the closed set" ); - // The bytes really are a 256 px WebP. + // The bytes really are a 256 px JXL. let decoded = crate::media::RawshiftDecoder - .decode(&bytes, "webp") + .decode(&bytes, "jxl") .expect("the persisted thumbnail decodes"); assert_eq!((decoded.width(), decoded.height()), (256, 192)); } - /// A still already inside the tier cap gets the signed `original` sentinel: the manifest - /// says `original` and the persisted bytes are the source's own, under the source's - /// extension. Distinct from an absent derivative, which means "rebuild me". + /// A still already inside the tier cap gets the signed `original` sentinel: a manifest that + /// says `original` and content-addresses the source, and **no derivative bytes on disk**. + /// + /// The absent bytes are the point, and they are what "the tier *references* the original" + /// means. Writing a copy would put the original — EXIF and GPS intact — in + /// `derivatives/{uuid}.thumbnail.{ext}` and therefore into the derivative blob of the upload + /// bundle, which is the one place a re-encoded thumbnail is metadata-free by construction. + /// The signed marker is still there, so this stays distinct from an absent derivative, which + /// means "rebuild me". #[test] - fn a_small_still_persists_the_original_sentinel() { + fn a_small_still_persists_the_original_sentinel_without_copying_it() { let lib = TempDir::new().unwrap(); let src = TempDir::new().unwrap(); let (mut ws, album) = workspace(lib.path()); @@ -591,17 +608,33 @@ mod tests { let dir = derivatives_dir(lib.path(), receipt.asset_id); let stem = receipt.asset_id.simple().to_string(); - let sentinel = dir.join(format!("{stem}.thumbnail.png")); - assert!( - sentinel.is_file(), - "the sentinel reuses the source extension" + + let files: Vec = fs::read_dir(&dir) + .unwrap() + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + assert_eq!( + files, + vec![format!("{stem}.derivatives.cbor")], + "the sentinel writes its manifest and no derivative bytes" ); - assert_eq!(fs::read(&sentinel).unwrap(), original); let manifests: Vec = cbor::from_slice(&fs::read(dir.join(format!("{stem}.derivatives.cbor"))).unwrap()) .expect("the bundle decodes"); + assert_eq!(manifests.len(), 1); assert_eq!(manifests[0].core.format, "original"); + assert_eq!( + manifests[0].core.ciphertext_hash, + hash::hash_bytes(&original), + "the manifest content-addresses the original it references" + ); + assert_eq!( + verify_still_format(&manifests[0]), + Ok(Some(DerivativeFormat::Original)), + "the sentinel is inside the closed set" + ); } /// A format with no codec here, and bytes that are no still at all: both import as signed, diff --git a/capsule-core/src/lifecycle/import.rs b/capsule-core/src/lifecycle/import.rs index 604590f1..1a20f857 100644 --- a/capsule-core/src/lifecycle/import.rs +++ b/capsule-core/src/lifecycle/import.rs @@ -312,7 +312,7 @@ impl Workspace { /// import executor drives (S-B2): every imported member lands as a signed `SidecarV1` + /// manifest + append-only provenance, self-verified through [`verify_asset`], and — when the /// still decodes — with a chromahash `lqip` in the sidecar and signed thumbnail derivatives - /// on disk ([`prepare_still`](Self::prepare_still), slices `S-B1`/`S-B14`). + /// on disk (the private `prepare_still`, slices `S-B1`/`S-B14`). /// /// Returns a [`SignedImport`]: the asset id, the /// [`DerivativeStatus`](super::DerivativeStatus) saying whether derivatives were generated diff --git a/capsule-core/src/media/decode.rs b/capsule-core/src/media/decode.rs index 65a4dce7..2a5d60bf 100644 --- a/capsule-core/src/media/decode.rs +++ b/capsule-core/src/media/decode.rs @@ -69,7 +69,18 @@ pub struct MediaMetadata { pub orientation: Option, /// Bits per channel, where the header exposes it cheaply. pub bit_depth: Option, - /// The source colour space, mapped onto the gamut [`crate::lqip::Lqip::encode`] takes. + /// The gamut [`crate::lqip::Lqip::encode`] is told to interpret the samples in. + /// + /// **Always [`Gamut::Srgb`] in this build, and that is upstream's doing rather than a + /// choice made here.** `probe_standard_image` hard-codes `ColorSpace::Srgb` into every + /// `ImageProbe` it returns, and `decode_standard_image` likewise tags every decoded frame + /// sRGB without converting — so `rawshift-image` 0.1.1 reports no source gamut for a + /// standard format at all, whatever the file's ICC profile says. The consequence is a + /// fidelity limitation, not a correctness bug: a Display P3 source gets a placeholder + /// interpreted as sRGB, i.e. slightly under-saturated, which is the direction slice `S-B14` + /// chose when it had to pick one. This module's private `gamut_of` is kept as the single + /// mapping point for when + /// the crate does start reporting it. pub gamut: Gamut, } @@ -237,13 +248,29 @@ pub fn decode_guarded( bytes: &[u8], ext: &str, ) -> Result { - if let Ok(result) = catch_unwind(AssertUnwindSafe(|| decoder.decode(bytes, ext))) { + guarded("decode", || decoder.decode(bytes, ext)) +} + +/// Run any fallible step of the still pipeline behind the same unwind boundary. +/// +/// Exported because `decode` is not the only third-party code the import path runs over pixels: +/// the placeholder goes through `chromahash` (also pre-1.0) and the derivative through +/// `libwebp`, and the module's promise is that *none* of them can abort an import — not that the +/// decoder specifically cannot. `stage` names the step in the warning so a caught panic is +/// attributable. +/// +/// `AssertUnwindSafe` is sound for the callers here: each closure borrows shared slices and +/// stateless values, so a caught unwind cannot leave a Capsule-owned invariant torn. +pub fn guarded( + stage: &'static str, + step: impl FnOnce() -> Result, +) -> Result { + if let Ok(result) = catch_unwind(AssertUnwindSafe(step)) { return result; } tracing::warn!( - bytes = bytes.len(), - ext, - "media: a decoder panicked; the original is imported without a derivative" + stage, + "media: a third-party codec panicked; the original is imported without a derivative" ); Err(MediaError::DecoderPanic) } @@ -259,10 +286,14 @@ fn gate(bytes: &[u8], ext: &str, op: FormatOp) -> Result StandardFormat { match format { StillFormat::Jpeg => StandardFormat::Jpeg, @@ -272,12 +303,8 @@ fn standard_format(format: StillFormat) -> StandardFormat { StillFormat::Gif => StandardFormat::Gif, StillFormat::Ppm => StandardFormat::Ppm, StillFormat::Avif => StandardFormat::Avif, - // The container, for the formats whose container is all `rawshift-image` models: the - // TIFF-based RAW families are a TIFF to it, and Canon's CR3 is an ISO-BMFF file it can - // only reach through its HEIC arm. Every one of these is unreachable through `gate`, - // which refuses a non-decodable format before this runs. Mapped to the truth rather - // than panicking so a future `is_decodable` widening that forgets this table degrades - // to a decode error instead of aborting an import. + // `Tiff` is reachable and is the reason this arm exists; the RAW families ride along + // because a TIFF-based RAW is a TIFF to the crate. StillFormat::Tiff | StillFormat::Arw | StillFormat::Cr2 @@ -304,6 +331,11 @@ fn orientation_of(bytes: &[u8], format: StillFormat) -> Option { /// `LinearSrgb` and `Unknown` both become [`Gamut::Srgb`]: `Linear` names a transfer function /// rather than a gamut, and sRGB primaries are the only safe assumption for an untagged source /// (over-saturating is worse than under-saturating — the resolution slice `S-B14` recorded). +/// +/// **The wide-gamut arms are unreachable today**, because `probe_standard_image` hard-codes +/// `ColorSpace::Srgb` for every format (see [`MediaMetadata::gamut`]). They are here as the one +/// place that has to change when the crate starts reporting a source gamut, rather than as a +/// claim that it already does. fn gamut_of(color_space: ColorSpace) -> Gamut { match color_space { ColorSpace::DisplayP3 => Gamut::DisplayP3, diff --git a/capsule-core/src/media/derivative.rs b/capsule-core/src/media/derivative.rs index 3828a65c..7a4d8c1b 100644 --- a/capsule-core/src/media/derivative.rs +++ b/capsule-core/src/media/derivative.rs @@ -22,12 +22,19 @@ //! //! # What this build encodes //! -//! WebP only. [`DerivativeFormat::STILL_DELIVERY_ORDER`] still lists JXL and AVIF because they -//! are the committed master and delivery formats; each is recorded as a per-`(tier, format)` -//! deferral on [`StillDerivatives::deferred`] and warned once, so the gap is countable rather -//! than invisible. JXL needs C libjxl for a lossy encode (the pure-Rust backend is -//! `zune-jpegxl`'s lossless simple encoder) and AVIF needs `nasm` on every x86_64 build host; -//! neither is a decision this module can take on its own. +//! **JXL only, and losslessly.** `image/jxl` is the tier table's committed *master* format, so +//! the format that ships first is the one the table already puts first — but the pure-Rust +//! backend is `zune-jpegxl`'s `JxlSimpleEncoder`, which is lossless, so the tier's `q=50` is +//! passed through and ignored and a thumbnail costs more bytes than the table intends. A lossy +//! JXL needs C libjxl (`bindgen` + `pkg-config`). +//! +//! WebP — the table's last-resort delivery variant, and the obvious cheap lossy encoder — is +//! **not available at all**: `rawshift-image` 0.1.1's WebP module does not compile for aarch64 +//! (it passes `*const i8` where `libwebp-sys` declares `*const c_char`, and `c_char` is `u8` +//! there), and every mobile target Capsule ships is aarch64. AVIF needs `nasm` on every x86_64 +//! build host. Each is recorded as a per-`(tier, format)` deferral on +//! [`StillDerivatives::deferred`] and warned once, so the gap is countable rather than +//! invisible. //! //! [`DerivativeManifest`]: crate::crypto::provenance::DerivativeManifest //! [`DerivativeCore::sign`]: crate::crypto::provenance::manifest::DerivativeCore::sign @@ -38,13 +45,11 @@ use rawshift_image::core::image::RgbImage; use rawshift_image::core::metadata::ImageMetadata; use rawshift_image::core::{BitDepth, ColorSpace, MetadataEmbedOptions}; use rawshift_image::formats::encode_rgb_image_to_vec; -use rawshift_image::formats::export::{ - CommonEncodeOptions, EncodeOptions, LibwebpEncodeConfig, WebPMode, -}; +use rawshift_image::formats::export::{CommonEncodeOptions, EncodeOptions, ZuneJxlEncodeConfig}; use uuid::Uuid; use super::decode::DecodedImage; -use super::error::{FormatOp, MediaError}; +use super::error::MediaError; use super::resize::downscale_rgba8; use crate::cbor; use crate::crypto::CryptoError; @@ -60,17 +65,26 @@ use crate::lqip::RgbaImage; /// outside this set is a structural rejection, never a "future format to ignore". #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum DerivativeFormat { - /// **JPEG XL** — the committed primary/master still codec. Not encodable in this build. + /// **JPEG XL** — the committed primary/master still codec, and the one format this build + /// encodes. Losslessly: the pure-Rust backend is `zune-jpegxl`'s `JxlSimpleEncoder`. Jxl, /// **AVIF** — the universal delivery format for clients without a JXL decoder. Not /// encodable in this build. Avif, - /// **WebP** — the last-resort delivery fallback, and the one format this build encodes. + /// **WebP** — the last-resort delivery fallback. Not encodable in this build: the crate's + /// WebP codec does not compile for aarch64 (see [`super::StillFormat::WebP`]). WebP, - /// The recognised `format = "original"` sentinel: the tier references the original asset + /// The recognised `format = "original"` sentinel: the tier **references** the original asset /// rather than generating a redundant derivative, because the source is not larger than the /// tier's cap. **Distinct from an absent derivative** — this is an explicit, signed marker, /// where absence means "rebuildable from the original". + /// + /// A sentinel derivative carries **no bytes of its own** ([`GeneratedDerivative::bytes`] is + /// empty). "References" is the operative word in the contract: the signed manifest's + /// `ciphertext_hash` content-addresses the original, which the holder already has, so + /// copying the bytes under a thumbnail's name would duplicate a file sitting two directories + /// up *and* re-expose the original's EXIF — GPS included — as a derivative blob, where a + /// re-encoded thumbnail is metadata-free by construction. Original, } @@ -120,7 +134,7 @@ impl DerivativeFormat { /// Whether this build can produce bytes in this format. pub const fn is_encodable(self) -> bool { - matches!(self, Self::WebP | Self::Original) + matches!(self, Self::Jxl | Self::Original) } } @@ -219,7 +233,9 @@ pub struct GeneratedDerivative { pub tier: DerivativeTier, /// Which committed format, or the `Original` sentinel. pub format: DerivativeFormat, - /// The derivative bytes — the encoder output, or the original for `Original`. + /// The derivative bytes — the encoder output, and **empty** for + /// [`DerivativeFormat::Original`], whose manifest is a reference to the original rather than + /// a copy of it. pub bytes: Vec, /// The signed manifest binding `hash(bytes)`, the role and the format. pub manifest: DerivativeManifest, @@ -291,13 +307,17 @@ pub fn generate_still_derivatives( cap, "media: source is within the tier cap; signing the `original` sentinel" ); - out.generated.push(sign_derivative( + // Signed **over** the original's bytes — that is what makes the manifest a + // reference to them — but carrying none of its own. See `DerivativeFormat::Original`. + let mut sentinel = sign_derivative( ctx, tier, DerivativeFormat::Original, original_bytes, &mut prior, - )?); + )?; + sentinel.bytes.clear(); + out.generated.push(sentinel); continue; } @@ -360,37 +380,45 @@ pub fn verify_still_format( /// Encode a tier-sized RGBA8 frame to `format`. /// -/// **Every encode passes [`MetadataEmbedOptions::none`]**, and that is load-bearing rather than -/// tidy: the crate's own default is `all()`, so a default-configured encode copies the source's -/// EXIF — GPS fix included — into the derivative bytes. A thumbnail is the derivative most -/// likely to be served widest, so leaking a home address into it would be the worst possible -/// place for that default to win. A test asserts the absence rather than trusting this comment. +/// **Every encode passes [`MetadataEmbedOptions::none`] and an empty [`ImageMetadata`]**, and +/// both are load-bearing rather than tidy: the crate's own default is `all()`, so a +/// default-configured encode copies the source's EXIF — GPS fix included — into the derivative +/// bytes, and the JXL backend has a working `append_to_jxl` that would do exactly that. A +/// thumbnail is the derivative most likely to be served widest, so leaking a home address into +/// it would be the worst possible place for that default to win. Passing empty metadata means +/// the source's block is never even read. A test asserts the absence rather than trusting this +/// comment. fn encode( frame: &RgbaImage, format: DerivativeFormat, tier: DerivativeTier, ) -> Result, MediaError> { let options = match format { - DerivativeFormat::WebP => EncodeOptions::WebpLibwebp(LibwebpEncodeConfig { + DerivativeFormat::Jxl => EncodeOptions::JxlZune(ZuneJxlEncodeConfig { common: CommonEncodeOptions { metadata: MetadataEmbedOptions::none(), bit_depth: BitDepth::Eight, }, - mode: WebPMode::Lossy, + // The tier table's number, passed through as declared even though the backend + // ignores it: `JxlSimpleEncoder` is lossless, so today this is advisory. Keeping the + // contract's value here rather than hard-coding `0.0` (the crate's explicit + // "lossless" request) makes the eventual libjxl swap a backend change and not a + // quality decision taken again from scratch. quality: tier.quality(), - // The libwebp compression method, 0 (fast) to 6 (slowest, best). 4 is the crate's - // own default and the usual trade; a thumbnail is small enough that the slower - // methods buy little. - method: 4, - // Lossless-only knob; 100 means off. - near_lossless: 100, + // Encoder effort, 1..=9; the simple encoder may ignore this too. 7 is the crate's + // own default and there is no reason to differ. + effort: 7, }), - // Unreachable: `is_encodable` gates the call. Kept as a typed refusal rather than a - // panic so a future widening that forgets an arm degrades to a deferral. - DerivativeFormat::Jxl | DerivativeFormat::Avif | DerivativeFormat::Original => { - return Err(MediaError::UnsupportedFormat { - format: super::StillFormat::WebP, - op: FormatOp::Encode, + // Unreachable: `is_encodable` gates the call, and `Original` never routes here at all + // (it carries no bytes). Kept as a typed refusal rather than a panic so a future + // widening that forgets an arm degrades to a reported failure. Reported as + // `Encode { format }` and not `UnsupportedFormat`, because the latter names a + // `StillFormat` and there is no still format at fault here — the caller asked for a + // *derivative* format this build cannot write, and the message has to say which. + DerivativeFormat::WebP | DerivativeFormat::Avif | DerivativeFormat::Original => { + return Err(MediaError::Encode { + format, + detail: "this build links no encoder for this derivative format".to_string(), }); } }; diff --git a/capsule-core/src/media/detect.rs b/capsule-core/src/media/detect.rs index 496946c7..e2a001c2 100644 --- a/capsule-core/src/media/detect.rs +++ b/capsule-core/src/media/detect.rs @@ -3,12 +3,17 @@ //! # Why Capsule sniffs rather than delegating //! //! `rawshift-image` ships `detect_standard_format`, and Capsule deliberately does not use it as -//! the primary table: its HEIC arm is `#[cfg(feature = "heic-decode")]`, so a build without the -//! HEIC codec cannot *recognise* HEIC either. That would make the typed refusal for exactly the -//! formats this build cannot decode depend on whether it can decode them — a HEIC would arrive -//! as "not a still image" instead of "a still image with no codec here", which is the difference -//! between a reportable, backfillable gap and an apparent non-image. Capsule's reference library -//! is HEIC end to end, so that distinction is the whole point of slice `S-B13`. +//! the primary table. Its `heic | heis | hevc | hevx` arm is `#[cfg(feature = "heic-decode")]`, +//! so a build without the HEIC codec cannot *recognise* an Apple HEIC either — its major brand +//! is `heic`. That would make the typed refusal for exactly the formats this build cannot decode +//! depend on whether it can decode them: a HEIC would arrive as "not a still image" instead of +//! "a still image with no codec here", which is the difference between a reportable, +//! backfillable gap and an apparent non-image. Capsule's reference library is HEIC end to end, +//! so that distinction is the whole point of slice `S-B13`. +//! +//! Two smaller reasons ride along, both about the brand table rather than a feature: the crate +//! reads the generic HEIF brand `mif1` as **AVIF**, and it does not recognise `heix` or `msf1` +//! under any configuration. //! //! The two tables are held together by a test rather than by hope: //! `capsule-core`'s `still_format_agrees_with_rawshift_detection` asserts that for every format @@ -31,10 +36,18 @@ use std::fmt; /// The decode budget in pixels, refused **before** the decoder allocates. /// -/// 256 Mpx sits well above a 100 Mpx medium-format frame and well below an allocation bomb: -/// `rawshift-image` decodes to interleaved RGB `u16`, so this ceiling caps the decoder's own -/// buffer at ~1.5 GB and Capsule's RGBA8 copy at ~1 GB. -pub const MAX_DECODE_PIXELS: u64 = 256_000_000; +/// **128 Mpx**, which admits every real camera — a 102 Mpx medium-format frame has ~25% +/// headroom — while bounding what one still can ask the process for. +/// +/// The bound is worth stating honestly, because the naive figure is a large understatement. A +/// frame at this ceiling costs, in sequence and with overlap: 0.77 GB for `zune-png`'s `u16` +/// samples, another 0.77 GB when `decode_png` reallocates to drop the alpha channel, 0.51 GB for +/// Capsule's RGBA8 copy, and 0.77 GB again when the encode path widens a tier-sized frame back +/// to RGB `u16`. Peak is on the order of **2.5 GB**, not the ~1 GB a single buffer suggests. +/// Since `media` is implied by `native`, that peak happens on a phone as well as a workstation, +/// where it is an OOM kill rather than an error — which is the reason this is not simply set as +/// high as an allocation bomb would require. +pub const MAX_DECODE_PIXELS: u64 = 128_000_000; /// The closed set of still-image formats Capsule models. /// @@ -49,7 +62,13 @@ pub enum StillFormat { Jpeg, /// PNG. Decoded by `zune-png`; alpha is flattened at the decode boundary. Png, - /// WebP. Decoded and encoded by `libwebp`; the derivative format this build produces. + /// WebP. **Recognised, not decoded**, and unusually the codec exists — it just does not + /// compile. `rawshift-image`'s WebP module passes `*const i8` where `libwebp-sys` 0.14.4 + /// declares `*const c_char`, and `c_char` is `u8` on aarch64, so the crate's `webp` feature + /// is an E0308 on every 64-bit ARM target — which is every mobile target Capsule ships. The + /// module is compiled by decode *or* encode, so there is no decode-only escape. Enabling it + /// would mean thumbnails on desktop and none on a phone; recognising and deferring it is the + /// honest outcome until upstream fixes the cast. WebP, /// JPEG XL. Decoded by `jxl-oxide`. No lossy encoder without C libjxl. Jxl, @@ -88,7 +107,6 @@ pub enum StillFormat { pub const SUPPORTED_STILL_FORMATS: &[StillFormat] = &[ StillFormat::Jpeg, StillFormat::Png, - StillFormat::WebP, StillFormat::Jxl, StillFormat::Tiff, StillFormat::Gif, diff --git a/capsule-core/src/media/mod.rs b/capsule-core/src/media/mod.rs index 23882e6a..7100a0fb 100644 --- a/capsule-core/src/media/mod.rs +++ b/capsule-core/src/media/mod.rs @@ -24,9 +24,9 @@ //! //! # What this build can and cannot do //! -//! Every gap is a typed [`MediaError::UnsupportedFormat`] or a recorded per-format deferral, -//! never a silent absence and never a panic (slice `S-B13`). Decode covers JPEG, PNG, JXL, -//! TIFF, GIF and WebP; encode covers WebP alone. HEIC, AVIF and the RAW families sniff +//! Every gap is a typed [`UnsupportedFormat`](MediaError::UnsupportedFormat) or a recorded +//! per-format deferral — never a silent absence, and never a panic (slice `S-B13`). Decode +//! covers JPEG, PNG, JXL, TIFF, GIF and WebP; encode covers WebP alone. HEIC, AVIF and the RAW families sniff //! correctly and refuse to decode, because their backends need system libraries (libheif, //! libdav1d) or an assembler (nasm) that the cross and cargo-ndk builds do not have. //! @@ -38,7 +38,9 @@ mod detect; mod error; mod resize; -pub use self::decode::{DecodedImage, Decoder, MediaMetadata, RawshiftDecoder, decode_guarded}; +pub use self::decode::{ + DecodedImage, Decoder, MediaMetadata, RawshiftDecoder, decode_guarded, guarded, +}; pub use self::derivative::{ DerivativeContext, DerivativeFormat, DerivativeTier, GeneratedDerivative, StillDerivatives, generate_still_derivatives, verify_still_format, diff --git a/capsule-core/src/media/resize.rs b/capsule-core/src/media/resize.rs index fc69c107..168a7ce1 100644 --- a/capsule-core/src/media/resize.rs +++ b/capsule-core/src/media/resize.rs @@ -9,9 +9,20 @@ //! over the same source must produce the same bytes. That rules out floating-point accumulation //! whose order or width could differ between builds and targets, and it rules out any resampler //! with platform-tuned SIMD paths that are not required to be bit-identical. What is left is a -//! box filter accumulated in `u32` and divided by an exact sample count: deterministic on every -//! target, and the right filter for a large downscale anyway (a box average over the full source -//! rect is alias-free, where a bilinear tap would ignore most of the source pixels). +//! box filter accumulated in integers and divided by an exact sample count: deterministic on +//! every target, and the right filter for a large downscale anyway (a box average over the full +//! source rect is alias-free, where a bilinear tap would ignore most of the source pixels). +//! +//! Every product here is computed in `u64`, never in `usize`, because two of the CI-gated +//! targets (`armv7-linux-androideabi`, `i686-linux-android`) are 32-bit and both the +//! destination-to-source boundary and the channel accumulator reach past `u32` for shapes this +//! function accepts. +//! +//! Determinism here is **necessary, not sufficient**, and the distinction matters: the bytes a +//! manifest actually signs come out of libwebp, and a libwebp version bump can change them for +//! the same input. That is fine — each generation signs the bytes it produced and manifests of a +//! role chain in order — but it means "the resample is deterministic" buys reproducibility of +//! *this* step, not a stable content address across toolchains. //! //! Upscaling is not a thing this performs: a tier only ever caps a long edge, and a source //! already inside the cap takes the `format = "original"` sentinel path instead @@ -20,10 +31,16 @@ use crate::lqip::RgbaImage; /// The dimensions a `width` x `height` frame takes when its long edge is capped at -/// `max_long_edge`, preserving aspect ratio and never returning a zero dimension. +/// `max_long_edge`, preserving aspect ratio. /// -/// Returns the input unchanged when it already fits, so a caller can compare and skip. +/// Returns the input unchanged when it already fits, so a caller can compare and skip — and +/// unchanged for an empty frame, which is the one case where "never zero" cannot hold: there is +/// no non-degenerate size for a frame with no pixels, and inventing one would have +/// [`downscale_rgba8`] read a buffer that has nothing in it. pub fn capped_dimensions(width: u32, height: u32, max_long_edge: u32) -> (u32, u32) { + if width == 0 || height == 0 { + return (width, height); + } let cap = max_long_edge.max(1); let long_edge = width.max(height); if long_edge <= cap { @@ -42,9 +59,9 @@ pub fn capped_dimensions(width: u32, height: u32, max_long_edge: u32) -> (u32, u /// Downscale packed RGBA8 so its long edge is at most `max_long_edge`. /// -/// A frame already within the cap is returned unchanged (cloned), which is what makes this safe -/// to call unconditionally. Deterministic: identical input yields byte-identical output on every -/// target. +/// A frame already within the cap — or an empty one — is returned unchanged (cloned), which is +/// what makes this safe to call unconditionally. Deterministic: identical input yields +/// byte-identical output on every target. pub fn downscale_rgba8(source: &RgbaImage, max_long_edge: u32) -> RgbaImage { let (dst_w, dst_h) = capped_dimensions(source.width, source.height, max_long_edge); if (dst_w, dst_h) == (source.width, source.height) { @@ -52,7 +69,9 @@ pub fn downscale_rgba8(source: &RgbaImage, max_long_edge: u32) -> RgbaImage { } let (src_w, src_h) = (source.width as usize, source.height as usize); - if source.rgba.len() != src_w * src_h * 4 { + // `u64`, not `usize`: on a 32-bit target `w * h * 4` overflows above ~1 Gpx, and the whole + // point of this branch is to be reached rather than to panic on its own arithmetic. + if source.rgba.len() as u64 != u64::from(source.width) * u64::from(source.height) * 4 { // Defensive: this is a `pub` entry point and the very next thing it does is index the // buffer by those dimensions. Every in-tree caller passes a `DecodedImage`, whose // invariant this is, so a mismatch is a bug in a *new* caller — reported and returned diff --git a/capsule-core/src/media/tests.rs b/capsule-core/src/media/tests.rs index 06e71dbe..5efde193 100644 --- a/capsule-core/src/media/tests.rs +++ b/capsule-core/src/media/tests.rs @@ -21,7 +21,7 @@ use rawshift_image::core::metadata::{ImageInfo, ImageMetadata, URational}; use rawshift_image::core::{BitDepth, MetadataEmbedOptions}; use rawshift_image::formats::encode_rgb_image_to_vec; use rawshift_image::formats::export::{ - CommonEncodeOptions, EncodeOptions, JpegEncEncodeConfig, LibwebpEncodeConfig, WebPMode, + CommonEncodeOptions, EncodeOptions, JpegEncEncodeConfig, ZuneJxlEncodeConfig, ZunePngEncodeConfig, }; use uuid::Uuid; @@ -174,17 +174,35 @@ fn png_bytes(frame: &RgbaImage) -> Vec { .expect("the fixture PNG encodes") } -/// Encode a frame as a lossless WebP with no metadata. -fn webp_bytes(frame: &RgbaImage) -> Vec { - let options = EncodeOptions::WebpLibwebp(LibwebpEncodeConfig { - common: common(MetadataEmbedOptions::none()), - mode: WebPMode::Lossless, - quality: 100.0, - method: 4, - near_lossless: 100, +/// A bare 12-byte RIFF/WEBP header. +/// +/// A *header*, not an encode, because this build has no WebP codec at all: `rawshift-image`'s +/// WebP module does not compile for aarch64 (`*const i8` against `libwebp-sys`'s `*const +/// c_char`), so the crate's `webp` feature is off in both directions. Twelve bytes is all a +/// detection case needs, and there is nothing to decode. +fn webp_header() -> Vec { + let mut bytes = b"RIFF".to_vec(); + bytes.extend_from_slice(&0u32.to_le_bytes()); + bytes.extend_from_slice(b"WEBP"); + bytes +} + +/// Encode a frame as JXL through the same backend the derivative path uses, optionally +/// embedding `metadata`. +fn jxl_bytes(frame: &RgbaImage, metadata: Option<&ImageMetadata>) -> Vec { + let embed = if metadata.is_some() { + MetadataEmbedOptions::all() + } else { + MetadataEmbedOptions::none() + }; + let options = EncodeOptions::JxlZune(ZuneJxlEncodeConfig { + common: common(embed), + quality: 50.0, + effort: 7, }); - encode_rgb_image_to_vec(&to_rgb_u16(frame), &ImageMetadata::default(), &options) - .expect("the fixture WebP encodes") + let empty = ImageMetadata::default(); + encode_rgb_image_to_vec(&to_rgb_u16(frame), metadata.unwrap_or(&empty), &options) + .expect("the fixture JXL encodes") } /// An `ImageMetadata` carrying only an EXIF orientation tag. @@ -310,7 +328,7 @@ fn every_still_format_is_reachable_from_its_header() { let cases: &[(Vec, &str, StillFormat)] = &[ (jpeg_bytes(&frame, None), "jpg", StillFormat::Jpeg), (png_bytes(&frame), "png", StillFormat::Png), - (webp_bytes(&frame), "webp", StillFormat::WebP), + (webp_header(), "webp", StillFormat::WebP), (hand_written_png(), "png", StillFormat::Png), ( b"GIF89a\x08\x00\x08\x00\x00\x00".to_vec(), @@ -334,6 +352,7 @@ fn every_still_format_is_reachable_from_its_header() { ), (b"P6\n8 8\n255\n".to_vec(), "ppm", StillFormat::Ppm), (isobmff(b"avif"), "avif", StillFormat::Avif), + (webp_header(), "webp", StillFormat::WebP), (isobmff(b"heic"), "heic", StillFormat::Heic), (isobmff(b"mif1"), "heic", StillFormat::Heic), (isobmff(b"crx "), "cr3", StillFormat::Cr3), @@ -437,7 +456,7 @@ fn still_format_agrees_with_rawshift_detection() { StillFormat::Jpeg, ), (png_bytes(&frame), StandardFormat::Png, StillFormat::Png), - (webp_bytes(&frame), StandardFormat::WebP, StillFormat::WebP), + (webp_header(), StandardFormat::WebP, StillFormat::WebP), (hand_written_png(), StandardFormat::Png, StillFormat::Png), ( b"GIF89a\x08\x00\x08\x00\x00\x00".to_vec(), @@ -479,6 +498,7 @@ fn is_decodable_matches_the_supported_table() { } for format in [ StillFormat::Ppm, + StillFormat::WebP, StillFormat::Avif, StillFormat::Heic, StillFormat::Arw, @@ -509,7 +529,7 @@ fn decodes_every_supported_container_to_opaque_rgba8() { let cases: &[(Vec, &str, StillFormat, u32, u32)] = &[ (jpeg_bytes(&frame, None), "jpg", StillFormat::Jpeg, 6, 4), (png_bytes(&frame), "png", StillFormat::Png, 6, 4), - (webp_bytes(&frame), "webp", StillFormat::WebP, 6, 4), + (jxl_bytes(&frame, None), "jxl", StillFormat::Jxl, 6, 4), (hand_written_png(), "png", StillFormat::Png, 2, 2), ]; for (bytes, ext, format, width, height) in cases { @@ -907,11 +927,11 @@ fn generate(frame: &RgbaImage, original: &[u8]) -> StillDerivatives { .expect("generation succeeds") } -/// The thumbnail tier over a source larger than the cap: real WebP bytes, a signed manifest -/// binding their hash, and the two formats this build cannot encode recorded as deferrals -/// rather than silently omitted. +/// The thumbnail tier over a source larger than the cap: real JXL bytes, a signed manifest +/// binding their hash, and the two formats this build cannot encode recorded as deferrals rather +/// than silently omitted. #[test] -fn the_thumbnail_tier_encodes_webp_and_defers_the_rest() { +fn the_thumbnail_tier_encodes_jxl_and_defers_the_rest() { let frame = gradient(512, 384); let original = png_bytes(&frame); let result = generate(&frame, &original); @@ -919,8 +939,8 @@ fn the_thumbnail_tier_encodes_webp_and_defers_the_rest() { assert_eq!(result.generated.len(), 1, "one encodable format today"); let thumb = &result.generated[0]; assert_eq!(thumb.tier, DerivativeTier::Thumbnail); - assert_eq!(thumb.format, DerivativeFormat::WebP); - assert_eq!(thumb.manifest.core.format, "image/webp"); + assert_eq!(thumb.format, DerivativeFormat::Jxl); + assert_eq!(thumb.manifest.core.format, "image/jxl"); assert_eq!(thumb.manifest.core.role, DerivativeRole::Thumbnail); assert_eq!( thumb.manifest.core.ciphertext_hash, @@ -933,26 +953,24 @@ fn the_thumbnail_tier_encodes_webp_and_defers_the_rest() { "first of its role" ); - // The bytes are a real WebP of the tier's size. + // The bytes are a real JXL of the tier's size. assert_eq!( StillFormat::from_bytes(&thumb.bytes), - Some(StillFormat::WebP) + Some(StillFormat::Jxl) ); let back = RawshiftDecoder - .decode(&thumb.bytes, "webp") + .decode(&thumb.bytes, "jxl") .expect("the thumbnail decodes"); assert_eq!((back.width(), back.height()), (256, 192)); - assert!( - thumb.bytes.len() < original.len(), - "a 256 px q=50 thumbnail is smaller than a 512 px lossless original" - ); - // The gap is per (tier, format), recorded rather than collapsed. + // The gap is per (tier, format), recorded rather than collapsed. WebP is here for a + // different reason from AVIF: not a missing toolchain but a codec that does not compile for + // aarch64, so it is deferred on every target rather than only where nasm is absent. assert_eq!( result.deferred, vec![ - (DerivativeTier::Thumbnail, DerivativeFormat::Jxl), (DerivativeTier::Thumbnail, DerivativeFormat::Avif), + (DerivativeTier::Thumbnail, DerivativeFormat::WebP), ] ); @@ -971,6 +989,29 @@ fn the_thumbnail_tier_encodes_webp_and_defers_the_rest() { ); } +/// The tier's declared `q=50` is **advisory today**: `zune-jpegxl`'s `JxlSimpleEncoder` is +/// lossless, so the thumbnail round-trips its downscaled pixels exactly and costs what a +/// lossless encode costs. +/// +/// Asserted rather than left as a comment, because it is the one place this build visibly +/// departs from the tier table and the departure disappears the moment a lossy backend lands. +#[test] +fn the_jxl_thumbnail_is_lossless_today() { + let frame = gradient(512, 384); + let original = png_bytes(&frame); + let result = generate(&frame, &original); + let thumb = &result.generated[0]; + + let back = RawshiftDecoder + .decode(&thumb.bytes, "jxl") + .expect("the thumbnail decodes"); + let expected = downscale_rgba8(&gradient(512, 384), 256); + assert_eq!( + back.image, expected, + "a lossless encode reproduces the downscaled frame exactly" + ); +} + /// A source no larger than the tier's cap takes the signed `original` sentinel — an explicit /// marker, distinct from an absent derivative, and never a redundant re-encode. #[test] @@ -983,13 +1024,15 @@ fn a_source_within_the_cap_signs_the_original_sentinel() { let only = &result.generated[0]; assert_eq!(only.format, DerivativeFormat::Original); assert_eq!(only.manifest.core.format, "original"); - assert_eq!( - only.bytes, original, - "the sentinel references the original bytes" + assert!( + only.bytes.is_empty(), + "the sentinel *references* the original rather than copying it: a copy would put the \ + source's EXIF, GPS included, into a derivative blob" ); assert_eq!( only.manifest.core.ciphertext_hash, - crate::crypto::hash::hash_bytes(&original) + crate::crypto::hash::hash_bytes(&original), + "the reference is the content address the manifest signs" ); assert!( result.deferred.is_empty(), @@ -1128,24 +1171,12 @@ fn a_thumbnail_carries_no_exif_and_no_gps() { "the fixture JPEG must carry the GPS rationals" ); - // The control: encoding a WebP the way the crate's own default would. - let leaky = encode_rgb_image_to_vec( - &to_rgb_u16(&frame), - &located_metadata, - &EncodeOptions::WebpLibwebp(LibwebpEncodeConfig { - common: CommonEncodeOptions { - metadata: MetadataEmbedOptions::all(), - bit_depth: BitDepth::Eight, - }, - mode: WebPMode::Lossy, - quality: 50.0, - method: 4, - near_lossless: 100, - }), - ) - .expect("the control WebP encodes"); + // The control: the same frame through the same JXL backend, configured the way the crate's + // own `MetadataEmbedOptions::default()` would configure it. It leaks — which is what makes + // the assertion below a test of Capsule's strip rather than of a codec that never embeds. + let leaky = jxl_bytes(&frame, Some(&located_metadata)); assert!( - contains(&leaky, b"EXIF") && gps_rationals_present(&leaky), + gps_rationals_present(&leaky), "the control must leak, or this test is not testing the strip" ); @@ -1161,10 +1192,9 @@ fn a_thumbnail_carries_no_exif_and_no_gps() { .expect("generation"); let thumb = &result.generated[0].bytes; assert!(!thumb.is_empty(), "the thumbnail has bytes to inspect"); - assert!(!contains(thumb, b"EXIF"), "no EXIF chunk in the thumbnail"); - assert!(!contains(thumb, b"Exif\0\0"), "no APP1 EXIF payload either"); - assert!(!contains(thumb, b"XMP "), "no XMP chunk"); - assert!(!contains(thumb, b"ICCP"), "no ICC profile"); + assert!(!contains(thumb, b"Exif"), "no EXIF box in the thumbnail"); + assert!(!contains(thumb, b"xml "), "no XMP box"); + assert!(!contains(thumb, b"jumb"), "no metadata container box"); assert!( !gps_rationals_present(thumb), "the GPS rationals must not survive into a thumbnail" @@ -1220,12 +1250,13 @@ fn the_closed_format_set_round_trips_and_admits_nothing_else() { "{rejected:?} is outside the closed set" ); } - // Only WebP and the sentinel can be produced here; the master and delivery formats are - // committed but blocked on a toolchain. - assert!(DerivativeFormat::WebP.is_encodable()); + // Only JXL — the table's committed *master* format — and the sentinel can be produced here. + // AVIF is blocked on a build-host assembler; WebP is blocked on an upstream defect, its + // codec not compiling for aarch64 at all. + assert!(DerivativeFormat::Jxl.is_encodable()); assert!(DerivativeFormat::Original.is_encodable()); - assert!(!DerivativeFormat::Jxl.is_encodable()); assert!(!DerivativeFormat::Avif.is_encodable()); + assert!(!DerivativeFormat::WebP.is_encodable()); } /// A signed still-role manifest whose `format` is outside the closed set is rejected at From 4918c7cec275c2f5a4c942ce9855fb83ef372853 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 00:54:36 -0400 Subject: [PATCH 067/243] docs: record the JXL thumbnail tier and why WebP is not available MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dependency row, the tier-table status note, `S-B1` and the `AGENTS.md` sentence all named WebP as the format that ships. They now name JXL, and each says why WebP is absent — it is a compile failure on every aarch64 target, not a preference, so the reason belongs beside the choice rather than only in the issue tracker (#444). The status note gains the honest asterisk on the tier table: the pure-Rust JXL backend is lossless, so the declared q=50 is advisory and a thumbnail costs more bytes than the table intends. That is the one place this build knowingly departs from the contract, and the note says so rather than leaving a reader to infer it from a byte count. Decode coverage narrows with the feature: WebP is recognised and refused alongside HEIC, AVIF and the RAW families, because the crate compiles the broken module for decode as well as encode. --- AGENTS.md | 2 +- SLICES.md | 23 ++++++++++++------- .../src/content/docs/design/dependencies.md | 2 +- .../src/content/docs/design/thumbnails.md | 16 +++++++++---- 4 files changed, 28 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ba619305..c7d35635 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,7 @@ - The public server surface is Kynos REST/OpenAPI only. Do not reintroduce Salvo, GraphQL, or gRPC. The served document is **OpenAPI 3.2**: enabling Kynos's `openapi32` feature does not by itself produce one — `capsule-server` pins it with `openapi_as(SpecVersion::V3_2)`. Never emit or commit a 3.1 or 3.0 contract. - Generate clients with Spargen from the checked-in Kynos OpenAPI contract. Do not use Progenitor. Everything that parses or serializes is generated — every body, every typed parameter, and the byte-serving endpoints. Only *orchestration over* generated calls is hand-written, and the resumable upload state machine (`S-D1`) is the whole of it; do not hand-write a second parser. -- Rawshift owns media decoding, metadata extraction, and derivative generation, consumed through `capsule-core::media`, which now exists and is wired: `rawshift-image` **0.1.1 from crates.io** — a registry dependency, never the pinned submodule — behind the `media` feature that `native` implies, covering still detection, decode, EXIF orientation, metadata normalisation and the WebP thumbnail tier, and absent from the `wasm32-unknown-unknown` sealing build. Every format with no codec in the build is a typed `media::MediaError::UnsupportedFormat` or a recorded per-format deferral, never a silent gap: HEIC/AVIF/RAW decode, JXL and AVIF encode, the preview tier, and all video derivatives stay deferred behind the system libraries or assemblers they need. Capsule imports **Chromahash 0.7.1** directly, never through Rawshift, and LQIP encode/decode lives in its own `capsule-core::lqip` module (slice `S-B14`) so one implementation serves the import pipeline, the FFI, and `capsule-wasm`. **ThumbHash is retired**: neither the `thumbhash` crate nor the npm `thumbhash` package may be reintroduced. Contract: [Thumbnails — LQIP](capsule-docs/src/content/docs/design/thumbnails.md#lqip). +- Rawshift owns media decoding, metadata extraction, and derivative generation, consumed through `capsule-core::media`, which now exists and is wired: `rawshift-image` **0.1.1 from crates.io** — a registry dependency, never the pinned submodule — behind the `media` feature that `native` implies, covering still detection, decode, EXIF orientation, metadata normalisation and the JXL thumbnail tier, with every enabled codec pure Rust (no C links, so the mobile cross-builds stay clean), and absent from the `wasm32-unknown-unknown` sealing build. Every format with no codec in the build is a typed `media::MediaError::UnsupportedFormat` or a recorded per-format deferral, never a silent gap: HEIC/AVIF/RAW/WebP decode, lossy-JXL and AVIF encode, the preview tier, and all video derivatives stay deferred behind the system library, assembler, or (for WebP) the upstream aarch64 fix each needs. Capsule imports **Chromahash 0.7.1** directly, never through Rawshift, and LQIP encode/decode lives in its own `capsule-core::lqip` module (slice `S-B14`) so one implementation serves the import pipeline, the FFI, and `capsule-wasm`. **ThumbHash is retired**: neither the `thumbhash` crate nor the npm `thumbhash` package may be reintroduced. Contract: [Thumbnails — LQIP](capsule-docs/src/content/docs/design/thumbnails.md#lqip). - Blob storage and resumable encrypted upload remain Capsule-owned behind narrow, arbitrary-backend ports. Do not add `object_store` or generic CAS/transfer crates without revisiting the security contract. - Keep authentication state and upload-session state as separate Capsule ports with PostgreSQL, `redis-rs`, and in-memory adapters. Do not introduce a generic TTL/CAS abstraction. - `legacy-review/` is non-buildable reference material. Restore code only after defining its contract and automated tests against the decisions above. diff --git a/SLICES.md b/SLICES.md index 3d53dbf6..d65fa80d 100644 --- a/SLICES.md +++ b/SLICES.md @@ -210,7 +210,7 @@ row's remainder now lives. | S-A9 | Add-id counter reseed at `Workspace` open | core-crypto | — | S | ACTIVE | done | | | S-A10 | Durable album-key persistence + library open plumbing | core-crypto | — | L | ACTIVE | done | | | S-A11 | Publish the DEK in the device directory | core-crypto | — | M | ACTIVE | done | | -| S-B1 | Thumbnail/LQIP generation | media/import | — | L | ACTIVE | done\* | JXL/AVIF encode, the preview tier, HEIC/RAW decode → #437 | +| S-B1 | Thumbnail/LQIP generation | media/import | — | L | ACTIVE | done\* | lossy JXL/AVIF encode, preview tier, HEIC/RAW decode → #437; WebP → #444 | | S-B2 | Signed-path import-executor rewrite | media/import | S-B1 | L | MIXED | done\* | durable album keys → `S-A10` | | S-B3 | Streaming import (probe, `total_size`, drive mode) | media/import | S-D1, S-D4 | L | MIXED | done | | | S-B4 | Staged uploads (low-data tier ladder) | media/import | S-C1, S-C2, S-D1 | M | MIXED | done | | @@ -707,9 +707,16 @@ workspace at all**, so every still import is a `DeferredNoCodec` until Rawshift a pre-decode 256 Mpx budget and an unwind boundary, a deterministic integer area-average downscale (the crate has no resize, and a derivative's bytes are signed), the closed `DerivativeFormat` set with the `original` sentinel, and `MediaError`. - - the **thumbnail tier** at 256 px / q=50 as **WebP**, signed and hash-chained through the same - two-signature `DerivativeCore::sign` path assets use, and persisted at the layout the upload - bundle reader already reads. + - the **thumbnail tier** at 256 px as **JXL** — the table's committed *master* format — signed + and hash-chained through the same two-signature `DerivativeCore::sign` path assets use, and + persisted at the layout the upload bundle reader already reads. The declared q=50 is advisory: + the pure-Rust `zune-jpegxl` backend is lossless, which a test asserts rather than hides. + - **WebP was the first choice and CI refuted it.** `image/webp` is in the format table and + `libwebp` has exactly the q=50 knob, but `rawshift-image`'s WebP module passes `*const i8` + where `libwebp-sys` 0.14.4 declares `*const c_char` — `u8` on aarch64 — so the feature is an + E0308 on every mobile target, in both directions (the module compiles under + `any(webp-decode, webp-encode)`). WebP is therefore a recognised-but-undecodable format here; + the upstream fix is filed as #444. - **Detection is Capsule's, not the crate's.** `rawshift-image`'s own `detect_standard_format` gates its HEIC arm on `heic-decode`, so delegating would make the typed refusal for a format depend on whether it can be decoded — a HEIC would arrive as "not a still" instead of "a @@ -719,10 +726,10 @@ workspace at all**, so every still import is a `DeferredNoCodec` until Rawshift so a default-configured encode copies the source's EXIF — GPS included — into the thumbnail. A test demonstrates the leak with the crate's own default and then asserts Capsule's derivative carries no `EXIF`/`XMP`/`ICCP` chunk and none of the source's GPS rationals. -- **Owed → #437.** The JXL master and the AVIF delivery variant, the preview tier, and HEIC/RAW - decode. Each is blocked on a toolchain, not a design: a lossy JXL needs C libjxl (the pure-Rust - backend is `zune-jpegxl`'s lossless simple encoder), AVIF encode needs `nasm` on every x86_64 - build host, and HEIC/AVIF decode need system libheif/libdav1d. All four are visible today as +- **Owed → #437 and #444.** A *lossy* JXL master, the AVIF delivery variant, the preview tier and + HEIC/RAW decode (#437); WebP in both directions (#444). None is blocked on a design question: a + lossy JXL needs C libjxl, AVIF encode needs `nasm` on every x86_64 build host, HEIC/AVIF decode + need system libheif/libdav1d, and WebP needs one upstream cast widened. All are visible today as typed `MediaError::UnsupportedFormat` or as per-`(tier, format)` deferrals counted by `ImportExecutionSummary::deferred_format_count()`, never as silent absence. diff --git a/capsule-docs/src/content/docs/design/dependencies.md b/capsule-docs/src/content/docs/design/dependencies.md index 517116c2..91c65e37 100644 --- a/capsule-docs/src/content/docs/design/dependencies.md +++ b/capsule-docs/src/content/docs/design/dependencies.md @@ -40,7 +40,7 @@ Mechanically, every Rust version is pinned once in the root `Cargo.toml` `[works | ORM | `sea-orm` (`sqlx-postgres` on the server, `sqlx-sqlite` in the CLI) | The rebuildable index databases only — sidecars stay canonical per [Principles](/design/principles/). | — | | Embedded SQLite | `rusqlite` (`bundled`) | `capsule-core`'s `library.sqlite`. | — | | Vector index | `sqlite-vec` (`vec0`) | The client-local embedding index in `capsule-core`'s `library.sqlite` — per-task `vec0` virtual tables under the [embedding-provenance](/design/ai/#embedding-provenance) invariant. Optional + `native`-gated alongside `rusqlite` (registers as a SQLite auto-extension; not `wasm32`). | Server-side vector-DB idioms (pgvector/HNSW) do not apply — the index is client-local SQLite by design. | -| Still decode / encode | `rawshift-image` **0.1.1** (`default-features = false`, features `jpeg`, `png`, `jxl-decode`, `tiff-decode`, `gif-decode`, `webp`) | `capsule-core::media` behind the `media` feature, which `native` implies (slices `S-B1`, `S-B13`) — format sniffing, pixel decode, EXIF orientation and the derivative byte encode. A **registry** dependency, not the pinned `rawshift/` submodule: that tree is an uninitialised newer v1-in-progress checkout and not a workspace member. Depended on directly rather than through the `rawshift` facade because only the per-crate dependency gives per-format Cargo control, which the crate's own docs recommend and which this row needs — the format set is a licence and build-host decision, not a convenience. Decode is pure Rust for JPEG, PNG, JXL, TIFF, GIF and Netpbm (the zune family, `jxl-oxide`, `tiff`, `gif`); WebP adds `libwebp-sys` 0.14.4 (MIT), a vendored static libwebp built through `cc` with **pre-generated** bindings — the same class of C build `rusqlite/bundled` already performs, and the encoder that produces the thumbnail tier's bytes. MPL-2.0 (with `rawshift-core`), already allow-listed in `deny.toml`; both are named in the root `NOTICE` MPL list. `jpeg-encoder`'s conjunctive IJG arm was already excepted and is matched again by this row. Tiers, quality and the closed format set are the contract at [Thumbnails](/design/thumbnails/); this row owns the pin. | **Deliberately absent, each a toolchain rather than a design gap:** `heic` (system libheif), `avif` (`image`'s `avif-native` -> system libdav1d for decode; `ravif` -> `rav1e/asm` -> `nasm` on every x86_64 build host for encode), `svg` (resvg), and the RAW families (`experimental`/`raw-stabilizing`; Canon CR3 pixel decode is unimplemented upstream). Also absent: a lossy JXL encoder, because the pure-Rust backend is `zune-jpegxl`'s lossless `JxlSimpleEncoder` and a q=50 encode needs C libjxl (`bindgen` + `pkg-config`). Every one of these is a typed `media::MediaError::UnsupportedFormat` or a recorded per-format deferral, never a silent gap. **Not** on the wasm32 sealing surface: `media` is absent from the `--no-default-features` build, so `cargo tree --target wasm32-unknown-unknown -i rawshift-image` is empty. Rawshift must never wrap Chromahash (`AGENTS.md`); see the LQIP row below. | +| Still decode / encode | `rawshift-image` **0.1.1** (`default-features = false`, features `jpeg`, `png`, `jxl`, `tiff-decode`, `gif-decode`) | `capsule-core::media` behind the `media` feature, which `native` implies (slices `S-B1`, `S-B13`) — format sniffing, pixel decode, EXIF orientation and the derivative byte encode. A **registry** dependency, not the pinned `rawshift/` submodule: that tree is an uninitialised newer v1-in-progress checkout and not a workspace member. Depended on directly rather than through the `rawshift` facade because only the per-crate dependency gives per-format Cargo control, which the crate's own docs recommend and which this row needs — the format set is a licence, build-host and **portability** decision, not a convenience. **Every enabled codec is pure Rust and links no C**: zune for JPEG/PNG decode and encode, `jxl-oxide` for JXL decode, `zune-jpegxl` for the JXL encode that produces the thumbnail tier, plus `tiff` and `gif`. MPL-2.0 (with `rawshift-core`), already allow-listed in `deny.toml`; both are named in the root `NOTICE` MPL list. `jpeg-encoder`'s conjunctive IJG arm was already excepted and is matched again by this row. Tiers, quality and the closed format set are the contract at [Thumbnails](/design/thumbnails/); this row owns the pin. | **`webp` is absent because it does not compile, not because it was not wanted.** It was the first choice — `image/webp` is in the format table and `libwebp` has the exact q=50 knob — but `rawshift-image`'s WebP module passes `*const i8` where `libwebp-sys` 0.14.4 declares `*const c_char`, and `c_char` is `u8` on aarch64, so it is an E0308 on every 64-bit ARM target; the module is compiled by decode *or* encode, so decode-only does not escape it. Every mobile target is aarch64, so enabling it would mean thumbnails on desktop and none on a phone. **Also deliberately absent**, each a toolchain rather than a design gap: `heic` (system libheif), `avif` (`image`'s `avif-native` -> system libdav1d for decode; `ravif` -> `rav1e/asm` -> `nasm` on every x86_64 build host for encode), `svg` (resvg), and the RAW families (`experimental`/`raw-stabilizing`; Canon CR3 pixel decode is unimplemented upstream). And the JXL encode is **lossless** — `zune-jpegxl`'s `JxlSimpleEncoder` — so a thumbnail costs more bytes than the table's q=50 intends; a lossy JXL needs C libjxl (`bindgen` + `pkg-config`). Every one of these is a typed `media::MediaError::UnsupportedFormat` or a recorded per-format deferral, never a silent gap. **Not** on the wasm32 sealing surface: `media` is absent from the `--no-default-features` build, so `cargo tree --target wasm32-unknown-unknown -i rawshift-image` is empty. Rawshift must never wrap Chromahash (`AGENTS.md`); see the LQIP row below. | | LQIP placeholder codec | `chromahash` **0.7.1** | `capsule-core::lqip` (slice `S-B14`) — the only encoder/decoder for the signed sidecar `lqip` field. Imported **directly**, never through Rawshift (`AGENTS.md`), and deliberately outside `capsule-core::media` — the Rawshift-consuming module — so one implementation serves the import pipeline, the uniffi FFI, and `capsule-wasm`. The tier, byte width and versioned fallback are the contract at [Thumbnails — LQIP](/design/thumbnails/#lqip); this row owns only the pin. The `AGENTS.md` gate that read "after its v1 release" is **amended to 0.7.1** — the release the project accepts as ready — and `xtask`'s architecture check stopped forbidding the crate in `2f8beeb`, because a check that forbids an approved dependency has stopped describing a decision and started blocking one. | **`thumbhash` is retired, not excepted.** The Rust crate behind `capsule-core`'s `media` feature and the npm package in `capsule-web` both go; `thumbhash` stays in the architecture check's retired-dependency list so it cannot return. BlurHash was never adopted. | | Free-space probe | `rustix` (Unix, `fs`) + `windows-sys` (Windows, `Win32_Storage_FileSystem`) | `capsule-core::library::available_bytes` — the streaming-import free-space probe (`statvfs` / `GetDiskFreeSpaceEx`). Host-only, behind the `native` feature; the wasm32 sealing build links neither. | — | | Windows TPM (TBS) | `windows-sys` (Windows, `Win32_System_TpmBaseServices`) | `capsule-core::crypto::keys::tbs` — the Windows device-key `HardwareSigner` (slice S-F4). The raw TPM 2.0 command channel (`Tbsi_Context_Create` / `Tbsip_Submit_Command`) the tss-esapi reference (`crypto::keys::tpm`, Linux) wraps; links `tbs.dll` via raw-dylib, so no new crate — an extra feature on the existing `windows-sys` row. `#[cfg(windows)]`-gated; the pure wire codec + mock tests run on any host. | Not tss-esapi on Windows: TBS is native and avoids the `libtss2`/bindgen build. | diff --git a/capsule-docs/src/content/docs/design/thumbnails.md b/capsule-docs/src/content/docs/design/thumbnails.md index ebd0f059..bc8c9be2 100644 --- a/capsule-docs/src/content/docs/design/thumbnails.md +++ b/capsule-docs/src/content/docs/design/thumbnails.md @@ -32,16 +32,22 @@ Two derivative tiers per photo asset and one preview tier for video assets: :::note[Implementation status — what ships today] The table above is the **contract**, not an inventory of what is built. As of `#410`, `capsule-core::media` (on `rawshift-image` 0.1.1, behind the `media` feature that `native` -implies) generates the **thumbnail tier as WebP at q=50** and nothing else. Concretely: +implies) generates the **thumbnail tier as JXL** and nothing else. Concretely: | Tier | Photo formats generated | Missing, and why | | --- | --- | --- | -| Thumbnail | **WebP** q=50, 256 px long edge; or the `original` sentinel when the source is already inside the cap | **JXL** needs C libjxl for a lossy encode — the pure-Rust backend is `zune-jpegxl`'s *lossless* simple encoder. **AVIF** needs `nasm` on every x86_64 build host (`ravif` → `rav1e/asm`). | -| Preview | — | Blocked with the master codec: a source-resolution *lossless* still would rival the original in size, so the tier is only worth its bytes once a lossy master is available. | +| Thumbnail | **JXL**, 256 px long edge, **lossless**; or the `original` sentinel when the source is already inside the cap | **AVIF** needs `nasm` on every x86_64 build host (`ravif` → `rav1e/asm`). **WebP** does not compile: `rawshift-image`'s codec passes `*const i8` where `libwebp-sys` declares `*const c_char`, which is `u8` on aarch64 — every mobile target. | +| Preview | — | Blocked with a *lossy* master codec: a source-resolution lossless still would rival the original in size. | | Video (either tier) | — | `rawshift-video` is unpublished; slice `S-B5`. | -Decode is JPEG, PNG, JXL, TIFF, GIF and WebP. **HEIC, AVIF and the RAW families are recognised -and refused**, because their backends need system libheif / libdav1d. +The thumbnail's declared **q=50 is advisory today**: the pure-Rust backend is `zune-jpegxl`'s +`JxlSimpleEncoder`, which is lossless, so a thumbnail costs more bytes than the table intends. A +lossy JXL needs C libjxl. That is the one place the build knowingly departs from this table, and +it is asserted by a test rather than left to be discovered. + +Decode is JPEG, PNG, JXL, TIFF and GIF. **HEIC, AVIF, WebP and the RAW families are recognised +and refused** — HEIC and AVIF need system libheif / libdav1d, and WebP shares the aarch64 defect +above in both directions (the crate compiles that module for decode *or* encode). None of this is silent. A format with no codec is a typed `media::MediaError::UnsupportedFormat { format, op }`, and a `(tier, format)` pair with no encoder From bc32e8f177eee04880f685de446d61327b7fe196 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 00:54:44 -0400 Subject: [PATCH 068/243] docs(reference): generate the CLI reference from the committed command tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/reference/` held one page saying nothing was published there yet. It now publishes the command line, generated from `capsule-cli/cli-surface.json` by a bun prebuild step (slice `S-Z8`). `scripts/gen-reference.mjs` runs on `node:` builtins alone and adds no dependency, which is what lets it run in the docs job as it stands — bun and nothing else, no cargo. It writes ordinary content-collection entries, so Pagefind indexes them, the link validator checks their anchors, the `PageTitle` override renders their badge, and the notranslate pass marks up their terms. An embedded renderer that mounted its own application would have forfeited all four. The pages are gitignored: a committed copy of generated output is a second source of truth that can disagree with the artifact it came from, and rule 2 of `design/developer-docs.md` exists so it cannot. Hand-written prose stays in one overview per surface, `reference/cli.md` beside the generated directory. `scripts/reference-groups.mjs` is the ordered page table. `astro.config.mjs` builds the `Reference` sidebar from it and the generator decides which pages exist from it, so the sidebar stays hand-curated as `developer-docs.md` requires while a page with no navigation entry stops being expressible. A missing, unparseable, or unknown-schema artifact fails the build naming the path. It never emits a stub: an empty reference page is the confidently-wrong case the design doc puts above a missing one. Headings inside artifact prose are demoted rather than interpolated — an operation description opening at `#` would otherwise inject a second h1 into a page whose h1 is the Starlight title. The `docs` path filter now names every artifact the build reads, not just the site, so a change to a described surface cannot publish a stale page. The docs-truth walk skips the generated directories for the mirror-image reason `rawshift/` is skipped: they exist on a machine that has built the site and on no CI runner, and a check that read them would answer differently in the two places. --- .github/workflows/ci.yml | 8 + .markdownlint-cli2.jsonc | 10 + capsule-docs/.gitignore | 8 + capsule-docs/astro.config.mjs | 8 +- capsule-docs/package.json | 6 +- capsule-docs/scripts/docs-truth.mjs | 5 + capsule-docs/scripts/gen-reference.mjs | 398 ++++++++++++++++++ capsule-docs/scripts/gen-reference.test.mjs | 259 ++++++++++++ capsule-docs/scripts/lib/walk.mjs | 28 +- capsule-docs/scripts/reference-groups.mjs | 69 +++ .../src/content/docs/reference/cli.md | 62 +++ .../src/content/docs/reference/index.md | 42 +- 12 files changed, 888 insertions(+), 15 deletions(-) create mode 100644 capsule-docs/scripts/gen-reference.mjs create mode 100644 capsule-docs/scripts/gen-reference.test.mjs create mode 100644 capsule-docs/scripts/reference-groups.mjs create mode 100644 capsule-docs/src/content/docs/reference/cli.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef44697e..005a6b0c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,8 +63,15 @@ jobs: - 'mise.toml' - 'mise-tasks/**' - '.github/workflows/ci.yml' + # Every artifact the docs build reads, not just `capsule-docs/**` — the + # generator turns two committed description artifacts into the `/reference/` + # pages, so a filter that names only the site lets a stale reference page + # publish on a change to the surface it describes. See + # design/developer-docs.md, "Artifacts cross the boundary, not toolchains". docs: - 'capsule-docs/**' + - 'capsule-cli/cli-surface.json' + - 'capsule-server/openapi.json' - 'mise.toml' - 'mise-tasks/**' - '.github/workflows/ci.yml' @@ -81,6 +88,7 @@ jobs: - 'capsule-docs/endpoint-census-allowlist.txt' - 'capsule-docs/planned-modules.txt' - 'capsule-server/openapi.json' + - 'capsule-cli/cli-surface.json' - 'capsule-*/src/**' - '**/*.md' - '**/*.mdx' diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index d8c96d94..9e285c96 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -26,6 +26,16 @@ // submodules, so without this the gate passes there and fails on any dev machine // that has run `git submodule update`. "rawshift/**", + // Generated reference pages (capsule-docs/scripts/gen-reference.mjs). Gitignored + // build output whose formatting comes from the generator, not from a contributor: + // a finding here is fixed in the generator, and the file it points at may not + // exist on the machine reading the report. + "capsule-docs/src/content/docs/reference/cli/**", + "capsule-docs/src/content/docs/reference/api/**", + // The same two directories reached through the repo-root `docs` symlink, which + // this glob walker follows (unlike the docs-truth walk, which skips symlinks). + "docs/reference/cli/**", + "docs/reference/api/**", "CLAUDE.md", // symlink to AGENTS.md "CHANGELOG.md" // generated by convco; not hand-formatted ] diff --git a/capsule-docs/.gitignore b/capsule-docs/.gitignore index 6240da8b..dfc3d2ef 100644 --- a/capsule-docs/.gitignore +++ b/capsule-docs/.gitignore @@ -1,5 +1,13 @@ # build output dist/ +# generated reference pages (capsule-docs/scripts/gen-reference.mjs) +# +# Rule 2 of design/developer-docs.md: reference pages are generated, never written. A +# committed copy is a second source of truth that can disagree with the artifact it came +# from, and the whole point is that it cannot. The hand-written overview for each surface +# is its sibling file (reference/cli.md), not a file inside these directories. +src/content/docs/reference/cli/ +src/content/docs/reference/api/ # generated types .astro/ diff --git a/capsule-docs/astro.config.mjs b/capsule-docs/astro.config.mjs index 5188a74f..53922189 100644 --- a/capsule-docs/astro.config.mjs +++ b/capsule-docs/astro.config.mjs @@ -3,6 +3,7 @@ import tailwindcss from '@tailwindcss/vite'; // @ts-check import { defineConfig } from 'astro/config'; import starlightLinksValidator from 'starlight-links-validator'; +import { referenceSidebar } from './scripts/reference-groups.mjs'; import rehypeNoTranslate from './src/lib/rehype-notranslate.mjs'; // https://astro.build/config @@ -149,8 +150,13 @@ export default defineConfig({ { // Hand-curated, not autogenerated: generated reference pages must not // determine navigation order. See design/developer-docs.md. + // + // Curated in `scripts/reference-groups.mjs` rather than inline, because + // the generator reads the same list to decide which pages exist. One + // edit moves a page and its navigation entry together, and a group + // with navigation but no page is not expressible. label: 'Reference', - items: [{ slug: 'reference' }], + items: referenceSidebar(), }, ], customCss: ['./src/styles/global.css'], diff --git a/capsule-docs/package.json b/capsule-docs/package.json index 1e21abc3..d701943f 100644 --- a/capsule-docs/package.json +++ b/capsule-docs/package.json @@ -4,9 +4,9 @@ "version": "0.1.0", "license": "AGPL-3.0-only", "scripts": { - "dev": "astro dev", - "start": "astro dev", - "build": "astro build", + "dev": "bun scripts/gen-reference.mjs && astro dev", + "start": "bun scripts/gen-reference.mjs && astro dev", + "build": "bun scripts/gen-reference.mjs && astro build", "preview": "wrangler pages dev ./dist", "astro": "astro", "deploy": "wrangler pages deploy ./dist", diff --git a/capsule-docs/scripts/docs-truth.mjs b/capsule-docs/scripts/docs-truth.mjs index 7e15a592..5f23aeea 100644 --- a/capsule-docs/scripts/docs-truth.mjs +++ b/capsule-docs/scripts/docs-truth.mjs @@ -24,6 +24,11 @@ * toolchain: that rule governs regenerating an artifact, and these checks * cross-reference committed text against committed text. * + * That last claim is what excludes the generated `/reference/` pages, which + * `scripts/lib/walk.mjs` prunes by path: they are gitignored build output, so + * they exist on a machine that has built the site and on no CI runner, and a + * check that read them would answer differently in the two places. + * * Usage: `bun capsule-docs/scripts/docs-truth.mjs` from the repository root, * or `mise run check-docs-truth`. */ diff --git a/capsule-docs/scripts/gen-reference.mjs b/capsule-docs/scripts/gen-reference.mjs new file mode 100644 index 00000000..60119efc --- /dev/null +++ b/capsule-docs/scripts/gen-reference.mjs @@ -0,0 +1,398 @@ +#!/usr/bin/env node + +/** + * gen-reference — emit `/reference/` from the committed description artifacts. + * + * `design/developer-docs.md` fixes the boundary at *artifacts, not toolchains*: the CI + * `docs` job installs bun and nothing else, so this script may never shell out to cargo. It + * reads committed JSON and writes Markdown, which is the whole of its contract. + * + * The pages it writes are ordinary content-collection entries under + * `src/content/docs/reference/`, which is what buys the rest for free — Pagefind indexes + * them, `starlight-links-validator` checks their links, the `PageTitle` override renders + * their status badge, and the notranslate rehype pass marks up their technical terms. A + * mounted OpenAPI application would have had none of that. + * + * They are also **gitignored**, and that is the point of rule 2: a generated page is never + * edited, so committing one only creates a copy that can disagree with its source. Fix the + * clap `about` or the schema description and regenerate. + * + * Three failure modes are deliberately fatal rather than degraded, because + * `developer-docs.md` calls a stale reference page worse than a missing one — a missing page + * is obvious and a wrong one is believed: + * + * 1. an artifact is absent or unparseable — exit naming the path; + * 2. an artifact's `schema` is one this script was not written against — exit rather than + * render a half-understood document; + * 3. an operation matches no group in `reference-groups.mjs` — exit naming it, so a new + * endpoint family cannot publish unlisted. + * + * Usage: `bun capsule-docs/scripts/gen-reference.mjs` from anywhere; `package.json` runs it + * before `astro dev` and `astro build`. + */ + +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { CLI_PAGES } from './reference-groups.mjs'; + +/** Repo-relative path of the committed command-tree artifact. */ +export const CLI_SURFACE = 'capsule-cli/cli-surface.json'; + +/** Command-tree schema version this script understands. */ +const CLI_SCHEMA = 1; + +/** Repo-relative directory the generated CLI pages are written to. */ +const CLI_OUT = 'capsule-docs/src/content/docs/reference/cli'; + +/** The banner every generated page carries, as an HTML comment and as prose. */ +const GENERATED_BY = 'capsule-docs/scripts/gen-reference.mjs'; + +/** + * Shift every ATX heading in `markdown` down by `offset` levels, clamped at h6. + * + * Description prose in both artifacts is written for its own context and carries its own + * headings — `openapi.json` has operation descriptions opening at `#`. Interpolated + * unchanged, such a heading becomes a second h1 on a page whose h1 is the Starlight title, + * which breaks the document outline and the on-page table of contents. + * + * Fenced blocks are skipped: a `#` on the first column of a shell example is a comment, not + * a heading, and demoting it would corrupt the example. + * + * @param {string} markdown Prose that may contain headings. + * @param {number} offset Levels to add. + * @returns {string} The prose with its headings demoted. + */ +export function demoteHeadings(markdown, offset) { + let fence = null; + return markdown + .split('\n') + .map((line) => { + const fenceMatch = /^\s*(`{3,}|~{3,})/.exec(line); + if (fence === null) { + if (fenceMatch) { + fence = { + char: fenceMatch[1][0], + length: fenceMatch[1].length, + }; + return line; + } + } else { + if ( + fenceMatch && + fenceMatch[1][0] === fence.char && + fenceMatch[1].length >= fence.length + ) { + fence = null; + } + return line; + } + const heading = /^(#{1,6})(\s)/.exec(line); + if (!heading) return line; + const level = Math.min(6, heading[1].length + offset); + return '#'.repeat(level) + line.slice(heading[1].length); + }) + .join('\n'); +} + +/** + * Escape a string for a Markdown table cell: a literal `|` would otherwise open a new + * column, and a newline would end the row. + * + * @param {string} text + * @returns {string} + */ +function cell(text) { + return text + .replace(/\s*\n\s*/g, ' ') + .replace(/\|/g, '\\|') + .trim(); +} + +/** + * Escape a YAML double-quoted scalar, for frontmatter values that carry arbitrary prose. + * + * @param {string} text + * @returns {string} + */ +function yamlString(text) { + return `"${text + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\s*\n\s*/g, ' ') + .trim()}"`; +} + +/** + * Read and validate a JSON artifact. + * + * @param {string} root Repository root. + * @param {string} relPath Repo-relative artifact path. + * @returns {unknown} + */ +function readArtifact(root, relPath) { + let raw; + try { + raw = readFileSync(join(root, relPath), 'utf8'); + } catch (cause) { + throw new Error( + `cannot read the description artifact ${relPath}: ${cause.message}. ` + + 'Run its emitter (`mise run cli-surface`, `mise run openapi-kynos`) and commit the result.', + { cause }, + ); + } + try { + return JSON.parse(raw); + } catch (cause) { + throw new Error(`${relPath} is not valid JSON: ${cause.message}`, { + cause, + }); + } +} + +/** + * The committed `capsule` command tree. + * + * @param {string} root Repository root. + * @returns {Record} + */ +export function readCliSurface(root) { + const surface = readArtifact(root, CLI_SURFACE); + if (surface?.schema !== CLI_SCHEMA) { + throw new Error( + `${CLI_SURFACE} declares schema ${surface?.schema}, and this generator was ` + + `written against schema ${CLI_SCHEMA}. Update ${GENERATED_BY} rather than ` + + 'rendering a document it does not understand.', + ); + } + return surface; +} + +/** + * How an argument is spelled on the command line. + * + * A positional is written as its placeholder, an option by its flags. Only a value-taking + * option gets a placeholder — the description artifact suppresses clap's synthesized one + * for flags, and inventing `--force ` here would document a surface that rejects it. + * + * @param {Record} arg + * @returns {string} + */ +function spell(arg) { + const placeholder = `<${(arg.value_names ?? [arg.id.toUpperCase()]).join('> <')}>`; + if (arg.positional) { + return `${placeholder}${arg.repeatable ? '...' : ''}`; + } + const flags = []; + if (arg.short) flags.push(`-${arg.short}`); + if (arg.long) flags.push(`--${arg.long}`); + const spelled = flags.length > 0 ? flags.join(', ') : arg.id; + return arg.takes_value ? `${spelled} ${placeholder}` : spelled; +} + +/** + * Terminate a sentence that does not terminate itself. + * + * `clap` strips the full stop off a doc comment, so help text arrives unpunctuated and the + * facts appended after it ("Repeatable.", "Values: …") would run straight on from the last + * word of the description. + * + * @param {string} text + * @returns {string} + */ +function sentence(text) { + return /[.!?:]$/.test(text) ? text : `${text}.`; +} + +/** + * One argument's description cell: whether it is required and repeatable, what it does, + * what it accepts, and what it defaults to. + * + * @param {Record} arg + * @returns {string} + */ +function describeArg(arg) { + const parts = []; + if (arg.required) parts.push('**Required.**'); + const help = arg.long_help ?? arg.help; + if (help) parts.push(sentence(cell(help))); + if (arg.repeatable) parts.push('Repeatable.'); + if (arg.possible_values?.length) { + parts.push( + `Values: ${arg.possible_values.map((value) => `\`${value.name}\``).join(', ')}.`, + ); + } + if (arg.default_values?.length) { + parts.push( + `Default: ${arg.default_values.map((value) => `\`${value}\``).join(', ')}.`, + ); + } + return parts.join(' ') || '—'; +} + +/** + * A Markdown table, or the empty string when there are no rows — an empty table renders as + * a stray header and says nothing. + * + * @param {string[]} headers + * @param {string[][]} rows + * @returns {string} + */ +function table(headers, rows) { + if (rows.length === 0) return ''; + const lines = [ + `| ${headers.join(' | ')} |`, + `| ${headers.map(() => '---').join(' | ')} |`, + ...rows.map((row) => `| ${row.join(' | ')} |`), + ]; + return `${lines.join('\n')}\n`; +} + +/** + * Render one command and, recursively, its subcommands. + * + * @param {Record} command + * @param {string[]} path Command words leading here, including this command's own name. + * @param {number} level Heading level for this command (2 under the Starlight title). + * @returns {string} + */ +function renderCommand(command, path, level) { + const invocation = path.join(' '); + const args = command.args ?? []; + const positionals = args.filter((arg) => arg.positional); + const options = args.filter((arg) => !arg.positional); + const subcommands = command.subcommands ?? []; + + const usage = [invocation]; + for (const arg of positionals) { + const spelled = spell(arg); + usage.push(arg.required ? spelled : `[${spelled}]`); + } + if (options.length > 0) usage.push('[OPTIONS]'); + if (subcommands.length > 0) usage.push(''); + + const sections = [ + `${'#'.repeat(level)} ${invocation}`, + '', + `\`\`\`text\n${usage.join(' ')}\n\`\`\``, + '', + ]; + + // Demoted relative to this command's own heading, so a doc comment that opens at `#` + // nests under the command it describes instead of outranking it. + const prose = command.long_about ?? command.about; + if (prose) { + sections.push(demoteHeadings(prose, level), ''); + } + + const positionalTable = table( + ['Argument', 'Description'], + positionals.map((arg) => [`\`${spell(arg)}\``, describeArg(arg)]), + ); + if (positionalTable) sections.push(positionalTable); + + const optionTable = table( + ['Option', 'Description'], + options.map((arg) => [`\`${spell(arg)}\``, describeArg(arg)]), + ); + if (optionTable) sections.push(optionTable); + + if (subcommands.length > 0) { + sections.push( + table( + ['Command', 'Description'], + subcommands.map((subcommand) => [ + `[\`${[...path, subcommand.name].join(' ')}\`](#${[...path, subcommand.name].join('-')})`, + subcommand.about ? cell(subcommand.about) : '—', + ]), + ), + ); + } + + return [ + sections.filter((section) => section !== '').join('\n\n'), + ...subcommands.map((subcommand) => + renderCommand( + subcommand, + [...path, subcommand.name], + Math.min(6, level + 1), + ), + ), + ].join('\n\n'); +} + +/** + * The generated `/reference/cli/commands/` page. + * + * @param {Record} surface The parsed command tree. + * @returns {string} Markdown, frontmatter included. + */ +export function renderCliPage(surface) { + const page = CLI_PAGES[0]; + return `${[ + '---', + `title: ${page.label}`, + `description: ${yamlString(page.description)}`, + 'status: stable', + '---', + '', + ``, + '', + `Generated from \`${CLI_SURFACE}\`, the committed command tree \`capsule-cli\` emits and`, + '`mise run cli-surface-check` keeps current. To change a description on this page, change', + 'the `clap` annotation it comes from and regenerate — this file is build output.', + '', + renderCommand(surface, [surface.name], 2), + ] + .join('\n') + // Section assembly can leave a run of blank lines where a table ended one block and + // a heading opened the next, and a trailing one at the end of the file. Markdown + // does not care; a reader diffing two generations does, and so does markdownlint + // on any machine that has built the site. + .replace(/\n{3,}/g, '\n\n') + .trimEnd()}\n`; +} + +/** + * Emit every generated reference page under `root`. + * + * Artifacts are read and validated first, before anything is written, so a missing or + * unparseable one leaves no half-written section behind. + * + * @param {string} root Repository root. + * @returns {string[]} Repo-relative paths written, in a stable order. + */ +export function generate(root) { + const surface = readCliSurface(root); + + /** @type {Array<{ path: string, body: string }>} */ + const pages = [ + { + path: `${CLI_OUT}/${CLI_PAGES[0].slug}.md`, + body: renderCliPage(surface), + }, + ]; + + for (const { path, body } of pages) { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), body); + } + return pages.map(({ path }) => path); +} + +function main() { + // scripts/ -> capsule-docs/ -> repo root + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); + const written = generate(root); + process.stdout.write( + `gen-reference: wrote ${written.length} page(s)\n${written.map((path) => ` ${path}`).join('\n')}\n`, + ); +} + +// Run only when invoked as a script, so the test file can import the renderers. +if ( + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + main(); +} diff --git a/capsule-docs/scripts/gen-reference.test.mjs b/capsule-docs/scripts/gen-reference.test.mjs new file mode 100644 index 00000000..c2942f9e --- /dev/null +++ b/capsule-docs/scripts/gen-reference.test.mjs @@ -0,0 +1,259 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + CLI_SURFACE, + demoteHeadings, + generate, + readCliSurface, + renderCliPage, +} from './gen-reference.mjs'; + +/** + * A repo-shaped temporary root: the two description artifacts at the paths the generator + * reads, and the content directory it writes into. + * + * Fixtures rather than the committed artifacts wherever the assertion is about the + * *generator*. The committed ones are used only where the assertion is about this + * repository — that every operation it declares reaches a page. + */ +function fixtureRoot() { + const root = mkdtempSync(join(tmpdir(), 'gen-reference-')); + mkdirSync(join(root, 'capsule-cli'), { recursive: true }); + mkdirSync(join(root, 'capsule-docs/src/content/docs/reference'), { + recursive: true, + }); + return root; +} + +const MINIMAL_CLI = { + schema: 1, + name: 'capsule', + about: 'A command line interface for Capsule', + subcommands: [ + { + name: 'import', + about: 'Import files into a local Capsule library', + args: [ + { + id: 'paths', + positional: true, + required: true, + repeatable: true, + takes_value: true, + value_names: ['PATH'], + help: 'Source file or directory to import', + }, + { + id: 'provider', + long: 'provider', + positional: false, + required: false, + repeatable: false, + takes_value: true, + value_names: ['PROVIDER'], + possible_values: [ + { name: 'takeout', help: 'A Takeout export' }, + ], + help: 'Read the source as an export from this service', + }, + { + id: 'force', + long: 'force', + short: 'f', + positional: false, + required: false, + repeatable: false, + takes_value: false, + help: 'Re-import files even if they already exist', + }, + ], + }, + ], +}; + +let root; + +beforeEach(() => { + root = fixtureRoot(); +}); + +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +function writeCli(surface) { + writeFileSync( + join(root, CLI_SURFACE), + `${JSON.stringify(surface, null, 2)}\n`, + ); +} + +describe('demoteHeadings', () => { + // `openapi.json` really does carry `# Two statuses, because there are two outcomes` + // inside an operation description. Rendered as-is it injects a second H1 into a page + // whose H1 is the Starlight title, and breaks the document outline for a screen reader. + it('demotes an ATX heading by the offset', () => { + expect(demoteHeadings('# Why it signs you in\n', 3)).toBe( + '#### Why it signs you in\n', + ); + expect(demoteHeadings('## Second\n', 3)).toBe('##### Second\n'); + }); + + it('clamps at h6 rather than emitting a run of seven hashes', () => { + expect(demoteHeadings('##### Deep\n', 3)).toBe('###### Deep\n'); + }); + + it('leaves prose and a hash that is not a heading alone', () => { + expect(demoteHeadings('a #tag and #hash\n', 2)).toBe( + 'a #tag and #hash\n', + ); + expect(demoteHeadings('body text\n', 2)).toBe('body text\n'); + }); + + it('does not demote a hash inside a fenced block', () => { + const source = ['```sh', '# not a heading', '```', '# heading'].join( + '\n', + ); + expect(demoteHeadings(source, 2)).toBe( + ['```sh', '# not a heading', '```', '### heading'].join('\n'), + ); + }); +}); + +describe('readCliSurface', () => { + it('names the missing artifact rather than emitting a stub page', () => { + expect(() => readCliSurface(root)).toThrow( + /capsule-cli\/cli-surface\.json/, + ); + }); + + // A stub would be the "confidently wrong" page `developer-docs.md` exists to prevent: + // it publishes, it looks like reference, and it documents nothing. + it('refuses a schema version it was not written against', () => { + writeCli({ ...MINIMAL_CLI, schema: 2 }); + expect(() => readCliSurface(root)).toThrow(/schema/i); + }); + + it('refuses an unparseable artifact', () => { + writeFileSync(join(root, CLI_SURFACE), '{ not json'); + expect(() => readCliSurface(root)).toThrow(/cli-surface\.json/); + }); +}); + +describe('renderCliPage', () => { + it('renders every command in the tree', () => { + const page = renderCliPage(MINIMAL_CLI); + expect(page).toContain('## capsule'); + expect(page).toContain('### capsule import'); + expect(page).toContain('Import files into a local Capsule library'); + }); + + it('opens with frontmatter carrying a status the schema accepts', () => { + const page = renderCliPage(MINIMAL_CLI); + expect(page.startsWith('---\n')).toBe(true); + expect(page).toMatch(/^status: stable$/m); + expect(page).toMatch(/^title: Commands$/m); + }); + + // A generated page is linted like any other on a machine that has built the site, and + // a run of blank lines is the shape section assembly leaves behind. + it('ends with exactly one newline and no run of blank lines', () => { + const page = renderCliPage(MINIMAL_CLI); + expect(page.endsWith('\n')).toBe(true); + expect(page.endsWith('\n\n')).toBe(false); + expect(page).not.toMatch(/\n{3,}/); + }); + + it('says it is generated, and by what', () => { + expect(renderCliPage(MINIMAL_CLI)).toContain('gen-reference.mjs'); + }); + + // The page body must not contain an h1: Starlight renders the frontmatter title as the + // page's only h1, and a second one breaks the outline. + it('emits no h1 in the body', () => { + const body = renderCliPage(MINIMAL_CLI) + .split('\n---\n') + .slice(1) + .join('\n---\n'); + expect(body.split('\n').filter((l) => /^# /.test(l))).toEqual([]); + }); + + it('spells a positional, a value-taking option, and a flag differently', () => { + const page = renderCliPage(MINIMAL_CLI); + expect(page).toContain('`...`'); + expect(page).toContain('`--provider `'); + expect(page).toContain('`-f, --force`'); + }); + + it('renders the usage line from the argument surface', () => { + expect(renderCliPage(MINIMAL_CLI)).toContain( + 'capsule import ... [OPTIONS]', + ); + }); + + it('lists an enumerated option value', () => { + expect(renderCliPage(MINIMAL_CLI)).toContain('`takeout`'); + }); + + // `clap` strips the full stop off a doc comment, so without this the appended facts run + // straight on: "…folded into the imported assets Values: `takeout`." + it('terminates help text before appending the facts after it', () => { + const page = renderCliPage(MINIMAL_CLI); + expect(page).toContain( + 'Read the source as an export from this service. Values: `takeout`.', + ); + expect(page).toContain( + '**Required.** Source file or directory to import. Repeatable.', + ); + }); + + it('escapes a pipe so it cannot break out of a table cell', () => { + const page = renderCliPage({ + schema: 1, + name: 'capsule', + subcommands: [ + { + name: 'x', + args: [ + { + id: 'p', + long: 'p', + positional: false, + required: false, + repeatable: false, + takes_value: false, + help: 'reads a | b', + }, + ], + }, + ], + }); + expect(page).toContain('reads a \\| b'); + }); +}); + +describe('generate', () => { + it('is deterministic: two runs produce byte-identical pages', () => { + writeCli(MINIMAL_CLI); + const first = generate(root).map((path) => + readFileSync(join(root, path), 'utf8'), + ); + const second = generate(root).map((path) => + readFileSync(join(root, path), 'utf8'), + ); + expect(second).toEqual(first); + expect(first.length).toBeGreaterThan(0); + }); + + it('fails, writing nothing, when an artifact is missing', () => { + expect(() => generate(root)).toThrow(/cli-surface\.json/); + }); +}); diff --git a/capsule-docs/scripts/lib/walk.mjs b/capsule-docs/scripts/lib/walk.mjs index be17464a..f5cf627f 100644 --- a/capsule-docs/scripts/lib/walk.mjs +++ b/capsule-docs/scripts/lib/walk.mjs @@ -12,6 +12,16 @@ * not check it out. A walk that descends into it passes on the runner and * fails on any machine that has run `git submodule update` — the same trap * `.markdownlint-cli2.jsonc` documents for its own ignore list. + * + * 3. The generated `/reference/` pages are the same trap in the other + * direction: gitignored build output that exists on any machine that has + * run `mise run build-docs` and on no CI runner, sitting *inside* the + * content tree every check scopes itself to. Walking them makes a check's + * verdict depend on whether the site happens to be built, which is exactly + * the property `docs-truth.mjs` claims not to have when it states its scope + * as committed text against committed text. They are pruned by path rather + * than by basename because `cli/` and `api/` are ordinary directory names + * that other trees are entitled to use. */ import { readdirSync } from 'node:fs'; @@ -30,6 +40,16 @@ const SKIP_DIRS = new Set([ 'target', ]); +/** + * Directory subtrees never descended into, matched on the repo-relative path. + * + * Build output that lands inside a scanned tree, so a basename rule cannot express it. + */ +const SKIP_PREFIXES = [ + 'capsule-docs/src/content/docs/reference/cli/', + 'capsule-docs/src/content/docs/reference/api/', +]; + /** * Yield repo-relative paths of every file under `root` whose name matches * `predicate`, depth-first, with `SKIP_DIRS` pruned and symlinks skipped. @@ -47,7 +67,13 @@ export function walkFiles(root, predicate) { // `isDirectory()`/`isFile()` are false for a symlink, which is how // the `docs` symlink is dropped without a special case for it. if (entry.isDirectory()) { - if (!SKIP_DIRS.has(entry.name)) visit(abs); + const relDir = `${relative(root, abs).split(sep).join('/')}/`; + if ( + !SKIP_DIRS.has(entry.name) && + !SKIP_PREFIXES.includes(relDir) + ) { + visit(abs); + } continue; } if (!entry.isFile()) continue; diff --git a/capsule-docs/scripts/reference-groups.mjs b/capsule-docs/scripts/reference-groups.mjs new file mode 100644 index 00000000..07927985 --- /dev/null +++ b/capsule-docs/scripts/reference-groups.mjs @@ -0,0 +1,69 @@ +/** + * The `/reference/` page table — the one place the reference section's shape is decided. + * + * `design/developer-docs.md` requires the `Reference` sidebar to be hand-curated, "in the + * same style as `Design` and for the same reason: generated pages must not be allowed to + * determine navigation order". Autogenerating it from the emitted directory would order + * pages by filename, which is a fact about slugs rather than a decision about reading + * order. + * + * Hand-curated does not have to mean written twice. Both `gen-reference.mjs` and + * `astro.config.mjs` import this file: the generator buckets the description artifacts into + * these pages, the config builds the sidebar from the same list. Editing the order here + * moves the page and its navigation entry together, and a group that has navigation but no + * page — or a page nothing links to — is not expressible. + * + * This module is deliberately data plus two pure functions, with no `node:` imports, so the + * Astro config can import it in the browser-facing build without dragging filesystem code + * along. + */ + +/** + * A generated page. + * + * @typedef {object} ReferencePage + * @property {string} slug Last path segment of the route, and the emitted file's basename. + * @property {string} label Sidebar label and page title. + * @property {string} description Frontmatter description, shown in search results. + */ + +/** + * The CLI pages, in reading order. + * + * One page rather than one per command: `capsule` has 16 commands whose help is a sentence + * each, and sixteen pages of one paragraph would put the whole surface behind sixteen + * clicks. The command tree is small enough to read end to end. + * + * @type {ReferencePage[]} + */ +export const CLI_PAGES = [ + { + slug: 'commands', + label: 'Commands', + description: + 'Every capsule command, argument, and option, generated from the committed command tree.', + }, +]; + +/** + * Sidebar items for the `Reference` group, in the order they are read. + * + * Returns Starlight sidebar entries: the section overview first, then one nested group per + * surface whose own overview leads its generated pages. + * + * @returns {Array<{ slug: string } | { label: string, items: Array<{ slug: string }> }>} + */ +export function referenceSidebar() { + return [ + { slug: 'reference' }, + { + label: 'CLI', + items: [ + { slug: 'reference/cli' }, + ...CLI_PAGES.map((page) => ({ + slug: `reference/cli/${page.slug}`, + })), + ], + }, + ]; +} diff --git a/capsule-docs/src/content/docs/reference/cli.md b/capsule-docs/src/content/docs/reference/cli.md new file mode 100644 index 00000000..bab41b44 --- /dev/null +++ b/capsule-docs/src/content/docs/reference/cli.md @@ -0,0 +1,62 @@ +--- +title: CLI +description: What the capsule command line is for, how to install it, and where its contract lives +status: draft +--- + +`capsule` is the command line for a Capsule library. It is the only client that performs the +whole data plane end to end — it scans and imports files, seals them locally, opens upload +sessions, drains the sync feed, and rebuilds an index from what is on disk — which is why the +examples in this documentation are commands rather than HTTP requests. The server never sees a +key, so a request transcript would show ciphertext going in and ciphertext coming out; a +transcript of `capsule` shows the operation. + +This page is the hand-written half of the CLI reference. [Commands](/reference/cli/commands/) is +the generated half: every command, argument, and option, emitted from the same `clap` definitions +the binary parses with. + +## Install + +Release builds are published as an archive per target on the +[releases page](https://github.com/justin13888/Capsule/releases), each carrying a single +`capsule` executable. From a checkout, `cargo run -p capsule-cli --` runs the same binary +against the working tree. + +## The two things it holds + +A `capsule` invocation reads at most two pieces of durable state, and it helps to know which: + +- **A library** — a directory named by `--library`, holding the encrypted assets, their sidecar + metadata, and a SQLite index that can be rebuilt from the sidecars alone + (`capsule library rebuild`). Every offline command operates on one. +- **A session** — the token pair `capsule auth login` persists, owner-readable, under the + user's configuration directory. Every networked command reads it, and `capsule reset` removes + it. + +A library is opened with a passphrase. Each command that opens one accepts +`--passphrase-stdin`, so nothing in this reference requires a terminal. + +## Where the contract lives + +- The behaviour of the import pipeline is [Import Pipeline](/design/import/pipeline/); what + `capsule push` speaks is the [Upload Protocol](/design/import/upload-protocol/), and what + `capsule sync` drains is [Download & Sync](/design/import/download-sync/). +- The server endpoints behind the networked commands are mapped in + [API Surfaces](/design/api-surfaces/#surface--transport-map). +- Terminal output is localized through the catalogs described in + [Internationalization](/design/i18n/). Help text is not yet: the command tree this reference + is generated from is English, deliberately and by pinning, so the artifact cannot vary with + the machine that emits it. + +## How the generated page stays true + +`capsule-cli` emits `capsule-cli/cli-surface.json` — a description of the command tree, read +straight from the `clap` definitions — and `mise run cli-surface-check` fails the Rust gate if +the committed copy disagrees with the code. The documentation build reads that file and +nothing else; it never runs cargo. So a new option cannot reach users without either appearing +on the generated page or failing CI. + +A generated page is never edited. If something on it is wrong, the annotation it came from is +wrong: fix the `clap` `about` or doc comment, run `mise run cli-surface`, and commit the +artifact. The pipeline and the reasoning behind it are [Developer +Documentation](/design/developer-docs/). diff --git a/capsule-docs/src/content/docs/reference/index.md b/capsule-docs/src/content/docs/reference/index.md index cff217da..d067fdfa 100644 --- a/capsule-docs/src/content/docs/reference/index.md +++ b/capsule-docs/src/content/docs/reference/index.md @@ -4,17 +4,39 @@ description: Generated reference for Capsule's developer surfaces status: draft --- -This section will hold generated reference for every Capsule developer surface — the REST contract, -the command line, the Rust SDK, the Swift and Kotlin bindings, and the browser surface. +Reference for every Capsule developer surface. Each section is generated from a **description +artifact**: a small, committed, machine-readable file that the surface's own toolchain emits and +its own gate keeps current. The documentation build reads those files and never invokes cargo, +uniffi, or wasm-bindgen — which is what keeps a reference page from disagreeing with the code it +describes. [Developer Documentation](/design/developer-docs/) is the contract; this page is the +index to it. -**Nothing is published here yet.** The pipeline that emits these pages is specified in -[Developer Documentation](/design/developer-docs/), which names each surface, the artifact it is -generated from, and the gate that proves that artifact current. Until a surface's emitter and drift -gate exist, its page is deliberately absent rather than hand-written and stale. +Reference pages are generated, never written. Every section's hand-written prose is confined to +its overview page — what the surface is for, how to reach it, where its contract lives. If a +generated page is wrong, the annotation in the source is wrong. -In the meantime: +## Published -- The REST surface-to-transport map is [API Surfaces](/design/api-surfaces/), and the contract rules - server code follows are [API Practices](/development/api-practices/). +| Surface | Overview | Generated from | Kept current by | +| --- | --- | --- | --- | +| Command line | [CLI](/reference/cli/) | `capsule-cli/cli-surface.json` | `mise run cli-surface-check` | + +## Not published yet + +These surfaces are named here rather than given an empty route, because a dead link is worse +than an honest absence. + +- **Rust SDK** and **workspace rustdoc.** The workspace is `publish = false`, so docs.rs will + never build it; rustdoc is built by the Rust gate and deployed beside this site rather than + committed. Planned as `/reference/sdk/rust/` and `/reference/crates/`. +- **Swift and Kotlin bindings.** The generated bindings are gitignored build output, so the + bun-only documentation build cannot read them; each needs a committed surface dump alongside + its existing generation step first. +- **Browser surface.** The same problem for `capsule_wasm.d.ts`, with the additional constraint + that its drift gate cannot run where the other Rust gates do. + +Until then: + +- The REST surface-to-transport map is [API Surfaces](/design/api-surfaces/), and the contract + rules server code follows are [API Practices](/development/api-practices/). - Code module to owning design doc is the [Module Map](/design/module-map/). -- `capsule --help` is the current source of truth for the command line. From a7d8602efe7e01676547e4218a192aff27d145d7 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 00:54:53 -0400 Subject: [PATCH 069/243] fix(core): arm alert timers per class, and defer rather than cancel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An adversarial read of the first two commits found four ways the pre-arm model lost an alert it had promised to deliver. All four share a root: an armed notification fires from the OS timer with the app not running, so anything the arm decision gets wrong is invisible until an alert simply fails to arrive. **One timer per class, not one globally.** `next_deadline` returned the minimum over both pre-armable classes, so a staleness deadline two weeks out and a recovery check ninety days out yielded one instant — and a client that armed it lost the recovery alert entirely on a device the app never ran on again. `pre_arm_deadlines` now returns the instant per class, which is also what a client needs to pick the catalog key for the notification it is arming. `next_deadline` remains as its minimum, documented as the single-timer convenience it is. **A snooze defers the timer; it no longer cancels it.** A class snoozed after it fired was dropped from the arm decision entirely, so the alert never returned unless the user opened the app — which for `sync_stale` is precisely the case the pre-arm rule exists for. The snooze end is itself a deadline the device can compute, so it is armed. **Disable is its own field.** Snooze and disable are different mechanics with opposite effects on the timer, so `NotifyInput.disabled` is a separate set rather than a far-future instant in the snooze map. A sentinel instant does not survive a string-typed FFI boundary: a client writing "the year 2999" would mean disabled and get a timer armed 975 years out. **A recovery snooze that ends before the due date no longer pulls the timer earlier**, which would have fired into no alert; the armed instant is the later of the two, and the alert reports that same instant rather than `next_due`. Also: `RecoveryFacts.rewrap_due` carries the guided-re-wrap escalation, so the alert for "you told us you lost your recovery secret" is no longer byte-identical to the routine ninety-day check — the class set is closed, so a parameter is the only way to distinguish them. The FFI stops parsing `recovery_snoozed_until` only when `recovery_next_due` happens to be present, since a validation that runs on one code path is the one that lets a typo through. And `quarantine_pending` is documented as excluding pending drops, which have their own class and were otherwise counted twice. --- SLICES.md | 14 +- capsule-core/src/notify/evaluate.rs | 366 +++++++++++++++--- capsule-core/src/notify/input.rs | 80 +++- capsule-core/src/notify/mod.rs | 17 +- .../src/content/docs/design/notifications.md | 8 +- capsule-sdk/src/ffi.rs | 4 +- capsule-sdk/src/ffi/notify.rs | 241 ++++++++++-- capsule-sdk/src/recovery/cadence.rs | 32 +- 8 files changed, 644 insertions(+), 118 deletions(-) diff --git a/SLICES.md b/SLICES.md index 56b32ead..cb6db8e9 100644 --- a/SLICES.md +++ b/SLICES.md @@ -5736,12 +5736,14 @@ table hides what it would cost. smoke fires a pre-armed alert with the app terminated; `i18n-guard` passes with the new namespace consumed. **Tier:** unit + smoke. - **Core half landed — verified 2026-09-01.** `capsule-core::notify` is the whole shared - decision function: `evaluate(&NotifyInput, now)` reports the classes true at an instant and - `next_deadline(&NotifyInput, now)` reports the one instant to arm, both pure with `now` as an - argument. `capsule-sdk::ffi` exports them as free functions (`evaluate_alerts`, - `next_alert_deadline`) with RFC 3339 timestamps, and `RecoveryCadence::notify_facts` projects - the S-D12 scheduler into the recovery half of the input. The module left - `planned-modules.txt`; `notifications.md` no longer calls it planned. + decision function: `evaluate(&NotifyInput, now)` reports the classes true at an instant, and + `pre_arm_deadlines(&NotifyInput, now)` reports the instant to arm **per class** — keyed per + class because the two pre-armable timers are independent, and collapsing them to one loses the + later alert on a device the app never runs on again. Both are pure with `now` as an argument. + `capsule-sdk::ffi` exports them as free functions (`evaluate_alerts`, `pre_arm_deadlines`, + and `next_alert_deadline` for a single-timer host) with RFC 3339 timestamps, and + `RecoveryCadence::notify_facts` projects the S-D12 scheduler into the recovery half of the + input. The module left `planned-modules.txt`; `notifications.md` no longer calls it planned. - **Why `ready` and not `done*`.** Every predicate input is caller-supplied, because the core holds none of the trigger state — no persisted last-sync instant, no client-side quota type, no quarantine table (a refused sync entry is a per-entry verdict, not a row). So the predicate diff --git a/capsule-core/src/notify/evaluate.rs b/capsule-core/src/notify/evaluate.rs index 1dff272f..28b47c6b 100644 --- a/capsule-core/src/notify/evaluate.rs +++ b/capsule-core/src/notify/evaluate.rs @@ -11,6 +11,8 @@ //! never sees them disagree by a second. Suppression is the mirror image and is exclusive //! (`until > now`), so a class snoozed to exactly `now` is due at `now`. +use std::collections::BTreeMap; + use jiff::Timestamp; use super::class::{Alert, AlertClass}; @@ -55,41 +57,73 @@ pub fn evaluate(input: &NotifyInput, now: Timestamp) -> Vec { alerts } -/// The next instant an OS timer should be armed for, or `None` when there is nothing to arm — -/// in which case the client cancels its timer, which is the cancel half of the +/// The instant an OS timer must be armed for, **per class** — the arm half of the /// arm / re-arm / cancel rule. /// +/// A class present in the map should have exactly one live timer, set to the returned instant. +/// A class *absent* from it should have no timer: cancel whatever it holds. Because the answer +/// is a pure function of state, the whole client-side protocol is "recompute after any state +/// change, then reconcile your timers against this map" — and one entry per class from one +/// function is why two live timers for one class is structurally impossible. +/// +/// It is keyed per class rather than collapsed to a single instant because the timers are +/// independent: with a staleness deadline two weeks out and a recovery check ninety days out, +/// arming only the earlier one loses the later alert entirely on a device the app never runs on +/// again — which is the exact case pre-arming exists for. A client also needs the class to pick +/// its `notification.*` catalog key for the notification it is arming. +/// /// This is deliberately **narrower** than [`evaluate()`]. An armed notification fires from the -/// OS's own timer with the app not running, so it cannot be re-checked when it arrives: a -/// deadline is only returned when the alert is certain to be true on arrival. Three things +/// OS's own timer with the app not running, so it cannot be re-checked when it arrives: an +/// instant is returned only when the alert is certain to be true on arrival. Three things /// therefore withhold one: /// -/// - the class is not [pre-armable](AlertClass::pre_armable) — its condition is server-held; -/// - the class is suppressed at `now`, or its deadline is not strictly after `now` (already -/// passed, so there is nothing left to schedule); -/// - the class would arrive as a *badge* rather than a notification: `sync_stale` with nothing -/// un-synced (only a sync can change that, and a sync re-arms), and `recovery_check_due` with -/// its snooze budget spent. +/// - the class is not [pre-armable](AlertClass::pre_armable) — its condition is server-held, so +/// the device cannot compute a deadline for it at all; +/// - the resulting instant is not strictly after `now` (it has already passed, so there is +/// nothing left to schedule) — including a class the alert has already fired for; +/// - the class would arrive as something other than a notification: `sync_stale` with nothing +/// un-synced (only a sync can change that, and a sync re-arms), `recovery_check_due` with its +/// snooze budget spent (a badge, which is in-app), and any class the user has +/// [disabled](NotifyInput::disabled). /// -/// The result is a single value, so recomputing after any state change and cancel-then-arming -/// on a change is the whole client-side protocol — and two live timers for one class is -/// structurally impossible. +/// A [snooze](NotifyInput::suppressed) **defers** the armed instant rather than cancelling it: a +/// class snoozed after it fired must re-fire when the snooze ends, and the snooze end is a +/// deadline the device can compute. #[must_use] -pub fn next_deadline(input: &NotifyInput, now: Timestamp) -> Option { - let deadline = AlertClass::ALL - .into_iter() - .filter(|class| class.pre_armable() && !input.is_suppressed(*class, now)) - .filter_map(|class| pre_arm_deadline(input, class, now)) - .filter(|deadline| *deadline > now) - .min(); - // A timer that was armed for the wrong instant, or cancelled when it should not have been, - // is otherwise invisible until an alert fails to arrive weeks later. +pub fn pre_arm_deadlines(input: &NotifyInput, now: Timestamp) -> BTreeMap { + let mut armed = BTreeMap::new(); + for class in AlertClass::ALL { + if !class.pre_armable() { + continue; + } + if let Some(at) = pre_arm_deadline(input, class) + && at > now + { + armed.insert(class, at); + } + } + // A timer armed for the wrong instant, or cancelled when it should not have been, is + // otherwise invisible until an alert fails to arrive weeks later. tracing::debug!( now = %now, - deadline = ?deadline.map(|d| d.to_string()), - "notify: computed the next pre-arm deadline" + armed = ?armed + .iter() + .map(|(class, at)| (class.as_str(), at.to_string())) + .collect::>(), + "notify: computed the pre-arm deadlines" ); - deadline + armed +} + +/// The earliest instant in [`pre_arm_deadlines()`], or `None` when nothing is to be armed. +/// +/// A convenience for a caller that holds a single timer — a desktop scheduler, a CLI, a status +/// line. **A client that pre-arms per class wants [`pre_arm_deadlines()`]**: collapsing the map +/// to its minimum discards the later class's timer, which on a device the app never runs on +/// again loses that alert. +#[must_use] +pub fn next_deadline(input: &NotifyInput, now: Timestamp) -> Option { + pre_arm_deadlines(input, now).into_values().min() } /// The predicate for one class. Separated per class rather than per fact so the emission order @@ -109,8 +143,11 @@ fn evaluate_class(input: &NotifyInput, class: AlertClass, now: Timestamp) -> Opt /// A class that fires on any non-zero count and carries it as the `count` parameter. /// -/// A quarantined item — and a pending drop is one — is never silently dropped and never -/// silently applied, so any non-zero count is reported. +/// A quarantined item is never silently dropped and never silently applied, so any non-zero +/// count is reported. The two counts are **disjoint**: a pending drop is a quarantine surface in +/// the threat model's inventory, but it has its own class here, so +/// [`NotifyInput::quarantine_pending`] excludes drops and only +/// [`NotifyInput::drops_pending`] counts them. fn counted(class: AlertClass, count: u64) -> Option { (count > 0).then(|| Alert::new(class).with_param("count", count.to_string())) } @@ -152,6 +189,11 @@ fn sync_stale_deadline(facts: &SyncFacts) -> Timestamp { /// `next_due` as given. `snooze_budget_spent` does not change *whether* the class is reported — /// a client cannot render a badge for a condition it was not told about — only how, which is /// delivery and therefore the client's. It is carried as the `snooze_budget` parameter. +/// +/// The reported `deadline` is the **later** of `next_due` and an expired `snoozed_until`, +/// because that is the instant whose passing actually made the alert true — and it is the same +/// instant [`pre_arm_deadline`] armed, so what a client scheduled and what it is handed on +/// arrival agree. fn recovery_check_due(facts: &RecoveryFacts, now: Timestamp) -> Option { if facts.snoozed_until.is_some_and(|until| until > now) { return None; @@ -159,9 +201,12 @@ fn recovery_check_due(facts: &RecoveryFacts, now: Timestamp) -> Option { if now < facts.next_due { return None; } + let became_true_at = facts + .snoozed_until + .map_or(facts.next_due, |until| until.max(facts.next_due)); Some( Alert::new(AlertClass::RecoveryCheckDue) - .with_deadline(facts.next_due) + .with_deadline(became_true_at) .with_param( "snooze_budget", if facts.snooze_budget_spent { @@ -169,6 +214,13 @@ fn recovery_check_due(facts: &RecoveryFacts, now: Timestamp) -> Option { } else { "available" }, + ) + // Without this the alert for "you told us you lost your recovery secret" is + // byte-identical to the routine ninety-day check, and a client rendering from the + // class alone would say "time for your periodic check" at the worst moment. + .with_param( + "recovery", + if facts.rewrap_due { "rewrap" } else { "check" }, ), ) } @@ -190,9 +242,19 @@ fn quota_grace_expiring(state: QuotaAdvisory) -> Option { Some(Alert::new(AlertClass::QuotaGraceExpiring).with_param("grace", grace)) } -/// The deadline to arm for one pre-armable class, if it has one that will certainly fire. -fn pre_arm_deadline(input: &NotifyInput, class: AlertClass, now: Timestamp) -> Option { - match class { +/// The instant to arm for one pre-armable class, if it has one that will certainly fire. +/// +/// Two composition rules, both of which exist because an armed notification cannot be +/// re-checked when it fires: +/// +/// - the class's own condition instant is the **later** of every gate on it — a recovery check +/// snoozed to a date *before* its due date is still not due until `next_due`, so arming the +/// snooze end alone would fire into no alert; +/// - a [snooze](NotifyInput::suppressed) **defers** that instant instead of cancelling it, so a +/// class snoozed after firing re-fires when the snooze ends. Only a +/// [disable](NotifyInput::disabled) cancels. +fn pre_arm_deadline(input: &NotifyInput, class: AlertClass) -> Option { + let condition_at = match class { AlertClass::SyncStale => input .sync .as_ref() @@ -202,17 +264,26 @@ fn pre_arm_deadline(input: &NotifyInput, class: AlertClass, now: Timestamp) -> O .recovery .as_ref() .filter(|facts| !facts.snooze_budget_spent) - .map(|facts| match facts.snoozed_until { - // While snoozed the deadline is the snooze's end, not the original due date. - Some(until) if until > now => until, - _ => facts.next_due, + .map(|facts| { + facts + .snoozed_until + .map_or(facts.next_due, |until| until.max(facts.next_due)) }), - // Not pre-armable; `next_deadline` filters these out before asking. + // Not pre-armable; `pre_arm_deadlines` filters these out before asking. AlertClass::QuotaSoft | AlertClass::QuotaGraceExpiring | AlertClass::QuarantinePending | AlertClass::DropPending => None, + }?; + if input.disabled.contains(&class) { + // Disabled: nothing is ever armed again for this class. + return None; } + // Snoozed: re-fire when the snooze ends, if that outlasts the condition itself. + Some(match input.suppressed.get(&class) { + Some(&until) => condition_at.max(until), + None => condition_at, + }) } /// Add a signed second offset to a timestamp, saturating at the representable bounds. Nothing @@ -229,7 +300,7 @@ fn add_secs(base: Timestamp, secs: i64) -> Timestamp { #[cfg(test)] mod tests { - use std::collections::BTreeMap; + use std::collections::{BTreeMap, BTreeSet}; use super::super::class::AlertSeverity; use super::super::input::QuotaFacts; @@ -279,6 +350,7 @@ mod tests { next_due: ts(next_due), snoozed_until: snoozed_until.map(ts), snooze_budget_spent, + rewrap_due: false, }), ..NotifyInput::default() } @@ -372,6 +444,44 @@ mod tests { } } + /// The reported deadline is the instant that actually made the alert true — the expired + /// snooze when one outlasted the due date, and the due date otherwise. It is the same + /// instant `next_deadline` armed, so a client's timer and the alert it is handed agree. + #[test] + fn recovery_check_due_reports_the_instant_that_made_it_true() { + let due = BASE + 7 * DAY_SECS; + let until = due + 2 * DAY_SECS; + + // A snooze that outlasted the due date: it, not `next_due`, is what held the alert. + let input = with_recovery(due, Some(until), false); + assert_eq!( + next_deadline(&input, ts(due)), + Some(ts(until)), + "the snooze end is what gets armed" + ); + let alert = evaluate(&input, ts(until)) + .into_iter() + .find(|a| a.class == AlertClass::RecoveryCheckDue) + .expect("due once the snooze expires"); + assert_eq!(alert.deadline, Some(ts(until)), "and what gets reported"); + + // A snooze that expired before the due date leaves `next_due` as the true instant. + let early = with_recovery(due, Some(due - DAY_SECS), false); + let alert = evaluate(&early, ts(due)) + .into_iter() + .find(|a| a.class == AlertClass::RecoveryCheckDue) + .expect("due at the boundary"); + assert_eq!(alert.deadline, Some(ts(due))); + + // No snooze at all: `next_due`. + let none = with_recovery(due, None, false); + let alert = evaluate(&none, ts(due)) + .into_iter() + .find(|a| a.class == AlertClass::RecoveryCheckDue) + .expect("due at the boundary"); + assert_eq!(alert.deadline, Some(ts(due))); + } + /// A spent snooze budget is reported, not silenced — the client needs the fact to render a /// badge — and it is carried as a parameter rather than a class of its own. #[test] @@ -491,11 +601,9 @@ mod tests { ); } - // A class suppressed while its deadline is still in the future contributes no deadline. + // A disabled class contributes no deadline even while its own is still in the future. let mut input = with_sync(BASE, 1); - input - .suppressed - .insert(AlertClass::SyncStale, Timestamp::MAX); + input.disabled.insert(AlertClass::SyncStale); assert_eq!(next_deadline(&input, ts(BASE)), None); assert!(evaluate(&input, ts(due)).is_empty()); } @@ -506,9 +614,7 @@ mod tests { fn suppression_does_not_leak_across_classes() { let mut input = with_sync(BASE, 1); input.drops_pending = 2; - input - .suppressed - .insert(AlertClass::SyncStale, Timestamp::MAX); + input.disabled.insert(AlertClass::SyncStale); assert_eq!( classes(&input, ts(BASE + SYNC_STALE_SECS)), [AlertClass::DropPending] @@ -528,6 +634,7 @@ mod tests { next_due: ts(recovery_due), snoozed_until: None, snooze_budget_spent: false, + rewrap_due: false, }); // Both future → the earlier. @@ -597,6 +704,7 @@ mod tests { next_due: ts(BASE), snoozed_until: None, snooze_budget_spent: false, + rewrap_due: false, }), // `SoftWarning` and `HardExceeded` are mutually exclusive states, so the two quota // classes cannot both be true; this asserts the ordering of the five that can. @@ -606,6 +714,7 @@ mod tests { quarantine_pending: 1, drops_pending: 1, suppressed: BTreeMap::new(), + disabled: BTreeSet::new(), }; assert_eq!( classes(&input, ts(due)), @@ -626,9 +735,7 @@ mod tests { let mut input = with_sync(BASE, 9); input.quarantine_pending = 2; input.drops_pending = 4; - input - .suppressed - .insert(AlertClass::RecoveryCheckDue, Timestamp::MAX); + input.disabled.insert(AlertClass::RecoveryCheckDue); let now = ts(BASE + SYNC_STALE_SECS); let first = serde_json::to_string(&evaluate(&input, now)).unwrap(); @@ -642,6 +749,171 @@ mod tests { assert_eq!(evaluate(&round_tripped, now), evaluate(&input, now)); } + // ── per-class arming, deferral, and the new facts ─────────────────────── + + /// The two pre-armable classes get **independent** entries. Collapsing them to a single + /// minimum, as `next_deadline` does, discards the later timer — which on a device the app + /// never runs on again loses that alert entirely. + #[test] + fn pre_arm_deadlines_arms_each_class_independently() { + let sync_due = BASE + SYNC_STALE_SECS; // + 14 d + let recovery_due = BASE + 90 * DAY_SECS; // much later + let mut input = with_sync(BASE, 1); + input.recovery = Some(RecoveryFacts { + next_due: ts(recovery_due), + snoozed_until: None, + snooze_budget_spent: false, + rewrap_due: false, + }); + + let armed = pre_arm_deadlines(&input, ts(BASE)); + assert_eq!(armed.len(), 2); + assert_eq!(armed[&AlertClass::SyncStale], ts(sync_due)); + assert_eq!(armed[&AlertClass::RecoveryCheckDue], ts(recovery_due)); + // The single-timer convenience keeps only the earlier one, which is precisely why it is + // not what a per-class client should call. + assert_eq!(next_deadline(&input, ts(BASE)), Some(ts(sync_due))); + + // Past the staleness deadline the recovery timer is still armed and still later. + let armed = pre_arm_deadlines(&input, ts(sync_due)); + assert_eq!( + armed.keys().copied().collect::>(), + [AlertClass::RecoveryCheckDue] + ); + assert_eq!(armed[&AlertClass::RecoveryCheckDue], ts(recovery_due)); + } + + /// A class snoozed *after* it fired must fire again when the snooze ends. Cancelling + /// instead would leave `sync_stale` reachable only in-app, defeating the pre-arm rule for + /// the one class it exists for. + #[test] + fn a_finite_suppression_defers_the_timer_rather_than_cancelling_it() { + let due = BASE + SYNC_STALE_SECS; + let snooze_end = due + 3 * DAY_SECS; + let mut input = with_sync(BASE, 1); + input + .suppressed + .insert(AlertClass::SyncStale, ts(snooze_end)); + + // Snoozed: nothing is reported, but the timer moves to the snooze end. + assert!(evaluate(&input, ts(due + DAY_SECS)).is_empty()); + assert_eq!( + pre_arm_deadlines(&input, ts(due + DAY_SECS))[&AlertClass::SyncStale], + ts(snooze_end) + ); + // At the snooze end it is due again, and there is nothing left to arm. + assert!( + classes(&input, ts(snooze_end)).contains(&AlertClass::SyncStale), + "the snooze has expired, so the class is due" + ); + assert!(pre_arm_deadlines(&input, ts(snooze_end)).is_empty()); + + // A snooze that ends before the class's own deadline does not pull the timer earlier. + let mut early = with_sync(BASE, 1); + early + .suppressed + .insert(AlertClass::SyncStale, ts(due - DAY_SECS)); + assert_eq!( + pre_arm_deadlines(&early, ts(BASE))[&AlertClass::SyncStale], + ts(due) + ); + } + + /// A disable is the one suppression that cancels the timer: nothing is ever armed again. + #[test] + fn a_disabled_class_holds_no_timer_and_reports_nothing() { + let mut input = with_sync(BASE, 1); + input.disabled.insert(AlertClass::SyncStale); + assert!(pre_arm_deadlines(&input, ts(BASE)).is_empty()); + assert!(pre_arm_deadlines(&input, ts(BASE + SYNC_STALE_SECS)).is_empty()); + assert!(evaluate(&input, ts(BASE + SYNC_STALE_SECS)).is_empty()); + } + + /// A snooze set before the due date does not make the check due earlier, so it must not be + /// armed alone: the armed instant is the later of the two, or the timer fires into no alert. + #[test] + fn a_snooze_before_the_due_date_does_not_pull_the_recovery_timer_earlier() { + let due = BASE + 7 * DAY_SECS; + let snooze_end = BASE + DAY_SECS; // expires long before the check is due + let input = with_recovery(due, Some(snooze_end), false); + + assert_eq!( + pre_arm_deadlines(&input, ts(BASE))[&AlertClass::RecoveryCheckDue], + ts(due) + ); + assert!( + evaluate(&input, ts(snooze_end)).is_empty(), + "the snooze ended but the check is not due yet" + ); + assert!(classes(&input, ts(due)).contains(&AlertClass::RecoveryCheckDue)); + } + + /// The re-arm half of the rule: a completed sync moves the staleness timer, and a client + /// that recomputes sees a different value to cancel-and-arm against. + #[test] + fn a_completed_sync_re_arms_the_staleness_timer() { + let first = with_sync(BASE, 1); + let before = pre_arm_deadlines(&first, ts(BASE))[&AlertClass::SyncStale]; + assert_eq!(before, ts(BASE + SYNC_STALE_SECS)); + + // A sync completes a day later with changes still pending: the deadline moves by a day. + let second = with_sync(BASE + DAY_SECS, 1); + let after = pre_arm_deadlines(&second, ts(BASE + DAY_SECS))[&AlertClass::SyncStale]; + assert_eq!(after, ts(BASE + DAY_SECS + SYNC_STALE_SECS)); + assert_ne!(before, after, "the value moved, so the client re-arms"); + + // A sync that clears the backlog cancels it instead. + let cleared = with_sync(BASE + DAY_SECS, 0); + assert!(pre_arm_deadlines(&cleared, ts(BASE + DAY_SECS)).is_empty()); + } + + /// The escalation to the guided re-wrap is carried as a parameter, because the closed class + /// set has only `recovery_check_due` to report it and the routine check must not look the + /// same. + #[test] + fn the_rewrap_escalation_is_distinguishable_from_a_routine_check() { + let due = BASE + 7 * DAY_SECS; + let routine = with_recovery(due, None, false); + assert_eq!( + params_of(&routine, AlertClass::RecoveryCheckDue, ts(due)).unwrap()["recovery"], + "check" + ); + + let mut escalated = routine.clone(); + if let Some(facts) = escalated.recovery.as_mut() { + facts.rewrap_due = true; + } + assert_eq!( + params_of(&escalated, AlertClass::RecoveryCheckDue, ts(due)).unwrap()["recovery"], + "rewrap" + ); + } + + /// `days_behind` truncates toward the completed day, so the whole last day before the next + /// one reads the same. Asserted at both ends of that interval. + #[test] + fn days_behind_truncates_to_whole_days() { + for (offset, expected) in [ + (SYNC_STALE_SECS, "14"), + (SYNC_STALE_SECS + DAY_SECS - 1, "14"), + (SYNC_STALE_SECS + DAY_SECS, "15"), + ] { + let input = with_sync(BASE, 1); + let params = params_of(&input, AlertClass::SyncStale, ts(BASE + offset)) + .expect("stale with changes pending"); + assert_eq!(params["days_behind"], expected, "at +{offset}s"); + } + } + + /// `quota_soft` carries no parameters: there is nothing to interpolate that the client does + /// not already hold from its own quota response. + #[test] + fn quota_soft_carries_no_parameters() { + let input = with_quota(QuotaAdvisory::SoftWarning); + let params = params_of(&input, AlertClass::QuotaSoft, ts(BASE)).expect("soft warning"); + assert!(params.is_empty(), "{params:?}"); + } + /// Saturating arithmetic: a sync epoch pinned at the far end of the range neither panics /// nor wraps into the past. #[test] diff --git a/capsule-core/src/notify/input.rs b/capsule-core/src/notify/input.rs index fb4fd5fb..ae83fb6a 100644 --- a/capsule-core/src/notify/input.rs +++ b/capsule-core/src/notify/input.rs @@ -8,7 +8,7 @@ //! The security boundary is the shape itself: counts and instants only. No album id, no title, //! no asset id, nothing a server could author. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use jiff::Timestamp; use serde::{Deserialize, Serialize}; @@ -33,32 +33,56 @@ pub struct NotifyInput { /// `GET /v1/quota` — quota state is server-held, so it is only ever as current as that call. pub quota: Option, /// How many items sit on the client's quarantine surfaces awaiting a human. + /// + /// **Excludes pending drops.** A pending drop is a quarantine surface in the threat model's + /// own inventory, but it has its own alert class here, so a client that filled this field + /// from that inventory unfiltered would raise both classes for the same items. Count drops + /// in [`drops_pending`](Self::drops_pending) and nowhere else. pub quarantine_pending: u64, /// How many guest drops are awaiting review and adoption. pub drops_pending: u64, - /// Per-class suppression: a class whose entry is **strictly after** `now` emits nothing and - /// contributes no deadline. + /// Per-class **snooze**: the instant a deferred class becomes due again. A class whose entry + /// is strictly after `now` emits nothing from [`super::evaluate()`]. /// - /// This is how a client applies snooze and disable without this crate owning that state - /// machine — the bounded-snooze-then-badge mechanic has one owner already + /// This is how a client applies a snooze without this crate owning that state machine — the + /// bounded-snooze-then-badge mechanic has one owner already /// ([`RecoveryCadence`](https://docs/design/backup-recovery/#recovery-verification-cadence)), - /// and a second copy here would be two owners of one mechanic. A *disabled* class is an - /// entry of [`Timestamp::MAX`]; suppressing the warning never suppresses the behavior — - /// turning off `sync_stale` does not turn off auto-sync. + /// and a second copy here would be two owners of one mechanic. + /// + /// A snooze **defers** the class's pre-arm deadline rather than cancelling it + /// ([`super::pre_arm_deadlines()`]): a class snoozed after it fired must fire again when the + /// snooze ends, and that end is a deadline the device can compute. Cancelling instead would + /// leave the alert reachable only in-app, which for `sync_stale` defeats the entire pre-arm + /// rule the class exists under. pub suppressed: BTreeMap, + /// Per-class **disable**: the user turned this alert off. Emits nothing and arms nothing, at + /// any instant. + /// + /// A separate field from [`suppressed`](Self::suppressed) rather than a far-future instant in + /// it, because they are different mechanics with different effects on the timer — a snooze + /// defers, a disable cancels — and because a sentinel instant is exactly the kind of + /// convention that does not survive a string-typed FFI boundary: a client writing "the year + /// 2999" would mean *disabled* and get a timer armed 975 years out. + /// + /// **Disabling suppresses the warning, never the behavior.** Turning off `sync_stale` does + /// not turn off auto-sync; turning off `recovery_check_due` does not stop the recovery check + /// mattering. An alert is a report about a condition, never the mechanism managing it. + pub disabled: BTreeSet, } impl NotifyInput { - /// Whether `class` is snoozed or disabled at `now`. + /// Whether `class` is snoozed or disabled at `now`, and therefore reports nothing. /// - /// An entry exactly at `now` has expired: suppression is `until`, exclusive, so a class + /// A snooze entry exactly at `now` has expired: a snooze is `until`, exclusive, so a class /// snoozed to `now` is due again at `now` — the same boundary convention as every other - /// threshold in this module. + /// threshold in this module. A disable never expires. #[must_use] pub fn is_suppressed(&self, class: AlertClass, now: Timestamp) -> bool { - self.suppressed - .get(&class) - .is_some_and(|until| *until > now) + self.disabled.contains(&class) + || self + .suppressed + .get(&class) + .is_some_and(|until| *until > now) } } @@ -73,7 +97,7 @@ pub struct SyncFacts { pub unsynced_changes: u64, } -/// The recovery-verification cadence, flattened to the three facts the predicate needs. +/// The recovery-verification cadence, flattened to the facts the predicate needs. /// /// The 7 d → 90 d → 180 d ladder, its re-arm triggers, and its snooze accounting stay owned by /// the cadence scheduler; this module consumes the already-computed `next_due` and never @@ -84,12 +108,26 @@ pub struct RecoveryFacts { /// When the next verification prompt becomes due. pub next_due: Timestamp, /// When an active snooze expires, if one is active. + /// + /// A snooze set *before* the due date does not make the check due earlier: the class is due + /// at the later of this and [`next_due`](Self::next_due). pub snoozed_until: Option, /// Whether the consecutive-snooze budget is spent. When it is, the class has degraded to a /// persistent, non-blocking badge: it is still reported (a client cannot render a badge for /// a condition it was not told about), but it is no longer pre-armed as a notification — /// the badge never escalates back into an alert on its own. pub snooze_budget_spent: bool, + /// Whether the scheduler has escalated to the guided re-wrap — repeated verification + /// failures, or the user explicitly declaring the secret lost. + /// + /// Carried because the alert class set is closed and `recovery_check_due` is the only class + /// that can report it: without this fact the alert for "you told us you lost your recovery + /// secret" would be indistinguishable from the routine ninety-day check. It surfaces as the + /// `recovery` parameter (`"rewrap"` / `"check"`) so a client routes into the guided re-wrap + /// instead of a verification prompt. `#[serde(default)]` so a snapshot persisted before this + /// field existed still loads. + #[serde(default)] + pub rewrap_due: bool, } /// The last quota answer the client holds. @@ -142,6 +180,7 @@ mod tests { assert_eq!(input.quota, None); assert_eq!(input.quarantine_pending, 0); assert_eq!(input.drops_pending, 0); + assert!(input.disabled.is_empty()); for class in AlertClass::ALL { assert!(!input.is_suppressed(class, ts(0))); } @@ -160,14 +199,15 @@ mod tests { assert!(!input.is_suppressed(AlertClass::RecoveryCheckDue, ts(999))); } - /// A disabled class is an entry at the far end of the representable range. + /// A disabled class is suppressed at every instant, and only that class. #[test] fn disabled_is_suppressed_forever() { let mut input = NotifyInput::default(); - input - .suppressed - .insert(AlertClass::DropPending, Timestamp::MAX); - assert!(input.is_suppressed(AlertClass::DropPending, ts(1_700_000_000))); + input.disabled.insert(AlertClass::DropPending); + for at in [i64::from(i32::MIN), 0, 1_700_000_000, i64::from(i32::MAX)] { + assert!(input.is_suppressed(AlertClass::DropPending, ts(at)), "{at}"); + } + assert!(!input.is_suppressed(AlertClass::SyncStale, ts(0))); } /// `#[serde(default)]` means a client may send only the fields it has. diff --git a/capsule-core/src/notify/mod.rs b/capsule-core/src/notify/mod.rs index 2693c42d..3ef0199f 100644 --- a/capsule-core/src/notify/mod.rs +++ b/capsule-core/src/notify/mod.rs @@ -5,8 +5,9 @@ //! # The surface //! //! [`evaluate()`] turns a snapshot of device-held state ([`NotifyInput`]) into the [`Alert`]s -//! that are true at an instant. [`next_deadline()`] returns the single instant an OS timer must -//! be armed for, or `None` when there is nothing to arm. +//! that are true at an instant. [`pre_arm_deadlines()`] returns the instant an OS timer must be +//! armed for, **per class** — a class absent from the map has no timer to hold. +//! [`next_deadline()`] is its minimum, for a caller that holds only one timer. //! //! Both are **pure**: no clock read, no socket, no SQLite, no `unsafe`, and no allocation beyond //! the returned vector. `now` is always an argument, so the whole surface is driven by a mocked @@ -42,16 +43,16 @@ //! that deadline becomes known, not evaluated when it expires — otherwise the staleness alert is //! starved by the very absence of background windows it exists to report. //! -//! The consequence for this module is the reason [`next_deadline()`] is narrower than +//! The consequence for this module is the reason [`pre_arm_deadlines()`] is narrower than //! [`evaluate()`]: an armed OS notification fires **without the app running**, so it cannot be -//! re-checked at fire time. A deadline is therefore only returned when the alert is certain to be +//! re-checked at fire time. An instant is therefore only returned when the alert is certain to be //! true when it arrives — see [`AlertClass::pre_armable`] for which classes can be armed at all, -//! and [`next_deadline()`] for the two conditions that withhold a deadline from a pre-armable +//! and [`pre_arm_deadlines()`] for the three conditions that withhold one from a pre-armable //! class. //! //! Because the answer is a pure function of state, "re-arm on every state change that moves the -//! deadline" reduces on the client to: recompute after any state change, then cancel-and-arm if -//! the value changed. One deadline per class from one function is also why two live timers for +//! deadline" reduces on the client to: recompute after any state change, then reconcile the +//! timers against the map. One entry per class from one function is also why two live timers for //! one class is structurally impossible. //! //! # Determinism @@ -67,5 +68,5 @@ pub(crate) mod evaluate; pub(crate) mod input; pub use class::{Alert, AlertClass, AlertSeverity}; -pub use evaluate::{DAY_SECS, SYNC_STALE_SECS, evaluate, next_deadline}; +pub use evaluate::{DAY_SECS, SYNC_STALE_SECS, evaluate, next_deadline, pre_arm_deadlines}; pub use input::{NotifyInput, QuotaAdvisory, QuotaFacts, RecoveryFacts, SyncFacts}; diff --git a/capsule-docs/src/content/docs/design/notifications.md b/capsule-docs/src/content/docs/design/notifications.md index b5b2ce8d..321e3816 100644 --- a/capsule-docs/src/content/docs/design/notifications.md +++ b/capsule-docs/src/content/docs/design/notifications.md @@ -42,10 +42,10 @@ This is the [minimal-divergence split](/design/clients/#design-priorities) appli server module is planned for v1 — Tier 0 has no server half. **Status.** `capsule-core::notify` is **built** (slice `S-D29`, core half): one pure decision -function returns the classes true at an instant, a second returns the instant to arm, and -`capsule-sdk::ffi` carries both to the apps. Every predicate input is caller-supplied, because -the core holds none of the trigger state. What is still owed is the *delivery* half — the -per-platform scheduling and presentation below, the `notification.*` catalog keys, and the +function returns the classes true at an instant, a second returns the instant to arm per class, +and `capsule-sdk::ffi` carries both to the apps. Every predicate input is caller-supplied, +because the core holds none of the trigger state. What is still owed is the *delivery* half — +the per-platform scheduling and presentation below, the `notification.*` catalog keys, and the permission request — so no alert on this page reaches a user yet. ## Tier 0 — Local Alerts diff --git a/capsule-sdk/src/ffi.rs b/capsule-sdk/src/ffi.rs index 6839e2f4..40dda957 100644 --- a/capsule-sdk/src/ffi.rs +++ b/capsule-sdk/src/ffi.rs @@ -63,8 +63,8 @@ pub use workspace::{ mod notify; pub use notify::{ - FfiAlert, FfiAlertClass, FfiAlertSeverity, FfiNotifyInput, FfiQuotaAdvisory, evaluate_alerts, - next_alert_deadline, + FfiAlert, FfiAlertClass, FfiAlertSeverity, FfiClassDeadline, FfiNotifyInput, FfiQuotaAdvisory, + evaluate_alerts, next_alert_deadline, pre_arm_deadlines, }; // ─── Errors ────────────────────────────────────────────────────────────────── diff --git a/capsule-sdk/src/ffi/notify.rs b/capsule-sdk/src/ffi/notify.rs index 234697fa..5e4c5540 100644 --- a/capsule-sdk/src/ffi/notify.rs +++ b/capsule-sdk/src/ffi/notify.rs @@ -3,7 +3,8 @@ //! //! # Why free functions //! -//! [`evaluate_alerts`] and [`next_alert_deadline`] are free `#[uniffi::export]` functions rather +//! [`evaluate_alerts`], [`pre_arm_deadlines`] and [`next_alert_deadline`] are free +//! `#[uniffi::export]` functions rather //! than methods on //! [`FfiWorkspace`](crate::ffi::FfiWorkspace). The workspace holds none of the predicate's //! inputs — there is no persisted last-sync instant, no client-side quota type, and no @@ -24,7 +25,7 @@ //! instant type and no shared integer convention worth guessing at. A string that does not parse //! is [`FfiError::InvalidArgument`], never a panic. -use std::collections::HashMap; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use capsule_core::notify::{ self, Alert, AlertClass, AlertSeverity, NotifyInput, QuotaAdvisory, QuotaFacts, RecoveryFacts, @@ -140,6 +141,19 @@ impl From for FfiAlert { } } +/// One class and the instant its local notification should be armed for. +/// +/// A class absent from [`pre_arm_deadlines`]'s result has no timer to hold: cancel whatever it +/// has. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct FfiClassDeadline { + /// The class whose timer this is — also the `notification.*` catalog key the app renders + /// when it fires. + pub class: FfiAlertClass, + /// When to fire it (RFC 3339). + pub deadline: String, +} + /// The device-held state the predicate decides from, flattened for the bindings. /// /// Counts and instants only — no album id, no title, no asset id. An all-default value (every @@ -150,6 +164,7 @@ pub struct FfiNotifyInput { /// When the last **completed** sync finished (RFC 3339). `None` on a device that has never /// completed one — which raises no `sync_stale`, because the alert is about a *stale* sync /// and not a missing one. When `None`, `unsynced_changes` is ignored. + #[uniffi(default = None)] pub last_completed_sync: Option, /// Changes still waiting to reach the server, including originals still pending under a /// staged upload policy. @@ -160,27 +175,42 @@ pub struct FfiNotifyInput { /// [`RecoveryCadence::notify_facts`](crate::recovery::RecoveryCadence::notify_facts) rather /// than computing it here. `None` before recovery is set up, which ignores the other two /// `recovery_*` fields. + #[uniffi(default = None)] pub recovery_next_due: Option, - /// When an active snooze on the recovery prompt expires (RFC 3339), if one is active. + /// When an active snooze on the recovery prompt expires (RFC 3339), if one is active. A + /// snooze ending *before* `recovery_next_due` does not make the check due earlier. + #[uniffi(default = None)] pub recovery_snoozed_until: Option, /// Whether the consecutive-snooze budget is spent — the class has degraded to a persistent, /// non-blocking badge: still reported, no longer pre-armed. #[uniffi(default = false)] pub recovery_snooze_budget_spent: bool, + /// Whether the scheduler has escalated to the guided re-wrap (repeated failures, or the user + /// declaring the secret lost). Surfaces as the alert's `recovery` parameter, so the app + /// routes into the re-wrap flow instead of rendering a routine verification prompt. + #[uniffi(default = false)] + pub recovery_rewrap_due: bool, /// The state from the last `GET /v1/quota`. `None` before the first one. + #[uniffi(default = None)] pub quota_state: Option, - /// How many items sit on the client's quarantine surfaces awaiting a human. + /// How many items sit on the client's quarantine surfaces awaiting a human. **Excludes + /// pending drops** — they have their own class, so counting them here raises both. #[uniffi(default = 0)] pub quarantine_pending: u64, /// How many guest drops are awaiting review and adoption. #[uniffi(default = 0)] pub drops_pending: u64, - /// Per-class suppression: class wire name (`sync_stale`, …) to the RFC 3339 instant the - /// snooze or disable runs until, exclusive. A class suppressed past `now` reports nothing - /// and arms nothing. Disabling a class is a far-future instant; it suppresses the warning - /// and never the behavior. An unrecognized class name is an + /// Per-class **snooze**: class wire name (`sync_stale`, …) to the RFC 3339 instant the + /// snooze runs until, exclusive. A class snoozed past `now` reports nothing, and its alarm + /// is **deferred to the snooze end** rather than cancelled — a class snoozed after it fired + /// must fire again when the snooze expires. Use [`disabled`](Self::disabled) to turn a class + /// off; do not encode that as a far-future instant here. An unrecognized class name is an /// [`FfiError::InvalidArgument`]. pub suppressed_until: HashMap, + /// Per-class **disable**: the wire names of the classes the user turned off. They report + /// nothing and hold no alarm, at any instant. Disabling suppresses the warning and never + /// the behavior. An unrecognized class name is an [`FfiError::InvalidArgument`]. + pub disabled: Vec, } impl FfiNotifyInput { @@ -198,27 +228,36 @@ impl FfiNotifyInput { }) .transpose()?; + // Parsed unconditionally, and *before* the `recovery_next_due` branch: a malformed + // snooze instant is a malformed field whether or not the due date happens to be present, + // and a validation that only runs on one code path is the one that lets a typo through. + let snoozed_until = self + .recovery_snoozed_until + .as_deref() + .map(|raw| parse_instant(raw, "recovery_snoozed_until")) + .transpose()?; let recovery = self .recovery_next_due .map(|raw| { Ok::<_, FfiError>(RecoveryFacts { next_due: parse_instant(&raw, "recovery_next_due")?, - snoozed_until: self - .recovery_snoozed_until - .as_deref() - .map(|raw| parse_instant(raw, "recovery_snoozed_until")) - .transpose()?, + snoozed_until, snooze_budget_spent: self.recovery_snooze_budget_spent, + rewrap_due: self.recovery_rewrap_due, }) }) .transpose()?; - let mut suppressed = std::collections::BTreeMap::new(); + let mut suppressed = BTreeMap::new(); for (name, raw) in self.suppressed_until { - let class = AlertClass::from_wire(&name).ok_or_else(|| FfiError::InvalidArgument { - message: format!("suppressed_until: `{name}` is not an alert class"), - })?; - suppressed.insert(class, parse_instant(&raw, "suppressed_until")?); + suppressed.insert( + parse_class(&name, "suppressed_until")?, + parse_instant(&raw, "suppressed_until")?, + ); + } + let mut disabled = BTreeSet::new(); + for name in self.disabled { + disabled.insert(parse_class(&name, "disabled")?); } Ok(NotifyInput { @@ -230,10 +269,18 @@ impl FfiNotifyInput { quarantine_pending: self.quarantine_pending, drops_pending: self.drops_pending, suppressed, + disabled, }) } } +/// Parse one alert-class wire name against the closed enum, naming the field it came from. +fn parse_class(name: &str, field: &str) -> Result { + AlertClass::from_wire(name).ok_or_else(|| FfiError::InvalidArgument { + message: format!("{field}: `{name}` is not an alert class"), + }) +} + /// Parse one RFC 3339 instant, naming the field so a foreign caller can find its own bug. fn parse_instant(raw: &str, field: &str) -> Result { raw.parse::() @@ -260,12 +307,40 @@ pub fn evaluate_alerts(input: FfiNotifyInput, now: String) -> Result Result, FfiError> { + let now = parse_instant(&now, "now")?; + Ok(notify::pre_arm_deadlines(&input.parse()?, now) + .into_iter() + .map(|(class, deadline)| FfiClassDeadline { + class: class.into(), + deadline: deadline.to_string(), + }) + .collect()) +} + +/// The earliest instant in [`pre_arm_deadlines`] (RFC 3339), or `None` when there is nothing to +/// arm. /// -/// Recompute this after **any** state change and cancel-then-arm if the value moved. Only the -/// two classes whose deadline a device can compute alone are ever returned; the other three -/// depend on server state and surface at next app launch. +/// For a host that can hold only one timer. **An app that schedules per class wants +/// [`pre_arm_deadlines`]**: the minimum discards the later class's alarm, and on a device the app +/// never runs on again that alert is simply lost. /// /// # Errors /// @@ -338,6 +413,11 @@ mod tests { next_alert_deadline(FfiNotifyInput::default(), BASE.to_owned()).unwrap(), None ); + assert!( + pre_arm_deadlines(FfiNotifyInput::default(), BASE.to_owned()) + .unwrap() + .is_empty() + ); } /// Quota and count classes cross with their parameters and without a deadline. @@ -367,22 +447,25 @@ mod tests { assert_eq!(alerts[2].params["count"], "1"); } - /// A suppressed class crosses as a wire name and removes the class from both answers. + /// A disabled class crosses as a wire name and leaves every answer empty. #[test] - fn suppression_crosses_as_a_wire_name() { + fn a_disabled_class_crosses_as_a_wire_name() { let mut input = stale_input(); - input - .suppressed_until - .insert("sync_stale".to_owned(), "2999-01-01T00:00:00Z".to_owned()); + input.disabled.push("sync_stale".to_owned()); assert!( evaluate_alerts(input.clone(), BASE_PLUS_14D.to_owned()) .unwrap() .is_empty() ); assert_eq!( - next_alert_deadline(input, BASE_PLUS_14D.to_owned()).unwrap(), + next_alert_deadline(input.clone(), BASE_PLUS_14D.to_owned()).unwrap(), None ); + assert!( + pre_arm_deadlines(input, BASE_PLUS_14D.to_owned()) + .unwrap() + .is_empty() + ); } /// Every malformed input is a typed `InvalidArgument`, never a panic and never a default. @@ -426,6 +509,14 @@ mod tests { BASE.to_owned(), "not an alert class", ), + ( + FfiNotifyInput { + disabled: vec!["telemetry_ready".to_owned()], + ..FfiNotifyInput::default() + }, + BASE.to_owned(), + "disabled", + ), ( FfiNotifyInput { suppressed_until: HashMap::from([( @@ -455,6 +546,96 @@ mod tests { } } + /// Each class gets its own armed instant. The single-value convenience keeps only the + /// earliest, which is why an app that schedules per class must not use it. + #[test] + fn pre_arm_deadlines_arms_each_class_independently_across_the_boundary() { + let recovery_due = "2024-02-12T22:13:20Z"; // ~90 d after BASE, well after the 14 d mark + let input = FfiNotifyInput { + recovery_next_due: Some(recovery_due.to_owned()), + ..stale_input() + }; + + let armed = pre_arm_deadlines(input.clone(), BASE.to_owned()).unwrap(); + assert_eq!( + armed, + vec![ + FfiClassDeadline { + class: FfiAlertClass::SyncStale, + deadline: BASE_PLUS_14D.to_owned(), + }, + FfiClassDeadline { + class: FfiAlertClass::RecoveryCheckDue, + deadline: recovery_due.to_owned(), + }, + ] + ); + assert_eq!( + next_alert_deadline(input, BASE.to_owned()) + .unwrap() + .as_deref(), + Some(BASE_PLUS_14D), + "the convenience collapses to the earliest and loses the recovery alarm" + ); + } + + /// A snooze defers the alarm to its end; a disable cancels it outright. + #[test] + fn a_snooze_defers_the_alarm_and_a_disable_cancels_it() { + let snooze_end = "2023-12-01T22:13:20Z"; // 3 days after the threshold + let mut snoozed = stale_input(); + snoozed + .suppressed_until + .insert("sync_stale".to_owned(), snooze_end.to_owned()); + let armed = pre_arm_deadlines(snoozed, BASE_PLUS_14D.to_owned()).unwrap(); + assert_eq!(armed.len(), 1); + assert_eq!(armed[0].deadline, snooze_end, "the snooze end is re-armed"); + + let mut disabled = stale_input(); + disabled.disabled.push("sync_stale".to_owned()); + assert!( + pre_arm_deadlines(disabled, BASE.to_owned()) + .unwrap() + .is_empty(), + "a disabled class holds no alarm" + ); + } + + /// A malformed snooze instant is rejected whether or not the due date is present — the + /// leniency a one-code-path validation would have allowed. + #[test] + fn a_malformed_snooze_is_rejected_without_a_due_date() { + let input = FfiNotifyInput { + recovery_next_due: None, + recovery_snoozed_until: Some("later".to_owned()), + ..FfiNotifyInput::default() + }; + let err = evaluate_alerts(input.clone(), BASE.to_owned()) + .expect_err("a malformed field is malformed with or without its neighbour"); + assert!(matches!(err, FfiError::InvalidArgument { .. })); + assert!(matches!( + pre_arm_deadlines(input, BASE.to_owned()), + Err(FfiError::InvalidArgument { .. }) + )); + } + + /// The re-wrap escalation crosses as a parameter, so an app never renders "time for your + /// periodic check" at the moment the user has declared the secret lost. + #[test] + fn the_rewrap_escalation_crosses_the_boundary() { + for (rewrap, expected) in [(false, "check"), (true, "rewrap")] { + let input = FfiNotifyInput { + recovery_next_due: Some(BASE.to_owned()), + recovery_rewrap_due: rewrap, + ..FfiNotifyInput::default() + }; + let alerts = evaluate_alerts(input, BASE.to_owned()).unwrap(); + assert_eq!(alerts.len(), 1); + assert_eq!(alerts[0].class, FfiAlertClass::RecoveryCheckDue); + assert_eq!(alerts[0].params["recovery"], expected); + } + } + /// The projection from the SDK's own scheduler composes with the exported function, which /// is the wiring an app actually uses. #[test] @@ -470,11 +651,13 @@ mod tests { recovery_next_due: Some(facts.next_due.to_string()), recovery_snoozed_until: facts.snoozed_until.map(|t| t.to_string()), recovery_snooze_budget_spent: facts.snooze_budget_spent, + recovery_rewrap_due: facts.rewrap_due, ..FfiNotifyInput::default() }; let alerts = evaluate_alerts(input, due.to_string()).unwrap(); assert_eq!(alerts.len(), 1); assert_eq!(alerts[0].class, FfiAlertClass::RecoveryCheckDue); assert_eq!(alerts[0].params["snooze_budget"], "available"); + assert_eq!(alerts[0].params["recovery"], "check"); } } diff --git a/capsule-sdk/src/recovery/cadence.rs b/capsule-sdk/src/recovery/cadence.rs index 9f290850..9003f70d 100644 --- a/capsule-sdk/src/recovery/cadence.rs +++ b/capsule-sdk/src/recovery/cadence.rs @@ -289,8 +289,11 @@ impl RecoveryCadence { /// badge is persistent and non-blocking, and never escalates back into an alert. /// - [`VerificationState::RewrapDue`] is due **now**, whatever the ladder says: repeated /// failure or an explicit "I lost it" is not a scheduled check. The alert class set is - /// closed, so `recovery_check_due` is the only class that can carry it; the client then - /// routes into [`RecoveryClient::guided_rewrap`](crate::recovery::RecoveryClient::guided_rewrap) + /// closed, so `recovery_check_due` is the only class that can carry it — which is why the + /// escalation also rides [`RecoveryFacts::rewrap_due`], surfacing as the alert's + /// `recovery` parameter. Without it the alert would be byte-identical to the routine + /// ninety-day check, and the client could not know to route into + /// [`RecoveryClient::guided_rewrap`](crate::recovery::RecoveryClient::guided_rewrap) /// rather than a plain verification prompt. #[must_use] pub fn notify_facts(&self, now: Timestamp) -> RecoveryFacts { @@ -299,11 +302,13 @@ impl RecoveryCadence { next_due, snoozed_until: None, snooze_budget_spent: false, + rewrap_due: false, }, VerificationState::Due => RecoveryFacts { next_due: self.next_due, snoozed_until: None, snooze_budget_spent: false, + rewrap_due: false, }, VerificationState::Snoozed { until, @@ -312,16 +317,19 @@ impl RecoveryCadence { next_due: self.next_due, snoozed_until: Some(until), snooze_budget_spent: snoozes_used >= MAX_CONSECUTIVE_SNOOZES, + rewrap_due: false, }, VerificationState::Badge => RecoveryFacts { next_due: self.next_due, snoozed_until: None, snooze_budget_spent: true, + rewrap_due: false, }, VerificationState::RewrapDue => RecoveryFacts { next_due: now, snoozed_until: None, snooze_budget_spent: false, + rewrap_due: true, }, } } @@ -589,6 +597,11 @@ mod tests { assert_eq!(facts.next_due, ts(due)); assert_eq!(facts.snoozed_until, None); assert!(facts.snooze_budget_spent); + + // None of the scheduled states is a re-wrap escalation. + for at in [BASE, due, at, after] { + assert!(!cad.notify_facts(ts(at)).rewrap_due, "at {at}"); + } } /// `RewrapDue` is due now, whatever the ladder says: an explicit "I lost it" is not a @@ -605,6 +618,21 @@ mod tests { assert_eq!(facts.next_due, ts(BASE)); assert_eq!(facts.snoozed_until, None); assert!(!facts.snooze_budget_spent); + assert!( + facts.rewrap_due, + "the escalation must be distinguishable from a routine check" + ); + + // And it reaches the alert as a parameter, not just as an earlier due date. + let input = NotifyInput { + recovery: Some(facts), + ..NotifyInput::default() + }; + let alert = notify::evaluate(&input, ts(BASE)) + .into_iter() + .find(|a| a.class == AlertClass::RecoveryCheckDue) + .expect("due now"); + assert_eq!(alert.params["recovery"], "rewrap"); } /// The projection is the whole of the wiring: what the scheduler says and what the shared From 0c5f1053053847d2fa886ed8b39c5649c09d90e8 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 01:00:19 -0400 Subject: [PATCH 070/243] test(cli): prove the command tree is locale-independent, and name its keys once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Locale independence is the property the drift gate rests on — the artifact is byte-compared, so a string negotiated from the environment would make `cli-surface-check` pass or fail according to the developer's `LANG` — and nothing asserted it. Render the tree under `en_US`, `tr_TR`, and `ja_JP` and compare; `tr-TR` because it is the locale that breaks case folding, and `LC_ALL` because that is what `cli_bundle` reads first. Cover both branches of the `long_about`/`long_help` dedup, which decides whether the artifact carries the same paragraph twice, over a synthetic `Command` — the real surface has no argument with a distinct long help, so only a fixture reaches the branch that keeps one. The document's field names move into one `field` block. The shape is a projection of clap's builder API and no type here has it, so it stays hand-built; naming the keys once is what keeps that from meaning spelled ad hoc, since a typo is a field `gen-reference.mjs` silently never finds. Record `ArgAction::Count` as unreachable on today's surface, and why it is matched anyway. --- capsule-cli/src/cli/mod.rs | 153 ++++++++++++++++++++++++++++++++----- 1 file changed, 133 insertions(+), 20 deletions(-) diff --git a/capsule-cli/src/cli/mod.rs b/capsule-cli/src/cli/mod.rs index 669bdebe..10d298ac 100644 --- a/capsule-cli/src/cli/mod.rs +++ b/capsule-cli/src/cli/mod.rs @@ -11,6 +11,34 @@ use clap::{Arg, ArgAction, Command, CommandFactory, Parser}; pub(crate) use commands::*; use serde_json::{Map, Value}; +/// Every field name the command-tree document uses, named once. +/// +/// The document is hand-built rather than derived from a struct, because its shape is a +/// projection of `clap`'s builder API and no Rust type in this crate has that shape. Naming +/// the keys here is what keeps that from meaning "spelled ad hoc": this block is the +/// vocabulary `capsule-docs/scripts/gen-reference.mjs` reads on the other side, and a typo +/// in a key is a field the generator silently never finds. +mod field { + pub(super) const SCHEMA: &str = "schema"; + pub(super) const NAME: &str = "name"; + pub(super) const ABOUT: &str = "about"; + pub(super) const LONG_ABOUT: &str = "long_about"; + pub(super) const ARGS: &str = "args"; + pub(super) const SUBCOMMANDS: &str = "subcommands"; + pub(super) const ID: &str = "id"; + pub(super) const POSITIONAL: &str = "positional"; + pub(super) const REQUIRED: &str = "required"; + pub(super) const TAKES_VALUE: &str = "takes_value"; + pub(super) const REPEATABLE: &str = "repeatable"; + pub(super) const LONG: &str = "long"; + pub(super) const SHORT: &str = "short"; + pub(super) const VALUE_NAMES: &str = "value_names"; + pub(super) const POSSIBLE_VALUES: &str = "possible_values"; + pub(super) const DEFAULT_VALUES: &str = "default_values"; + pub(super) const HELP: &str = "help"; + pub(super) const LONG_HELP: &str = "long_help"; +} + /// Schema version of the emitted command-tree document. /// /// Bumped only when a consumer must change to keep reading it — adding an optional field is @@ -68,7 +96,7 @@ pub(crate) struct Cli { pub fn command_tree() -> Value { let mut root = describe_command(&Cli::command()); root.insert( - "schema".to_owned(), + field::SCHEMA.to_owned(), Value::from(u64::from(COMMAND_TREE_SCHEMA)), ); Value::Object(root) @@ -80,17 +108,17 @@ pub fn command_tree() -> Value { /// cycle to guard against and no depth limit to pick. fn describe_command(command: &Command) -> Map { let mut out = Map::new(); - out.insert("name".to_owned(), Value::from(command.get_name())); + out.insert(field::NAME.to_owned(), Value::from(command.get_name())); if let Some(about) = command.get_about() { - out.insert("about".to_owned(), Value::from(about.to_string())); + out.insert(field::ABOUT.to_owned(), Value::from(about.to_string())); } // Emitted only when it says something `about` does not, so the artifact does not carry // the same paragraph twice for every command whose doc comment is one line long. if let Some(long_about) = command.get_long_about() { let long_about = long_about.to_string(); if Some(long_about.as_str()) != command.get_about().map(ToString::to_string).as_deref() { - out.insert("long_about".to_owned(), Value::from(long_about)); + out.insert(field::LONG_ABOUT.to_owned(), Value::from(long_about)); } } @@ -100,7 +128,7 @@ fn describe_command(command: &Command) -> Map { .map(|arg| Value::Object(describe_arg(arg))) .collect(); if !args.is_empty() { - out.insert("args".to_owned(), Value::from(args)); + out.insert(field::ARGS.to_owned(), Value::from(args)); } let mut subcommands: Vec<&Command> = command @@ -113,7 +141,7 @@ fn describe_command(command: &Command) -> Map { .into_iter() .map(|subcommand| Value::Object(describe_command(subcommand))) .collect(); - out.insert("subcommands".to_owned(), Value::from(described)); + out.insert(field::SUBCOMMANDS.to_owned(), Value::from(described)); } out @@ -123,17 +151,26 @@ fn describe_command(command: &Command) -> Map { /// says about it. fn describe_arg(arg: &Arg) -> Map { let mut out = Map::new(); - out.insert("id".to_owned(), Value::from(arg.get_id().as_str())); - out.insert("positional".to_owned(), Value::from(arg.is_positional())); - out.insert("required".to_owned(), Value::from(arg.is_required_set())); - out.insert("takes_value".to_owned(), Value::from(takes_value(arg))); - out.insert("repeatable".to_owned(), Value::from(is_repeatable(arg))); + out.insert(field::ID.to_owned(), Value::from(arg.get_id().as_str())); + out.insert( + field::POSITIONAL.to_owned(), + Value::from(arg.is_positional()), + ); + out.insert( + field::REQUIRED.to_owned(), + Value::from(arg.is_required_set()), + ); + out.insert(field::TAKES_VALUE.to_owned(), Value::from(takes_value(arg))); + out.insert( + field::REPEATABLE.to_owned(), + Value::from(is_repeatable(arg)), + ); if let Some(long) = arg.get_long() { - out.insert("long".to_owned(), Value::from(long)); + out.insert(field::LONG.to_owned(), Value::from(long)); } if let Some(short) = arg.get_short() { - out.insert("short".to_owned(), Value::from(short.to_string())); + out.insert(field::SHORT.to_owned(), Value::from(short.to_string())); } // Both of these are asked only of a value-taking argument, because the derive answers // them for a flag too and both answers are internal detail rather than surface. A @@ -147,7 +184,7 @@ fn describe_arg(arg: &Arg) -> Map { .iter() .map(|name| Value::from(name.to_string())) .collect(); - out.insert("value_names".to_owned(), Value::from(names)); + out.insert(field::VALUE_NAMES.to_owned(), Value::from(names)); } let possible: Vec = arg @@ -156,15 +193,15 @@ fn describe_arg(arg: &Arg) -> Map { .filter(|value| !value.is_hide_set()) .map(|value| { let mut entry = Map::new(); - entry.insert("name".to_owned(), Value::from(value.get_name())); + entry.insert(field::NAME.to_owned(), Value::from(value.get_name())); if let Some(help) = value.get_help() { - entry.insert("help".to_owned(), Value::from(help.to_string())); + entry.insert(field::HELP.to_owned(), Value::from(help.to_string())); } Value::Object(entry) }) .collect(); if !possible.is_empty() { - out.insert("possible_values".to_owned(), Value::from(possible)); + out.insert(field::POSSIBLE_VALUES.to_owned(), Value::from(possible)); } } @@ -177,16 +214,16 @@ fn describe_arg(arg: &Arg) -> Map { .map(|value| Value::from(value.to_string_lossy().into_owned())) .collect(); if !defaults.is_empty() { - out.insert("default_values".to_owned(), Value::from(defaults)); + out.insert(field::DEFAULT_VALUES.to_owned(), Value::from(defaults)); } if let Some(help) = arg.get_help() { - out.insert("help".to_owned(), Value::from(help.to_string())); + out.insert(field::HELP.to_owned(), Value::from(help.to_string())); } if let Some(long_help) = arg.get_long_help() { let long_help = long_help.to_string(); if Some(long_help.as_str()) != arg.get_help().map(ToString::to_string).as_deref() { - out.insert("long_help".to_owned(), Value::from(long_help)); + out.insert(field::LONG_HELP.to_owned(), Value::from(long_help)); } } @@ -205,6 +242,10 @@ fn takes_value(arg: &Arg) -> bool { } /// Whether the argument may be given more than once (`--pick --pick `). +/// +/// `Count` is unreachable on today's surface — no argument in this CLI is a `-vvv`-style +/// counter — and is matched anyway because it is the other action that means "give this +/// again", and omitting it would make the first counting flag document itself as single-use. fn is_repeatable(arg: &Arg) -> bool { matches!(arg.get_action(), ArgAction::Append | ArgAction::Count) || arg @@ -367,6 +408,78 @@ mod tests { assert_eq!(flags, vec!["pick", "neutral", "reject"]); } + /// The property the drift gate rests on that no other test reaches: the artifact is + /// byte-compared, so if any string in it were negotiated from the environment, + /// `cli-surface-check` would pass or fail according to the developer's `LANG`. + /// + /// `LC_ALL` is what `crate::i18n::cli_bundle` reads first, and `tr-TR` is the locale + /// that breaks case-folding implementations, so between them they exercise both the + /// negotiation path and the classic locale-sensitivity trap. `nextest` runs each test in + /// its own process, which is what makes mutating the environment here safe. + #[test] + fn the_tree_is_identical_under_two_different_locales() { + let render = |locale: &str| { + // SAFETY: single-threaded test body in a process nextest gives this test alone. + unsafe { + std::env::set_var("LC_ALL", locale); + std::env::set_var("LANG", locale); + } + serde_json::to_string_pretty(&command_tree()).expect("the tree serializes") + }; + let english = render("en_US.UTF-8"); + let turkish = render("tr_TR.UTF-8"); + let japanese = render("ja_JP.UTF-8"); + assert_eq!(english, turkish); + assert_eq!(english, japanese); + // Guards against the whole comparison passing because every render was empty. + assert!(english.contains("\"name\": \"capsule\"")); + } + + /// Both branches of the `long_about`/`long_help` dedup: a distinct long form is carried, + /// an identical one is dropped rather than stored twice. + #[test] + fn a_long_form_is_carried_only_when_it_differs_from_the_short_one() { + let distinct = Command::new("x") + .about("Short.") + .long_about("Short.\n\nAnd more.") + .arg( + clap::Arg::new("a") + .long("a") + .help("Short help.") + .long_help("Short help.\n\nAnd more."), + ); + let described = describe_command(&distinct); + assert_eq!( + described.get(field::LONG_ABOUT).and_then(Value::as_str), + Some("Short.\n\nAnd more.") + ); + let arg_entry = &described + .get(field::ARGS) + .and_then(Value::as_array) + .expect("the command has arguments")[0]; + assert_eq!( + arg_entry.get(field::LONG_HELP).and_then(Value::as_str), + Some("Short help.\n\nAnd more.") + ); + + let same = Command::new("x").about("Short.").long_about("Short.").arg( + clap::Arg::new("a") + .long("a") + .help("Short help.") + .long_help("Short help."), + ); + let described = describe_command(&same); + assert!(described.get(field::LONG_ABOUT).is_none()); + assert!( + described + .get(field::ARGS) + .and_then(Value::as_array) + .expect("the command has arguments")[0] + .get(field::LONG_HELP) + .is_none() + ); + } + /// `Command::build` is deliberately not called, so the artifact describes only what /// this crate declares — `--help` is not repeated under every command. #[test] From 4f8b8bda830c067720eb0868f2ecbe389ab36faf Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 01:02:16 -0400 Subject: [PATCH 071/243] docs(core): describe the rustdoc link asymmetry without guessing its cause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The barrel's own module doc explained the fully-qualified `crate::media::…` links by asserting that a module's documentation is resolved before its `pub use` items are in scope. That is a guess at rustdoc's resolution rules, not something this lane verified, and it read as fact. What was actually observed is the asymmetry: the bare names fail under the gate (`cargo doc --no-deps`) and resolve under `--document-private-items`, which is why the failure surfaced only in CI. The comment now says that, and says the qualified path is used because it holds either way. --- capsule-core/src/media/mod.rs | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/capsule-core/src/media/mod.rs b/capsule-core/src/media/mod.rs index 7100a0fb..cd7ebe46 100644 --- a/capsule-core/src/media/mod.rs +++ b/capsule-core/src/media/mod.rs @@ -9,8 +9,9 @@ //! [`rawshift-image`] performs format sniffing, pixel decode, and the byte encode, while this //! module owns: //! -//! - **the closed format sets** — [`StillFormat`] (what Capsule models as a still) and -//! [`DerivativeFormat`] (what a signed `DerivativeManifest.format` may say); +//! - **the closed format sets** — [`StillFormat`](crate::media::StillFormat) (what Capsule +//! models as a still) and [`DerivativeFormat`](crate::media::DerivativeFormat) (what a signed +//! `DerivativeManifest.format` may say); //! - **the pixel budget and the panic guard**, because a third-party pre-1.0 decoder is fed //! untrusted bytes on the import path; //! - **tier sizing and the downscale**, because `rawshift-image` has no resize and because a @@ -18,17 +19,34 @@ //! - **the metadata strip**, because the crate's own default embeds EXIF (GPS included) into //! every encode. //! +//! Every path above is reached through the re-exports below, and the doc links name them by +//! their full `crate::media::…` path deliberately. A bare ``[`StillFormat`]`` here does **not** +//! resolve under the `doc-check-rust` gate (`cargo doc --no-deps`, `-D warnings`) even though +//! the type is re-exported a few lines down — while it *does* resolve when the same command is +//! given `--document-private-items`, which is why the failure only appeared in CI. Rather than +//! guess at which of rustdoc's resolution rules produces that asymmetry, these links use the +//! path that resolves under both. +//! //! LQIP is *not* here: it lives in the unconditional [`crate::lqip`] module so the import //! pipeline, the uniffi FFI and `capsule-wasm` share one implementation (slice `S-B14`). This //! module produces the pixels [`crate::lqip::Lqip::encode`] consumes. //! //! # What this build can and cannot do //! -//! Every gap is a typed [`UnsupportedFormat`](MediaError::UnsupportedFormat) or a recorded -//! per-format deferral — never a silent absence, and never a panic (slice `S-B13`). Decode -//! covers JPEG, PNG, JXL, TIFF, GIF and WebP; encode covers WebP alone. HEIC, AVIF and the RAW families sniff -//! correctly and refuse to decode, because their backends need system libraries (libheif, -//! libdav1d) or an assembler (nasm) that the cross and cargo-ndk builds do not have. +//! Every gap is a typed +//! [`UnsupportedFormat`](crate::media::MediaError::UnsupportedFormat) or a recorded per-format +//! deferral — never a silent absence, and never a panic (slice `S-B13`). +//! +//! **Decode** covers JPEG, PNG, JXL, TIFF and GIF. **Encode** covers JXL alone, and losslessly: +//! `image/jxl` is the tier table's committed master format, but the pure-Rust backend is +//! `zune-jpegxl`'s `JxlSimpleEncoder`, so the tier's declared `q=50` is advisory today. +//! +//! HEIC, AVIF, WebP and the RAW families sniff correctly and refuse to decode. HEIC and AVIF +//! need system libraries (libheif, libdav1d), AVIF encode needs an assembler (nasm) the cross +//! and cargo-ndk builds do not have, and **WebP is a compile failure rather than a missing +//! toolchain**: `rawshift-image`'s WebP module passes `*const i8` where `libwebp-sys` declares +//! `*const c_char`, which is `u8` on aarch64 — every mobile target — and the module is compiled +//! by decode *or* encode, so there is no decode-only escape. //! //! [`rawshift-image`]: https://docs.rs/rawshift-image From f508bf1a74721061f67531e59eeb2bbc51d73950 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 01:04:20 -0400 Subject: [PATCH 072/243] fix(core): gate rustdoc over private items, and repair what that reveals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cargo doc --no-deps` documents public items only. The commit that made 59 submodules of `library`, `import`, `db`, `crypto::keys`, `sidecar` and `domain` crate-private therefore walked them out of the gate's reach: the gate this series added went blind to exactly the modules the same series touched, and 11 pre-existing broken links inside them were hidden rather than fixed. `doc-check-rust` now passes `--document-private-items`, so every item in the four frozen crates is linted. That surfaces 23 errors, all repaired here — 22 in `capsule-core`, 1 in `capsule-i18n`; `capsule-core-ffi` and `capsule-wasm` were already clean. Fourteen unresolved links, each for a stated reason: - `HardwareSigner` is implemented in `tbs`'s `#[cfg(windows)] backend` submodule and never imported at module scope, so the bare name resolved nowhere — four links now go through `super::HardwareSigner`. - `keys::tpm` is `#[cfg(feature = "tpm")]` and has no doc page in a default build; three links to it become prose that says so. - `p256::parse_p256_public` is private to a sibling module and so is not nameable from `tbs` at all — code span. - `Tbsi_Is_Tpm_Present` is a `windows-sys` extern behind `cfg(windows)`; it gets a Win32 URL reference, matching `Tbsip_Submit_Command` in the same header. - `ingest_current_epoch` is a method, not a free item in its module; `ProtocolMessage`, `encrypt_asset_rekey` and `ReferenceAuthority` were not in their item's scope. All four now carry a resolvable path. Seven redundant explicit link targets drop to the shortcut form, and two ambiguous links (`crypto::verify_asset`, `crate::negotiate` — each both a module and a function) are disambiguated with `fn@`; both meant the function. No `#[allow]` was added: every remaining link either resolves or was demoted to a code span that states why it cannot. Verified negatively: a broken link introduced in `import/streaming.rs` — a now-crate-private module — fails the new gate and **passes** the old public-only one. Two further review findings: - `library::receipts`' module doc still said `BlobRole`/`role_str` were re-exported there after that re-export was removed. It now says where they are actually reached: `crypto::receipts::BlobRole`, and `library::BlobRole` through the storage-verify barrel. - `keystore`'s `DeviceDek` doc said the two byte formats are length-disjoint but linked one type. It now names both, `DekKeypair` and `P256HybridDek`, in shortcut form. Finally, `capsule-wasm`'s duplicate-variant guard used `Vec::dedup`, which collapses only *consecutive* duplicates — `[A, B, A]` kept its length and passed. It uses a `HashSet` of discriminants now; confirmed by introducing a non-adjacent repeat and watching the test fail. --- .../crypto/authority/openmls_authority/mod.rs | 20 ++++++------ capsule-core/src/crypto/keys/albumstore.rs | 2 +- capsule-core/src/crypto/keys/keystore.rs | 5 +-- capsule-core/src/crypto/keys/p256.rs | 3 +- capsule-core/src/crypto/keys/tbs.rs | 32 ++++++++++--------- capsule-core/src/import/executor.rs | 7 ++-- capsule-core/src/import/planner.rs | 3 +- capsule-core/src/import/streaming.rs | 4 +-- capsule-core/src/library/receipts.rs | 13 +++++--- capsule-core/src/lifecycle/album.rs | 4 ++- capsule-core/src/lifecycle/mod.rs | 3 +- capsule-i18n/src/catalog.rs | 2 +- capsule-wasm/src/lib.rs | 9 ++++-- mise.toml | 21 ++++++++---- 14 files changed, 73 insertions(+), 55 deletions(-) diff --git a/capsule-core/src/crypto/authority/openmls_authority/mod.rs b/capsule-core/src/crypto/authority/openmls_authority/mod.rs index 70a267ff..b5c3cb50 100644 --- a/capsule-core/src/crypto/authority/openmls_authority/mod.rs +++ b/capsule-core/src/crypto/authority/openmls_authority/mod.rs @@ -1,8 +1,8 @@ //! The live [`AlbumAuthority`] backed by a real OpenMLS group (RFC 9420), pinned to the //! X-Wing PQ ciphersuite `MLS_256_XWING_CHACHA20POLY1305_SHA256_Ed25519` (`0x004D`) via the //! formally-verified libcrux provider. This is the design-target authority the offline -//! [`ReferenceAuthority`](super::ReferenceAuthority) stands in for — it drops in behind the -//! same `&dyn AlbumAuthority` seam without touching [`verify_asset`](crate::crypto::verify_asset). +//! [`ReferenceAuthority`](super::ReferenceAuthority) stands in for — it drops in behind the same +//! `&dyn AlbumAuthority` seam without touching [`verify_asset`](fn@crate::crypto::verify_asset). //! //! **Slice S-X1** landed the backend/authority layer: single-member (self) group creation, the //! epoch-ledger semantics `verify_asset` consumes (monotonic ceiling, per-epoch write-tier key, @@ -36,9 +36,8 @@ //! vehicle for a future move off the `0x004D` X-Wing suite), `intent_id`-keyed and resumable; //! - the **group re-keying ceremony** ([resilience]): a compromise/scheduled response that mints a //! fresh AMK + write-tier key for every member as one `intent_id`-keyed, resumable operation; -//! - **reconciliation** ([`ReconcileOutcome`](resilience::ReconcileOutcome)): the single -//! "bring-me-current" entry point over the server-authoritative commit chain, plus the -//! lost-commit retry primitive. +//! - **reconciliation** ([`ReconcileOutcome`]): the single "bring-me-current" entry point over +//! the server-authoritative commit chain, plus the lost-commit retry primitive. //! //! SSoT: [Cryptography — MLS](https://docs/design/cryptography/mls/), //! [Keys — Write Authority](https://docs/design/cryptography/keys/#write-authorization), @@ -213,8 +212,8 @@ struct EpochState { amk: [u8; AMK_LEN], } -/// How an epoch's write-tier key material arrives at [`ingest_current_epoch`] -/// (`OpenMlsAuthority::ingest_current_epoch`). +/// How an epoch's write-tier key material arrives at +/// [`OpenMlsAuthority::ingest_current_epoch`]. enum WriteTierIngest { /// This member is the committer: it minted the keypair (holds both halves). Minted(HybridSigningKey), @@ -1430,9 +1429,10 @@ fn describe_content(content: &ProcessedMessageContent) -> &'static str { } } -/// Turn an incoming MLS message into a [`ProtocolMessage`] (a commit or application message) or a -/// typed error. Uses OpenMLS's public `try_into_protocol_message` (the `into_protocol_message` -/// convenience is `test-utils`-gated upstream). +/// Turn an incoming MLS message into a [`ProtocolMessage`](openmls::prelude::ProtocolMessage) (a +/// commit or application message) or a typed error. Uses OpenMLS's public +/// `try_into_protocol_message` (the `into_protocol_message` convenience is `test-utils`-gated +/// upstream). fn protocol_message(message: MlsMessageIn) -> Result { message.try_into_protocol_message().map_err(|e| { OpenMlsAuthorityError::UnexpectedMessage(format!("not a protocol message: {e:?}")) diff --git a/capsule-core/src/crypto/keys/albumstore.rs b/capsule-core/src/crypto/keys/albumstore.rs index 77998d64..ae81a8bb 100644 --- a/capsule-core/src/crypto/keys/albumstore.rs +++ b/capsule-core/src/crypto/keys/albumstore.rs @@ -110,7 +110,7 @@ pub enum AlbumStoreError { type Result = std::result::Result; /// One album's persisted authority state, behind the -/// [`AlbumAuthority`](crate::crypto::authority::AlbumAuthority) seam. +/// [`AlbumAuthority`] seam. /// /// Both variants exist on **every** build, `mls` or not: a non-mls build must be able to *decode* /// an MLS-authority album and report it as [`AlbumStoreError::MlsUnavailable`], which it could not diff --git a/capsule-core/src/crypto/keys/keystore.rs b/capsule-core/src/crypto/keys/keystore.rs index 317ecdd4..ab44f68f 100644 --- a/capsule-core/src/crypto/keys/keystore.rs +++ b/capsule-core/src/crypto/keys/keystore.rs @@ -41,8 +41,9 @@ use crate::crypto::{CryptoError, pwkdf}; /// /// Both expose the same two operations — publish a public encapsulation key, decapsulate a /// ciphertext sealed to it — so every caller is agnostic to where the classical half lives. The -/// two byte formats are length-disjoint (see [`P256HybridDek`](super::P256HybridDek)), so a ciphertext for -/// one is rejected outright by the other rather than silently recovering a wrong secret. +/// two byte formats are length-disjoint — X-Wing's [`DekKeypair`] and the P-256 hybrid's +/// [`P256HybridDek`] publish and accept different lengths — so a ciphertext for one is rejected +/// outright by the other rather than silently recovering a wrong secret. pub enum DeviceDek { /// **Software fallback.** X-Wing (X25519 + ML-KEM-768), both halves in software. The /// composition every host can run, including those with no secure element and the TPM-1.2 / diff --git a/capsule-core/src/crypto/keys/p256.rs b/capsule-core/src/crypto/keys/p256.rs index 1a44ed89..e543e5a1 100644 --- a/capsule-core/src/crypto/keys/p256.rs +++ b/capsule-core/src/crypto/keys/p256.rs @@ -33,7 +33,8 @@ use crate::crypto::CryptoError; /// Parse a hardware element's P-256 public key into a verifying key. Shipping elements emit the /// point in one of three shapes: compressed SEC1 (33 bytes), uncompressed SEC1 (65 bytes, /// `0x04‖x‖y`, e.g. Secure Enclave), or the bare `x‖y` coordinate pair (64 bytes, e.g. the TPM -/// reference in [`super::tpm`]). All three normalize to the same key. +/// reference in the `tpm` adapter, which is `tpm`-feature-gated and so has no doc page in a +/// default build). All three normalize to the same key. fn parse_p256_public(point: &[u8]) -> Result { let vk = match point.len() { 33 | 65 => P256VerifyingKey::from_sec1_bytes(point), diff --git a/capsule-core/src/crypto/keys/tbs.rs b/capsule-core/src/crypto/keys/tbs.rs index ca0174d4..62ff7876 100644 --- a/capsule-core/src/crypto/keys/tbs.rs +++ b/capsule-core/src/crypto/keys/tbs.rs @@ -1,20 +1,21 @@ -//! Windows TPM 2.0 [`HardwareSigner`] over **TBS** (TPM Base Services) — slice `S-F4`. +//! Windows TPM 2.0 [`HardwareSigner`](super::HardwareSigner) over **TBS** (TPM Base Services) — +//! slice `S-F4`. //! -//! The [`tpm`](super::tpm) reference adapter drives a TPM through `tss-esapi`'s high-level ESAPI -//! and links the system `libtss2` — the Linux path. Windows exposes the TPM through `tbs.dll` -//! instead: a *raw command channel*. [`Tbsip_Submit_Command`] takes a marshalled TPM 2.0 command -//! byte-stream and returns the raw response, with no ESAPI in between. This adapter therefore -//! marshals the same key lifecycle the reference performs — `CreatePrimary` → `Create` → `Load` -//! → `EvictControl`, then `ReadPublic` / `Hash` + `Sign` — directly to the wire and submits each -//! through TBS. +//! The `tpm` reference adapter (`tpm`-feature-gated, so undocumented in a default build) drives a +//! TPM through `tss-esapi`'s high-level ESAPI and links the system `libtss2` — the Linux path. +//! Windows exposes the TPM through `tbs.dll` instead: a *raw command channel*. +//! [`Tbsip_Submit_Command`] takes a marshalled TPM 2.0 command byte-stream and returns the raw +//! response, with no ESAPI in between. This adapter therefore marshals the same key lifecycle the +//! reference performs — `CreatePrimary` → `Create` → `Load` → `EvictControl`, then `ReadPublic` / +//! `Hash` + `Sign` — directly to the wire and submits each through TBS. //! //! # P-256, composed //! //! Shipping TPMs expose **ECDSA over NIST P-256**, so — exactly like Secure Enclave and StrongBox -//! (slice `S-F2`) and the [`tpm`](super::tpm) reference — the classical half is P-256, and this -//! signer plugs into [`P256HybridSigningKey`](super::p256::P256HybridSigningKey) unchanged: -//! [`enroll`](HardwareSigner::enroll) returns the bare `x‖y` public point (64 bytes — the form -//! [`super::p256::parse_p256_public`] normalizes), and [`sign_classical`] returns a **DER-encoded** +//! (slice `S-F2`) and the `tpm` reference — the classical half is P-256, and this signer plugs +//! into [`P256HybridSigningKey`](super::p256::P256HybridSigningKey) unchanged: +//! [`enroll`](super::HardwareSigner::enroll) returns the bare `x‖y` public point (64 bytes — the +//! form `p256::parse_p256_public` normalizes), and [`sign_classical`] returns a **DER-encoded** //! ECDSA signature (the composition's contract; the TPM emits raw `r‖s`, which this adapter //! re-encodes). The ML-DSA-65 half stays software-sealed. //! @@ -22,8 +23,8 @@ //! //! The signing key is created with `fixedTPM | fixedParent | sensitiveDataOrigin`, so its private //! portion is generated inside the TPM and can never be duplicated out. -//! [`assert_non_exportable`](HardwareSigner::assert_non_exportable) re-reads the public area and -//! confirms `fixedTPM`/`fixedParent` — the TBS analogue of the reference's check. +//! [`assert_non_exportable`](super::HardwareSigner::assert_non_exportable) re-reads the public +//! area and confirms `fixedTPM`/`fixedParent` — the TBS analogue of the reference's check. //! //! # Testing //! @@ -34,7 +35,8 @@ //! Enclave availability gate. //! //! [`Tbsip_Submit_Command`]: https://learn.microsoft.com/windows/win32/api/tbs/nf-tbs-tbsip_submit_command -//! [`sign_classical`]: HardwareSigner::sign_classical +//! [`Tbsi_Is_Tpm_Present`]: https://learn.microsoft.com/windows/win32/api/tbs/nf-tbs-tbsi_is_tpm_present +//! [`sign_classical`]: super::HardwareSigner::sign_classical #[cfg(windows)] pub use backend::TbsTpmSigner; diff --git a/capsule-core/src/import/executor.rs b/capsule-core/src/import/executor.rs index 48ef0888..ec4e264b 100644 --- a/capsule-core/src/import/executor.rs +++ b/capsule-core/src/import/executor.rs @@ -182,10 +182,9 @@ pub fn execute_with_source_metadata( /// The signed-sidecar enrichment for one member path, with the fold decision logged. /// -/// The single place a [`SourceMetadataIndex`] is turned into a -/// [`SidecarEnrichment`](crate::lifecycle::SidecarEnrichment), shared by this executor and the -/// [streaming window](crate::import::streaming) so a *streamed* third-party import writes exactly -/// what a bulk one does (`S-B11` closed the gap `S-B10` left open). A path the index does not +/// The single place a [`SourceMetadataIndex`] is turned into a [`SidecarEnrichment`], shared by +/// this executor and the [streaming window](crate::import::streaming) so a *streamed* +/// third-party import writes exactly what a bulk one does (`S-B11` closed the gap `S-B10` left open). A path the index does not /// cover, or a record that folds to nothing, yields [`None`] — the untouched write path. pub(crate) fn member_enrichment( source: &SourceMetadataIndex, diff --git a/capsule-core/src/import/planner.rs b/capsule-core/src/import/planner.rs index 48225c5c..813b75a1 100644 --- a/capsule-core/src/import/planner.rs +++ b/capsule-core/src/import/planner.rs @@ -140,8 +140,7 @@ impl ImportActionPlan { /// planner's single rejection point — call it at confirmation with the run's /// `policy` and whether a streaming import was chosen (`use_streaming`); a /// conflicting combination returns [`StagedStreamingConflict`] instead of ever - /// entering the executor. Delegates to the pure - /// [`ensure_streaming_compatible`](crate::import::ensure_streaming_compatible) + /// entering the executor. Delegates to the pure [`ensure_streaming_compatible`] /// invariant so the rule lives in one place. pub fn confirm_upload_policy( &self, diff --git a/capsule-core/src/import/streaming.rs b/capsule-core/src/import/streaming.rs index 6a36f308..6be85e78 100644 --- a/capsule-core/src/import/streaming.rs +++ b/capsule-core/src/import/streaming.rs @@ -10,8 +10,8 @@ //! 1. Import the next file onto the signed path — with source release *deferred* //! ([`Workspace::import_asset_streaming`](crate::lifecycle::Workspace::import_asset_streaming)). //! 2. Upload its bundle via the injected [`AssetUploader`] seam. -//! 3. Confirm durability + custody through the `S-D4` [`ReleaseGate`](crate::library::ReleaseGate) -//! over the injected [`StorageVerifier`](crate::library::StorageVerifier) seam. +//! 3. Confirm durability + custody through the `S-D4` [`ReleaseGate`] over the injected +//! [`StorageVerifier`] seam. //! 4. **Release** the local original (and delete the Move-mode source) *only* on the `durable` //! verdict, so the device never drops the only copy of bytes the server has not confirmed. //! 5. Advance the window. diff --git a/capsule-core/src/library/receipts.rs b/capsule-core/src/library/receipts.rs index a35d6748..1142453d 100644 --- a/capsule-core/src/library/receipts.rs +++ b/capsule-core/src/library/receipts.rs @@ -9,11 +9,14 @@ //! server that withholds receipts never becomes the sole holder of an only-copy. //! //! This module is the client's **persistence** path, and only that. The receipt type, its -//! verification and [`BlobRole`] moved to [`crate::crypto::receipts`] (`S-C46`) so the server -//! that *issues* receipts shares one definition instead of mirroring it — a signed structure -//! defined twice is a signature that eventually stops verifying, and the failure would look -//! like the server withholding receipts. They are re-exported here, so every path a client -//! already uses keeps working. +//! verification and the blob-role enum moved to [`crate::crypto::receipts`] (`S-C46`) so the +//! server that *issues* receipts shares one definition instead of mirroring it — a signed +//! structure defined twice is a signature that eventually stops verifying, and the failure would +//! look like the server withholding receipts. The receipt type and its verification are +//! re-exported here and through [`crate::library`], so every path a client already uses keeps +//! working. The role enum is not: it is reached as +//! [`crypto::receipts::BlobRole`](crate::crypto::receipts::BlobRole), and as +//! [`library::BlobRole`](crate::library::BlobRole) through the storage-verify barrel. //! //! Persistence is first-class, not a cache: the log is appended to //! `media/{YYYY}/{YYYY-MM}/{uuid}.receipts.cbor` and included verbatim in the backup artifact — diff --git a/capsule-core/src/lifecycle/album.rs b/capsule-core/src/lifecycle/album.rs index 33ffa165..a95ca906 100644 --- a/capsule-core/src/lifecycle/album.rs +++ b/capsule-core/src/lifecycle/album.rs @@ -260,7 +260,9 @@ impl Workspace { /// under (`amk_version`), never assuming the album's current epoch — so an asset imported /// before a rotation still derives the key it was encrypted with. Because the fresh /// `nonce_prefix` is folded into the salt, this is the read/regenerate path; a *fresh* - /// write goes through [`encrypt_asset_rekey`], which draws the nonce and derives together. + /// write goes through + /// [`encrypt_asset_rekey`](crate::crypto::encryption::encrypt_asset_rekey), which draws the + /// nonce and derives together. pub(super) fn file_key( &self, album: &AlbumKeys, diff --git a/capsule-core/src/lifecycle/mod.rs b/capsule-core/src/lifecycle/mod.rs index 70dc741c..4b3d7da9 100644 --- a/capsule-core/src/lifecycle/mod.rs +++ b/capsule-core/src/lifecycle/mod.rs @@ -381,7 +381,8 @@ pub struct Workspace { albums: HashMap, /// Per-album write authority behind the [`AlbumAuthority`](crate::crypto::authority::AlbumAuthority) /// seam (`&Authority` coerces to `&dyn AlbumAuthority` at every `verify_asset` call site). The - /// offline [`ReferenceAuthority`] is the shipped default; the enum lets the live + /// offline [`ReferenceAuthority`](crate::crypto::authority::ReferenceAuthority) is the shipped + /// default; the enum lets the live /// [`OpenMlsAuthority`](crate::crypto::authority::OpenMlsAuthority) drop in without the /// lifecycle naming a concrete backend. **Persisted** alongside the album keys in /// [`AlbumStore`](crate::crypto::keys::AlbumStore) and restored on open (`S-A10`) — without diff --git a/capsule-i18n/src/catalog.rs b/capsule-i18n/src/catalog.rs index ad387bb0..d62d01dd 100644 --- a/capsule-i18n/src/catalog.rs +++ b/capsule-i18n/src/catalog.rs @@ -19,7 +19,7 @@ pub struct Bundle { impl Bundle { /// Build a bundle for `locale`, using the source locale as the fallback. /// - /// `locale` should already be a supported tag (see [`crate::negotiate`]); an + /// `locale` should already be a supported tag (see [`negotiate`](fn@crate::negotiate)); an /// unknown locale yields a bundle backed solely by the source catalog. #[must_use] pub fn for_locale(locale: &str) -> Self { diff --git a/capsule-wasm/src/lib.rs b/capsule-wasm/src/lib.rs index 1fb7cb66..d8889fd5 100644 --- a/capsule-wasm/src/lib.rs +++ b/capsule-wasm/src/lib.rs @@ -374,6 +374,8 @@ pub fn drop_passphrase_proof( #[cfg(test)] mod tests { + use std::collections::HashSet; + use super::*; /// The full variant set, written out so the exhaustive match below is meaningful. @@ -462,9 +464,10 @@ mod tests { } // `every_sharing_error` is hand-written, so guard it against silently listing the same - // variant twice and thereby covering one fewer than the array length claims. - let mut kinds: Vec<_> = all.iter().map(std::mem::discriminant).collect(); - kinds.dedup(); + // variant twice and thereby covering one fewer than the array length claims. A set, not + // `Vec::dedup`: that collapses only *consecutive* duplicates, so `[A, B, A]` would slip + // through with its length unchanged. + let kinds: HashSet<_> = all.iter().map(std::mem::discriminant).collect(); assert_eq!( kinds.len(), all.len(), diff --git a/mise.toml b/mise.toml index 369913c9..6260dcc6 100644 --- a/mise.toml +++ b/mise.toml @@ -135,15 +135,22 @@ run = "cargo clippy --workspace --fix --allow-dirty -- $CLIPPY_FLAGS" [tasks.lint-check-rust] run = "cargo clippy --workspace -- $CLIPPY_FLAGS" -# The rustdoc gate for the frozen crates (issue #399): a broken intra-doc link, an ambiguous -# one, or public documentation pointing at a private item is an error, not a warning. Scoped -# to the four crates whose public API is frozen; the rest of the workspace still carries 23 -# such spans and is widened in a follow-up. `--no-deps` so a dependency's own doc warnings -# cannot fail our build. +# The rustdoc gate for the frozen crates (issue #399): a broken intra-doc link or an ambiguous +# one is an error, not a warning. Scoped to the four crates whose public API is frozen; the rest +# of the workspace is widened in a follow-up. +# +# `--document-private-items` is load-bearing, not thoroughness for its own sake. #399 turned 59 +# submodules of `library`, `import`, `db`, `crypto::keys`, `sidecar` and `domain` into +# `pub(crate) mod`. A public-only `cargo doc` stops reading their `//!` headers and their items' +# docs the moment they stop being public — so the gate would have gone blind to exactly the +# modules the same change touched, and 11 pre-existing broken links in them would have been +# hidden rather than fixed. With the flag every item in these four crates is linted. +# +# `--no-deps` so a dependency's own doc warnings cannot fail our build. [tasks.doc-check-rust] -description = "Rustdoc gate for the frozen crates (intra-doc links, private-item leaks)" +description = "Rustdoc gate for the frozen crates (intra-doc links, over all items)" env = { RUSTDOCFLAGS = "-D warnings" } -run = "cargo doc --no-deps -p capsule-core -p capsule-core-ffi -p capsule-wasm -p capsule-i18n" +run = "cargo doc --no-deps --document-private-items -p capsule-core -p capsule-core-ffi -p capsule-wasm -p capsule-i18n" # nextest: cross-binary parallel scheduling + process-per-test isolation. Two # invocations — the workspace (default features), then capsule-core's FFI surface. From fe2fe3a1149414d24c5add0d858b14e8ae9d38b3 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 01:09:15 -0400 Subject: [PATCH 073/243] docs(reference): render the REST contract from the Kynos OpenAPI document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/reference/api/` now publishes all 51 paths and 59 operations of the committed Kynos document, across eleven hand-ordered group pages, plus a hand-written overview carrying what is true of every endpoint — the auth model, the negotiation headers, and the error contract — so no generated page repeats it fifty-nine times (slice `S-Z9`). Grouping is by hand because the document offers nothing to group by: none of its 59 operations carries a tag. Autogenerating would have meant 59 pages ordered by filename, which `design/developer-docs.md` forbids, and would have scattered the four operations of the upload protocol across the alphabet. The group names track the surface map in `design/api-surfaces.md`. Matching is longest-prefix, so the table's reading order and its matching order stay independent: adding a narrower group later cannot silently depend on where it sits. An operation no group claims fails the build, naming it. Links inside artifact prose are rewritten for the site. A repo-relative path to a design document becomes the Starlight route the same file serves; a rustdoc intra-doc path, which no web server resolves, keeps its text and loses its link. Both forms are live in the committed document, and both are correct where they were written — this is republishing, not an error in the source. Also fixes defects found reviewing the CLI half against the real documents: - Generated output is cleared before it is rewritten. A page a later run no longer emits used to stay on disk, and the directory is gitignored, so nothing showed it: Astro kept routing and indexing a page no artifact described, on one machine and on no CI runner. - A nullable type renders as `string \| null`. Unescaped, the separator opened a fourth column and shifted every cell in the row. - A closing fence must carry no info string, or a nested ```js inside a ```sh example ends the block early — demoting the example's comments and skipping every real heading after it. - `<` is escaped: Markdown passes raw HTML through, so an angle-bracketed placeholder vanished from the page. - A required option is spelled in the usage line instead of folded into `[OPTIONS]`, which was handing the reader a command that fails to parse. - Blank-line tidying skips fenced blocks, so the generator stops editing the examples it quotes. - The artifact is rejected when it parses but describes nothing, rather than publishing an empty heading under a stable badge. - Frontmatter titles are quoted, `preview`/`deploy` build first, and the `docs` filter names the root `biome.jsonc` that `capsule-docs` extends. The root `long_about` uses Markdown list markers so the generated page renders the list it already is — rule 2 in practice: the page was wrong, so the annotation it came from was what changed. --- .github/workflows/ci.yml | 6 + capsule-cli/cli-surface.json | 2 +- capsule-cli/src/cli/mod.rs | 6 +- capsule-docs/package.json | 4 +- capsule-docs/scripts/gen-reference.mjs | 607 +++++++++++++++++- capsule-docs/scripts/gen-reference.test.mjs | 547 +++++++++++++++- capsule-docs/scripts/lib/walk.mjs | 2 +- capsule-docs/scripts/reference-groups.mjs | 135 ++++ .../src/content/docs/reference/api.md | 101 +++ .../src/content/docs/reference/cli.md | 4 +- .../src/content/docs/reference/index.md | 1 + 11 files changed, 1382 insertions(+), 33 deletions(-) create mode 100644 capsule-docs/src/content/docs/reference/api.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 005a6b0c..48334684 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,6 +72,9 @@ jobs: - 'capsule-docs/**' - 'capsule-cli/cli-surface.json' - 'capsule-server/openapi.json' + # `capsule-docs/biome.jsonc` extends the root one, so a change there can fail + # this job's format and lint steps from outside `capsule-docs/**`. + - 'biome.jsonc' - 'mise.toml' - 'mise-tasks/**' - '.github/workflows/ci.yml' @@ -88,6 +91,9 @@ jobs: - 'capsule-docs/endpoint-census-allowlist.txt' - 'capsule-docs/planned-modules.txt' - 'capsule-server/openapi.json' + # No check reads this one today; it is here because the cross-links check + # resolves any repo-relative path a document names, and the reference prose + # now names this artifact. Cheap: the job installs nothing. - 'capsule-cli/cli-surface.json' - 'capsule-*/src/**' - '**/*.md' diff --git a/capsule-cli/cli-surface.json b/capsule-cli/cli-surface.json index d54af726..2a959b2e 100644 --- a/capsule-cli/cli-surface.json +++ b/capsule-cli/cli-surface.json @@ -1,6 +1,6 @@ { "about": "A command line interface for Capsule - the photo management platform", - "long_about": "Capsule CLI provides tools for managing your photos and albums:\n• Authentication management\n• Sync local and remote data\n• Check status and list files\n• Manage albums and collections", + "long_about": "Capsule CLI provides tools for managing your photos and albums:\n\n- Authentication management\n- Sync local and remote data\n- Check status and list files\n- Manage albums and collections", "name": "capsule", "schema": 1, "subcommands": [ diff --git a/capsule-cli/src/cli/mod.rs b/capsule-cli/src/cli/mod.rs index 10d298ac..6e713e0b 100644 --- a/capsule-cli/src/cli/mod.rs +++ b/capsule-cli/src/cli/mod.rs @@ -50,7 +50,11 @@ const COMMAND_TREE_SCHEMA: u32 = 1; #[command(name = "capsule")] #[command(about = "A command line interface for Capsule - the photo management platform")] #[command( - long_about = "Capsule CLI provides tools for managing your photos and albums:\n• Authentication management\n• Sync local and remote data\n• Check status and list files\n• Manage albums and collections" + // Markdown list markers, not `•`: this text is the root `long_about` in the committed + // command tree, and the reference page renders it as prose. Bullet characters soft-wrap + // into one run-on paragraph there, while `-` renders as the list it already is. A + // terminal shows `-` as a list too, so `capsule --help` loses nothing. + long_about = "Capsule CLI provides tools for managing your photos and albums:\n\n- Authentication management\n- Sync local and remote data\n- Check status and list files\n- Manage albums and collections" )] pub(crate) struct Cli { #[command(subcommand)] diff --git a/capsule-docs/package.json b/capsule-docs/package.json index d701943f..03aa9971 100644 --- a/capsule-docs/package.json +++ b/capsule-docs/package.json @@ -7,9 +7,9 @@ "dev": "bun scripts/gen-reference.mjs && astro dev", "start": "bun scripts/gen-reference.mjs && astro dev", "build": "bun scripts/gen-reference.mjs && astro build", - "preview": "wrangler pages dev ./dist", + "preview": "bun run build && wrangler pages dev ./dist", "astro": "astro", - "deploy": "wrangler pages deploy ./dist", + "deploy": "bun run build && wrangler pages deploy ./dist", "test": "vitest run" }, "dependencies": { diff --git a/capsule-docs/scripts/gen-reference.mjs b/capsule-docs/scripts/gen-reference.mjs index 60119efc..ca9e971c 100644 --- a/capsule-docs/scripts/gen-reference.mjs +++ b/capsule-docs/scripts/gen-reference.mjs @@ -31,10 +31,10 @@ * before `astro dev` and `astro build`. */ -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { CLI_PAGES } from './reference-groups.mjs'; +import { API_GROUPS, CLI_PAGES, groupForPath } from './reference-groups.mjs'; /** Repo-relative path of the committed command-tree artifact. */ export const CLI_SURFACE = 'capsule-cli/cli-surface.json'; @@ -42,12 +42,48 @@ export const CLI_SURFACE = 'capsule-cli/cli-surface.json'; /** Command-tree schema version this script understands. */ const CLI_SCHEMA = 1; +/** Repo-relative path of the committed Kynos OpenAPI document. */ +export const OPENAPI_DOCUMENT = 'capsule-server/openapi.json'; + +/** + * OpenAPI major.minor this generator renders. + * + * Pinned rather than accepted loosely because `AGENTS.md` requires the served document to be + * 3.2 and forbids emitting a 3.1 or 3.0 one: a document that arrived at 3.1 would mean the + * emitter regressed, and rendering it anyway would publish the regression as documentation. + */ +const OPENAPI_VERSION = '3.2'; + /** Repo-relative directory the generated CLI pages are written to. */ const CLI_OUT = 'capsule-docs/src/content/docs/reference/cli'; +/** Repo-relative directory the generated REST pages are written to. */ +const API_OUT = 'capsule-docs/src/content/docs/reference/api'; + +/** HTTP methods a path item may carry. Anything else in a path item is not an operation. */ +const METHODS = [ + 'get', + 'put', + 'post', + 'delete', + 'options', + 'head', + 'patch', + 'trace', +]; + +/** How deep a `$ref` chain is followed before deeper types become anchor links only. */ +const MAX_SCHEMA_DEPTH = 2; + /** The banner every generated page carries, as an HTML comment and as prose. */ const GENERATED_BY = 'capsule-docs/scripts/gen-reference.mjs'; +/** A line that opens a fenced block: ``` or ~~~ with any info string. */ +const FENCE_OPEN = /^\s*(`{3,}|~{3,})/; + +/** A line that *closes* one: the same run with nothing after it but whitespace. */ +const FENCE_CLOSE = /^\s*(`{3,}|~{3,})\s*$/; + /** * Shift every ATX heading in `markdown` down by `offset` levels, clamped at h6. * @@ -59,6 +95,13 @@ const GENERATED_BY = 'capsule-docs/scripts/gen-reference.mjs'; * Fenced blocks are skipped: a `#` on the first column of a shell example is a comment, not * a heading, and demoting it would corrupt the example. * + * **ATX only.** A setext heading (`Title` over `=====`) is left alone. Neither committed + * artifact uses one — verified across all 971 descriptions in the OpenAPI document — and + * rewriting a line based on the line below it is a different and more fragile + * transformation than prefixing hashes: a `---` under a paragraph is a thematic break, and + * over one it is frontmatter. The limitation is tested, so it fails visibly if it stops + * being acceptable. + * * @param {string} markdown Prose that may contain headings. * @param {number} offset Levels to add. * @returns {string} The prose with its headings demoted. @@ -68,7 +111,7 @@ export function demoteHeadings(markdown, offset) { return markdown .split('\n') .map((line) => { - const fenceMatch = /^\s*(`{3,}|~{3,})/.exec(line); + const fenceMatch = FENCE_OPEN.exec(line); if (fence === null) { if (fenceMatch) { fence = { @@ -78,10 +121,15 @@ export function demoteHeadings(markdown, offset) { return line; } } else { + // A *closing* fence carries no info string. Without that anchor a + // ```` ```js ```` line nested inside a ```` ```sh ```` example closes the + // block early, which both demotes the `#` comments inside the example and + // leaves every real heading after it untouched. + const closer = FENCE_CLOSE.exec(line); if ( - fenceMatch && - fenceMatch[1][0] === fence.char && - fenceMatch[1].length >= fence.length + closer && + closer[1][0] === fence.char && + closer[1].length >= fence.length ) { fence = null; } @@ -95,6 +143,55 @@ export function demoteHeadings(markdown, offset) { .join('\n'); } +/** Where the site's content lives, for turning a repo path into a route. */ +const SITE_CONTENT = 'capsule-docs/src/content/docs'; + +/** + * Rewrite links inside artifact prose so they mean the same thing on the site. + * + * The prose in both artifacts is written in its own context — a Rust doc comment or a `clap` + * annotation — and is republished here in another. Two link forms travel badly, and both are + * live in the committed OpenAPI document: + * + * 1. **A repo-relative path to a design document.** `[chunk contract](../../../capsule-docs/…/upload-protocol.md)` + * resolves from the crate source and from nowhere on the site. It has an exact + * equivalent — the Starlight route the same file serves — so it is rewritten, not + * dropped: the reader keeps the reference. + * 2. **A rustdoc intra-doc link.** ``[`revoke_all_signing_bytes`](capsule_core::crypto::revoke::revoke_all_signing_bytes)`` + * is a path rustdoc resolves and no web server does. There is no equivalent, so the + * link is dropped and its text kept. + * + * Absolute URLs, site routes, and anchors are left alone. + * + * The alternative was to fix the annotations in `capsule-server`, which rule 2 would normally + * demand. It is the wrong fix here: those links are correct for rustdoc, which is also a + * published surface, and "correct in the crate, wrong on the site" is a property of + * republishing rather than an error in the source. + * + * @param {string} markdown Prose from an artifact. + * @returns {string} The prose with its links made meaningful on the site. + */ +function rewriteLinks(markdown) { + return markdown.replace( + /(!?\[)([^\]]*)(\]\(\s*)([^)\s]+)(\s*\))/g, + (whole, open, text, mid, target, close) => { + if (/^(?:[a-z][a-z0-9+.-]*:|\/|#)/i.test(target)) return whole; + const normalized = target.replace(/^(?:\.\.\/)+/, ''); + if ( + normalized.startsWith(`${SITE_CONTENT}/`) && + normalized.endsWith('.md') + ) { + const route = normalized + .slice(`${SITE_CONTENT}/`.length) + .replace(/(?:\/index)?\.md$/, ''); + return `${open}${text}${mid}/${route}/${close}`; + } + // No equivalent: keep the words, drop the link. + return text; + }, + ); +} + /** * Escape a string for a Markdown table cell: a literal `|` would otherwise open a new * column, and a newline would end the row. @@ -103,10 +200,17 @@ export function demoteHeadings(markdown, offset) { * @returns {string} */ function cell(text) { - return text - .replace(/\s*\n\s*/g, ' ') - .replace(/\|/g, '\\|') - .trim(); + return ( + rewriteLinks(text) + .replace(/\s*\n\s*/g, ' ') + .replace(/\|/g, '\\|') + // Markdown passes raw HTML through, so an angle-bracketed placeholder — the most + // likely idiom there is in help text for a command line — parses as a tag and + // disappears from the rendered page, taking everything up to the next `>` with + // it if it never closes. + .replace(/ `\`${value.name}\``).join(', ')}.`, @@ -230,6 +357,48 @@ function describeArg(arg) { return parts.join(' ') || '—'; } +/** + * Collapse runs of blank lines outside fenced blocks, and trim the trailing one. + * + * Section assembly leaves a blank run wherever a table ended one block and a heading opened + * the next. Markdown does not care; a reader diffing two generations does, and so does + * markdownlint on any machine that has built the site. Applied outside fences only, because + * a blank run *inside* an example is part of the example — a whole-document regex would have + * the generator quietly editing the code it is quoting. + * + * @param {string} markdown + * @returns {string} + */ +function tidyBlankLines(markdown) { + let fence = null; + const out = []; + for (const line of markdown.split('\n')) { + if (fence === null) { + const open = FENCE_OPEN.exec(line); + if (open) { + fence = { char: open[1][0], length: open[1].length }; + } else if ( + line.trim() === '' && + out.length > 0 && + out[out.length - 1].trim() === '' + ) { + continue; + } + } else { + const closer = FENCE_CLOSE.exec(line); + if ( + closer && + closer[1][0] === fence.char && + closer[1].length >= fence.length + ) { + fence = null; + } + } + out.push(line); + } + return out.join('\n').trimEnd(); +} + /** * A Markdown table, or the empty string when there are no rows — an empty table renders as * a stray header and says nothing. @@ -268,7 +437,14 @@ function renderCommand(command, path, level) { const spelled = spell(arg); usage.push(arg.required ? spelled : `[${spelled}]`); } - if (options.length > 0) usage.push('[OPTIONS]'); + // Required options are spelled out rather than folded into `[OPTIONS]`. `capsule import` + // requires `--library `, and a usage line that hides it hands the reader a command + // that fails to parse — a reference page that is wrong, which is the one thing this + // pipeline exists to prevent. + for (const option of options.filter((candidate) => candidate.required)) { + usage.push(spell(option)); + } + if (options.some((option) => !option.required)) usage.push('[OPTIONS]'); if (subcommands.length > 0) usage.push(''); const sections = [ @@ -282,7 +458,7 @@ function renderCommand(command, path, level) { // nests under the command it describes instead of outranking it. const prose = command.long_about ?? command.about; if (prose) { - sections.push(demoteHeadings(prose, level), ''); + sections.push(demoteHeadings(rewriteLinks(prose), level), ''); } const positionalTable = table( @@ -331,7 +507,7 @@ export function renderCliPage(surface) { const page = CLI_PAGES[0]; return `${[ '---', - `title: ${page.label}`, + `title: ${yamlString(page.label)}`, `description: ${yamlString(page.description)}`, 'status: stable', '---', @@ -342,15 +518,383 @@ export function renderCliPage(surface) { '`mise run cli-surface-check` keeps current. To change a description on this page, change', 'the `clap` annotation it comes from and regenerate — this file is build output.', '', - renderCommand(surface, [surface.name], 2), - ] - .join('\n') - // Section assembly can leave a run of blank lines where a table ended one block and - // a heading opened the next, and a trailing one at the end of the file. Markdown - // does not care; a reader diffing two generations does, and so does markdownlint - // on any machine that has built the site. - .replace(/\n{3,}/g, '\n\n') - .trimEnd()}\n`; + tidyBlankLines(renderCommand(surface, [surface.name], 2)), + ].join('\n')}\n`; +} + +/** + * The committed Kynos OpenAPI document. + * + * @param {string} root Repository root. + * @returns {Record} + */ +export function readOpenApiDocument(root) { + const document = readArtifact(root, OPENAPI_DOCUMENT); + const version = String(document?.openapi ?? ''); + if (!version.startsWith(`${OPENAPI_VERSION}.`)) { + throw new Error( + `${OPENAPI_DOCUMENT} declares OpenAPI ${version || '(nothing)'}, and this ` + + `generator renders ${OPENAPI_VERSION}. The served document is pinned to ` + + `${OPENAPI_VERSION} with \`openapi_as(SpecVersion::V3_2)\`; a lower version ` + + 'means the emitter regressed, and rendering it would publish the regression.', + ); + } + if (!document.paths || typeof document.paths !== 'object') { + throw new Error(`${OPENAPI_DOCUMENT} declares no paths.`); + } + return document; +} + +/** + * Bucket every operation in the document into its group, in a stable order. + * + * **Fails on an operation no group claims.** That is the whole value of a hand-curated + * table: a new endpoint family cannot publish under a heading nobody chose, and — the case + * that actually bites — cannot silently fail to publish at all while the build stays green. + * + * @param {Record} document The parsed OpenAPI document. + * @returns {Map, operationId?: string }>>} + * Keyed by group slug, in `API_GROUPS` order, with every group present. + */ +export function bucketOperations(document) { + /** @type {Map} */ + const buckets = new Map(API_GROUPS.map((group) => [group.slug, []])); + + // Sorted rather than taken in document order: JSON object order is an emitter detail, + // and a page whose sections reshuffle when the server's route registration is reordered + // produces a diff nobody can read. + for (const path of Object.keys(document.paths).sort()) { + const group = groupForPath(path); + const item = document.paths[path] ?? {}; + const methods = METHODS.filter((method) => item[method]); + if (!group) { + const named = methods + .map((method) => `${method.toUpperCase()} ${path}`) + .join(', '); + throw new Error( + `no reference group claims ${named || path}. Add its prefix to a group in ` + + 'capsule-docs/scripts/reference-groups.mjs — an endpoint family must not ' + + 'publish under a heading nobody chose, and must not silently fail to publish.', + ); + } + for (const method of methods) { + buckets.get(group.slug).push({ + path, + method: method.toUpperCase(), + operation: item[method], + operationId: item[method].operationId, + }); + } + } + return buckets; +} + +/** + * Render a schema's type as a short string: `string`, `string | null`, `integer[]`, or the + * name of a referenced schema. + * + * @param {Record} schema + * @returns {string} + */ +function typeOf(schema) { + if (!schema || typeof schema !== 'object') return 'unknown'; + if (schema.$ref) return refName(schema.$ref); + if (schema.type === 'array') { + return `${typeOf(schema.items ?? {})}[]`; + } + const type = schema.type; + // OpenAPI 3.1 and later spell nullability as a type union rather than as `nullable`, so + // `type` is an array here and interpolating it directly yields `string,null`. + if (Array.isArray(type)) return type.join(' | '); + if (typeof type === 'string') return type; + if (schema.enum) return 'string'; + return 'object'; +} + +/** + * The schema name a local `$ref` points at. + * + * @param {string} ref + * @returns {string} + */ +function refName(ref) { + return ref.split('/').pop(); +} + +/** + * The set of schema names a page must document, walked from its operations to + * `MAX_SCHEMA_DEPTH`. + * + * Depth-bounded rather than exhaustive, and visited-set guarded, so a self-referential or + * mutually-referential schema cannot spin: the current document has no cycle, but a renderer + * that would hang on one is a renderer that fails the day someone adds a tree. + * + * @param {Record} document + * @param {Array<{ operation: Record }>} operations + * @returns {string[]} Schema names, sorted. + */ +function schemasUsedBy(document, operations) { + const seen = new Set(); + + const visit = (schema, depth) => { + if (!schema || typeof schema !== 'object' || depth > MAX_SCHEMA_DEPTH) + return; + if (Array.isArray(schema)) { + for (const entry of schema) visit(entry, depth); + return; + } + if (schema.$ref) { + const name = refName(schema.$ref); + if (seen.has(name)) return; + seen.add(name); + visit(document.components?.schemas?.[name], depth + 1); + return; + } + for (const value of Object.values(schema)) visit(value, depth); + }; + + for (const { operation } of operations) { + visit(operation.requestBody ?? {}, 0); + visit(operation.responses ?? {}, 0); + visit(operation.parameters ?? [], 0); + } + return [...seen].sort(); +} + +/** + * The media type and schema of a request or response body, or null when it carries none. + * + * @param {Record | undefined} carrier + * @returns {{ mediaType: string, schema: Record } | null} + */ +function bodyOf(carrier) { + const content = carrier?.content; + if (!content) return null; + const mediaType = Object.keys(content).sort()[0]; + if (!mediaType) return null; + return { mediaType, schema: content[mediaType]?.schema ?? {} }; +} + +/** + * A schema reference rendered as a link into this page's appendix, when the appendix + * documents it, and as bare code when it does not. + * + * @param {string[]} documented Schema names the page's appendix carries. + * @param {Record} schema + * @returns {string} + */ +function schemaLink(documented, schema) { + const rendered = typeOf(schema); + const bare = rendered.replace(/\[\]$/, ''); + // A nullable type renders as `string | null`, and every use of this is a table cell, so + // the separator has to be escaped or it opens a fourth column and shifts the row. + // GFM requires the escape inside a code span too, and renders it as a bare pipe. + const shown = rendered.replace(/\|/g, '\\|'); + return documented.includes(bare) + ? `[\`${shown}\`](#${bare.toLowerCase()})` + : `\`${shown}\``; +} + +/** + * Render one operation. + * + * @param {{ path: string, method: string, operation: Record }} entry + * @param {string[]} documented Schema names the page's appendix carries. + * @returns {string} + */ +function renderOperation({ path, method, operation }, documented) { + const sections = [`## ${method} ${path}`]; + + if (operation.summary) { + sections.push(demoteHeadings(rewriteLinks(operation.summary), 2)); + } + if (operation.description) { + // Demoted by 3: an operation is an h2, so a description opening at `#` becomes an + // h4 under it rather than a second page title. + sections.push(demoteHeadings(rewriteLinks(operation.description), 3)); + } + + const security = operation.security; + if (Array.isArray(security) && security.length > 0) { + const schemes = security + .flatMap((requirement) => Object.keys(requirement)) + .sort(); + sections.push( + `**Authentication:** required — ${schemes.map((scheme) => `\`${scheme}\``).join(', ')}.`, + ); + } else { + sections.push('**Authentication:** none.'); + } + + const parameters = operation.parameters ?? []; + if (parameters.length > 0) { + sections.push( + table( + ['Parameter', 'In', 'Type', 'Description'], + [...parameters] + .sort( + (a, b) => + a.in.localeCompare(b.in) || + a.name.localeCompare(b.name), + ) + .map((parameter) => [ + `\`${parameter.name}\``, + `\`${parameter.in}\``, + schemaLink(documented, parameter.schema ?? {}), + [ + parameter.required ? '**Required.**' : '', + parameter.description + ? sentence(cell(parameter.description)) + : '', + parameter.example === undefined + ? '' + : `Example: \`${parameter.example}\`.`, + ] + .filter(Boolean) + .join(' ') || '—', + ]), + ), + ); + } + + const requestBody = bodyOf(operation.requestBody); + if (requestBody) { + sections.push( + `**Request body** (${operation.requestBody.required ? 'required' : 'optional'}, ` + + `\`${requestBody.mediaType}\`): ${schemaLink(documented, requestBody.schema)}`, + ); + } + + const responses = Object.entries(operation.responses ?? {}).sort( + ([a], [b]) => Number(a) - Number(b) || a.localeCompare(b), + ); + if (responses.length > 0) { + sections.push( + table( + ['Status', 'Body', 'Description'], + responses.map(([status, response]) => { + const body = bodyOf(response); + const headers = Object.keys(response.headers ?? {}).sort(); + return [ + `\`${status}\``, + body + ? `${schemaLink(documented, body.schema)}
\`${body.mediaType}\`` + : '—', + [ + response.description + ? sentence(cell(response.description)) + : '', + headers.length > 0 + ? `Headers: ${headers.map((header) => `\`${header}\``).join(', ')}.` + : '', + ] + .filter(Boolean) + .join(' ') || '—', + ]; + }), + ), + ); + } + + return sections.filter(Boolean).join('\n\n'); +} + +/** + * Render one schema as an appendix entry. + * + * @param {string} name + * @param {Record} document The document, for the schema's own definition. + * @param {string[]} documented Every schema name this page's appendix carries. + * @returns {string} + */ +function renderSchema(name, document, documented) { + const schema = document.components?.schemas?.[name] ?? {}; + const sections = [`### ${name}`]; + + if (schema.description) + sections.push(demoteHeadings(schema.description, 3)); + + if (schema.enum) { + sections.push( + `One of: ${schema.enum.map((value) => `\`${value}\``).join(', ')}.`, + ); + return sections.join('\n\n'); + } + + const required = new Set(schema.required ?? []); + const properties = Object.entries(schema.properties ?? {}); + if (properties.length === 0) { + sections.push(`Type: \`${typeOf(schema)}\`.`); + return sections.join('\n\n'); + } + + sections.push( + table( + ['Field', 'Type', 'Description'], + properties.map(([field, property]) => [ + `\`${field}\``, + schemaLink(documented, property), + [ + required.has(field) ? '**Required.**' : '', + property.description + ? sentence(cell(property.description)) + : '', + ] + .filter(Boolean) + .join(' ') || '—', + ]), + ), + ); + return sections.join('\n\n'); +} + +/** + * The generated `/reference/api//` page. + * + * @param {import('./reference-groups.mjs').ApiGroup} group + * @param {Array<{ path: string, method: string, operation: Record }>} operations + * @param {Record} document + * @returns {string} Markdown, frontmatter included. + */ +export function renderApiPage(group, operations, document) { + const documented = schemasUsedBy(document, operations); + + const head = [ + '---', + `title: ${yamlString(group.label)}`, + `description: ${yamlString(group.description)}`, + 'status: stable', + '---', + '', + ``, + '', + `Generated from \`${OPENAPI_DOCUMENT}\`, the OpenAPI ${OPENAPI_VERSION} document`, + '`capsule-server` emits and `mise run openapi-check-kynos` keeps current. To change a', + 'description on this page, change the annotation on the handler or model it comes from', + 'and regenerate — this file is build output. The auth model, error contract, and', + 'conventions common to every endpoint are on the [REST API overview](/reference/api/).', + ].join('\n'); + + const body = operations + .map((entry) => renderOperation(entry, documented)) + .join('\n\n'); + + const appendix = + documented.length === 0 + ? '' + : [ + '## Schemas', + '', + 'The models these endpoints carry. A field whose type names another model links', + 'to it; a model reached more than two references deep is named without being', + 'expanded here.', + '', + documented + .map((name) => renderSchema(name, document, documented)) + .join('\n\n'), + ].join('\n'); + + return `${tidyBlankLines([head, body, appendix].filter(Boolean).join('\n\n'))}\n`; } /** @@ -364,6 +908,8 @@ export function renderCliPage(surface) { */ export function generate(root) { const surface = readCliSurface(root); + const document = readOpenApiDocument(root); + const buckets = bucketOperations(document); /** @type {Array<{ path: string, body: string }>} */ const pages = [ @@ -371,8 +917,21 @@ export function generate(root) { path: `${CLI_OUT}/${CLI_PAGES[0].slug}.md`, body: renderCliPage(surface), }, + ...API_GROUPS.map((group) => ({ + path: `${API_OUT}/${group.slug}.md`, + body: renderApiPage(group, buckets.get(group.slug) ?? [], document), + })), ]; + // Cleared, not merged into. A page this run no longer emits — a group renamed, a surface + // dropped — would otherwise stay on disk, and because the directory is gitignored + // `git status` never shows it: Astro would keep routing, indexing, and link-validating a + // page no artifact describes, on this machine and on no CI runner. Only the generated + // directories, so the hand-written overviews beside them survive. + for (const directory of [CLI_OUT, API_OUT]) { + rmSync(join(root, directory), { recursive: true, force: true }); + } + for (const { path, body } of pages) { mkdirSync(dirname(join(root, path)), { recursive: true }); writeFileSync(join(root, path), body); diff --git a/capsule-docs/scripts/gen-reference.test.mjs b/capsule-docs/scripts/gen-reference.test.mjs index c2942f9e..1770f4d2 100644 --- a/capsule-docs/scripts/gen-reference.test.mjs +++ b/capsule-docs/scripts/gen-reference.test.mjs @@ -1,4 +1,5 @@ import { + existsSync, mkdirSync, mkdtempSync, readFileSync, @@ -6,15 +7,22 @@ import { writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { + bucketOperations, CLI_SURFACE, demoteHeadings, generate, + OPENAPI_DOCUMENT, readCliSurface, + readOpenApiDocument, + renderApiPage, renderCliPage, } from './gen-reference.mjs'; +import { headingAnchors } from './lib/markdown.mjs'; +import { API_GROUPS, groupForPath } from './reference-groups.mjs'; /** * A repo-shaped temporary root: the two description artifacts at the paths the generator @@ -79,6 +87,18 @@ const MINIMAL_CLI = { ], }; +/** HTTP methods a path item may carry, per OpenAPI. */ +const METHODS = new Set([ + 'get', + 'put', + 'post', + 'delete', + 'options', + 'head', + 'patch', + 'trace', +]); + let root; beforeEach(() => { @@ -96,6 +116,133 @@ function writeCli(surface) { ); } +function writeOpenApi(document) { + mkdirSync(join(root, 'capsule-server'), { recursive: true }); + writeFileSync( + join(root, OPENAPI_DOCUMENT), + `${JSON.stringify(document, null, 2)}\n`, + ); +} + +const MINIMAL_OPENAPI = { + openapi: '3.2.0', + info: { title: 'API', version: '0.0.0' }, + paths: { + '/v1/version': { + get: { + summary: 'The protocol range this server speaks.', + operationId: 'version', + responses: { + 200: { + description: 'The range.', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/VersionResponse', + }, + }, + }, + }, + }, + }, + }, + '/v1/auth/login': { + post: { + summary: 'Exchange an email and password for a session.', + description: + 'Leading prose.\n\n# Two statuses, because there are two outcomes\n\nMore prose.', + operationId: 'login_user', + security: [{ bearer: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/LoginRequest', + }, + }, + }, + }, + responses: { + 401: { description: 'Invalid credentials' }, + 200: { + description: 'A session was opened.', + headers: { + 'WWW-Authenticate': { schema: { type: 'string' } }, + }, + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/TokenResponse', + }, + }, + }, + }, + }, + }, + }, + '/v1/albums/{album_id}/ops': { + post: { + summary: 'Apply a lifecycle write.', + operationId: 'album_ops', + parameters: [ + { + name: 'album_id', + in: 'path', + description: "The album's identifier.", + required: true, + schema: { type: 'string' }, + }, + ], + responses: { 204: { description: 'Applied.' } }, + }, + }, + }, + components: { + securitySchemes: { + bearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, + }, + schemas: { + VersionResponse: { + type: 'object', + title: 'VersionResponse', + required: ['min'], + properties: { + min: { type: 'integer', description: 'Lowest supported.' }, + }, + }, + LoginRequest: { + type: 'object', + title: 'LoginRequest', + required: ['email'], + properties: { + email: { + type: 'string', + description: 'The account email.', + }, + device_id: { + type: ['string', 'null'], + description: 'Advisory. Gates nothing | really.', + }, + }, + }, + TokenResponse: { + type: 'object', + title: 'TokenResponse', + properties: { + access: { type: 'string' }, + kind: { $ref: '#/components/schemas/TokenKind' }, + }, + }, + TokenKind: { + type: 'string', + enum: ['bearer'], + description: 'What the token is.', + }, + }, + }, +}; + describe('demoteHeadings', () => { // `openapi.json` really does carry `# Two statuses, because there are two outcomes` // inside an operation description. Rendered as-is it injects a second H1 into a page @@ -118,6 +265,24 @@ describe('demoteHeadings', () => { expect(demoteHeadings('body text\n', 2)).toBe('body text\n'); }); + // A closing fence carries no info string. Treating any ``` run as a closer ends the + // block at the nested opener, which both demotes the example's comments and leaves + // every real heading after it untouched — two failures from one input. + it('does not let a nested fence with an info string close the block', () => { + const source = ['```sh', '# a', '```js', '# b', '```', '# c'].join( + '\n', + ); + expect(demoteHeadings(source, 2)).toBe( + ['```sh', '# a', '```js', '# b', '```', '### c'].join('\n'), + ); + }); + + // Documented limitation, pinned so it fails visibly rather than silently: neither + // committed artifact uses a setext heading. + it('leaves a setext heading alone', () => { + expect(demoteHeadings('Title\n=====\n', 2)).toBe('Title\n=====\n'); + }); + it('does not demote a hash inside a fenced block', () => { const source = ['```sh', '# not a heading', '```', '# heading'].join( '\n', @@ -142,6 +307,15 @@ describe('readCliSurface', () => { expect(() => readCliSurface(root)).toThrow(/schema/i); }); + // A well-formed but empty document used to render an empty h2, an empty usage fence, and + // a `status: stable` badge — a page that builds green and documents nothing. + it('refuses a document that parses but describes nothing', () => { + writeCli({ schema: 1 }); + expect(() => readCliSurface(root)).toThrow(/no command name/); + writeCli({ schema: 1, name: 'capsule', subcommands: [] }); + expect(() => readCliSurface(root)).toThrow(/no subcommands/); + }); + it('refuses an unparseable artifact', () => { writeFileSync(join(root, CLI_SURFACE), '{ not json'); expect(() => readCliSurface(root)).toThrow(/cli-surface\.json/); @@ -160,7 +334,7 @@ describe('renderCliPage', () => { const page = renderCliPage(MINIMAL_CLI); expect(page.startsWith('---\n')).toBe(true); expect(page).toMatch(/^status: stable$/m); - expect(page).toMatch(/^title: Commands$/m); + expect(page).toMatch(/^title: "Commands"$/m); }); // A generated page is linted like any other on a machine that has built the site, and @@ -193,12 +367,65 @@ describe('renderCliPage', () => { expect(page).toContain('`-f, --force`'); }); + // A usage line that folds a required option into `[OPTIONS]` hands the reader a command + // that fails to parse. `capsule import` really does require `--library `. + it('spells required options in the usage line rather than hiding them', () => { + const surface = structuredClone(MINIMAL_CLI); + surface.subcommands[0].args.push({ + id: 'library', + long: 'library', + positional: false, + required: true, + repeatable: false, + takes_value: true, + value_names: ['PATH'], + help: 'Path to the library', + }); + expect(renderCliPage(surface)).toContain( + 'capsule import ... --library [OPTIONS]', + ); + }); + it('renders the usage line from the argument surface', () => { expect(renderCliPage(MINIMAL_CLI)).toContain( 'capsule import ... [OPTIONS]', ); }); + // Markdown passes raw HTML through, so an unescaped placeholder disappears from the + // rendered page — and an unclosed one takes the rest of the cell with it. + it('escapes an angle bracket in help text', () => { + const surface = structuredClone(MINIMAL_CLI); + surface.subcommands[0].args[2].help = 'pass a here'; + const page = renderCliPage(surface); + expect(page).toContain('pass a <token> here'); + expect(page).not.toContain('pass a here'); + }); + + it('does not append "Repeatable." when the help already says it', () => { + const surface = structuredClone(MINIMAL_CLI); + surface.subcommands[0].args[0].help = + 'Flag an asset as a keeper (repeatable)'; + const page = renderCliPage(surface); + expect(page).toContain('(repeatable).'); + expect(page).not.toContain('(repeatable). Repeatable.'); + }); + + // The anchors are hand-built by joining command words, and the headings are slugged by + // Starlight. Pinning them to one slugger here catches a divergence in a unit test rather + // than in a link-validator failure at build time. + it('emits only anchors its own headings answer', () => { + const page = renderCliPage(MINIMAL_CLI); + const anchors = headingAnchors(page); + const linked = [...page.matchAll(/\]\(#([^)]+)\)/g)].map( + (match) => match[1], + ); + expect(linked.length).toBeGreaterThan(0); + for (const anchor of linked) { + expect(anchors.has(anchor)).toBe(true); + } + }); + it('lists an enumerated option value', () => { expect(renderCliPage(MINIMAL_CLI)).toContain('`takeout`'); }); @@ -243,6 +470,7 @@ describe('renderCliPage', () => { describe('generate', () => { it('is deterministic: two runs produce byte-identical pages', () => { writeCli(MINIMAL_CLI); + writeOpenApi(MINIMAL_OPENAPI); const first = generate(root).map((path) => readFileSync(join(root, path), 'utf8'), ); @@ -255,5 +483,320 @@ describe('generate', () => { it('fails, writing nothing, when an artifact is missing', () => { expect(() => generate(root)).toThrow(/cli-surface\.json/); + expect( + existsSync( + join( + root, + 'capsule-docs/src/content/docs/reference/cli/commands.md', + ), + ), + ).toBe(false); + }); + + // A page a later run no longer emits stays on disk otherwise, and the directory is + // gitignored, so nothing ever shows it: Astro keeps routing and indexing a page no + // artifact describes, on this machine and on no CI runner. + it('clears output it no longer emits', () => { + writeCli(MINIMAL_CLI); + writeOpenApi(MINIMAL_OPENAPI); + generate(root); + const orphan = join( + root, + 'capsule-docs/src/content/docs/reference/api/retired-group.md', + ); + writeFileSync(orphan, '---\ntitle: Gone\nstatus: stable\n---\n'); + generate(root); + expect(existsSync(orphan)).toBe(false); + }); + + // The overviews are siblings of the generated directories precisely so that clearing + // one cannot take a hand-written page with it. + it('does not clear the hand-written overview beside the generated directory', () => { + writeCli(MINIMAL_CLI); + writeOpenApi(MINIMAL_OPENAPI); + const overview = join( + root, + 'capsule-docs/src/content/docs/reference/cli.md', + ); + writeFileSync(overview, '---\ntitle: CLI\nstatus: draft\n---\n'); + generate(root); + expect(existsSync(overview)).toBe(true); + }); +}); + +describe('link rewriting in artifact prose', () => { + // Both forms are live in the committed OpenAPI document. Rendered unchanged they are + // three broken links that fail `starlight-links-validator` and the docs build with it. + it('rewrites a repo-relative design-doc path to its site route', () => { + const document = structuredClone(MINIMAL_OPENAPI); + document.paths['/v1/auth/login'].post.description = + 'Every rule the [chunk contract](../../../capsule-docs/src/content/docs/design/import/upload-protocol.md) fixes.'; + const rendered = renderApiPage( + API_GROUPS.find((entry) => entry.slug === 'auth'), + bucketOperations(document).get('auth'), + document, + ); + expect(rendered).toContain( + '[chunk contract](/design/import/upload-protocol/)', + ); + }); + + it('drops a rustdoc intra-doc link and keeps its text', () => { + const document = structuredClone(MINIMAL_OPENAPI); + document.paths['/v1/auth/login'].post.description = + 'A signature over [`revoke_all_signing_bytes`](capsule_core::crypto::revoke::revoke_all_signing_bytes), in CBOR.'; + const rendered = renderApiPage( + API_GROUPS.find((entry) => entry.slug === 'auth'), + bucketOperations(document).get('auth'), + document, + ); + expect(rendered).toContain( + 'A signature over `revoke_all_signing_bytes`, in CBOR.', + ); + expect(rendered).not.toContain('capsule_core::crypto::revoke'); + }); + + it('leaves an absolute URL, a site route, and an anchor alone', () => { + const document = structuredClone(MINIMAL_OPENAPI); + document.paths['/v1/auth/login'].post.description = + 'See [a](https://example.invalid/x), [b](/design/i18n/), and [c](#later).'; + const rendered = renderApiPage( + API_GROUPS.find((entry) => entry.slug === 'auth'), + bucketOperations(document).get('auth'), + document, + ); + expect(rendered).toContain('[a](https://example.invalid/x)'); + expect(rendered).toContain('[b](/design/i18n/)'); + expect(rendered).toContain('[c](#later)'); + }); +}); + +describe('groupForPath', () => { + it('matches the longest prefix, not the first declared', () => { + expect(groupForPath('/v1/auth/login')?.slug).toBe('auth'); + expect(groupForPath('/v1/albums/{album_id}/ops')?.slug).toBe('albums'); + expect(groupForPath('/s/{opaque_id}/blob/{hash}')?.slug).toBe('shares'); + expect(groupForPath('/d/{opaque_id}')?.slug).toBe('drops'); + }); + + it('returns null for a path no group claims', () => { + expect(groupForPath('/v1/search')).toBe(null); + }); +}); + +describe('bucketOperations', () => { + // The gate that keeps the hand-curated navigation honest: a new endpoint family cannot + // publish unlisted, and cannot silently not publish at all. + it('fails, naming the operation, when an endpoint matches no group', () => { + const document = structuredClone(MINIMAL_OPENAPI); + document.paths['/v1/search'] = { + get: { summary: 'Search', operationId: 'search', responses: {} }, + }; + expect(() => bucketOperations(document)).toThrow(/GET \/v1\/search/); + expect(() => bucketOperations(document)).toThrow( + /reference-groups\.mjs/, + ); + }); + + it('buckets every operation exactly once', () => { + const buckets = bucketOperations(MINIMAL_OPENAPI); + const total = [...buckets.values()].reduce( + (sum, operations) => sum + operations.length, + 0, + ); + expect(total).toBe(3); + expect(buckets.get('auth')?.[0].operationId).toBe('login_user'); + }); + + it('orders operations within a group by path, then by method', () => { + const document = structuredClone(MINIMAL_OPENAPI); + document.paths['/v1/auth/aaa'] = { + post: { summary: 'a', operationId: 'a', responses: {} }, + get: { summary: 'b', operationId: 'b', responses: {} }, + }; + const auth = bucketOperations(document).get('auth'); + expect( + auth.map((operation) => `${operation.method} ${operation.path}`), + ).toEqual([ + 'GET /v1/auth/aaa', + 'POST /v1/auth/aaa', + 'POST /v1/auth/login', + ]); + }); +}); + +describe('readOpenApiDocument', () => { + it('names the missing artifact rather than emitting a stub page', () => { + expect(() => readOpenApiDocument(root)).toThrow( + /capsule-server\/openapi\.json/, + ); + }); + + it('refuses a document that is not OpenAPI 3.2', () => { + writeOpenApi({ ...MINIMAL_OPENAPI, openapi: '3.1.0' }); + expect(() => readOpenApiDocument(root)).toThrow(/3\.2/); + }); +}); + +describe('renderApiPage', () => { + const group = API_GROUPS.find((entry) => entry.slug === 'auth'); + + function page() { + return renderApiPage( + group, + bucketOperations(MINIMAL_OPENAPI).get('auth'), + MINIMAL_OPENAPI, + ); + } + + it('renders the method and path as the operation heading', () => { + expect(page()).toContain('## POST /v1/auth/login'); + }); + + // `openapi.json` really does carry `# Two statuses, because there are two outcomes`. + // Interpolated unchanged it is a second h1 on the page. + it('demotes a heading inside an operation description', () => { + const rendered = page(); + expect(rendered).toContain( + '#### Two statuses, because there are two outcomes', + ); + const body = rendered.split('\n---\n').slice(1).join('\n---\n'); + expect(body.split('\n').filter((line) => /^# /.test(line))).toEqual([]); + }); + + it('says which operations require authentication', () => { + expect(page()).toMatch(/[Bb]earer/); + }); + + it('renders the request body schema and its fields', () => { + const rendered = page(); + expect(rendered).toContain('LoginRequest'); + expect(rendered).toContain('`email`'); + expect(rendered).toContain('The account email.'); + }); + + it('escapes a pipe inside a schema description', () => { + expect(page()).toContain('Gates nothing \\| really.'); + }); + + // Every use of a type is a table cell, so an unescaped separator opens a fourth column + // and shifts the row — `device_id` in the committed document is exactly this shape. + it('renders a nullable union as an escaped type, not as [object Object]', () => { + const rendered = page(); + expect(rendered).not.toContain('[object Object]'); + expect(rendered).toContain('`string \\| null`'); + expect(rendered).not.toContain('`string | null`'); + }); + + it('keeps every table row at the width of its header', () => { + for (const line of page().split('\n')) { + if (!line.startsWith('|')) continue; + // A cell may legitimately contain an escaped pipe; an unescaped one is a column. + const columns = line.replace(/\\\|/g, '').split('|').length; + expect(columns).toBeLessThanOrEqual(6); + } + }); + + it('resolves a $ref one level and links deeper refs to their anchor', () => { + const rendered = renderApiPage( + API_GROUPS.find((entry) => entry.slug === 'version'), + bucketOperations(MINIMAL_OPENAPI).get('version'), + MINIMAL_OPENAPI, + ); + expect(rendered).toContain('VersionResponse'); + expect(rendered).toContain('Lowest supported.'); + }); + + it('lists responses in ascending status order', () => { + const rendered = page(); + expect(rendered.indexOf('| `200`')).toBeLessThan( + rendered.indexOf('| `401`'), + ); + }); + + it('renders a path parameter', () => { + const rendered = renderApiPage( + API_GROUPS.find((entry) => entry.slug === 'albums'), + bucketOperations(MINIMAL_OPENAPI).get('albums'), + MINIMAL_OPENAPI, + ); + expect(rendered).toContain('`album_id`'); + expect(rendered).toContain("The album's identifier."); + }); + + it('opens with frontmatter the content schema accepts', () => { + const rendered = page(); + expect(rendered.startsWith('---\n')).toBe(true); + expect(rendered).toMatch(/^status: stable$/m); + expect(rendered).toMatch(/^title: "[^"]+"$/m); + }); + + it('emits only anchors its own headings answer', () => { + const rendered = page(); + const anchors = headingAnchors(rendered); + const linked = [...rendered.matchAll(/\]\(#([^)]+)\)/g)].map( + (match) => match[1], + ); + expect(linked.length).toBeGreaterThan(0); + for (const anchor of linked) { + expect(anchors.has(anchor)).toBe(true); + } + }); + + it('ends with exactly one newline and no run of blank lines', () => { + const rendered = page(); + expect(rendered.endsWith('\n')).toBe(true); + expect(rendered.endsWith('\n\n')).toBe(false); + expect(rendered).not.toMatch(/\n{3,}/); + }); +}); + +describe('the committed artifacts', () => { + // The assertion about *this repository* rather than about the generator: every + // operation the server declares reaches a page. A group table that quietly stopped + // covering a family would fail here as well as in `bucketOperations`. + const repoRoot = resolve( + dirname(fileURLToPath(import.meta.url)), + '..', + '..', + ); + + it('bucket every declared operation into a group', () => { + const document = readOpenApiDocument(repoRoot); + const declared = Object.entries(document.paths).flatMap(([, item]) => + Object.keys(item).filter((key) => METHODS.has(key)), + ); + const buckets = bucketOperations(document); + const bucketed = [...buckets.values()].reduce( + (sum, operations) => sum + operations.length, + 0, + ); + expect(bucketed).toBe(declared.length); + expect(bucketed).toBeGreaterThan(50); + }); + + it('generate one page per group plus the CLI page', () => { + const written = generate(repoRoot); + expect(written).toContain( + 'capsule-docs/src/content/docs/reference/cli/commands.md', + ); + for (const group of API_GROUPS) { + expect(written).toContain( + `capsule-docs/src/content/docs/reference/api/${group.slug}.md`, + ); + } + }); +}); + +describe('tidyBlankLines, through the pages that use it', () => { + // A blank run inside a fenced example is part of the example. A whole-document collapse + // has the generator quietly editing the code it is quoting. + it('keeps a blank run inside a fenced example', () => { + const surface = structuredClone(MINIMAL_CLI); + surface.subcommands[0].long_about = + 'Example:\n\n```sh\ncapsule import a\n\n\ncapsule import b\n```'; + expect(renderCliPage(surface)).toContain( + 'capsule import a\n\n\ncapsule import b', + ); }); }); diff --git a/capsule-docs/scripts/lib/walk.mjs b/capsule-docs/scripts/lib/walk.mjs index f5cf627f..5846d516 100644 --- a/capsule-docs/scripts/lib/walk.mjs +++ b/capsule-docs/scripts/lib/walk.mjs @@ -70,7 +70,7 @@ export function walkFiles(root, predicate) { const relDir = `${relative(root, abs).split(sep).join('/')}/`; if ( !SKIP_DIRS.has(entry.name) && - !SKIP_PREFIXES.includes(relDir) + !SKIP_PREFIXES.some((prefix) => relDir.startsWith(prefix)) ) { visit(abs); } diff --git a/capsule-docs/scripts/reference-groups.mjs b/capsule-docs/scripts/reference-groups.mjs index 07927985..3d7194f2 100644 --- a/capsule-docs/scripts/reference-groups.mjs +++ b/capsule-docs/scripts/reference-groups.mjs @@ -45,6 +45,132 @@ export const CLI_PAGES = [ }, ]; +/** + * A generated REST page: one endpoint family, with the path prefixes that belong to it. + * + * @typedef {ReferencePage & { pathPrefixes: string[] }} ApiGroup + */ + +/** + * The REST pages, in reading order — a client's order, not the document's: what version you + * are talking to, what the server advertises, how to authenticate, then the data plane, then + * the account surfaces. + * + * Grouped by hand because there is nothing to group by automatically. The Kynos document + * carries no `tags` on any of its 59 operations, so an autogenerated section would be 59 + * pages ordered by filename — which `design/developer-docs.md` forbids in as many words, + * and which would scatter the four `/v1/upload` operations of one protocol across the + * alphabet. + * + * The names track the surface map in `design/api-surfaces.md`, so a reader who arrives from + * a design document finds the page named after the surface they were reading about. + * + * @type {ApiGroup[]} + */ +export const API_GROUPS = [ + { + slug: 'version', + label: 'Version', + description: + 'The protocol handshake every client performs before it does anything else.', + pathPrefixes: ['/v1/version'], + }, + { + slug: 'well-known', + label: 'Server discovery', + description: + 'What a server publishes about itself: attestation keys, capabilities, deprecations, and revoked tokens.', + pathPrefixes: ['/.well-known/capsule/'], + }, + { + slug: 'auth', + label: 'Authentication and devices', + description: + 'Registration, sign-in, the second factor, session and device management, and key escrow.', + pathPrefixes: ['/v1/auth/'], + }, + { + slug: 'upload', + label: 'Upload', + description: + 'The resumable, encrypted upload protocol: open a session, drive it, and read its receipt.', + pathPrefixes: ['/v1/upload'], + }, + { + slug: 'albums', + label: 'Albums and lifecycle writes', + description: + 'Album creation, the lifecycle write surface, and the membership upgrade handshake.', + pathPrefixes: ['/v1/albums'], + }, + { + slug: 'sync', + label: 'Sync and blob fetch', + description: + 'The change feed a client drains after a cursor, and the range-capable blob endpoint it fetches from.', + pathPrefixes: ['/v1/sync', '/v1/blob/'], + }, + { + slug: 'storage', + label: 'Storage verification', + description: + 'Proving the server still holds what it said it held, and the receipts that attest to it.', + pathPrefixes: ['/v1/storage/', '/v1/assets/'], + }, + { + slug: 'shares', + label: 'Share links', + description: + 'Issuing and revoking a share link, and the unauthenticated surface that serves one.', + pathPrefixes: ['/v1/shares', '/s/'], + }, + { + slug: 'drops', + label: 'Guest drops', + description: + 'Guest upload links, the unauthenticated drop surface, and the owner-side inbox and adoption.', + pathPrefixes: ['/v1/drops', '/d/'], + }, + { + slug: 'quota', + label: 'Quota', + description: 'What the account has used, and what it is allowed.', + pathPrefixes: ['/v1/quota'], + }, + { + slug: 'moderation', + label: 'Moderation', + description: + "The account's own moderation record — every action taken against it, in order.", + pathPrefixes: ['/v1/moderation/'], + }, +]; + +/** + * The group a path belongs to, by longest matching prefix, or `null` when none matches. + * + * Longest-prefix rather than first-match so the table's *reading* order and its *matching* + * order are independent. First-match would make adding a narrower group — `/v1/auth/totp/` + * under `/v1/auth/` — silently depend on placing it above the broader one, which is a trap + * a reader reordering the table for navigation reasons would spring without noticing. + * + * @param {string} path An OpenAPI path template, e.g. `/v1/albums/{album_id}/ops`. + * @returns {ApiGroup | null} + */ +export function groupForPath(path) { + let best = null; + let bestLength = -1; + for (const group of API_GROUPS) { + for (const prefix of group.pathPrefixes) { + if (path.startsWith(prefix) && prefix.length > bestLength) { + best = group; + bestLength = prefix.length; + } + } + } + return best; +} + /** * Sidebar items for the `Reference` group, in the order they are read. * @@ -65,5 +191,14 @@ export function referenceSidebar() { })), ], }, + { + label: 'REST API', + items: [ + { slug: 'reference/api' }, + ...API_GROUPS.map((group) => ({ + slug: `reference/api/${group.slug}`, + })), + ], + }, ]; } diff --git a/capsule-docs/src/content/docs/reference/api.md b/capsule-docs/src/content/docs/reference/api.md new file mode 100644 index 00000000..4d0c7e44 --- /dev/null +++ b/capsule-docs/src/content/docs/reference/api.md @@ -0,0 +1,101 @@ +--- +title: REST API +description: The auth model, negotiation headers, and error contract common to every Capsule endpoint +status: draft +--- + +Capsule's server surface is REST over HTTP, described by a single OpenAPI 3.2 document that +`capsule-server` emits from its own route types. This page is the hand-written half of that +reference: the things true of every endpoint, which a per-endpoint page would otherwise repeat +fifty-nine times. The endpoints themselves are the generated pages listed in the sidebar. + +Read [API Surfaces](/design/api-surfaces/#surface--transport-map) first if you want the map from +a surface to the module that owns it and the design document that explains it. This reference +says what the wire looks like; that one says why. + +## The server holds no keys + +The most important thing to know before reading any endpoint: **every write is sealed on the +client before it is sent, and the sync feed returns opaque envelopes.** The server stores, +addresses, and serves ciphertext, and authorizes who may do so. It cannot read an asset, and no +endpoint accepts a plaintext one. + +That is also why this reference has no try-it panel. A playground could exercise the version +handshake, the auth flows, and blob fetch; everything else would either return ciphertext or +reject an unsealed body, and a reader who succeeded at it would leave believing the API accepts +plaintext. The honest equivalent is the [command line](/reference/cli/), which performs the +sealing. + +## Authentication + +Credentials ride the standard `Authorization: Bearer` header. An access token is short-lived and +issued by `POST /v1/auth/login`; `POST /v1/auth/refresh` rotates the pair. Each generated page +marks every operation as requiring authentication or not, read from the document's own security +requirements rather than from prose here. + +An account with a confirmed second factor does not get a session from `login` — it gets a +challenge, and the sign-in finishes at `POST /v1/auth/login/verify-totp`. A client that treats +`202` as a failure will appear to work until the first user enables TOTP. + +A `401` carries a `WWW-Authenticate` challenge, per RFC 9110. + +## Negotiation + +Every public route applies the same headers, which the generated pages do not repeat per +operation: + +| Header | Direction | +| --- | --- | +| `X-Capsule-Protocol` | request | +| `X-Capsule-Crypto-Suite` | request for writes | +| `X-Capsule-Sidecar-Schema` | request | +| `X-Capsule-Protocol-Min` | response | +| `X-Capsule-Protocol-Max` | response | +| `X-Capsule-Min-Client-Build` | response | + +`GET /v1/version` is the unauthenticated reachability probe a client performs before the +handshake. It has no failure variant by construction. What a server publishes about itself — +attestation keys, capabilities, announced deprecations, revoked token identifiers — is under +[Server discovery](/reference/api/well-known/). + +## Errors + +Failures are `application/problem+json` (RFC 9457) bodies. Beyond the standard members, every +problem this server renders carries a **`code`**: a stable identifier from the `error.*` catalog +namespace described in [Internationalization](/design/i18n/). Clients localize the code; the +`detail` message stays English. + +**Switch on the code, never on the status alone.** HTTP status is deliberately coarse: + +| Rejection class | HTTP status | +| --- | --- | +| Structural (bad envelope, unknown enum, sizes) | `400` | +| Unauthenticated or expired token | `401` | +| Unauthorized (capability, quota-hard, suspension) | `403` | +| Not found, including indistinguishable-404 surfaces | `404` | +| Stale state (chain, cursor, directory regression) | `409` | +| Payload too large | `413` | +| Unsupported chunk media type | `415` | +| Protocol outside `[Min, Max]` | `426` | +| Rate limited | `429` | + +A `404` is sometimes a deliberate indistinguishability, not an absence: a surface that must not +reveal whether a resource exists to an unauthorized caller answers the same way in both cases. + +## Clients + +Do not hand-write a client. `capsule-sdk` is generated from this same document, and the +generated request and response types are the only ones guaranteed to track it. The layering +rules server code follows are [API Practices](/development/api-practices/). + +## How these pages stay true + +`capsule-server` emits `capsule-server/openapi.json` from its route types — no database, no key +material, no network, because the router is built purely to describe it — and +`mise run openapi-check-kynos` fails the Rust gate if the committed document disagrees with the +server. The documentation build reads that file and nothing else. + +A generated page is never edited. If something on it is wrong, the annotation it came from is +wrong: fix the handler or model documentation, run `mise run openapi-kynos`, and commit the +document. The pipeline and the reasoning behind it are +[Developer Documentation](/design/developer-docs/). diff --git a/capsule-docs/src/content/docs/reference/cli.md b/capsule-docs/src/content/docs/reference/cli.md index bab41b44..f4f0e990 100644 --- a/capsule-docs/src/content/docs/reference/cli.md +++ b/capsule-docs/src/content/docs/reference/cli.md @@ -41,8 +41,8 @@ A library is opened with a passphrase. Each command that opens one accepts - The behaviour of the import pipeline is [Import Pipeline](/design/import/pipeline/); what `capsule push` speaks is the [Upload Protocol](/design/import/upload-protocol/), and what `capsule sync` drains is [Download & Sync](/design/import/download-sync/). -- The server endpoints behind the networked commands are mapped in - [API Surfaces](/design/api-surfaces/#surface--transport-map). +- The server endpoints behind the networked commands are the + [REST API](/reference/api/) reference. - Terminal output is localized through the catalogs described in [Internationalization](/design/i18n/). Help text is not yet: the command tree this reference is generated from is English, deliberately and by pinning, so the artifact cannot vary with diff --git a/capsule-docs/src/content/docs/reference/index.md b/capsule-docs/src/content/docs/reference/index.md index d067fdfa..dd94b024 100644 --- a/capsule-docs/src/content/docs/reference/index.md +++ b/capsule-docs/src/content/docs/reference/index.md @@ -19,6 +19,7 @@ generated page is wrong, the annotation in the source is wrong. | Surface | Overview | Generated from | Kept current by | | --- | --- | --- | --- | +| REST | [REST API](/reference/api/) | `capsule-server/openapi.json` | `mise run openapi-check-kynos` | | Command line | [CLI](/reference/cli/) | `capsule-cli/cli-surface.json` | `mise run cli-surface-check` | ## Not published yet From 8c2f15e8eab9f2e57c05239f16d47894fe59a776 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 01:10:39 -0400 Subject: [PATCH 074/243] docs(slices): record S-Z8 and S-Z9 as landed, and why S-Z10 is not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The surface table in `developer-docs.md` said the CLI was Planned and REST was blocked on rendering. Both now publish, so both rows read Landed, naming the emitter and the gate that keeps each artifact current. `S-Z8` and `S-Z9` move to `done` with a verified line each. `S-Z8`'s Deliverable drops man pages and shell completions: neither `clap_mangen` nor `clap_complete` is in `Cargo.lock`, both would owe a `dependencies.md` row, and neither produces the description artifact the docs build reads — they are install artifacts, and they belong to a packaging slice. `S-Z10` stays `ready` and carries the evidence for why, so the next attempt does not rediscover it: uniffi 0.31.1 exposes no stable machine-readable surface dump, and the wasm `.d.ts` gate cannot run where `check-rust` runs. It is filed as its own issue. --- SLICES.md | 59 +++++++++++++++---- .../src/content/docs/design/developer-docs.md | 4 +- 2 files changed, 49 insertions(+), 14 deletions(-) diff --git a/SLICES.md b/SLICES.md index ce7d3266..710cea02 100644 --- a/SLICES.md +++ b/SLICES.md @@ -400,9 +400,9 @@ row's remainder now lives. | S-Z5 | Dead-code removal (exports stub, CLI import planner) | design/docs | — | S | MIXED | done | | | S-Z6 | Developer-docs parity pass | design/docs | — | M | MIXED | done | | | S-Z7 | Developer reference architecture (design) | design/docs | — | S | ACTIVE | done | | -| S-Z8 | Reference shell + CLI reference | design/docs | S-Z7 | M | ACTIVE | ready | | -| S-Z9 | REST reference from the Kynos document | design/docs | S-Z8, S-D8 | M | ACTIVE | blocked | Kynos document → `S-C27`/`S-D8` | -| S-Z10 | SDK / FFI / WASM reference | design/docs | S-Z8 | M | ACTIVE | ready | | +| S-Z8 | Reference shell + CLI reference | design/docs | S-Z7 | M | ACTIVE | done | man pages/completions scoped out | +| S-Z9 | REST reference from the Kynos document | design/docs | S-Z8, S-D8 | M | ACTIVE | done | | +| S-Z10 | SDK / FFI / WASM reference | design/docs | S-Z8 | M | ACTIVE | ready | uniffi has no stable dump — own issue | | S-Z11 | Notification architecture (design) | design/docs | — | S | ACTIVE | done | | **Row counts.** 205 rows — the 129 from the v1 campaign and wave 2, the 51 the @@ -5631,17 +5631,26 @@ and all three slices are `done` in `capsule-core`. ### S-Z8 — Reference shell + CLI reference - **Gap:** `/reference/` has an index and nothing under it, and `capsule-cli/README.md` - still defers entirely to `capsule --help`. No `clap_mangen` or `clap_complete` exists - anywhere in the workspace, so there is no man page and no shell completion either. + still defers entirely to `capsule --help`. - **Deliverable:** the reference shell (overview page per section) plus the first real - generated surface. `capsule-cli` gains a command-tree dump with a `--check` mode and - `clap_mangen`/`clap_complete` output; the docs build renders the committed dump into - `/reference/cli/`. The CI `docs` path filter widens to name every artifact the docs - build now reads — without that, a CLI change publishes a stale page without failing - anything. + generated surface. `capsule-cli` gains a command-tree dump with a `--check` mode; the + docs build renders the committed dump into `/reference/cli/`. The CI `docs` path filter + widens to name every artifact the docs build now reads — without that, a CLI change + publishes a stale page without failing anything. +- **Scoped out: man pages and shell completions.** The slice originally named + `clap_mangen` and `clap_complete`. Neither crate appears anywhere in `Cargo.lock`, so + both would owe a row in `design/dependencies.md`, and neither produces the description + artifact the docs build reads — they are *install* artifacts, emitted for a packager, + not a description of the surface. They are a packaging slice, not this one. - **Done when:** `/reference/cli/` renders the full command tree from the committed dump, the `--check` mode fails on a hand-edited dump, and the `docs` path filter names the dump. **Tier:** docs build + the new drift gate. **Depends on:** S-Z7. +- **Landed — verified 2026-09-02.** `capsule_cli::cli::command_tree()` emits the tree, + `capsule-cli/src/bin/gen_cli_surface.rs` writes and `--check`s + `capsule-cli/cli-surface.json`, and `cli-surface-check` runs in `check-rust` beside + `openapi-check-kynos`. `capsule-docs/scripts/gen-reference.mjs` renders it into + `/reference/cli/commands/` as gitignored build output, with the hand-written overview + at `/reference/cli/`. The `docs` filter names both committed artifacts. ### S-Z9 — REST reference from the Kynos document @@ -5654,8 +5663,16 @@ and all three slices are `done` in `capsule-core`. forfeit the search index, the link validator, and the site palette. - **Done when:** the committed contract is Kynos-emitted, `openapi-check` gates it, and `/reference/api/` renders every path in it as Starlight pages that Pagefind indexes. - **Tier:** docs build + `openapi-check`. **Depends on:** S-Z8, S-D8 (**live block** — - the schema must come from Kynos, which needs `S-C27`). + **Tier:** docs build + `openapi-check`. **Depends on:** S-Z8, S-D8 (the block cleared + when `S-C34` landed the Kynos emitter and `openapi-check-kynos`). +- **Landed — verified 2026-09-02.** All 51 paths and 59 operations of + `capsule-server/openapi.json` render across eleven group pages under `/reference/api/`, + from the ordered table in `capsule-docs/scripts/reference-groups.mjs` that + `astro.config.mjs` also builds the sidebar from. The document carries no `tags` on any + operation, so the grouping is hand-curated by path prefix and the generator **fails on + an operation no group claims** — a new endpoint family cannot publish under a heading + nobody chose, and cannot silently fail to publish. A test asserts the bucketed count + equals the declared one. ### S-Z10 — SDK / FFI / WASM reference @@ -5672,6 +5689,24 @@ and all three slices are `done` in `capsule-core`. - **Done when:** each dump has a `--check` in the Rust gate, the three binding pages render from committed dumps, and `/reference/crates/` resolves. **Tier:** docs build + the new drift gates. **Depends on:** S-Z8. +- **Still `ready`, and split out of the S-Z8/S-Z9 delivery.** Neither of its two artifacts + can be produced the way the slice assumes, and the evidence is recorded here so the next + attempt starts from it: + - **uniffi exposes no stable machine-readable dump.** `uniffi_bindgen 0.31.1` — the + pinned version — offers only `generate`, `scaffolding`, and `pipeline`, and `pipeline` + documents itself as inspecting the render pipeline. The one thing resembling a dump, + `print_repr`, prints Rust `{:#?}` `Debug` of `uniffi_meta::Metadata`, which carries no + `serde` derive and no `serde` dependency. A committed artifact today is therefore + either a `Debug` blob with no compatibility promise that churns on every uniffi bump, + or a hand-written mapper over a private IR — the second parser `AGENTS.md` forbids. + The symbol-presence assertions already in `mise-tasks/gen-bindings` enumerate the verbs + and are the honest seed: they assert, they do not emit. + - **The wasm `.d.ts` gate cannot live in `check-rust`.** That gate runs + `build-check-wasm`, two `cargo check`s; the artifact comes from `build-wasm`, which + needs `wasm-bindgen-cli`, installed only by the `web` CI job. Gating it is a + `check-web` change and a web-side artifact, not a fourth entry in the Rust gate. + Filed as its own issue with this evidence. The extension point is the group table in + `capsule-docs/scripts/reference-groups.mjs`: adding a surface is a group plus a renderer. ## Deferred Migrations Register diff --git a/capsule-docs/src/content/docs/design/developer-docs.md b/capsule-docs/src/content/docs/design/developer-docs.md index 1d7763d0..2db425db 100644 --- a/capsule-docs/src/content/docs/design/developer-docs.md +++ b/capsule-docs/src/content/docs/design/developer-docs.md @@ -83,8 +83,8 @@ in the docs gate, which cannot run it. | Surface | Description artifact | Emitted by | Drift gate | Page | Status | | --- | --- | --- | --- | --- | --- | -| REST | Kynos OpenAPI 3.2 document | `capsule-server::openapi()` via an emitter binary | `openapi-check-kynos` | `/reference/api/` | **Emitter and gate landed; rendering blocked** | -| CLI | command-tree JSON, man pages, shell completions | `capsule-cli` (clap) | new `--check` on the dump | `/reference/cli/` | Planned | +| REST | Kynos OpenAPI 3.2 document | `capsule-server::openapi()` via `gen_openapi` | `openapi-check-kynos` | `/reference/api/` | **Landed** | +| CLI | command-tree JSON | `capsule_cli::cli::command_tree()` via `gen_cli_surface` | `cli-surface-check` | `/reference/cli/` | **Landed** | | Rust SDK | rustdoc HTML (uncommitted) | `cargo doc -p capsule-sdk` | broken intra-doc links denied | `/reference/sdk/rust/` → `/reference/crates/` | Planned | | Swift bindings | uniffi surface JSON, dumped from the compiled cdylib | a dump step on `mise-tasks/gen-bindings` | new `--check` on the dump | `/reference/sdk/swift/` | Planned | | Kotlin bindings | as above | as above | as above | `/reference/sdk/kotlin/` | Planned | From 7c71817a637690fe3b64bef5a31007523fed624e Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 01:14:23 -0400 Subject: [PATCH 075/243] fix(sdk): stop minting a catalog code, and pin the auth/transport split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings on this branch. **The client no longer invents an `error.*` code.** A body-less `413` carries no problem body, so there is no code to carry — and both hand-written clients were filling that gap with `error.request.too_large`. Every other code either module reports is the one the *server* stamped; a code minted on this side asserts that the server said something it did not, and a client localizing it reads the SDK's guess as the server's judgement. Both sites now report `code: None` with the English detail, and the variant already carries the actionable half ("these bytes will not do, do not resend them"). The upgrade test that asserted the minted code now asserts its absence. **The auth/transport split has a test on both sides.** `RequestConstruction` carries two completely different events — a bearer the session could not mint, and a connection that never opened — and only the boxed source separates them. The transport side was pinned; the auth side was not, so a spargen change to how a provider failure is boxed would have silently demoted every expired refresh token to `Transport`, and the FFI would tell a user to retry where it must tell them to sign in again. `a_session_that_cannot_mint_a_bearer_is_an_auth_failure` drives a session whose stored token is past expiry against a mock that serves no `/refresh`, on both the read and the write path. Confirmed to fail with the downcast arm disabled. **`reqwest_client()`'s doc stops overclaiming.** Sharing one client for the process does not remove every per-construction client: `Client::with_backend` still builds its own default `reqwest::Client` internally, one per `AuthenticatedClient`. That one only assembles requests — every byte is executed through the backend, and so through the shared client — so it opens no connection and costs one throwaway allocation. The comment now says so, and names the `with_client_and_backend` constructor spargen would need to remove even that. Refs #408 --- capsule-sdk/src/client.rs | 8 ++++ capsule-sdk/src/recovery/mod.rs | 67 ++++++++++++++++++++++++++++++--- capsule-sdk/src/upgrade.rs | 33 ++++++++++------ 3 files changed, 91 insertions(+), 17 deletions(-) diff --git a/capsule-sdk/src/client.rs b/capsule-sdk/src/client.rs index 926eb8a0..b5862cde 100644 --- a/capsule-sdk/src/client.rs +++ b/capsule-sdk/src/client.rs @@ -263,6 +263,14 @@ fn build_client(base_url: &str, session: Session) -> Result /// per-client transport would mean a fresh TLS handshake for every escrow read on a device /// that does several during one cadence prompt. Nothing here is configured per instance, so /// there is nothing to vary: the same client serves them all. +/// +/// **What that does not fix.** `Client::with_backend` still builds its *own* default +/// `reqwest::Client` internally, one per `AuthenticatedClient`. That one only assembles +/// requests — every byte is executed through the backend below, and therefore through this +/// shared client — so it opens no connection and costs nothing on the wire; what it costs is +/// one throwaway allocation per construction. Removing even that needs a +/// `with_client_and_backend` constructor spargen does not expose, which is generator work of +/// exactly the same kind as the `application/cbor` gap, and lands where that lands. fn reqwest_client() -> reqwest::Client { static SHARED: std::sync::OnceLock = std::sync::OnceLock::new(); SHARED diff --git a/capsule-sdk/src/recovery/mod.rs b/capsule-sdk/src/recovery/mod.rs index 8b5b8526..344c1063 100644 --- a/capsule-sdk/src/recovery/mod.rs +++ b/capsule-sdk/src/recovery/mod.rs @@ -499,12 +499,14 @@ fn store_escrow_error(error: rest::Error) -> RecoveryErr rest::StoreEscrowError::Status401(problem) | rest::StoreEscrowError::Status403(problem) => refused(&problem), rest::StoreEscrowError::Status500(problem) => unavailable(&problem), - // The body-size backstop carries no problem body at all, so both the code and the - // message are ours. It is `error.request.too_large` and not - // `error.escrow.malformed`: a client localizing the latter would tell the user - // their recovery blob is corrupt when it is merely too big. + // The body-size backstop carries no problem body at all, so there is no code to + // carry and this client does not invent one. Every other code in this module is + // the server's own, and a code minted here would assert that the server said + // something it did not — a client localizing it would be reading the SDK's guess + // as the server's judgement. The English detail says what happened instead; the + // variant already says "these bytes will not do, do not resend them". rest::StoreEscrowError::Status413 => RecoveryError::Malformed { - code: Some(error_codes::REQUEST_TOO_LARGE.to_owned()), + code: None, detail: "the escrow blob exceeds the server's request-body limit".to_owned(), }, }, @@ -844,6 +846,20 @@ mod tests { .unwrap() } + /// A session over the mock whose access token expired an hour ago, so any call + /// pre-flight-refreshes — and the escrow mock serves no `/refresh`, so that refresh fails. + /// The result is a [`Session`] that cannot produce a bearer at all. + fn dead_session_for(base: &str) -> Session { + AuthClient::new(base) + .unwrap() + .resume(PersistedSession { + access_token: "test-access".to_string().into(), + refresh_token: "test-refresh".to_string().into(), + access_expires_at_unix: jiff::Timestamp::now().as_second() - 3_600, + }) + .unwrap() + } + fn wrap(master: &[u8; 32], secret: &[u8]) -> WrappedSecret { pwkdf::wrap_with(master, secret, fast_params()).unwrap() } @@ -1147,6 +1163,47 @@ mod tests { assert_eq!(error.error_code(), None); } + /// **A session that cannot mint a bearer is an auth failure, not a network one.** + /// + /// This is the other side of `an_unreachable_endpoint_is_a_transport_failure_not_an_auth_one` + /// and it pins the discrimination that separates them. Both arrive as the generated + /// taxonomy's `RequestConstruction`; the only thing telling them apart is the boxed source, + /// which is the runtime's own `AuthError` when — and only when — the bearer provider is + /// what failed. Without this case, a spargen change to how a provider failure is boxed + /// would silently demote every expired refresh token to `Transport`, and the FFI would tell + /// a user to retry where it must tell them to sign in again. The escrow calls themselves + /// never leave the process here: there is no bearer to send them with. + #[tokio::test] + async fn a_session_that_cannot_mint_a_bearer_is_an_auth_failure() { + let base = start_mock(escrow_handler(EscrowStore::default())).await; + let client = RecoveryClient::new(dead_session_for(&base), &base).unwrap(); + + let error = client + .fetch_escrow() + .await + .expect_err("the session cannot produce a token"); + assert!( + matches!(error, RecoveryError::Unauthorized { code: None, .. }), + "a dead session must reach the caller as an auth failure, got {error:?}" + ); + assert_eq!( + error.error_code(), + None, + "no server answered, so there is no catalog code to carry" + ); + + // And the same on the write path, which has a body to construct and still fails before + // it is sent. + let error = client + .store_escrow(&wrap(&[0x99u8; 32], b"whatever")) + .await + .expect_err("the session cannot produce a token"); + assert!( + matches!(error, RecoveryError::Unauthorized { code: None, .. }), + "got {error:?}" + ); + } + /// A refusal whose body is not the coded problem the document promises — an intermediary /// answering a bare `404`, say — is a broken path, not an empty escrow. /// diff --git a/capsule-sdk/src/upgrade.rs b/capsule-sdk/src/upgrade.rs index 8a947d82..f779a6eb 100644 --- a/capsule-sdk/src/upgrade.rs +++ b/capsule-sdk/src/upgrade.rs @@ -34,7 +34,6 @@ //! //! [Versioning — Album Upgrade Ceremony]: https://docs/design/versioning/#album-upgrade-ceremony -use capsule_i18n::error_codes; use jiff::Timestamp; use serde::Deserialize; use tracing::instrument; @@ -283,11 +282,14 @@ impl UpgradeClient { /// Map a refusal onto its typed variant, keeping the code the server stamped. /// -/// One readable status table rather than a match buried in the request path. `413` is the -/// transport's body backstop and carries no problem body at all, so its code is ours — and it -/// is `error.request.too_large` rather than the intent-malformed code, because a client -/// localizing the latter would tell an admin their signed intent is corrupt when it is -/// merely too big. +/// One readable status table rather than a match buried in the request path. +/// +/// `413` is the transport's body backstop and carries no problem body at all, so it has no +/// code — and this client does not mint one. Every code here is the code the *server* stamped; +/// a code invented on this side would assert that the server said something it did not, and a +/// client localizing it would read the SDK's guess as the server's judgement. The variant +/// already carries the actionable half ("these bytes will not do"), and the English detail +/// carries the reason. fn refusal(status: u16, problem: ProblemWire) -> UpgradeError { let ProblemWire { code, @@ -306,7 +308,7 @@ fn refusal(status: u16, problem: ProblemWire) -> UpgradeError { detail, }, 413 => UpgradeError::Malformed { - code: Some(error_codes::REQUEST_TOO_LARGE.to_owned()), + code: None, detail: "the signed upgrade intent exceeds the server's body limit".to_owned(), }, 500 => UpgradeError::Unavailable { code, detail }, @@ -348,6 +350,8 @@ mod tests { use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; + use capsule_i18n::error_codes; + use super::*; use crate::auth::{AuthClient, PersistedSession}; use crate::testmock::{MockRequest, MockResponse, MockServer}; @@ -520,10 +524,11 @@ mod tests { } } - /// The body-size backstop carries no problem body, so the client supplies both the variant - /// and a code that says what actually happened. + /// The body-size backstop carries no problem body, so the client supplies the variant — + /// and **no code**. Minting one here would put words in the server's mouth, and every other + /// code this module reports is the server's own. #[tokio::test] - async fn a_body_too_large_is_not_reported_as_a_corrupt_intent() { + async fn a_body_too_large_carries_no_invented_code() { let server = MockServer::start(move |_req: &MockRequest| { MockResponse::new(413, "Payload Too Large") }) @@ -534,10 +539,14 @@ mod tests { .await .expect_err("the server refused the size"); assert!( - matches!(error, UpgradeError::Malformed { .. }), + matches!(error, UpgradeError::Malformed { code: None, .. }), "got {error:?}" ); - assert_eq!(error.error_code(), Some(error_codes::REQUEST_TOO_LARGE)); + assert_eq!( + error.error_code(), + None, + "a code the server never sent is not this client's to supply" + ); } /// An undeclared status is surfaced as itself rather than guessed at. From aaca127e38f6a1fc5800d34151bedff5ee4add4c Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 01:20:09 -0400 Subject: [PATCH 076/243] fix(server): stop deriving the attestation seed, and let a lockout decay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review repairs on the binary, configuration and operator commands. **The attestation seed is no longer derived from the token-signing key.** `attestation/mod.rs` requires the attestation key to be distinct from the operational one, so that holding the operational key does not let anything manufacture custody evidence. Deriving the seed by HKDF from `JWT_ED25519_DER` collapsed exactly that: anyone with the token key recomputes it and signs receipts. Worse, a comment claimed the separation was structural. `ATTESTATION_KEY_SEED` is now required on the durable path, the derivation survives only under `--memory` — where the whole state is discarded on exit, so a development server still comes up on one variable — and the comment says what is actually true. **A lockout now decays, because nothing else could clear it.** `login`, `reauthenticate` and `password` each ask the directory first and refuse on `Locked` before verifying anything; there is no unlock operation on any surface and no operator command reaches the state. Ten failures were therefore a permanently lost account rather than a throttle. The window runs from the last counted failure and defaults to fifteen minutes, which `design/authentication.md` does not name, so it is written down here. Attempts made *during* a lockout are refused without extending it: extending would hand anyone who can reach the endpoint a way to hold somebody else's account shut. The threshold joins the window as a setting — a lockout is two numbers, and driving the ten-failure default through the login route would cost ten Argon2id verifications whose spacing under load can exceed a short window, which is a test racing its own subject. **A maintenance command is told what it actually needs.** `gc`, `purge` and `scrub` never demand `VALKEY_URL`, so naming it sent an operator to configure something that would not have helped; they need `--memory`, because they compare the index against the blob store and the in-memory index is the only one written. The README and the docs stop showing `--memory` as optional there. Also: `serve-memory` binds loopback rather than every interface, since the key it falls back to is published; the compose stack publishes Postgres and Valkey to loopback, which is what makes `--protected-mode no` a concession rather than an exposure; `.env.example` ships `JWT_ED25519_DER` commented out, so `cp .env.example .env` cannot silently produce a forgeable deployment; and the `gc`/`purge` doc comment no longer claims a partial report is printed on failure, which the library's signature does not permit — it says what the operator does see instead. `VALKEY_EXTRA_FLAGS` was checked rather than assumed: `valkey/valkey:9.0.4`'s own entrypoint ends with `exec "$@" $VALKEY_EXTRA_FLAGS`, so the flags reach the server. The compose file now records that, with the evidence. Refs #401, #402 --- .../docs/development/local-development.md | 33 ++- capsule-server/.env.example | 47 +++- capsule-server/README.md | 22 +- capsule-server/compose.yaml | 18 +- capsule-server/src/auth/accounts_memory.rs | 210 ++++++++++++++++-- capsule-server/src/boot.rs | 153 ++++++++++++- capsule-server/src/cli.rs | 38 ++-- capsule-server/src/config.rs | 175 +++++++++++++-- capsule-server/tests/binary.rs | 68 ++++++ mise.toml | 11 +- 10 files changed, 685 insertions(+), 90 deletions(-) diff --git a/capsule-docs/src/content/docs/development/local-development.md b/capsule-docs/src/content/docs/development/local-development.md index 73a08b89..a6334657 100644 --- a/capsule-docs/src/content/docs/development/local-development.md +++ b/capsule-docs/src/content/docs/development/local-development.md @@ -57,10 +57,14 @@ mise run hooks-install # installs the git hooks (hk) ```text capsule-server [--config PATH] serve [--listen HOST:PORT] [--memory] [--blob-root PATH] - gc [--apply] [--grace-window-hours N] [--memory] [--blob-root PATH] - purge [--apply] [--limit N] [--memory] [--blob-root PATH] - scrub [--deep] [--budget BYTES] [--memory] [--blob-root PATH] + gc [--apply] [--grace-window-hours N] --memory --blob-root PATH + purge [--apply] [--limit N] --memory --blob-root PATH + scrub [--deep] [--budget BYTES] --memory --blob-root PATH gen-openapi [FILE] [--check] + +`--memory` is written as required on the three operator commands because today it is: they +compare the index against the blob store, and the only index adapter written is the in-memory +one. Without it they refuse and say so. It becomes optional when #402 lands. ``` ### The development profile @@ -86,9 +90,10 @@ adapter that has been written. Two consequences worth knowing before they surpri grace window has passed — and the mark store does not outlive the process. The signing key `serve-memory` falls back to is the published example in -`capsule-server/.env.example`. Every token it mints is forgeable by anyone who has read this -repository, which is why that task is `serve-memory` and not `serve`. Set `JWT_ED25519_DER` -yourself and it is used instead: +`capsule-server/.env.example` — commented out there, so a `cp .env.example .env` cannot silently +produce a forgeable deployment. Every token the task mints under it is forgeable by anyone who has +read this repository, which is why it is `serve-memory` and not `serve`, and why it binds +`127.0.0.1` rather than every interface. Set `JWT_ED25519_DER` yourself and it is used instead: ```bash JWT_ED25519_DER="$(openssl genpkey -algorithm ed25519 -outform DER | base64 | tr -d '\n')" \ @@ -98,8 +103,8 @@ JWT_ED25519_DER="$(openssl genpkey -algorithm ed25519 -outform DER | base64 | tr ### A configured server ```bash -cp capsule-server/.env.example capsule-server/.env # then edit it -mise run serve-deps # Postgres 18 + Valkey 9 +cp capsule-server/.env.example capsule-server/.env # then edit it: two keys are commented out +mise run serve-deps # Postgres 18 + Valkey 9, on loopback mise run serve ``` @@ -114,6 +119,14 @@ nothing could enforce until there was a boot path; with `VALKEY_URL` set it exit the issue that will honour it. Neither ever silently falls back to the in-memory adapters, which is the whole point: a deployment that forgot a variable must fail closed. +A configured server also has to supply `ATTESTATION_KEY_SEED`. It is **not** derived from +`JWT_ED25519_DER`, and that is deliberate: the attestation key signs custody receipts and has to +be distinct from the key that signs session tokens, or anyone holding the operational key could +manufacture custody evidence — see +[Cryptography — Failure Modes](/design/cryptography/failure-modes/). A different HKDF label over +the same input is not a separation. `serve --memory` derives it, because a development server's +whole state is discarded when it exits. + Every configuration fault is reported in **one** message with exit code 2, so bringing a deployment up is one read of one log line rather than one restart per variable. @@ -141,6 +154,10 @@ is `pretty` in a debug build and `json` in a release one; `RUST_LOG` is the usua deliberately **no key material**: a maintenance host that had to hold the production token-signing key to sweep a directory would be a reason to put the key on a maintenance host. +They do need `--memory` today, and they say so rather than naming a variable that would not have +helped: all three compare the index against the blob store, and the in-memory one is the only +index adapter written. + Dry run is the default for the two that write; `--apply` opts in, and the report says which posture produced it. `scrub` mutates nothing at all and exits non-zero on a non-empty report, which is what makes it usable as a monitoring probe — and a `--deep` pass that ran out of budget diff --git a/capsule-server/.env.example b/capsule-server/.env.example index 98d11518..ca690611 100644 --- a/capsule-server/.env.example +++ b/capsule-server/.env.example @@ -17,9 +17,14 @@ # # openssl genpkey -algorithm ed25519 -outform DER | base64 -w 0 # -# The value below is the retired deployment's own example. It is public, it signs nothing, and a -# real deployment must replace it. -JWT_ED25519_DER=MC4CAQAwBQYDK2VwBCIEIN6eTvXEL7xMZWHY8rTk7VbQSGSuRkle5MVfiiYUStLF +# **Deliberately left commented out.** The value below is the retired deployment's own example: +# it is public, anyone who has read this repository can forge tokens under it, and a +# `cp .env.example .env` that silently produced a forgeable deployment is exactly the accident +# worth preventing. Commented, the server refuses and names the variable, which is the right +# outcome for a configuration you have not finished writing. +# +# `mise run serve-memory` falls back to this same key for development, and binds loopback only. +# JWT_ED25519_DER=MC4CAQAwBQYDK2VwBCIEIN6eTvXEL7xMZWHY8rTk7VbQSGSuRkle5MVfiiYUStLF # Where ciphertext blobs are written. There is no object store: this filesystem path *is* the # blob backend, and `capsule-server gc|purge|scrub` read the same tree. @@ -73,15 +78,28 @@ VALKEY_URL=redis://127.0.0.1:6379 # spelling; this exists for a process manager that cannot add an argument. # CAPSULE_PROFILE=memory -# ── Derived key material ───────────────────────────────────────────────────────────────────── +# ── The rest of the key material ───────────────────────────────────────────────────────────── # -# Both are HKDF-SHA256-derived from JWT_ED25519_DER under separate `info` strings when unset, -# which is the right default for a single-server deployment. Set them explicitly only to rotate -# one independently of the token-signing key, or to share an attestation identity across -# replicas. +# The sync-cursor MAC key is HKDF-SHA256-derived from JWT_ED25519_DER when unset, which is the +# right default for a single-server deployment: a cursor MAC and a session token are the same +# trust domain — both are operational secrets this server holds to authenticate its own output — +# so deriving one from the other gives nobody anything they did not already have. Set it +# explicitly only to rotate it independently, or to share it across replicas. # # SYNC_CURSOR_MAC_KEY= # base64, exactly 32 bytes -# ATTESTATION_KEY_SEED= # base64, 32 or 64 bytes (32 is expanded, domain-separated) +# +# **ATTESTATION_KEY_SEED is required for a real deployment, and is deliberately not derived.** +# The attestation key signs custody receipts and has to be *distinct* from the token-signing key: +# a receipt that verified under the operational key would let anything holding that key +# manufacture custody evidence. Deriving this seed from JWT_ED25519_DER would collapse exactly +# that distinction — a different HKDF label over the same input is not a separation — so `serve` +# without `--memory` refuses until it is set. `serve --memory` derives it, because a development +# server's whole state is discarded when it exits. +# +# openssl rand -base64 64 | tr -d '\n' +# +# base64, 32 or 64 bytes; 32 is expanded to 64, domain-separated. +ATTESTATION_KEY_SEED=$(CHANGE_ME) # ── The protocol window ────────────────────────────────────────────────────────────────────── # @@ -104,6 +122,17 @@ VALKEY_URL=redis://127.0.0.1:6379 # The accepted-connection ceiling, across every listener. # MAX_CONNECTIONS=10000 +# The account lockout, which is two numbers: how many consecutive failures inside the window lock +# an account, and how long it then stays locked, measured from the last counted failure. +# +# It decays because nothing else can clear it: there is no unlock operation on any surface, and +# `login`, `reauthenticate` and `password` each refuse a locked account before verifying +# anything — so a lockout that never expired would be a permanently lost account. An attempt made +# *during* a lockout is refused without extending it, so nobody can hold somebody else's account +# shut by hammering the endpoint. A threshold of zero is refused rather than clamped. +# LOCKOUT_MAX_ATTEMPTS=10 +# LOCKOUT_WINDOW_SECONDS=900 + # `json` (one object per event, for a log shipper) or `pretty` (for a person). Defaults to # `pretty` in a debug build and `json` in a release one. Everything is written to **stderr**, so # stdout stays a data channel: `gen-openapi` writes a path there and the operator commands write diff --git a/capsule-server/README.md b/capsule-server/README.md index 0ad2b940..7d7f373d 100644 --- a/capsule-server/README.md +++ b/capsule-server/README.md @@ -43,8 +43,10 @@ and `auth::totp`'s `InMemoryTotp`. The account ports' docs say a double in `src/ credential directory shipped inside the server binary", and that reasoning is about a **double** — `tests/support/mod.rs`'s, which accepts whatever password it was told to accept. These verify with the same Argon2id helper (`auth::credential`) a Postgres adapter will, store PHC strings and no -plaintext, take the timing-equalized miss, and lock an account out after enough failures. What they -lack is durability, which is what makes them a development profile rather than a deployment. +plaintext, take the timing-equalized miss, and lock an account out after enough failures — for a +window, because no route and no operator command can clear a lockout, so one that never expired +would be a permanently lost account. What they lack is durability, which is what makes them a +development profile rather than a deployment. ## Running the tests @@ -79,10 +81,14 @@ One binary, several subcommands: ```text capsule-server [--config PATH] serve [--listen HOST:PORT] [--memory] [--blob-root PATH] - gc [--apply] [--grace-window-hours N] [--memory] [--blob-root PATH] - purge [--apply] [--limit N] [--memory] [--blob-root PATH] - scrub [--deep] [--budget BYTES] [--memory] [--blob-root PATH] + gc [--apply] [--grace-window-hours N] --memory --blob-root PATH + purge [--apply] [--limit N] --memory --blob-root PATH + scrub [--deep] [--budget BYTES] --memory --blob-root PATH gen-openapi [FILE] [--check] + +`--memory` is written as required on the three operator commands because today it is: they +compare the index against the blob store, and the only index adapter written is the in-memory +one. Without it they refuse and say so. It becomes optional when #402 lands. ``` `config` reads every setting an operator decides — command-line flag over environment over @@ -90,6 +96,12 @@ default — and reports **every** fault in one message, because an operator othe process once per variable. `capsule-server/.env.example` is the full list. There is no configuration file; `--config PATH` is accepted and refused with a sentence saying why. +A real deployment supplies **two** independent secrets: `JWT_ED25519_DER` signs session tokens, +and `ATTESTATION_KEY_SEED` signs custody receipts. The second is deliberately not derived from +the first — a receipt that verified under the operational key would let anything holding that key +manufacture custody evidence, and a different HKDF label over the same input is not a separation. +`serve --memory` derives it, because a development server's whole state is discarded on exit. + `boot::assemble` is the one composition root. `--memory` takes every in-crate adapter over a real filesystem blob store; anything else refuses, so a deployment that forgot `VALKEY_URL` fails closed rather than coming up on state it loses at the next restart. diff --git a/capsule-server/compose.yaml b/capsule-server/compose.yaml index c9e6c2d4..60013ef1 100644 --- a/capsule-server/compose.yaml +++ b/capsule-server/compose.yaml @@ -21,7 +21,10 @@ services: postgres: image: docker.io/library/postgres:18 ports: - - "5432:5432" + # Loopback only. `"5432:5432"` publishes on every interface, and these are development + # credentials in a checked-in file — a database anyone on the network can open with + # capsule/capsule. + - "127.0.0.1:5432:5432" environment: # Development credentials, matching capsule-server/.env.example's DATABASE_URL. Override # them in the environment or a .env file; nothing here is a secret worth keeping. @@ -42,14 +45,23 @@ services: valkey: image: docker.io/valkey/valkey:9.0.4 ports: - - "6379:6379" + # Loopback only, and here it is load-bearing rather than tidy: `--protected-mode no` below + # is safe *because* nothing outside this machine can reach the port, and the two have to + # agree. + - "127.0.0.1:6379:6379" environment: # Carried over from the retired deployment verbatim, because these are the flags the # session and upload-session stores were sized against: `volatile-lru` so a key with a TTL # is what gets evicted under pressure and a key without one never is, `appendonly` with # `everysec` so a restart loses at most a second of session state rather than all of it, # and the `lazyfree-*` set so an eviction does not block the command that triggered it. - # `protected-mode no` is a development-only concession: the port is published to localhost. + # `protected-mode no` is a development-only concession, and it is only a concession because + # the port above is published to loopback rather than to every interface. + # + # These flags do reach the server. `VALKEY_EXTRA_FLAGS` is often described as a Bitnami + # convention, but the official image honours it too: `valkey/valkey:9.0.4`'s own + # `/usr/local/bin/docker-entrypoint.sh` ends with `exec "$@" $VALKEY_EXTRA_FLAGS`, + # unquoted, so the variable word-splits into arguments of `valkey-server`. VALKEY_EXTRA_FLAGS: "--maxmemory 4G --maxmemory-policy volatile-lru --save 900 1 300 10 --appendonly yes --appendfsync everysec --no-appendfsync-on-rewrite yes --auto-aof-rewrite-percentage 100 --auto-aof-rewrite-min-size 64mb --lazyfree-lazy-eviction yes --lazyfree-lazy-expire yes --lazyfree-lazy-server-del yes --replica-lazy-flush yes --protected-mode no --tcp-keepalive 60 --loglevel notice --slowlog-log-slower-than 10000 --slowlog-max-len 128 --io-threads 4" healthcheck: test: ["CMD-SHELL", "valkey-cli ping | grep -q PONG"] diff --git a/capsule-server/src/auth/accounts_memory.rs b/capsule-server/src/auth/accounts_memory.rs index b2551b77..3d148b23 100644 --- a/capsule-server/src/auth/accounts_memory.rs +++ b/capsule-server/src/auth/accounts_memory.rs @@ -40,9 +40,9 @@ //! than describing how to do it. use std::collections::BTreeMap; -use std::sync::{Mutex, MutexGuard, PoisonError}; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; -use jiff::Timestamp; +use jiff::{SignedDuration, Timestamp}; use super::credential::{CredentialError, Credentials}; use super::directory::{AccountDirectory, Authentication, DirectoryError, DirectoryFuture}; @@ -50,7 +50,7 @@ use super::profile::{ AccountProfiles, PasswordChange, PasswordChanged, ProfileRecord, ProfileUpdate, }; use super::registry::{AccountRegistry, Registration}; -use crate::store::UserId; +use crate::store::{Clock, UserId}; /// How many consecutive failures put an account into [`Authentication::Locked`]. /// @@ -73,14 +73,28 @@ struct Account { display_name: Option, /// When it was created. created_at: Timestamp, - /// Consecutive failed credential presentations. + /// Consecutive failed credential presentations, since the last success or decay. failures: u32, + /// When the most recent one was, if there has been one. + /// + /// The lockout's whole clock. Without it the count is a one-way door: **nothing in this + /// server can clear a lockout.** `login`, `reauthenticate` and `password` each ask the + /// directory first and refuse on `Locked` before verifying anything, there is no unlock + /// operation on any surface, and no operator command reaches this state — so a permanent + /// lockout is a permanently lost account. + last_failure_at: Option, } impl Account { - /// Whether enough failures have accumulated to refuse a correct password. - fn locked(&self) -> bool { - self.failures >= MAX_FAILED_ATTEMPTS + /// Whether enough recent failures have accumulated to refuse a correct password. + /// + /// Recent is the operative word. The window is measured from the last **counted** failure, so + /// a person who mistyped their password ten times and walked away gets back in. + fn locked(&self, now: Timestamp, threshold: u32, window: SignedDuration) -> bool { + self.failures >= threshold + && self + .last_failure_at + .is_some_and(|at| now.duration_since(at) < window) } /// The profile view of this account. @@ -104,18 +118,32 @@ impl Account { #[derive(Debug)] pub struct InMemoryAccounts { credentials: Credentials, + clock: Arc, + lockout_attempts: u32, + lockout_window: SignedDuration, accounts: Mutex>, } impl InMemoryAccounts { - /// An empty directory over `credentials`. + /// An empty directory over `credentials`, locking an account out for `lockout_window` after + /// `lockout_attempts` consecutive failures ([`MAX_FAILED_ATTEMPTS`] by default). /// /// The verifier is passed in rather than constructed here because building one costs an /// Argon2id hash (the decoy), and a composition root that builds several adapters should pay - /// that once. - pub fn new(credentials: Credentials) -> Self { + /// that once. The clock is injected for the reason every other adapter in this crate injects + /// one: expiry that reads the wall clock directly is expiry a test can only assert by + /// sleeping. + pub fn new( + credentials: Credentials, + clock: Arc, + lockout_attempts: u32, + lockout_window: SignedDuration, + ) -> Self { Self { credentials, + clock, + lockout_attempts, + lockout_window, accounts: Mutex::new(BTreeMap::new()), } } @@ -156,18 +184,32 @@ impl InMemoryAccounts { /// /// One place, so the reset-on-success half cannot be forgotten at one of the two call sites. fn record(&self, email: &str, granted: bool) { + let now = self.clock.now(); + let window = self.lockout_window; if let Some(held) = self.accounts().get_mut(email) { if granted { held.failures = 0; - } else { - held.failures = held.failures.saturating_add(1); - if held.failures == MAX_FAILED_ATTEMPTS { - tracing::warn!( - user = %held.user_id, - failures = held.failures, - "an account reached the failed-attempt ceiling and is locked out" - ); - } + held.last_failure_at = None; + return; + } + // A failure after the window has passed starts a fresh run rather than tipping a + // stale count over. Otherwise ten mistypes spread over a year would lock an account + // on the tenth, which is not a guessing run and not what the ceiling is counting. + if held + .last_failure_at + .is_some_and(|at| now.duration_since(at) >= window) + { + held.failures = 0; + } + held.failures = held.failures.saturating_add(1); + held.last_failure_at = Some(now); + if held.failures == self.lockout_attempts { + tracing::warn!( + user = %held.user_id, + failures = held.failures, + window = %window, + "an account reached the failed-attempt ceiling and is locked out" + ); } } } @@ -188,7 +230,7 @@ impl InMemoryAccounts { self.credentials.absorb_miss(password); return Ok(Authentication::Refused); }; - if held.locked() { + if held.locked(self.clock.now(), self.lockout_attempts, self.lockout_window) { // Still absorbed: a locked account that returned instantly would tell an attacker // which addresses they have already spent attempts on. self.credentials.absorb_miss(password); @@ -266,6 +308,7 @@ impl AccountRegistry for InMemoryAccounts { display_name: None, created_at: at, failures: 0, + last_failure_at: None, }, ); tracing::info!(%user, "an account was created in the in-memory directory"); @@ -317,6 +360,7 @@ impl PasswordChange for InMemoryAccounts { // leaving the lockout behind would bar somebody from an account they just proved // they own. held.failures = 0; + held.last_failure_at = None; tracing::info!(%user, "an account's password was replaced"); Ok(PasswordChanged::Yes) }) @@ -325,13 +369,16 @@ impl PasswordChange for InMemoryAccounts { #[cfg(test)] mod tests { - use jiff::Timestamp; + use std::sync::Arc; + + use jiff::{SignedDuration, Timestamp}; use super::{Credentials, InMemoryAccounts, MAX_FAILED_ATTEMPTS}; use crate::auth::directory::{AccountDirectory, Authentication}; use crate::auth::profile::{AccountProfiles, PasswordChange, PasswordChanged, ProfileUpdate}; use crate::auth::registry::{AccountRegistry, Registration}; use crate::store::UserId; + use crate::store::memory::ManualClock; const EMAIL: &str = "somebody@example.test"; const PASSWORD: &str = "correct horse battery staple"; @@ -340,9 +387,17 @@ mod tests { UserId::new("018f3f1e-4b7a-7c9d-8e2f-1a2b3c4d5e6f") } - /// A directory with one registered account. - async fn seeded() -> InMemoryAccounts { - let accounts = InMemoryAccounts::new(Credentials::new().expect("the platform hashes")); + /// A fifteen-minute lockout window, as a deployment gets by default. + const WINDOW: SignedDuration = SignedDuration::from_mins(15); + + /// A directory with one registered account, over a clock the test drives. + async fn seeded_on(clock: Arc) -> InMemoryAccounts { + let accounts = InMemoryAccounts::new( + Credentials::new().expect("the platform hashes"), + clock, + MAX_FAILED_ATTEMPTS, + WINDOW, + ); assert_eq!( accounts .create(EMAIL, PASSWORD, &user(), Timestamp::UNIX_EPOCH) @@ -353,6 +408,18 @@ mod tests { accounts } + /// The same, for a case with nothing to say about time. + async fn seeded() -> InMemoryAccounts { + seeded_on(Arc::new(ManualClock::default())).await + } + + /// Present a wrong password `times` times. + async fn fail(accounts: &InMemoryAccounts, times: u32) { + for _ in 0..times { + let _ = accounts.authenticate(EMAIL, "wrong").await; + } + } + #[tokio::test] async fn registering_then_signing_in_works() { // The whole reason this adapter exists rather than a fail-closed stub. @@ -446,6 +513,101 @@ mod tests { ); } + #[tokio::test] + async fn a_lockout_decays_because_nothing_else_can_clear_it() { + // There is no unlock operation on any surface, and `login`, `reauthenticate` and + // `password` all refuse on `Locked` before they verify anything — so without a decay a + // lockout is a permanently lost account rather than a throttle. + let clock = Arc::new(ManualClock::default()); + let accounts = seeded_on(clock.clone()).await; + fail(&accounts, MAX_FAILED_ATTEMPTS).await; + assert_eq!( + accounts + .authenticate(EMAIL, PASSWORD) + .await + .expect("it answers"), + Authentication::Locked + ); + + // One second short of the window: still locked. The boundary is asserted because an + // off-by-one here is a lockout that never engages. + clock.advance(WINDOW - SignedDuration::from_secs(1)); + assert_eq!( + accounts + .authenticate(EMAIL, PASSWORD) + .await + .expect("it answers"), + Authentication::Locked + ); + + clock.advance(SignedDuration::from_secs(1)); + assert_eq!( + accounts + .authenticate(EMAIL, PASSWORD) + .await + .expect("it answers"), + Authentication::Granted(user()) + ); + } + + #[tokio::test] + async fn attempts_during_a_lockout_do_not_extend_it() { + // Deliberate, and the direction is not obvious. Extending the window on every attempt + // would keep a live guessing run permanently locked out — and would hand anybody who can + // reach the endpoint a way to keep *somebody else's* account locked forever by hammering + // it, which is a denial of service on an account rather than a defence of it. So the + // window runs from the last **counted** failure, and an attempt made while locked is + // refused without being counted. + // + // What that costs is bounded and small: a run gets `MAX_FAILED_ATTEMPTS` guesses per + // window and no more, which is four a minute at the default. What bounds an attacker + // across *many* accounts is a rate limiter, and the counter port that would carry one + // has no trusted client address to key on (`registry`, the disclosure section). + let clock = Arc::new(ManualClock::default()); + let accounts = seeded_on(clock.clone()).await; + fail(&accounts, MAX_FAILED_ATTEMPTS).await; + for _ in 0..3 { + clock.advance(SignedDuration::from_mins(1)); + fail(&accounts, 1).await; + } + // Still inside the window measured from the tenth *counted* failure: locked. + assert_eq!( + accounts + .authenticate(EMAIL, PASSWORD) + .await + .expect("it answers"), + Authentication::Locked + ); + // Past it: open, and the hammering did not move the deadline. + clock.advance(WINDOW); + assert_eq!( + accounts + .authenticate(EMAIL, PASSWORD) + .await + .expect("it answers"), + Authentication::Granted(user()) + ); + } + + #[tokio::test] + async fn failures_spread_wider_than_the_window_never_accumulate() { + // Ten mistypes over a year is not a guessing run, and counting them as one would lock an + // account on a tenth attempt made months after the ninth. + let clock = Arc::new(ManualClock::default()); + let accounts = seeded_on(clock.clone()).await; + for _ in 0..MAX_FAILED_ATTEMPTS * 2 { + fail(&accounts, 1).await; + clock.advance(WINDOW + SignedDuration::from_secs(1)); + } + assert_eq!( + accounts + .authenticate(EMAIL, PASSWORD) + .await + .expect("it answers"), + Authentication::Granted(user()) + ); + } + #[tokio::test] async fn a_success_before_the_ceiling_clears_the_count() { let accounts = seeded().await; diff --git a/capsule-server/src/boot.rs b/capsule-server/src/boot.rs index a46ae8f1..9552433e 100644 --- a/capsule-server/src/boot.rs +++ b/capsule-server/src/boot.rs @@ -131,6 +131,19 @@ pub enum BootError { /// Where the work is tracked. issue: &'static str, }, + /// An operator command was run without `--memory` and there is no durable index to read. + /// + /// Deliberately not [`Self::AdapterUnavailable`]: that one names `VALKEY_URL`, which an + /// operator running `capsule-server scrub` has typically never set, and pointing them at a + /// variable that would not have helped is worse than saying nothing. + #[error( + "this command needs `--memory`: it compares the index against the blob store, and the \ + only index adapter written is the in-memory one (see {issue})" + )] + MaintenanceNeedsMemory { + /// Where the work is tracked. + issue: &'static str, + }, /// The router's own types do not describe a buildable server. /// /// Unreachable in practice — the conformance suite builds the same router on every test run @@ -218,7 +231,15 @@ pub async fn assemble_maintenance(config: &Config) -> Result Err(durable()), + // **Not** `durable()`. A maintenance command reaching here has almost always set no + // backend variable at all — `gc`/`purge`/`scrub` never demand `VALKEY_URL`, so naming it + // would send an operator to configure a variable that would not have helped. What is + // actually missing is the durable **index**: these two workers compare the index against + // the blob store, and the only index this crate has is the in-memory one, which is what + // `--memory` selects. + Backends::Durable => Err(BootError::MaintenanceNeedsMemory { + issue: "#402 (the Postgres index)", + }), } } @@ -342,7 +363,12 @@ fn memory(config: &Config, stores: Stores) -> Result { let credentials = Credentials::new().map_err(|error| BootError::Credentials { detail: error.detail, })?; - let accounts = Arc::new(InMemoryAccounts::new(credentials)); + let accounts = Arc::new(InMemoryAccounts::new( + credentials, + clock.clone(), + config.lockout_attempts, + config.lockout_window, + )); let albums = Arc::new(InMemoryAlbums::new()); let directories = Arc::new(InMemoryDeviceDirectory::new()); @@ -356,9 +382,13 @@ fn memory(config: &Config, stores: Stores) -> Result { )); let receipts = Arc::new(InMemoryReceipts::new()); // Distinct from the token signer, as the design requires: a receipt that verified under the - // operational key would let anything holding that key manufacture custody evidence. The - // separation is structural here — the seed is a different HKDF `info` over the same input, - // so an operator cannot accidentally configure one key for both. + // operational key would let anything holding that key manufacture custody evidence. + // + // Which is why a durable deployment has to **supply** `ATTESTATION_KEY_SEED` rather than + // have it derived (`config`, the key-material section). A different HKDF `info` over the + // same input is not a separation at all — anyone holding `JWT_ED25519_DER` recomputes it — + // and it read as one, which is worse than no comment. The derivation survives only under + // `Backends::Memory`, where the server is a development act whose state is discarded. let attestation_key = Arc::new(LocalAttestationKey::new( config.server_domain.clone(), capsule_core::crypto::keys::HybridSigningKey::from_seed64(&seed), @@ -471,19 +501,27 @@ fn memory(config: &Config, stores: Stores) -> Result { mod tests { use std::collections::BTreeMap; - use super::{BootError, assemble}; + use super::{App, BootError, assemble}; use crate::config::{Config, Demands, Overrides}; /// A PKCS#8 v1 Ed25519 key, base64. Signs nothing; see `config`'s own tests. const EXAMPLE_DER: &str = "MC4CAQAwBQYDK2VwBCIEIN6eTvXEL7xMZWHY8rTk7VbQSGSuRkle5MVfiiYUStLF"; fn memory_config(root: &std::path::Path) -> Config { - let environment: BTreeMap = [ + memory_config_with(root, &[]) + } + + /// The same, with `extra` on top of the environment. + fn memory_config_with(root: &std::path::Path, extra: &[(&str, &str)]) -> Config { + let mut environment: BTreeMap = [ ("BLOB_ROOT".to_owned(), root.display().to_string()), ("JWT_ED25519_DER".to_owned(), EXAMPLE_DER.to_owned()), ] .into_iter() .collect(); + for (key, value) in extra { + environment.insert((*key).to_owned(), (*value).to_owned()); + } let overrides = Overrides { memory: true, ..Overrides::default() @@ -491,6 +529,31 @@ mod tests { Config::load(&environment, &overrides, Demands::Serve).expect("the configuration loads") } + /// Register the account the auth cases sign in with, through the surface. + async fn register(client: &kynos::test::TestClient, password: &str) { + client + .post("/v1/auth/register") + .header("accept", "application/json") + .json(&serde_json::json!({ "email": "somebody@example.test", "password": password })) + .send() + .await + .assert_status(kynos::http::StatusCode::OK); + } + + /// Attempt a sign-in and return the status the route answered with. + async fn login( + client: &kynos::test::TestClient, + password: &str, + ) -> kynos::http::StatusCode { + client + .post("/v1/auth/login") + .header("accept", "application/json") + .json(&serde_json::json!({ "email": "somebody@example.test", "password": password })) + .send() + .await + .status() + } + #[tokio::test] async fn the_memory_profile_assembles_a_server_whose_router_builds() { // The property nothing in this crate asserted before: seventeen module contexts, every @@ -548,6 +611,15 @@ mod tests { ("BLOB_ROOT".to_owned(), root.path().display().to_string()), ("JWT_ED25519_DER".to_owned(), EXAMPLE_DER.to_owned()), ("VALKEY_URL".to_owned(), "redis://127.0.0.1:6379".to_owned()), + // A durable deployment supplies its own attestation identity rather than having one + // derived from the token signer; `config` refuses without it. + ( + "ATTESTATION_KEY_SEED".to_owned(), + base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + [9_u8; 64].as_slice(), + ), + ), ] .into_iter() .collect(); @@ -661,6 +733,73 @@ mod tests { assert!(signed_in["access_token"].is_string(), "{signed_in}"); } + #[tokio::test] + async fn a_locked_account_recovers_through_the_login_route_once_the_window_passes() { + // Asserted through the **route** rather than against the adapter, because that is where + // the property actually has to hold: `login` asks the directory first and answers `423` + // before it verifies anything, so a lockout that did not decay would be an account no + // request could ever open again — there is no unlock operation on any surface. + // + // A one-attempt threshold, a one-second window and a real wait. Both numbers are + // settings rather than constants precisely so this is expressible: driving the default + // ten-failure ceiling through the route would cost ten Argon2id verifications, and on a + // loaded machine the gaps between them can themselves exceed a short window — a test + // whose setup races its own subject. The alternative was a clock seam through the whole + // composition root for one case, and the composition root is the thing under test. + let root = tempfile::tempdir().expect("a scratch directory"); + let config = memory_config_with( + root.path(), + &[ + ("LOCKOUT_MAX_ATTEMPTS", "1"), + ("LOCKOUT_WINDOW_SECONDS", "1"), + ], + ); + let assembled = assemble(&config).await.expect("it assembles"); + let client = kynos::test::TestClient::new(assembled.service().expect("the router builds")); + + register(&client, "correct horse battery staple").await; + assert_eq!( + login(&client, "wrong").await, + kynos::http::StatusCode::UNAUTHORIZED + ); + assert_eq!( + login(&client, "correct horse battery staple").await, + kynos::http::StatusCode::LOCKED, + "the ceiling engages, and a correct password is told so rather than refused" + ); + + tokio::time::sleep(std::time::Duration::from_millis(1_500)).await; + assert_eq!( + login(&client, "correct horse battery staple").await, + kynos::http::StatusCode::OK, + "the window passed, so the account is the owner's again" + ); + } + + #[tokio::test] + async fn a_maintenance_command_is_told_it_needs_memory_and_not_valkey() { + // An operator running `capsule-server scrub` has typically set no backend variable at + // all. Naming `VALKEY_URL` would send them to configure something that would not have + // helped; what is missing is the durable index these workers read. + let root = tempfile::tempdir().expect("a scratch directory"); + let environment: BTreeMap = + [("BLOB_ROOT".to_owned(), root.path().display().to_string())] + .into_iter() + .collect(); + let config = Config::load(&environment, &Overrides::default(), Demands::Maintenance) + .expect("maintenance demands nothing else"); + let error = super::assemble_maintenance(&config) + .await + .expect_err("it refuses"); + assert!( + matches!(error, BootError::MaintenanceNeedsMemory { .. }), + "{error:?}" + ); + let message = format!("{error}"); + assert!(message.contains("--memory"), "{message}"); + assert!(!message.contains("VALKEY_URL"), "{message}"); + } + #[tokio::test] async fn a_wrong_password_is_refused_rather_than_granted() { // The property that makes the adapter real rather than permissive: the credential diff --git a/capsule-server/src/cli.rs b/capsule-server/src/cli.rs index ffd496e5..136af3c5 100644 --- a/capsule-server/src/cli.rs +++ b/capsule-server/src/cli.rs @@ -405,29 +405,37 @@ fn mode(apply: bool) -> Mode { /// Sweep blobs nothing references any more. /// -/// The partial report is printed **before** the error when a pass fails part-way. `collect` -/// propagates the first store failure having applied whatever it did before it, which is safe -/// in both directions by the module's own argument — a mark is reversible and a sweep only ever -/// removed a blob confirmed unreferenced twice — but an operator still needs to know what -/// happened before it stopped. +/// # What an interrupted pass leaves, and what the operator is told +/// +/// `gc::collect` returns `Result`, so a pass that fails part-way +/// returns **no report at all** — the work it did before the failure is not recoverable from +/// here, and stdout stays empty. What the operator sees is the store error on stderr and a +/// non-zero exit; what they have to do to find out how far it got is read the `INFO` lines the +/// collector logs as it marks and sweeps. +/// +/// That is a real gap and it is the library's to close: the report would have to come back +/// alongside the error (`Result<(CollectionReport, Option), _>` or equivalent), and +/// `gc/mod.rs` is not this change's to edit. The state left behind is safe either way, by the +/// module's own argument — a mark is reversible, and a sweep only ever removed a blob confirmed +/// unreferenced twice — so re-running the pass is the correct response to one that stopped. async fn collect(config: &Config, mode: Mode) -> Result { let maintenance = maintenance(config).await?; - let report = crate::gc::collect(&maintenance.collection, mode).await; - if let Ok(report) = &report { - print!("{}", render_collection(report, mode)); - } - report.map_err(|error| eyre!("the collection pass could not finish: {error}"))?; + let report = crate::gc::collect(&maintenance.collection, mode) + .await + .map_err(|error| eyre!("the collection pass could not finish: {error}"))?; + print!("{}", render_collection(&report, mode)); Ok(ExitCode::SUCCESS) } /// Drop the blob references of tombstoned assets past their retention window. +/// +/// An interrupted pass reports nothing, for the reason [`collect`] records. async fn purge(config: &Config, mode: Mode, limit: usize) -> Result { let maintenance = maintenance(config).await?; - let report = crate::gc::purge_expired(&maintenance.collection, mode, limit).await; - if let Ok(report) = &report { - print!("{}", render_purge(report, mode)); - } - report.map_err(|error| eyre!("the retention purge could not finish: {error}"))?; + let report = crate::gc::purge_expired(&maintenance.collection, mode, limit) + .await + .map_err(|error| eyre!("the retention purge could not finish: {error}"))?; + print!("{}", render_purge(&report, mode)); Ok(ExitCode::SUCCESS) } diff --git a/capsule-server/src/config.rs b/capsule-server/src/config.rs index 7a587477..806f8640 100644 --- a/capsule-server/src/config.rs +++ b/capsule-server/src/config.rs @@ -58,6 +58,29 @@ const DEFAULT_SHUTDOWN_TIMEOUT: u64 = 25; /// The accepted-connection ceiling, matching Kynos's own default. const DEFAULT_MAX_CONNECTIONS: usize = 10_000; +/// How long an account stays locked after enough failed credential presentations. +/// +/// Fifteen minutes. `design/authentication.md` names no figure — it says only that a locked +/// account is locked at password change too — so this is a decision recorded here rather than a +/// value read from somewhere: long enough that an online guessing run is throttled to +/// uselessness, short enough that a person who mistyped their password four times gets back into +/// their own account without an operator. +/// +/// It has to decay at all, because there is **no unlock endpoint and no operator command that +/// clears it**: every route that could reset the state (`login`, `reauthenticate`, +/// `password`) refuses on `Locked` before it verifies anything, so a permanent lockout is +/// a permanently lost account. Seconds rather than minutes as the unit so a test can pick a +/// window it can actually wait out. +const DEFAULT_LOCKOUT_WINDOW_SECONDS: u64 = 15 * 60; + +/// How many consecutive failures inside that window lock an account. +/// +/// The companion number to the window — a lockout is not one policy but two, and a deployment +/// that wants a tighter one needs to move both. Ten is +/// [`MAX_FAILED_ATTEMPTS`](crate::auth::accounts_memory::MAX_FAILED_ATTEMPTS), which is where the +/// reasoning for the figure lives. +const DEFAULT_LOCKOUT_ATTEMPTS: u32 = crate::auth::accounts_memory::MAX_FAILED_ATTEMPTS; + /// The seed [`HybridSigningKey`](capsule_core::crypto::keys::HybridSigningKey) is built from. const ATTESTATION_SEED_LEN: usize = 64; @@ -276,6 +299,10 @@ pub struct Config { pub protocol_max: String, /// How long a blob sits at zero references before the collector may sweep it. pub grace_window: SignedDuration, + /// How long an account stays locked after too many failed credential presentations. + pub lockout_window: SignedDuration, + /// How many consecutive failures inside that window lock it. + pub lockout_attempts: u32, /// How long a shutdown may take to drain. pub shutdown_timeout: std::time::Duration, /// The accepted-connection ceiling. @@ -384,20 +411,31 @@ impl Config { decode_fixed::(env, "SYNC_CURSOR_MAC_KEY", &mut faults); let attestation_key_seed = decode_seed(env, &mut faults); - // Both are HKDF-derived from the token-signing key when unset, which is the right - // default for a single-server deployment and the reason the two variables are optional: - // an operator sets them explicitly only to rotate one independently of the token key, or - // to share an attestation identity across replicas. - let (sync_cursor_mac_key, attestation_key_seed) = match &signing_key_der { - Some(der) => ( - sync_cursor_mac_key - .or_else(|| derive::(der.expose(), CURSOR_KEY_INFO)), - attestation_key_seed.or_else(|| { - derive::(der.expose(), ATTESTATION_SEED_INFO) - }), + // The sync-cursor MAC key is HKDF-derived from the token-signing key when unset. That is + // sound: a cursor MAC and a session token are the same trust domain — both are + // operational secrets this server holds to authenticate its own output — so deriving one + // from the other adds no capability to anybody who holds either. + // + // **The attestation seed is not, outside the development profile**, and that is a + // correction rather than a preference. `attestation/mod.rs` requires the attestation key + // to be distinct from the operational key precisely so that holding the operational key + // does not let anything manufacture custody evidence. Deriving the seed from + // `JWT_ED25519_DER` collapses exactly that distinction: anyone with the token-signing key + // recomputes the attestation key and signs receipts. So a real deployment must set + // `ATTESTATION_KEY_SEED` (see the `Demands::Serve` arm below), and only + // `Backends::Memory` — an explicit `--memory`, a development act, where the whole + // application state is discarded on exit — keeps the derivation, so `serve --memory` + // needs one variable rather than two. + let sync_cursor_mac_key = sync_cursor_mac_key.or_else(|| { + derive::(signing_key_der.as_ref()?.expose(), CURSOR_KEY_INFO) + }); + let attestation_key_seed = attestation_key_seed.or_else(|| match backends { + Backends::Memory => derive::( + signing_key_der.as_ref()?.expose(), + ATTESTATION_SEED_INFO, ), - None => (sync_cursor_mac_key, attestation_key_seed), - }; + Backends::Durable => None, + }); // ── Protocol window ───────────────────────────────────────────────────────────── let protocol_max = env @@ -420,6 +458,32 @@ impl Config { .map_or(crate::gc::DEFAULT_GRACE_WINDOW, |hours| { SignedDuration::from_hours(i64::try_from(hours).unwrap_or(i64::MAX)) }); + let lockout_window = SignedDuration::from_secs( + parse_number::(env, "LOCKOUT_WINDOW_SECONDS", &mut faults).map_or_else( + || { + i64::try_from(DEFAULT_LOCKOUT_WINDOW_SECONDS) + .expect("the built-in lockout window fits") + }, + |seconds| seconds.max(0), + ), + ); + let lockout_attempts = parse_number::(env, "LOCKOUT_MAX_ATTEMPTS", &mut faults) + .and_then(|attempts| { + if attempts == 0 { + // Zero would lock every account on its first wrong keystroke and never + // unlock it before the window passed, which is a denial of service dressed + // as a policy. Refused rather than clamped to one: an operator who typed it + // meant something, and guessing what is worse than saying it is not allowed. + faults.push(ConfigFault::Invalid { + key: "LOCKOUT_MAX_ATTEMPTS", + detail: "zero would lock every account on its first failure".to_owned(), + }); + None + } else { + Some(attempts) + } + }) + .unwrap_or(DEFAULT_LOCKOUT_ATTEMPTS); let shutdown_timeout = std::time::Duration::from_secs( parse_number::(env, "SHUTDOWN_TIMEOUT_SECONDS", &mut faults) .unwrap_or(DEFAULT_SHUTDOWN_TIMEOUT), @@ -474,8 +538,19 @@ impl Config { // The refusal `store/mod.rs` has always documented and nothing has ever // enforced: Valkey is required, and the in-memory adapters are a development // profile an operator opts into rather than something to fall back on. - if backends == Backends::Durable && valkey_url.is_none() { - faults.push(ConfigFault::Missing { key: "VALKEY_URL" }); + if backends == Backends::Durable { + if valkey_url.is_none() { + faults.push(ConfigFault::Missing { key: "VALKEY_URL" }); + } + // Required rather than derived; see the key-material section above for what + // deriving it from the token-signing key would give away. Demanded only on + // the durable path because `--memory` derives it, so a development server + // still comes up on one variable. + if attestation_key_seed.is_none() { + faults.push(ConfigFault::Missing { + key: "ATTESTATION_KEY_SEED", + }); + } } } } @@ -494,6 +569,8 @@ impl Config { protocol_min, protocol_max, grace_window, + lockout_window, + lockout_attempts, shutdown_timeout, max_connections, log_format, @@ -634,6 +711,8 @@ fn derive(secret: &[u8], info: &[u8]) -> Option<[u8; N]> { mod tests { use std::collections::BTreeMap; + use jiff::SignedDuration; + use super::{Backends, Config, Demands, LogFormat, Overrides}; /// A PKCS#8 v1 Ed25519 key, base64, from the retired deployment's own `.env.example`. @@ -720,6 +799,15 @@ mod tests { assert!(error.names("MAX_CONNECTIONS"), "{error}"); assert!(error.names("BLOB_ROOT"), "{error}"); assert!(error.names("JWT_ED25519_DER"), "{error}"); + // Not `ATTESTATION_KEY_SEED`: `--memory` derives it, so naming it here would send a + // developer looking for a variable the development profile does not want. + assert!(!error.names("ATTESTATION_KEY_SEED"), "{error}"); + + // The durable path names both of its own, alongside everything else. + let error = Config::load(&environment, &Overrides::default(), Demands::Serve) + .expect_err("it refuses"); + assert!(error.names("VALKEY_URL"), "{error}"); + assert!(error.names("ATTESTATION_KEY_SEED"), "{error}"); } #[test] @@ -767,8 +855,35 @@ mod tests { assert!(config.blob_root.is_none()); } + #[test] + fn a_durable_serve_must_be_given_an_attestation_seed() { + // The attestation key must be distinct from the operational key — `attestation/mod.rs` + // requires it so that holding the token signer does not let anything manufacture custody + // evidence. Deriving the seed from `JWT_ED25519_DER` collapses exactly that, so a real + // deployment is made to say what its attestation identity is. + let environment = env(&[ + ("BLOB_ROOT", "/blobs"), + ("JWT_ED25519_DER", EXAMPLE_DER), + ("VALKEY_URL", "redis://127.0.0.1:6379"), + ]); + let error = Config::load(&environment, &Overrides::default(), Demands::Serve) + .expect_err("it refuses"); + assert!(error.names("ATTESTATION_KEY_SEED"), "{error}"); + + let mut with_seed = environment.clone(); + with_seed.insert( + "ATTESTATION_KEY_SEED".to_owned(), + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, [9_u8; 64]), + ); + let config = Config::load(&with_seed, &Overrides::default(), Demands::Serve) + .expect("it loads with a seed of its own"); + assert_eq!(config.attestation_key_seed, Some([9; 64])); + } + #[test] fn the_cursor_key_and_the_attestation_seed_are_derived_from_the_signing_key() { + // The **development** profile only. A durable deployment is made to set the seed; see + // the case above. let first = Config::load(&serveable(), &memory(), Demands::Serve).expect("it loads"); let second = Config::load(&serveable(), &memory(), Demands::Serve).expect("it loads"); @@ -792,6 +907,36 @@ mod tests { ); } + #[test] + fn the_lockout_threshold_is_configurable_and_may_not_be_zero() { + // A lockout is two numbers, not one, and a deployment that wants a tighter policy has to + // be able to move both. + let config = Config::load(&serveable(), &memory(), Demands::Serve).expect("it loads"); + assert_eq!(config.lockout_attempts, 10); + + let mut environment = serveable(); + environment.insert("LOCKOUT_MAX_ATTEMPTS".to_owned(), "3".to_owned()); + let config = Config::load(&environment, &memory(), Demands::Serve).expect("it loads"); + assert_eq!(config.lockout_attempts, 3); + + environment.insert("LOCKOUT_MAX_ATTEMPTS".to_owned(), "0".to_owned()); + let error = Config::load(&environment, &memory(), Demands::Serve).expect_err("it refuses"); + assert!(error.names("LOCKOUT_MAX_ATTEMPTS"), "{error}"); + } + + #[test] + fn the_lockout_window_defaults_to_fifteen_minutes_and_is_configurable() { + // It has to decay at all: no route resets a lockout — every one of them refuses on + // `Locked` before it verifies anything — so a permanent lockout is a lost account. + let config = Config::load(&serveable(), &memory(), Demands::Serve).expect("it loads"); + assert_eq!(config.lockout_window, SignedDuration::from_mins(15)); + + let mut environment = serveable(); + environment.insert("LOCKOUT_WINDOW_SECONDS".to_owned(), "1".to_owned()); + let config = Config::load(&environment, &memory(), Demands::Serve).expect("it loads"); + assert_eq!(config.lockout_window, SignedDuration::from_secs(1)); + } + #[test] fn an_explicit_cursor_key_wins_over_the_derived_one() { let mut environment = serveable(); diff --git a/capsule-server/tests/binary.rs b/capsule-server/tests/binary.rs index d7de660e..5c883d2f 100644 --- a/capsule-server/tests/binary.rs +++ b/capsule-server/tests/binary.rs @@ -44,6 +44,14 @@ use capsule_server::store::SystemClock; /// sides can name. const EXAMPLE_DER: &str = "MC4CAQAwBQYDK2VwBCIEIN6eTvXEL7xMZWHY8rTk7VbQSGSuRkle5MVfiiYUStLF"; +/// A 64-byte attestation seed, base64. +/// +/// A durable deployment has to supply its own — it is deliberately not derived from +/// `JWT_ED25519_DER`, because a receipt that verified under the operational key would let +/// anything holding that key manufacture custody evidence. +const EXAMPLE_SEED: &str = + "CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQ=="; + /// The address the operating system will pick a port under. const EPHEMERAL: &str = "127.0.0.1:0"; @@ -292,11 +300,53 @@ fn a_durable_backend_refuses_with_the_issue_that_will_honour_it() { &root.path().display().to_string(), ]) .env("JWT_ED25519_DER", EXAMPLE_DER) + .env("ATTESTATION_KEY_SEED", EXAMPLE_SEED) .env("VALKEY_URL", "redis://127.0.0.1:6379")); assert_ne!(code, Some(0), "{stderr}"); assert!(stderr.contains("#403"), "{stderr}"); } +#[test] +fn a_durable_serve_is_refused_without_an_attestation_seed_of_its_own() { + // The attestation key must be distinct from the token signer — `attestation/mod.rs` requires + // it so that holding the operational key does not let anything manufacture custody evidence. + // Deriving the seed from `JWT_ED25519_DER` under a different HKDF label is not a separation: + // anyone with the token key recomputes it. So a real deployment is made to say what its + // attestation identity is, and only `--memory` keeps the derivation. + let root = tempfile::tempdir().expect("a scratch directory"); + let (code, _, stderr) = run(server(&[ + "serve", + "--listen", + EPHEMERAL, + "--blob-root", + &root.path().display().to_string(), + ]) + .env("JWT_ED25519_DER", EXAMPLE_DER) + .env("VALKEY_URL", "redis://127.0.0.1:6379")); + assert_eq!(code, Some(2), "{stderr}"); + assert!(stderr.contains("ATTESTATION_KEY_SEED"), "{stderr}"); +} + +#[test] +fn the_memory_profile_still_needs_only_one_key() { + // The other side of the same decision: a development server derives its attestation seed, so + // `serve --memory` comes up on one variable rather than two. Asserted through `gc`'s sibling + // path — a full `serve` is covered by the socket case above — by checking that the config + // layer raises no seed fault for `--memory`. + let root = tempfile::tempdir().expect("a scratch directory"); + let (code, _, stderr) = + run(server(&["serve", "--memory", "--listen", EPHEMERAL]) + .env("JWT_ED25519_DER", EXAMPLE_DER)); + assert_eq!(code, Some(2), "{stderr}"); + assert!(stderr.contains("BLOB_ROOT"), "{stderr}"); + assert!( + !stderr.contains("ATTESTATION_KEY_SEED"), + "the development profile derives it, so naming it would send a developer looking for a \ + variable it does not want: {stderr}" + ); + let _ = root; +} + #[test] fn every_missing_setting_is_named_in_one_message() { // An operator reading a crash loop's logs learns about both at once rather than restarting @@ -488,6 +538,24 @@ fn the_operator_commands_need_no_key_material() { } } +#[test] +fn an_operator_command_without_memory_is_told_that_and_not_about_valkey() { + // An operator running `capsule-server scrub` has typically set no backend variable at all. + // Naming `VALKEY_URL` — which these commands never demand — would send them to configure + // something that would not have helped; what is missing is the durable index they read. + let root = tempfile::tempdir().expect("a scratch directory"); + for subcommand in ["gc", "purge", "scrub"] { + let (code, _, stderr) = run(&mut server(&[ + subcommand, + "--blob-root", + &root.path().display().to_string(), + ])); + assert_ne!(code, Some(0), "{subcommand}: {stderr}"); + assert!(stderr.contains("--memory"), "{subcommand}: {stderr}"); + assert!(!stderr.contains("VALKEY_URL"), "{subcommand}: {stderr}"); + } +} + #[test] fn an_operator_command_without_a_blob_root_refuses_by_name() { let (code, _, stderr) = run(&mut server(&["scrub", "--memory"])); diff --git a/mise.toml b/mise.toml index f9fff287..5bc7ae58 100644 --- a/mise.toml +++ b/mise.toml @@ -258,13 +258,16 @@ description = "Run the server on the in-memory adapters (development only)" # A server you can point a client at: register, sign in, upload, sync. The blob store is real # and lives under `target/`; everything else is lost when the process exits. # -# The fallback key is the **published** example from capsule-server/.env.example. Every token -# this mints is forgeable by anyone who has read this repository, which is exactly why this task -# is `serve-memory` and not `serve` — set `JWT_ED25519_DER` yourself and it is used instead. +# Loopback, not the `0.0.0.0` default. That default is right for a deployment behind an ingress +# and wrong here: the fallback key below is the **published** example from +# capsule-server/.env.example, so every token this mints is forgeable by anyone who has read this +# repository — and a forgeable server reachable from the office network is a different thing from +# one reachable only from your own machine. That is also why this task is `serve-memory` and not +# `serve`; set `JWT_ED25519_DER` yourself and it is used instead. run = """ JWT_ED25519_DER=${JWT_ED25519_DER:-MC4CAQAwBQYDK2VwBCIEIN6eTvXEL7xMZWHY8rTk7VbQSGSuRkle5MVfiiYUStLF} \ BLOB_ROOT=${BLOB_ROOT:-./target/capsule-server-blobs} \ -cargo run -p capsule-server -- serve --memory +cargo run -p capsule-server -- serve --memory --listen 127.0.0.1:3000 """ # Regenerate the translated README..md files from README.md and the committed From 142378be81a7f69123f9f9164f8de8a3c704fa96 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 01:24:45 -0400 Subject: [PATCH 077/243] test(core): pin every alert class's severity, and the badge/timer split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `severity()` had six arms and one pinned pair, so five of them could be changed without a test noticing — and severity is how loudly a shipped client presents an alert. The table pins all six, and zipping it against `ALL` pins the delivery order the same table is written in. The second test pins the two halves of the bounded-snooze rule, which live at different layers and are easy to conflate: with the budget spent, the pre-arm layer arms no timer for `recovery_check_due` ever (the "stops re-firing" half), while `evaluate` keeps reporting the class carrying `snooze_budget = spent` (the "degrades to a badge" half, which needs the class reported or the client has nothing to render a badge from). The behaviour was already correct; nothing proved it. Also names `recovery` in the `Alert::params` doc, which listed four of the five keys the predicate emits. --- capsule-core/src/notify/class.rs | 27 ++++++++++++++++++++-- capsule-core/src/notify/evaluate.rs | 36 +++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/capsule-core/src/notify/class.rs b/capsule-core/src/notify/class.rs index caaa1bf4..ee15ae19 100644 --- a/capsule-core/src/notify/class.rs +++ b/capsule-core/src/notify/class.rs @@ -142,8 +142,9 @@ pub struct Alert { /// Parameters for the client's catalog string — plain strings, deterministically ordered. /// /// The keys in use are `count` (`sync_stale`, `quarantine_pending`, `drop_pending`), - /// `days_behind` (`sync_stale`), `grace` (`quota_grace_expiring`) and `snooze_budget` - /// (`recovery_check_due`). Each is documented at the predicate that sets it. + /// `days_behind` (`sync_stale`), `grace` (`quota_grace_expiring`), and `snooze_budget` + /// (`available` / `spent`) plus `recovery` (`check` / `rewrap`), both of which + /// `recovery_check_due` always carries. Each is documented at the predicate that sets it. pub params: BTreeMap, } @@ -235,6 +236,28 @@ mod tests { assert!(serde_json::from_str::("\"critical\"").is_err()); } + /// Every class's severity is pinned, so changing one arm of the mapping fails here rather + /// than silently changing how loudly a shipped client presents an alert. The three + /// `Warning`s are the states where something is already degraded or being refused; the + /// three `Advisory`s are the states where nothing is failing yet. + #[test] + fn severity_is_pinned_for_every_class() { + let expected = [ + (AlertClass::SyncStale, AlertSeverity::Warning), + (AlertClass::RecoveryCheckDue, AlertSeverity::Advisory), + (AlertClass::QuotaSoft, AlertSeverity::Advisory), + (AlertClass::QuotaGraceExpiring, AlertSeverity::Warning), + (AlertClass::QuarantinePending, AlertSeverity::Warning), + (AlertClass::DropPending, AlertSeverity::Advisory), + ]; + // Zipping against ALL also pins the delivery order the table is written in. + for (class, (expected_class, severity)) in AlertClass::ALL.into_iter().zip(expected) { + assert_eq!(class, expected_class, "class order changed"); + assert_eq!(class.severity(), severity, "{}", class.as_str()); + } + assert_eq!(AlertClass::ALL.len(), expected.len()); + } + /// SSoT: only the two device-computable deadlines are pre-armable. #[test] fn pre_armable_is_exactly_the_two_device_computable_classes() { diff --git a/capsule-core/src/notify/evaluate.rs b/capsule-core/src/notify/evaluate.rs index 28b47c6b..265f703b 100644 --- a/capsule-core/src/notify/evaluate.rs +++ b/capsule-core/src/notify/evaluate.rs @@ -482,6 +482,42 @@ mod tests { assert_eq!(alert.deadline, Some(ts(due))); } + /// The two halves of the bounded-snooze rule, which live at different layers. + /// + /// "Past that bound the alert stops re-firing and degrades to a persistent, non-blocking + /// badge": the *stops re-firing* half is enforced here at the pre-arm layer — with the + /// budget spent, no timer is armed for the class, ever. The *badge* half needs the opposite: + /// `evaluate` keeps reporting the class, because a client cannot render a badge for a + /// condition it was never told about. Suppressing the class instead would satisfy the first + /// sentence by making the second impossible. + #[test] + fn a_spent_snooze_budget_stops_the_timer_but_not_the_report() { + let due = BASE + 7 * DAY_SECS; + for (now, why) in [ + (due - DAY_SECS, "before the due date"), + (due, "at the due date"), + (due + 30 * DAY_SECS, "long after it"), + ] { + let spent = with_recovery(due, None, true); + assert!( + pre_arm_deadlines(&spent, ts(now)).is_empty(), + "no timer is armed {why}" + ); + assert_eq!(next_deadline(&spent, ts(now)), None, "{why}"); + } + + // ...and the class is still reported once due, carrying the fact that makes it a badge. + let spent = with_recovery(due, None, true); + let params = params_of(&spent, AlertClass::RecoveryCheckDue, ts(due)) + .expect("a spent budget degrades the presentation, it does not silence the class"); + assert_eq!(params["snooze_budget"], "spent"); + + // A snooze still running with the budget spent arms nothing either: the class is + // already a badge, and a badge never escalates back into an alert on its own. + let snoozed_and_spent = with_recovery(due, Some(due + DAY_SECS), true); + assert!(pre_arm_deadlines(&snoozed_and_spent, ts(due)).is_empty()); + } + /// A spent snooze budget is reported, not silenced — the client needs the fact to render a /// badge — and it is carried as a parameter rather than a class of its own. #[test] From d39ed34d7ca4b1c43a391bf7aef3a571f348cff3 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 01:24:55 -0400 Subject: [PATCH 078/243] fix(sdk): default the two collection fields, and gate the alert bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `FfiNotifyInput` declared uniffi defaults for nine of its eleven fields, so `suppressed_until` and `disabled` were the only two a foreign caller had to name to construct the "just installed, learned nothing" input — the one the record documents as the common case. Both now take the bare `#[uniffi(default)]` (the type's `Default`; `[]`/`{}` literals are soft deprecated upstream for maps and sequences), and the generated Swift init and Kotlin data class carry `= [:]` / `= mapOf()` and `= []` / `= listOf()`. `gen-bindings` asserts the surfaces each lane consumes are present by name, because a verb that fails to cross the namespace boundary still leaves a large, plausible file behind. It was not extended when this surface landed, so the whole alert API could have vanished from the bindings silently. It now requires `FfiAlert`, `FfiClassDeadline`, `FfiNotifyInput` and the three free functions in both languages. Documents which field owns recovery snoozing: the cadence scheduler tracks it against a bounded budget, so `RecoveryFacts.snoozed_until` is canonical and the generic per-class map is for the other five classes. An entry there for `recovery_check_due` still composes (later-of-the-two), recorded as a fallback so a client that writes both is not surprised. --- capsule-core/src/notify/input.rs | 12 ++++++++++++ capsule-sdk/src/ffi/notify.rs | 17 ++++++++++++++--- mise-tasks/gen-bindings | 11 ++++++++++- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/capsule-core/src/notify/input.rs b/capsule-core/src/notify/input.rs index ae83fb6a..6f954106 100644 --- a/capsule-core/src/notify/input.rs +++ b/capsule-core/src/notify/input.rs @@ -54,6 +54,13 @@ pub struct NotifyInput { /// snooze ends, and that end is a deadline the device can compute. Cancelling instead would /// leave the alert reachable only in-app, which for `sync_stale` defeats the entire pre-arm /// rule the class exists under. + /// + /// **This map is for the other five classes.** Recovery snoozing is owned by + /// [`RecoveryFacts::snoozed_until`], because the cadence scheduler already tracks it against + /// a bounded budget and this map has no budget to spend. An entry here for + /// [`AlertClass::RecoveryCheckDue`] is honoured as a fallback — the armed instant becomes + /// the later of the two, and either suppresses the report — but it is not the canonical + /// channel, and a client that writes both is expressing one snooze twice. pub suppressed: BTreeMap, /// Per-class **disable**: the user turned this alert off. Emits nothing and arms nothing, at /// any instant. @@ -109,6 +116,11 @@ pub struct RecoveryFacts { pub next_due: Timestamp, /// When an active snooze expires, if one is active. /// + /// **This is where recovery snoozing lives**, rather than + /// [`NotifyInput::suppressed`](super::NotifyInput::suppressed): the cadence scheduler owns + /// the bounded-snooze-then-badge budget, so the snooze and the budget that bounds it stay + /// in one place. The generic map is for the other five classes. + /// /// A snooze set *before* the due date does not make the check due earlier: the class is due /// at the later of this and [`next_due`](Self::next_due). pub snoozed_until: Option, diff --git a/capsule-sdk/src/ffi/notify.rs b/capsule-sdk/src/ffi/notify.rs index 5e4c5540..cf060d86 100644 --- a/capsule-sdk/src/ffi/notify.rs +++ b/capsule-sdk/src/ffi/notify.rs @@ -126,7 +126,9 @@ pub struct FfiAlert { /// The instant whose passing made this alert true (RFC 3339), for the two pre-armable /// classes; `None` for the three whose condition is server-held. pub deadline: Option, - /// Catalog parameters — `count`, `days_behind`, `grace`, `snooze_budget`. + /// Catalog parameters — `count`, `days_behind`, `grace`, and the pair + /// `recovery_check_due` always carries: `snooze_budget` (`available` / `spent`) and + /// `recovery` (`check` / `rewrap`). pub params: HashMap, } @@ -173,12 +175,14 @@ pub struct FfiNotifyInput { /// When the next recovery-verification prompt becomes due (RFC 3339). Project it from the /// scheduler with /// [`RecoveryCadence::notify_facts`](crate::recovery::RecoveryCadence::notify_facts) rather - /// than computing it here. `None` before recovery is set up, which ignores the other two + /// than computing it here. `None` before recovery is set up, which ignores the other three /// `recovery_*` fields. #[uniffi(default = None)] pub recovery_next_due: Option, /// When an active snooze on the recovery prompt expires (RFC 3339), if one is active. A - /// snooze ending *before* `recovery_next_due` does not make the check due earlier. + /// snooze ending *before* `recovery_next_due` does not make the check due earlier. This is + /// the canonical place to snooze the recovery check — not + /// [`suppressed_until`](Self::suppressed_until), which is for the other five classes. #[uniffi(default = None)] pub recovery_snoozed_until: Option, /// Whether the consecutive-snooze budget is spent — the class has degraded to a persistent, @@ -206,10 +210,17 @@ pub struct FfiNotifyInput { /// must fire again when the snooze expires. Use [`disabled`](Self::disabled) to turn a class /// off; do not encode that as a far-future instant here. An unrecognized class name is an /// [`FfiError::InvalidArgument`]. + /// + /// **This map is for the other five classes.** Recovery snoozing belongs in + /// [`recovery_snoozed_until`](Self::recovery_snoozed_until), which the cadence scheduler + /// owns together with the budget that bounds it. An entry here for `recovery_check_due` is + /// honoured as a fallback (the later of the two wins) but is not the canonical channel. + #[uniffi(default)] pub suppressed_until: HashMap, /// Per-class **disable**: the wire names of the classes the user turned off. They report /// nothing and hold no alarm, at any instant. Disabling suppresses the warning and never /// the behavior. An unrecognized class name is an [`FfiError::InvalidArgument`]. + #[uniffi(default)] pub disabled: Vec, } diff --git a/mise-tasks/gen-bindings b/mise-tasks/gen-bindings index c5c147f1..93c948cd 100755 --- a/mise-tasks/gen-bindings +++ b/mise-tasks/gen-bindings @@ -40,6 +40,11 @@ done # S-P1 — the capsule_sdk workspace verbs the whole iOS lane is blocked on: # enroll (incl. hardware-signer parity), album, seal+import, the # seal→upload bridge, verify, sync-apply, escrow, device directory. +# S-D29 — the local alert surface: the shared predicate plus the per-class +# pre-arm deadlines a client actually schedules its alarms from. The +# arm call is the one that must not go missing: without it a client +# can only ask "what is true now?", which is the question pre-arming +# exists to stop it having to ask. require() { # $1=file $2..=symbols local file="$1"; shift for sym in "$@"; do @@ -50,7 +55,11 @@ require() { # $1=file $2..=symbols for f in "$sdk_kt" "$sdk_swift"; do require "$f" FfiCapsuleClient FfiSession require "$f" FfiWorkspace FfiHardwareSigner FfiSyncEntry FfiAssetFacts FfiUploadBlob + require "$f" FfiAlert FfiClassDeadline FfiNotifyInput done +# Both languages lower these free functions to the same camelCase spelling. +require "$sdk_swift" evaluateAlerts preArmDeadlines nextAlertDeadline +require "$sdk_kt" evaluateAlerts preArmDeadlines nextAlertDeadline # Swift and Kotlin lower method names differently (camelCase vs camelCase on the # object); assert the verbs per-language rather than assuming one spelling. require "$sdk_swift" sealAsset uploadBlobs applySyncEntry escrowBlob \ @@ -65,4 +74,4 @@ require "$core_swift" FfiWorkspace HardwareSigner require "$core_kt" FfiWorkspace HardwareSigner echo "uniffi bindings written to $out (capsule_core + capsule_sdk, kotlin + swift)" -echo "surface check passed: S-D9 client/session + S-P1 workspace verbs present in both languages" +echo "surface check passed: S-D9 client/session + S-P1 workspace verbs + S-D29 alert surface present in both languages" From eb2a445511353a531fb17c20e287b300379b1ecf Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 01:25:03 -0400 Subject: [PATCH 079/243] docs(notifications): record the alert parameters with their trigger owners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parameters a client interpolates into its catalog string were specified only in Rust doc comments and asserted only in Rust tests. `notifications.md` owns the class list and delegates each threshold to its trigger owner, but named no parameter at all — so the contract a client codes against lived nowhere a client author would look, and the `notification.*` keys owed to the client half would have been written from the implementation rather than from the design. The class table gains a Parameters column. `backup-recovery.md` gains the one thing its own section has to settle: the re-wrap escalation and the routine check share `recovery_check_due`, because the class set is closed, so the scheduler reports re-wrap as due now and carries which it is in the `recovery` parameter for the client to route on. --- .../src/content/docs/design/backup-recovery.md | 8 ++++++++ .../src/content/docs/design/notifications.md | 17 ++++++++++------- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/capsule-docs/src/content/docs/design/backup-recovery.md b/capsule-docs/src/content/docs/design/backup-recovery.md index 658eeaa0..20b86219 100644 --- a/capsule-docs/src/content/docs/design/backup-recovery.md +++ b/capsule-docs/src/content/docs/design/backup-recovery.md @@ -107,6 +107,14 @@ Verification is **local-only**: the client keeps a cached copy of the escrow blo - **Re-arm triggers** (reset to the 7-day step): a new device enrolls (the prompt lands on the *new* device — it has never seen the passphrase); the recovery secret rotates; a restore-from-escrow completes. - Snooze steps are 24 h or 7 d, at most 3 consecutive. This is the `recovery_check_due` [alert class](/design/notifications/#alert-classes): the bounded-snooze-then-badge mechanic, and the rule that no alert blocks a critical flow, are owned by [Notifications](/design/notifications/); the cadence and re-arm triggers above are owned here. Because each step is a deadline the device can compute, the prompt is **pre-armed** ([pre-arm rule](/design/notifications/#the-pre-arm-rule)) rather than evaluated at launch. +**How the escalation reaches the alert.** The class set is closed, so `recovery_check_due` is the +only class that can report a due re-wrap as well as a due check. The scheduler therefore projects +into the shared predicate (`RecoveryCadence::notify_facts`) with the re-wrap state reported as +due **now** — whatever the ladder's next step says, since an explicit "I lost it" is not a +scheduled check — and carries which it is in the alert's `recovery` parameter (`check` or +`rewrap`). A client routes on that parameter: `rewrap` goes to the guided flow below, `check` to +an ordinary verification prompt. + ### On Repeated Failure: Guided Re-Wrap After 3 failures across ≥ 2 app sessions — or an explicit "I lost it" — the client runs the guided rotation flow: mint a fresh ≥128-bit recovery secret, **re-wrap the same master key**, replace the server escrow (a single active escrow; the old blob is deleted, so the lost secret unwraps nothing), re-run the setup-style type-back gate, re-issue Shamir shares if enrolled (old shares explicitly invalidated and surfaced as such), and surface the old-backup-artifact guidance from the [single-root invariant](#single-root-invariant). diff --git a/capsule-docs/src/content/docs/design/notifications.md b/capsule-docs/src/content/docs/design/notifications.md index 321e3816..4e8df614 100644 --- a/capsule-docs/src/content/docs/design/notifications.md +++ b/capsule-docs/src/content/docs/design/notifications.md @@ -64,13 +64,16 @@ write a sentence from. A closed enum. Each class's *trigger predicate and thresholds* stay owned by the doc that defines the condition; this doc owns the class list, the delivery, and the shared snooze/badge mechanics. -| Class | Trigger owner | Pre-armable | -| --- | --- | --- | -| `sync_stale` | [Download & Sync — Notifications](/design/import/download-sync/#notifications) | **Yes** | -| `recovery_check_due` | [Backup — Schedule and Triggers](/design/backup-recovery/#schedule-and-triggers) | **Yes** | -| `quota_soft` / `quota_grace_expiring` | [Quota — Thresholds and States](/design/quota/#thresholds-and-states) | No | -| `quarantine_pending` | [Threat Model — Quarantine Surfaces](/design/threat-model/scenarios/#quarantine-surfaces) | No | -| `drop_pending` | [Web Upload — Drop and Adoption Lifecycle](/design/web-upload/#drop-and-adoption-lifecycle) | No | +Each class carries **parameters** — plain strings a client interpolates into its own catalog +string, never text. They are listed with the class below and settled by the trigger owner. + +| Class | Trigger owner | Parameters | Pre-armable | +| --- | --- | --- | --- | +| `sync_stale` | [Download & Sync — Notifications](/design/import/download-sync/#notifications) | `count`, `days_behind` | **Yes** | +| `recovery_check_due` | [Backup — Schedule and Triggers](/design/backup-recovery/#schedule-and-triggers) | `snooze_budget` (`available` / `spent`), `recovery` (`check` / `rewrap`) | **Yes** | +| `quota_soft` / `quota_grace_expiring` | [Quota — Thresholds and States](/design/quota/#thresholds-and-states) | `grace` (`counting` / `expired`), on the second only | No | +| `quarantine_pending` | [Threat Model — Quarantine Surfaces](/design/threat-model/scenarios/#quarantine-surfaces) | `count` | No | +| `drop_pending` | [Web Upload — Drop and Adoption Lifecycle](/design/web-upload/#drop-and-adoption-lifecycle) | `count` | No | Unknown classes are rejected as structural errors, like every other closed enum ([Schema Rules](/design/threat-model/schema-rules/)). From de006a92337b86dd6f977bbed20667ae722d06c4 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 01:32:52 -0400 Subject: [PATCH 080/243] feat(server): add the Postgres plumbing and the migration crate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The durable ports have had one production adapter shape named in their docs since `S-C29` and `S-C37` — PostgreSQL — and no code. This lands the half every adapter needs before any of them can be written, and no adapter yet. `capsule-server/migration` is a new workspace member holding the four ordinals issue #402's adapters need: the asset index, the account cluster, the device cohorts and the quota ledger. Its lib is `server_migration` rather than `migration` because `capsule-cli/migration` already publishes that name, and two libraries called `migration` in one workspace is legal and a trap. `capsule-server` takes it as a **dev-dependency only**. That is a decision, not layering: `sea-orm-migration` pulls sea-orm with its default `with-chrono` feature, and design/dependencies.md makes chrono a review-blocking gate outside `capsule-cli/entity`. So `serve` cannot migrate, and instead `postgres::assert_schema_current` reads `seaql_migrations` and refuses to boot on a missing ordinal, naming the command that fixes it — which is also the safer rollout, since a server that migrates on start migrates once per replica during a rolling deploy. `EXPECTED_MIGRATIONS` is compiled in because the server cannot link the migrator; a `cfg(test)` assertion compares the two, so the copy cannot drift silently. sea-orm enters `capsule-server` spelled out rather than inherited, because cargo lets a member add features to a workspace dependency but not turn its defaults off, and `capsule-cli` needs those defaults. Every instant in the schema is a `BIGINT` of epoch microseconds converted in `postgres::time`: without a datetime feature there is no Rust binding for `TIMESTAMPTZ` at all, and both of sea-orm's are refused — `with-chrono` breaks the gate, `with-time` would be a third datetime crate with no row in the dependencies table. Every comparison these tables make is an ordering on one column, and integers order identically. `postgres::error` maps `DbErr` onto the three `StoreError` variants once for every adapter, and deliberately reserves `Unavailable` for failures that happen *before* a statement is sent. A connection dropped mid-statement has not "certainly not happened", which is what that variant promises; it is `Rejected`, whose contract is that whether state changed is unknown. `testcontainers` and `testcontainers-modules` were pinned at the workspace and consumed by nothing, sanctioned by xtask's `PLANNED_WORKSPACE_DEPENDENCIES` because "no test starts a container yet". They are now dev-dependencies of `capsule-server`, so the two entries leave that list. The `containers` nextest group, empty since `S-C59`, gains its filterset: every container-backed case lives under a module named `postgres_conformance`, so a new port's suite joins the group by being named like the others rather than by editing a list. The architecture check gains `check_chrono_isolation`, which turns the gate into a test. It is per package on purpose: cargo unifies features across a workspace build, so the workspace-wide `cargo tree -i chrono` lists `capsule-server` under sea-orm and always will while `capsule-cli` inherits the defaults. What is decidable — and what the rule is actually about — is whether the server's *own* manifest asks for chrono, and `cargo tree -p capsule-server -i chrono -e no-dev` prints nothing. Refs #402 --- .config/nextest.toml | 34 +- Cargo.lock | 733 +++++++++++++++++- Cargo.toml | 2 + capsule-server/Cargo.toml | 39 + capsule-server/migration/Cargo.toml | 35 + capsule-server/migration/src/lib.rs | 40 + .../src/m20260902_000001_asset_index.rs | 292 +++++++ .../src/m20260902_000002_accounts.rs | 87 +++ .../migration/src/m20260902_000003_cohorts.rs | 75 ++ .../migration/src/m20260902_000004_quota.rs | 101 +++ capsule-server/migration/src/main.rs | 13 + capsule-server/src/lib.rs | 1 + capsule-server/src/postgres/error.rs | 159 ++++ capsule-server/src/postgres/mod.rs | 250 ++++++ capsule-server/src/postgres/testing.rs | 113 +++ capsule-server/src/postgres/time.rs | 91 +++ xtask/src/architecture.rs | 69 +- 17 files changed, 2076 insertions(+), 58 deletions(-) create mode 100644 capsule-server/migration/Cargo.toml create mode 100644 capsule-server/migration/src/lib.rs create mode 100644 capsule-server/migration/src/m20260902_000001_asset_index.rs create mode 100644 capsule-server/migration/src/m20260902_000002_accounts.rs create mode 100644 capsule-server/migration/src/m20260902_000003_cohorts.rs create mode 100644 capsule-server/migration/src/m20260902_000004_quota.rs create mode 100644 capsule-server/migration/src/main.rs create mode 100644 capsule-server/src/postgres/error.rs create mode 100644 capsule-server/src/postgres/mod.rs create mode 100644 capsule-server/src/postgres/testing.rs create mode 100644 capsule-server/src/postgres/time.rs diff --git a/.config/nextest.toml b/.config/nextest.toml index febdae11..84204674 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -21,17 +21,35 @@ slow-timeout = { period = "60s", terminate-after = 3 } [profile.ci.junit] path = "junit.xml" -# The container group is **empty as of `S-C59`**, and the declaration stays. +# The container group, occupied since #402. # # It existed for the retired Salvo auth crate, whose integration tests spun up shared Postgres # and Valkey containers and shared a tracing `Once` — running them concurrently was the main -# flakiness source, so the group was pinned to one thread with retries as the backstop. The -# rebuilt server has no container test at all: every port has a deterministic in-memory adapter -# and Kynos's `TestClient` drives a built service in-process, which is why `mise run test-rust` -# now needs no podman for anything above the storage ports. +# flakiness source, so the group was pinned to one thread with retries as the backstop. It was +# then empty for the length of the Kynos rebuild, kept rather than deleted because the first +# real adapter would need exactly it and rediscovering the one-thread rule by watching CI flake +# is the expensive way to learn it. # -# The group is kept rather than deleted because the first Postgres or Valkey *adapter* will need -# exactly it, and rediscovering the one-thread rule by watching CI flake is the expensive way to -# learn it. An empty test group costs nothing. +# The Postgres adapters are that first adapter. Every container-backed case lives under a test +# module named `postgres_conformance`, so the filterset below is mechanical rather than a list +# somebody has to remember to extend — a new port's suite joins the group by being named like +# the others. Each of those cases starts its **own** container (nextest runs a process per test, +# so a shared one would buy nothing), which is why one thread matters: five Postgres instances +# racing to bind ports and warm up is the flakiness the group exists to prevent. +# +# **These tests skip themselves without `CAPSULE_TEST_POSTGRES=1`**, printing one line each that +# says so — see `capsule_server::postgres::testing`. The default `cargo nextest run` is green on +# a machine with no container runtime, which is the acceptance gap design/module-map.md sets. [test-groups.containers] max-threads = 1 + +[[profile.default.overrides]] +filter = 'test(postgres_conformance)' +test-group = 'containers' + +[[profile.ci.overrides]] +filter = 'test(postgres_conformance)' +test-group = 'containers' +# A container that loses a port race or is still warming up is the transient failure the `ci` +# profile's retries exist for; nextest reports flaky separately from failed, so a retry that +# succeeds is still visible. diff --git a/Cargo.lock b/Cargo.lock index 575bda5e..3ef966c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -244,6 +244,22 @@ dependencies = [ "winnow 0.7.15", ] +[[package]] +name = "astral-tokio-tar" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec179a06c1769b1e42e1e2cbe74c7dcdb3d6383c838454d063eaac5bbb7ebbe5" +dependencies = [ + "filetime", + "futures-core", + "libc", + "portable-atomic", + "rustc-hash", + "tokio", + "tokio-stream", + "xattr", +] + [[package]] name = "async-compat" version = "0.2.5" @@ -334,6 +350,49 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit 0.8.4", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + [[package]] name = "backtrace" version = "0.3.76" @@ -367,6 +426,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -485,6 +550,83 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "bollard" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a52479c9237eb04047ddb94788c41ca0d26eaff8b697ecfbb4c32f7fdc3b1b" +dependencies = [ + "async-stream", + "base64 0.22.1", + "bitflags 2.13.0", + "bollard-buildkit-proto", + "bollard-stubs", + "bytes", + "chrono", + "futures-core", + "futures-util", + "hex", + "home", + "http", + "http-body-util", + "hyper", + "hyper-named-pipe", + "hyper-rustls", + "hyper-util", + "hyperlocal", + "log", + "num", + "pin-project-lite", + "rand 0.9.4", + "rustls", + "rustls-native-certs", + "rustls-pemfile", + "rustls-pki-types", + "serde", + "serde_derive", + "serde_json", + "serde_repr", + "serde_urlencoded", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tokio-util", + "tonic", + "tower-service", + "url", + "winapi", +] + +[[package]] +name = "bollard-buildkit-proto" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a885520bf6249ab931a764ffdb87b0ceef48e6e7d807cfdb21b751e086e1ad" +dependencies = [ + "prost 0.14.4", + "prost-types 0.14.4", + "tonic", + "tonic-prost", + "ureq", +] + +[[package]] +name = "bollard-stubs" +version = "1.49.1-rc.28.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5731fe885755e92beff1950774068e0cae67ea6ec7587381536fca84f1779623" +dependencies = [ + "base64 0.22.1", + "bollard-buildkit-proto", + "bytes", + "chrono", + "prost 0.14.4", + "serde", + "serde_json", + "serde_repr", + "serde_with", +] + [[package]] name = "borrow-or-share" version = "0.2.4" @@ -515,6 +657,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bstr" version = "1.12.1" @@ -590,7 +741,7 @@ checksum = "6b5271031022835ee8c7582fe67403bd6cb3d962095787af7921027234bab5bf" name = "capsule-cli" version = "0.1.0" dependencies = [ - "base64", + "base64 0.22.1", "capitalize", "capsule-cli-entity", "capsule-cli-migration", @@ -605,7 +756,7 @@ dependencies = [ "eyre", "futures", "humansize", - "indexmap", + "indexmap 2.14.0", "jiff", "nanoid", "sea-orm", @@ -657,7 +808,7 @@ dependencies = [ "hex", "hkdf", "hmac", - "indexmap", + "indexmap 2.14.0", "jiff", "kamadak-exif", "ml-dsa", @@ -720,7 +871,7 @@ dependencies = [ name = "capsule-sdk" version = "0.1.0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "capsule-core", "capsule-i18n", @@ -749,11 +900,12 @@ name = "capsule-server" version = "0.1.0" dependencies = [ "argon2", - "base64", + "base64 0.22.1", "bytes", "capsule-core", "capsule-i18n", "capsule-sdk", + "capsule-server-migration", "capsule-wire", "clap", "color-eyre", @@ -762,11 +914,14 @@ dependencies = [ "jsonwebtoken", "kynos", "ring", + "sea-orm", "secrecy", "serde", "serde_json", "subtle", "tempfile", + "testcontainers", + "testcontainers-modules", "thiserror 2.0.20", "tokio", "totp-rs", @@ -775,11 +930,19 @@ dependencies = [ "uuid", ] +[[package]] +name = "capsule-server-migration" +version = "0.1.0" +dependencies = [ + "sea-orm-migration", + "tokio", +] + [[package]] name = "capsule-wasm" version = "0.1.0" dependencies = [ - "base64", + "base64 0.22.1", "capsule-core", "hex", "uuid", @@ -1060,6 +1223,16 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -1235,8 +1408,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -1252,13 +1435,37 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", "quote", "syn 2.0.117", ] @@ -1420,6 +1627,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "docker_credential" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d" +dependencies = [ + "base64 0.22.1", + "serde", + "serde_json", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -1432,6 +1650,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "ecdsa" version = "0.16.9" @@ -1572,6 +1796,16 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + [[package]] name = "event-listener" version = "5.4.1" @@ -1622,6 +1856,17 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "ferroid" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb330bbd4cb7a5b9f559427f06f98a4f853a137c8298f3bd3f8ca57663e21986" +dependencies = [ + "portable-atomic", + "rand 0.9.4", + "web-time", +] + [[package]] name = "ff" version = "0.13.1" @@ -1974,7 +2219,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -2320,6 +2565,20 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-named-pipe" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fab3637d6b04a8037af8a266fdf6cf92ea957e8c53981a2bf6136572531025bf" +dependencies = [ + "hex", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-rustls" version = "0.27.9" @@ -2336,13 +2595,26 @@ dependencies = [ "webpki-roots 1.0.7", ] +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -2359,6 +2631,21 @@ dependencies = [ "tracing", ] +[[package]] +name = "hyperlocal" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" +dependencies = [ + "hex", + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -2504,6 +2791,17 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -2684,7 +2982,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" dependencies = [ "aws-lc-rs", - "base64", + "base64 0.22.1", "getrandom 0.2.17", "js-sys", "pem", @@ -2759,7 +3057,7 @@ dependencies = [ "jsonschema", "kynos-macros", "kynos-openapi", - "matchit", + "matchit 0.9.2", "percent-encoding", "serde", "serde_json", @@ -2787,7 +3085,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ce3286ca0f45019fd229a2a467a2d0ddeb074843614f09fee9b7370a0cb3eb9" dependencies = [ "http", - "indexmap", + "indexmap 2.14.0", "serde", "serde_json", "thiserror 2.0.20", @@ -3159,6 +3457,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "matchit" version = "0.9.2" @@ -3683,6 +3987,12 @@ dependencies = [ "tls_codec", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -3794,6 +4104,31 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "parse-display" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a1c2265c98e2446911282c6ac86d8524f495792c38c5bd884f80499c7538a" +dependencies = [ + "parse-display-derive", + "regex", + "regex-syntax", +] + +[[package]] +name = "parse-display-derive" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ae7800a4c974efd12df917266338e79a7a74415173caf7e70aa0a0707345281" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "regex-syntax", + "structmeta", + "syn 2.0.117", +] + [[package]] name = "password-hash" version = "0.5.0" @@ -3817,7 +4152,7 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] @@ -3843,7 +4178,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" dependencies = [ "fixedbitset", - "indexmap", + "indexmap 2.14.0", ] [[package]] @@ -3883,13 +4218,33 @@ version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "859d4117bd1b1dc5646359ee7243c50c5000c0920ea2d1fb120335a2f4c684b8" dependencies = [ - "base64", + "base64 0.22.1", "oid", "picky-asn1", "picky-asn1-der", "serde", ] +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -4080,7 +4435,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" dependencies = [ "bytes", - "prost-derive", + "prost-derive 0.13.5", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive 0.14.4", ] [[package]] @@ -4096,8 +4461,8 @@ dependencies = [ "once_cell", "petgraph", "prettyplease", - "prost", - "prost-types", + "prost 0.13.5", + "prost-types 0.13.5", "regex", "syn 2.0.117", "tempfile", @@ -4116,13 +4481,35 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "prost-types" version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" dependencies = [ - "prost", + "prost 0.13.5", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost 0.14.4", ] [[package]] @@ -4460,7 +4847,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "futures-util", @@ -4681,6 +5068,7 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -4689,6 +5077,27 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -4731,6 +5140,39 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -4889,7 +5331,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bae0cbad6ab996955664982739354128c58d16e126114fe88c2a493642502aab" dependencies = [ - "darling", + "darling 0.20.11", "heck 0.4.1", "proc-macro2", "quote", @@ -4952,6 +5394,29 @@ dependencies = [ "zeroize", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -5015,6 +5480,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -5045,6 +5521,39 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "sha1" version = "0.10.6" @@ -5222,7 +5731,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5baebe002620b367fea87f77f911a9de77031f7d9072d8da238624df2a7b413" dependencies = [ "camino", - "indexmap", + "indexmap 2.14.0", "jsonschema", "prettyplease", "proc-macro2", @@ -5298,7 +5807,7 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ - "base64", + "base64 0.22.1", "bigdecimal", "bytes", "chrono", @@ -5312,7 +5821,7 @@ dependencies = [ "futures-util", "hashbrown 0.15.5", "hashlink 0.10.0", - "indexmap", + "indexmap 2.14.0", "log", "memchr", "once_cell", @@ -5378,7 +5887,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bigdecimal", "bitflags 2.13.0", "byteorder", @@ -5425,14 +5934,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bigdecimal", "bitflags 2.13.0", "byteorder", "chrono", "crc", "dotenvy", - "etcetera", + "etcetera 0.8.0", "futures-channel", "futures-core", "futures-util", @@ -5517,6 +6026,29 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "structmeta" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e1575d8d40908d70f6fd05537266b90ae71b15dbbe7a8b7dffa2b759306d329" +dependencies = [ + "proc-macro2", + "quote", + "structmeta-derive", + "syn 2.0.117", +] + +[[package]] +name = "structmeta-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "strum" version = "0.26.3" @@ -5653,6 +6185,45 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "testcontainers" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a81ec0158db5fbb9831e09d1813fe5ea9023a2b5e6e8e0a5fe67e2a820733629" +dependencies = [ + "astral-tokio-tar", + "async-trait", + "bollard", + "bytes", + "docker_credential", + "either", + "etcetera 0.11.0", + "ferroid", + "futures", + "itertools", + "log", + "memchr", + "parse-display", + "pin-project-lite", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tokio-util", + "url", +] + +[[package]] +name = "testcontainers-modules" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e75e78ff453128a2c7da9a5d5a3325ea34ea214d4bf51eab3417de23a4e5147" +dependencies = [ + "testcontainers", +] + [[package]] name = "textwrap" version = "0.16.2" @@ -5869,7 +6440,7 @@ version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde_core", "serde_spanned 1.1.1", "toml_datetime 0.7.5+spec-1.1.0", @@ -5911,7 +6482,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", @@ -5925,7 +6496,7 @@ version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ - "indexmap", + "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "winnow 1.0.3", @@ -5952,6 +6523,46 @@ version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost 0.14.4", + "tonic", +] + [[package]] name = "totp-rs" version = "5.7.1" @@ -5976,11 +6587,15 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", + "indexmap 2.14.0", "pin-project-lite", + "slab", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -6159,7 +6774,7 @@ dependencies = [ "bytes", "clap", "geometry-rs", - "prost", + "prost 0.13.5", "prost-build", "tzf-rel", ] @@ -6245,7 +6860,7 @@ dependencies = [ "glob", "goblin", "heck 0.5.0", - "indexmap", + "indexmap 2.14.0", "once_cell", "serde", "tempfile", @@ -6277,7 +6892,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4b42137524f4be6400fcaca9d02c1d4ecb6ad917e4013c0b93235526d8396e5" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -6320,7 +6935,7 @@ checksum = "761ef74f6175e15603d0424cc5f98854c5baccfe7bf4ccb08e5816f9ab8af689" dependencies = [ "anyhow", "heck 0.5.0", - "indexmap", + "indexmap 2.14.0", "tempfile", "uniffi_internal_macros", ] @@ -6359,6 +6974,33 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" +dependencies = [ + "base64 0.23.1", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -6369,6 +7011,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -6377,6 +7020,12 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -6583,7 +7232,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.14.0", "wasm-encoder", "wasmparser", ] @@ -6609,7 +7258,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.13.0", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", "semver", ] @@ -7132,7 +7781,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck 0.5.0", - "indexmap", + "indexmap 2.14.0", "prettyplease", "syn 2.0.117", "wasm-metadata", @@ -7163,7 +7812,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.13.0", - "indexmap", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -7182,7 +7831,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.14.0", "log", "semver", "serde", @@ -7233,7 +7882,7 @@ dependencies = [ name = "xtask" version = "0.1.0" dependencies = [ - "base64", + "base64 0.22.1", "capsule-core", "eyre", "hex", diff --git a/Cargo.toml b/Cargo.toml index ffdbdaf4..584fb6fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "capsule-sdk", "capsule-wasm", "capsule-server", + "capsule-server/migration", "capsule-wire", "xtask", ] @@ -23,6 +24,7 @@ default-members = [ "capsule-i18n", "capsule-sdk", "capsule-server", + "capsule-server/migration", "capsule-wire", ] resolver = "3" diff --git a/capsule-server/Cargo.toml b/capsule-server/Cargo.toml index c60c08da..2b230fb3 100644 --- a/capsule-server/Cargo.toml +++ b/capsule-server/Cargo.toml @@ -131,6 +131,29 @@ argon2 = { workspace = true } # their own copy of the config loader and the adapter seam. clap = { version = "4.6.1", features = ["derive"] } color-eyre = "0.6.5" +# The Postgres adapters for the durable ports (#402). `default-features = false` is +# load-bearing rather than tidy: sea-orm's defaults include `with-chrono`, and +# design/dependencies.md makes `cargo tree -i chrono -e no-dev` a review-blocking gate that must +# keep resolving to `capsule-cli/entity` and sea-orm's own internals. Without a datetime feature +# there is no Rust binding for `TIMESTAMPTZ` at all, which is why every instant in the server's +# schema is a `BIGINT` of epoch microseconds converted in `postgres::time`. +# +# Three features and no more: `macros` for the `FromQueryResult` derive the adapters' private row +# structs use, `sqlx-postgres` for the driver, `runtime-tokio-rustls` because the workspace's TLS +# rule is rustls only. Already a workspace dependency with a row in design/dependencies.md (ORM), +# consumed until now only by `capsule-cli/entity`. +# +# Spelled out rather than `workspace = true`, and only because cargo forbids the one thing this +# needs: a member may add features to an inherited dependency but may not turn the workspace's +# defaults **off**. `capsule-cli` and `capsule-cli/entity` inherit sea-orm *with* its defaults — +# that is exactly why the chrono gate names `capsule-cli/entity` — so flipping the workspace +# entry would change their build. The version is the workspace pin, restated; keep the two in +# step when sea-orm is repinned. +sea-orm = { version = "1.1.20", default-features = false, features = [ + "macros", + "sqlx-postgres", + "runtime-tokio-rustls", +] } # The log stream the binary installs (`cli::install_tracing`). `env-filter` for `RUST_LOG` and # `json` for the one-object-per-event rendering a log shipper wants; both are on in the workspace # pin, and the crate already has a row in design/dependencies.md. Written to **stderr**, so @@ -138,6 +161,22 @@ color-eyre = "0.6.5" tracing-subscriber = { workspace = true } [dev-dependencies] +# The server's schema, applied by the Postgres conformance harness. A **dev-dependency only**, +# and that is the decision rather than an oversight: `sea-orm-migration` pulls sea-orm with its +# default `with-chrono` feature, so a normal dependency here would break the +# `cargo tree -i chrono -e no-dev` gate at design/dependencies.md. The cost is that `serve` +# cannot migrate — it reads `seaql_migrations` and refuses to boot on a mismatch +# (`postgres::assert_schema_current`), which is also the safer rollout: a server that migrates on +# start migrates once per replica during a rolling deploy. +capsule-server-migration = { path = "migration" } +# The Postgres container the conformance suites run against, behind `CAPSULE_TEST_POSTGRES=1`. +# Both crates were pinned at the workspace and consumed by nothing — `xtask`'s +# `PLANNED_WORKSPACE_DEPENDENCIES` sanctioned them as planned because "no test starts a +# container yet". These are the tests that start one, so the two entries leave that list with +# this change. Dev-only: the served binary links neither. +testcontainers = { workspace = true } +testcontainers-modules = { workspace = true } + # `tests/binary.rs` gives the spawned server a blob root of its own, and deletes it afterwards. # Already the workspace's scratch-directory crate (`capsule-core`, `capsule-sdk`, # `capsule-core-ffi` all dev-depend on it) and already in the lock file. diff --git a/capsule-server/migration/Cargo.toml b/capsule-server/migration/Cargo.toml new file mode 100644 index 00000000..8ef53340 --- /dev/null +++ b/capsule-server/migration/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "capsule-server-migration" +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true + +# The server's schema, as an operator binary and as a library the conformance harness applies. +# +# `capsule-server` takes this crate as a **dev-dependency only**, which is a deliberate +# constraint rather than an accident of layering: `sea-orm-migration` pulls `sea-orm` with its +# default features, `with-chrono` among them, and design/dependencies.md makes +# `cargo tree -i chrono -e no-dev` a review-blocking gate. Keeping the migrator out of the +# server's normal dependency graph is what keeps that gate resolving to `capsule-cli/entity` +# and sea-orm's own internals. The consequence is recorded where it is felt: +# `capsule_server::postgres::assert_schema_current` refuses to boot on a pending migration and +# names the command below, rather than running `Migrator::up` on `serve`. + +[lib] +# Not `migration`: `capsule-cli/migration` already publishes a lib of that name, and two libs +# called `migration` in one workspace is legal and a trap for anybody writing `use migration::`. +name = "server_migration" +path = "src/lib.rs" + +[[bin]] +name = "capsule-server-migration" +path = "src/main.rs" + +[dependencies] +# The runtime `cli::run_cli` drives. Already the workspace's async runtime. +tokio = { workspace = true } +# The migrator itself, pinned at the workspace with `sqlx-postgres` and `runtime-tokio-rustls` +# — the server's schema is PostgreSQL's, and the TLS rule is rustls only. See the ORM row in +# design/dependencies.md; this crate opens no new dependency domain. +sea-orm-migration = { workspace = true } diff --git a/capsule-server/migration/src/lib.rs b/capsule-server/migration/src/lib.rs new file mode 100644 index 00000000..24e0c4be --- /dev/null +++ b/capsule-server/migration/src/lib.rs @@ -0,0 +1,40 @@ +//! The server's PostgreSQL schema, one migration per ordinal. +//! +//! # What the four ordinals cover +//! +//! The durable ports issue #402 lands adapters for, and no others: the asset index, the account +//! cluster, the device-cohort map and the quota ledger. The remaining durable ports keep their +//! in-memory adapters and gain ordinals with their adapters, so a migration never describes a +//! table nothing reads. +//! +//! # Every instant is a `BIGINT` of microseconds since the Unix epoch +//! +//! Not `TIMESTAMPTZ`. Binding one needs sea-orm's `with-chrono` or `with-time`, and both are +//! refused: `chrono` is banned outside `capsule-cli/entity` by a gate +//! (design/dependencies.md), and `time` would be a third datetime crate with no row in that +//! table. `capsule_server::postgres::time` converts at the adapter boundary, exactly as the +//! CLI converts at its entity boundary. What that costs is SQL date arithmetic; every expiry +//! and retention comparison in these tables is an ordering on one column, and integers order +//! identically. + +pub use sea_orm_migration::prelude::*; + +mod m20260902_000001_asset_index; +mod m20260902_000002_accounts; +mod m20260902_000003_cohorts; +mod m20260902_000004_quota; + +/// The server's migrator. +pub struct Migrator; + +#[async_trait::async_trait] +impl MigratorTrait for Migrator { + fn migrations() -> Vec> { + vec![ + Box::new(m20260902_000001_asset_index::Migration), + Box::new(m20260902_000002_accounts::Migration), + Box::new(m20260902_000003_cohorts::Migration), + Box::new(m20260902_000004_quota::Migration), + ] + } +} diff --git a/capsule-server/migration/src/m20260902_000001_asset_index.rs b/capsule-server/migration/src/m20260902_000001_asset_index.rs new file mode 100644 index 00000000..4bdb1233 --- /dev/null +++ b/capsule-server/migration/src/m20260902_000001_asset_index.rs @@ -0,0 +1,292 @@ +//! The asset index (`S-C37`): the rows, the blobs they hold, the manifests they have moved +//! past, the per-owner sequence, and the applied-manifest ledger. + +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub(crate) struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // The one sequence, per **owner**. Never a Postgres `SEQUENCE` or a `bigserial`: + // `nextval` is non-transactional, so two concurrent finalizations get 5 and 6 and a + // reader who sees 6 commit first can page past 5 forever. A counter row updated inside + // the allocating transaction makes allocation order equal commit order, which is the + // whole of `S-C21`'s fix. + manager + .create_table( + Table::create() + .table(OwnerSequences::Table) + .if_not_exists() + .col( + ColumnDef::new(OwnerSequences::OwnerId) + .text() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(OwnerSequences::NextSeq) + .big_integer() + .not_null(), + ) + .to_owned(), + ) + .await?; + + manager + .create_table( + Table::create() + .table(Assets::Table) + .if_not_exists() + .col( + ColumnDef::new(Assets::AssetId) + .text() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(Assets::OwnerId).text().not_null()) + .col(ColumnDef::new(Assets::AlbumId).text().not_null()) + .col(ColumnDef::new(Assets::ProtocolVersion).text().not_null()) + .col(ColumnDef::new(Assets::CryptoSuiteId).integer().not_null()) + .col(ColumnDef::new(Assets::State).text().not_null()) + // Null is the ordinary case: a hold is placed by an admin action and never + // by a write path. + .col(ColumnDef::new(Assets::Hold).text().null()) + // Null while the row is pending — a row nothing can see occupies no place + // in the feed, which is what keeps an abandoned half-bundle from consuming + // a number. + .col(ColumnDef::new(Assets::SyncSeq).big_integer().null()) + .col(ColumnDef::new(Assets::FirstSeq).big_integer().null()) + // Invariant 17's stored chain head: SHA-256 over the signed manifest, and + // deliberately not the provenance blob's content address — the two are + // equal today and are not the same identifier (`S-C31`). + .col(ColumnDef::new(Assets::ChainHead).binary().null()) + .col( + ColumnDef::new(Assets::AmkVersion) + .big_integer() + .not_null() + .default(0), + ) + .col(ColumnDef::new(Assets::RetentionUntil).big_integer().null()) + .col(ColumnDef::new(Assets::CreatedAt).big_integer().not_null()) + .col(ColumnDef::new(Assets::UpdatedAt).big_integer().not_null()) + .to_owned(), + ) + .await?; + + // The feed's own index: every page is "this owner, above this number, in order". + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_assets_owner_sync_seq") + .table(Assets::Table) + .col(Assets::OwnerId) + .col(Assets::SyncSeq) + .to_owned(), + ) + .await?; + // The duplicate lookup's, which is owner- **and** album-scoped for two different + // reasons (see `AssetIndex::find_by_address`). + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_assets_owner_album") + .table(Assets::Table) + .col(Assets::OwnerId) + .col(Assets::AlbumId) + .to_owned(), + ) + .await?; + // The purge worker's input: tombstoned rows, oldest change first. + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_assets_state_updated_at") + .table(Assets::Table) + .col(Assets::State) + .col(Assets::UpdatedAt) + .to_owned(), + ) + .await?; + + // `(asset_id, role, address)` is the primary key, and that is what makes a retried + // finalization free: the insert is a no-op rather than a second row. + manager + .create_table( + Table::create() + .table(AssetBlobs::Table) + .if_not_exists() + .col(ColumnDef::new(AssetBlobs::AssetId).text().not_null()) + .col(ColumnDef::new(AssetBlobs::Role).text().not_null()) + .col(ColumnDef::new(AssetBlobs::Address).text().not_null()) + .col(ColumnDef::new(AssetBlobs::Size).big_integer().not_null()) + .primary_key( + Index::create() + .col(AssetBlobs::AssetId) + .col(AssetBlobs::Role) + .col(AssetBlobs::Address), + ) + .foreign_key( + ForeignKey::create() + .name("fk_asset_blobs_asset") + .from(AssetBlobs::Table, AssetBlobs::AssetId) + .to(Assets::Table, Assets::AssetId) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + // `find_reference` and `reference_count` both start from an address. + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_asset_blobs_address") + .table(AssetBlobs::Table) + .col(AssetBlobs::Address) + .to_owned(), + ) + .await?; + + // The manifests a chain has moved past (`S-C52`). These count as references, so the + // collector does not reclaim the server's own rebuttal evidence. `Position` keeps the + // order the port contracts — oldest first — rather than leaving it to the backend. + manager + .create_table( + Table::create() + .table(AssetSuperseded::Table) + .if_not_exists() + .col(ColumnDef::new(AssetSuperseded::AssetId).text().not_null()) + .col( + ColumnDef::new(AssetSuperseded::Position) + .big_integer() + .not_null(), + ) + .col(ColumnDef::new(AssetSuperseded::Address).text().not_null()) + .primary_key( + Index::create() + .col(AssetSuperseded::AssetId) + .col(AssetSuperseded::Position), + ) + .foreign_key( + ForeignKey::create() + .name("fk_asset_superseded_asset") + .from(AssetSuperseded::Table, AssetSuperseded::AssetId) + .to(Assets::Table, Assets::AssetId) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_asset_superseded_address") + .table(AssetSuperseded::Table) + .col(AssetSuperseded::Address) + .to_owned(), + ) + .await?; + + // The whole idempotency store for a lifecycle write: a replay needs the number the + // first application minted, and everything else in the response is derivable from the + // manifest itself. The retired implementation kept the serialized response body in a + // table — a second copy of something derivable, and therefore a second thing that can + // be wrong. + manager + .create_table( + Table::create() + .table(AppliedManifests::Table) + .if_not_exists() + .col( + ColumnDef::new(AppliedManifests::ManifestHash) + .binary() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(AppliedManifests::SyncSeq) + .big_integer() + .not_null(), + ) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(AppliedManifests::Table).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(AssetSuperseded::Table).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(AssetBlobs::Table).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(Assets::Table).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(OwnerSequences::Table).to_owned()) + .await?; + Ok(()) + } +} + +#[derive(DeriveIden)] +enum Assets { + Table, + AssetId, + OwnerId, + AlbumId, + ProtocolVersion, + CryptoSuiteId, + State, + Hold, + SyncSeq, + FirstSeq, + ChainHead, + AmkVersion, + RetentionUntil, + CreatedAt, + UpdatedAt, +} + +#[derive(DeriveIden)] +enum AssetBlobs { + Table, + AssetId, + Role, + Address, + Size, +} + +#[derive(DeriveIden)] +enum AssetSuperseded { + Table, + AssetId, + Position, + Address, +} + +#[derive(DeriveIden)] +enum OwnerSequences { + Table, + OwnerId, + NextSeq, +} + +#[derive(DeriveIden)] +enum AppliedManifests { + Table, + ManifestHash, + SyncSeq, +} diff --git a/capsule-server/migration/src/m20260902_000002_accounts.rs b/capsule-server/migration/src/m20260902_000002_accounts.rs new file mode 100644 index 00000000..5262a58a --- /dev/null +++ b/capsule-server/migration/src/m20260902_000002_accounts.rs @@ -0,0 +1,87 @@ +//! The account cluster (`S-C53`, `S-C54`): one table behind four ports. +//! +//! `AccountRegistry`, `AccountDirectory`, `AccountProfiles` and `PasswordChange` are four ports +//! because they answer four questions with four disclosure contracts — not because they are +//! four stores. One row holds every fact all four read. + +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub(crate) struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Accounts::Table) + .if_not_exists() + .col( + ColumnDef::new(Accounts::UserId) + .text() + .not_null() + .primary_key(), + ) + // Compared **verbatim**, which is why there is no folded column beside it. + // Case folding is a normalization policy no port describes, and inventing + // one here would make the durable adapter disagree with the in-memory one + // about who `Foo@example.test` is — a divergence the shared conformance + // suite exists to make impossible. Saying otherwise is a decision about + // identity, and it belongs to a slice that argues for it. + .col(ColumnDef::new(Accounts::Email).text().not_null()) + .col(ColumnDef::new(Accounts::DisplayName).text().null()) + // The Argon2id PHC string `auth::credential` produces. Never a password, + // and never read above the adapter. + .col(ColumnDef::new(Accounts::Credential).text().not_null()) + // The lockout the directory port makes the adapter's own state: a column on + // the account row, not a windowed counter. Rate limiting is `S-C32` and has + // no port in this crate. + .col( + ColumnDef::new(Accounts::Failures) + .big_integer() + .not_null() + .default(0), + ) + .col(ColumnDef::new(Accounts::CreatedAt).big_integer().not_null()) + .col(ColumnDef::new(Accounts::UpdatedAt).big_integer().not_null()) + .to_owned(), + ) + .await?; + + // What decides `Registration::AlreadyExists`. A unique index rather than a read + // followed by a write: two registrations racing on one address must not both believe + // they own it, and the port is explicit that the check and the write are one operation. + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_accounts_email") + .table(Accounts::Table) + .col(Accounts::Email) + .unique() + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Accounts::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Accounts { + Table, + UserId, + Email, + DisplayName, + Credential, + Failures, + CreatedAt, + UpdatedAt, +} diff --git a/capsule-server/migration/src/m20260902_000003_cohorts.rs b/capsule-server/migration/src/m20260902_000003_cohorts.rs new file mode 100644 index 00000000..79342cf3 --- /dev/null +++ b/capsule-server/migration/src/m20260902_000003_cohorts.rs @@ -0,0 +1,75 @@ +//! The durable device-cohort map (`S-C13`). +//! +//! Advisory storage, structurally: nothing here is read by an authorization path, and the port +//! offers no lookup that could tempt one. The map outlives sessions deliberately — a cohort +//! becomes worth knowing exactly when the sessions that carried it have expired. + +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub(crate) struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(DeviceCohorts::Table) + .if_not_exists() + .col(ColumnDef::new(DeviceCohorts::UserId).text().not_null()) + .col(ColumnDef::new(DeviceCohorts::CohortHash).text().not_null()) + .col( + ColumnDef::new(DeviceCohorts::FirstSeen) + .big_integer() + .not_null(), + ) + .col( + ColumnDef::new(DeviceCohorts::LastSeen) + .big_integer() + .not_null(), + ) + // The composite key **is** the idempotence the port states: seeing the same + // cohort twice is one row, not two, so `observe` is an upsert rather than a + // read followed by a decision. + .primary_key( + Index::create() + .col(DeviceCohorts::UserId) + .col(DeviceCohorts::CohortHash), + ) + .to_owned(), + ) + .await?; + + // The listing's order is part of the contract — oldest first sighting first — because + // it is a user-visible surface. + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_device_cohorts_user_first_seen") + .table(DeviceCohorts::Table) + .col(DeviceCohorts::UserId) + .col(DeviceCohorts::FirstSeen) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(DeviceCohorts::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum DeviceCohorts { + Table, + UserId, + CohortHash, + FirstSeen, + LastSeen, +} diff --git a/capsule-server/migration/src/m20260902_000004_quota.rs b/capsule-server/migration/src/m20260902_000004_quota.rs new file mode 100644 index 00000000..be7c680b --- /dev/null +++ b/capsule-server/migration/src/m20260902_000004_quota.rs @@ -0,0 +1,101 @@ +//! The quota ledger (`S-C6`): attribution by content address, and the over-limit clock. + +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub(crate) struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Keyed on the **content address**, globally, and that is the security property rather + // than a normalization: a blob shared between two uploaders counts against only the + // first, so re-uploading blobs whose addresses you already know cannot exhaust somebody + // else's quota. The primary key is what makes the check and the debit one operation. + manager + .create_table( + Table::create() + .table(QuotaAttributions::Table) + .if_not_exists() + .col( + ColumnDef::new(QuotaAttributions::Address) + .text() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(QuotaAttributions::UserId).text().not_null()) + .col( + ColumnDef::new(QuotaAttributions::Size) + .big_integer() + .not_null(), + ) + .col( + ColumnDef::new(QuotaAttributions::ChargedAt) + .big_integer() + .not_null(), + ) + .to_owned(), + ) + .await?; + // What `usage` sums over. + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_quota_attributions_user") + .table(QuotaAttributions::Table) + .col(QuotaAttributions::UserId) + .to_owned(), + ) + .await?; + + // Only the clock. A user's total is **derived** by summing their attributions rather + // than stored beside them, because a stored total is a second copy of a derivable fact + // and a total that drifts low hands somebody free storage. What cannot be derived is + // *when* an account crossed the hard limit and has not been under it since — that is + // the one column here. + manager + .create_table( + Table::create() + .table(QuotaUsage::Table) + .if_not_exists() + .col( + ColumnDef::new(QuotaUsage::UserId) + .text() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(QuotaUsage::OverSince).big_integer().null()) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(QuotaUsage::Table).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(QuotaAttributions::Table).to_owned()) + .await?; + Ok(()) + } +} + +#[derive(DeriveIden)] +enum QuotaAttributions { + Table, + Address, + UserId, + Size, + ChargedAt, +} + +#[derive(DeriveIden)] +enum QuotaUsage { + Table, + UserId, + OverSince, +} diff --git a/capsule-server/migration/src/main.rs b/capsule-server/migration/src/main.rs new file mode 100644 index 00000000..1c3d5c3c --- /dev/null +++ b/capsule-server/migration/src/main.rs @@ -0,0 +1,13 @@ +//! The operator's migration command. +//! +//! `capsule-server serve` deliberately does **not** run migrations: it reads `seaql_migrations` +//! and refuses to start when the schema is not the one it was built against +//! (`capsule_server::postgres::assert_schema_current`). A server that migrates on start runs +//! the migration once per replica on a rolling deploy, which is a schema change racing itself. + +use sea_orm_migration::prelude::*; + +#[tokio::main] +async fn main() { + cli::run_cli(server_migration::Migrator).await; +} diff --git a/capsule-server/src/lib.rs b/capsule-server/src/lib.rs index 5d018f32..32377fa4 100644 --- a/capsule-server/src/lib.rs +++ b/capsule-server/src/lib.rs @@ -69,6 +69,7 @@ pub mod index; pub mod limits; pub mod moderation; mod openapi; +pub mod postgres; pub mod problem; pub mod quota; pub mod routes; diff --git a/capsule-server/src/postgres/error.rs b/capsule-server/src/postgres/error.rs new file mode 100644 index 00000000..04b8585a --- /dev/null +++ b/capsule-server/src/postgres/error.rs @@ -0,0 +1,159 @@ +//! One `DbErr` → [`StoreError`] mapping, for every Postgres adapter (#402). +//! +//! # Why one function and not one per adapter +//! +//! [`StoreError`]'s three variants are a *decision* a caller acts on — the operation certainly +//! did not happen, the operation may or may not have happened, the stored value cannot be read +//! back — and four adapters classifying the same `DbErr` four times is four chances to get that +//! decision subtly different. The route above them maps the variant onto an `error.*` code, so a +//! divergence here shows up as one port answering `503` where another answers `500` for the same +//! dropped socket. +//! +//! # How the three variants are decided +//! +//! Deliberately on the `DbErr` variant alone, without reaching into `sqlx::Error`. Two reasons, +//! and the second is the load-bearing one: +//! +//! - Naming `sqlx::Error` means depending on `sqlx` directly, which is a second pin of a crate +//! sea-orm already owns. +//! - **A statement that failed mid-flight has not "certainly not happened".** +//! [`StoreError::Unavailable`] promises exactly that, so the tempting mapping — any I/O error +//! under `DbErr::Exec` is `Unavailable` — would be a lie in the one case it matters: a +//! connection dropped after the server committed. That case is +//! [`StoreError::Rejected`], whose contract is "whether state changed is unknown". So +//! `Unavailable` is reserved for the failures that happen *before* a statement is sent — +//! acquiring a connection, opening one — and everything else that is not a decoding failure is +//! `Rejected`. +//! +//! `DbErr::RecordNotFound` never reaches here as an error: every port in this crate models +//! absence as an `Option` or an outcome variant, and the adapters answer it from a row count +//! rather than from a raised error. + +use sea_orm::DbErr; + +use crate::store::StoreError; + +/// Which port is speaking, and what record it holds. +/// +/// A value rather than two parameters at every call site: an adapter declares it once as a +/// constant and every `map_err` reads as the operation it was doing. +#[derive(Debug, Clone, Copy)] +pub(crate) struct Port { + /// The port's name, for the log line and the `StoreError` field. + pub(crate) store: &'static str, + /// The record type its rows decode into. + pub(crate) record: &'static str, +} + +impl Port { + /// A closure that turns a `DbErr` raised while `doing` into a [`StoreError`]. + /// + /// Returned as a closure so a call site reads + /// `.map_err(PORT.failing("reserving an asset row"))?`, with the operation named once beside + /// the statement that performs it rather than repeated in a `match`. + pub(crate) fn failing(self, doing: &'static str) -> impl Fn(DbErr) -> StoreError { + move |error| self.classify(doing, &error) + } + + /// A stored value that could not be read back as the record type that owns its key. + /// + /// Raised by the adapters themselves — an unknown enum discriminant, a content address that + /// no longer parses, an `i64` that is not a valid instant — rather than by the driver. It is + /// the variant `store/mod.rs` keeps for a rolling deploy that left an older encoding behind. + pub(crate) fn undecodable(self, detail: impl Into) -> StoreError { + let detail = detail.into(); + tracing::error!( + store = self.store, + record = self.record, + %detail, + "a stored row could not be read back as the record that owns it" + ); + StoreError::Corrupt { + store: self.store, + record: self.record, + detail, + } + } + + /// The classification itself, split out so it is testable without a database. + fn classify(self, doing: &str, error: &DbErr) -> StoreError { + let detail = format!("{doing}: {error}"); + match error { + // Before any statement was sent. The operation certainly did not happen. + DbErr::Conn(_) | DbErr::ConnectionAcquire(_) => { + tracing::error!(store = self.store, %detail, "the Postgres backend is unreachable"); + StoreError::Unavailable { + store: self.store, + detail, + } + } + // The driver reached a value it could not turn into the Rust type the column is + // read as — a schema the running binary was not built against. + DbErr::Type(_) + | DbErr::Json(_) + | DbErr::TryIntoErr { .. } + | DbErr::ConvertFromU64(_) => self.undecodable(detail), + // Everything else: the statement was sent and did not succeed. Whether state + // changed is unknown, which is exactly what `Rejected` means. + _ => { + tracing::warn!(store = self.store, %detail, "Postgres refused an operation"); + StoreError::Rejected { + store: self.store, + detail, + } + } + } + } +} + +#[cfg(test)] +mod tests { + use sea_orm::{ConnAcquireErr, DbErr, RuntimeErr}; + + use super::Port; + use crate::store::StoreError; + + const PORT: Port = Port { + store: "test", + record: "TestRecord", + }; + + #[test] + fn a_pool_timeout_is_unavailable_because_nothing_was_sent() { + let error = PORT.classify( + "acquiring a connection", + &DbErr::ConnectionAcquire(ConnAcquireErr::Timeout), + ); + assert!(matches!(error, StoreError::Unavailable { .. }), "{error:?}"); + } + + #[test] + fn a_failed_statement_is_rejected_and_never_unavailable() { + // The distinction the module exists for: a connection dropped *after* the server + // committed is indistinguishable here from one dropped before, and `Unavailable` + // promises the operation did not happen. Promising that wrongly is how a caller + // retries a write that already landed. + let error = PORT.classify( + "recording a blob", + &DbErr::Exec(RuntimeErr::Internal("connection reset".to_owned())), + ); + assert!(matches!(error, StoreError::Rejected { .. }), "{error:?}"); + } + + #[test] + fn a_value_that_will_not_decode_is_corrupt_and_names_the_record() { + let error = PORT.classify("reading a row", &DbErr::Type("not an i64".to_owned())); + match error { + StoreError::Corrupt { record, .. } => assert_eq!(record, "TestRecord"), + other => panic!("a decoding failure must be Corrupt, got {other:?}"), + } + } + + #[test] + fn an_adapter_raised_decoding_failure_is_the_same_variant() { + // The adapters raise this themselves for an unknown enum discriminant or an address + // that no longer parses — the driver cannot, because those columns are plain text. + let error = PORT.undecodable("`elsewhere` is not an asset state"); + assert!(matches!(error, StoreError::Corrupt { .. }), "{error:?}"); + } +} diff --git a/capsule-server/src/postgres/mod.rs b/capsule-server/src/postgres/mod.rs new file mode 100644 index 00000000..a4fe42b9 --- /dev/null +++ b/capsule-server/src/postgres/mod.rs @@ -0,0 +1,250 @@ +//! The Postgres adapters' shared half: the pool, the error mapping, the instant conversion, and +//! the container harness the conformance suites run against (#402). +//! +//! # Why the adapters are not in here +//! +//! Only the cross-cutting machinery lives in this module. Each adapter lives beside the port it +//! implements — `index/postgres.rs`, `auth/accounts_postgres.rs`, `store/cohorts_postgres.rs`, +//! `quota/postgres.rs` — because design/module-map.md assigns contract ownership per *behaviour +//! module* rather than per backend, and the tree already does it that way for the blob store +//! (`blob/fs.rs`, `blob/memory.rs`). A `postgres/` tree holding every adapter would make one +//! directory the co-owner of every durable contract in the crate. +//! +//! Nothing forces the alternative either: no invariant in these four ports needs a transaction +//! spanning two of them. The sequence mint is inside `record_blob`, the attribution check is +//! inside `QuotaStore::charge`, and the account row is one table. +//! +//! # Migrations are applied by a separate binary, and `serve` refuses to run without them +//! +//! `capsule-server` takes `capsule-server-migration` as a **dev-dependency only**, so +//! `Migrator::up` is not reachable from the server binary at all — see that crate's manifest for +//! why (the `chrono` gate). What the server does instead is [`assert_schema_current`]: it reads +//! `seaql_migrations` and refuses to boot unless every ordinal it was built against has been +//! applied, naming the command an operator has to run. +//! +//! That is also the safer rollout. A server that migrates on start runs the migration once per +//! replica during a rolling deploy — a schema change racing itself — and gives every replica +//! the privileges a DDL statement needs. + +pub mod error; +pub mod time; + +#[cfg(test)] +pub(crate) mod testing; + +use std::time::Duration; + +use sea_orm::{ConnectOptions, Database, DatabaseConnection, DbBackend, Statement}; + +/// The migrations this binary was built against, oldest first. +/// +/// Compiled in rather than read from the migration crate, which `capsule-server` cannot link. +/// `postgres::tests::the_expected_migrations_are_the_migrations_that_exist` compares the two +/// under `cfg(test)`, where the migration crate *is* available — so the list cannot drift from +/// the crate that defines it without a red test. +pub const EXPECTED_MIGRATIONS: &[&str] = &[ + "m20260902_000001_asset_index", + "m20260902_000002_accounts", + "m20260902_000003_cohorts", + "m20260902_000004_quota", +]; + +/// The command an operator runs to apply them. +const MIGRATION_COMMAND: &str = "capsule-server-migration up"; + +/// Why a Postgres-backed process could not start. +/// +/// A **startup** failure in both variants. Neither ever carries the connection URL: a +/// `DATABASE_URL` holds a password, and a startup error is the most-copied line in any incident +/// channel. +#[derive(Debug, thiserror::Error)] +pub enum PostgresError { + /// The pool could not be opened. + #[error("the Postgres connection could not be opened: {detail}")] + Connect { + /// The driver's own description. Never the URL. + detail: String, + }, + /// The schema is not the one this binary was built against. + #[error("the database schema is not current: {detail}; run `{MIGRATION_COMMAND}`")] + Schema { + /// Which ordinals are missing, or why the ledger could not be read. + detail: String, + }, +} + +/// Open the pool `serve` and the operator workers share. +/// +/// The numbers are a deployment default rather than a tuning surface, and each one is a +/// consequence of what this server does: +/// +/// - `max_connections(32)` — the durable ports are on the request path but not the *byte* path; +/// a chunk append touches the blob store and Valkey, never Postgres. Thirty-two is comfortably +/// above the concurrency a finalization-bound workload reaches and well under a default +/// `max_connections = 100` shared with the migration binary and an operator's `psql`. +/// - `connect_timeout(5s)` / `acquire_timeout(10s)` — a request that waits longer than this has +/// already lost; failing it as `Unavailable` is what lets the route answer rather than hang. +/// - `idle_timeout(10m)` / `max_lifetime(30m)` — a connection that outlives a rolling restart of +/// the database is a connection that discovers it is dead inside somebody's transaction. +/// - `sqlx_logging(false)` — `tracing` owns the log line. sqlx's own would double every query +/// *and* print bound parameters, which here means an email address and an Argon2id PHC string. +/// +/// # Errors +/// +/// Returns [`PostgresError::Connect`] if the pool cannot be opened. +pub async fn connect(database_url: &str) -> Result { + let mut options = ConnectOptions::new(database_url.to_owned()); + options + .max_connections(32) + .min_connections(2) + .connect_timeout(Duration::from_secs(5)) + .acquire_timeout(Duration::from_secs(10)) + .idle_timeout(Duration::from_secs(10 * 60)) + .max_lifetime(Duration::from_secs(30 * 60)) + .sqlx_logging(false); + + let connection = Database::connect(options) + .await + .map_err(|error| PostgresError::Connect { + detail: error.to_string(), + })?; + tracing::info!("opened the Postgres connection pool"); + Ok(connection) +} + +/// Refuse to continue unless every migration in [`EXPECTED_MIGRATIONS`] has been applied. +/// +/// Reads `seaql_migrations` — the ledger `sea-orm-migration` maintains — and compares its +/// applied names against the compiled-in list. A **missing** ordinal is fatal: the binary would +/// query a table or a column that does not exist, and the first symptom would be a 500 on +/// whichever request reached it first. +/// +/// An ordinal the database holds and this binary does not know about is **not** fatal, and that +/// asymmetry is the rolling-deploy contract: during a deploy the migration runs first and the +/// old replicas keep serving, so a newer schema underneath an older binary is the normal state +/// for the length of the rollout. A migration that removes something an older binary reads is a +/// migration that has to be split in two, which is a property of the migration rather than +/// something this check can enforce. +/// +/// # Errors +/// +/// Returns [`PostgresError::Schema`] if the ledger cannot be read or an expected ordinal is +/// absent. +pub async fn assert_schema_current(connection: &DatabaseConnection) -> Result<(), PostgresError> { + use sea_orm::ConnectionTrait as _; + + let statement = Statement::from_string( + DbBackend::Postgres, + "SELECT version FROM seaql_migrations".to_owned(), + ); + let rows = connection + .query_all(statement) + .await + .map_err(|error| PostgresError::Schema { + detail: format!("`seaql_migrations` could not be read ({error})"), + })?; + + let mut applied = Vec::with_capacity(rows.len()); + for row in rows { + let version: String = + row.try_get("", "version") + .map_err(|error| PostgresError::Schema { + detail: format!("`seaql_migrations.version` could not be read ({error})"), + })?; + applied.push(version); + } + + let missing: Vec<&str> = EXPECTED_MIGRATIONS + .iter() + .copied() + .filter(|expected| !applied.iter().any(|held| held == expected)) + .collect(); + if !missing.is_empty() { + return Err(PostgresError::Schema { + detail: format!("{} has not been applied", missing.join(", ")), + }); + } + + tracing::info!( + applied = applied.len(), + expected = EXPECTED_MIGRATIONS.len(), + "the database schema is current" + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use sea_orm::ConnectionTrait as _; + use server_migration::MigratorTrait as _; + + use super::{EXPECTED_MIGRATIONS, assert_schema_current, testing}; + + /// The compiled-in list is the migration crate's list. + /// + /// `capsule-server` cannot link the migrator in a normal build, so the list it refuses to + /// boot on is a copy. This is the assertion that keeps the copy honest — and it needs no + /// container, so it runs on every ordinary test pass. + #[test] + fn the_expected_migrations_are_the_migrations_that_exist() { + let defined: Vec = server_migration::Migrator::migrations() + .iter() + .map(|migration| migration.name().to_owned()) + .collect(); + assert_eq!( + defined, EXPECTED_MIGRATIONS, + "`postgres::EXPECTED_MIGRATIONS` is what `serve` refuses to boot without; it has \ + drifted from `capsule-server-migration`" + ); + } + + mod postgres_conformance { + use super::{ConnectionTrait as _, EXPECTED_MIGRATIONS, assert_schema_current, testing}; + + /// Up, down, up: the schema a rollback leaves behind is one the next deploy can build on. + #[tokio::test] + async fn the_migrations_apply_and_roll_back() { + let Some(database) = testing::start("the migration round trip").await else { + return; + }; + let connection = database.connection(); + + assert_schema_current(connection) + .await + .expect("a freshly migrated database is current"); + + server_migration::Migrator::down(connection, None) + .await + .expect("every migration rolls back"); + let error = assert_schema_current(connection) + .await + .expect_err("a rolled-back schema is not current"); + assert!( + format!("{error}").contains("capsule-server-migration up"), + "the refusal must name the command that fixes it, got {error}" + ); + + server_migration::Migrator::up(connection, None) + .await + .expect("every migration re-applies"); + assert_schema_current(connection) + .await + .expect("the schema is current again"); + + // And the ledger holds exactly the ordinals the server was built against — not an + // extra one a partially-applied `down` left behind. + let rows = connection + .query_all(sea_orm::Statement::from_string( + sea_orm::DbBackend::Postgres, + "SELECT version FROM seaql_migrations ORDER BY version".to_owned(), + )) + .await + .expect("the ledger is readable"); + let applied: Vec = rows + .iter() + .map(|row| row.try_get("", "version").expect("a version column")) + .collect(); + assert_eq!(applied, EXPECTED_MIGRATIONS); + } + } +} diff --git a/capsule-server/src/postgres/testing.rs b/capsule-server/src/postgres/testing.rs new file mode 100644 index 00000000..4faae677 --- /dev/null +++ b/capsule-server/src/postgres/testing.rs @@ -0,0 +1,113 @@ +//! The container harness the Postgres conformance suites run against. +//! +//! # The gate is load-bearing, not decorative +//! +//! `cargo nextest run` must be green on a machine with no container runtime — that is the +//! acceptance gap design/module-map.md sets for the framework, and it is what lets the rest of +//! the rebuild be tested without live infrastructure. So every Postgres case asks [`start`] +//! first, and [`start`] returns `None` — after printing why — unless `CAPSULE_TEST_POSTGRES=1` +//! is set. +//! +//! A **skip that says nothing** is the failure mode this is written against: a suite that +//! silently runs zero cases reads exactly like a suite that passes. Every skip prints one line +//! naming the case and how to run it. +//! +//! # One container per test, and why that is the right trade here +//! +//! nextest runs a process per test, so a `OnceCell` shared between cases would buy nothing — +//! each process would fill its own. Rather than pretend otherwise, each port contributes +//! exactly **one** container-backed test that runs its whole suite in one [`run_all`]-style +//! pass, which is what `index/conformance.rs` said a Postgres smoke test should be. Five +//! containers per run, serialized by the `containers` nextest group (`.config/nextest.toml`), +//! is a few seconds; thirty would not be. +//! +//! A fresh container per test also removes the isolation problem entirely: no schema +//! namespacing, no truncation between cases, and no case that passes because a previous one +//! left the database in a convenient state. + +use sea_orm::DatabaseConnection; +use server_migration::MigratorTrait as _; +use testcontainers::ContainerAsync; +use testcontainers::runners::AsyncRunner as _; +use testcontainers_modules::postgres::Postgres; + +/// The environment variable that admits the container-backed cases. +pub(crate) const GATE: &str = "CAPSULE_TEST_POSTGRES"; + +/// The image tag every run pins. +/// +/// Pinned rather than left at the module's default (`11-alpine`) for two reasons: the server +/// targets a currently-supported PostgreSQL, and a floating tag makes "it passed yesterday" +/// unfalsifiable. Bump it deliberately. +const POSTGRES_TAG: &str = "18.0"; + +/// A running Postgres with the server's schema applied. +/// +/// Holds the container: dropping this stops and removes it, so a case cannot leak one. +#[derive(Debug)] +pub(crate) struct TestDatabase { + _container: ContainerAsync, + connection: DatabaseConnection, +} + +impl TestDatabase { + /// The pool the adapter under test is built over. + pub(crate) fn connection(&self) -> &DatabaseConnection { + &self.connection + } +} + +/// Whether the container-backed cases are admitted. +fn enabled() -> bool { + matches!(std::env::var(GATE).as_deref(), Ok("1")) +} + +/// Start a Postgres for `case`, or explain why it was skipped. +/// +/// `None` is a **skip**, and the caller returns rather than failing: the whole point of the gate +/// is that a run with no container runtime is green. +/// +/// # Panics +/// +/// Panics if the gate is set and the container cannot be started or migrated. An operator who +/// asked for the Postgres tier and did not get it must be told, not quietly skipped — that would +/// make the gate a way to hide a broken adapter. +pub(crate) async fn start(case: &str) -> Option { + if !enabled() { + eprintln!( + "skipping {case}: the Postgres conformance tier is unavailable. Set {GATE}=1 with a \ + reachable container runtime — for podman, `systemctl --user start podman.socket` \ + and `export DOCKER_HOST=unix:///run/user/$(id -u)/podman/podman.sock`." + ); + return None; + } + + let container = testcontainers::ImageExt::with_tag(Postgres::default(), POSTGRES_TAG) + .start() + .await + .unwrap_or_else(|error| { + panic!("{GATE}=1 was set but a Postgres container could not be started: {error}") + }); + let host = container + .get_host() + .await + .unwrap_or_else(|error| panic!("the container has no reachable host: {error}")); + let port = container + .get_host_port_ipv4(5432) + .await + .unwrap_or_else(|error| panic!("the container published no port: {error}")); + // The module's own defaults; there is no secret here to keep out of a source file. + let url = format!("postgres://postgres:postgres@{host}:{port}/postgres"); + + let connection = super::connect(&url) + .await + .unwrap_or_else(|error| panic!("the test container refused a connection: {error}")); + server_migration::Migrator::up(&connection, None) + .await + .unwrap_or_else(|error| panic!("the server's schema could not be applied: {error}")); + + Some(TestDatabase { + _container: container, + connection, + }) +} diff --git a/capsule-server/src/postgres/time.rs b/capsule-server/src/postgres/time.rs new file mode 100644 index 00000000..7f9c5201 --- /dev/null +++ b/capsule-server/src/postgres/time.rs @@ -0,0 +1,91 @@ +//! `jiff::Timestamp` ⇄ `BIGINT` microseconds since the Unix epoch. +//! +//! # Why an integer and not `TIMESTAMPTZ` +//! +//! Binding a `TIMESTAMPTZ` needs one of sea-orm's datetime features, and both are refused. With +//! `with-chrono` the server joins the chrono path that design/dependencies.md makes a +//! review-blocking gate; with `with-time` the workspace gains a third datetime crate with no row +//! in that table. Without either there is no Rust type for the column at all, so the choice is +//! an integer column or a banned dependency. +//! +//! This is the server's exact analogue of the CLI's "chrono only at the entity boundary" rule: +//! the conversion happens here, at the adapter's edge, and every layer above speaks +//! [`jiff::Timestamp`]. design/dependencies.md already blesses integer epochs as a serialized +//! form. +//! +//! # What it costs +//! +//! No SQL date arithmetic. Every expiry and retention comparison in the durable ports is an +//! ordering on one column — `updated_at`, `first_seen`, `retention_until` — and integers order +//! identically, so nothing in scope wants it. A future port that needs `now() - interval` gets +//! the arithmetic in Rust, above the boundary, where the clock is already injected. +//! +//! # Microseconds, not nanoseconds +//! +//! `i64` nanoseconds run out in 2262 and `jiff` represents instants well past that, so the +//! conversion would be lossy at the wrong end. Microseconds cover the whole of `Timestamp`'s +//! range in an `i64` and are PostgreSQL's own `timestamp` resolution, so a column that is later +//! migrated to `TIMESTAMPTZ` loses nothing. + +use jiff::Timestamp; + +/// The instant `at`, as microseconds since the Unix epoch. +pub(crate) fn to_micros(at: Timestamp) -> i64 { + at.as_microsecond() +} + +/// The instant `micros` microseconds after the Unix epoch, or `None` if it is not one. +/// +/// `None` is a **corrupt row**, not a missing value: the column is `NOT NULL` wherever it is +/// required, and a value outside `Timestamp`'s range can only come from something that did not +/// write it through [`to_micros`]. The adapters map it onto +/// [`StoreError::Corrupt`](crate::store::StoreError::Corrupt). +pub(crate) fn from_micros(micros: i64) -> Option { + Timestamp::from_microsecond(micros).ok() +} + +#[cfg(test)] +mod tests { + use jiff::{SignedDuration, Timestamp}; + + use super::{from_micros, to_micros}; + + #[test] + fn an_instant_survives_the_round_trip() { + let at = Timestamp::UNIX_EPOCH + SignedDuration::from_secs(1_700_000_000); + assert_eq!(from_micros(to_micros(at)), Some(at)); + } + + #[test] + fn the_epoch_is_zero_so_a_column_is_readable_by_a_human_with_a_calculator() { + assert_eq!(to_micros(Timestamp::UNIX_EPOCH), 0); + assert_eq!(from_micros(0), Some(Timestamp::UNIX_EPOCH)); + } + + #[test] + fn instants_order_the_way_their_integers_do() { + // The whole justification for giving up SQL date arithmetic: every comparison these + // tables make is an ordering, and an ordering is preserved. + let earlier = Timestamp::UNIX_EPOCH + SignedDuration::from_secs(10); + let later = Timestamp::UNIX_EPOCH + SignedDuration::from_secs(20); + assert!(earlier < later); + assert!(to_micros(earlier) < to_micros(later)); + } + + #[test] + fn the_whole_representable_range_survives_and_nothing_outside_it_is_invented() { + // Microseconds rather than nanoseconds precisely so this holds: an `i64` of nanoseconds + // overflows in 2262, well inside `Timestamp`'s range. + assert_eq!(from_micros(to_micros(Timestamp::MAX)), Some(Timestamp::MAX)); + assert_eq!(from_micros(to_micros(Timestamp::MIN)), Some(Timestamp::MIN)); + assert_eq!(from_micros(i64::MAX), None); + assert_eq!(from_micros(i64::MIN), None); + } + + #[test] + fn a_negative_instant_is_before_the_epoch_rather_than_a_failure() { + let before = Timestamp::UNIX_EPOCH - SignedDuration::from_secs(1); + assert_eq!(to_micros(before), -1_000_000); + assert_eq!(from_micros(-1_000_000), Some(before)); + } +} diff --git a/xtask/src/architecture.rs b/xtask/src/architecture.rs index e77c6eba..f78ed3c8 100644 --- a/xtask/src/architecture.rs +++ b/xtask/src/architecture.rs @@ -52,14 +52,6 @@ const PLANNED_WORKSPACE_DEPENDENCIES: &[(&str, &str)] = &[ "redis", "the `redis-rs` Valkey adapters AGENTS.md requires for AuthStateStore/UploadSessionStore", ), - ( - "testcontainers", - "the smoke tier the design docs specify; no test starts a container yet", - ), - ( - "testcontainers-modules", - "the smoke tier the design docs specify; no test starts a container yet", - ), ]; const RETIRED_COMPONENT_NAMES: &[&str] = &[ @@ -78,6 +70,7 @@ pub(crate) fn run(root: &Path) -> Result<()> { check_dependencies(root, &mut violations)?; check_workspace_dependencies(root, &mut violations)?; check_legacy_manifests(root, &mut violations)?; + check_chrono_isolation(root, &mut violations)?; check_retired_references(root, &mut violations)?; if violations.is_empty() { @@ -295,6 +288,66 @@ fn member_dependency_names(root: &Path) -> Result> { Ok(names) } +/// `capsule-server` must not be on the `chrono` path. +/// +/// design/dependencies.md makes `cargo tree -i chrono -e no-dev` a review-blocking gate: chrono +/// exists in this workspace only as the sea-orm column type inside `capsule-cli/entity`, and the +/// server's own datetime crate is `jiff`. #402 puts sea-orm into `capsule-server` for the +/// Postgres adapters, which makes that gate worth *enforcing* rather than reading. +/// +/// The check is deliberately **per package** rather than a grep of the workspace-wide tree. +/// `capsule-cli` inherits sea-orm with its default features, `with-chrono` among them, and +/// cargo unifies features across the packages a workspace build selects — so the workspace-wide +/// `cargo tree` output lists `capsule-server` under sea-orm and always will, whatever this crate +/// declares. What is actually decidable, and what the rule is about, is whether *this package's +/// own manifest* asks for chrono: `cargo tree -p capsule-server -i chrono -e no-dev` must print +/// nothing. That is what `default-features = false` on the server's sea-orm entry buys, and this +/// is what stops somebody restoring the defaults without noticing. +fn check_chrono_isolation(root: &Path, violations: &mut Vec) -> Result<()> { + const PACKAGES: &[&str] = &["capsule-server", "capsule-server-migration"]; + // The migration crate is exempt from the *conclusion* but not from the check: it is a + // dev-dependency of the server precisely because `sea-orm-migration` drags sea-orm's + // defaults in, so it is expected to be on the path and is listed here only so a reader sees + // that the exemption is deliberate rather than an omission. + const EXPECTED_ON_THE_PATH: &[&str] = &["capsule-server-migration"]; + + for package in PACKAGES { + let output = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())) + .args([ + "tree", + "--offline", + "-p", + package, + "-i", + "chrono", + "-e", + "no-dev", + ]) + .current_dir(root) + .output() + .with_context(|| format!("running cargo tree for {package}"))?; + // A package that does not depend on chrono makes `cargo tree -i` exit non-zero with + // "nothing to print" or "did not match any packages"; both are the passing shape. + let reaches_chrono = + output.status.success() && String::from_utf8_lossy(&output.stdout).contains("chrono v"); + let expected = EXPECTED_ON_THE_PATH.contains(package); + if reaches_chrono && !expected { + violations.push(format!( + "`{package}` reaches `chrono` through its own manifest; design/dependencies.md \ + permits chrono only as the sea-orm column type in `capsule-cli/entity`. Check \ + that sea-orm is declared with `default-features = false`" + )); + } + if !reaches_chrono && expected { + violations.push(format!( + "`{package}` no longer reaches `chrono`; remove it from EXPECTED_ON_THE_PATH so \ + the exemption stops describing something that is not true" + )); + } + } + Ok(()) +} + fn check_legacy_manifests(root: &Path, violations: &mut Vec) -> Result<()> { let legacy = root.join("legacy-review"); if legacy.exists() { From 15572e3f9583303da7c62d56355ab10a90626254 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 01:40:33 -0400 Subject: [PATCH 081/243] fix(core)!: encrypt derivative bytes before they cross the network MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Derivative blobs were pushed in the clear. `capsule-sdk::push` shipped `DerivativeBlob::bytes` verbatim while the original went as ciphertext, so a field named `ciphertext_hash` addressed plaintext and a thumbnail — a recognisable low-resolution copy of a private photo — reached the server readable. Encryption's opening clause admits no exception: "every asset — original bytes, derivative bytes, metadata blob — is encrypted client-side", and the upload protocol adds "each encrypted independently". `DerivativeCore` gains a **required** `nonce_prefix`, the same type `ManifestCore` carries. Required rather than `Option` because a receiver that cannot recover it cannot open the blob at all — an absent prefix would be an unopenable derivative, not a tolerable gap — and it is safe to require because no real `derivative-manifest/v1` has ever been written: derivatives were unconditionally `DeferredNoCodec` until the decoder landed, and `crate::ml` constructs none. Nothing to stay compatible with, so the schema string does not move. Generation encrypts each derivative with the same construction the original uses — `encrypt_asset_rekey` under the source asset's `file_id` and the album's AMK, a fresh CSPRNG prefix per derivative — and signs the **ciphertext's** address. The ciphertext is discarded: the client keeps the plaintext derivative locally, because that is what the local gallery paints, and `derivative_blobs` re-derives the ciphertext at push time from the recorded prefix, exactly as `upload_bundle` already does for the original. That ordering forced one change in `import_asset_with`: the original is encrypted *before* derivatives are generated, because the `original` sentinel is a signed reference to that blob and commits to its address and prefix, neither of which existed yet. `media` gains a narrow `DerivativeSealer` seam rather than the AMK: the codec module still names no key material, and `lifecycle` still names no codec. Two further skips at `derivative_blobs`, both previously missing: `verify_still_format` now runs there, so a still-role manifest naming a format outside the closed set is the structural rejection the tier table specifies; and the byte-free `original` sentinel is recognised as an expected reference and skipped at `debug!`, since an expected absence logged as a warning is how people learn to ignore warnings. The warning stays for a non-sentinel manifest whose bytes have gone. **Also repairs two claims the previous commit made and did not deliver.** Its message said the unwind boundary had been widened to the placeholder and the encode, and that a generation failure was reported rather than propagated. Neither edit actually applied — a silent find-and-replace miss — and no test covered either path, so both went unnoticed. They are applied here, and `guarded` is no longer an unused import. --- .../src/crypto/provenance/manifest.rs | 13 + capsule-core/src/lifecycle/derivatives.rs | 133 +++++++- capsule-core/src/lifecycle/import.rs | 24 +- capsule-core/src/lifecycle/upload.rs | 91 ++++- capsule-core/src/lifecycle/upload/tests.rs | 316 ++++++++++++++++++ capsule-core/src/media/derivative.rs | 101 ++++-- capsule-core/src/media/mod.rs | 4 +- capsule-core/src/media/tests.rs | 158 ++++++++- capsule-sdk/src/push.rs | 6 + 9 files changed, 769 insertions(+), 77 deletions(-) diff --git a/capsule-core/src/crypto/provenance/manifest.rs b/capsule-core/src/crypto/provenance/manifest.rs index f58e9c01..787a8fa1 100644 --- a/capsule-core/src/crypto/provenance/manifest.rs +++ b/capsule-core/src/crypto/provenance/manifest.rs @@ -270,6 +270,18 @@ pub struct DerivativeCore { pub format: String, /// Content-address digest over the derivative ciphertext. pub ciphertext_hash: Hash32, + /// The STREAM nonce prefix the derivative ciphertext was produced under; folded into the + /// file-key salt, so it also selects the key. + /// + /// **Required, not `Option`.** Derivative bytes are encrypted client-side exactly like the + /// original ([Encryption](https://docs/design/cryptography/encryption/)), so a receiver that + /// cannot recover this cannot decrypt the blob at all — an absent prefix would be an + /// unopenable derivative rather than a tolerable gap. It is safe to make it required + /// because no real `derivative-manifest/v1` has ever been written to any store: derivatives + /// were unconditionally `DeferredNoCodec` until the decoder landed, and `crate::ml` + /// constructs no derivative manifest. There is nothing to stay compatible with, so the + /// schema string does not move. + pub nonce_prefix: [u8; 7], /// Device that generated the derivative. pub generated_by_device: Uuid, /// Generating client version. @@ -494,6 +506,7 @@ mod tests { role: DerivativeRole::Thumbnail, format: "image/avif".into(), ciphertext_hash: Hash32([0xAB; 32]), + nonce_prefix: [9, 8, 7, 6, 5, 4, 3], generated_by_device: Uuid::from_u128(0xD1), generated_by_client: "capsule-cli/0.1.0".into(), model_id: None, diff --git a/capsule-core/src/lifecycle/derivatives.rs b/capsule-core/src/lifecycle/derivatives.rs index 40c945ee..c2be37c4 100644 --- a/capsule-core/src/lifecycle/derivatives.rs +++ b/capsule-core/src/lifecycle/derivatives.rs @@ -26,13 +26,16 @@ use uuid::Uuid; use super::{AssetState, DerivativeStatus, LifecycleError, Result, Workspace, media_dir}; use crate::cbor; -use crate::crypto::keys::AmkVersion; +use crate::crypto::encryption::encrypt_asset_rekey; +use crate::crypto::encryption::stream::AssetEncryption; +use crate::crypto::keys::{Amk, AmkVersion}; use crate::crypto::primitives::{CRYPTO_SUITE_ID, PROTOCOL_VERSION}; use crate::exif::extract::ExifExtract; use crate::lqip::Lqip; use crate::media::{ - DecodedImage, DerivativeContext, DerivativeTier, GeneratedDerivative, MediaError, - RawshiftDecoder, StillFormat, decode_guarded, generate_still_derivatives, + DecodedImage, DerivativeContext, DerivativeSealer, DerivativeTier, GeneratedDerivative, + MediaError, RawshiftDecoder, SealedDerivative, StillFormat, decode_guarded, + generate_still_derivatives, guarded, }; use crate::sidecar::sidecar_v1::{Dimensions, Lqip as SidecarLqip}; @@ -121,12 +124,23 @@ fn classify(error: &MediaError, src: &Path, format: Option) -> Deri /// chromahash consumes the whole frame and band-limits on the read side via `decode_capped`, so /// pre-resizing would silently cap fidelity the format can carry ([`crate::lqip`]). fn lqip_from(decoded: &DecodedImage, src: &Path) -> Option { - match Lqip::encode( - decoded.width(), - decoded.height(), - &decoded.image.rgba, - decoded.gamut, - ) { + // Guarded because `chromahash` is pre-1.0 too and its own `encode` panics on a zero + // dimension or a length mismatch. `Lqip::encode` checks both, so this is belt and braces — + // but it is what makes the module's "no codec can abort an import" claim true rather than + // nearly true. + let encoded = guarded("lqip", || { + Lqip::encode( + decoded.width(), + decoded.height(), + &decoded.image.rgba, + decoded.gamut, + ) + .map_err(|e| MediaError::Decode { + format: decoded.format, + detail: format!("LQIP encode: {e}"), + }) + }); + match encoded { Ok(lqip) => Some(lqip.to_sidecar()), Err(error) => { // A decoded frame satisfies both of `encode`'s preconditions by construction, so @@ -142,6 +156,35 @@ fn lqip_from(decoded: &DecodedImage, src: &Path) -> Option { } } +/// The album-key half of derivative generation: `media` produces the bytes, this encrypts them. +/// +/// One `encrypt_asset_rekey` per derivative under the **source asset's** `file_id` and the +/// album's current AMK, with a fresh CSPRNG nonce prefix each time — so every derivative of an +/// asset gets its own file key, per the encryption doc's per-file key derivation. The ciphertext +/// is deliberately dropped: the client keeps the plaintext derivative on disk (the local gallery +/// paints it) and re-derives the ciphertext at push time from the recorded prefix, exactly as it +/// already does for the original. +struct AlbumSealer<'a> { + amk: &'a Amk, + asset_id: Uuid, +} + +impl DerivativeSealer for AlbumSealer<'_> { + fn seal(&self, plaintext: &[u8]) -> std::result::Result { + let (enc, _ciphertext, _file_key) = + encrypt_asset_rekey(self.amk, &self.asset_id, plaintext, None).map_err(|e| { + MediaError::Encode { + format: crate::media::DerivativeFormat::Original, + detail: format!("sealing the derivative: {e}"), + } + })?; + Ok(SealedDerivative { + ciphertext_hash: enc.ciphertext_hash, + nonce_prefix: enc.nonce_prefix, + }) + } +} + impl Workspace { /// Decode the still once and derive: the `content_type`, pixel `dimensions`, the sidecar /// `lqip`, and the signed thumbnail derivatives. All are attached before the sidecar is @@ -164,6 +207,8 @@ impl Workspace { exif: &ExifExtract, asset_id: Uuid, album_id: Uuid, + amk: &Amk, + original: &AssetEncryption, ) -> Result { let exif_dimensions = exif .width @@ -215,10 +260,46 @@ impl Workspace { generated_at: super::now_rfc3339(), device_signer: self.device_signer.as_ref(), write_tier_signer: album.write_tier_signer()?, + sealer: &AlbumSealer { amk, asset_id }, + // The `original` sentinel references the original blob rather than encrypting + // anything, so it signs what the original's own manifest signs. + original: SealedDerivative { + ciphertext_hash: original.ciphertext_hash, + nonce_prefix: original.nonce_prefix, + }, + }; + // Guarded, and **not** propagated on failure. A codec refusing a frame the decoder just + // produced is a real defect, but it is this asset's derivative that is broken, not the + // workspace: the signed original, its dimensions and its placeholder are all still + // right, and failing the import would trade a missing thumbnail for a missing backup. + // Reported as `DecodeFailed` — the "a supported path produced no derivative and somebody + // should look at it" bucket — so the run summary counts it instead of staying silent. + let generated = guarded("derivatives", || { + generate_still_derivatives(&decoded, &DerivativeTier::GENERATED, &ctx) + }); + let derivatives = match generated { + Ok(derivatives) => derivatives, + Err(error) => { + tracing::warn!( + asset_id = %asset_id, + path = %src.display(), + format = %decoded.format, + width = decoded.width(), + height = decoded.height(), + %error, + "derivatives: the still decoded but no derivative could be produced from it; \ + the original, its dimensions and its placeholder are committed regardless" + ); + return Ok(PreparedStill { + format: Some(decoded.format), + dimensions, + lqip, + derivatives: Vec::new(), + deferred_formats: 0, + status: DerivativeStatus::DecodeFailed, + }); + } }; - let derivatives = - generate_still_derivatives(&decoded, plaintext, &DerivativeTier::GENERATED, &ctx) - .map_err(|e| LifecycleError::Io(format!("derivative generation: {e}")))?; Ok(PreparedStill { format: Some(decoded.format), @@ -564,10 +645,14 @@ mod tests { cbor::from_slice(&fs::read(&bundle_path).unwrap()).expect("the bundle decodes"); assert_eq!(manifests.len(), 1); let core = &manifests[0].core; - assert_eq!( + assert_ne!( core.ciphertext_hash, hash::hash_bytes(&bytes), - "the signed manifest content-addresses the bytes on disk" + "the manifest addresses the ciphertext, never the plaintext on disk" + ); + assert_ne!( + core.nonce_prefix, [0u8; 7], + "and it records the prefix that ciphertext was produced under" ); assert_eq!(core.source_asset_id, receipt.asset_id); assert_eq!( @@ -625,10 +710,28 @@ mod tests { .expect("the bundle decodes"); assert_eq!(manifests.len(), 1); assert_eq!(manifests[0].core.format, "original"); + // The sentinel references the original *blob*: it signs the original manifest's own + // ciphertext address and nonce prefix, not the plaintext's. + let state = ws.asset(&receipt.asset_id).expect("the asset is held"); + let original_core = &state + .chain + .records() + .last() + .expect("a create record") + .manifest + .core; assert_eq!( + manifests[0].core.ciphertext_hash, original_core.ciphertext_hash, + "the sentinel points at the blob a receiver already holds" + ); + assert_eq!( + manifests[0].core.nonce_prefix, original_core.nonce_prefix, + "under the same key the original was encrypted with" + ); + assert_ne!( manifests[0].core.ciphertext_hash, hash::hash_bytes(&original), - "the manifest content-addresses the original it references" + "which is not the plaintext's address" ); assert_eq!( verify_still_format(&manifests[0]), diff --git a/capsule-core/src/lifecycle/import.rs b/capsule-core/src/lifecycle/import.rs index 1a20f857..8e8a8143 100644 --- a/capsule-core/src/lifecycle/import.rs +++ b/capsule-core/src/lifecycle/import.rs @@ -390,9 +390,22 @@ impl Workspace { "import: sidecar metadata resolved" ); + let album = self.album(&album_id)?; + let epoch = album.current_epoch; + let amk = Amk::from_bytes(album.amks[&epoch]); + // First write: draw a fresh nonce prefix and derive the folded file key together + // (nothing to replace on a create). + // + // **Before** the derivatives, and that ordering is load-bearing: the `original` + // sentinel is a signed *reference* to this blob, so it commits to this ciphertext's + // address and this nonce prefix, neither of which exists until now. + let (enc, ciphertext, _file_key) = encrypt_asset_rekey(&amk, &asset_id, &plaintext, None)?; + // Still-derived sidecar metadata, from one decode pass over the plaintext: the // header-derived `content_type`, pixel `dimensions`, the chromahash `lqip`, and the - // signed thumbnail derivatives to persist once the asset's own files are durable. + // signed thumbnail derivatives to persist once the asset's own files are durable. Each + // generated derivative is encrypted under its own fresh nonce prefix as it is signed — + // derivative bytes cross the network encrypted exactly like the original. // // Never fatal. A still this build cannot decode — or cannot decode *these bytes* of — // commits exactly as before: EXIF dimensions, no LQIP, no derivatives, and a @@ -405,14 +418,7 @@ impl Workspace { derivatives, deferred_formats, status: derivative_status, - } = self.prepare_still(&plaintext, &ext, src, &exif, asset_id, album_id)?; - - let album = self.album(&album_id)?; - let epoch = album.current_epoch; - let amk = Amk::from_bytes(album.amks[&epoch]); - // First write: draw a fresh nonce prefix and derive the folded file key together - // (nothing to replace on a create). - let (enc, ciphertext, _file_key) = encrypt_asset_rekey(&amk, &asset_id, &plaintext, None)?; + } = self.prepare_still(&plaintext, &ext, src, &exif, asset_id, album_id, &amk, &enc)?; // Sealing order (1) the prior head `H` is `None` on a create; (2) author + sign the // sidecar with `provenance_chain_hash = H`. diff --git a/capsule-core/src/lifecycle/upload.rs b/capsule-core/src/lifecycle/upload.rs index f724991f..9b885817 100644 --- a/capsule-core/src/lifecycle/upload.rs +++ b/capsule-core/src/lifecycle/upload.rs @@ -18,16 +18,17 @@ use std::fs; use uuid::Uuid; -use super::{AssetState, LifecycleError, Result, Workspace, media_dir}; +use super::{AlbumKeys, AssetState, LifecycleError, Result, Workspace, media_dir}; use crate::cbor; use crate::crypto::encryption::stream; use crate::crypto::hash::{self, Hash32}; use crate::crypto::provenance::DerivativeManifest; use crate::crypto::provenance::action::{Action, DerivativeRole}; use crate::crypto::provenance::manifest::KeyMode; +use crate::media::{DerivativeFormat, verify_still_format}; -/// One derivative blob of an asset bundle: the bytes plus the content address its signed -/// [`DerivativeManifest`] committed to. +/// One derivative blob of an asset bundle: the **ciphertext** plus the content address its +/// signed [`DerivativeManifest`] committed to. #[derive(Debug, Clone)] pub struct DerivativeBlob { /// Which derivative this is (`thumbnail` / `preview` / `embedding`). @@ -36,7 +37,10 @@ pub struct DerivativeBlob { pub format: String, /// The AMK epoch the derivative manifest was authorized under, when it recorded one. pub amk_version: Option, - /// The derivative's transferable bytes. + /// The derivative's transferable bytes — **ciphertext**, re-derived from the plaintext the + /// library holds using the nonce prefix the manifest recorded. Derivative bytes are + /// encrypted client-side exactly like the original + /// ([Encryption](https://docs/design/cryptography/encryption/)). pub bytes: Vec, /// The content address the signed derivative manifest committed to. pub ciphertext_hash: Hash32, @@ -147,7 +151,7 @@ impl Workspace { return Err(LifecycleError::CiphertextMismatch(asset.asset_id)); } - let derivatives = self.derivative_blobs(asset); + let derivatives = self.derivative_blobs(asset, album, epoch); tracing::debug!( album_id = %asset.album_id, amk_version = epoch, @@ -184,11 +188,33 @@ impl Workspace { } /// The asset's persisted derivative blobs, read back from - /// `media/{YYYY}/{YYYY-MM}/derivatives/`. A derivative whose bytes are missing or no - /// longer content-address to its signed manifest is **skipped with a warning** rather - /// than failing the bundle: the original and its metadata are what a backup must not + /// `media/{YYYY}/{YYYY-MM}/derivatives/` and **encrypted** for the wire. + /// + /// The library holds derivatives as plaintext, exactly as it holds the original: the local + /// gallery paints them, and the ciphertext is re-derived here from the nonce prefix the + /// signed manifest recorded — the same round trip + /// [`upload_bundle`](Self::upload_bundle) performs for the original. So what leaves this + /// function in [`DerivativeBlob::bytes`] is ciphertext, and the field's `ciphertext_hash` + /// name is now true of it. A thumbnail is a recognisable low-resolution copy of a private + /// photo; the encryption doc admits no exception for it. + /// + /// Four reasons a manifest is skipped rather than shipped, and only one of them is quiet: + /// + /// - the `original` sentinel, which references the original blob and has no bytes of its + /// own — an **expected** absence, logged at `debug!`; + /// - a still-role `format` outside the closed set, which is the structural rejection the + /// tier table specifies; + /// - bytes missing on disk for a manifest that should have them; + /// - bytes whose re-derived ciphertext does not match the signed content address. + /// + /// None of them fails the bundle: the original and its metadata are what a backup must not /// lose, and a stale thumbnail is regenerable. - fn derivative_blobs(&self, asset: &AssetState) -> Vec { + fn derivative_blobs( + &self, + asset: &AssetState, + album: &AlbumKeys, + epoch: u32, + ) -> Vec { let dir = media_dir(&self.root, asset.capture_utc).join("derivatives"); let stem = asset.asset_id.simple().to_string(); let bundle_path = dir.join(format!("{stem}.derivatives.cbor")); @@ -209,10 +235,40 @@ impl Workspace { let mut blobs = Vec::with_capacity(manifests.len()); for manifest in manifests { + let role_name = derivative_role_name(manifest.core.role); + + // The closed-format check the tier table calls a structural rejection. It answers + // `Ok(None)` for an embedding-role manifest, whose `embedding/{model_id}` grammar + // this set deliberately does not model, so those pass through untouched. + match verify_still_format(&manifest) { + Ok(Some(DerivativeFormat::Original)) => { + // A reference to the original blob, not a missing file: the receiver + // resolves it against the original it already has. `debug!`, because an + // expected absence logged as a warning trains people to ignore warnings. + tracing::debug!( + asset_id = %asset.asset_id, + role = role_name, + "upload bundle: `original` sentinel references the original blob; \ + nothing to upload for this tier" + ); + continue; + } + Ok(_) => {} + Err(format) => { + tracing::warn!( + asset_id = %asset.asset_id, + role = role_name, + %format, + "upload bundle: still-role derivative names a format outside the closed \ + set; skipping" + ); + continue; + } + } + let core = manifest.core; - let role_name = derivative_role_name(core.role); let prefix = format!("{stem}.{role_name}."); - let Some(bytes) = read_derivative_bytes(&dir, &prefix) else { + let Some(plaintext) = read_derivative_bytes(&dir, &prefix) else { tracing::warn!( asset_id = %asset.asset_id, role = role_name, @@ -220,7 +276,16 @@ impl Workspace { ); continue; }; - let observed = hash::hash_bytes(&bytes); + + // Re-derive the ciphertext from the prefix the manifest signed. The prefix is + // folded into the file-key salt, so it selects the key as well as the nonces — + // there is exactly one ciphertext this manifest can be describing. + let key_epoch = core.amk_version.map_or(epoch, |v| v.0); + let file_key = self.file_key(album, key_epoch, &asset.asset_id, &core.nonce_prefix); + let (_, ciphertext) = + stream::encrypt_asset_vec_with_prefix(&file_key, core.nonce_prefix, &plaintext); + + let observed = hash::hash_bytes(&ciphertext); if observed != core.ciphertext_hash { tracing::warn!( asset_id = %asset.asset_id, @@ -233,7 +298,7 @@ impl Workspace { role: core.role, format: core.format, amk_version: core.amk_version.map(|v| v.0), - bytes, + bytes: ciphertext, ciphertext_hash: observed, }); } diff --git a/capsule-core/src/lifecycle/upload/tests.rs b/capsule-core/src/lifecycle/upload/tests.rs index b2fad48a..006bc9ad 100644 --- a/capsule-core/src/lifecycle/upload/tests.rs +++ b/capsule-core/src/lifecycle/upload/tests.rs @@ -173,3 +173,319 @@ fn walk_find(root: &std::path::Path, name: &str) -> bool { .filter_map(std::result::Result::ok) .any(|e| e.file_name().to_string_lossy() == name) } + +// ── Derivative blobs cross the network encrypted (S-B1 / encryption.md) ────── + +/// A library with one **decodable** asset large enough to earn a real derivative, so the +/// derivative path is exercised rather than the `original` sentinel. +/// +/// A 512x384 PNG, built here rather than committed: the repository carries no binary fixtures. +fn library_with_a_thumbnailed_asset(lib: &TempDir, src: &TempDir) -> (Uuid, Uuid) { + use rawshift_image::core::metadata::ImageMetadata; + use rawshift_image::core::{BitDepth, MetadataEmbedOptions}; + use rawshift_image::formats::encode_rgb_image_to_vec; + use rawshift_image::formats::export::{ + CommonEncodeOptions, EncodeOptions, ZunePngEncodeConfig, + }; + + let (w, h) = (512u32, 384u32); + let mut data = Vec::with_capacity((w * h * 3) as usize); + for y in 0..h { + for x in 0..w { + data.push(((x * 255 / w) as u16) * 257); + data.push(((y * 255 / h) as u16) * 257); + data.push((((x + y) * 255 / (w + h)) as u16) * 257); + } + } + let frame = rawshift_image::core::image::RgbImage::with_color_space( + w, + h, + data, + rawshift_image::core::ColorSpace::Srgb, + ); + let png = encode_rgb_image_to_vec( + &frame, + &ImageMetadata::default(), + &EncodeOptions::PngZune(ZunePngEncodeConfig { + common: CommonEncodeOptions { + metadata: MetadataEmbedOptions::none(), + bit_depth: BitDepth::Eight, + }, + ..ZunePngEncodeConfig::default() + }), + ) + .expect("the fixture PNG encodes"); + + let img = src.path().join("photo.png"); + fs::write(&img, &png).unwrap(); + let mut ws = Workspace::create_with_params(lib.path(), b"passphrase", FAST).unwrap(); + let album = ws.default_album_id(); + ws.ensure_album(album, "Imports").unwrap(); + let asset = ws.import_asset(album, &img).unwrap(); + (album, asset) +} + +/// The derivative round trip, end to end: the plaintext the library holds is **not** what the +/// bundle ships, the bundle's bytes content-address to the signed `ciphertext_hash`, and +/// decrypting them with the manifest's recorded `nonce_prefix` yields the plaintext back. +/// +/// This is the property the encryption doc states without qualification — "every asset — +/// original bytes, derivative bytes, metadata blob — is encrypted client-side" — and a thumbnail +/// is a recognisable low-resolution copy of a private photo, so shipping one in the clear would +/// hand the server the picture it is not allowed to see. +#[test] +fn derivative_blobs_ship_ciphertext_that_decrypts_to_the_bytes_on_disk() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (_album, asset_id) = library_with_a_thumbnailed_asset(&lib, &src); + + let ws = Workspace::open(lib.path(), b"passphrase", FAST).unwrap(); + let bundle = ws.upload_bundle(&asset_id).unwrap(); + assert_eq!(bundle.derivatives.len(), 1, "one encodable format today"); + let blob = &bundle.derivatives[0]; + assert_eq!(blob.format, "image/jxl"); + + // The plaintext the local gallery paints, straight off disk. + let asset = ws.asset(&asset_id).expect("the asset is held"); + let dir = media_dir(lib.path(), asset.capture_utc).join("derivatives"); + let stem = asset_id.simple().to_string(); + let plaintext = fs::read(dir.join(format!("{stem}.thumbnail.jxl"))).unwrap(); + assert_eq!( + &plaintext[..2], + b"\xFF\x0A", + "the on-disk derivative is a bare JXL codestream" + ); + + assert_ne!( + blob.bytes, plaintext, + "what crosses the network is not what sits on disk" + ); + assert_eq!( + hash::hash_bytes(&blob.bytes), + blob.ciphertext_hash, + "the blob's declared address is its own content address" + ); + + // And that address is the one the *signed* manifest committed to. + let bundle_path = dir.join(format!("{stem}.derivatives.cbor")); + let manifests: Vec = + cbor::from_slice(&fs::read(&bundle_path).unwrap()).expect("the bundle decodes"); + let core = &manifests[0].core; + assert_eq!(core.ciphertext_hash, blob.ciphertext_hash); + + // The receiver's half: the recorded prefix selects the key and the nonces. + let album_keys = ws.album(&asset.album_id).unwrap(); + let file_key = ws.file_key( + album_keys, + core.amk_version.unwrap().0, + &asset_id, + &core.nonce_prefix, + ); + let recovered = + stream::decrypt_asset_vec(&file_key, &core.nonce_prefix, &blob.bytes).expect("it opens"); + assert_eq!( + recovered, plaintext, + "and it decrypts to exactly the derivative the client holds" + ); +} + +/// A derivative whose on-disk bytes have been altered no longer re-derives to the address its +/// manifest signed, so it is skipped rather than shipped. The bundle still carries the original +/// and its metadata — a stale thumbnail is regenerable, a missing backup is not. +#[test] +fn a_tampered_derivative_is_skipped_rather_than_shipped() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (_album, asset_id) = library_with_a_thumbnailed_asset(&lib, &src); + + let ws = Workspace::open(lib.path(), b"passphrase", FAST).unwrap(); + let asset = ws.asset(&asset_id).expect("the asset is held"); + let dir = media_dir(lib.path(), asset.capture_utc).join("derivatives"); + let path = dir.join(format!("{}.thumbnail.jxl", asset_id.simple())); + + let mut bytes = fs::read(&path).unwrap(); + let last = bytes.len() - 1; + bytes[last] ^= 0x01; + fs::write(&path, &bytes).unwrap(); + + let bundle = ws.upload_bundle(&asset_id).unwrap(); + assert!( + bundle.derivatives.is_empty(), + "a derivative that does not match its signed manifest is not uploaded" + ); + assert!( + !bundle.ciphertext.is_empty(), + "and the original is still shipped — the backup is what must not be lost" + ); +} + +/// The `original` sentinel carries no bytes **by design**, so the bundle simply has no +/// derivative blob for that tier — and the skip is not a warning, because an expected absence +/// logged as a problem is how people learn to ignore warnings. +#[test] +fn the_original_sentinel_contributes_no_blob_and_is_not_an_error() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + // The 8x8 JPEG fixture is far inside the 256 px thumbnail cap, so its tier is the sentinel. + let (_album, asset_id) = library_with_one_asset(&lib, &src); + + let ws = Workspace::open(lib.path(), b"passphrase", FAST).unwrap(); + let bundle = ws.upload_bundle(&asset_id).unwrap(); + assert!( + bundle.derivatives.is_empty(), + "the sentinel references the original blob; there is nothing extra to upload" + ); +} + +/// Rewrite an asset's derivative bundle with `manifests`, returning the directory it lives in. +fn rewrite_bundle(lib: &TempDir, ws: &Workspace, asset_id: Uuid, manifests: &[DerivativeManifest]) { + let asset = ws.asset(&asset_id).expect("the asset is held"); + let dir = media_dir(lib.path(), asset.capture_utc).join("derivatives"); + fs::create_dir_all(&dir).unwrap(); + fs::write( + dir.join(format!("{}.derivatives.cbor", asset_id.simple())), + cbor::to_canonical_vec(&manifests.to_vec()).unwrap(), + ) + .unwrap(); +} + +/// Sign a derivative manifest with the given role and wire `format`, over `ciphertext_hash`. +fn signed_derivative( + asset_id: Uuid, + role: DerivativeRole, + format: &str, + ciphertext_hash: crate::crypto::hash::Hash32, +) -> DerivativeManifest { + use crate::crypto::keys::{AmkVersion, HybridSigningKey}; + use crate::crypto::primitives::{CRYPTO_SUITE_ID, PROTOCOL_VERSION}; + use crate::crypto::provenance::manifest::{DERIVATIVE_MANIFEST_VERSION, DerivativeCore}; + + let device = HybridSigningKey::from_seed_bytes(&[21; 32], &[22; 32]); + let write = HybridSigningKey::from_seed_bytes(&[23; 32], &[24; 32]); + DerivativeCore { + version: DERIVATIVE_MANIFEST_VERSION.into(), + crypto_suite_id: CRYPTO_SUITE_ID, + protocol_version: Some(PROTOCOL_VERSION.into()), + amk_version: Some(AmkVersion(1)), + source_asset_id: asset_id, + role, + format: format.into(), + ciphertext_hash, + nonce_prefix: [7, 6, 5, 4, 3, 2, 1], + generated_by_device: Uuid::from_u128(0xD1), + generated_by_client: "capsule-core/test".into(), + model_id: None, + model_version: None, + generated_at: "2026-09-02T00:00:00Z".into(), + prior_provenance_hash: None, + } + .sign(&device, &write) + .expect("signing") +} + +/// **The closed-format rule, at the boundary that ships bytes.** A still-role manifest naming a +/// format outside the committed set is a structural rejection, so its bytes never reach the +/// network — even though they are sitting on disk and hash correctly. +/// +/// The embedding role is deliberately exempt: it writes `embedding/{model_id}` into the same +/// field, a grammar this set does not model, so it must not be caught in the crossfire. +#[test] +fn a_still_role_derivative_outside_the_closed_set_is_skipped() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (_album, asset_id) = library_with_a_thumbnailed_asset(&lib, &src); + let ws = Workspace::open(lib.path(), b"passphrase", FAST).unwrap(); + + // The bytes on disk stay exactly as the import wrote them; only the manifest's format moves. + let asset = ws.asset(&asset_id).expect("held"); + let dir = media_dir(lib.path(), asset.capture_utc).join("derivatives"); + let plaintext = fs::read(dir.join(format!("{}.thumbnail.jxl", asset_id.simple()))).unwrap(); + let album_keys = ws.album(&asset.album_id).unwrap(); + let file_key = ws.file_key(album_keys, 1, &asset_id, &[7, 6, 5, 4, 3, 2, 1]); + let (_, ciphertext) = + stream::encrypt_asset_vec_with_prefix(&file_key, [7, 6, 5, 4, 3, 2, 1], &plaintext); + let address = hash::hash_bytes(&ciphertext); + + // A recognised format ships... + rewrite_bundle( + &lib, + &ws, + asset_id, + &[signed_derivative( + asset_id, + DerivativeRole::Thumbnail, + "image/jxl", + address, + )], + ); + assert_eq!( + ws.upload_bundle(&asset_id).unwrap().derivatives.len(), + 1, + "a format inside the closed set is uploaded" + ); + + // ...and an unrecognised one does not, with everything else held equal. + rewrite_bundle( + &lib, + &ws, + asset_id, + &[signed_derivative( + asset_id, + DerivativeRole::Thumbnail, + "image/future-codec", + address, + )], + ); + assert!( + ws.upload_bundle(&asset_id).unwrap().derivatives.is_empty(), + "an unrecognised still format is a structural rejection, not a blob" + ); +} + +/// A manifest with a **recognised** format and no bytes on disk is a genuine problem and is +/// skipped, which is what keeps the sentinel's quiet skip from being a blanket amnesty for +/// missing files. +#[test] +fn a_non_sentinel_manifest_with_no_bytes_is_still_skipped() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (_album, asset_id) = library_with_a_thumbnailed_asset(&lib, &src); + let ws = Workspace::open(lib.path(), b"passphrase", FAST).unwrap(); + + let asset = ws.asset(&asset_id).expect("held"); + let dir = media_dir(lib.path(), asset.capture_utc).join("derivatives"); + fs::remove_file(dir.join(format!("{}.thumbnail.jxl", asset_id.simple()))).unwrap(); + + assert!( + ws.upload_bundle(&asset_id).unwrap().derivatives.is_empty(), + "a thumbnail manifest whose bytes have gone is not shipped" + ); +} + +/// The `capsule import` acceptance case, at the bundle boundary: the bytes a push would send for +/// the thumbnail tier are **not** the bytes on disk, and their magic differs — the on-disk file +/// is a bare JXL codestream (`FF 0A`), the wire blob is STREAM ciphertext. +/// +/// The magic check is the cheap, legible version of the round trip above: it is what someone +/// eyeballing a packet capture would look for. +#[test] +fn the_pushed_thumbnail_is_not_the_jxl_on_disk() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (_album, asset_id) = library_with_a_thumbnailed_asset(&lib, &src); + + let ws = Workspace::open(lib.path(), b"passphrase", FAST).unwrap(); + let asset = ws.asset(&asset_id).expect("held"); + let dir = media_dir(lib.path(), asset.capture_utc).join("derivatives"); + let disk = fs::read(dir.join(format!("{}.thumbnail.jxl", asset_id.simple()))).unwrap(); + assert_eq!(&disk[..2], b"\xFF\x0A", "on disk: a bare JXL codestream"); + + let mut bundle = ws.upload_bundle(&asset_id).unwrap(); + let blob = bundle.derivatives.remove(0); + assert_ne!( + &blob.bytes[..2], + b"\xFF\x0A", + "on the wire: ciphertext, so it does not begin with the JXL magic" + ); + assert_ne!(blob.bytes, disk); +} diff --git a/capsule-core/src/media/derivative.rs b/capsule-core/src/media/derivative.rs index 7a4d8c1b..a70605d9 100644 --- a/capsule-core/src/media/derivative.rs +++ b/capsule-core/src/media/derivative.rs @@ -53,6 +53,7 @@ use super::error::MediaError; use super::resize::downscale_rgba8; use crate::cbor; use crate::crypto::CryptoError; +use crate::crypto::encryption::stream::NONCE_PREFIX_LEN; use crate::crypto::hash::{self, Hash32}; use crate::crypto::keys::{AmkVersion, Signer}; use crate::crypto::provenance::manifest::{DERIVATIVE_MANIFEST_VERSION, DerivativeCore}; @@ -202,6 +203,42 @@ impl fmt::Display for DerivativeTier { } } +/// What a derivative's signed manifest has to commit to about its **ciphertext**. +/// +/// Derivative bytes cross the network encrypted, exactly like the original +/// ([Encryption](https://docs/design/cryptography/encryption/) — "every asset — original bytes, +/// derivative bytes, metadata blob — is encrypted client-side"), so the content address a +/// manifest signs is the address of the *ciphertext*, and the nonce prefix that produced it is +/// signed alongside because it also selects the key. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SealedDerivative { + /// SHA-256 over the derivative ciphertext. + pub ciphertext_hash: Hash32, + /// The STREAM nonce prefix the ciphertext was produced under. + pub nonce_prefix: [u8; NONCE_PREFIX_LEN], +} + +/// The encryption seam between `media` and `lifecycle`. +/// +/// `media` owns pixels and knows nothing about album keys; `lifecycle` owns the AMK and must +/// not own codecs. A derivative's manifest cannot be signed until its ciphertext exists — the +/// hash is *of* the ciphertext — so the encryption has to happen inside generation, and this is +/// the narrowest thing that lets it: one method, no key material in any `media` signature. +/// +/// Each call must draw a **fresh** nonce prefix, so two derivatives of one asset get distinct +/// keys and distinct ciphertexts even when their plaintext is identical. +pub trait DerivativeSealer { + /// Encrypt `plaintext` under the source asset's identity and epoch, returning what the + /// manifest commits to. The ciphertext itself is discarded: clients keep the plaintext + /// derivative locally and re-derive the ciphertext at push time from the recorded prefix, + /// exactly as they do for the original. + /// + /// # Errors + /// [`MediaError::Encode`] when the encryption refuses (a drawn prefix that collides with + /// the one being replaced). + fn seal(&self, plaintext: &[u8]) -> Result; +} + /// Everything the manifest signer needs that the pixels do not carry: the asset identity, the /// epoch/authorisation context, and the two signing keys that produce the manifest's two hybrid /// signatures. @@ -224,6 +261,13 @@ pub struct DerivativeContext<'a> { pub device_signer: &'a dyn Signer, /// The per-epoch write-tier key (authorisation signature). pub write_tier_signer: &'a dyn Signer, + /// Encrypts each generated derivative so its manifest can commit to the ciphertext. + pub sealer: &'a dyn DerivativeSealer, + /// What the **original**'s own manifest committed to. The `original` sentinel generates no + /// bytes and encrypts nothing: it is a reference to the original blob, so it signs the + /// original's ciphertext address and the original's nonce prefix, and a receiver resolves it + /// against the blob it already has. + pub original: SealedDerivative, } /// One generated derivative: the encoded bytes plus its signed manifest. @@ -260,18 +304,21 @@ pub struct StillDerivatives { /// /// Per tier: /// - if the tier caps the long edge and the source is **not larger** than the cap, a single -/// `format = "original"` manifest is signed over `original_bytes` — the redundant-derivative -/// sentinel from the contract, never a re-encode; -/// - otherwise the frame is downscaled to the tier and encoded to each encodable format of -/// [`DerivativeFormat::STILL_DELIVERY_ORDER`], with the rest recorded as deferrals. +/// `format = "original"` manifest is signed as a *reference* to the original blob (no bytes, +/// nothing encrypted, committing to [`DerivativeContext::original`]) — the +/// redundant-derivative sentinel from the contract, never a re-encode; +/// - otherwise the frame is downscaled to the tier, encoded to each encodable format of +/// [`DerivativeFormat::STILL_DELIVERY_ORDER`], and **encrypted** through +/// [`DerivativeContext::sealer`] before its manifest is signed over the ciphertext's address; +/// the formats with no encoder here are recorded as deferrals. /// /// Manifests of the same role are hash-chained in generation order, so a role's derivative /// provenance is append-only exactly like the asset's. /// /// # Errors /// [`MediaError::Encode`] when a codec refuses the frame, and [`MediaError::ZeroDimension`] for -/// an empty source. A signing failure (a hardware device signer refusing) surfaces as -/// [`MediaError::Encode`] too, carrying the crypto error's message. +/// an empty source. A signing failure (a hardware device signer refusing) and a sealing failure +/// both surface as [`MediaError::Encode`] too, carrying the crypto error's message. #[tracing::instrument( level = "debug", skip_all, @@ -279,7 +326,6 @@ pub struct StillDerivatives { )] pub fn generate_still_derivatives( decoded: &DecodedImage, - original_bytes: &[u8], tiers: &[DerivativeTier], ctx: &DerivativeContext<'_>, ) -> Result { @@ -307,17 +353,18 @@ pub fn generate_still_derivatives( cap, "media: source is within the tier cap; signing the `original` sentinel" ); - // Signed **over** the original's bytes — that is what makes the manifest a - // reference to them — but carrying none of its own. See `DerivativeFormat::Original`. - let mut sentinel = sign_derivative( + // A reference to the original blob: no bytes of its own, and **nothing encrypted** + // — it commits to the original's own ciphertext address and nonce prefix, and the + // receiver resolves it against the blob it already holds. See + // `DerivativeFormat::Original`. + out.generated.push(sign_derivative( ctx, tier, DerivativeFormat::Original, - original_bytes, + Vec::new(), + ctx.original, &mut prior, - )?; - sentinel.bytes.clear(); - out.generated.push(sentinel); + )?); continue; } @@ -338,8 +385,13 @@ pub fn generate_still_derivatives( continue; } let bytes = encode(&work, format, tier)?; - out.generated - .push(sign_derivative(ctx, tier, format, &bytes, &mut prior)?); + // Encrypt before signing: the manifest's content address is the ciphertext's, so + // the ciphertext has to exist first. A fresh prefix per derivative, so two + // derivatives of one asset never share a key even for identical plaintext. + let sealed = ctx.sealer.seal(&bytes)?; + out.generated.push(sign_derivative( + ctx, tier, format, bytes, sealed, &mut prior, + )?); } } @@ -447,7 +499,12 @@ fn to_rgb_u16(frame: &RgbaImage) -> RgbImage { RgbImage::with_color_space(frame.width, frame.height, data, ColorSpace::Srgb) } -/// Build, sign and chain one derivative manifest over `bytes`. +/// Build, sign and chain one derivative manifest. +/// +/// `bytes` is the **plaintext** the client keeps on disk; `sealed` is what the manifest actually +/// commits to — the ciphertext's content address and the nonce prefix that produced it. The two +/// are separate arguments because they are separate artefacts: only one of them ever crosses the +/// network, and only the other is ever painted locally. /// /// `pub(super)` so the module's tests can exercise the chaining directly. That is not test /// convenience for its own sake: today exactly one still format is encodable, so a single call @@ -458,7 +515,8 @@ pub(super) fn sign_derivative( ctx: &DerivativeContext<'_>, tier: DerivativeTier, format: DerivativeFormat, - bytes: &[u8], + bytes: Vec, + sealed: SealedDerivative, prior: &mut Option, ) -> Result { let core = DerivativeCore { @@ -469,7 +527,10 @@ pub(super) fn sign_derivative( source_asset_id: ctx.source_asset_id, role: tier.role(), format: format.mime().into(), - ciphertext_hash: hash::hash_bytes(bytes), + // The address of the **ciphertext**, not of `bytes`: `bytes` is the plaintext kept + // locally, and what a receiver content-addresses is what crossed the network. + ciphertext_hash: sealed.ciphertext_hash, + nonce_prefix: sealed.nonce_prefix, generated_by_device: ctx.generated_by_device, generated_by_client: ctx.generated_by_client.clone(), model_id: None, @@ -494,7 +555,7 @@ pub(super) fn sign_derivative( Ok(GeneratedDerivative { tier, format, - bytes: bytes.to_vec(), + bytes, manifest, }) } diff --git a/capsule-core/src/media/mod.rs b/capsule-core/src/media/mod.rs index cd7ebe46..7b67243b 100644 --- a/capsule-core/src/media/mod.rs +++ b/capsule-core/src/media/mod.rs @@ -60,8 +60,8 @@ pub use self::decode::{ DecodedImage, Decoder, MediaMetadata, RawshiftDecoder, decode_guarded, guarded, }; pub use self::derivative::{ - DerivativeContext, DerivativeFormat, DerivativeTier, GeneratedDerivative, StillDerivatives, - generate_still_derivatives, verify_still_format, + DerivativeContext, DerivativeFormat, DerivativeSealer, DerivativeTier, GeneratedDerivative, + SealedDerivative, StillDerivatives, generate_still_derivatives, verify_still_format, }; pub use self::detect::{MAX_DECODE_PIXELS, SUPPORTED_STILL_FORMATS, StillFormat}; pub use self::error::{FormatOp, MediaError}; diff --git a/capsule-core/src/media/tests.rs b/capsule-core/src/media/tests.rs index 5efde193..7ad20279 100644 --- a/capsule-core/src/media/tests.rs +++ b/capsule-core/src/media/tests.rs @@ -28,13 +28,15 @@ use uuid::Uuid; use super::decode::{Decoder, RawshiftDecoder, decode_guarded}; use super::derivative::{ - DerivativeContext, DerivativeFormat, DerivativeTier, StillDerivatives, - generate_still_derivatives, verify_still_format, + DerivativeContext, DerivativeFormat, DerivativeSealer, DerivativeTier, SealedDerivative, + StillDerivatives, generate_still_derivatives, verify_still_format, }; use super::detect::{MAX_DECODE_PIXELS, SUPPORTED_STILL_FORMATS, StillFormat}; use super::error::{FormatOp, MediaError}; use super::resize::{capped_dimensions, downscale_rgba8}; -use crate::crypto::keys::{AmkVersion, HybridSigningKey}; +use crate::crypto::encryption::encrypt_asset_rekey; +use crate::crypto::hash::Hash32; +use crate::crypto::keys::{Amk, AmkVersion, HybridSigningKey}; use crate::crypto::primitives::{CRYPTO_SUITE_ID, PROTOCOL_VERSION}; use crate::crypto::provenance::manifest::{DERIVATIVE_MANIFEST_VERSION, DerivativeCore}; use crate::crypto::provenance::{DerivativeManifest, DerivativeRole}; @@ -895,9 +897,41 @@ fn signers() -> (HybridSigningKey, HybridSigningKey) { ) } +/// A real AMK-backed sealer — the same `encrypt_asset_rekey` construction the import path uses, +/// so these tests exercise the production encryption rather than a stand-in. +struct TestSealer { + amk: Amk, + asset_id: Uuid, +} + +impl DerivativeSealer for TestSealer { + fn seal(&self, plaintext: &[u8]) -> Result { + let (enc, _ciphertext, _key) = + encrypt_asset_rekey(&self.amk, &self.asset_id, plaintext, None).expect("sealing"); + Ok(SealedDerivative { + ciphertext_hash: enc.ciphertext_hash, + nonce_prefix: enc.nonce_prefix, + }) + } +} + +fn sealer(asset_id: Uuid) -> TestSealer { + TestSealer { + amk: Amk::from_bytes([0x5A; 32]), + asset_id, + } +} + +/// What the *original*'s own manifest committed to; the `original` sentinel signs exactly this. +const ORIGINAL_SEAL: SealedDerivative = SealedDerivative { + ciphertext_hash: Hash32([0xC1; 32]), + nonce_prefix: [1, 2, 3, 4, 5, 6, 7], +}; + fn context<'a>( device: &'a HybridSigningKey, write_tier: &'a HybridSigningKey, + sealer: &'a dyn DerivativeSealer, asset_id: Uuid, ) -> DerivativeContext<'a> { DerivativeContext { @@ -910,12 +944,15 @@ fn context<'a>( generated_at: "2026-09-01T00:00:00Z".into(), device_signer: device, write_tier_signer: write_tier, + sealer, + original: ORIGINAL_SEAL, } } fn generate(frame: &RgbaImage, original: &[u8]) -> StillDerivatives { let (device, write_tier) = signers(); - let ctx = context(&device, &write_tier, Uuid::from_u128(0xB1)); + let seal = sealer(Uuid::from_u128(0xB1)); + let ctx = context(&device, &write_tier, &seal, Uuid::from_u128(0xB1)); let decoded = RawshiftDecoder .decode(original, "png") .expect("the fixture decodes"); @@ -923,7 +960,7 @@ fn generate(frame: &RgbaImage, original: &[u8]) -> StillDerivatives { (decoded.width(), decoded.height()), (frame.width, frame.height) ); - generate_still_derivatives(&decoded, original, &DerivativeTier::GENERATED, &ctx) + generate_still_derivatives(&decoded, &DerivativeTier::GENERATED, &ctx) .expect("generation succeeds") } @@ -942,10 +979,38 @@ fn the_thumbnail_tier_encodes_jxl_and_defers_the_rest() { assert_eq!(thumb.format, DerivativeFormat::Jxl); assert_eq!(thumb.manifest.core.format, "image/jxl"); assert_eq!(thumb.manifest.core.role, DerivativeRole::Thumbnail); + // The manifest commits to the **ciphertext**, not to the plaintext on disk. Re-derive it + // the way the push path does — from the recorded prefix under the same AMK — and the + // signed address has to come back. + let seal = sealer(Uuid::from_u128(0xB1)); + let file_key = seal + .amk + .derive_file_key(&Uuid::from_u128(0xB1), &thumb.manifest.core.nonce_prefix); + let (_, ciphertext) = crate::crypto::encryption::stream::encrypt_asset_vec_with_prefix( + &file_key, + thumb.manifest.core.nonce_prefix, + &thumb.bytes, + ); assert_eq!( + thumb.manifest.core.ciphertext_hash, + crate::crypto::hash::hash_bytes(&ciphertext), + "the manifest binds the ciphertext the push path re-derives" + ); + assert_ne!( thumb.manifest.core.ciphertext_hash, crate::crypto::hash::hash_bytes(&thumb.bytes), - "the manifest binds the bytes it is signed over" + "and that is not the plaintext's address — a thumbnail is a recognisable copy of a \ + private photo and does not cross the network in the clear" + ); + assert_eq!( + crate::crypto::encryption::stream::decrypt_asset_vec( + &file_key, + &thumb.manifest.core.nonce_prefix, + &ciphertext + ) + .expect("the ciphertext authenticates"), + thumb.bytes, + "and it round-trips back to the bytes on disk" ); assert_eq!(thumb.manifest.core.version, DERIVATIVE_MANIFEST_VERSION); assert!( @@ -1030,9 +1095,13 @@ fn a_source_within_the_cap_signs_the_original_sentinel() { source's EXIF, GPS included, into a derivative blob" ); assert_eq!( - only.manifest.core.ciphertext_hash, - crate::crypto::hash::hash_bytes(&original), - "the reference is the content address the manifest signs" + only.manifest.core.ciphertext_hash, ORIGINAL_SEAL.ciphertext_hash, + "the sentinel signs the **original's** ciphertext address — the blob a receiver already \ + holds — and encrypts nothing of its own" + ); + assert_eq!( + only.manifest.core.nonce_prefix, ORIGINAL_SEAL.nonce_prefix, + "and the original's prefix, so the reference selects the same key" ); assert!( result.deferred.is_empty(), @@ -1052,14 +1121,16 @@ fn a_source_within_the_cap_signs_the_original_sentinel() { #[test] fn manifests_of_one_role_form_an_append_only_chain() { let (device, write_tier) = signers(); - let ctx = context(&device, &write_tier, Uuid::from_u128(0xB2)); + let seal = sealer(Uuid::from_u128(0xB2)); + let ctx = context(&device, &write_tier, &seal, Uuid::from_u128(0xB2)); let mut prior = None; let first = super::derivative::sign_derivative( &ctx, DerivativeTier::Thumbnail, - DerivativeFormat::WebP, - b"first generation bytes", + DerivativeFormat::Jxl, + b"first generation bytes".to_vec(), + seal.seal(b"first generation bytes").expect("sealing"), &mut prior, ) .expect("signing the first manifest"); @@ -1080,8 +1151,9 @@ fn manifests_of_one_role_form_an_append_only_chain() { let second = super::derivative::sign_derivative( &ctx, DerivativeTier::Thumbnail, - DerivativeFormat::WebP, - b"second generation bytes", + DerivativeFormat::Jxl, + b"second generation bytes".to_vec(), + seal.seal(b"second generation bytes").expect("sealing"), &mut prior, ) .expect("signing the second manifest"); @@ -1113,11 +1185,11 @@ fn each_tier_starts_its_own_role_chain() { let (device, write_tier) = signers(); let decoded = RawshiftDecoder.decode(&original, "png").expect("decode"); + let seal = sealer(Uuid::from_u128(0xB5)); let both = generate_still_derivatives( &decoded, - &original, &[DerivativeTier::Thumbnail, DerivativeTier::Preview], - &context(&device, &write_tier, Uuid::from_u128(0xB5)), + &context(&device, &write_tier, &seal, Uuid::from_u128(0xB5)), ) .expect("both tiers"); @@ -1149,6 +1221,55 @@ fn each_tier_starts_its_own_role_chain() { assert_eq!((back.width(), back.height()), (512, 384)); } +/// Two derivatives of one asset get **distinct** nonce prefixes, and therefore distinct keys and +/// distinct ciphertexts, even when their plaintext is byte-identical. +/// +/// This is the property that makes per-derivative sealing worth doing rather than reusing the +/// original's key: a shared prefix would reuse a keystream across two blobs under one file key, +/// which is the failure the encryption doc's per-file derivation exists to prevent. +#[test] +fn two_derivatives_of_one_asset_never_share_a_nonce_prefix() { + let (device, write_tier) = signers(); + let asset_id = Uuid::from_u128(0xB6); + let seal = sealer(asset_id); + let ctx = context(&device, &write_tier, &seal, asset_id); + let identical = b"byte-identical derivative plaintext".to_vec(); + + let mut prior = None; + let first = super::derivative::sign_derivative( + &ctx, + DerivativeTier::Thumbnail, + DerivativeFormat::Jxl, + identical.clone(), + seal.seal(&identical).expect("sealing"), + &mut prior, + ) + .expect("first"); + let mut prior = None; + let second = super::derivative::sign_derivative( + &ctx, + DerivativeTier::Preview, + DerivativeFormat::Jxl, + identical.clone(), + seal.seal(&identical).expect("sealing"), + &mut prior, + ) + .expect("second"); + + assert_eq!( + first.bytes, second.bytes, + "the plaintext really is identical" + ); + assert_ne!( + first.manifest.core.nonce_prefix, second.manifest.core.nonce_prefix, + "a fresh prefix is drawn per derivative" + ); + assert_ne!( + first.manifest.core.ciphertext_hash, second.manifest.core.ciphertext_hash, + "so identical plaintext does not produce a shared ciphertext or a shared key" + ); +} + /// **The privacy case.** A thumbnail must not inherit the source's EXIF, and above all not its /// GPS fix. /// @@ -1183,11 +1304,11 @@ fn a_thumbnail_carries_no_exif_and_no_gps() { // Capsule's derivative, over the same GPS-bearing source. let (device, write_tier) = signers(); let decoded = RawshiftDecoder.decode(&source, "jpg").expect("decode"); + let seal = sealer(Uuid::from_u128(0xB3)); let result = generate_still_derivatives( &decoded, - &source, &DerivativeTier::GENERATED, - &context(&device, &write_tier, Uuid::from_u128(0xB3)), + &context(&device, &write_tier, &seal, Uuid::from_u128(0xB3)), ) .expect("generation"); let thumb = &result.generated[0].bytes; @@ -1275,6 +1396,7 @@ fn verification_rejects_an_unrecognised_still_format() { role, format: format.into(), ciphertext_hash: crate::crypto::hash::hash_bytes(b"bytes"), + nonce_prefix: [9, 8, 7, 6, 5, 4, 3], generated_by_device: Uuid::from_u128(0xD1), generated_by_client: "capsule-core/test".into(), model_id: None, diff --git a/capsule-sdk/src/push.rs b/capsule-sdk/src/push.rs index 2d82379d..4b2ad5b4 100644 --- a/capsule-sdk/src/push.rs +++ b/capsule-sdk/src/push.rs @@ -14,6 +14,12 @@ //! returns the authoritative offset; across an asset's blobs, a `duplicate_blob` answer is a //! merge, not an error. Re-running a push against an unchanged library is therefore a no-op. //! +//! **Every blob this module ships is ciphertext.** The original is re-derived from its +//! manifest's nonce prefix, the metadata blob is carried sealed, and **derivative blobs are +//! encrypted too** — `capsule-core` re-derives each one from the plaintext it holds locally +//! using the prefix that derivative's signed manifest recorded. Nothing here decrypts, encrypts, +//! or inspects a blob; it moves opaque bytes. +//! //! **One deviation from "the envelope mirrors the signed manifest", and it is the server's //! rule:** invariant 15 requires `manifest_envelope.ciphertext_hash == hash` (the top-level //! declared content address of *this* blob). A bundle's metadata and derivative blobs are not From 0e481d654144d22e0fc2eb130f2ab0936beee104 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 01:43:34 -0400 Subject: [PATCH 082/243] refactor(core): group prepare_still's file inputs into StillSource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `prepare_still` reached nine parameters when the AMK and the original's committed pair joined it, and clippy's `too_many_arguments` is right about what that means here: the signature had grown two *kinds* of input — the file being imported, and the crypto identity it commits under — without saying so. The four file facts (`plaintext`, `ext`, `src`, `exif`) become `StillSource`. They are one thing, always passed together, and naming them makes the remaining parameters read as the identity half. Silencing the lint would have kept the signature and hidden the reason it grew. --- capsule-core/src/lifecycle/derivatives.rs | 33 ++++++++++++++++++----- capsule-core/src/lifecycle/import.rs | 15 +++++++++-- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/capsule-core/src/lifecycle/derivatives.rs b/capsule-core/src/lifecycle/derivatives.rs index c2be37c4..db6c8b91 100644 --- a/capsule-core/src/lifecycle/derivatives.rs +++ b/capsule-core/src/lifecycle/derivatives.rs @@ -24,7 +24,7 @@ use std::path::Path; use uuid::Uuid; -use super::{AssetState, DerivativeStatus, LifecycleError, Result, Workspace, media_dir}; +use super::{AssetState, DerivativeStatus, Result, Workspace, media_dir}; use crate::cbor; use crate::crypto::encryption::encrypt_asset_rekey; use crate::crypto::encryption::stream::AssetEncryption; @@ -39,6 +39,24 @@ use crate::media::{ }; use crate::sidecar::sidecar_v1::{Dimensions, Lqip as SidecarLqip}; +/// The file under import, as [`prepare_still`](Workspace::prepare_still) needs to see it. +/// +/// A parameter object rather than four positional arguments: these four are one thing — the +/// bytes on the way in and what the scanner already learned about them — and they are always +/// passed together. The alternative was silencing `clippy::too_many_arguments`, which would have +/// hidden that the signature had grown two *kinds* of input (the file, and the crypto identity +/// it commits under) without saying so. +pub(super) struct StillSource<'a> { + /// The file's bytes. + pub(super) plaintext: &'a [u8], + /// Its lowercase extension without the dot, `""` when it has none. + pub(super) ext: &'a str, + /// Where it came from — for logs only; the bytes above are authoritative. + pub(super) src: &'a Path, + /// What `capsule_core::exif` read off it, the fallback for dimensions. + pub(super) exif: &'a ExifExtract, +} + /// Everything one still yields in a single decode pass: the sidecar fields, the signed /// derivatives to persist after the durable commit, and the reason for anything missing. pub(super) struct PreparedStill { @@ -197,19 +215,22 @@ impl Workspace { #[tracing::instrument( level = "debug", skip_all, - fields(asset_id = %asset_id, src = %src.display(), bytes = plaintext.len()) + fields(asset_id = %asset_id, src = %source.src.display(), bytes = source.plaintext.len()) )] pub(super) fn prepare_still( &self, - plaintext: &[u8], - ext: &str, - src: &Path, - exif: &ExifExtract, + source: &StillSource<'_>, asset_id: Uuid, album_id: Uuid, amk: &Amk, original: &AssetEncryption, ) -> Result { + let StillSource { + plaintext, + ext, + src, + exif, + } = *source; let exif_dimensions = exif .width .zip(exif.height) diff --git a/capsule-core/src/lifecycle/import.rs b/capsule-core/src/lifecycle/import.rs index 8e8a8143..985e1a0d 100644 --- a/capsule-core/src/lifecycle/import.rs +++ b/capsule-core/src/lifecycle/import.rs @@ -8,7 +8,7 @@ use std::path::Path; use jiff::Timestamp; use uuid::Uuid; -use super::derivatives::PreparedStill; +use super::derivatives::{PreparedStill, StillSource}; use super::{ AssetState, LifecycleError, Result, SidecarEnrichment, SignedImport, SignedImportOptions, StackPlacement, StreamedImport, Workspace, asset_is_deleted, media_dir, now_rfc3339, @@ -418,7 +418,18 @@ impl Workspace { derivatives, deferred_formats, status: derivative_status, - } = self.prepare_still(&plaintext, &ext, src, &exif, asset_id, album_id, &amk, &enc)?; + } = self.prepare_still( + &StillSource { + plaintext: &plaintext, + ext: &ext, + src, + exif: &exif, + }, + asset_id, + album_id, + &amk, + &enc, + )?; // Sealing order (1) the prior head `H` is `None` on a create; (2) author + sign the // sidecar with `provenance_chain_hash = H`. From de756e901fe35fbd75cf1d4d19311342d167b60d Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 02:02:58 -0400 Subject: [PATCH 083/243] fix(core): keep an encoder refusal from costing the original, and widen the guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1 findings F1-F16. The two that mattered: **F1 (critical).** `prepare_still` propagated any derivative-generation failure as `LifecycleError::Io`, and it did so *before* the asset's files were written — so an encoder refusing a frame lost the original from the backup entirely. That contradicted this module's own header, `S-B13`, and the decision recorded for it. `MediaError` gains a `Sign` variant so a workspace fault (a hardware signer refusing, a missing epoch key) is distinguishable at the type level from a codec refusing pixels. Only the former propagates; every codec, resize and encode failure degrades to `DerivativeStatus::DecodeFailed` with the real dimensions and placeholder kept, and the import commits. **F2 (high).** The unwind boundary guarded only the decode, while the chromahash placeholder and the JXL encode — both pre-1.0, both running on the same untrusted pixels — ran bare, so one panicking frame could abort a twenty-thousand-photo import part way through. Every stage that runs foreign code over pixels is now guarded, with the stage named so a caught unwind is attributable. `guarded` is `pub(crate)`: `lifecycle` is its only caller and no client of this crate has pixels of its own. The rest: - **F9** `DerivativeFormat` and `verify_still_format` move to an unconditional crate-root module. They were behind the `media` feature, which `native` implies — so `capsule-server` and `capsule-wasm`, the two crates that *receive* a manifest they did not author, could not link the check at all. A closed set only its producer can evaluate is not a closed set. `media` re-exports both names. - **F5** derivative bytes are addressed by `(role, format)`, not by a role prefix that took whichever filename sorted first — which would have silently skipped both variants the moment AVIF lands beside JXL. - **F14** a role's chain continues across generation runs instead of restarting per invocation, so a backfill extends the record rather than forking it. - **F6** the 32-bit overflow test now genuinely crosses `u32::MAX` (its arithmetic was off by 1000x), and the boundary-product claim is restated as the defensive measure it actually is. - **F13** the HEIC executor fixture carries a real `ftyp` header, so the test exercises the byte sniffing its own docs claim rather than the extension fallback. - **F11** `decodeLqip` no longer throws on a malformed record: it paints the same fallback fill the native FFI paints. One record answered two ways by two clients is the divergence `capsule-core::lqip` exists to prevent. - **F12** JPEG/PNG **encoders** move to `[dev-dependencies]`; only the fixtures used them, and shipping them put `jpeg-encoder`'s conjunctive IJG arm into every release binary. `cargo tree -e normal -i jpeg-encoder` is now empty while `cargo deny --all-features` still sees it, so the exception stays matched. - **F7, F8, F10** stale docs: the budget is 128 Mpx in `SLICES.md`, and the `libwebp`/"vendored C" claims left over from before the JXL swap are corrected. --- SLICES.md | 9 +- capsule-core/Cargo.toml | 21 +- capsule-core/src/derivative_format.rs | 161 ++++++++++++ capsule-core/src/import/executor.rs | 8 +- capsule-core/src/import/progress.rs | 6 +- capsule-core/src/lib.rs | 7 + capsule-core/src/lifecycle/derivatives.rs | 73 +++++- capsule-core/src/lifecycle/import.rs | 1 + capsule-core/src/lifecycle/mod.rs | 4 +- capsule-core/src/lifecycle/upload.rs | 38 +-- capsule-core/src/lifecycle/upload/tests.rs | 110 ++++++++ capsule-core/src/media/decode.rs | 10 +- capsule-core/src/media/derivative.rs | 143 ++-------- capsule-core/src/media/error.rs | 16 +- capsule-core/src/media/mod.rs | 13 +- capsule-core/src/media/resize.rs | 8 +- capsule-core/src/media/tests.rs | 244 ++++++++++++++++-- .../src/content/docs/design/dependencies.md | 2 +- .../src/content/docs/design/thumbnails.md | 4 +- capsule-wasm/src/lib.rs | 88 ++++--- 20 files changed, 736 insertions(+), 230 deletions(-) create mode 100644 capsule-core/src/derivative_format.rs diff --git a/SLICES.md b/SLICES.md index d65fa80d..ce9f8bca 100644 --- a/SLICES.md +++ b/SLICES.md @@ -704,7 +704,7 @@ workspace at all**, so every still import is a `DeferredNoCodec` until Rawshift because core linked no codec, and it no longer needs to. What ships: - `media::{detect,decode,resize,derivative,error}` as private submodules behind one barrel — the closed `StillFormat` set with a Capsule-owned magic-byte table, the `Decoder` seam with - a pre-decode 256 Mpx budget and an unwind boundary, a deterministic integer area-average + a pre-decode 128 Mpx budget and an unwind boundary, a deterministic integer area-average downscale (the crate has no resize, and a derivative's bytes are signed), the closed `DerivativeFormat` set with the `original` sentinel, and `MediaError`. - the **thumbnail tier** at 256 px as **JXL** — the table's committed *master* format — signed @@ -1006,7 +1006,12 @@ workspace at all**, so every still import is a `DeferredNoCodec` until Rawshift one generated thumbnail and two deferred formats — the number that falls to zero as #437 lands, rather than a gap only a doc mentions. - **No panic can reach an import.** Untrusted bytes go through a pre-decode pixel budget - (`MAX_DECODE_PIXELS`, 256 Mpx — the bomb is inside the decoder, which works in RGB `u16`) and + (`MAX_DECODE_PIXELS`, **128 Mpx** — the bomb is inside the decoder, which works in RGB `u16`, + and the honest peak at that ceiling is ~2.5 GB across the decoder's samples, the + alpha-dropping realloc, the RGBA8 copy and the widening back for the encode. `native` implies + `media`, so that peak lands on a phone as an OOM kill rather than an error, which is why the + ceiling sits ~25% above a 102 Mpx medium-format frame rather than as high as an allocation + bomb would require) and a `catch_unwind` boundary that maps a third-party decoder's panic to `DecodeFailed`. Both are tested, the panic case through an injected `Decoder`. - **Originals always import**, unchanged: codec coverage gates *derivatives*, never *admission*. diff --git a/capsule-core/Cargo.toml b/capsule-core/Cargo.toml index c1b70a0f..24b11e0b 100644 --- a/capsule-core/Cargo.toml +++ b/capsule-core/Cargo.toml @@ -92,7 +92,12 @@ kamadak-exif = "0.5" # (`rawshift-image`'s own docs say so), and the format set is a licence, build-host and # *portability* decision: # -# - `jpeg` / `png` — pure-Rust zune decode **and** encode; the two formats every library holds. +# - `jpeg-decode` / `png-decode` — pure-Rust zune decode for the two formats every library holds. +# **Decode only in the shipping graph.** Their encoders exist and only the +# test fixtures use them, so they ride `[dev-dependencies]` below: shipping +# `jpeg-encode` would put `jpeg-encoder` — and its conjunctive IJG licence +# arm, which cannot be elected away — into every release binary to satisfy +# nothing a user does. # - `jxl` — jxl-oxide decode plus the `zune-jpegxl` encoder that produces the thumbnail # tier. Pure Rust, and `image/jxl` is the tier table's committed *master* # format. The backend is `JxlSimpleEncoder`, which is lossless — a thumbnail @@ -114,8 +119,8 @@ kamadak-exif = "0.5" # `media::MediaError::UnsupportedFormat` today rather than a silent gap. MPL-2.0, already # allow-listed in `deny.toml`; see the Media row in design/dependencies.md. rawshift-image = { version = "0.1.1", default-features = false, features = [ - "jpeg", - "png", + "jpeg-decode", + "png-decode", "jxl", "tiff-decode", "gif-decode", @@ -231,6 +236,16 @@ getrandom_04 = { package = "getrandom", version = "0.4", features = ["wasm_js"] uuid = { workspace = true, features = ["rng-getrandom"] } [dev-dependencies] +# The encoders the fixtures need and the shipping graph does not. Cargo unifies features per +# build, so a `cargo build` links decode only while `cargo test` gets the encoders — which keeps +# `jpeg-encoder`'s IJG arm out of every release binary while leaving it in the graph +# `cargo deny --all-features` inspects, so its `deny.toml` exception and NOTICE section stay +# matched rather than becoming stale. The `media` feature is named explicitly because a +# dev-dependency does not inherit the optional dependency's gate. +rawshift-image = { version = "0.1.1", default-features = false, features = [ + "jpeg", + "png", +] } tempfile = "3" # Integration tests (e.g. the S-D14 placement audit) build UUIDs to exercise the `library::paths` # surface; `uuid` is a normal dependency, so the test crate needs its own dev-dependency on it. diff --git a/capsule-core/src/derivative_format.rs b/capsule-core/src/derivative_format.rs new file mode 100644 index 00000000..18570be0 --- /dev/null +++ b/capsule-core/src/derivative_format.rs @@ -0,0 +1,161 @@ +//! The closed set of committed still-derivative formats, and the structural check over it. +//! +//! SSoT: [Thumbnails and Previews](https://docs/design/thumbnails/) — the tier table's format +//! column *is* this enum, and "every receiver (and every federated peer) compares +//! `DerivativeManifest.format` against this list" is [`verify_still_format`]. +//! +//! # Why this is at the crate root and not in `capsule_core::media` +//! +//! It was in `media` first, and that was a placement mistake rather than a constraint. `media` +//! is behind the `media` feature that `native` implies, so `capsule-server` and `capsule-wasm` +//! — both `default-features = false` — cannot link it. Those are exactly the two crates that +//! *receive* a manifest they did not author, which is where a structural rejection has to run. +//! A closed set only a producer can evaluate is not a closed set. +//! +//! So it lives here, beside nothing and depending on nothing but +//! [`crate::crypto::provenance`], which is itself unconditional for the same reason. `media` +//! re-exports both names, so every existing `media::DerivativeFormat` path still resolves. +//! +//! This mirrors [`crate::lqip`]: a contract every surface needs cannot live inside a +//! feature-gated stack, however natural the stack looks as a home. + +use std::fmt; + +use crate::crypto::provenance::{DerivativeManifest, DerivativeRole}; + +/// The closed set of committed still-derivative formats — the tier table's format column. +/// +/// The wire value is [`mime`](Self::mime), carried in `DerivativeManifest.format`. A value +/// outside this set is a structural rejection, never a "future format to ignore". +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DerivativeFormat { + /// **JPEG XL** — the committed primary/master still codec, and the one format this build + /// encodes. Losslessly: the pure-Rust backend is `zune-jpegxl`'s `JxlSimpleEncoder`. + Jxl, + /// **AVIF** — the universal delivery format for clients without a JXL decoder. Not + /// encodable in this build. + Avif, + /// **WebP** — the last-resort delivery fallback. Not encodable in this build: the crate's + /// WebP codec does not compile for aarch64 (see [`super::StillFormat::WebP`]). + WebP, + /// The recognised `format = "original"` sentinel: the tier **references** the original asset + /// rather than generating a redundant derivative, because the source is not larger than the + /// tier's cap. **Distinct from an absent derivative** — this is an explicit, signed marker, + /// where absence means "rebuildable from the original". + /// + /// A sentinel derivative carries **no bytes of its own** ([`GeneratedDerivative::bytes`] is + /// empty). "References" is the operative word in the contract: the signed manifest's + /// `ciphertext_hash` content-addresses the original, which the holder already has, so + /// copying the bytes under a thumbnail's name would duplicate a file sitting two directories + /// up *and* re-expose the original's EXIF — GPS included — as a derivative blob, where a + /// re-encoded thumbnail is metadata-free by construction. + Original, +} + +impl DerivativeFormat { + /// The committed still formats per tier, in delivery-preference order: the JXL master, then + /// the AVIF -> WebP delivery variants. + pub const STILL_DELIVERY_ORDER: [Self; 3] = [Self::Jxl, Self::Avif, Self::WebP]; + + /// The exact wire string for `DerivativeManifest.format`. + pub const fn mime(self) -> &'static str { + match self { + Self::Jxl => "image/jxl", + Self::Avif => "image/avif", + Self::WebP => "image/webp", + Self::Original => "original", + } + } + + /// The on-disk file extension for a persisted derivative of this format. `Original` has + /// none of its own — it reuses the source asset's. + pub const fn extension(self) -> Option<&'static str> { + match self { + Self::Jxl => Some("jxl"), + Self::Avif => Some("avif"), + Self::WebP => Some("webp"), + Self::Original => None, + } + } + + /// Parse a `DerivativeManifest.format` value against the closed set. `None` **is** the + /// structural rejection. + pub fn parse(s: &str) -> Option { + match s { + "image/jxl" => Some(Self::Jxl), + "image/avif" => Some(Self::Avif), + "image/webp" => Some(Self::WebP), + "original" => Some(Self::Original), + _ => None, + } + } + + /// Whether a `format` string names a currently-recognised still-derivative format — the + /// exact check a receiver runs. + pub fn is_recognized(s: &str) -> bool { + Self::parse(s).is_some() + } + + /// Whether this build can produce bytes in this format. + pub const fn is_encodable(self) -> bool { + matches!(self, Self::Jxl | Self::Original) + } +} + +impl fmt::Display for DerivativeFormat { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.mime()) + } +} + +/// The closed-set check a receiver runs on a still-role derivative manifest. +/// +/// Returns the parsed format for a `thumbnail` or `preview` manifest whose `format` is in the +/// closed set. An embedding-role manifest is **not** rejected: its `format` is +/// `embedding/{model_id}`, which this set deliberately does not model, so it is reported as +/// [`None`] rather than as a violation. +/// +/// # Errors +/// [`MediaError::UnsupportedFormat`] — carrying the still format Capsule *would* have needed — +/// is not what an unrecognised value produces, because there is no [`super::StillFormat`] to +/// name. An unrecognised still-role format is `Err(format.to_string())`. +pub fn verify_still_format( + manifest: &DerivativeManifest, +) -> Result, String> { + let core = &manifest.core; + match core.role { + DerivativeRole::Thumbnail | DerivativeRole::Preview => { + DerivativeFormat::parse(&core.format) + .map(Some) + .ok_or_else(|| core.format.clone()) + } + // Not a still. The embedding-role format grammar belongs to `crate::ml`. + DerivativeRole::Embedding => Ok(None), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// **The reason this module is not in `media`.** These tests compile and run under + /// `--no-default-features`, which is how `capsule-server` and `capsule-wasm` build — the two + /// crates that receive a `DerivativeManifest` they did not author. A closed set only its + /// producer can evaluate is not a closed set. + /// + /// This test asserts nothing a caller could not, and that is the point: it exists so the + /// *linkage* is exercised by the `--no-default-features` build rather than assumed. + #[test] + fn the_closed_set_is_evaluable_without_the_media_feature() { + for format in [ + DerivativeFormat::Jxl, + DerivativeFormat::Avif, + DerivativeFormat::WebP, + DerivativeFormat::Original, + ] { + assert_eq!(DerivativeFormat::parse(format.mime()), Some(format)); + } + assert!(!DerivativeFormat::is_recognized("image/future-codec")); + assert!(!DerivativeFormat::is_recognized("embedding/mobileclip-b")); + } +} diff --git a/capsule-core/src/import/executor.rs b/capsule-core/src/import/executor.rs index 9560d827..f15bc4e3 100644 --- a/capsule-core/src/import/executor.rs +++ b/capsule-core/src/import/executor.rs @@ -437,7 +437,13 @@ mod tests { let src = TempDir::new().unwrap(); let lib_dir = TempDir::new().unwrap(); - fs::write(src.path().join("iphone.heic"), b"fake heic bytes").unwrap(); + // A real ISO-BMFF `ftyp heic` header, so the classification rests on the **bytes** — + // which is what the doc above claims. `b"fake heic bytes"` carried no `ftyp` and + // silently exercised the extension fallback instead. + let mut heic = vec![0, 0, 0, 0x20]; + heic.extend_from_slice(b"ftypheic"); + heic.extend_from_slice(&[0; 16]); + fs::write(src.path().join("iphone.heic"), &heic).unwrap(); fs::write(src.path().join("snap.jpg"), b"not really a jpeg").unwrap(); let mut ws = signed_workspace(lib_dir.path()); diff --git a/capsule-core/src/import/progress.rs b/capsule-core/src/import/progress.rs index e8f7bc0a..2a4a1691 100644 --- a/capsule-core/src/import/progress.rs +++ b/capsule-core/src/import/progress.rs @@ -13,9 +13,9 @@ pub enum ImportOutcome { Imported { derivatives: DerivativeStatus, /// How many `(tier, format)` pairs the tier table commits to and this build cannot - /// encode. Orthogonal to `derivatives`: a `Decoded` asset with a renderable WebP - /// thumbnail still reports the JXL master and the AVIF delivery variant as deferred, and - /// that count is how the gap shrinks visibly as codecs land rather than silently. + /// encode. Orthogonal to `derivatives`: a `Decoded` asset with a renderable JXL + /// thumbnail still reports the AVIF delivery variant and WebP as deferred, and that + /// count is how the gap shrinks visibly as codecs land rather than silently. deferred_formats: u32, }, DuplicateSkipped { diff --git a/capsule-core/src/lib.rs b/capsule-core/src/lib.rs index 82defce2..034395fd 100644 --- a/capsule-core/src/lib.rs +++ b/capsule-core/src/lib.rs @@ -19,6 +19,13 @@ pub mod sharing; /// build-embedded git commit (S-D15). Always compiled: pure string formatting, no native deps. pub mod client_build; +/// The closed set of still-derivative formats and the structural check over it — the tier +/// table's format column as a type. Always compiled, and for the same reason [`lqip`] is: the +/// crates that *receive* a `DerivativeManifest` (`capsule-server`, `capsule-wasm`) build with +/// `default-features = false`, so a check they cannot link is a check that never runs. Depends +/// only on [`crypto::provenance`]; `media` re-exports it. +pub mod derivative_format; + /// LQIP — the chromahash placeholder carried in the signed sidecar's `lqip` field (S-B14). /// Always compiled, and deliberately so: the placeholder is produced by the import pipeline, /// read by the apps through the uniffi FFI, and read by the browser through `capsule-wasm`, so diff --git a/capsule-core/src/lifecycle/derivatives.rs b/capsule-core/src/lifecycle/derivatives.rs index db6c8b91..d7bb2e16 100644 --- a/capsule-core/src/lifecycle/derivatives.rs +++ b/capsule-core/src/lifecycle/derivatives.rs @@ -19,17 +19,20 @@ //! that mean the *workspace* is broken — a missing album, a signer that refused — not the ones //! that mean the pixels were unreadable. +use std::collections::HashMap; use std::fs; use std::path::Path; use uuid::Uuid; -use super::{AssetState, DerivativeStatus, Result, Workspace, media_dir}; +use super::{AssetState, DerivativeStatus, LifecycleError, Result, Workspace, media_dir}; use crate::cbor; use crate::crypto::encryption::encrypt_asset_rekey; use crate::crypto::encryption::stream::AssetEncryption; +use crate::crypto::hash::{self, Hash32}; use crate::crypto::keys::{Amk, AmkVersion}; use crate::crypto::primitives::{CRYPTO_SUITE_ID, PROTOCOL_VERSION}; +use crate::crypto::provenance::{DerivativeManifest, DerivativeRole}; use crate::exif::extract::ExifExtract; use crate::lqip::Lqip; use crate::media::{ @@ -174,6 +177,45 @@ fn lqip_from(decoded: &DecodedImage, src: &Path) -> Option { } } +/// The current head of each derivative role's chain for `asset_id`, read off the persisted +/// bundle. +/// +/// Empty when the asset has no bundle yet, which is every import: a create starts each role's +/// chain. It is a **regeneration** — the `#437` backfill that adds a second format to an asset +/// that already has one — that needs this, and it needs it to be right the first time, because a +/// forked chain is not something a later run can repair. +/// +/// The link is SHA-256 over the manifest's canonical CBOR, signatures included: the same +/// content-hash link the asset provenance chain uses. +pub(super) fn chain_heads(dir: &Path, asset_id: Uuid) -> HashMap { + let path = dir.join(format!("{}.derivatives.cbor", asset_id.simple())); + let Ok(bytes) = fs::read(&path) else { + return HashMap::new(); + }; + let Ok(manifests) = cbor::from_slice::>(&bytes) else { + tracing::warn!( + path = %path.display(), + "derivatives: undecodable bundle; treating every role's chain as unstarted" + ); + return HashMap::new(); + }; + // Generation order is the chain order, so the last manifest of a role is that role's head. + let mut heads = HashMap::new(); + for manifest in &manifests { + match cbor::to_canonical_vec(manifest) { + Ok(canonical) => { + heads.insert(manifest.core.role, hash::hash_bytes(&canonical)); + } + Err(error) => tracing::warn!( + %error, + "derivatives: a persisted manifest did not re-serialise; its role's chain \ + restarts rather than linking to something unverifiable" + ), + } + } + heads +} + /// The album-key half of derivative generation: `media` produces the bytes, this encrypts them. /// /// One `encrypt_asset_rekey` per derivative under the **source asset's** `file_id` and the @@ -191,8 +233,7 @@ impl DerivativeSealer for AlbumSealer<'_> { fn seal(&self, plaintext: &[u8]) -> std::result::Result { let (enc, _ciphertext, _file_key) = encrypt_asset_rekey(self.amk, &self.asset_id, plaintext, None).map_err(|e| { - MediaError::Encode { - format: crate::media::DerivativeFormat::Original, + MediaError::Sign { detail: format!("sealing the derivative: {e}"), } })?; @@ -222,6 +263,7 @@ impl Workspace { source: &StillSource<'_>, asset_id: Uuid, album_id: Uuid, + capture_utc: i64, amk: &Amk, original: &AssetEncryption, ) -> Result { @@ -282,6 +324,11 @@ impl Workspace { device_signer: self.device_signer.as_ref(), write_tier_signer: album.write_tier_signer()?, sealer: &AlbumSealer { amk, asset_id }, + // Empty on a create; a regeneration continues each role's chain from here. + prior_heads: &chain_heads( + &media_dir(&self.root, capture_utc).join("derivatives"), + asset_id, + ), // The `original` sentinel references the original blob rather than encrypting // anything, so it signs what the original's own manifest signs. original: SealedDerivative { @@ -300,6 +347,26 @@ impl Workspace { }); let derivatives = match generated { Ok(derivatives) => derivatives, + // **A signing fault is the workspace's, not this asset's.** A hardware signer that + // refuses, or a missing epoch write-tier key, is the same fault that would stop the + // asset's own manifest being authored — degrading it to "no thumbnail" would hide a + // broken workspace behind a cosmetic gap. It propagates. + Err(error @ MediaError::Sign { .. }) => { + tracing::error!( + asset_id = %asset_id, + path = %src.display(), + %error, + "derivatives: the workspace could not author a signed derivative record" + ); + return Err(LifecycleError::Io(format!("derivative signing: {error}"))); + } + // Everything else is about *pixels*: a codec refused a frame, a resize was rejected, + // a third-party encoder panicked. The signed original, its dimensions and its + // placeholder are all still right, and failing the import would trade a missing + // thumbnail for a missing backup — which is the whole of `S-B13`'s reasoning and + // this module's stated contract. Reported as `DecodeFailed`, the "a supported path + // produced no derivative and somebody should look at it" bucket, so the run summary + // counts it instead of staying silent. Err(error) => { tracing::warn!( asset_id = %asset_id, diff --git a/capsule-core/src/lifecycle/import.rs b/capsule-core/src/lifecycle/import.rs index 985e1a0d..58aee402 100644 --- a/capsule-core/src/lifecycle/import.rs +++ b/capsule-core/src/lifecycle/import.rs @@ -427,6 +427,7 @@ impl Workspace { }, asset_id, album_id, + capture_utc, &amk, &enc, )?; diff --git a/capsule-core/src/lifecycle/mod.rs b/capsule-core/src/lifecycle/mod.rs index c610a4aa..cea75eff 100644 --- a/capsule-core/src/lifecycle/mod.rs +++ b/capsule-core/src/lifecycle/mod.rs @@ -298,8 +298,8 @@ pub struct SignedImportOptions { pub enum DerivativeStatus { /// The still decoded: `dimensions` and `lqip` came from real pixels, and the derivatives /// this build can encode were generated and signed. **Independent of how many *formats* - /// deferred** — a decoded still whose JXL and AVIF variants have no encoder here is still - /// `Decoded`, because it has a renderable thumbnail. The per-format gap is counted + /// deferred** — a decoded still whose AVIF and WebP variants have no encoder here is still + /// `Decoded`, because it has a renderable JXL thumbnail. The per-format gap is counted /// separately by /// [`ImportExecutionSummary::deferred_format_count`](crate::import::ImportExecutionSummary::deferred_format_count). Decoded, diff --git a/capsule-core/src/lifecycle/upload.rs b/capsule-core/src/lifecycle/upload.rs index 9b885817..3c5f88ba 100644 --- a/capsule-core/src/lifecycle/upload.rs +++ b/capsule-core/src/lifecycle/upload.rs @@ -267,8 +267,8 @@ impl Workspace { } let core = manifest.core; - let prefix = format!("{stem}.{role_name}."); - let Some(plaintext) = read_derivative_bytes(&dir, &prefix) else { + let Some(plaintext) = read_derivative_bytes(&dir, &stem, role_name, &core.format) + else { tracing::warn!( asset_id = %asset.asset_id, role = role_name, @@ -315,18 +315,24 @@ fn derivative_role_name(role: DerivativeRole) -> &'static str { } } -/// The first file in `dir` whose name starts with `prefix` — the derivative's bytes, whose -/// extension varies with the encoder's chosen format. -fn read_derivative_bytes(dir: &std::path::Path, prefix: &str) -> Option> { - let entries = fs::read_dir(dir).ok()?; - let mut names: Vec<_> = entries - .filter_map(std::result::Result::ok) - .map(|e| e.file_name().to_string_lossy().into_owned()) - .filter(|name| name.starts_with(prefix)) - .collect(); - names.sort(); - fs::read(dir.join(names.first()?)).ok() +/// The persisted bytes of one derivative, addressed by **(role, format)** rather than by role +/// alone. +/// +/// Role alone was ambiguous the moment a tier could carry more than one format: with a JXL and +/// an AVIF thumbnail side by side, a prefix match would take whichever sorted first and then +/// content-address it against the *other* manifest, so both would be skipped as mismatched. +/// `#437` lands exactly that pair, so this is a latent break rather than a hypothetical one. +/// +/// A format outside the closed set has no extension to look for and returns `None`; the caller +/// has already rejected that manifest, so this is belt and braces. A stale file left by a +/// retired format is simply never read — nothing enumerates the directory any more, so an +/// orphan is inert rather than a candidate, and it is regenerable by design. +fn read_derivative_bytes( + dir: &std::path::Path, + stem: &str, + role_name: &str, + format: &str, +) -> Option> { + let extension = DerivativeFormat::parse(format)?.extension()?; + fs::read(dir.join(format!("{stem}.{role_name}.{extension}"))).ok() } - -#[cfg(test)] -mod tests; diff --git a/capsule-core/src/lifecycle/upload/tests.rs b/capsule-core/src/lifecycle/upload/tests.rs index 006bc9ad..54bcce9c 100644 --- a/capsule-core/src/lifecycle/upload/tests.rs +++ b/capsule-core/src/lifecycle/upload/tests.rs @@ -489,3 +489,113 @@ fn the_pushed_thumbnail_is_not_the_jxl_on_disk() { ); assert_ne!(blob.bytes, disk); } + +/// **The `F1` contract at the import boundary.** A derivative that cannot be produced must never +/// cost the original: the asset still lands signed, encrypted and `verify_asset`-accepting, and +/// the run reports `DecodeFailed` rather than failing. +/// +/// Exercised through a *decodable* still whose derivative directory is then made unwritable, so +/// the failure happens after a successful decode — the exact shape that used to propagate as +/// `LifecycleError::Io` and lose the import. +#[test] +fn a_derivative_that_cannot_be_persisted_never_costs_the_original() { + use crate::crypto::verify_asset::VerifyOutcome; + + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (_album, asset_id) = library_with_a_thumbnailed_asset(&lib, &src); + + // The asset committed, derivatives or not. + let ws = Workspace::open(lib.path(), b"passphrase", FAST).unwrap(); + assert_eq!(ws.verify(&asset_id).unwrap(), VerifyOutcome::Accept); + + // And the original's own bytes are on disk and re-derive to their signed address, which is + // the property that makes this a backup rather than a thumbnail service. + let bundle = ws.upload_bundle(&asset_id).unwrap(); + assert!(!bundle.ciphertext.is_empty()); + assert_eq!(hash::hash_bytes(&bundle.ciphertext), bundle.ciphertext_hash); +} + +/// A persisted derivative survives a `Workspace` reopen and still reaches `UploadBundle`: the +/// bundle is rebuilt from the library directory alone, so the manifest, the on-disk plaintext +/// and the re-derived ciphertext all have to agree across processes. +#[test] +fn a_derivative_survives_a_reopen_and_still_reaches_the_bundle() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (_album, asset_id) = library_with_a_thumbnailed_asset(&lib, &src); + + // A second `Workspace::open` — the S-A10 shape: nothing shared but the directory. + let ws = Workspace::open(lib.path(), b"passphrase", FAST).unwrap(); + let bundle = ws.upload_bundle(&asset_id).unwrap(); + assert_eq!(bundle.derivatives.len(), 1, "the derivative survives a reopen"); + let blob = &bundle.derivatives[0]; + assert_eq!( + hash::hash_bytes(&blob.bytes), + blob.ciphertext_hash, + "and its ciphertext still content-addresses to the signed manifest" + ); +} + +/// Two formats persisted for one role are told apart by **(role, format)**, not by whichever +/// filename sorts first. +/// +/// `#437` lands AVIF beside JXL, at which point a role-prefix match would read one file and +/// content-address it against the other manifest — skipping both. Asserted before that lands, +/// because the failure mode is a silent skip rather than an error. +#[test] +fn two_formats_for_one_role_are_addressed_by_format_not_by_filename_order() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (_album, asset_id) = library_with_a_thumbnailed_asset(&lib, &src); + let ws = Workspace::open(lib.path(), b"passphrase", FAST).unwrap(); + + let asset = ws.asset(&asset_id).expect("held"); + let dir = media_dir(lib.path(), asset.capture_utc).join("derivatives"); + let stem = asset_id.simple().to_string(); + let jxl = fs::read(dir.join(format!("{stem}.thumbnail.jxl"))).unwrap(); + + // A decoy AVIF for the same role, sorting *before* `.jxl`, with different bytes. + let avif = b"not a real avif, and deliberately different".to_vec(); + fs::write(dir.join(format!("{stem}.thumbnail.avif")), &avif).unwrap(); + + // Both manifests, each addressing its own ciphertext. + let album_keys = ws.album(&asset.album_id).unwrap(); + let address = |bytes: &[u8]| { + let key = ws.file_key(album_keys, 1, &asset_id, &[7, 6, 5, 4, 3, 2, 1]); + let (_, ct) = stream::encrypt_asset_vec_with_prefix(&key, [7, 6, 5, 4, 3, 2, 1], bytes); + hash::hash_bytes(&ct) + }; + rewrite_bundle( + &lib, + &ws, + asset_id, + &[ + signed_derivative( + asset_id, + DerivativeRole::Thumbnail, + "image/jxl", + address(&jxl), + ), + signed_derivative( + asset_id, + DerivativeRole::Thumbnail, + "image/avif", + address(&avif), + ), + ], + ); + + let bundle = ws.upload_bundle(&asset_id).unwrap(); + assert_eq!( + bundle.derivatives.len(), + 2, + "both formats of the role are shipped; neither is mistaken for the other" + ); + let formats: Vec<&str> = bundle + .derivatives + .iter() + .map(|d| d.format.as_str()) + .collect(); + assert_eq!(formats, vec!["image/jxl", "image/avif"]); +} diff --git a/capsule-core/src/media/decode.rs b/capsule-core/src/media/decode.rs index 2a5d60bf..4f32bc86 100644 --- a/capsule-core/src/media/decode.rs +++ b/capsule-core/src/media/decode.rs @@ -253,15 +253,19 @@ pub fn decode_guarded( /// Run any fallible step of the still pipeline behind the same unwind boundary. /// -/// Exported because `decode` is not the only third-party code the import path runs over pixels: +/// `pub(crate)` rather than `pub`: `lifecycle` is the only caller and no client of this crate has +/// pixels of its own to run through it, so exporting it would widen the frozen surface for +/// nothing. +/// +/// Not just for `decode` — that is not the only third-party code the import path runs over pixels: /// the placeholder goes through `chromahash` (also pre-1.0) and the derivative through -/// `libwebp`, and the module's promise is that *none* of them can abort an import — not that the +/// `zune-jpegxl`, and the module's promise is that *none* of them can abort an import — not that the /// decoder specifically cannot. `stage` names the step in the warning so a caught panic is /// attributable. /// /// `AssertUnwindSafe` is sound for the callers here: each closure borrows shared slices and /// stateless values, so a caught unwind cannot leave a Capsule-owned invariant torn. -pub fn guarded( +pub(crate) fn guarded( stage: &'static str, step: impl FnOnce() -> Result, ) -> Result { diff --git a/capsule-core/src/media/derivative.rs b/capsule-core/src/media/derivative.rs index a70605d9..a1859899 100644 --- a/capsule-core/src/media/derivative.rs +++ b/capsule-core/src/media/derivative.rs @@ -39,6 +39,7 @@ //! [`DerivativeManifest`]: crate::crypto::provenance::DerivativeManifest //! [`DerivativeCore::sign`]: crate::crypto::provenance::manifest::DerivativeCore::sign +use std::collections::HashMap; use std::fmt; use rawshift_image::core::image::RgbImage; @@ -58,93 +59,9 @@ use crate::crypto::hash::{self, Hash32}; use crate::crypto::keys::{AmkVersion, Signer}; use crate::crypto::provenance::manifest::{DERIVATIVE_MANIFEST_VERSION, DerivativeCore}; use crate::crypto::provenance::{DerivativeManifest, DerivativeRole}; +use crate::derivative_format::DerivativeFormat; use crate::lqip::RgbaImage; -/// The closed set of committed still-derivative formats — the tier table's format column. -/// -/// The wire value is [`mime`](Self::mime), carried in `DerivativeManifest.format`. A value -/// outside this set is a structural rejection, never a "future format to ignore". -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum DerivativeFormat { - /// **JPEG XL** — the committed primary/master still codec, and the one format this build - /// encodes. Losslessly: the pure-Rust backend is `zune-jpegxl`'s `JxlSimpleEncoder`. - Jxl, - /// **AVIF** — the universal delivery format for clients without a JXL decoder. Not - /// encodable in this build. - Avif, - /// **WebP** — the last-resort delivery fallback. Not encodable in this build: the crate's - /// WebP codec does not compile for aarch64 (see [`super::StillFormat::WebP`]). - WebP, - /// The recognised `format = "original"` sentinel: the tier **references** the original asset - /// rather than generating a redundant derivative, because the source is not larger than the - /// tier's cap. **Distinct from an absent derivative** — this is an explicit, signed marker, - /// where absence means "rebuildable from the original". - /// - /// A sentinel derivative carries **no bytes of its own** ([`GeneratedDerivative::bytes`] is - /// empty). "References" is the operative word in the contract: the signed manifest's - /// `ciphertext_hash` content-addresses the original, which the holder already has, so - /// copying the bytes under a thumbnail's name would duplicate a file sitting two directories - /// up *and* re-expose the original's EXIF — GPS included — as a derivative blob, where a - /// re-encoded thumbnail is metadata-free by construction. - Original, -} - -impl DerivativeFormat { - /// The committed still formats per tier, in delivery-preference order: the JXL master, then - /// the AVIF -> WebP delivery variants. - pub const STILL_DELIVERY_ORDER: [Self; 3] = [Self::Jxl, Self::Avif, Self::WebP]; - - /// The exact wire string for `DerivativeManifest.format`. - pub const fn mime(self) -> &'static str { - match self { - Self::Jxl => "image/jxl", - Self::Avif => "image/avif", - Self::WebP => "image/webp", - Self::Original => "original", - } - } - - /// The on-disk file extension for a persisted derivative of this format. `Original` has - /// none of its own — it reuses the source asset's. - pub const fn extension(self) -> Option<&'static str> { - match self { - Self::Jxl => Some("jxl"), - Self::Avif => Some("avif"), - Self::WebP => Some("webp"), - Self::Original => None, - } - } - - /// Parse a `DerivativeManifest.format` value against the closed set. `None` **is** the - /// structural rejection. - pub fn parse(s: &str) -> Option { - match s { - "image/jxl" => Some(Self::Jxl), - "image/avif" => Some(Self::Avif), - "image/webp" => Some(Self::WebP), - "original" => Some(Self::Original), - _ => None, - } - } - - /// Whether a `format` string names a currently-recognised still-derivative format — the - /// exact check a receiver runs. - pub fn is_recognized(s: &str) -> bool { - Self::parse(s).is_some() - } - - /// Whether this build can produce bytes in this format. - pub const fn is_encodable(self) -> bool { - matches!(self, Self::Jxl | Self::Original) - } -} - -impl fmt::Display for DerivativeFormat { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.mime()) - } -} - /// A derivative tier from the tier table. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum DerivativeTier { @@ -263,6 +180,19 @@ pub struct DerivativeContext<'a> { pub write_tier_signer: &'a dyn Signer, /// Encrypts each generated derivative so its manifest can commit to the ciphertext. pub sealer: &'a dyn DerivativeSealer, + /// The current head of each role's derivative chain, when this asset already has one. + /// + /// A `HashMap` rather than a `BTreeMap` because [`DerivativeRole`] derives `Hash + Eq` and + /// not `Ord`, and adding `Ord` to a signed wire type to key a lookup would be the tail + /// wagging the dog. Iteration order never escapes this map — it is only ever queried by + /// key — so the non-determinism a `HashMap` would otherwise introduce cannot reach the + /// signed bytes. + /// + /// A role's manifests are append-only **across time**, not merely within one call: a + /// backfill that adds the AVIF variant to an asset that already has a JXL thumbnail extends + /// that role's chain rather than starting a second one. Empty on a create, which is why the + /// import path passes an empty map; a regeneration reads it off the persisted bundle. + pub prior_heads: &'a HashMap, /// What the **original**'s own manifest committed to. The `original` sentinel generates no /// bytes and encrypts nothing: it is a reference to the original blob, so it signs the /// original's ciphertext address and the original's nonce prefix, and a receiver resolves it @@ -340,8 +270,9 @@ pub fn generate_still_derivatives( let source_long_edge = decoded.width().max(decoded.height()); for &tier in tiers { - // Each tier records a distinct role, so its manifests form their own chain. - let mut prior: Option = None; + // Each tier records a distinct role, so its manifests form their own chain — continued + // from wherever that role left off, not restarted. + let mut prior: Option = ctx.prior_heads.get(&tier.role()).copied(); if let Some(cap) = tier.max_long_edge() && source_long_edge <= cap @@ -384,7 +315,11 @@ pub fn generate_still_derivatives( out.deferred.push((tier, format)); continue; } - let bytes = encode(&work, format, tier)?; + // Guarded: `libwebp`'s successor here is `zune-jpegxl`, also pre-1.0, and a panic + // inside it must not abort an import that may hold the only copy of a file. The + // stage name makes a caught unwind attributable to the encoder rather than to the + // decoder that ran before it. + let bytes = super::decode::guarded("encode", || encode(&work, format, tier))?; // Encrypt before signing: the manifest's content address is the ciphertext's, so // the ciphertext has to exist first. A fresh prefix per derivative, so two // derivatives of one asset never share a key even for identical plaintext. @@ -404,32 +339,6 @@ pub fn generate_still_derivatives( Ok(out) } -/// The closed-set check a receiver runs on a still-role derivative manifest. -/// -/// Returns the parsed format for a `thumbnail` or `preview` manifest whose `format` is in the -/// closed set. An embedding-role manifest is **not** rejected: its `format` is -/// `embedding/{model_id}`, which this set deliberately does not model, so it is reported as -/// [`None`] rather than as a violation. -/// -/// # Errors -/// [`MediaError::UnsupportedFormat`] — carrying the still format Capsule *would* have needed — -/// is not what an unrecognised value produces, because there is no [`super::StillFormat`] to -/// name. An unrecognised still-role format is `Err(format.to_string())`. -pub fn verify_still_format( - manifest: &DerivativeManifest, -) -> Result, String> { - let core = &manifest.core; - match core.role { - DerivativeRole::Thumbnail | DerivativeRole::Preview => { - DerivativeFormat::parse(&core.format) - .map(Some) - .ok_or_else(|| core.format.clone()) - } - // Not a still. The embedding-role format grammar belongs to `crate::ml`. - DerivativeRole::Embedding => Ok(None), - } -} - /// Encode a tier-sized RGBA8 frame to `format`. /// /// **Every encode passes [`MetadataEmbedOptions::none`] and an empty [`ImageMetadata`]**, and @@ -540,15 +449,13 @@ pub(super) fn sign_derivative( }; let manifest = core .sign(ctx.device_signer, ctx.write_tier_signer) - .map_err(|e: CryptoError| MediaError::Encode { - format, + .map_err(|e: CryptoError| MediaError::Sign { detail: format!("signing the derivative manifest: {e}"), })?; // The next manifest of this role chains to this one: SHA-256 over its canonical CBOR, // signatures included — the same content-hash link the asset provenance chain uses. *prior = Some(hash::hash_bytes( - &cbor::to_canonical_vec(&manifest).map_err(|e| MediaError::Encode { - format, + &cbor::to_canonical_vec(&manifest).map_err(|e| MediaError::Sign { detail: format!("serialising the derivative manifest: {e}"), })?, )); diff --git a/capsule-core/src/media/error.rs b/capsule-core/src/media/error.rs index f290501d..b0e65426 100644 --- a/capsule-core/src/media/error.rs +++ b/capsule-core/src/media/error.rs @@ -8,8 +8,8 @@ use thiserror::Error; -use super::derivative::DerivativeFormat; use super::detect::StillFormat; +use crate::derivative_format::DerivativeFormat; /// Which direction of a codec a format was needed for. A build can decode a format it cannot /// encode (every format here except WebP) and the message has to say which half is missing. @@ -113,6 +113,20 @@ pub enum MediaError { /// The sample count the decoder actually returned. actual: u128, }, + /// Signing or sealing a derivative manifest failed — a hardware device signer refused, or + /// the album's write-tier key for this epoch is missing. + /// + /// **The one derivative failure that is not about pixels**, and the reason it has its own + /// variant rather than being folded into [`Encode`](Self::Encode): every other error here + /// says "this asset has no thumbnail", which an import survives, while this one says the + /// *workspace* cannot author a signed record — the same fault that would stop the asset's + /// own manifest. The import path propagates this and degrades on everything else, and it can + /// only tell them apart if the type does. + #[error("signing the derivative manifest failed: {detail}")] + Sign { + /// The underlying crypto error's message. + detail: String, + }, /// A third-party decoder panicked and the unwind was caught at the pipeline boundary. /// /// A pre-1.0 decoder fed untrusted bytes is exactly the place a panic is plausible, and an diff --git a/capsule-core/src/media/mod.rs b/capsule-core/src/media/mod.rs index 7b67243b..0b6aa15b 100644 --- a/capsule-core/src/media/mod.rs +++ b/capsule-core/src/media/mod.rs @@ -56,16 +56,19 @@ mod detect; mod error; mod resize; -pub use self::decode::{ - DecodedImage, Decoder, MediaMetadata, RawshiftDecoder, decode_guarded, guarded, -}; +pub(crate) use self::decode::guarded; +pub use self::decode::{DecodedImage, Decoder, MediaMetadata, RawshiftDecoder, decode_guarded}; pub use self::derivative::{ - DerivativeContext, DerivativeFormat, DerivativeSealer, DerivativeTier, GeneratedDerivative, - SealedDerivative, StillDerivatives, generate_still_derivatives, verify_still_format, + DerivativeContext, DerivativeSealer, DerivativeTier, GeneratedDerivative, SealedDerivative, + StillDerivatives, generate_still_derivatives, }; pub use self::detect::{MAX_DECODE_PIXELS, SUPPORTED_STILL_FORMATS, StillFormat}; pub use self::error::{FormatOp, MediaError}; pub use self::resize::{capped_dimensions, downscale_rgba8}; +// Re-exported so `media::DerivativeFormat` keeps resolving, but *owned* by the unconditional +// module: the closed set has to be linkable by the crates that receive a manifest, and they +// build without this feature. See [`crate::derivative_format`]. +pub use crate::derivative_format::{DerivativeFormat, verify_still_format}; #[cfg(test)] mod tests; diff --git a/capsule-core/src/media/resize.rs b/capsule-core/src/media/resize.rs index 168a7ce1..7c992481 100644 --- a/capsule-core/src/media/resize.rs +++ b/capsule-core/src/media/resize.rs @@ -19,10 +19,10 @@ //! function accepts. //! //! Determinism here is **necessary, not sufficient**, and the distinction matters: the bytes a -//! manifest actually signs come out of libwebp, and a libwebp version bump can change them for -//! the same input. That is fine — each generation signs the bytes it produced and manifests of a -//! role chain in order — but it means "the resample is deterministic" buys reproducibility of -//! *this* step, not a stable content address across toolchains. +//! manifest actually signs come out of `zune-jpegxl`, and an encoder version bump can change +//! them for the same input. That is fine — each generation signs the bytes it produced, and +//! manifests of a role chain in order — but it means "the resample is deterministic" buys +//! reproducibility of *this* step, not a stable content address across toolchains. //! //! Upscaling is not a thing this performs: a tier only ever caps a long edge, and a source //! already inside the cap takes the `format = "original"` sentinel path instead diff --git a/capsule-core/src/media/tests.rs b/capsule-core/src/media/tests.rs index 7ad20279..56f50642 100644 --- a/capsule-core/src/media/tests.rs +++ b/capsule-core/src/media/tests.rs @@ -17,6 +17,8 @@ //! decode there and nothing to fake: the assertion is that they are *recognised* and refused //! with a typed error. +use std::collections::HashMap; + use rawshift_image::core::metadata::{ImageInfo, ImageMetadata, URational}; use rawshift_image::core::{BitDepth, MetadataEmbedOptions}; use rawshift_image::formats::encode_rgb_image_to_vec; @@ -28,8 +30,8 @@ use uuid::Uuid; use super::decode::{Decoder, RawshiftDecoder, decode_guarded}; use super::derivative::{ - DerivativeContext, DerivativeFormat, DerivativeSealer, DerivativeTier, SealedDerivative, - StillDerivatives, generate_still_derivatives, verify_still_format, + DerivativeContext, DerivativeSealer, DerivativeTier, SealedDerivative, StillDerivatives, + generate_still_derivatives, }; use super::detect::{MAX_DECODE_PIXELS, SUPPORTED_STILL_FORMATS, StillFormat}; use super::error::{FormatOp, MediaError}; @@ -40,6 +42,7 @@ use crate::crypto::keys::{Amk, AmkVersion, HybridSigningKey}; use crate::crypto::primitives::{CRYPTO_SUITE_ID, PROTOCOL_VERSION}; use crate::crypto::provenance::manifest::{DERIVATIVE_MANIFEST_VERSION, DerivativeCore}; use crate::crypto::provenance::{DerivativeManifest, DerivativeRole}; +use crate::derivative_format::{DerivativeFormat, verify_still_format}; use crate::lqip::{Gamut, Lqip, RgbaImage}; // ── Procedural fixtures ────────────────────────────────────────────────────── @@ -928,10 +931,16 @@ const ORIGINAL_SEAL: SealedDerivative = SealedDerivative { nonce_prefix: [1, 2, 3, 4, 5, 6, 7], }; +/// No prior chain: every test that does not say otherwise generates for a fresh asset. +fn no_prior_heads() -> HashMap { + HashMap::new() +} + fn context<'a>( device: &'a HybridSigningKey, write_tier: &'a HybridSigningKey, sealer: &'a dyn DerivativeSealer, + prior_heads: &'a HashMap, asset_id: Uuid, ) -> DerivativeContext<'a> { DerivativeContext { @@ -945,6 +954,7 @@ fn context<'a>( device_signer: device, write_tier_signer: write_tier, sealer, + prior_heads, original: ORIGINAL_SEAL, } } @@ -952,7 +962,8 @@ fn context<'a>( fn generate(frame: &RgbaImage, original: &[u8]) -> StillDerivatives { let (device, write_tier) = signers(); let seal = sealer(Uuid::from_u128(0xB1)); - let ctx = context(&device, &write_tier, &seal, Uuid::from_u128(0xB1)); + let heads = no_prior_heads(); + let ctx = context(&device, &write_tier, &seal, &heads, Uuid::from_u128(0xB1)); let decoded = RawshiftDecoder .decode(original, "png") .expect("the fixture decodes"); @@ -1122,7 +1133,8 @@ fn a_source_within_the_cap_signs_the_original_sentinel() { fn manifests_of_one_role_form_an_append_only_chain() { let (device, write_tier) = signers(); let seal = sealer(Uuid::from_u128(0xB2)); - let ctx = context(&device, &write_tier, &seal, Uuid::from_u128(0xB2)); + let heads = no_prior_heads(); + let ctx = context(&device, &write_tier, &seal, &heads, Uuid::from_u128(0xB2)); let mut prior = None; let first = super::derivative::sign_derivative( @@ -1189,7 +1201,13 @@ fn each_tier_starts_its_own_role_chain() { let both = generate_still_derivatives( &decoded, &[DerivativeTier::Thumbnail, DerivativeTier::Preview], - &context(&device, &write_tier, &seal, Uuid::from_u128(0xB5)), + &context( + &device, + &write_tier, + &seal, + &no_prior_heads(), + Uuid::from_u128(0xB5), + ), ) .expect("both tiers"); @@ -1232,7 +1250,8 @@ fn two_derivatives_of_one_asset_never_share_a_nonce_prefix() { let (device, write_tier) = signers(); let asset_id = Uuid::from_u128(0xB6); let seal = sealer(asset_id); - let ctx = context(&device, &write_tier, &seal, asset_id); + let heads = no_prior_heads(); + let ctx = context(&device, &write_tier, &seal, &heads, asset_id); let identical = b"byte-identical derivative plaintext".to_vec(); let mut prior = None; @@ -1308,7 +1327,13 @@ fn a_thumbnail_carries_no_exif_and_no_gps() { let result = generate_still_derivatives( &decoded, &DerivativeTier::GENERATED, - &context(&device, &write_tier, &seal, Uuid::from_u128(0xB3)), + &context( + &device, + &write_tier, + &seal, + &no_prior_heads(), + Uuid::from_u128(0xB3), + ), ) .expect("generation"); let thumb = &result.generated[0].bytes; @@ -1464,17 +1489,46 @@ fn a_decoded_frame_encodes_an_lqip_at_the_committed_width() { ); } -/// The two integer widths the downscale depends on, exercised at the shapes that would overflow -/// a narrower one. +/// The per-channel accumulator genuinely crosses `u32::MAX`, and the boundary arithmetic is +/// documented for what it is. +/// +/// **The accumulator overflow is real and reachable.** Reducing a frame to a cap of 1 sums every +/// sample into one destination pixel, so the running total is `pixels * 255`. At 4200x4200 that +/// is 4.5e9 — past `u32::MAX` (4.29e9) — and a `u32` accumulator would panic in debug and wrap +/// into wrong pixels in release. `downscale_rgba8` is a `pub` entry point, so a cap of 1 is +/// reachable even though the tier table only ever passes 256. /// -/// A 1 x 300000 frame reduced to a 256 px long edge makes `(y + 1) * src_h` reach 7.7e10, past a -/// 32-bit `usize` — and `armv7-linux-androideabi` and `i686-linux-android` are both CI-gated -/// targets. Reducing a frame to a **cap of 1** makes the per-channel accumulator reach -/// `w * h * 255`, past `u32::MAX` for a frame of any size; `downscale_rgba8` is a `pub` entry -/// point, so that cap is reachable even though the tier table only ever passes 256. +/// **The boundary product is defensive, not demonstrated.** `(y + 1) * src_h` reaches +/// `dst_edge * src_edge`, which for the shapes this build actually produces (a 256 px cap) stays +/// far inside 32 bits. It is computed in `u64` anyway because the function is public and its +/// inputs are not bounded by the tier table — but the earlier claim that a 1x300000 frame +/// crossed `u32::MAX` was simply wrong arithmetic (7.7e7, not 7.7e10), and a test asserting a +/// false reason is worse than no test. #[test] -fn the_downscale_survives_the_shapes_that_overflow_narrow_arithmetic() { - // A tall, one-pixel-wide frame: every destination row averages a large run of source rows. +fn the_downscale_accumulator_survives_crossing_u32_max() { + // 4200 * 4200 * 255 = 4_501_980_000 > u32::MAX. + let edge = 4200u32; + let pixels = u64::from(edge) * u64::from(edge); + assert!( + pixels * 255 > u64::from(u32::MAX), + "the fixture must actually cross the boundary it exists to test" + ); + + let flat = RgbaImage { + width: edge, + height: edge, + rgba: vec![255, 255, 255, 255].repeat((edge * edge) as usize), + }; + let single = downscale_rgba8(&flat, 1); + assert_eq!((single.width, single.height), (1, 1)); + assert_eq!( + single.rgba, + vec![255, 255, 255, 255], + "every sample sums into one pixel, and the mean is still 255" + ); + + // The tall-frame shape, kept because it is the one the tier path can actually meet: a + // lopsided source reduced to a 256 px long edge. let tall = RgbaImage { width: 1, height: 300_000, @@ -1482,7 +1536,6 @@ fn the_downscale_survives_the_shapes_that_overflow_narrow_arithmetic() { }; let reduced = downscale_rgba8(&tall, 256); assert_eq!((reduced.width, reduced.height), (1, 256)); - assert_eq!(reduced.rgba.len(), 256 * 4); assert!( reduced .rgba @@ -1490,18 +1543,153 @@ fn the_downscale_survives_the_shapes_that_overflow_narrow_arithmetic() { .all(|px| px == [200, 100, 50, 255]), "a flat frame survives a 1172x row reduction exactly" ); +} - // A cap of 1: one destination pixel accumulates the entire frame. - let wide = RgbaImage { - width: 600, - height: 400, - rgba: vec![255, 255, 255, 255].repeat(600 * 400), - }; - let single = downscale_rgba8(&wide, 1); - assert_eq!((single.width, single.height), (1, 1)); +/// A sealer that refuses is a **workspace** fault and must be distinguishable at the type level +/// from a codec that refuses, because the import path propagates one and degrades on the other. +/// +/// This is the `F1` contract: `MediaError::Sign` is its own variant precisely so +/// `prepare_still` can tell "this asset has no thumbnail" from "this workspace cannot author a +/// signed record", instead of string-matching both into one `LifecycleError::Io`. +#[test] +fn a_signing_fault_is_its_own_variant_not_an_encode_failure() { + struct RefusingSealer; + impl DerivativeSealer for RefusingSealer { + fn seal(&self, _plaintext: &[u8]) -> Result { + Err(MediaError::Sign { + detail: "the hardware signer refused".into(), + }) + } + } + + let frame = gradient(512, 384); + let original = png_bytes(&frame); + let (device, write_tier) = signers(); + let decoded = RawshiftDecoder.decode(&original, "png").expect("decode"); + + let error = generate_still_derivatives( + &decoded, + &DerivativeTier::GENERATED, + &context( + &device, + &write_tier, + &RefusingSealer, + &no_prior_heads(), + Uuid::from_u128(0xB8), + ), + ) + .expect_err("a refusing sealer fails generation"); + assert!( + matches!(error, MediaError::Sign { .. }), + "a signing/sealing refusal keeps its own identity all the way out: {error:?}" + ); +} + +/// **A role's chain continues across generations.** A second run over an asset that already has +/// a thumbnail extends that role's chain rather than starting a parallel one. +/// +/// This is what makes derivative provenance append-only *in time* rather than merely within one +/// call, and it is the property a `#437` backfill depends on: adding the AVIF variant to an +/// asset that already has JXL must not fork the record. A forked chain is not something a later +/// run can repair, so it is asserted before the backfill exists. +#[test] +fn a_roles_chain_continues_across_generation_runs() { + let frame = gradient(512, 384); + let original = png_bytes(&frame); + let (device, write_tier) = signers(); + let asset_id = Uuid::from_u128(0xB7); + let seal = sealer(asset_id); + let decoded = RawshiftDecoder.decode(&original, "png").expect("decode"); + + // First generation: the role's chain starts. + let first = generate_still_derivatives( + &decoded, + &DerivativeTier::GENERATED, + &context(&device, &write_tier, &seal, &no_prior_heads(), asset_id), + ) + .expect("first run"); + let head = &first.generated[0].manifest; + assert!( + head.core.prior_provenance_hash.is_none(), + "the first manifest of a role starts that role's chain" + ); + + // What a reader would compute from the persisted bundle. + let link = crate::crypto::hash::hash_bytes( + &crate::cbor::to_canonical_vec(head).expect("canonical CBOR"), + ); + let mut heads = HashMap::new(); + heads.insert(DerivativeRole::Thumbnail, link); + + // Second generation, handed that head. + let second = generate_still_derivatives( + &decoded, + &DerivativeTier::GENERATED, + &context(&device, &write_tier, &seal, &heads, asset_id), + ) + .expect("second run"); assert_eq!( - single.rgba, - vec![255, 255, 255, 255], - "240000 samples at 255 each sum past u32::MAX and must still average to 255" + second.generated[0].manifest.core.prior_provenance_hash, + Some(link), + "the second run extends the chain instead of forking it" + ); + + // A role with no recorded head still starts cleanly — the map is a lookup, not a gate. + let preview = generate_still_derivatives( + &decoded, + &[DerivativeTier::Preview], + &context(&device, &write_tier, &seal, &heads, asset_id), + ) + .expect("preview run"); + assert!( + preview.generated[0] + .manifest + .core + .prior_provenance_hash + .is_none(), + "a role the map does not mention starts its own chain" + ); +} + +/// A **panicking sealer/encoder** is caught at the same boundary a panicking decoder is, so one +/// bad frame cannot abort a 20,000-photo import part way through. +/// +/// The decode guard was never the whole story: `chromahash` and the JXL encoder are both pre-1.0 +/// too, and they run *after* the decoder on the same untrusted pixels. This mirrors +/// [`HostileDecoder`] on the encode side. +#[test] +fn a_panicking_encoder_is_caught_like_a_panicking_decoder() { + struct PanickingSealer; + impl DerivativeSealer for PanickingSealer { + fn seal(&self, _plaintext: &[u8]) -> Result { + panic!("a pre-1.0 codec panicking on a frame the decoder accepted"); + } + } + + let frame = gradient(512, 384); + let original = png_bytes(&frame); + let (device, write_tier) = signers(); + let decoded = RawshiftDecoder.decode(&original, "png").expect("decode"); + + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(|_| {})); + let caught = super::guarded("derivatives", || { + generate_still_derivatives( + &decoded, + &DerivativeTier::GENERATED, + &context( + &device, + &write_tier, + &PanickingSealer, + &no_prior_heads(), + Uuid::from_u128(0xB9), + ), + ) + }); + std::panic::set_hook(previous); + + assert!( + matches!(caught, Err(MediaError::DecoderPanic)), + "an unwind from anywhere in generation becomes a reported error, never an abort" ); } diff --git a/capsule-docs/src/content/docs/design/dependencies.md b/capsule-docs/src/content/docs/design/dependencies.md index 91c65e37..ed5fd6f1 100644 --- a/capsule-docs/src/content/docs/design/dependencies.md +++ b/capsule-docs/src/content/docs/design/dependencies.md @@ -40,7 +40,7 @@ Mechanically, every Rust version is pinned once in the root `Cargo.toml` `[works | ORM | `sea-orm` (`sqlx-postgres` on the server, `sqlx-sqlite` in the CLI) | The rebuildable index databases only — sidecars stay canonical per [Principles](/design/principles/). | — | | Embedded SQLite | `rusqlite` (`bundled`) | `capsule-core`'s `library.sqlite`. | — | | Vector index | `sqlite-vec` (`vec0`) | The client-local embedding index in `capsule-core`'s `library.sqlite` — per-task `vec0` virtual tables under the [embedding-provenance](/design/ai/#embedding-provenance) invariant. Optional + `native`-gated alongside `rusqlite` (registers as a SQLite auto-extension; not `wasm32`). | Server-side vector-DB idioms (pgvector/HNSW) do not apply — the index is client-local SQLite by design. | -| Still decode / encode | `rawshift-image` **0.1.1** (`default-features = false`, features `jpeg`, `png`, `jxl`, `tiff-decode`, `gif-decode`) | `capsule-core::media` behind the `media` feature, which `native` implies (slices `S-B1`, `S-B13`) — format sniffing, pixel decode, EXIF orientation and the derivative byte encode. A **registry** dependency, not the pinned `rawshift/` submodule: that tree is an uninitialised newer v1-in-progress checkout and not a workspace member. Depended on directly rather than through the `rawshift` facade because only the per-crate dependency gives per-format Cargo control, which the crate's own docs recommend and which this row needs — the format set is a licence, build-host and **portability** decision, not a convenience. **Every enabled codec is pure Rust and links no C**: zune for JPEG/PNG decode and encode, `jxl-oxide` for JXL decode, `zune-jpegxl` for the JXL encode that produces the thumbnail tier, plus `tiff` and `gif`. MPL-2.0 (with `rawshift-core`), already allow-listed in `deny.toml`; both are named in the root `NOTICE` MPL list. `jpeg-encoder`'s conjunctive IJG arm was already excepted and is matched again by this row. Tiers, quality and the closed format set are the contract at [Thumbnails](/design/thumbnails/); this row owns the pin. | **`webp` is absent because it does not compile, not because it was not wanted.** It was the first choice — `image/webp` is in the format table and `libwebp` has the exact q=50 knob — but `rawshift-image`'s WebP module passes `*const i8` where `libwebp-sys` 0.14.4 declares `*const c_char`, and `c_char` is `u8` on aarch64, so it is an E0308 on every 64-bit ARM target; the module is compiled by decode *or* encode, so decode-only does not escape it. Every mobile target is aarch64, so enabling it would mean thumbnails on desktop and none on a phone. **Also deliberately absent**, each a toolchain rather than a design gap: `heic` (system libheif), `avif` (`image`'s `avif-native` -> system libdav1d for decode; `ravif` -> `rav1e/asm` -> `nasm` on every x86_64 build host for encode), `svg` (resvg), and the RAW families (`experimental`/`raw-stabilizing`; Canon CR3 pixel decode is unimplemented upstream). And the JXL encode is **lossless** — `zune-jpegxl`'s `JxlSimpleEncoder` — so a thumbnail costs more bytes than the table's q=50 intends; a lossy JXL needs C libjxl (`bindgen` + `pkg-config`). Every one of these is a typed `media::MediaError::UnsupportedFormat` or a recorded per-format deferral, never a silent gap. **Not** on the wasm32 sealing surface: `media` is absent from the `--no-default-features` build, so `cargo tree --target wasm32-unknown-unknown -i rawshift-image` is empty. Rawshift must never wrap Chromahash (`AGENTS.md`); see the LQIP row below. | +| Still decode / encode | `rawshift-image` **0.1.1** (`default-features = false`, features `jpeg-decode`, `png-decode`, `jxl`, `tiff-decode`, `gif-decode`; the JPEG/PNG **encoders** ride `[dev-dependencies]`, so `jpeg-encoder` and its conjunctive IJG arm stay out of every release binary while remaining in the graph `cargo deny --all-features` inspects — the exception and its NOTICE section stay matched rather than going stale) | `capsule-core::media` behind the `media` feature, which `native` implies (slices `S-B1`, `S-B13`) — format sniffing, pixel decode, EXIF orientation and the derivative byte encode. A **registry** dependency, not the pinned `rawshift/` submodule: that tree is an uninitialised newer v1-in-progress checkout and not a workspace member. Depended on directly rather than through the `rawshift` facade because only the per-crate dependency gives per-format Cargo control, which the crate's own docs recommend and which this row needs — the format set is a licence, build-host and **portability** decision, not a convenience. **Every enabled codec is pure Rust and links no C**: zune for JPEG/PNG decode and encode, `jxl-oxide` for JXL decode, `zune-jpegxl` for the JXL encode that produces the thumbnail tier, plus `tiff` and `gif`. MPL-2.0 (with `rawshift-core`), already allow-listed in `deny.toml`; both are named in the root `NOTICE` MPL list. `jpeg-encoder`'s conjunctive IJG arm was already excepted and is matched again by this row. Tiers, quality and the closed format set are the contract at [Thumbnails](/design/thumbnails/); this row owns the pin. | **`webp` is absent because it does not compile, not because it was not wanted.** It was the first choice — `image/webp` is in the format table and `libwebp` has the exact q=50 knob — but `rawshift-image`'s WebP module passes `*const i8` where `libwebp-sys` 0.14.4 declares `*const c_char`, and `c_char` is `u8` on aarch64, so it is an E0308 on every 64-bit ARM target; the module is compiled by decode *or* encode, so decode-only does not escape it. Every mobile target is aarch64, so enabling it would mean thumbnails on desktop and none on a phone. **Also deliberately absent**, each a toolchain rather than a design gap: `heic` (system libheif), `avif` (`image`'s `avif-native` -> system libdav1d for decode; `ravif` -> `rav1e/asm` -> `nasm` on every x86_64 build host for encode), `svg` (resvg), and the RAW families (`experimental`/`raw-stabilizing`; Canon CR3 pixel decode is unimplemented upstream). And the JXL encode is **lossless** — `zune-jpegxl`'s `JxlSimpleEncoder` — so a thumbnail costs more bytes than the table's q=50 intends; a lossy JXL needs C libjxl (`bindgen` + `pkg-config`). Every one of these is a typed `media::MediaError::UnsupportedFormat` or a recorded per-format deferral, never a silent gap. **Not** on the wasm32 sealing surface: `media` is absent from the `--no-default-features` build, so `cargo tree --target wasm32-unknown-unknown -i rawshift-image` is empty. Rawshift must never wrap Chromahash (`AGENTS.md`); see the LQIP row below. | | LQIP placeholder codec | `chromahash` **0.7.1** | `capsule-core::lqip` (slice `S-B14`) — the only encoder/decoder for the signed sidecar `lqip` field. Imported **directly**, never through Rawshift (`AGENTS.md`), and deliberately outside `capsule-core::media` — the Rawshift-consuming module — so one implementation serves the import pipeline, the uniffi FFI, and `capsule-wasm`. The tier, byte width and versioned fallback are the contract at [Thumbnails — LQIP](/design/thumbnails/#lqip); this row owns only the pin. The `AGENTS.md` gate that read "after its v1 release" is **amended to 0.7.1** — the release the project accepts as ready — and `xtask`'s architecture check stopped forbidding the crate in `2f8beeb`, because a check that forbids an approved dependency has stopped describing a decision and started blocking one. | **`thumbhash` is retired, not excepted.** The Rust crate behind `capsule-core`'s `media` feature and the npm package in `capsule-web` both go; `thumbhash` stays in the architecture check's retired-dependency list so it cannot return. BlurHash was never adopted. | | Free-space probe | `rustix` (Unix, `fs`) + `windows-sys` (Windows, `Win32_Storage_FileSystem`) | `capsule-core::library::available_bytes` — the streaming-import free-space probe (`statvfs` / `GetDiskFreeSpaceEx`). Host-only, behind the `native` feature; the wasm32 sealing build links neither. | — | | Windows TPM (TBS) | `windows-sys` (Windows, `Win32_System_TpmBaseServices`) | `capsule-core::crypto::keys::tbs` — the Windows device-key `HardwareSigner` (slice S-F4). The raw TPM 2.0 command channel (`Tbsi_Context_Create` / `Tbsip_Submit_Command`) the tss-esapi reference (`crypto::keys::tpm`, Linux) wraps; links `tbs.dll` via raw-dylib, so no new crate — an extra feature on the existing `windows-sys` row. `#[cfg(windows)]`-gated; the pure wire codec + mock tests run on any host. | Not tss-esapi on Windows: TBS is native and avoids the `libtss2`/bindgen build. | diff --git a/capsule-docs/src/content/docs/design/thumbnails.md b/capsule-docs/src/content/docs/design/thumbnails.md index bc8c9be2..947b8e0b 100644 --- a/capsule-docs/src/content/docs/design/thumbnails.md +++ b/capsule-docs/src/content/docs/design/thumbnails.md @@ -83,7 +83,7 @@ Four calls carry the whole contract, and the module uses no more than these: ### Where LQIP Lives -`capsule-core::lqip` — a dedicated module, slice `S-B14` in the repo-root `SLICES.md`. It is deliberately **not** in `capsule-core::media`, and the reason outlived the teardown that first prompted it: `media` is `native`-only wherever it exists (it links codecs, and since `#410` a vendored C one), so a placeholder scheme every client depends on cannot live inside it and still reach the browser. It is equally not in Rawshift — `AGENTS.md` is explicit that Rawshift owns media decoding but must not wrap Chromahash, which Capsule imports directly. `media` is the module that *produces* the pixels this one hashes; it never owns the hash. +`capsule-core::lqip` — a dedicated module, slice `S-B14` in the repo-root `SLICES.md`. It is deliberately **not** in `capsule-core::media`, and the reason outlived the teardown that first prompted it: `media` is `native`-only wherever it exists — it links image codecs and a decode budget sized for a workstation, neither of which a browser has any use for, so a placeholder scheme every client depends on cannot live inside it and still reach the browser. It is equally not in Rawshift — `AGENTS.md` is explicit that Rawshift owns media decoding but must not wrap Chromahash, which Capsule imports directly. `media` is the module that *produces* the pixels this one hashes; it never owns the hash. A small Capsule-owned module outside the retiring stack satisfies both constraints at once, and is reachable from all three places a placeholder is produced or consumed: the import pipeline, the native apps through the uniffi FFI, and the browser through `capsule-wasm`. That is the point of a single home — one implementation for every surface, so a photo's placeholder does not depend on which client happened to import it. @@ -106,6 +106,8 @@ Thumbnails and previews are *ephemeral by recovery posture* (they can always be The full derivative manifest structure and the `derivative-add` / `derivative-replace` action set are owned by [Cryptography — Derivative Provenance](/design/cryptography/provenance/#derivative-provenance) and [Authorization — The Closed Action Set](/design/authorization/#the-closed-action-set); this doc owns only the *format* of the derivative bytes. The two interact at exactly one point: the `DerivativeManifest.format` field names the codec/format from the table above, and the verifying side rejects a manifest whose `format` is not currently recognized (the closed-enum rule from [Threat Model — Schema Rules](/design/threat-model/schema-rules/#schema-evolution-and-field-grammar)). +A receiver never needs a manifest for a tier that was satisfied by the original itself. The signed sidecar carries the asset's pixel `dimensions`, so a receiver can see for itself that an original at or below the thumbnail tier's long edge needs no thumbnail, and must not schedule a regeneration for one; the `format = "original"` sentinel is a **local** record that keeps "this original is small" apart from "this thumbnail is missing" for the client's own rebuild path, and it is not shipped. + A thumbnail whose `DerivativeManifest` fails verification is **regenerated locally from the original** rather than trusted — the [recovery-first principle](/design/principles/) means a derivative is always rebuildable, so refusal-and-regenerate is the safe default. The corrupt copy is discarded (not quarantined — it carries no irreplaceable bytes), and the corresponding regeneration appends a new `derivative-replace` provenance record. ## Validation diff --git a/capsule-wasm/src/lib.rs b/capsule-wasm/src/lib.rs index 56b0919e..12a53ef3 100644 --- a/capsule-wasm/src/lib.rs +++ b/capsule-wasm/src/lib.rs @@ -34,7 +34,8 @@ //! is the one surface here that is not about crypto: //! //! 6. [`decode_lqip`] (`decodeLqip`) — render a sidecar `lqip` record to packed RGBA the viewer -//! can hand straight to `CanvasRenderingContext2D.putImageData`. The placeholder lives inside +//! can hand straight to `CanvasRenderingContext2D.putImageData`. Infallible: a placeholder is +//! cosmetic, so a malformed record paints a fallback fill rather than failing a gallery. The placeholder lives inside //! the *encrypted* metadata blob, so the browser only ever holds it after opening a share //! link — which is why this belongs in the same crate as the open path rather than beside a //! server route. It is the same [`capsule_core::lqip`] implementation the import pipeline @@ -431,12 +432,15 @@ impl WasmLqipImage { /// rather than to a fixed size is the point of `decode_capped`: a grid cell never scales down /// a larger decode. /// -/// **Infallible by design, except on a malformed `dominant_color`.** An unrecognised -/// `format_version` or a payload the parser rejects yields the 1x1 solid fallback fill rather -/// than a throw, because a reader must never misrender a payload it does not understand and a -/// missing placeholder is not an error worth failing a gallery over. Only a `dominant_color` -/// that is not three bytes throws `malformed` — there is no colour to fall back *to*, so -/// guessing one would invent pixels. +/// **Infallible.** An unrecognised `format_version`, a payload the parser rejects, *and* a +/// `dominant_color` that is not three bytes all yield a solid fallback fill rather than a throw. +/// +/// A placeholder is cosmetic: it never damages a library and its absence never loses data, so +/// failing a viewer over one trades a blurry square for a broken gallery. The earlier version +/// threw on a malformed `dominant_color` while the native FFI painted black for the same input — +/// one record, two behaviours, decided by which client opened it. That is exactly the +/// client-dependent divergence `capsule-core::lqip` exists to prevent, so both surfaces now +/// paint [`FALLBACK_FILL`] and say so. #[wasm_bindgen(js_name = decodeLqip)] pub fn decode_lqip( format_version: u16, @@ -444,40 +448,40 @@ pub fn decode_lqip( dominant_color: &[u8], max_width: u32, max_height: u32, -) -> Result { - render_lqip_record( - format_version, - chromahash, - dominant_color, - max_width, - max_height, - ) - .map(|image| WasmLqipImage { image }) - .ok_or_else(|| JsError::new(err::MALFORMED)) +) -> WasmLqipImage { + WasmLqipImage { + image: render_lqip_record( + format_version, + chromahash, + dominant_color, + max_width, + max_height, + ), + } } +/// The colour a malformed record paints when it carries no usable `dominant_color`. +/// +/// Black, the conventional empty-cell fill, and identical to what `capsule-core-ffi`'s +/// `render_lqip` paints for the same input — the two surfaces are asserted to agree. +const FALLBACK_FILL: [u8; 3] = [0, 0, 0]; + /// [`decode_lqip`] without the JS boundary — the whole of its logic, so the host unit tests can /// exercise it. /// /// The split is not ceremony: `JsError` cannot be *constructed* off-wasm (its host shim aborts), -/// so a test that reached the error arm through the exported function would abort the test -/// binary rather than fail an assertion. Keeping the boundary to a `map`/`ok_or_else` is also -/// the thin-glue discipline the module docs ask for. +/// so a test reaching an error arm through the exported function would abort the test binary +/// rather than fail an assertion. It survives the move to an infallible signature because the +/// `#[wasm_bindgen]` wrapper is still unusable from a host test. fn render_lqip_record( format_version: u16, chromahash: &[u8], dominant_color: &[u8], max_width: u32, max_height: u32, -) -> Option { - let fill: [u8; 3] = dominant_color.try_into().ok()?; - Some(lqip_render( - format_version, - chromahash, - fill, - max_width, - max_height, - )) +) -> RgbaImage { + let fill: [u8; 3] = dominant_color.try_into().unwrap_or(FALLBACK_FILL); + lqip_render(format_version, chromahash, fill, max_width, max_height) } #[cfg(test)] @@ -622,8 +626,7 @@ mod tests { let payload = lqip.as_bytes(); assert_eq!(payload.len(), 32, "the committed tier is 32 bytes"); - let decoded = render_lqip_record(LQIP_FORMAT_V1, payload, &lqip.dominant_color(), 64, 64) - .expect("a well-formed record decodes"); + let decoded = render_lqip_record(LQIP_FORMAT_V1, payload, &lqip.dominant_color(), 64, 64); assert_eq!( decoded, lqip.decode_capped(64, 64), @@ -653,25 +656,32 @@ mod tests { (LQIP_FORMAT_V1, vec![0xDE, 0xAD, 0xBE, 0xEF]), (LQIP_FORMAT_V1, Vec::new()), ] { - let decoded = render_lqip_record(version, &payload, &fill, 32, 32) - .expect("the fallback never fails"); + let decoded = render_lqip_record(version, &payload, &fill, 32, 32); assert_eq!((decoded.width, decoded.height), (1, 1)); assert_eq!(decoded.rgba, vec![12, 34, 56, 255]); } } - /// The one throwing case: there is no colour to fall back *to*, so guessing one would invent - /// pixels. + /// A malformed `dominant_color` paints [`FALLBACK_FILL`] rather than throwing — the same + /// answer `capsule-core-ffi`'s `render_lqip` gives, so a viewer's behaviour does not depend + /// on which client opened the record. #[test] - fn decode_lqip_rejects_a_malformed_dominant_colour() { + fn decode_lqip_paints_the_fallback_fill_for_a_malformed_dominant_colour() { use capsule_core::lqip::LQIP_FORMAT_V1; for fill in [&[][..], &[1][..], &[1, 2][..], &[1, 2, 3, 4][..]] { - assert!( - render_lqip_record(LQIP_FORMAT_V1, &[0; 32], fill, 16, 16).is_none(), - "a {}-byte dominant_color is malformed", + let decoded = render_lqip_record(LQIP_FORMAT_V1, &[0; 32], fill, 16, 16); + assert_eq!( + (decoded.width, decoded.height), + (1, 1), + "a {}-byte dominant_color paints the fill, not an error", fill.len() ); + assert_eq!( + decoded.rgba, + vec![FALLBACK_FILL[0], FALLBACK_FILL[1], FALLBACK_FILL[2], 255], + "and it is black — identical to the native surface" + ); } } From fa37547c6b4c7e7dfa59a4c4181a3ce97c853d9f Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 02:03:43 -0400 Subject: [PATCH 084/243] test(server): let the Postgres container harness run on a rootless runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The harness landed with the plumbing and had never been run. Starting it on a rootless podman host turned up two failures, both of which would have met the next person as an unexplained "permission denied". The image's declared `VOLUME` is the first. An anonymous volume is created with an ownership the container's own user cannot `chmod`, so `initdb` dies before Postgres ever listens. `PGDATA=/tmp/pgdata` puts the cluster in the container's own writable layer, which for a database that lives for one test is strictly better anyway: nothing to create, nothing to reap. The second is user namespaces. Under rootless podman a container process that is not the container's root maps to a host *subuid*, and that subuid has to traverse the image store to reach anything — so on a machine whose home is not world-traversable the official image dies at `gosu postgres /usr/local/bin/docker-entrypoint.sh` with a message that names the entrypoint and says nothing about why. `--userns=keep-id` maps every container uid onto the invoking user and the entrypoint then skips its drop-privileges branch entirely. It is read from `CAPSULE_TEST_CONTAINER_USERNS` rather than hardcoded because `keep-id` is podman's spelling and Docker rejects it, so CI must not carry it. The skip line every gated case prints now names all three variables. The image tag moves off the module's `11-alpine` default to `17-alpine`: the server targets a currently-supported PostgreSQL and a floating tag makes "it passed yesterday" unfalsifiable. What that image cannot prove is recorded beside the pin — musl collates `en_US.utf8` byte-for-byte, so a case that asserts byte ordering is weaker here than on the glibc PostgreSQL a deployment runs. Refs #402 --- capsule-server/src/postgres/mod.rs | 10 +++-- capsule-server/src/postgres/testing.rs | 57 ++++++++++++++++++++++---- 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/capsule-server/src/postgres/mod.rs b/capsule-server/src/postgres/mod.rs index a4fe42b9..e21d83ea 100644 --- a/capsule-server/src/postgres/mod.rs +++ b/capsule-server/src/postgres/mod.rs @@ -99,8 +99,8 @@ pub async fn connect(database_url: &str) -> Result Result<() #[cfg(test)] mod tests { - use sea_orm::ConnectionTrait as _; use server_migration::MigratorTrait as _; use super::{EXPECTED_MIGRATIONS, assert_schema_current, testing}; @@ -199,7 +198,10 @@ mod tests { } mod postgres_conformance { - use super::{ConnectionTrait as _, EXPECTED_MIGRATIONS, assert_schema_current, testing}; + use sea_orm::ConnectionTrait as _; + use server_migration::MigratorTrait as _; + + use super::{EXPECTED_MIGRATIONS, assert_schema_current, testing}; /// Up, down, up: the schema a rollback leaves behind is one the next deploy can build on. #[tokio::test] diff --git a/capsule-server/src/postgres/testing.rs b/capsule-server/src/postgres/testing.rs index 4faae677..ef148a58 100644 --- a/capsule-server/src/postgres/testing.rs +++ b/capsule-server/src/postgres/testing.rs @@ -39,7 +39,43 @@ pub(crate) const GATE: &str = "CAPSULE_TEST_POSTGRES"; /// Pinned rather than left at the module's default (`11-alpine`) for two reasons: the server /// targets a currently-supported PostgreSQL, and a floating tag makes "it passed yesterday" /// unfalsifiable. Bump it deliberately. -const POSTGRES_TAG: &str = "18.0"; +/// +/// **One case is weaker against this image than it is in production**, and it is worth saying +/// so rather than discovering it later: alpine is musl, and musl collates `en_US.utf8` +/// byte-for-byte, so `index/conformance.rs`'s +/// `the_row_walk_orders_by_the_identifiers_own_bytes` cannot tell a query that pins +/// `COLLATE "C"` from one that inherits the database's collation. On a glibc PostgreSQL it can: +/// glibc orders `walkord-a-b, walkord-ab, walkord-a-c` where bytes order +/// `walkord-a-b, walkord-a-c, walkord-ab`. The `COLLATE "C"` in `index/postgres.rs` is written +/// against the glibc behaviour, which is what a Debian-based deployment runs. Moving this pin to +/// a glibc image would make the case bite here too — `postgres:18.0` was tried and its +/// entrypoint does not survive `--userns=keep-id`, which is the rootless-podman workaround +/// below. +const POSTGRES_TAG: &str = "17-alpine"; + +/// Where the container keeps its cluster. +/// +/// Off the image's declared `VOLUME` deliberately. The data of a container that lives for one +/// test is throwaway by definition, so an anonymous volume buys nothing and costs two things: a +/// volume to create and reap per test, and — on a rootless runtime, where the volume is created +/// with an ownership the container's own user cannot `chmod` — an `initdb` that fails before +/// Postgres ever listens. +const PGDATA: &str = "/tmp/pgdata"; + +/// The user-namespace mode to run the container under, when the host needs one. +/// +/// Unset on an ordinary Docker host and on CI, which is why this is an environment variable +/// rather than a constant: `keep-id` is podman's spelling and Docker rejects it. +/// +/// It exists because of a real and non-obvious host shape. Under **rootless podman**, a +/// container process that is not the container's root maps to a host *subuid*, and that subuid +/// has to traverse the image store to reach anything — so on a machine whose home directory is +/// not world-traversable (`drwxrws---`), the official Postgres image dies at +/// `gosu postgres /usr/local/bin/docker-entrypoint.sh` with a bare "permission denied" that +/// names the entrypoint and says nothing about why. `--userns=keep-id` maps every container uid +/// onto the invoking user, which both fixes the traversal and makes the entrypoint skip its +/// drop-privileges branch entirely. +const USERNS_MODE: &str = "CAPSULE_TEST_CONTAINER_USERNS"; /// A running Postgres with the server's schema applied. /// @@ -76,18 +112,21 @@ pub(crate) async fn start(case: &str) -> Option { if !enabled() { eprintln!( "skipping {case}: the Postgres conformance tier is unavailable. Set {GATE}=1 with a \ - reachable container runtime — for podman, `systemctl --user start podman.socket` \ - and `export DOCKER_HOST=unix:///run/user/$(id -u)/podman/podman.sock`." + reachable container runtime — for rootless podman, `systemctl --user start \ + podman.socket`, `export DOCKER_HOST=unix:///run/user/$(id -u)/podman/podman.sock` \ + and `export {USERNS_MODE}=keep-id`." ); return None; } - let container = testcontainers::ImageExt::with_tag(Postgres::default(), POSTGRES_TAG) - .start() - .await - .unwrap_or_else(|error| { - panic!("{GATE}=1 was set but a Postgres container could not be started: {error}") - }); + let mut request = testcontainers::ImageExt::with_tag(Postgres::default(), POSTGRES_TAG); + request = testcontainers::ImageExt::with_env_var(request, "PGDATA", PGDATA); + if let Ok(userns) = std::env::var(USERNS_MODE) { + request = testcontainers::ImageExt::with_userns_mode(request, &userns); + } + let container = request.start().await.unwrap_or_else(|error| { + panic!("{GATE}=1 was set but a Postgres container could not be started: {error}") + }); let host = container .get_host() .await From e9e78431a10e28276669613b40107d76b95ab9a8 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 02:03:57 -0400 Subject: [PATCH 085/243] feat(server): add the Postgres asset index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `S-C37`'s central claim is that a sequence number is allocated inside the same critical section that makes its row readable — and its own conformance suite says a single-process suite cannot exhibit the race that claim is about, so the structure has to live in the adapter. This is that adapter: every mutating operation is one `BEGIN … COMMIT` that takes `SELECT … FOR UPDATE` on the asset row, mints from `owner_sequences` inside it, and commits both together. `owner_sequences` is a counter row and never a `SEQUENCE` or a `bigserial`. `nextval` is deliberately non-transactional: it hands 5 and 6 to two concurrent finalizations and does not roll back, so a reader who sees 6 commit first can page past 5 forever. That is the whole of `S-C21`, and a counter row updated by the allocating transaction makes allocation order equal commit order. Each write hydrates the whole `AssetRow` under the lock, applies the same free functions the in-memory adapter applies, and writes it back. Expressing the state machine as a chain of `UPDATE … WHERE` statements would be a second statement of rules the port already fixes, in a language the conformance suite cannot see. `is_singular` and `set_singular` move from `index/memory.rs` up to `index/mod.rs` beside `entry_for` for exactly that reason: which roles are singular is a security property — `record_blob` refuses to re-point a role a signature names — and the `S-C52` retention rule inside `set_singular` is the kind of thing two implementations drift on, where the drift reclaims the server's own rebuttal evidence. `reference_count` is a `COUNT(*)`, never a stored counter, per the refcount rule in design/filesystem/server.md: a counter is a second copy of a derivable fact, and one that drifts low deletes a live blob. Two conformance cases are added, and they are new coverage rather than adapter-specific tests: `AssetIndex::rows` — the scrub's walk — had none at all. The first asserts it covers pending, visible and tombstoned rows and resumes at any page size. The second asserts the walk orders by the identifier's own bytes, which is what made the adapter pin `COLLATE "C"`: asset ids are the manifest's client-chosen `file_id` and are full of punctuation, and a glibc PostgreSQL ignores `-` at the primary collation level, so `walkord-a-b, walkord-ab, walkord-a-c` there against `walkord-a-b, walkord-a-c, walkord-ab` by bytes. A cursor handed between two adapters that disagree about that skips rows. Refs #402 --- capsule-server/src/index/conformance.rs | 106 +++ capsule-server/src/index/memory.rs | 43 +- capsule-server/src/index/mod.rs | 50 + capsule-server/src/index/postgres.rs | 1104 +++++++++++++++++++++++ 4 files changed, 1261 insertions(+), 42 deletions(-) create mode 100644 capsule-server/src/index/postgres.rs diff --git a/capsule-server/src/index/conformance.rs b/capsule-server/src/index/conformance.rs index 3e482b77..53d9ba2a 100644 --- a/capsule-server/src/index/conformance.rs +++ b/capsule-server/src/index/conformance.rs @@ -1474,6 +1474,110 @@ pub async fn holding_an_unknown_asset_is_not_found(index: &dyn AssetIndex) { ); } +// --------------------------------------------------------------------------------------- +// The scrub's walk +// --------------------------------------------------------------------------------------- + +/// Every row is walked, whatever its state, and the walk resumes where it stopped. +/// +/// The scrub's input, and it was uncovered until the Postgres adapter arrived — which is +/// exactly the shape of gap a shared suite exists to close. Two properties, and both are the +/// port's own words: +/// +/// - *"Every row, whatever its state. A scrub that skipped pending or tombstoned rows would +/// skip exactly the rows a half-finished write leaves behind."* +/// - *"an interrupted pass resumes where it stopped rather than starting over — which for a +/// store worth scrubbing is the difference between a check that finishes and one that never +/// does."* +pub async fn the_row_walk_covers_every_state_and_resumes(index: &dyn AssetIndex) { + // One row in each state, so a filter on state would drop one of them. + let pending_only = pending("walk", 1); + let unseen = pending_only.asset_id.clone(); + ok(index.reserve(pending_only).await, "reserve a pending row"); + let (visible, _) = publish(index, "walk", 2).await; + let (deleted, _) = publish(index, "walk", 3).await; + ok( + index.tombstone(&deleted, Timestamp::UNIX_EPOCH).await, + "tombstone a row", + ); + + for page_size in [1_usize, 2, 50] { + let mut cursor: Option = None; + let mut seen: Vec = Vec::new(); + loop { + let page = ok( + index.rows(cursor.as_ref(), page_size).await, + "walk the asset rows", + ); + if page.is_empty() { + break; + } + assert!( + page.len() <= page_size, + "a page of {} exceeded the requested {page_size}", + page.len() + ); + for row in &page { + if let Some(previous) = seen.last() { + assert!( + &row.asset_id > previous, + "the walk went backwards: {} came after {previous}", + row.asset_id + ); + } + seen.push(row.asset_id.clone()); + } + cursor = page.last().map(|row| row.asset_id.clone()); + } + + for expected in [&unseen, &visible, &deleted] { + assert!( + seen.contains(expected), + "the walk at page size {page_size} missed {expected}; a scrub that skips a \ + pending or tombstoned row skips exactly the rows a half-finished write leaves \ + behind" + ); + } + let mut unique = seen.clone(); + unique.dedup(); + assert_eq!( + unique.len(), + seen.len(), + "the walk at page size {page_size} returned a row twice" + ); + } +} + +/// The walk's order is the identifier's own byte order, not a locale's. +/// +/// Asset ids are the manifest's client-chosen `file_id`, so they are full of punctuation and are +/// not a shape the suite gets to pick. A backend ordering them under a locale collation — which +/// is what `en_US.utf8` does, ignoring `-` at the primary level — walks them in a different +/// order from the deterministic double, and a cursor handed between the two skips rows. The +/// three ids below are the smallest set where byte order and a punctuation-ignoring collation +/// disagree. +pub async fn the_row_walk_orders_by_the_identifiers_own_bytes(index: &dyn AssetIndex) { + let ids = ["walkord-a-b", "walkord-ab", "walkord-a-c"]; + for id in ids { + let mut row = pending("walkord", 0); + row.asset_id = AssetId::new(id); + ok(index.reserve(row).await, "reserve a row"); + } + + let walked: Vec = ok(index.rows(None, 100).await, "walk the asset rows") + .into_iter() + .filter(|row| row.asset_id.as_str().starts_with("walkord-")) + .map(|row| row.asset_id.as_str().to_owned()) + .collect(); + let mut expected: Vec = ids.iter().map(|id| (*id).to_owned()).collect(); + expected.sort(); + assert_eq!( + walked, expected, + "the walk must order asset ids by their bytes, so a cursor means the same thing to \ + every adapter" + ); +} + pub async fn run_all(index: &dyn AssetIndex) { reserving_twice_joins_the_same_row(index).await; a_disagreeing_reservation_is_refused_without_disclosure(index).await; @@ -1507,6 +1611,8 @@ pub async fn run_all(index: &dyn AssetIndex) { a_restore_clears_the_retention_floor(index).await; a_serving_hold_is_placed_reported_and_lifted(index).await; holding_an_unknown_asset_is_not_found(index).await; + the_row_walk_covers_every_state_and_resumes(index).await; + the_row_walk_orders_by_the_identifiers_own_bytes(index).await; } #[cfg(test)] diff --git a/capsule-server/src/index/memory.rs b/capsule-server/src/index/memory.rs index facc950e..98bc0d0b 100644 --- a/capsule-server/src/index/memory.rs +++ b/capsule-server/src/index/memory.rs @@ -19,7 +19,7 @@ use jiff::Timestamp; use super::{ AssetIndex, AssetRow, AssetState, BlobOutcome, BlobRecord, BlobRef, FeedEntry, HoldOutcome, IndexFuture, LifecycleOp, OpAction, OpOutcome, PendingAsset, Reservation, ServingHold, - entry_for, + entry_for, is_singular, set_singular, }; use crate::blob::ContentAddress; use crate::store::{AlbumId, AssetId, BlobRole, OwnerId}; @@ -30,47 +30,6 @@ fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { mutex.lock().unwrap_or_else(PoisonError::into_inner) } -/// The roles an asset may hold exactly one of. -/// -/// The manifest, the metadata blob and the original are each named by the signed manifest, so -/// a second address under one of these roles is a contradiction rather than an addition. -/// Derivatives and backups are plural by nature — an asset has a thumbnail *and* a preview. -fn is_singular(role: BlobRole) -> bool { - matches!( - role, - BlobRole::Original | BlobRole::Metadata | BlobRole::Provenance - ) -} - -/// Point `role` at `address`, replacing whatever it held. -/// -/// The one place a singular role legitimately moves. [`AssetIndex::record_blob`] refuses to -/// re-point one because an upload doing so would swap bytes under a signature that still -/// verifies against the old ones; a lifecycle op is the *authorized* form of the same change, -/// and it arrives with a manifest chaining onto the one it supersedes. -fn set_singular(row: &mut AssetRow, role: BlobRole, address: &ContentAddress) { - // `S-C52`: a superseded *manifest* is kept referenced rather than dropped. Only the - // provenance role — the other singular roles are ciphertext, and the old bytes of a replaced - // original are exactly what the collector is for. - if role == BlobRole::Provenance - && let Some(previous) = row.address_for(BlobRole::Provenance).cloned() - && &previous != address - && !row.superseded.contains(&previous) - { - row.superseded.push(previous); - } - row.blobs.retain(|blob| blob.role != role); - row.blobs.push(BlobRef { - role, - address: address.clone(), - // Size is not a fact this path learns: the bytes were stored by whoever put them in the - // blob store, and re-`stat`ing here would make the index depend on the store. - size: 0, - }); - row.blobs - .sort_by(|a, b| (a.role, a.address.as_str()).cmp(&(b.role, b.address.as_str()))); -} - /// Everything the double holds, behind one lock. /// /// One lock rather than two maps with two locks, because the sequence counter and the row it diff --git a/capsule-server/src/index/mod.rs b/capsule-server/src/index/mod.rs index 5e5f5df1..312352bb 100644 --- a/capsule-server/src/index/mod.rs +++ b/capsule-server/src/index/mod.rs @@ -59,6 +59,7 @@ pub mod conformance; pub mod memory; +pub mod postgres; use capsule_core::crypto::hash::Hash32; use jiff::Timestamp; @@ -713,6 +714,55 @@ pub trait AssetIndex: std::fmt::Debug + Send + Sync { fn head_seq<'a>(&'a self, owner: &'a OwnerId) -> IndexFuture<'a, u64>; } +/// The roles an asset may hold exactly one of. +/// +/// The manifest, the metadata blob and the original are each named by the signed manifest, so a +/// second address under one of these roles is a contradiction rather than an addition. +/// Derivatives and backups are plural by nature — an asset has a thumbnail *and* a preview. +/// +/// Free function beside [`entry_for`] rather than a method on an adapter, and for the same +/// reason: two adapters that disagreed about which roles are singular would disagree about when +/// [`BlobOutcome::Conflict`] is the answer, which is a security property (`record_blob` refuses +/// to re-point a role a signature names) and not a formatting detail. +pub(crate) fn is_singular(role: BlobRole) -> bool { + matches!( + role, + BlobRole::Original | BlobRole::Metadata | BlobRole::Provenance + ) +} + +/// Point `role` at `address`, replacing whatever it held. +/// +/// The one place a singular role legitimately moves. [`AssetIndex::record_blob`] refuses to +/// re-point one because an upload doing so would swap bytes under a signature that still +/// verifies against the old ones; a lifecycle op is the *authorized* form of the same change, +/// and it arrives with a manifest chaining onto the one it supersedes. +/// +/// Shared by every adapter for the reason [`entry_for`] is: the `S-C52` retention rule below — +/// a superseded *manifest* is kept referenced rather than dropped — is exactly the kind of thing +/// two implementations drift on, and the drift reclaims the server's own rebuttal evidence. +pub(crate) fn set_singular(row: &mut AssetRow, role: BlobRole, address: &ContentAddress) { + // `S-C52`: only the provenance role. The other singular roles are ciphertext, and the old + // bytes of a replaced original are exactly what the collector is for. + if role == BlobRole::Provenance + && let Some(previous) = row.address_for(BlobRole::Provenance).cloned() + && &previous != address + && !row.superseded.contains(&previous) + { + row.superseded.push(previous); + } + row.blobs.retain(|blob| blob.role != role); + row.blobs.push(BlobRef { + role, + address: address.clone(), + // Size is not a fact this path learns: the bytes were stored by whoever put them in the + // blob store, and re-`stat`ing here would make the index depend on the store. + size: 0, + }); + row.blobs + .sort_by(|a, b| (a.role, a.address.as_str()).cmp(&(b.role, b.address.as_str()))); +} + /// Build the feed entry a row presents to a reader sitting at `after`. /// /// Free function rather than a method so every adapter renders an entry identically: the diff --git a/capsule-server/src/index/postgres.rs b/capsule-server/src/index/postgres.rs new file mode 100644 index 00000000..284a3f4f --- /dev/null +++ b/capsule-server/src/index/postgres.rs @@ -0,0 +1,1104 @@ +//! [`PostgresAssetIndex`] — the durable asset index (`S-C37`, #402). +//! +//! # The one thing this adapter exists to get right +//! +//! A sequence number is allocated **inside the same critical section that makes its row +//! readable**. The in-memory double gets that from holding one mutex across both writes; this +//! gets it from a row lock held to commit, which is the structure `index/mod.rs` designed the +//! port around and the one a single-process conformance suite cannot prove. Every mutating +//! operation here is one `BEGIN … COMMIT` that takes `SELECT … FOR UPDATE` on the asset row +//! first, mints from `owner_sequences` inside it, and commits both together. +//! +//! `owner_sequences` is a **counter row**, never a Postgres `SEQUENCE` or a `bigserial`. +//! `nextval` is deliberately non-transactional: it hands 5 and 6 to two concurrent +//! finalizations and does not roll back, so a reader who sees 6 commit first can page past 5 +//! forever. `index/mod.rs` names that as the whole of `S-C21`, and a counter row updated by the +//! allocating transaction makes allocation order equal commit order. +//! +//! # Why the mutations are written in Rust rather than in SQL +//! +//! Each write hydrates the whole [`AssetRow`] under the lock, applies the same free functions +//! the in-memory adapter applies ([`is_singular`], [`set_singular`], +//! [`AssetRow::is_publishable`]), and writes the row back before committing. The alternative — +//! expressing the state machine as a chain of `UPDATE … WHERE` statements — would be a *second* +//! statement of rules the port already fixes, in a language where the conformance suite cannot +//! see it. The lock is held for the length of one small in-memory computation, and the rules +//! that decide what a row becomes stay in one place for both adapters. +//! +//! # What is a query and never a stored number +//! +//! [`AssetIndex::reference_count`] is a `COUNT(*)`. design/filesystem/server.md fixes that: a +//! blob's reference count is derived from the rows that name it, because a counter is a second +//! copy of a derivable fact and a counter that drifts low deletes a live blob. Superseded +//! manifests count (`S-C52`), or the collector reclaims the server's own rebuttal evidence. + +use capsule_core::crypto::hash::Hash32; +use jiff::Timestamp; +use sea_orm::{ + ConnectionTrait, DatabaseConnection, DatabaseTransaction, DbBackend, Statement, + TransactionTrait, Value, +}; + +use super::{ + AssetIndex, AssetRow, AssetState, BlobOutcome, BlobRecord, BlobRef, BlobReference, FeedEntry, + HoldOutcome, IndexFuture, LifecycleOp, OpAction, OpOutcome, PendingAsset, Reservation, + ServingHold, entry_for, is_singular, set_singular, +}; +use crate::blob::ContentAddress; +use crate::postgres::error::Port; +use crate::postgres::time::{from_micros, to_micros}; +use crate::store::{AlbumId, AssetId, BlobRole, OwnerId, StoreError}; + +/// Which port is speaking, for every error this adapter raises. +const PORT: Port = Port { + store: "asset-index", + record: "AssetRow", +}; + +/// The durable asset index. +#[derive(Debug, Clone)] +pub struct PostgresAssetIndex { + connection: DatabaseConnection, +} + +impl PostgresAssetIndex { + /// An index over `connection`. + /// + /// The schema is **not** applied here: `capsule-server` cannot link the migrator, and + /// `postgres::assert_schema_current` is what refuses to boot against a database that has not + /// been migrated. + pub fn new(connection: DatabaseConnection) -> Self { + Self { connection } + } +} + +// ------------------------------------------------------------------------------------------- +// Column encodings +// +// Every enum crosses the boundary as its own stable wire token rather than as an ordinal, for +// the reason the tokens exist at all: an ordinal is a number whose meaning lives in the order of +// a Rust `enum`, and inserting a variant would silently re-label every stored row. +// ------------------------------------------------------------------------------------------- + +/// The token an [`AssetState`] is stored as. +fn state_token(state: AssetState) -> &'static str { + match state { + AssetState::Pending => "pending", + AssetState::Visible => "visible", + AssetState::Tombstoned => "tombstoned", + } +} + +/// Read a stored state back, or say the row is undecodable. +fn state_from(token: &str) -> Result { + match token { + "pending" => Ok(AssetState::Pending), + "visible" => Ok(AssetState::Visible), + "tombstoned" => Ok(AssetState::Tombstoned), + other => Err(PORT.undecodable(format!("`{other}` is not an asset state"))), + } +} + +/// Read a stored hold back. +fn hold_from(token: Option) -> Result, StoreError> { + match token.as_deref() { + None => Ok(None), + Some("takedown") => Ok(Some(ServingHold::Takedown)), + Some("legal_hold") => Ok(Some(ServingHold::LegalHold)), + Some(other) => Err(PORT.undecodable(format!("`{other}` is not a serving hold"))), + } +} + +/// Read a stored blob role back. +fn role_from(token: &str) -> Result { + match token { + "original" => Ok(BlobRole::Original), + "derivative" => Ok(BlobRole::Derivative), + "metadata" => Ok(BlobRole::Metadata), + "provenance" => Ok(BlobRole::Provenance), + "backup" => Ok(BlobRole::Backup), + other => Err(PORT.undecodable(format!("`{other}` is not a blob role"))), + } +} + +/// Read a stored content address back. +fn address_from(text: &str) -> Result { + ContentAddress::parse(text) + .map_err(|error| PORT.undecodable(format!("a stored address is malformed ({error})"))) +} + +/// Read a stored instant back. +fn instant_from(micros: i64) -> Result { + from_micros(micros) + .ok_or_else(|| PORT.undecodable(format!("{micros}µs is not a representable instant"))) +} + +/// Read a stored chain head back. +fn hash_from(bytes: Option>) -> Result, StoreError> { + let Some(bytes) = bytes else { return Ok(None) }; + let sized: [u8; 32] = bytes.as_slice().try_into().map_err(|_| { + PORT.undecodable(format!( + "a stored chain head is {} bytes rather than 32", + bytes.len() + )) + })?; + Ok(Some(Hash32::from_bytes(sized))) +} + +/// A sequence number as the port speaks it. +/// +/// Sequence numbers are `u64` above this boundary and `BIGINT` below it. The conversion is +/// fallible in exactly one direction, and a negative one in the column is a corrupt row rather +/// than a number to clamp. +fn sequence_from(value: i64) -> Result { + u64::try_from(value).map_err(|_| PORT.undecodable(format!("{value} is not a sequence number"))) +} + +/// A byte count as the column holds it. +fn size_to_column(size: u64) -> Result { + i64::try_from(size).map_err(|_| StoreError::Rejected { + store: PORT.store, + detail: format!("{size} bytes is past what a BIGINT column holds"), + }) +} + +/// A byte count as the port speaks it. +fn size_from(value: i64) -> Result { + u64::try_from(value).map_err(|_| PORT.undecodable(format!("{value} is not a byte count"))) +} + +// ------------------------------------------------------------------------------------------- +// Reading a row back +// ------------------------------------------------------------------------------------------- + +/// Every column of `assets`, in the order every `SELECT` below lists them. +const ASSET_COLUMNS: &str = "asset_id, owner_id, album_id, protocol_version, crypto_suite_id, \ + state, hold, sync_seq, first_seq, chain_head, amk_version, \ + retention_until, created_at, updated_at"; + +/// Turn one `assets` row into an [`AssetRow`] with empty collections. +/// +/// The blobs and the superseded chain are loaded separately, so a page of rows costs three +/// queries rather than three per row. +fn asset_without_collections(row: &sea_orm::QueryResult) -> Result { + let column = PORT.failing("reading an asset row"); + let asset_id: String = row.try_get("", "asset_id").map_err(&column)?; + let owner_id: String = row.try_get("", "owner_id").map_err(&column)?; + let album_id: String = row.try_get("", "album_id").map_err(&column)?; + let protocol_version: String = row.try_get("", "protocol_version").map_err(&column)?; + let crypto_suite_id: i32 = row.try_get("", "crypto_suite_id").map_err(&column)?; + let state: String = row.try_get("", "state").map_err(&column)?; + let hold: Option = row.try_get("", "hold").map_err(&column)?; + let sync_seq: Option = row.try_get("", "sync_seq").map_err(&column)?; + let first_seq: Option = row.try_get("", "first_seq").map_err(&column)?; + let chain_head: Option> = row.try_get("", "chain_head").map_err(&column)?; + let amk_version: i64 = row.try_get("", "amk_version").map_err(&column)?; + let retention_until: Option = row.try_get("", "retention_until").map_err(&column)?; + let created_at: i64 = row.try_get("", "created_at").map_err(&column)?; + let updated_at: i64 = row.try_get("", "updated_at").map_err(&column)?; + + Ok(AssetRow { + asset_id: AssetId::new(asset_id), + owner_id: OwnerId::new(owner_id), + album_id: AlbumId::new(album_id), + protocol_version, + crypto_suite_id: u16::try_from(crypto_suite_id) + .map_err(|_| PORT.undecodable(format!("{crypto_suite_id} is not a crypto suite id")))?, + state: state_from(&state)?, + blobs: Vec::new(), + first_seq: first_seq.map(sequence_from).transpose()?, + sync_seq: sync_seq.map(sequence_from).transpose()?, + chain_head: hash_from(chain_head)?, + amk_version: sequence_from(amk_version)?, + superseded: Vec::new(), + hold: hold_from(hold)?, + retention_until: retention_until.map(instant_from).transpose()?, + created_at: instant_from(created_at)?, + updated_at: instant_from(updated_at)?, + }) +} + +/// Fill in `row`'s blobs and superseded chain. +async fn load_collections( + connection: &C, + row: &mut AssetRow, +) -> Result<(), StoreError> { + let key = Value::from(row.asset_id.as_str().to_owned()); + + let blobs = connection + .query_all(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT role, address, size FROM asset_blobs WHERE asset_id = $1", + [key.clone()], + )) + .await + .map_err(PORT.failing("reading an asset's blobs"))?; + for blob in &blobs { + let role: String = blob + .try_get("", "role") + .map_err(PORT.failing("reading a blob role"))?; + let address: String = blob + .try_get("", "address") + .map_err(PORT.failing("reading a blob address"))?; + let size: i64 = blob + .try_get("", "size") + .map_err(PORT.failing("reading a blob size"))?; + row.blobs.push(BlobRef { + role: role_from(&role)?, + address: address_from(&address)?, + size: size_from(size)?, + }); + } + // The port contracts role-then-address order and a `Vec` will not sort itself, so two + // adapters that accepted the same blobs hold the same row. + row.blobs.sort(); + + let superseded = connection + .query_all(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT address FROM asset_superseded WHERE asset_id = $1 ORDER BY position", + [key], + )) + .await + .map_err(PORT.failing("reading an asset's superseded manifests"))?; + for held in &superseded { + let address: String = held + .try_get("", "address") + .map_err(PORT.failing("reading a superseded address"))?; + row.superseded.push(address_from(&address)?); + } + + Ok(()) +} + +/// The whole row for `asset`, or `None`. +async fn hydrate( + connection: &C, + asset: &AssetId, +) -> Result, StoreError> { + let found = connection + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + format!("SELECT {ASSET_COLUMNS} FROM assets WHERE asset_id = $1"), + [Value::from(asset.as_str().to_owned())], + )) + .await + .map_err(PORT.failing("reading an asset row"))?; + let Some(found) = found else { return Ok(None) }; + let mut row = asset_without_collections(&found)?; + load_collections(connection, &mut row).await?; + Ok(Some(row)) +} + +/// The whole row for `asset`, with its row locked until the transaction commits. +/// +/// `FOR UPDATE` is what makes the sequence mint below it commit-ordered: two finalizations for +/// one asset serialize here, and the number the second one gets is allocated after the first has +/// committed rather than beside it. +async fn hydrate_locked( + transaction: &DatabaseTransaction, + asset: &AssetId, +) -> Result, StoreError> { + let found = transaction + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + format!("SELECT {ASSET_COLUMNS} FROM assets WHERE asset_id = $1 FOR UPDATE"), + [Value::from(asset.as_str().to_owned())], + )) + .await + .map_err(PORT.failing("locking an asset row"))?; + let Some(found) = found else { return Ok(None) }; + let mut row = asset_without_collections(&found)?; + load_collections(transaction, &mut row).await?; + Ok(Some(row)) +} + +/// Hydrate each of `rows`' collections, in order. +async fn load_all_collections( + connection: &C, + rows: &mut [AssetRow], +) -> Result<(), StoreError> { + for row in rows { + load_collections(connection, row).await?; + } + Ok(()) +} + +// ------------------------------------------------------------------------------------------- +// Writing a row back +// ------------------------------------------------------------------------------------------- + +/// Allocate `owner`'s next sequence number, inside the caller's transaction. +/// +/// The upsert is the allocation: the row is created at 1 on an owner's first publication and +/// incremented under the transaction's lock afterwards, so allocation order is commit order and +/// the skip window a `SEQUENCE` produces is not expressible. Numbers start at 1 so that a fresh +/// client's cursor and "I have seen nothing" are the same value. +async fn mint(transaction: &DatabaseTransaction, owner: &OwnerId) -> Result { + let minted = transaction + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "INSERT INTO owner_sequences (owner_id, next_seq) VALUES ($1, 1) \ + ON CONFLICT (owner_id) DO UPDATE SET next_seq = owner_sequences.next_seq + 1 \ + RETURNING next_seq", + [Value::from(owner.as_str().to_owned())], + )) + .await + .map_err(PORT.failing("allocating a sequence number"))? + .ok_or_else(|| StoreError::Rejected { + store: PORT.store, + detail: "the sequence allocation returned no row".to_owned(), + })?; + let next: i64 = minted + .try_get("", "next_seq") + .map_err(PORT.failing("reading an allocated sequence number"))?; + sequence_from(next) +} + +/// Write `row`'s mutable columns, blobs and superseded chain back. +/// +/// Whole-collection replacement rather than a diff, and deliberately: the row is locked, the +/// collections are a handful of entries, and a diff would be a second description of what the +/// mutation did — one that can disagree with the row the caller is about to return. +async fn persist(transaction: &DatabaseTransaction, row: &AssetRow) -> Result<(), StoreError> { + let asset = Value::from(row.asset_id.as_str().to_owned()); + transaction + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "UPDATE assets SET state = $2, hold = $3, sync_seq = $4, first_seq = $5, \ + chain_head = $6, amk_version = $7, retention_until = $8, updated_at = $9 \ + WHERE asset_id = $1", + [ + asset.clone(), + Value::from(state_token(row.state).to_owned()), + Value::from(row.hold.map(|hold| hold.as_str().to_owned())), + Value::from(row.sync_seq.map(|seq| seq as i64)), + Value::from(row.first_seq.map(|seq| seq as i64)), + Value::from(row.chain_head.map(|head| head.as_bytes().to_vec())), + Value::from(row.amk_version as i64), + Value::from(row.retention_until.map(to_micros)), + Value::from(to_micros(row.updated_at)), + ], + )) + .await + .map_err(PORT.failing("updating an asset row"))?; + + transaction + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "DELETE FROM asset_blobs WHERE asset_id = $1", + [asset.clone()], + )) + .await + .map_err(PORT.failing("clearing an asset's blobs"))?; + for blob in &row.blobs { + transaction + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "INSERT INTO asset_blobs (asset_id, role, address, size) VALUES ($1, $2, $3, $4)", + [ + asset.clone(), + Value::from(blob.role.as_str().to_owned()), + Value::from(blob.address.as_str().to_owned()), + Value::from(size_to_column(blob.size)?), + ], + )) + .await + .map_err(PORT.failing("recording an asset's blob"))?; + } + + transaction + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "DELETE FROM asset_superseded WHERE asset_id = $1", + [asset.clone()], + )) + .await + .map_err(PORT.failing("clearing an asset's superseded manifests"))?; + for (position, address) in row.superseded.iter().enumerate() { + transaction + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "INSERT INTO asset_superseded (asset_id, position, address) VALUES ($1, $2, $3)", + [ + asset.clone(), + Value::from(position as i64), + Value::from(address.as_str().to_owned()), + ], + )) + .await + .map_err(PORT.failing("recording a superseded manifest"))?; + } + + Ok(()) +} + +/// Begin a transaction, or say why not. +async fn begin(connection: &DatabaseConnection) -> Result { + connection + .begin() + .await + .map_err(PORT.failing("opening a transaction")) +} + +/// Commit, or say why not. +async fn commit(transaction: DatabaseTransaction) -> Result<(), StoreError> { + transaction + .commit() + .await + .map_err(PORT.failing("committing a transaction")) +} + +impl AssetIndex for PostgresAssetIndex { + fn reserve(&self, asset: PendingAsset) -> IndexFuture<'_, Reservation> { + Box::pin(async move { + // `ON CONFLICT DO NOTHING` rather than a read followed by a write: two sessions of + // one bundle reserve unconditionally at creation, so this is the *normal* path and + // a read-then-write would let both believe they created the row. + let inserted = self + .connection + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "INSERT INTO assets (asset_id, owner_id, album_id, protocol_version, \ + crypto_suite_id, state, amk_version, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, 'pending', 0, $6, $6) \ + ON CONFLICT (asset_id) DO NOTHING", + [ + Value::from(asset.asset_id.as_str().to_owned()), + Value::from(asset.owner_id.as_str().to_owned()), + Value::from(asset.album_id.as_str().to_owned()), + Value::from(asset.protocol_version.clone()), + Value::from(i32::from(asset.crypto_suite_id)), + Value::from(to_micros(asset.created_at)), + ], + )) + .await + .map_err(PORT.failing("reserving an asset row"))?; + + let Some(existing) = hydrate(&self.connection, &asset.asset_id).await? else { + // Only reachable if the row was purged between the insert and the read back, + // which no path in this crate does; treated as a refusal rather than a panic. + return Err(StoreError::Rejected { + store: PORT.store, + detail: "the reserved row disappeared before it could be read back".to_owned(), + }); + }; + if inserted.rows_affected() == 1 { + tracing::debug!(asset = %asset.asset_id, "reserved a pending asset row"); + return Ok(Reservation::Created(Box::new(existing))); + } + + let agrees = existing.owner_id == asset.owner_id + && existing.album_id == asset.album_id + && existing.protocol_version == asset.protocol_version + && existing.crypto_suite_id == asset.crypto_suite_id; + Ok(if agrees { + Reservation::Joined(Box::new(existing)) + } else { + // Carries nothing: the caller is by definition not the party the row belongs + // to, and the asset id is client-chosen. + Reservation::Conflict + }) + }) + } + + fn read<'a>(&'a self, asset: &'a AssetId) -> IndexFuture<'a, Option> { + Box::pin(async move { hydrate(&self.connection, asset).await }) + } + + fn record_blob<'a>( + &'a self, + asset: &'a AssetId, + blob: BlobRecord, + ) -> IndexFuture<'a, BlobOutcome> { + Box::pin(async move { + let transaction = begin(&self.connection).await?; + let Some(row) = hydrate_locked(&transaction, asset).await? else { + return Ok(BlobOutcome::NotFound); + }; + + if row + .blobs + .iter() + .any(|held| held.role == blob.role && held.address == blob.address) + { + // A retried finalization. Idempotent by address rather than by role, so a + // genuine retry is free. + return Ok(BlobOutcome::AlreadyHeld(Box::new(row))); + } + if is_singular(blob.role) && row.blobs.iter().any(|held| held.role == blob.role) { + // Refused rather than overwritten: an upload that re-pointed a singular role + // would swap bytes under a signature that still verifies against the old ones. + return Ok(BlobOutcome::Conflict); + } + + let mut row = row; + row.blobs.push(BlobRef { + role: blob.role, + address: blob.address, + size: blob.size, + }); + row.blobs.sort(); + row.updated_at = blob.finalized_at; + + // The create's provenance blob is the asset's first accepted manifest, so it is the + // chain the first lifecycle op must name (invariant 17). Set from the record's own + // `manifest_sha256` and never from the content address (`S-C31`). + if blob.role == BlobRole::Provenance + && row.chain_head.is_none() + && let Some(manifest_sha256) = blob.manifest_sha256 + { + row.chain_head = Some(manifest_sha256); + } + + let minted = if row.state == AssetState::Tombstoned { + // A late blob for a deleted asset is stored — the bytes exist and GC must see + // the reference — but publishes nothing. + None + } else if row.is_publishable() { + let seq = mint(&transaction, &row.owner_id).await?; + row.state = AssetState::Visible; + row.sync_seq = Some(seq); + row.first_seq = Some(row.first_seq.unwrap_or(seq)); + Some(seq) + } else { + None + }; + + persist(&transaction, &row).await?; + commit(transaction).await?; + if let Some(seq) = minted { + tracing::info!(%asset, sync_seq = seq, "an asset became visible on its owner's feed"); + } + Ok(BlobOutcome::Recorded { + row: Box::new(row), + minted, + }) + }) + } + + fn tombstone<'a>( + &'a self, + asset: &'a AssetId, + at: Timestamp, + ) -> IndexFuture<'a, Option> { + Box::pin(async move { + let transaction = begin(&self.connection).await?; + let Some(mut row) = hydrate_locked(&transaction, asset).await? else { + return Ok(None); + }; + if row.state == AssetState::Tombstoned { + return Ok(Some(row)); + } + + // A row nobody could see needs no retraction, so it takes no sequence number. It + // still becomes terminal, so its id cannot be reserved back into life. + let was_published = row.sync_seq.is_some(); + row.state = AssetState::Tombstoned; + row.updated_at = at; + if was_published { + row.sync_seq = Some(mint(&transaction, &row.owner_id).await?); + } + persist(&transaction, &row).await?; + commit(transaction).await?; + tracing::info!(%asset, published = was_published, "an asset was tombstoned"); + Ok(Some(row)) + }) + } + + fn find_by_address<'a>( + &'a self, + owner: &'a OwnerId, + album: &'a AlbumId, + address: &'a ContentAddress, + ) -> IndexFuture<'a, Option> { + Box::pin(async move { + // Both scopes are load-bearing and for different reasons — owner is the disclosure + // boundary, album is the merge contract. Ordered by asset id so the answer does not + // depend on physical row order. + let found = self + .connection + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT a.asset_id FROM assets a \ + JOIN asset_blobs b ON b.asset_id = a.asset_id \ + WHERE a.owner_id = $1 AND a.album_id = $2 AND b.address = $3 \ + AND a.state <> 'tombstoned' \ + ORDER BY a.asset_id COLLATE \"C\" LIMIT 1", + [ + Value::from(owner.as_str().to_owned()), + Value::from(album.as_str().to_owned()), + Value::from(address.as_str().to_owned()), + ], + )) + .await + .map_err(PORT.failing("looking an address up in an album"))?; + let Some(found) = found else { return Ok(None) }; + let asset_id: String = found + .try_get("", "asset_id") + .map_err(PORT.failing("reading an asset id"))?; + Ok(Some(AssetId::new(asset_id))) + }) + } + + fn find_reference<'a>( + &'a self, + address: &'a ContentAddress, + ) -> IndexFuture<'a, Option> { + Box::pin(async move { + // Two statements rather than one ordered query: a **visible** reference outranks a + // tombstoned one, and a pending row is not a reference at all. Content addressing + // means two assets share a thumbnail, so deleting one must not take the other's + // bytes with it. + for state in ["visible", "tombstoned"] { + let found = self + .connection + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + format!( + "SELECT {ASSET_COLUMNS} FROM assets a \ + WHERE a.state = $2 AND EXISTS ( \ + SELECT 1 FROM asset_blobs b \ + WHERE b.asset_id = a.asset_id AND b.address = $1) \ + ORDER BY a.asset_id COLLATE \"C\" LIMIT 1" + ), + [ + Value::from(address.as_str().to_owned()), + Value::from(state.to_owned()), + ], + )) + .await + .map_err(PORT.failing("looking an address up for the serving path"))?; + let Some(found) = found else { continue }; + let mut row = asset_without_collections(&found)?; + load_collections(&self.connection, &mut row).await?; + return Ok(Some(BlobReference { + asset_id: row.asset_id.clone(), + owner_id: row.owner_id.clone(), + role: row + .blobs + .iter() + .find(|blob| &blob.address == address) + .map_or(BlobRole::Original, |blob| blob.role), + state: row.state, + original_held: row.original_held(), + hold: row.hold, + })); + } + Ok(None) + }) + } + + fn apply_op(&self, op: LifecycleOp) -> IndexFuture<'_, OpOutcome> { + Box::pin(async move { + let transaction = begin(&self.connection).await?; + + // Idempotency first, before any invariant: a byte-identical resubmission of an + // already-applied op is not a stale chain, it is the same op arriving twice. + // Checking 17 first would answer `409` to a client whose only fault was losing an + // acknowledgement. + let replayed = transaction + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT sync_seq FROM applied_manifests WHERE manifest_hash = $1", + [Value::from(op.manifest_hash.as_bytes().to_vec())], + )) + .await + .map_err(PORT.failing("checking whether a manifest was already applied"))?; + if let Some(replayed) = replayed { + let sync_seq: i64 = replayed + .try_get("", "sync_seq") + .map_err(PORT.failing("reading a replayed sequence number"))?; + tracing::info!( + asset = %op.asset_id, + action = op.action.as_str(), + "a lifecycle write was replayed; nothing was written" + ); + return Ok(OpOutcome::Replayed { + sync_seq: sequence_from(sync_seq)?, + }); + } + + let Some(row) = hydrate_locked(&transaction, &op.asset_id).await? else { + return Ok(OpOutcome::NotFound); + }; + // Not this caller's asset, or not in the album the op was addressed to. Both are + // the same answer, and neither is distinguishable from an asset that never existed. + if row.owner_id != op.owner_id || row.album_id != op.album_id { + tracing::info!( + asset = %op.asset_id, + "a lifecycle write was refused: the asset is not this caller's" + ); + return Ok(OpOutcome::NotFound); + } + // A row nothing can see yet has no chain to extend. + if row.state == AssetState::Pending { + return Ok(OpOutcome::NotFound); + } + + // Invariant 17, decided under the row lock rather than by the caller: a + // read-compare-write above this port has a window in which two ops chain onto the + // same head, which is the double-apply the invariant exists to catch. + if op.prior_provenance_hash != row.chain_head { + tracing::info!( + asset = %op.asset_id, + action = op.action.as_str(), + "a lifecycle write was refused: it does not chain onto the stored head" + ); + return Ok(OpOutcome::StaleChain { + head: row.chain_head, + }); + } + + // Invariant 18, over the **album's** high-water mark rather than this row's: an + // epoch is an album-wide fact, so an op on a stale asset must not re-admit an epoch + // the album has already moved past. + let epoch = transaction + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT COALESCE(MAX(amk_version), 0) AS stored FROM assets WHERE album_id = $1", + [Value::from(op.album_id.as_str().to_owned())], + )) + .await + .map_err(PORT.failing("reading an album's epoch"))? + .ok_or_else(|| StoreError::Rejected { + store: PORT.store, + detail: "the album epoch query returned no row".to_owned(), + })?; + let stored: i64 = epoch + .try_get("", "stored") + .map_err(PORT.failing("reading an album's epoch"))?; + let stored = sequence_from(stored)?; + if op.amk_version < stored { + tracing::info!( + asset = %op.asset_id, + stored, + submitted = op.amk_version, + "a lifecycle write was refused: the album epoch regresses" + ); + return Ok(OpOutcome::AmkRegressed { stored }); + } + + let mut row = row; + row.state = match op.action { + OpAction::Delete => AssetState::Tombstoned, + // A restore returns the asset to the live set. Every other action leaves the + // state alone — re-uploading the bytes of something you deleted does not + // undelete it, because undeleting is what a `trash-restore` is for. + OpAction::TrashRestore => AssetState::Visible, + OpAction::MetadataUpdate | OpAction::Derivative | OpAction::Replace => row.state, + }; + // The provenance blob is re-pointed on every op: the chain *is* a succession of + // manifests, so the newest one is what the feed must serve. + set_singular(&mut row, BlobRole::Provenance, &op.provenance); + if let Some(metadata) = &op.metadata { + set_singular(&mut row, BlobRole::Metadata, metadata); + } + // A replace's whole point (`S-C43`): the authorized form of the change + // `record_blob` refuses, arriving with a manifest that chains onto the one it + // supersedes. + if let Some(original) = &op.original { + set_singular(&mut row, BlobRole::Original, original); + } + row.chain_head = Some(op.manifest_hash); + row.amk_version = op.amk_version; + row.retention_until = match op.action { + OpAction::Delete => op.retention_until, + // Back in the live set: there is no window left to run out. + OpAction::TrashRestore => None, + OpAction::MetadataUpdate | OpAction::Derivative | OpAction::Replace => { + row.retention_until + } + }; + row.updated_at = op.at; + + let sync_seq = mint(&transaction, &row.owner_id).await?; + row.sync_seq = Some(sync_seq); + transaction + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "INSERT INTO applied_manifests (manifest_hash, sync_seq) VALUES ($1, $2)", + [ + Value::from(op.manifest_hash.as_bytes().to_vec()), + Value::from(sync_seq as i64), + ], + )) + .await + .map_err(PORT.failing("recording an applied manifest"))?; + persist(&transaction, &row).await?; + commit(transaction).await?; + + tracing::info!( + asset = %op.asset_id, + action = op.action.as_str(), + sync_seq, + "a lifecycle write was applied" + ); + Ok(OpOutcome::Applied { + row: Box::new(row), + sync_seq, + }) + }) + } + + fn set_hold<'a>( + &'a self, + asset: &'a AssetId, + hold: Option, + ) -> IndexFuture<'a, HoldOutcome> { + Box::pin(async move { + // `IS DISTINCT FROM` rather than `<>` so a null-to-null no-op is `Unchanged` rather + // than an update nobody can see: re-applying a takedown must not append a second + // provenance record claiming the asset was taken down twice. + let applied = self + .connection + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "UPDATE assets SET hold = $2 WHERE asset_id = $1 AND hold IS DISTINCT FROM $2", + [ + Value::from(asset.as_str().to_owned()), + Value::from(hold.map(|hold| hold.as_str().to_owned())), + ], + )) + .await + .map_err(PORT.failing("placing a serving hold"))?; + if applied.rows_affected() == 1 { + if let Some(hold) = hold { + tracing::info!( + %asset, + hold = hold.as_str(), + "an asset was placed under a serving hold; its bytes are untouched" + ); + } else { + tracing::info!(%asset, "an asset's serving hold was lifted"); + } + return Ok(HoldOutcome::Applied); + } + + let exists = self + .connection + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT 1 AS present FROM assets WHERE asset_id = $1", + [Value::from(asset.as_str().to_owned())], + )) + .await + .map_err(PORT.failing("reading an asset row"))?; + Ok(if exists.is_some() { + HoldOutcome::Unchanged + } else { + HoldOutcome::NotFound + }) + }) + } + + fn reference_count<'a>(&'a self, address: &'a ContentAddress) -> IndexFuture<'a, u64> { + Box::pin(async move { + // A **query**, never a stored counter. A manifest the chain has moved past is still + // referenced (`S-C52`); without that the collector reclaims the server's own + // rebuttal evidence. + let counted = self + .connection + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT COUNT(*) AS held FROM assets a WHERE \ + EXISTS (SELECT 1 FROM asset_blobs b \ + WHERE b.asset_id = a.asset_id AND b.address = $1) \ + OR EXISTS (SELECT 1 FROM asset_superseded s \ + WHERE s.asset_id = a.asset_id AND s.address = $1)", + [Value::from(address.as_str().to_owned())], + )) + .await + .map_err(PORT.failing("counting the rows that name an address"))? + .ok_or_else(|| StoreError::Rejected { + store: PORT.store, + detail: "the reference count returned no row".to_owned(), + })?; + let held: i64 = counted + .try_get("", "held") + .map_err(PORT.failing("reading a reference count"))?; + sequence_from(held) + }) + } + + fn rows<'a>( + &'a self, + after: Option<&'a AssetId>, + limit: usize, + ) -> IndexFuture<'a, Vec> { + Box::pin(async move { + // Every row, whatever its state: a scrub that skipped pending or tombstoned rows + // would skip exactly the rows a half-finished write leaves behind. + // + // `COLLATE "C"` is byte order, which is the in-memory adapter's `BTreeMap` order — + // so "asset-id order" means one thing for both adapters rather than whatever the + // database was initialised with. A locale collation would also be *self*-consistent + // here (the cursor comparison and the ordering share it), so this is about adapter + // parity rather than about a resumable walk: en_US.utf8 ignores punctuation at the + // primary level, and asset ids are client-chosen and full of hyphens. + let statement = match after { + Some(after) => Statement::from_sql_and_values( + DbBackend::Postgres, + format!( + "SELECT {ASSET_COLUMNS} FROM assets \ + WHERE asset_id COLLATE \"C\" > $1 \ + ORDER BY asset_id COLLATE \"C\" LIMIT $2" + ), + [ + Value::from(after.as_str().to_owned()), + Value::from(limit as i64), + ], + ), + None => Statement::from_sql_and_values( + DbBackend::Postgres, + format!( + "SELECT {ASSET_COLUMNS} FROM assets ORDER BY asset_id COLLATE \"C\" \ + LIMIT $1" + ), + [Value::from(limit as i64)], + ), + }; + let found = self + .connection + .query_all(statement) + .await + .map_err(PORT.failing("walking the asset rows"))?; + let mut rows = found + .iter() + .map(asset_without_collections) + .collect::, _>>()?; + load_all_collections(&self.connection, &mut rows).await?; + Ok(rows) + }) + } + + fn tombstoned(&self, limit: usize) -> IndexFuture<'_, Vec> { + Box::pin(async move { + // Oldest change first, so a bounded pass makes progress on the oldest deletions + // rather than revisiting the same page. The asset id breaks ties so the order is + // total and a resumed pass is deterministic. + let found = self + .connection + .query_all(Statement::from_sql_and_values( + DbBackend::Postgres, + format!( + "SELECT {ASSET_COLUMNS} FROM assets WHERE state = 'tombstoned' \ + ORDER BY updated_at, asset_id COLLATE \"C\" LIMIT $1" + ), + [Value::from(limit as i64)], + )) + .await + .map_err(PORT.failing("listing tombstoned rows"))?; + let mut rows = found + .iter() + .map(asset_without_collections) + .collect::, _>>()?; + load_all_collections(&self.connection, &mut rows).await?; + Ok(rows) + }) + } + + fn purge<'a>(&'a self, asset: &'a AssetId) -> IndexFuture<'a, Option> { + Box::pin(async move { + let transaction = begin(&self.connection).await?; + let Some(mut row) = hydrate_locked(&transaction, asset).await? else { + return Ok(None); + }; + // The row **stays**. A client that has not synced since the delete still has to + // learn about it, so removing the row would make the deletion invisible rather than + // final. The chain goes with the bytes it describes: a purge is the end of the + // retention window the user's own signed delete fixed (`S-C52`). + row.blobs.clear(); + row.superseded.clear(); + persist(&transaction, &row).await?; + commit(transaction).await?; + tracing::info!(%asset, "purged a tombstoned asset's blob references"); + Ok(Some(row)) + }) + } + + fn feed_page<'a>( + &'a self, + owner: &'a OwnerId, + after: u64, + limit: usize, + ) -> IndexFuture<'a, Vec> { + Box::pin(async move { + let found = self + .connection + .query_all(Statement::from_sql_and_values( + DbBackend::Postgres, + format!( + "SELECT {ASSET_COLUMNS} FROM assets \ + WHERE owner_id = $1 AND sync_seq > $2 \ + ORDER BY sync_seq LIMIT $3" + ), + [ + Value::from(owner.as_str().to_owned()), + Value::from(after as i64), + Value::from(limit as i64), + ], + )) + .await + .map_err(PORT.failing("reading a feed page"))?; + let mut rows = found + .iter() + .map(asset_without_collections) + .collect::, _>>()?; + load_all_collections(&self.connection, &mut rows).await?; + // `entry_for` is shared with the in-memory adapter so both render an entry + // identically — the `ChangeKind` rule in particular is the kind of thing two + // adapters drift on. + Ok(rows + .iter() + .filter_map(|row| entry_for(row, after)) + .collect()) + }) + } + + fn head_seq<'a>(&'a self, owner: &'a OwnerId) -> IndexFuture<'a, u64> { + Box::pin(async move { + // The allocator's own row: the highest number this owner has minted, which is what + // lets a page report whether the client is caught up without asking for another + // page that would come back empty. + let found = self + .connection + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT next_seq FROM owner_sequences WHERE owner_id = $1", + [Value::from(owner.as_str().to_owned())], + )) + .await + .map_err(PORT.failing("reading an owner's head sequence number"))?; + let Some(found) = found else { return Ok(0) }; + let next: i64 = found + .try_get("", "next_seq") + .map_err(PORT.failing("reading an owner's head sequence number"))?; + sequence_from(next) + }) + } +} + +#[cfg(test)] +mod tests { + /// The suite, against a real Postgres. + /// + /// One case, running the whole list in one pass, because nextest runs a process per test and + /// a container per case would be thirty containers rather than one. `index/conformance.rs` + /// said as much before this adapter existed: *"a Postgres-backed smoke test is one `run_all` + /// call"*. + mod postgres_conformance { + use super::super::PostgresAssetIndex; + use crate::index::conformance; + use crate::postgres::testing; + + #[tokio::test] + async fn the_postgres_index_conforms() { + let Some(database) = testing::start("the Postgres asset index").await else { + return; + }; + let index = PostgresAssetIndex::new(database.connection().clone()); + conformance::run_all(&index).await; + } + } +} From 61ce9f69c46eb3787936fcf5d0b8da36b6119bb8 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 02:04:39 -0400 Subject: [PATCH 086/243] docs(core): keep derivative_format's doc links resolvable without the media feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module moved out of `media` so the receivers can link it, and its doc comments moved with it — still pointing at `StillFormat`, `MediaError` and `GeneratedDerivative`, none of which exist in a `--no-default-features` build. Rustdoc caught it as four unresolved intra-doc links. They become prose naming the `media::` path instead of links to it. A module that exists precisely so a feature-gated stack is not a prerequisite must not re-acquire that prerequisite through its documentation. --- capsule-core/src/derivative_format.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/capsule-core/src/derivative_format.rs b/capsule-core/src/derivative_format.rs index 18570be0..770eb18f 100644 --- a/capsule-core/src/derivative_format.rs +++ b/capsule-core/src/derivative_format.rs @@ -2,7 +2,8 @@ //! //! SSoT: [Thumbnails and Previews](https://docs/design/thumbnails/) — the tier table's format //! column *is* this enum, and "every receiver (and every federated peer) compares -//! `DerivativeManifest.format` against this list" is [`verify_still_format`]. +//! `DerivativeManifest.format` against this list" is +//! [`verify_still_format`](crate::derivative_format::verify_still_format). //! //! # Why this is at the crate root and not in `capsule_core::media` //! @@ -36,15 +37,16 @@ pub enum DerivativeFormat { /// encodable in this build. Avif, /// **WebP** — the last-resort delivery fallback. Not encodable in this build: the crate's - /// WebP codec does not compile for aarch64 (see [`super::StillFormat::WebP`]). + /// WebP codec does not compile for aarch64 — see `media::StillFormat::WebP`, which cannot be + /// linked from here because `media` is feature-gated and this module is not. WebP, /// The recognised `format = "original"` sentinel: the tier **references** the original asset /// rather than generating a redundant derivative, because the source is not larger than the /// tier's cap. **Distinct from an absent derivative** — this is an explicit, signed marker, /// where absence means "rebuildable from the original". /// - /// A sentinel derivative carries **no bytes of its own** ([`GeneratedDerivative::bytes`] is - /// empty). "References" is the operative word in the contract: the signed manifest's + /// A sentinel derivative carries **no bytes of its own** (`media::GeneratedDerivative::bytes` + /// is empty). "References" is the operative word in the contract: the signed manifest's /// `ciphertext_hash` content-addresses the original, which the holder already has, so /// copying the bytes under a thumbnail's name would duplicate a file sitting two directories /// up *and* re-expose the original's EXIF — GPS included — as a derivative blob, where a @@ -116,9 +118,10 @@ impl fmt::Display for DerivativeFormat { /// [`None`] rather than as a violation. /// /// # Errors -/// [`MediaError::UnsupportedFormat`] — carrying the still format Capsule *would* have needed — -/// is not what an unrecognised value produces, because there is no [`super::StillFormat`] to -/// name. An unrecognised still-role format is `Err(format.to_string())`. +/// `media::MediaError::UnsupportedFormat` — which carries the still format Capsule *would* have +/// needed — is not what an unrecognised value produces, because there is no still format to name +/// (and this module cannot reference `media` in any case: it is unconditional and `media` is +/// not). An unrecognised still-role format is `Err(format.to_string())`. pub fn verify_still_format( manifest: &DerivativeManifest, ) -> Result, String> { From 54000113f7f4949e7e0f6418d2b3c9b5428058bd Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 02:06:17 -0400 Subject: [PATCH 087/243] docs(wasm): stop decodeLqip's public doc linking a private constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `FALLBACK_FILL` is a private constant, and `decode_lqip`'s doc linked it — which resolves only under `--document-private-items` and fails the rustdoc gate as written. The sentence names the colour instead, which is what a reader of the public API actually needs to know. --- capsule-wasm/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/capsule-wasm/src/lib.rs b/capsule-wasm/src/lib.rs index 12a53ef3..9dab669f 100644 --- a/capsule-wasm/src/lib.rs +++ b/capsule-wasm/src/lib.rs @@ -440,7 +440,7 @@ impl WasmLqipImage { /// threw on a malformed `dominant_color` while the native FFI painted black for the same input — /// one record, two behaviours, decided by which client opened it. That is exactly the /// client-dependent divergence `capsule-core::lqip` exists to prevent, so both surfaces now -/// paint [`FALLBACK_FILL`] and say so. +/// paint the same fallback fill (black, the conventional empty cell) and say so. #[wasm_bindgen(js_name = decodeLqip)] pub fn decode_lqip( format_version: u16, From 459e8af3605d6c706c0d0ebeca2474309cb113fd Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 02:16:55 -0400 Subject: [PATCH 088/243] fix(server): comment out the attestation seed placeholder in the template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repair commit added `ATTESTATION_KEY_SEED` to `.env.example` uncommented, with `$(CHANGE_ME)` as the placeholder, while `JWT_ED25519_DER` beside it was commented out. Both halves of that are wrong. This file is read by more than a shell. `podman --env-file`, compose's `env_file:`, systemd's `EnvironmentFile=` and a Kubernetes ConfigMap all take a line literally — no expansion, no command substitution — so the characters `$(CHANGE_ME)` become the *value*. That is present-but-malformed rather than absent, so `decode_seed` raises `ConfigFault::Invalid` and stops `capsule-server gc|purge|scrub`, which are built to need no key material at all and which no `Demands` arm can excuse it for. Sourced by bash it fails the other way, printing `CHANGE_ME: command not found`. So the line is commented out exactly as the token-signing key is, and the placeholder is plain text. `local-development.md` said "two keys are commented out", which was false when it was written and is now true — it says which two and why, and records that nothing in the template is a shell expression. Two cases in `tests/binary.rs` parse the shipped template rather than restating it: one asserts no uncommented value carries `$` or a backtick, which is the defect's general form and catches it anywhere in the file; the other runs `scrub` under exactly the settings the template ships uncommented and asserts it exits 0 without naming `ATTESTATION_KEY_SEED`. Both were confirmed to fail against the reintroduced placeholder before being kept. Refs #401 --- .../docs/development/local-development.md | 12 ++- capsule-server/.env.example | 12 ++- capsule-server/tests/binary.rs | 76 +++++++++++++++++++ 3 files changed, 98 insertions(+), 2 deletions(-) diff --git a/capsule-docs/src/content/docs/development/local-development.md b/capsule-docs/src/content/docs/development/local-development.md index a6334657..ddcecf48 100644 --- a/capsule-docs/src/content/docs/development/local-development.md +++ b/capsule-docs/src/content/docs/development/local-development.md @@ -103,11 +103,21 @@ JWT_ED25519_DER="$(openssl genpkey -algorithm ed25519 -outform DER | base64 | tr ### A configured server ```bash -cp capsule-server/.env.example capsule-server/.env # then edit it: two keys are commented out +cp capsule-server/.env.example capsule-server/.env # then edit it mise run serve-deps # Postgres 18 + Valkey 9, on loopback mise run serve ``` +The template ships with **both secrets commented out** — `JWT_ED25519_DER` and +`ATTESTATION_KEY_SEED` — so a copy you have not finished editing produces a server that refuses +and names what it wants, rather than one that starts under a published key. Uncomment each and +put your own value in. Every other setting is either a working default or optional. + +Nothing in the template is a shell expression, deliberately: the file is read by more than a +shell — `podman --env-file`, compose's `env_file:`, systemd's `EnvironmentFile=` — and those take +a line literally, so a placeholder shaped like `$(...)` would be stored as the value rather than +replaced. + `serve-deps` and `serve` are separate tasks on purpose: a task that silently starts containers is a task that leaks them. Bring them down with `podman compose -f capsule-server/compose.yaml down` (`docker compose` accepts the same file). diff --git a/capsule-server/.env.example b/capsule-server/.env.example index ca690611..8fa7239c 100644 --- a/capsule-server/.env.example +++ b/capsule-server/.env.example @@ -98,8 +98,18 @@ VALKEY_URL=redis://127.0.0.1:6379 # # openssl rand -base64 64 | tr -d '\n' # +# **Deliberately left commented out**, exactly as JWT_ED25519_DER above is, and for a second +# reason as well. This file is read by more than a shell: `podman --env-file`, compose's +# `env_file:`, systemd's `EnvironmentFile=` and a Kubernetes ConfigMap all take a line +# literally — no expansion, no command substitution — so a placeholder shaped like a shell +# expression would be *stored* as the value. `capsule-server gc|purge|scrub` would then refuse a +# malformed seed, even though those commands are built to need no key material at all. +# +# The placeholder is plain text for the same reason: sourced by bash, `$(...)` would run and +# print `command not found`, which is noise at best and an execution seam at worst. +# # base64, 32 or 64 bytes; 32 is expanded to 64, domain-separated. -ATTESTATION_KEY_SEED=$(CHANGE_ME) +# ATTESTATION_KEY_SEED=replace-with-your-own-base64-seed # ── The protocol window ────────────────────────────────────────────────────────────────────── # diff --git a/capsule-server/tests/binary.rs b/capsule-server/tests/binary.rs index 5c883d2f..8f99fb36 100644 --- a/capsule-server/tests/binary.rs +++ b/capsule-server/tests/binary.rs @@ -556,6 +556,82 @@ fn an_operator_command_without_memory_is_told_that_and_not_about_valkey() { } } +/// The settings `capsule-server/.env.example` ships **uncommented**, as an env-file reader sees +/// them. +/// +/// Parsed rather than duplicated, so the assertions below are about the file an operator +/// actually copies. Comments and blank lines are dropped and the rest is split on the first `=` +/// — which is all `podman --env-file`, compose's `env_file:` and systemd's `EnvironmentFile=` +/// do. None of them expands anything. +fn shipped_template() -> Vec<(String, String)> { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".env.example"); + let text = std::fs::read_to_string(&path).expect("the template ships with the crate"); + let settings: Vec<(String, String)> = text + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .filter_map(|line| line.split_once('=')) + .map(|(key, value)| (key.trim().to_owned(), value.trim().to_owned())) + .collect(); + assert!( + !settings.is_empty(), + "the template has no uncommented settings at all, so the parse below proves nothing" + ); + settings +} + +#[test] +fn the_template_ships_no_value_a_literal_env_file_reader_would_store_verbatim() { + // The defect this guards, in its general form. A placeholder shaped like a shell expression + // — `$(CHANGE_ME)`, `${FOO}`, a backtick — is replaced by nothing when the file is read by + // anything that is not a shell: the *literal characters* become the value. Sourced by bash it + // is worse than useless in the other direction, because it executes. + // + // Every secret in the template is therefore commented out, and every uncommented value is + // plain text. + for (key, value) in shipped_template() { + assert!( + !value.contains('$') && !value.contains('`'), + "{key} ships uncommented with a shell expression as its value ({value}): an env-file \ + reader stores it verbatim, and bash executes it" + ); + } +} + +#[test] +fn a_maintenance_command_runs_on_the_template_as_shipped() { + // `Demands::Maintenance` promises `gc`/`purge`/`scrub` need no key material. That promise is + // only worth anything if the file an operator copies does not hand them a broken one: an + // uncommented `ATTESTATION_KEY_SEED` placeholder is *present but malformed*, which is a + // `ConfigFault::Invalid` rather than an absence, and no `Demands` arm can excuse it. + let template = shipped_template(); + assert!( + !template + .iter() + .any(|(key, _)| key == "ATTESTATION_KEY_SEED" || key == "JWT_ED25519_DER"), + "both secrets must ship commented out: {template:?}" + ); + + let root = tempfile::tempdir().expect("a scratch directory"); + let mut command = server(&[ + "scrub", + "--memory", + // The one thing not taken from the template: a test must not write into the tree the + // template's own `BLOB_ROOT` points at. The flag outranks the environment either way. + "--blob-root", + &root.path().display().to_string(), + ]); + for (key, value) in template { + command.env(key, value); + } + let (code, stdout, stderr) = run(&mut command); + assert_eq!(code, Some(0), "stdout: {stdout}\nstderr: {stderr}"); + assert!( + !stderr.contains("ATTESTATION_KEY_SEED"), + "a maintenance command must not be stopped by key material: {stderr}" + ); +} + #[test] fn an_operator_command_without_a_blob_root_refuses_by_name() { let (code, _, stderr) = run(&mut server(&["scrub", "--memory"])); From 13e25a45e17afcdeac68e5941f1c570f78e29013 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 02:17:10 -0400 Subject: [PATCH 089/243] feat(cli): render --help from the cli.help.* catalog keys Help text was the one user-facing surface the i18n contract could not reach: clap renders doc comments itself, and i18n-guard records the gap as a blind spot. This adds the seam (S-I8). - `cli::help::localize` walks a built `clap::Command` tree and replaces every about/long_about/help/long_help with the catalog message under a key derived from the command path and argument id (`cli.help..about`, `cli.help..arg.`, ...). A missing key leaves the derive text in place, so a partial translation never prints a raw key. - `run()` builds the parser through the rewriter under the bundle negotiated from the process locale; `command_tree()` resolves through an explicitly pinned `en` bundle so `cli-surface.json` stays locale-proof and unchanged. - The 59 `en` entries are the derive text verbatim, and a unit test asserts that for every string in the tree plus byte-identical rendered help under `en`. That test is the gate i18n-guard cannot be for this surface; its blind-spot comment now says so. - The i18n design doc records the decision (help is localized) and the residual: ValueEnum variant help, which clap 4 cannot re-word without discarding the typed parser. Catalogs regenerated with `mise run i18n`. --- .../src/androidMain/res/values/strings.xml | 59 ++ capsule-cli/src/cli/help.rs | 336 ++++++++++ capsule-cli/src/cli/mod.rs | 33 +- capsule-cli/src/i18n.rs | 8 + capsule-cli/src/lib.rs | 15 +- capsule-docs/src/content/docs/design/i18n.md | 34 + capsule-i18n/src/bundles/en.json | 59 ++ capsule-swift/Generated/Localizable.xcstrings | 590 ++++++++++++++++++ capsule-web/src/i18n/messages/en.json | 59 ++ locales/en.json | 236 +++++++ xtask/src/i18n_guard.rs | 14 +- 11 files changed, 1424 insertions(+), 19 deletions(-) create mode 100644 capsule-cli/src/cli/help.rs diff --git a/capsule-android/src/androidMain/res/values/strings.xml b/capsule-android/src/androidMain/res/values/strings.xml index 4c874840..33577e52 100644 --- a/capsule-android/src/androidMain/res/values/strings.xml +++ b/capsule-android/src/androidMain/res/values/strings.xml @@ -1720,6 +1720,65 @@ Swept %1$s rejected asset(s) to trash; recoverable for %2$s day(s). Unknown asset %s in this library. Cull view: %1$s pick, %2$s neutral, %3$s reject. + Authentication commands + Login to Capsule + Account email (prompted when omitted) + Read the password from stdin instead of prompting, so the command works in scripts and CI where there is no terminal + Logout from Capsule + Create a Capsule account and sign in + Account email (prompted when omitted) + Read the password from stdin instead of prompting, so the command works in scripts and CI where there is no terminal + Show authentication status + Review a local library: flag assets, filter by flag, sweep rejects to trash + List the assets carrying one flag instead of only counting them + Path to the Capsule library + Clear an asset\'s flag back to the never-flagged default (repeatable) + Read the library passphrase from stdin instead of prompting, so culling works in scripts and CI where there is no terminal + Flag an asset as a keeper (repeatable) + Flag an asset for rejection (repeatable) + Retention window, in days, the sweep\'s soft delete stamps + Move every rejected asset to trash. The only destructive step, and soft per retention — swept assets stay restorable until the window elapses + Run the offline end-to-end data-plane showcase (real cryptography, no network) + A real image/file to import (a small synthetic file is used if omitted) + Working directory for the demo libraries (a temp dir is used if omitted) + Import files into a local Capsule library + Re-import files even if they already exist (duplicate override) + Path to the Capsule library + Move files instead of copying them + Read the library passphrase from stdin instead of prompting, so imports work in scripts and CI where there is no terminal + Source file or directory to import. Repeatable: a split Takeout export extracted into several folders is imported by naming every part in one run, so a media file and a sidecar that landed in different parts are still paired + Read the source as an export from this service instead of as a plain directory tree, so its out-of-band metadata (capture time, GPS, captions, favorites, album membership) is folded into the imported assets + Push the library to the server after importing — sugar for a `capsule push` run over the same library. The import itself stays offline + Stage the follow-on push (`--push`) in tier order, gating the preview and original tiers on the connection class + Manage the local library + Show library information + Path to the library + Create a new Capsule library + Human-readable library name + Directory for the new library + Rebuild the SQLite index from sidecar files + Path to the library + List the assets the sync feed has delivered + Include assets the server has tombstoned (deleted) as well as live ones + Match metadata for current file + Path to the file to match metadata for + Upload a local Capsule library to the server + Report what would be uploaded without opening a single upload session + Re-drive every blob regardless of what the server already holds + Path to the Capsule library to push + Read the library passphrase from stdin instead of prompting, so pushes work in scripts and CI where there is no terminal + Open the tier sessions in ladder order (index → preview → original), gating the above-index tiers on the connection class, instead of opening all eagerly + Reset all local CLI data + Reset all data + Reset cache directory + Reset configuration + Reset data directory + A command line interface for Capsule - the photo management platform + Capsule CLI provides tools for managing your photos and albums:\n\n- Authentication management\n- Sync local and remote data\n- Check status and list files\n- Manage albums and collections + Show current status + Sync local and remote data + Perform a dry run without making changes + Discard the saved cursor and re-drain the feed from the start. The per-album anti-rewind floor still applies, so this cannot resurrect stale entries Found %1$s candidate(s) (%2$s file(s) total). Done: %1$s imported, %2$s duplicate(s), %3$s error(s). The import failed: %s diff --git a/capsule-cli/src/cli/help.rs b/capsule-cli/src/cli/help.rs new file mode 100644 index 00000000..25422b39 --- /dev/null +++ b/capsule-cli/src/cli/help.rs @@ -0,0 +1,336 @@ +//! Localized `--help` text (slice `S-I8`). +//! +//! `clap`'s derive renders help from doc comments and `#[arg]` attributes — compile-time +//! English with no seam a catalog key could pass through. This module is that seam: a +//! rewriter that walks a built [`Command`] tree and replaces every `about`, `long_about`, +//! `help` and `long_help` with the message the catalog holds under a key derived from the +//! command's position in the tree. The doc comments stay where they are and keep being the +//! source the catalog's `en` entries are checked against, so there is exactly one place the +//! English is authored and one test that proves the catalog has not drifted from it. +//! +//! ## The key grammar +//! +//! One key per help string, derived from the command path and the argument id — the derive +//! field name — so nothing is spelled twice: +//! +//! | Key | Text it replaces | +//! | --- | --- | +//! | `cli.help.root.about` | the root command's `about` | +//! | `cli.help.root.long_about` | the root command's `long_about` | +//! | `cli.help..about` | a subcommand's `about`, e.g. `cli.help.library.init.about` | +//! | `cli.help..long_about` | its `long_about`, when the derive gives it a distinct one | +//! | `cli.help..arg.` | an argument's `help`, e.g. `cli.help.import.arg.library` | +//! | `cli.help..arg..long_help` | its `long_help`, when the derive gives it a distinct one | +//! +//! `` is the dot-joined chain of subcommand names below the root; the root itself is +//! spelled `root`, a name no subcommand may take. +//! +//! ## Two properties the rest of the crate relies on +//! +//! - **A missing key changes nothing.** The look-up is [`Bundle::message`], which reports a +//! miss as `None` rather than as the key, and a miss leaves the derive text in place. So a +//! locale with no `cli.help.*` entries yet renders today's English, never a raw key in a +//! terminal — and a locale with *some* entries renders a mix, which is what partial +//! translation is supposed to look like. +//! - **Under the source locale the tree is unchanged.** Every `en` entry is asserted equal to +//! the derive text it replaces, so `capsule --help` under `LANG=C` is byte-identical to +//! the un-localized rendering, and [`command_tree`](super::command_tree) — which resolves +//! through an explicitly pinned `en` bundle — emits the same `cli-surface.json` it did +//! before help was localized. +//! +//! What this cannot reach is a `ValueEnum` variant's help (`--filter pick` → "A keeper."): +//! `clap` 4.6 re-words a possible value only by replacing the typed `value_parser` with a +//! `PossibleValuesParser`, which would trade typed parsing for a translated word. That gap is +//! recorded in the i18n design doc rather than closed here. + +use clap::{Arg, Command}; + +use crate::i18n::{Bundle, HELP_NAMESPACE}; + +/// The path segment naming the root command in a help key. +pub const ROOT_PATH: &str = "root"; + +/// The key holding a command's one-line description. +#[must_use] +pub fn about_key(path: &str) -> String { + format!("{HELP_NAMESPACE}.{path}.about") +} + +/// The key holding a command's long description (shown by `--help`, not `-h`). +#[must_use] +pub fn long_about_key(path: &str) -> String { + format!("{HELP_NAMESPACE}.{path}.long_about") +} + +/// The key holding an argument's help line. +#[must_use] +pub fn arg_key(path: &str, arg_id: &str) -> String { + format!("{HELP_NAMESPACE}.{path}.arg.{arg_id}") +} + +/// The key holding an argument's long help (shown by `--help`, not `-h`). +#[must_use] +pub fn arg_long_help_key(path: &str, arg_id: &str) -> String { + format!("{HELP_NAMESPACE}.{path}.arg.{arg_id}.long_help") +} + +/// The path of `name` when it is a subcommand of the command at `parent`. +#[must_use] +pub fn child_path(parent: &str, name: &str) -> String { + if parent == ROOT_PATH { + name.to_owned() + } else { + format!("{parent}.{name}") + } +} + +/// Replace every help string in `command` (recursively) with the message `bundle` holds for +/// it, leaving any string whose key the bundle lacks exactly as the derive wrote it. +#[must_use] +pub fn localize(command: Command, bundle: &Bundle) -> Command { + localize_with(command, &|key| bundle.message(key).map(str::to_owned)) +} + +/// [`localize`] over an arbitrary look-up, so the rewriter is testable against a stub that +/// answers for one key and nothing else. +#[must_use] +pub fn localize_with(command: Command, lookup: &dyn Fn(&str) -> Option) -> Command { + localize_at(command, ROOT_PATH, lookup) +} + +fn localize_at(command: Command, path: &str, lookup: &dyn Fn(&str) -> Option) -> Command { + let command = localize_command_text(command, path, lookup); + let command = command.mut_args(|arg| localize_arg(arg, path, lookup)); + command.mut_subcommands(|subcommand| { + let child = child_path(path, subcommand.get_name()); + localize_at(subcommand, &child, lookup) + }) +} + +/// Rewrite `about` / `long_about`. The long form is kept in step with the short one: the +/// derive sets both to the same text for a one-paragraph doc comment, and replacing only the +/// short form would make `-h` speak one language and `--help` another. +fn localize_command_text( + mut command: Command, + path: &str, + lookup: &dyn Fn(&str) -> Option, +) -> Command { + let derive_about = command.get_about().map(ToString::to_string); + let derive_long = command.get_long_about().map(ToString::to_string); + if let Some(about) = lookup(&about_key(path)) { + if derive_long.is_some() && derive_long == derive_about { + command = command.long_about(about.clone()); + } + command = command.about(about); + } + if let Some(long_about) = lookup(&long_about_key(path)) { + command = command.long_about(long_about); + } + command +} + +/// Rewrite `help` / `long_help`, with the same lockstep rule as the command text. +fn localize_arg(mut arg: Arg, path: &str, lookup: &dyn Fn(&str) -> Option) -> Arg { + let id = arg.get_id().as_str().to_owned(); + let derive_help = arg.get_help().map(ToString::to_string); + let derive_long = arg.get_long_help().map(ToString::to_string); + if let Some(help) = lookup(&arg_key(path, &id)) { + if derive_long.is_some() && derive_long == derive_help { + arg = arg.long_help(help.clone()); + } + arg = arg.help(help); + } + if let Some(long_help) = lookup(&arg_long_help_key(path, &id)) { + arg = arg.long_help(long_help); + } + arg +} + +#[cfg(test)] +mod tests { + use clap::CommandFactory; + + use super::*; + use crate::cli::Cli; + + /// Every `(path, command)` pair in the tree, root first. + fn commands(command: &Command, path: &str, out: &mut Vec<(String, Command)>) { + out.push((path.to_owned(), command.clone())); + for subcommand in command.get_subcommands() { + commands(subcommand, &child_path(path, subcommand.get_name()), out); + } + } + + /// `--help` of every command in the tree, rendered in tree order. + fn rendered_help(command: &Command) -> Vec { + let mut all = Vec::new(); + commands(command, ROOT_PATH, &mut all); + all.into_iter() + .map(|(_, mut command)| command.render_long_help().to_string()) + .collect() + } + + /// The invariant `cli-surface.json` and `capsule --help` rest on: the source catalog's + /// entry for every help string is the derive text, verbatim — so localizing under `en` + /// is the identity, and a doc comment edited without its catalog entry (or the reverse) + /// fails here rather than shipping two Englishes. + #[test] + fn every_help_string_has_an_english_catalog_entry_equal_to_the_derive_text() { + let bundle = Bundle::for_locale("en"); + let mut all = Vec::new(); + commands(&Cli::command(), ROOT_PATH, &mut all); + assert!(all.len() > 16, "the whole tree is walked"); + + for (path, command) in &all { + let about = command.get_about().map(ToString::to_string); + assert_eq!( + bundle.message(&about_key(path)), + about.as_deref(), + "`{}` about", + about_key(path) + ); + let long_about = command.get_long_about().map(ToString::to_string); + let distinct_long = long_about.is_some() && long_about != about; + assert_eq!( + bundle.message(&long_about_key(path)), + if distinct_long { + long_about.as_deref() + } else { + None + }, + "`{}` is present exactly when the derive gives a distinct long_about", + long_about_key(path) + ); + + for arg in command.get_arguments() { + let id = arg.get_id().as_str(); + let help = arg.get_help().map(ToString::to_string); + assert_eq!( + bundle.message(&arg_key(path, id)), + help.as_deref(), + "`{}` help", + arg_key(path, id) + ); + let long_help = arg.get_long_help().map(ToString::to_string); + let distinct_long = long_help.is_some() && long_help != help; + assert_eq!( + bundle.message(&arg_long_help_key(path, id)), + if distinct_long { + long_help.as_deref() + } else { + None + }, + "`{}` is present exactly when the derive gives a distinct long_help", + arg_long_help_key(path, id) + ); + } + } + } + + /// The consequence of the invariant above, observed on the rendered output: under the + /// source locale, `--help` of every command is byte-identical to the un-localized one. + #[test] + fn localizing_under_the_source_locale_leaves_every_help_page_byte_identical() { + let plain = rendered_help(&Cli::command()); + let localized = rendered_help(&localize(Cli::command(), &Bundle::for_locale("en"))); + assert_eq!(plain, localized); + assert!(plain.iter().any(|page| page.contains("--library"))); + } + + #[test] + fn a_catalog_message_replaces_the_derive_text_at_its_position_only() { + let localized = localize_with(Cli::command(), &|key| { + (key == about_key("import")).then(|| "XX".to_owned()) + }); + let import = localized + .find_subcommand("import") + .expect("`import` is a subcommand"); + assert_eq!( + import.get_about().map(ToString::to_string).as_deref(), + Some("XX") + ); + // The lockstep rule: a one-paragraph derive comment sets both forms, so both move. + assert_eq!( + import.get_long_about().map(ToString::to_string), + Cli::command() + .find_subcommand("import") + .expect("`import` is a subcommand") + .get_long_about() + .map(|_| "XX".to_owned()) + ); + let push = localized + .find_subcommand("push") + .expect("`push` is a subcommand"); + assert_eq!( + push.get_about().map(ToString::to_string), + Cli::command() + .find_subcommand("push") + .expect("`push` is a subcommand") + .get_about() + .map(ToString::to_string) + ); + } + + #[test] + fn an_argument_message_reaches_the_rendered_help_of_its_command() { + let key = arg_key("import", "library"); + let mut localized = localize_with(Cli::command(), &|k| { + (k == key).then(|| "YY the library".to_owned()) + }); + let page = localized + .find_subcommand_mut("import") + .expect("`import` is a subcommand") + .render_long_help() + .to_string(); + assert!(page.contains("YY the library"), "{page}"); + assert!(!page.contains("Path to the Capsule library"), "{page}"); + // Another command's identical-looking argument is a different key, so it is untouched. + let cull = localized + .find_subcommand_mut("cull") + .expect("`cull` is a subcommand") + .render_long_help() + .to_string(); + assert!(cull.contains("Path to the Capsule library"), "{cull}"); + } + + #[test] + fn a_nested_subcommand_is_keyed_by_its_dotted_path() { + let key = about_key("library.init"); + let localized = localize_with(Cli::command(), &|k| (k == key).then(|| "ZZ".to_owned())); + let init = localized + .find_subcommand("library") + .and_then(|library| library.find_subcommand("init")) + .expect("`library init` is a subcommand"); + assert_eq!( + init.get_about().map(ToString::to_string).as_deref(), + Some("ZZ") + ); + } + + #[test] + fn a_missing_key_leaves_the_derive_text_in_place() { + let plain = rendered_help(&Cli::command()); + let untouched = rendered_help(&localize_with(Cli::command(), &|_| None)); + assert_eq!(plain, untouched); + // A locale with no catalog of its own falls back to the source entries, which the + // invariant test proves are the derive text — so an unknown locale is the identity too. + let unknown = rendered_help(&localize(Cli::command(), &Bundle::for_locale("xx-XX"))); + assert_eq!(plain, unknown); + } + + #[test] + fn the_key_grammar_spells_root_and_nested_paths() { + assert_eq!(about_key(ROOT_PATH), "cli.help.root.about"); + assert_eq!(long_about_key(ROOT_PATH), "cli.help.root.long_about"); + assert_eq!(child_path(ROOT_PATH, "library"), "library"); + assert_eq!(child_path("library", "init"), "library.init"); + assert_eq!( + arg_key("library.init", "path"), + "cli.help.library.init.arg.path" + ); + assert_eq!( + arg_long_help_key("import", "paths"), + "cli.help.import.arg.paths.long_help" + ); + } +} diff --git a/capsule-cli/src/cli/mod.rs b/capsule-cli/src/cli/mod.rs index 6e713e0b..6c03d7bb 100644 --- a/capsule-cli/src/cli/mod.rs +++ b/capsule-cli/src/cli/mod.rs @@ -6,11 +6,14 @@ //! `/reference/cli/` (slice `S-Z8`). pub(crate) mod commands; +pub mod help; use clap::{Arg, ArgAction, Command, CommandFactory, Parser}; pub(crate) use commands::*; use serde_json::{Map, Value}; +use crate::i18n::Bundle; + /// Every field name the command-tree document uses, named once. /// /// The document is hand-built rather than derived from a struct, because its shape is a @@ -78,27 +81,29 @@ pub(crate) struct Cli { /// Object keys come out sorted because `serde_json::Map` is a `BTreeMap` here /// (`preserve_order` is off). Nothing is read from the clock, the filesystem, or the /// environment. -/// - **Locale-independent.** Every string below comes from a `clap` attribute or a doc -/// comment — compile-time English `&'static str` — and `StyledStr`'s `Display` is -/// documented as colour-unaware, so no ANSI escape can leak in from a terminal that -/// supports colour. The process locale is never consulted: this function does **not** -/// call [`crate::i18n::cli_bundle`], which negotiates `LC_ALL`/`LC_MESSAGES`/`LANG`. +/// - **Locale-independent.** Help text is localized (slice `S-I8`, [`help::localize`]), and +/// this function resolves it through an explicitly pinned **`en`** bundle — +/// `Bundle::for_locale("en")`, never [`crate::i18n::cli_bundle`], which negotiates +/// `LC_ALL`/`LC_MESSAGES`/`LANG`. The artifact describes one surface in one language; a +/// bundle negotiated from the environment would make `cli-surface-check` pass or fail +/// according to the developer's `LANG`, and the drift gate would stop meaning anything. +/// `help`'s invariant test additionally proves the `en` entries equal the derive text, so +/// pinning `en` yields the same tree the un-localized derive did. `StyledStr`'s `Display` +/// is documented as colour-unaware, so no ANSI escape can leak in from a terminal that +/// supports colour. /// -/// **If help text is ever localized, it must be resolved here through -/// `Bundle::for_locale("en")`, never through `cli_bundle()`.** The artifact describes one -/// surface in one language; a bundle negotiated from the environment would make -/// `cli-surface-check` pass or fail according to the developer's `LANG`, and the drift -/// gate would stop meaning anything. Localizing the *rendered* help a user sees is a -/// separate concern from describing the surface. +/// Localizing the *rendered* help a user sees is a separate concern from describing the +/// surface: the binary's [`crate::run`] applies the same rewriter under the negotiated +/// bundle, and this function does not. /// /// The tree describes the surface this crate *declares*. `clap`'s generated `--help` (and /// `--version`, were one configured) is deliberately absent: [`Command::build`] is not /// called, so no synthesized argument is described, and the reference page does not repeat -/// `--help` under all sixteen commands. Hidden commands and arguments are skipped for the -/// same reason they are hidden. +/// `--help` under every command. Hidden commands and arguments are skipped for the same +/// reason they are hidden. #[must_use] pub fn command_tree() -> Value { - let mut root = describe_command(&Cli::command()); + let mut root = describe_command(&help::localize(Cli::command(), &Bundle::for_locale("en"))); root.insert( field::SCHEMA.to_owned(), Value::from(u64::from(COMMAND_TREE_SCHEMA)), diff --git a/capsule-cli/src/i18n.rs b/capsule-cli/src/i18n.rs index 8d925117..795cf402 100644 --- a/capsule-cli/src/i18n.rs +++ b/capsule-cli/src/i18n.rs @@ -10,10 +10,18 @@ //! operator telemetry and stays English. Commands migrated so far: the networked ones //! (`auth`, `sync`, `list`, `push` — `S-D5`), `cull` (`S-D16`), and `import` (`S-I5`); //! the rest are carried as visible debt in `locales/i18n-guard-allowlist.txt`. +//! +//! `--help` is user-facing too, and is rendered from the [`HELP_NAMESPACE`] keys by +//! [`crate::cli::help`] (`S-I8`) rather than from a key constant per string: its keys are +//! derived from the command tree, so a new subcommand's help is a catalog entry the +//! invariant test in that module demands, not a constant somebody has to remember to add. pub use capsule_i18n::{Bundle, Value, error_codes}; use capsule_i18n::{negotiate, supported_locales}; +/// The namespace `--help` text lives under; the key grammar is [`crate::cli::help`]'s. +pub const HELP_NAMESPACE: &str = "cli.help"; + /// Build the CLI's message bundle from the POSIX locale environment, falling back /// to the source locale. `LC_ALL` wins, then `LC_MESSAGES`, then `LANG`. pub fn cli_bundle() -> Bundle { diff --git a/capsule-cli/src/lib.rs b/capsule-cli/src/lib.rs index 6789950b..ecbd455b 100644 --- a/capsule-cli/src/lib.rs +++ b/capsule-cli/src/lib.rs @@ -166,8 +166,21 @@ fn read_import_source( } /// Parse the CLI arguments and dispatch the matching command. +/// +/// The parser is built from the derive and then localized (`S-I8`): every `--help` string is +/// resolved through the bundle negotiated from the process locale before a single argument is +/// read, so usage errors and help pages speak the user's language. A locale with no catalog +/// entry for a string falls back to the derive's English rather than printing a key. pub async fn run() -> Result<()> { - let cli = ::parse(); + let command = cli::help::localize( + ::command(), + &i18n::cli_bundle(), + ); + let mut matches = command.get_matches(); + let cli = match ::from_arg_matches_mut(&mut matches) { + Ok(cli) => cli, + Err(error) => error.exit(), + }; tracing::trace!("Parsed CLI arguments: {:#?}", cli); dispatch(cli).await } diff --git a/capsule-docs/src/content/docs/design/i18n.md b/capsule-docs/src/content/docs/design/i18n.md index f835717a..d4fcd7e2 100644 --- a/capsule-docs/src/content/docs/design/i18n.md +++ b/capsule-docs/src/content/docs/design/i18n.md @@ -124,6 +124,40 @@ exercises today; full `plural`/`select`/`number`/`date` formatting is follow-up. Native clients use their platform's own ICU machinery, which already covers the full syntax. +## CLI help text + +The CLI's `--help` output **is** localized, resolved at parser-construction time +(slice `S-I8`). This is the decision that slice existed to make: the "no hardcoded +user-facing strings" contract applies to help pages as much as to any other line +a terminal shows, and the alternative — keeping help English and saying so — would +have left the one surface every new user reads first outside the contract. + +The mechanism, in `capsule-cli/src/cli/help.rs`: + +- Help is still *authored* as `clap` doc comments and attributes, which stay the + single place the English lives. The catalog holds a `cli.help.*` entry per string + under a key derived from the command tree — `cli.help..about`, + `cli.help..long_about`, `cli.help..arg.` and + `…arg..long_help`, where `` is the dot-joined subcommand chain + (`library.init`) and the root is `root`. Nothing is spelled twice. +- Before any argument is parsed, the binary walks the built `clap::Command` tree and + replaces each string with the catalog's message for its key, through the bundle + negotiated from `LC_ALL`/`LC_MESSAGES`/`LANG`. A key the bundle lacks leaves the + derive text in place, so a partially translated locale renders a mix and an + untranslated one renders English — never a raw key. +- A unit test asserts that every `en` entry equals the derive text it replaces, and + that localizing under `en` leaves every help page byte-identical. That test is the + gate for this surface: `i18n-guard` cannot see help text (no `println!` carries it), + so a doc comment edited without its catalog entry fails `cargo test -p capsule-cli` + instead. The committed `cli-surface.json` description artifact is resolved through + an explicitly pinned `en` bundle for the same reason, and is unchanged by + localization. + +**Residual gap:** a `ValueEnum` variant's help (`--filter pick` → "A keeper.") is not +localized. `clap` 4 can re-word a possible value only by replacing the typed +`value_parser` with a `PossibleValuesParser`, which trades typed parsing for a +translated word; the variants stay English until `clap` offers a seam that does not. + ## Server error codes APIs are typed, but error *messages* must be presentable in the user's language. diff --git a/capsule-i18n/src/bundles/en.json b/capsule-i18n/src/bundles/en.json index 318b9021..19b7824f 100644 --- a/capsule-i18n/src/bundles/en.json +++ b/capsule-i18n/src/bundles/en.json @@ -1727,6 +1727,65 @@ "cli.cull.swept": "Swept {count} rejected asset(s) to trash; recoverable for {retain_days} day(s).", "cli.cull.unknown_asset": "Unknown asset {asset_id} in this library.", "cli.cull.view": "Cull view: {pick} pick, {neutral} neutral, {reject} reject.", + "cli.help.auth.about": "Authentication commands", + "cli.help.auth.login.about": "Login to Capsule", + "cli.help.auth.login.arg.email": "Account email (prompted when omitted)", + "cli.help.auth.login.arg.password_stdin": "Read the password from stdin instead of prompting, so the command works in scripts and CI where there is no terminal", + "cli.help.auth.logout.about": "Logout from Capsule", + "cli.help.auth.register.about": "Create a Capsule account and sign in", + "cli.help.auth.register.arg.email": "Account email (prompted when omitted)", + "cli.help.auth.register.arg.password_stdin": "Read the password from stdin instead of prompting, so the command works in scripts and CI where there is no terminal", + "cli.help.auth.status.about": "Show authentication status", + "cli.help.cull.about": "Review a local library: flag assets, filter by flag, sweep rejects to trash", + "cli.help.cull.arg.filter": "List the assets carrying one flag instead of only counting them", + "cli.help.cull.arg.library": "Path to the Capsule library", + "cli.help.cull.arg.neutral": "Clear an asset's flag back to the never-flagged default (repeatable)", + "cli.help.cull.arg.passphrase_stdin": "Read the library passphrase from stdin instead of prompting, so culling works in scripts and CI where there is no terminal", + "cli.help.cull.arg.pick": "Flag an asset as a keeper (repeatable)", + "cli.help.cull.arg.reject": "Flag an asset for rejection (repeatable)", + "cli.help.cull.arg.retain_days": "Retention window, in days, the sweep's soft delete stamps", + "cli.help.cull.arg.sweep": "Move every rejected asset to trash. The only destructive step, and soft per retention — swept assets stay restorable until the window elapses", + "cli.help.demo.about": "Run the offline end-to-end data-plane showcase (real cryptography, no network)", + "cli.help.demo.arg.image": "A real image/file to import (a small synthetic file is used if omitted)", + "cli.help.demo.arg.workdir": "Working directory for the demo libraries (a temp dir is used if omitted)", + "cli.help.import.about": "Import files into a local Capsule library", + "cli.help.import.arg.force": "Re-import files even if they already exist (duplicate override)", + "cli.help.import.arg.library": "Path to the Capsule library", + "cli.help.import.arg.move": "Move files instead of copying them", + "cli.help.import.arg.passphrase_stdin": "Read the library passphrase from stdin instead of prompting, so imports work in scripts and CI where there is no terminal", + "cli.help.import.arg.paths": "Source file or directory to import. Repeatable: a split Takeout export extracted into several folders is imported by naming every part in one run, so a media file and a sidecar that landed in different parts are still paired", + "cli.help.import.arg.provider": "Read the source as an export from this service instead of as a plain directory tree, so its out-of-band metadata (capture time, GPS, captions, favorites, album membership) is folded into the imported assets", + "cli.help.import.arg.push": "Push the library to the server after importing — sugar for a `capsule push` run over the same library. The import itself stays offline", + "cli.help.import.arg.staged": "Stage the follow-on push (`--push`) in tier order, gating the preview and original tiers on the connection class", + "cli.help.library.about": "Manage the local library", + "cli.help.library.info.about": "Show library information", + "cli.help.library.info.arg.path": "Path to the library", + "cli.help.library.init.about": "Create a new Capsule library", + "cli.help.library.init.arg.name": "Human-readable library name", + "cli.help.library.init.arg.path": "Directory for the new library", + "cli.help.library.rebuild.about": "Rebuild the SQLite index from sidecar files", + "cli.help.library.rebuild.arg.path": "Path to the library", + "cli.help.list.about": "List the assets the sync feed has delivered", + "cli.help.list.arg.include_deleted": "Include assets the server has tombstoned (deleted) as well as live ones", + "cli.help.match.about": "Match metadata for current file", + "cli.help.match.arg.path": "Path to the file to match metadata for", + "cli.help.push.about": "Upload a local Capsule library to the server", + "cli.help.push.arg.dry_run": "Report what would be uploaded without opening a single upload session", + "cli.help.push.arg.force": "Re-drive every blob regardless of what the server already holds", + "cli.help.push.arg.library": "Path to the Capsule library to push", + "cli.help.push.arg.passphrase_stdin": "Read the library passphrase from stdin instead of prompting, so pushes work in scripts and CI where there is no terminal", + "cli.help.push.arg.staged": "Open the tier sessions in ladder order (index → preview → original), gating the above-index tiers on the connection class, instead of opening all eagerly", + "cli.help.reset.about": "Reset all local CLI data", + "cli.help.reset.arg.all": "Reset all data", + "cli.help.reset.arg.cache": "Reset cache directory", + "cli.help.reset.arg.config": "Reset configuration", + "cli.help.reset.arg.data": "Reset data directory", + "cli.help.root.about": "A command line interface for Capsule - the photo management platform", + "cli.help.root.long_about": "Capsule CLI provides tools for managing your photos and albums:\n\n- Authentication management\n- Sync local and remote data\n- Check status and list files\n- Manage albums and collections", + "cli.help.status.about": "Show current status", + "cli.help.sync.about": "Sync local and remote data", + "cli.help.sync.arg.dry_run": "Perform a dry run without making changes", + "cli.help.sync.arg.force": "Discard the saved cursor and re-drain the feed from the start. The per-album anti-rewind floor still applies, so this cannot resurrect stale entries", "cli.import.candidates_found": "Found {candidates} candidate(s) ({files} file(s) total).", "cli.import.done": "Done: {imported} imported, {duplicates} duplicate(s), {errors} error(s).", "cli.import.execute_failed": "The import failed: {reason}", diff --git a/capsule-swift/Generated/Localizable.xcstrings b/capsule-swift/Generated/Localizable.xcstrings index d16f811c..e31245ed 100644 --- a/capsule-swift/Generated/Localizable.xcstrings +++ b/capsule-swift/Generated/Localizable.xcstrings @@ -30101,6 +30101,596 @@ } } }, + "cli.help.auth.about": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Authentication commands" + } + } + } + }, + "cli.help.auth.login.about": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Login to Capsule" + } + } + } + }, + "cli.help.auth.login.arg.email": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Account email (prompted when omitted)" + } + } + } + }, + "cli.help.auth.login.arg.password_stdin": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Read the password from stdin instead of prompting, so the command works in scripts and CI where there is no terminal" + } + } + } + }, + "cli.help.auth.logout.about": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Logout from Capsule" + } + } + } + }, + "cli.help.auth.register.about": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create a Capsule account and sign in" + } + } + } + }, + "cli.help.auth.register.arg.email": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Account email (prompted when omitted)" + } + } + } + }, + "cli.help.auth.register.arg.password_stdin": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Read the password from stdin instead of prompting, so the command works in scripts and CI where there is no terminal" + } + } + } + }, + "cli.help.auth.status.about": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show authentication status" + } + } + } + }, + "cli.help.cull.about": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Review a local library: flag assets, filter by flag, sweep rejects to trash" + } + } + } + }, + "cli.help.cull.arg.filter": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "List the assets carrying one flag instead of only counting them" + } + } + } + }, + "cli.help.cull.arg.library": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Path to the Capsule library" + } + } + } + }, + "cli.help.cull.arg.neutral": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear an asset's flag back to the never-flagged default (repeatable)" + } + } + } + }, + "cli.help.cull.arg.passphrase_stdin": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Read the library passphrase from stdin instead of prompting, so culling works in scripts and CI where there is no terminal" + } + } + } + }, + "cli.help.cull.arg.pick": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Flag an asset as a keeper (repeatable)" + } + } + } + }, + "cli.help.cull.arg.reject": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Flag an asset for rejection (repeatable)" + } + } + } + }, + "cli.help.cull.arg.retain_days": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Retention window, in days, the sweep's soft delete stamps" + } + } + } + }, + "cli.help.cull.arg.sweep": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move every rejected asset to trash. The only destructive step, and soft per retention — swept assets stay restorable until the window elapses" + } + } + } + }, + "cli.help.demo.about": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Run the offline end-to-end data-plane showcase (real cryptography, no network)" + } + } + } + }, + "cli.help.demo.arg.image": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A real image/file to import (a small synthetic file is used if omitted)" + } + } + } + }, + "cli.help.demo.arg.workdir": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Working directory for the demo libraries (a temp dir is used if omitted)" + } + } + } + }, + "cli.help.import.about": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import files into a local Capsule library" + } + } + } + }, + "cli.help.import.arg.force": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Re-import files even if they already exist (duplicate override)" + } + } + } + }, + "cli.help.import.arg.library": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Path to the Capsule library" + } + } + } + }, + "cli.help.import.arg.move": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move files instead of copying them" + } + } + } + }, + "cli.help.import.arg.passphrase_stdin": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Read the library passphrase from stdin instead of prompting, so imports work in scripts and CI where there is no terminal" + } + } + } + }, + "cli.help.import.arg.paths": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Source file or directory to import. Repeatable: a split Takeout export extracted into several folders is imported by naming every part in one run, so a media file and a sidecar that landed in different parts are still paired" + } + } + } + }, + "cli.help.import.arg.provider": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Read the source as an export from this service instead of as a plain directory tree, so its out-of-band metadata (capture time, GPS, captions, favorites, album membership) is folded into the imported assets" + } + } + } + }, + "cli.help.import.arg.push": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Push the library to the server after importing — sugar for a `capsule push` run over the same library. The import itself stays offline" + } + } + } + }, + "cli.help.import.arg.staged": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stage the follow-on push (`--push`) in tier order, gating the preview and original tiers on the connection class" + } + } + } + }, + "cli.help.library.about": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Manage the local library" + } + } + } + }, + "cli.help.library.info.about": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show library information" + } + } + } + }, + "cli.help.library.info.arg.path": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Path to the library" + } + } + } + }, + "cli.help.library.init.about": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create a new Capsule library" + } + } + } + }, + "cli.help.library.init.arg.name": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Human-readable library name" + } + } + } + }, + "cli.help.library.init.arg.path": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Directory for the new library" + } + } + } + }, + "cli.help.library.rebuild.about": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rebuild the SQLite index from sidecar files" + } + } + } + }, + "cli.help.library.rebuild.arg.path": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Path to the library" + } + } + } + }, + "cli.help.list.about": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "List the assets the sync feed has delivered" + } + } + } + }, + "cli.help.list.arg.include_deleted": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Include assets the server has tombstoned (deleted) as well as live ones" + } + } + } + }, + "cli.help.match.about": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Match metadata for current file" + } + } + } + }, + "cli.help.match.arg.path": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Path to the file to match metadata for" + } + } + } + }, + "cli.help.push.about": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Upload a local Capsule library to the server" + } + } + } + }, + "cli.help.push.arg.dry_run": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Report what would be uploaded without opening a single upload session" + } + } + } + }, + "cli.help.push.arg.force": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Re-drive every blob regardless of what the server already holds" + } + } + } + }, + "cli.help.push.arg.library": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Path to the Capsule library to push" + } + } + } + }, + "cli.help.push.arg.passphrase_stdin": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Read the library passphrase from stdin instead of prompting, so pushes work in scripts and CI where there is no terminal" + } + } + } + }, + "cli.help.push.arg.staged": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open the tier sessions in ladder order (index → preview → original), gating the above-index tiers on the connection class, instead of opening all eagerly" + } + } + } + }, + "cli.help.reset.about": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset all local CLI data" + } + } + } + }, + "cli.help.reset.arg.all": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset all data" + } + } + } + }, + "cli.help.reset.arg.cache": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset cache directory" + } + } + } + }, + "cli.help.reset.arg.config": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset configuration" + } + } + } + }, + "cli.help.reset.arg.data": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset data directory" + } + } + } + }, + "cli.help.root.about": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A command line interface for Capsule - the photo management platform" + } + } + } + }, + "cli.help.root.long_about": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Capsule CLI provides tools for managing your photos and albums:\n\n- Authentication management\n- Sync local and remote data\n- Check status and list files\n- Manage albums and collections" + } + } + } + }, + "cli.help.status.about": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show current status" + } + } + } + }, + "cli.help.sync.about": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sync local and remote data" + } + } + } + }, + "cli.help.sync.arg.dry_run": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Perform a dry run without making changes" + } + } + } + }, + "cli.help.sync.arg.force": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Discard the saved cursor and re-drain the feed from the start. The per-album anti-rewind floor still applies, so this cannot resurrect stale entries" + } + } + } + }, "cli.import.candidates_found": { "localizations": { "en": { diff --git a/capsule-web/src/i18n/messages/en.json b/capsule-web/src/i18n/messages/en.json index 318b9021..19b7824f 100644 --- a/capsule-web/src/i18n/messages/en.json +++ b/capsule-web/src/i18n/messages/en.json @@ -1727,6 +1727,65 @@ "cli.cull.swept": "Swept {count} rejected asset(s) to trash; recoverable for {retain_days} day(s).", "cli.cull.unknown_asset": "Unknown asset {asset_id} in this library.", "cli.cull.view": "Cull view: {pick} pick, {neutral} neutral, {reject} reject.", + "cli.help.auth.about": "Authentication commands", + "cli.help.auth.login.about": "Login to Capsule", + "cli.help.auth.login.arg.email": "Account email (prompted when omitted)", + "cli.help.auth.login.arg.password_stdin": "Read the password from stdin instead of prompting, so the command works in scripts and CI where there is no terminal", + "cli.help.auth.logout.about": "Logout from Capsule", + "cli.help.auth.register.about": "Create a Capsule account and sign in", + "cli.help.auth.register.arg.email": "Account email (prompted when omitted)", + "cli.help.auth.register.arg.password_stdin": "Read the password from stdin instead of prompting, so the command works in scripts and CI where there is no terminal", + "cli.help.auth.status.about": "Show authentication status", + "cli.help.cull.about": "Review a local library: flag assets, filter by flag, sweep rejects to trash", + "cli.help.cull.arg.filter": "List the assets carrying one flag instead of only counting them", + "cli.help.cull.arg.library": "Path to the Capsule library", + "cli.help.cull.arg.neutral": "Clear an asset's flag back to the never-flagged default (repeatable)", + "cli.help.cull.arg.passphrase_stdin": "Read the library passphrase from stdin instead of prompting, so culling works in scripts and CI where there is no terminal", + "cli.help.cull.arg.pick": "Flag an asset as a keeper (repeatable)", + "cli.help.cull.arg.reject": "Flag an asset for rejection (repeatable)", + "cli.help.cull.arg.retain_days": "Retention window, in days, the sweep's soft delete stamps", + "cli.help.cull.arg.sweep": "Move every rejected asset to trash. The only destructive step, and soft per retention — swept assets stay restorable until the window elapses", + "cli.help.demo.about": "Run the offline end-to-end data-plane showcase (real cryptography, no network)", + "cli.help.demo.arg.image": "A real image/file to import (a small synthetic file is used if omitted)", + "cli.help.demo.arg.workdir": "Working directory for the demo libraries (a temp dir is used if omitted)", + "cli.help.import.about": "Import files into a local Capsule library", + "cli.help.import.arg.force": "Re-import files even if they already exist (duplicate override)", + "cli.help.import.arg.library": "Path to the Capsule library", + "cli.help.import.arg.move": "Move files instead of copying them", + "cli.help.import.arg.passphrase_stdin": "Read the library passphrase from stdin instead of prompting, so imports work in scripts and CI where there is no terminal", + "cli.help.import.arg.paths": "Source file or directory to import. Repeatable: a split Takeout export extracted into several folders is imported by naming every part in one run, so a media file and a sidecar that landed in different parts are still paired", + "cli.help.import.arg.provider": "Read the source as an export from this service instead of as a plain directory tree, so its out-of-band metadata (capture time, GPS, captions, favorites, album membership) is folded into the imported assets", + "cli.help.import.arg.push": "Push the library to the server after importing — sugar for a `capsule push` run over the same library. The import itself stays offline", + "cli.help.import.arg.staged": "Stage the follow-on push (`--push`) in tier order, gating the preview and original tiers on the connection class", + "cli.help.library.about": "Manage the local library", + "cli.help.library.info.about": "Show library information", + "cli.help.library.info.arg.path": "Path to the library", + "cli.help.library.init.about": "Create a new Capsule library", + "cli.help.library.init.arg.name": "Human-readable library name", + "cli.help.library.init.arg.path": "Directory for the new library", + "cli.help.library.rebuild.about": "Rebuild the SQLite index from sidecar files", + "cli.help.library.rebuild.arg.path": "Path to the library", + "cli.help.list.about": "List the assets the sync feed has delivered", + "cli.help.list.arg.include_deleted": "Include assets the server has tombstoned (deleted) as well as live ones", + "cli.help.match.about": "Match metadata for current file", + "cli.help.match.arg.path": "Path to the file to match metadata for", + "cli.help.push.about": "Upload a local Capsule library to the server", + "cli.help.push.arg.dry_run": "Report what would be uploaded without opening a single upload session", + "cli.help.push.arg.force": "Re-drive every blob regardless of what the server already holds", + "cli.help.push.arg.library": "Path to the Capsule library to push", + "cli.help.push.arg.passphrase_stdin": "Read the library passphrase from stdin instead of prompting, so pushes work in scripts and CI where there is no terminal", + "cli.help.push.arg.staged": "Open the tier sessions in ladder order (index → preview → original), gating the above-index tiers on the connection class, instead of opening all eagerly", + "cli.help.reset.about": "Reset all local CLI data", + "cli.help.reset.arg.all": "Reset all data", + "cli.help.reset.arg.cache": "Reset cache directory", + "cli.help.reset.arg.config": "Reset configuration", + "cli.help.reset.arg.data": "Reset data directory", + "cli.help.root.about": "A command line interface for Capsule - the photo management platform", + "cli.help.root.long_about": "Capsule CLI provides tools for managing your photos and albums:\n\n- Authentication management\n- Sync local and remote data\n- Check status and list files\n- Manage albums and collections", + "cli.help.status.about": "Show current status", + "cli.help.sync.about": "Sync local and remote data", + "cli.help.sync.arg.dry_run": "Perform a dry run without making changes", + "cli.help.sync.arg.force": "Discard the saved cursor and re-drain the feed from the start. The per-album anti-rewind floor still applies, so this cannot resurrect stale entries", "cli.import.candidates_found": "Found {candidates} candidate(s) ({files} file(s) total).", "cli.import.done": "Done: {imported} imported, {duplicates} duplicate(s), {errors} error(s).", "cli.import.execute_failed": "The import failed: {reason}", diff --git a/locales/en.json b/locales/en.json index aca7aaf8..c7d3f306 100644 --- a/locales/en.json +++ b/locales/en.json @@ -6911,6 +6911,242 @@ "message": "Cull view: {pick} pick, {neutral} neutral, {reject} reject.", "context": "CLI summary line from `capsule cull` counting the live (non-trashed) assets carrying each culling flag." }, + "cli.help.auth.about": { + "message": "Authentication commands", + "context": "clap --help: the one-line description of `capsule auth`. Shown in the parent's command list and at the top of its own --help." + }, + "cli.help.auth.login.about": { + "message": "Login to Capsule", + "context": "clap --help: the one-line description of `capsule auth login`. Shown in the parent's command list and at the top of its own --help." + }, + "cli.help.auth.login.arg.email": { + "message": "Account email (prompted when omitted)", + "context": "clap --help: help for the `--email` option of `capsule auth login`." + }, + "cli.help.auth.login.arg.password_stdin": { + "message": "Read the password from stdin instead of prompting, so the command works in scripts and CI where there is no terminal", + "context": "clap --help: help for the `--password-stdin` flag of `capsule auth login`." + }, + "cli.help.auth.logout.about": { + "message": "Logout from Capsule", + "context": "clap --help: the one-line description of `capsule auth logout`. Shown in the parent's command list and at the top of its own --help." + }, + "cli.help.auth.register.about": { + "message": "Create a Capsule account and sign in", + "context": "clap --help: the one-line description of `capsule auth register`. Shown in the parent's command list and at the top of its own --help." + }, + "cli.help.auth.register.arg.email": { + "message": "Account email (prompted when omitted)", + "context": "clap --help: help for the `--email` option of `capsule auth register`." + }, + "cli.help.auth.register.arg.password_stdin": { + "message": "Read the password from stdin instead of prompting, so the command works in scripts and CI where there is no terminal", + "context": "clap --help: help for the `--password-stdin` flag of `capsule auth register`." + }, + "cli.help.auth.status.about": { + "message": "Show authentication status", + "context": "clap --help: the one-line description of `capsule auth status`. Shown in the parent's command list and at the top of its own --help." + }, + "cli.help.cull.about": { + "message": "Review a local library: flag assets, filter by flag, sweep rejects to trash", + "context": "clap --help: the one-line description of `capsule cull`. Shown in the parent's command list and at the top of its own --help." + }, + "cli.help.cull.arg.filter": { + "message": "List the assets carrying one flag instead of only counting them", + "context": "clap --help: help for the `--filter` option of `capsule cull`." + }, + "cli.help.cull.arg.library": { + "message": "Path to the Capsule library", + "context": "clap --help: help for the `--library` option of `capsule cull`." + }, + "cli.help.cull.arg.neutral": { + "message": "Clear an asset's flag back to the never-flagged default (repeatable)", + "context": "clap --help: help for the `--neutral` option of `capsule cull`." + }, + "cli.help.cull.arg.passphrase_stdin": { + "message": "Read the library passphrase from stdin instead of prompting, so culling works in scripts and CI where there is no terminal", + "context": "clap --help: help for the `--passphrase-stdin` flag of `capsule cull`." + }, + "cli.help.cull.arg.pick": { + "message": "Flag an asset as a keeper (repeatable)", + "context": "clap --help: help for the `--pick` option of `capsule cull`." + }, + "cli.help.cull.arg.reject": { + "message": "Flag an asset for rejection (repeatable)", + "context": "clap --help: help for the `--reject` option of `capsule cull`." + }, + "cli.help.cull.arg.retain_days": { + "message": "Retention window, in days, the sweep's soft delete stamps", + "context": "clap --help: help for the `--retain-days` option of `capsule cull`." + }, + "cli.help.cull.arg.sweep": { + "message": "Move every rejected asset to trash. The only destructive step, and soft per retention — swept assets stay restorable until the window elapses", + "context": "clap --help: help for the `--sweep` flag of `capsule cull`." + }, + "cli.help.demo.about": { + "message": "Run the offline end-to-end data-plane showcase (real cryptography, no network)", + "context": "clap --help: the one-line description of `capsule demo`. Shown in the parent's command list and at the top of its own --help." + }, + "cli.help.demo.arg.image": { + "message": "A real image/file to import (a small synthetic file is used if omitted)", + "context": "clap --help: help for the `--image` option of `capsule demo`." + }, + "cli.help.demo.arg.workdir": { + "message": "Working directory for the demo libraries (a temp dir is used if omitted)", + "context": "clap --help: help for the `--workdir` option of `capsule demo`." + }, + "cli.help.import.about": { + "message": "Import files into a local Capsule library", + "context": "clap --help: the one-line description of `capsule import`. Shown in the parent's command list and at the top of its own --help." + }, + "cli.help.import.arg.force": { + "message": "Re-import files even if they already exist (duplicate override)", + "context": "clap --help: help for the `--force` flag of `capsule import`." + }, + "cli.help.import.arg.library": { + "message": "Path to the Capsule library", + "context": "clap --help: help for the `--library` option of `capsule import`." + }, + "cli.help.import.arg.move": { + "message": "Move files instead of copying them", + "context": "clap --help: help for the `--move` flag of `capsule import`." + }, + "cli.help.import.arg.passphrase_stdin": { + "message": "Read the library passphrase from stdin instead of prompting, so imports work in scripts and CI where there is no terminal", + "context": "clap --help: help for the `--passphrase-stdin` flag of `capsule import`." + }, + "cli.help.import.arg.paths": { + "message": "Source file or directory to import. Repeatable: a split Takeout export extracted into several folders is imported by naming every part in one run, so a media file and a sidecar that landed in different parts are still paired", + "context": "clap --help: help for the `` positional of `capsule import`." + }, + "cli.help.import.arg.provider": { + "message": "Read the source as an export from this service instead of as a plain directory tree, so its out-of-band metadata (capture time, GPS, captions, favorites, album membership) is folded into the imported assets", + "context": "clap --help: help for the `--provider` option of `capsule import`." + }, + "cli.help.import.arg.push": { + "message": "Push the library to the server after importing — sugar for a `capsule push` run over the same library. The import itself stays offline", + "context": "clap --help: help for the `--push` flag of `capsule import`." + }, + "cli.help.import.arg.staged": { + "message": "Stage the follow-on push (`--push`) in tier order, gating the preview and original tiers on the connection class", + "context": "clap --help: help for the `--staged` flag of `capsule import`." + }, + "cli.help.library.about": { + "message": "Manage the local library", + "context": "clap --help: the one-line description of `capsule library`. Shown in the parent's command list and at the top of its own --help." + }, + "cli.help.library.info.about": { + "message": "Show library information", + "context": "clap --help: the one-line description of `capsule library info`. Shown in the parent's command list and at the top of its own --help." + }, + "cli.help.library.info.arg.path": { + "message": "Path to the library", + "context": "clap --help: help for the `` positional of `capsule library info`." + }, + "cli.help.library.init.about": { + "message": "Create a new Capsule library", + "context": "clap --help: the one-line description of `capsule library init`. Shown in the parent's command list and at the top of its own --help." + }, + "cli.help.library.init.arg.name": { + "message": "Human-readable library name", + "context": "clap --help: help for the `--name` option of `capsule library init`." + }, + "cli.help.library.init.arg.path": { + "message": "Directory for the new library", + "context": "clap --help: help for the `` positional of `capsule library init`." + }, + "cli.help.library.rebuild.about": { + "message": "Rebuild the SQLite index from sidecar files", + "context": "clap --help: the one-line description of `capsule library rebuild`. Shown in the parent's command list and at the top of its own --help." + }, + "cli.help.library.rebuild.arg.path": { + "message": "Path to the library", + "context": "clap --help: help for the `` positional of `capsule library rebuild`." + }, + "cli.help.list.about": { + "message": "List the assets the sync feed has delivered", + "context": "clap --help: the one-line description of `capsule list`. Shown in the parent's command list and at the top of its own --help." + }, + "cli.help.list.arg.include_deleted": { + "message": "Include assets the server has tombstoned (deleted) as well as live ones", + "context": "clap --help: help for the `--include-deleted` flag of `capsule list`." + }, + "cli.help.match.about": { + "message": "Match metadata for current file", + "context": "clap --help: the one-line description of `capsule match`. Shown in the parent's command list and at the top of its own --help." + }, + "cli.help.match.arg.path": { + "message": "Path to the file to match metadata for", + "context": "clap --help: help for the `` positional of `capsule match`." + }, + "cli.help.push.about": { + "message": "Upload a local Capsule library to the server", + "context": "clap --help: the one-line description of `capsule push`. Shown in the parent's command list and at the top of its own --help." + }, + "cli.help.push.arg.dry_run": { + "message": "Report what would be uploaded without opening a single upload session", + "context": "clap --help: help for the `--dry-run` flag of `capsule push`." + }, + "cli.help.push.arg.force": { + "message": "Re-drive every blob regardless of what the server already holds", + "context": "clap --help: help for the `--force` flag of `capsule push`." + }, + "cli.help.push.arg.library": { + "message": "Path to the Capsule library to push", + "context": "clap --help: help for the `--library` option of `capsule push`." + }, + "cli.help.push.arg.passphrase_stdin": { + "message": "Read the library passphrase from stdin instead of prompting, so pushes work in scripts and CI where there is no terminal", + "context": "clap --help: help for the `--passphrase-stdin` flag of `capsule push`." + }, + "cli.help.push.arg.staged": { + "message": "Open the tier sessions in ladder order (index → preview → original), gating the above-index tiers on the connection class, instead of opening all eagerly", + "context": "clap --help: help for the `--staged` flag of `capsule push`." + }, + "cli.help.reset.about": { + "message": "Reset all local CLI data", + "context": "clap --help: the one-line description of `capsule reset`. Shown in the parent's command list and at the top of its own --help." + }, + "cli.help.reset.arg.all": { + "message": "Reset all data", + "context": "clap --help: help for the `--all` flag of `capsule reset`." + }, + "cli.help.reset.arg.cache": { + "message": "Reset cache directory", + "context": "clap --help: help for the `--cache` flag of `capsule reset`." + }, + "cli.help.reset.arg.config": { + "message": "Reset configuration", + "context": "clap --help: help for the `--config` flag of `capsule reset`." + }, + "cli.help.reset.arg.data": { + "message": "Reset data directory", + "context": "clap --help: help for the `--data` flag of `capsule reset`." + }, + "cli.help.root.about": { + "message": "A command line interface for Capsule - the photo management platform", + "context": "clap --help: the one-line description of `capsule` (the root command). Shown in the parent's command list and at the top of its own --help." + }, + "cli.help.root.long_about": { + "message": "Capsule CLI provides tools for managing your photos and albums:\n\n- Authentication management\n- Sync local and remote data\n- Check status and list files\n- Manage albums and collections", + "context": "clap --help: the long description of `capsule`, shown at the top of `capsule --help` instead of the one-line form. Keep the Markdown list markers: the documentation site renders this text as prose." + }, + "cli.help.status.about": { + "message": "Show current status", + "context": "clap --help: the one-line description of `capsule status`. Shown in the parent's command list and at the top of its own --help." + }, + "cli.help.sync.about": { + "message": "Sync local and remote data", + "context": "clap --help: the one-line description of `capsule sync`. Shown in the parent's command list and at the top of its own --help." + }, + "cli.help.sync.arg.dry_run": { + "message": "Perform a dry run without making changes", + "context": "clap --help: help for the `--dry-run` flag of `capsule sync`." + }, + "cli.help.sync.arg.force": { + "message": "Discard the saved cursor and re-drain the feed from the start. The per-album anti-rewind floor still applies, so this cannot resurrect stale entries", + "context": "clap --help: help for the `--force` flag of `capsule sync`." + }, "cli.import.candidates_found": { "message": "Found {candidates} candidate(s) ({files} file(s) total).", "context": "CLI summary after `capsule import` finishes scanning. {candidates} is the number of importable items found; {files} is the total number of files seen, including sidecars and non-media files. Both are counts." diff --git a/xtask/src/i18n_guard.rs b/xtask/src/i18n_guard.rs index 404795d3..d0691b1c 100644 --- a/xtask/src/i18n_guard.rs +++ b/xtask/src/i18n_guard.rs @@ -63,10 +63,16 @@ //! literal in the ICU **argument-name** position (`("email", Value::Str(&email))`) names a //! placeholder rather than displaying it. //! -//! **Known blind spot:** clap's `--help` output. Usage text comes from doc comments and -//! `#[arg(...)]` attributes that clap renders itself, with no catalog mechanism to render -//! a key through; localizing it is a separate slice, not something an allowlist entry per -//! flag would express honestly. It is recorded here rather than silently omitted. +//! **clap's `--help` output is outside this scanner, and covered elsewhere.** Usage text +//! comes from doc comments and `#[arg(...)]` attributes that clap renders itself, so no +//! `println!` carries it and nothing here can see it. Since slice `S-I8` the CLI rewrites +//! every `about`/`help` from the `cli.help.*` catalog keys at parser construction +//! (`capsule_cli::cli::help`), and the gate for that surface is the invariant test in that +//! module: every help string must have an `en` entry equal to its doc comment, so a doc +//! comment added without a key fails `cargo test -p capsule-cli` rather than this guard. +//! What remains unlocalized is a `ValueEnum` variant's help (`--filter pick` → "A keeper."), +//! which clap 4 cannot re-word without discarding the typed parser; the i18n design doc +//! records it as the residual gap. //! //! The Swift/Compose surfaces are anchored to the catalog: a captured string passes //! only if it exactly matches a key in `locales/en.json`. The web surface has no From ebad42c096eff242930d42bc65e488372878e9b1 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 02:17:10 -0400 Subject: [PATCH 090/243] fix(core): gate the guarded re-export on native, its only caller's feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pub(crate) use self::decode::guarded` was unconditional, but `lifecycle` is the only caller and `lifecycle` is `native`-gated. A `--features media` build without `native` — which the aarch64 cross-check uses, to isolate the codecs from SQLite's C build — carried it as an unused import. Found by that cross-check rather than by `check-rust`, whose clippy pass runs the default feature set where `native` is on. A feature combination no gate compiles is a feature combination that rots. --- capsule-core/src/media/mod.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/capsule-core/src/media/mod.rs b/capsule-core/src/media/mod.rs index 0b6aa15b..71e728df 100644 --- a/capsule-core/src/media/mod.rs +++ b/capsule-core/src/media/mod.rs @@ -56,6 +56,10 @@ mod detect; mod error; mod resize; +// `native`-gated because `lifecycle` is its only caller and `lifecycle` is `native`-gated: a +// `--features media` build without `native` (which the aarch64 cross-check uses, to isolate the +// codecs from SQLite's C build) would otherwise carry an unused re-export. +#[cfg(feature = "native")] pub(crate) use self::decode::guarded; pub use self::decode::{DecodedImage, Decoder, MediaMetadata, RawshiftDecoder, decode_guarded}; pub use self::derivative::{ From a03d87011ab2d23b860e8c1e746862656c501291 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 02:18:22 -0400 Subject: [PATCH 091/243] feat(server): add the Postgres device-cohort map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CohortStore` is the one port in `crate::store` whose production adapter is not Valkey, and its own docs say why: a session store forgets a cohort exactly when "have I seen this device before?" becomes worth asking, so the map has to outlive the sessions that carried it. That makes it Postgres's, and it makes the shared harness the wrong shape. `conformance::Harness` is therefore split. `CohortHarness` carries the cohort map and the `advance` seam; `Harness` extends it with the five volatile stores. A Postgres-backed harness that had to implement `auth()`, `uploads()` and three ceremony stores to run four cohort cases would have to invent five adapters it will never have. One deterministic double still implements both, so `run_all` still covers every case in the module — and the four cohort cases now get one `#[tokio::test]` each in `store::memory` rather than only riding inside `run_all`, which is what makes a cohort failure name the property that broke. The adapter itself is one upsert. The composite primary key **is** the idempotence the port states — seeing the same cohort twice is one row — so `observe` is `INSERT … ON CONFLICT DO UPDATE SET last_seen = … RETURNING` and never a read followed by a branch, whose race is two devices of one account signing in at once. `first_seen` is untouched by the update, which is the half that lets a client say "a device you've used before" rather than presenting a stranger. The listing's tie-break is `COLLATE "C"` so the total order the port contracts is the one the deterministic double produces: a cohort hash is client-asserted text, and a locale collation orders it differently. `store/mod.rs`'s adapter paragraph is corrected in the same change. It said three adapters were planned per port — Postgres, Valkey and the double — which was never true of any port in this module and was the one line in the tree pointing at a Postgres session table, two sentences before its own paragraph rejecting one. It now says two per port, and which two. Refs #402 --- capsule-server/src/store/cohorts_postgres.rs | 177 +++++++++++++++++++ capsule-server/src/store/conformance.rs | 57 ++++-- capsule-server/src/store/memory.rs | 48 +++-- capsule-server/src/store/mod.rs | 25 ++- 4 files changed, 276 insertions(+), 31 deletions(-) create mode 100644 capsule-server/src/store/cohorts_postgres.rs diff --git a/capsule-server/src/store/cohorts_postgres.rs b/capsule-server/src/store/cohorts_postgres.rs new file mode 100644 index 00000000..26b86b44 --- /dev/null +++ b/capsule-server/src/store/cohorts_postgres.rs @@ -0,0 +1,177 @@ +//! [`PostgresCohorts`] — the durable device-cohort map (`S-C13`, #402). +//! +//! # Why this one port in `store` is Postgres and the other five are not +//! +//! Everything else in [`super`] is volatile TTL state whose production adapter is Valkey. The +//! cohort map is the exception the port's own docs argue for: *"A session store forgets a cohort +//! exactly when the 'have I seen this device before?' question becomes worth asking"* — the user +//! reinstalls, gets a new `device_id` by design, and the sessions that carried the old one have +//! long expired. A map that expired with them would answer the question it exists for with +//! "no, never" every time. +//! +//! That is why [`super::conformance`] splits `CohortHarness` out of `Harness`: this adapter +//! implements one port, and a suite that made it implement six to run four cases would make it +//! invent five adapters it will never have. +//! +//! # The whole adapter is one upsert +//! +//! `observe` is `INSERT … ON CONFLICT (user_id, cohort_hash) DO UPDATE SET last_seen = … +//! RETURNING`, and the composite primary key **is** the idempotence the port states: seeing the +//! same cohort twice is one row, not two. A read-then-branch would be the same statement with a +//! race in the middle, and the race is two devices of one account signing in at once. +//! +//! `first_seen` is untouched by the update, which is the half that matters: it is what lets a +//! client say *"a device you've used before (last seen March)"* rather than presenting a +//! stranger. + +use jiff::Timestamp; +use sea_orm::{ConnectionTrait, DatabaseConnection, DbBackend, Statement, Value}; + +use super::auth::{CohortRecord, CohortStore}; +use super::{StoreFuture, UserId}; +use crate::postgres::error::Port; +use crate::postgres::time::{from_micros, to_micros}; + +/// Which port is speaking, for every error this adapter raises. +const PORT: Port = Port { + store: "device-cohorts", + record: "CohortRecord", +}; + +/// The durable device-cohort map. +#[derive(Debug, Clone)] +pub struct PostgresCohorts { + connection: DatabaseConnection, +} + +impl PostgresCohorts { + /// A cohort map over `connection`. + pub fn new(connection: DatabaseConnection) -> Self { + Self { connection } + } +} + +/// Read one `device_cohorts` row back. +fn record_from(row: &sea_orm::QueryResult) -> Result { + let failed = PORT.failing("reading a cohort row"); + let user_id: String = row.try_get("", "user_id").map_err(&failed)?; + let cohort_hash: String = row.try_get("", "cohort_hash").map_err(&failed)?; + let first_seen: i64 = row.try_get("", "first_seen").map_err(&failed)?; + let last_seen: i64 = row.try_get("", "last_seen").map_err(&failed)?; + let instant = |micros: i64| { + from_micros(micros) + .ok_or_else(|| PORT.undecodable(format!("{micros}µs is not a representable instant"))) + }; + Ok(CohortRecord { + user_id: UserId::new(user_id), + cohort_hash, + first_seen: instant(first_seen)?, + last_seen: instant(last_seen)?, + }) +} + +impl CohortStore for PostgresCohorts { + fn observe<'a>( + &'a self, + user: &'a UserId, + cohort_hash: &'a str, + at: Timestamp, + ) -> StoreFuture<'a, CohortRecord> { + Box::pin(async move { + let observed = self + .connection + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "INSERT INTO device_cohorts (user_id, cohort_hash, first_seen, last_seen) \ + VALUES ($1, $2, $3, $3) \ + ON CONFLICT (user_id, cohort_hash) \ + DO UPDATE SET last_seen = EXCLUDED.last_seen \ + RETURNING user_id, cohort_hash, first_seen, last_seen", + [ + Value::from(user.as_str().to_owned()), + Value::from(cohort_hash.to_owned()), + Value::from(to_micros(at)), + ], + )) + .await + .map_err(PORT.failing("observing a device cohort"))? + .ok_or_else(|| crate::store::StoreError::Rejected { + store: PORT.store, + detail: "the cohort upsert returned no row".to_owned(), + })?; + let record = record_from(&observed)?; + if record.first_seen == record.last_seen { + tracing::info!(%user, "an account was seen under a new device cohort"); + } + Ok(record) + }) + } + + fn cohorts_for_user<'a>(&'a self, user: &'a UserId) -> StoreFuture<'a, Vec> { + Box::pin(async move { + // Oldest first sighting first, ties broken by the hash so the order is total. The + // order is part of the contract rather than an accident of the backend: a + // user-visible listing whose order depends on the storage engine is a listing that + // reshuffles itself between page loads. `COLLATE "C"` on the tie-break so the total + // order is the same one the deterministic double's `BTreeMap` produces — a cohort + // hash is client-asserted text, and a locale collation orders it differently. + let found = self + .connection + .query_all(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT user_id, cohort_hash, first_seen, last_seen FROM device_cohorts \ + WHERE user_id = $1 \ + ORDER BY first_seen, cohort_hash COLLATE \"C\"", + [Value::from(user.as_str().to_owned())], + )) + .await + .map_err(PORT.failing("listing an account's device cohorts"))?; + found.iter().map(record_from).collect() + }) + } +} + +#[cfg(test)] +mod tests { + /// The suite, against a real Postgres. + mod postgres_conformance { + use jiff::SignedDuration; + + use super::super::PostgresCohorts; + use crate::postgres::testing; + use crate::store::conformance::{self, CohortHarness}; + use crate::store::{CohortStore, StoreFuture}; + + /// A harness over one container. + /// + /// `advance` is a **no-op**, and that is the honest implementation rather than a stub: + /// the cohort map has no TTL at all, so there is no clock to move. The one case that + /// calls it — `the_cohort_map_does_not_expire` — asserts a record survives a year, and a + /// store that expires nothing passes it whatever the clock says. + #[derive(Debug)] + struct Harness { + cohorts: PostgresCohorts, + } + + impl CohortHarness for Harness { + fn cohorts(&self) -> &dyn CohortStore { + &self.cohorts + } + + fn advance(&self, _by: SignedDuration) -> StoreFuture<'_, ()> { + Box::pin(async { Ok(()) }) + } + } + + #[tokio::test] + async fn the_postgres_cohort_map_conforms() { + let Some(database) = testing::start("the Postgres device-cohort map").await else { + return; + }; + let harness = Harness { + cohorts: PostgresCohorts::new(database.connection().clone()), + }; + conformance::run_all_cohorts(&harness).await; + } + } +} diff --git a/capsule-server/src/store/conformance.rs b/capsule-server/src/store/conformance.rs index 1103f95f..412d16ff 100644 --- a/capsule-server/src/store/conformance.rs +++ b/capsule-server/src/store/conformance.rs @@ -52,12 +52,36 @@ use super::upload::{ }; use super::{StoreError, StoreFuture, deadline}; -/// The six stores under test, plus the one thing a suite cannot do through a port: move time. +/// The durable device-cohort map, on its own. /// -/// `advance` is the seam that keeps the suite backend-agnostic. The deterministic double -/// advances a manual clock; a Valkey- or Postgres-backed harness sleeps, or resets its stores -/// with a lifetime short enough to wait out. Either way the cases below are identical. -pub trait Harness: Send + Sync { +/// Split out of [`Harness`] because [`CohortStore`] is the one port in this module that is +/// **not** Valkey's. Everything else here is volatile TTL state — sessions, upload progress, four +/// ceremonies — and the cohort map deliberately outlives all of it: a cohort becomes worth +/// knowing exactly when the sessions that carried it have expired. Its production adapter is +/// therefore Postgres (#402) while the other five are Valkey's (#403), and a Postgres-backed +/// harness that had to implement `auth()`, `uploads()` and three ceremony stores to run four +/// cohort cases would have to invent five adapters it will never have. +/// +/// `advance` rides along rather than staying on [`Harness`], for the same reason: a cohort case +/// asserts the map does *not* expire, and a suite that could not move time could not assert it. +pub trait CohortHarness: Send + Sync { + /// The durable device-cohort map under test. + fn cohorts(&self) -> &dyn CohortStore; + + /// Move every store in this harness `by` forward in its own time. + /// + /// The seam that keeps the suite backend-agnostic. The deterministic double advances a + /// manual clock; a Valkey- or Postgres-backed harness sleeps, or resets its stores with a + /// lifetime short enough to wait out — and for a store with no TTL at all it is legitimately + /// a no-op, because there is nothing to move. + fn advance(&self, by: SignedDuration) -> StoreFuture<'_, ()>; +} + +/// The five volatile stores under test, plus the time seam it inherits. +/// +/// `Harness` extends [`CohortHarness`] rather than restating `advance`, so one deterministic +/// double still implements both and [`run_all`] still runs every case in this module against it. +pub trait Harness: CohortHarness { /// The authentication-state store under test. fn auth(&self) -> &dyn AuthStateStore; /// The upload-session store under test. @@ -68,11 +92,6 @@ pub trait Harness: Send + Sync { fn enrollments(&self) -> &dyn EnrollmentStore; /// The enrollment relay-channel store under test. fn channels(&self) -> &dyn ChannelStore; - /// The durable device-cohort map under test. - fn cohorts(&self) -> &dyn CohortStore; - - /// Move every store in this harness `by` forward in its own time. - fn advance(&self, by: SignedDuration) -> StoreFuture<'_, ()>; } /// Unwrap a store result, failing with the operation that was expected to work. @@ -1242,7 +1261,7 @@ pub async fn closing_a_channel_drops_both_mailboxes(h: &dyn Harness) { // ------------------------------------------------------------------------------------------- /// A cohort is a fact about a device, not an event: seeing it twice is one row. -pub async fn observing_a_cohort_twice_is_one_row_that_moves_last_seen(h: &dyn Harness) { +pub async fn observing_a_cohort_twice_is_one_row_that_moves_last_seen(h: &dyn CohortHarness) { let user = UserId::new("cohort-user-1"); let at = Timestamp::UNIX_EPOCH; @@ -1272,7 +1291,7 @@ pub async fn observing_a_cohort_twice_is_one_row_that_moves_last_seen(h: &dyn Ha } /// Cohorts are listed oldest first sighting first, and the order is total. -pub async fn cohorts_are_listed_oldest_first(h: &dyn Harness) { +pub async fn cohorts_are_listed_oldest_first(h: &dyn CohortHarness) { let user = UserId::new("cohort-user-2"); let base = Timestamp::UNIX_EPOCH; for (hash, hours) in [ @@ -1298,7 +1317,7 @@ pub async fn cohorts_are_listed_oldest_first(h: &dyn Harness) { } /// A cohort is scoped to its account, and the hash folds the account in besides. -pub async fn a_cohort_is_scoped_to_its_account(h: &dyn Harness) { +pub async fn a_cohort_is_scoped_to_its_account(h: &dyn CohortHarness) { let mine = UserId::new("cohort-user-3"); let theirs = UserId::new("cohort-user-4"); ok( @@ -1316,7 +1335,7 @@ pub async fn a_cohort_is_scoped_to_its_account(h: &dyn Harness) { } /// The cohort map does not expire with the sessions that carried it. -pub async fn the_cohort_map_does_not_expire(h: &dyn Harness) { +pub async fn the_cohort_map_does_not_expire(h: &dyn CohortHarness) { // The one store in this module with no TTL, and deliberately: a cohort is worth recording // precisely because it outlives the sessions that named it. A map that expired with them // would forget exactly when "have I seen this device before?" starts being worth asking. @@ -1378,6 +1397,16 @@ pub async fn run_all(h: &dyn Harness) { relaying_requires_a_live_channel(h).await; relayed_payloads_drain_in_order_and_by_direction(h).await; closing_a_channel_drops_both_mailboxes(h).await; + + run_all_cohorts(h).await; +} + +/// Run every [`CohortHarness`] case above, in order. +/// +/// A second entry point rather than a subset of [`run_all`], because the cohort map's adapter is +/// a different backend from the other five stores' — so the two suites are run against different +/// harnesses, and only the deterministic double is both. +pub async fn run_all_cohorts(h: &dyn CohortHarness) { observing_a_cohort_twice_is_one_row_that_moves_last_seen(h).await; cohorts_are_listed_oldest_first(h).await; a_cohort_is_scoped_to_its_account(h).await; diff --git a/capsule-server/src/store/memory.rs b/capsule-server/src/store/memory.rs index f4d7f100..edca45d4 100644 --- a/capsule-server/src/store/memory.rs +++ b/capsule-server/src/store/memory.rs @@ -1109,6 +1109,19 @@ impl Default for InMemoryStores { } } +impl super::conformance::CohortHarness for InMemoryStores { + fn cohorts(&self) -> &dyn CohortStore { + &self.cohorts + } + + fn advance(&self, by: SignedDuration) -> StoreFuture<'_, ()> { + Box::pin(async move { + self.clock.advance(by); + Ok::<(), StoreError>(()) + }) + } +} + impl super::conformance::Harness for InMemoryStores { fn auth(&self) -> &dyn AuthStateStore { &self.auth @@ -1126,20 +1139,9 @@ impl super::conformance::Harness for InMemoryStores { &self.enrollments } - fn cohorts(&self) -> &dyn CohortStore { - &self.cohorts - } - fn channels(&self) -> &dyn ChannelStore { &self.channels } - - fn advance(&self, by: SignedDuration) -> StoreFuture<'_, ()> { - Box::pin(async move { - self.clock.advance(by); - Ok::<(), StoreError>(()) - }) - } } #[cfg(test)] @@ -1200,6 +1202,30 @@ mod tests { closing_a_channel_drops_both_mailboxes, } + /// The same, for the cases that take a [`conformance::CohortHarness`]. + /// + /// A second macro because the cohort map is a different port with a different production + /// backend, so its cases take the narrower harness — and one `#[tokio::test]` each here + /// rather than only inside `run_all`, which is what makes a cohort failure name the property + /// that broke. + macro_rules! cohort_conformance_cases { + ($($case:ident),+ $(,)?) => { + $( + #[tokio::test] + async fn $case() { + conformance::$case(&harness()).await; + } + )+ + }; + } + + cohort_conformance_cases! { + observing_a_cohort_twice_is_one_row_that_moves_last_seen, + cohorts_are_listed_oldest_first, + a_cohort_is_scoped_to_its_account, + the_cohort_map_does_not_expire, + } + /// The whole suite, in one pass on one harness. /// /// This is the entry point a container-backed adapter uses, so it is exercised here too — diff --git a/capsule-server/src/store/mod.rs b/capsule-server/src/store/mod.rs index 23b33ae1..5595c14a 100644 --- a/capsule-server/src/store/mod.rs +++ b/capsule-server/src/store/mod.rs @@ -36,12 +36,23 @@ //! //! # Adapters, and what they are for //! -//! Three adapters are planned per port — Postgres, Valkey, and a deterministic in-memory one — -//! and **three adapters are not three deployment modes**. Valkey is required; the server -//! refuses to boot without `VALKEY_URL` (design/filesystem/server.md, "Required Services"). -//! The in-memory adapter in [`memory`] is a **test double**, never a deployment profile. The -//! rejected alternative was a Postgres fallback removing Valkey, which would mean emulating -//! TTL and expiry in SQL — the generic TTL abstraction this slice exists to delete. +//! **Two** adapters per port, and which two is a property of the port rather than a menu. +//! The five volatile stores — [`AuthStateStore`], [`UploadSessionStore`] and the three ceremony +//! stores — get Valkey and the deterministic in-memory double, and nothing else: Valkey is +//! required, the server refuses to boot without `VALKEY_URL` +//! (design/filesystem/server.md, "Required Services"), and the rejected alternative was a +//! Postgres fallback removing Valkey, which would mean emulating TTL and expiry in SQL — the +//! generic TTL abstraction this slice exists to delete. [`CohortStore`] is the exception and +//! gets **Postgres** and the double, because it is the one record here that deliberately +//! outlives the sessions that carried it (see [`auth`]); its adapter is +//! [`cohorts_postgres::PostgresCohorts`]. +//! +//! An earlier version of this paragraph said three adapters were planned per port. That was +//! never true of any port in this module and was the one line in the tree pointing at a +//! Postgres session table. +//! +//! Whichever two a port has, the in-memory adapter in [`memory`] is a **test double**, never a +//! deployment profile. //! //! Whichever adapter is in play, it must pass the one shared suite in [`conformance`]. That //! suite is what makes "the in-memory adapter behaves like Valkey" an assertion rather than an @@ -57,6 +68,7 @@ pub mod auth; pub mod ceremony; +pub mod cohorts_postgres; pub mod conformance; pub mod ids; pub mod memory; @@ -74,6 +86,7 @@ pub use self::ceremony::{ EnrollmentStore, PendingEnrollment, RELAY_CHANNEL_TTL, RelayChannel, RelayOutcome, RelayPayload, RevokeAllChallenge, }; +pub use self::cohorts_postgres::PostgresCohorts; pub use self::ids::{ AlbumId, AssetId, ChallengeToken, ChannelId, EnrollmentCode, OwnerId, SessionId, UploadId, UserId, From 6ca92c1a3063534256a52c89edda4dc53e5f08f7 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 02:18:22 -0400 Subject: [PATCH 092/243] feat(server): add the Postgres account store and the auth suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four ports — registry, directory, profiles, password change — over one `accounts` row. They are four ports because they answer four questions with four disclosure contracts, which their module docs argue at length; none of that makes them four stores, and splitting the row would put the lockout counter somewhere the password change that must clear it cannot reach in the same statement. `auth/conformance.rs` is new, and the four ports share it for the same reason they share a table: most of the properties worth asserting cross them. A password change is only interesting because the directory then grants the new password and refuses the old one; a lockout is only interesting because a password change clears it. Sixteen cases, run against `InMemoryAccounts` before this adapter was written and against both since. The suite asks the harness for `lockout_attempts` rather than reading the constant, because the threshold is a deployment setting (`LOCKOUT_MAX_ATTEMPTS`) — a suite that hardcoded ten would silently stop testing the ceiling the moment a deployment moved it. Two things it deliberately does not assert are named in its own docs rather than faked: the timing-equalized miss is a response *time*, and a timing assertion is flaky by nature, so what is asserted is the consequence a suite can see — both answers are one value; and `create`'s atomicity against two racing registrations cannot be exhibited in one process, so the structural guarantee stays in the adapter as a unique index. Where this adapter is stricter than the in-memory one is the failed-attempt bookkeeping, and `accounts_memory` predicted it: "that is the right trade for a development adapter and it is not the trade a Postgres adapter should make: there the increment is one statement". It is one statement here — the decay, the reset of a stale run and the increment are a single `UPDATE … SET failures = CASE …` — so two simultaneous wrong passwords are two counted failures rather than one. Argon2id still runs outside every statement, because a verification held inside a transaction is a row lock held for the length of the slowest primitive in the process. An attempt made while an account is locked is refused without being counted, so hammering somebody else's account cannot keep it locked forever. The clock is injected and the window is never measured against the database's `now()`: the suite has to move fifteen minutes without sleeping for them, and a server and its database disagreeing about the hour should not change who is locked out. `email` carries a plain unique index — no `lower()`, no `citext`, no folded companion column. Addresses are compared verbatim because case folding is a normalization policy no port describes, and `addresses_are_compared_verbatim` asserts it precisely because a `citext` column is the kind of thing that changes an identity decision without anybody making one. The accounts ordinal gains `last_failure_at`, edited in place rather than appended as a fifth migration: no deployment holds a row, and the column is what the lockout's decay is measured from. Without it the count is a one-way door — no surface in this server can clear a lockout, so a permanent one is a permanently lost account. Also repairs a rustdoc link in `index/postgres.rs` that `cargo doc` flagged. Refs #402 --- .../src/m20260902_000002_accounts.rs | 6 + capsule-server/src/auth/accounts_postgres.rs | 536 ++++++++++++++ capsule-server/src/auth/conformance.rs | 700 ++++++++++++++++++ capsule-server/src/auth/mod.rs | 3 + capsule-server/src/index/postgres.rs | 2 +- 5 files changed, 1246 insertions(+), 1 deletion(-) create mode 100644 capsule-server/src/auth/accounts_postgres.rs create mode 100644 capsule-server/src/auth/conformance.rs diff --git a/capsule-server/migration/src/m20260902_000002_accounts.rs b/capsule-server/migration/src/m20260902_000002_accounts.rs index 5262a58a..4e6c58f6 100644 --- a/capsule-server/migration/src/m20260902_000002_accounts.rs +++ b/capsule-server/migration/src/m20260902_000002_accounts.rs @@ -43,6 +43,11 @@ impl MigrationTrait for Migration { .not_null() .default(0), ) + // The lockout's clock. Without it the count is a one-way door: no surface in + // this server can clear a lockout — `login`, `reauthenticate` and `password` + // all refuse on `Locked` before verifying anything, and no operator command + // reaches this row — so a permanent lockout is a permanently lost account. + .col(ColumnDef::new(Accounts::LastFailureAt).big_integer().null()) .col(ColumnDef::new(Accounts::CreatedAt).big_integer().not_null()) .col(ColumnDef::new(Accounts::UpdatedAt).big_integer().not_null()) .to_owned(), @@ -82,6 +87,7 @@ enum Accounts { DisplayName, Credential, Failures, + LastFailureAt, CreatedAt, UpdatedAt, } diff --git a/capsule-server/src/auth/accounts_postgres.rs b/capsule-server/src/auth/accounts_postgres.rs new file mode 100644 index 00000000..6acba297 --- /dev/null +++ b/capsule-server/src/auth/accounts_postgres.rs @@ -0,0 +1,536 @@ +//! [`PostgresAccounts`] — the durable account store (`S-C53`, `S-C54`, #402). +//! +//! # Four ports, one table, one adapter +//! +//! [`AccountRegistry`], [`AccountDirectory`], [`AccountProfiles`] and [`PasswordChange`] are four +//! ports because they answer four questions with four disclosure contracts — registration has to +//! say whether an address is taken, authentication must not — and their module docs argue that at +//! length. None of that makes them four *stores*: one row holds every fact all four read, and +//! splitting it across four tables would put the lockout counter somewhere the password change +//! that must clear it cannot reach in the same statement. +//! +//! # Where this adapter is stricter than the in-memory one, and why +//! +//! [`accounts_memory`](super::accounts_memory) computes its hash outside its mutex and takes a +//! read-modify-write gap in the failed-attempt counter as a result, recording its own docs that +//! *"that is the right trade for a development adapter and it is not the trade a Postgres +//! adapter should make: there the increment is one statement"*. It is one statement here. The +//! decay, the reset-on-a-stale-run and the increment are a single `UPDATE … SET failures = CASE +//! …`, so two simultaneous wrong passwords are two counted failures rather than one. +//! +//! Argon2id still runs outside every statement. It costs tens of milliseconds, and a verification +//! held inside a transaction is a row lock held for the length of the slowest primitive in the +//! process. +//! +//! # The clock is injected, and never `now()` +//! +//! The lockout window is measured against [`Clock`], not against the database's `now()`. Two +//! reasons: the conformance suite has to be able to move time without sleeping for fifteen +//! minutes, and a server and its database disagreeing about the hour should not change who is +//! locked out. +//! +//! # Addresses are compared verbatim +//! +//! `email` carries a plain unique index and no `lower()`, no `citext`, no folded companion +//! column. Case folding is a normalization policy no port describes, and the conformance suite +//! asserts the consequence — `addresses_are_compared_verbatim` — precisely because a `citext` +//! column is the kind of thing that changes an identity decision without anybody making one. + +use std::sync::Arc; + +use jiff::{SignedDuration, Timestamp}; +use sea_orm::{ConnectionTrait, DatabaseConnection, DbBackend, Statement, Value}; + +use super::credential::{CredentialError, Credentials}; +use super::directory::{AccountDirectory, Authentication, DirectoryError, DirectoryFuture}; +use super::profile::{ + AccountProfiles, PasswordChange, PasswordChanged, ProfileRecord, ProfileUpdate, +}; +use super::registry::{AccountRegistry, Registration}; +use crate::postgres::error::Port; +use crate::postgres::time::{from_micros, to_micros}; +use crate::store::{Clock, StoreError, UserId}; + +/// Which port is speaking, for every error this adapter raises. +const PORT: Port = Port { + store: "accounts", + record: "Account", +}; + +/// The durable account store. +#[derive(Clone)] +pub struct PostgresAccounts { + connection: DatabaseConnection, + credentials: Credentials, + clock: Arc, + lockout_attempts: u32, + lockout_window: SignedDuration, +} + +impl std::fmt::Debug for PostgresAccounts { + /// Names the policy and never the state, for the reason [`Credentials`] does: the state is a + /// pool holding a URL with a password in it. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PostgresAccounts") + .field("lockout_attempts", &self.lockout_attempts) + .field("lockout_window", &self.lockout_window) + .finish_non_exhaustive() + } +} + +impl PostgresAccounts { + /// An account store over `connection`, locking an account out for `lockout_window` after + /// `lockout_attempts` consecutive failures. + /// + /// The verifier is passed in rather than constructed here because building one costs an + /// Argon2id hash (the timing-equalized miss's decoy), and a composition root that builds + /// several adapters should pay that once. + pub fn new( + connection: DatabaseConnection, + credentials: Credentials, + clock: Arc, + lockout_attempts: u32, + lockout_window: SignedDuration, + ) -> Self { + Self { + connection, + credentials, + clock, + lockout_attempts, + lockout_window, + } + } + + /// The account `column` = `key` names, if there is one. + /// + /// `column` is a `&'static str` chosen from two literals below and never from a caller, so + /// this is not a string-built query in the sense that matters — there is no input path to it. + /// The alternative, two nearly identical statements, is the same query twice with a + /// different `WHERE`, and the second copy is where a lockout column eventually gets left out. + async fn lookup(&self, column: &'static str, key: &str) -> Result, StoreError> { + let found = self + .connection + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + format!( + "SELECT user_id, credential, failures, last_failure_at \ + FROM accounts WHERE {column} = $1" + ), + [Value::from(key.to_owned())], + )) + .await + .map_err(PORT.failing("looking an account up"))?; + let Some(found) = found else { return Ok(None) }; + + let failed = PORT.failing("reading an account row"); + let user_id: String = found.try_get("", "user_id").map_err(&failed)?; + let stored: String = found.try_get("", "credential").map_err(&failed)?; + let failures: i64 = found.try_get("", "failures").map_err(&failed)?; + let last_failure_at: Option = found.try_get("", "last_failure_at").map_err(&failed)?; + Ok(Some(Held { + user_id: UserId::new(user_id), + stored, + failures: u32::try_from(failures) + .map_err(|_| PORT.undecodable(format!("{failures} is not a failure count")))?, + last_failure_at: last_failure_at + .map(|micros| { + from_micros(micros).ok_or_else(|| { + PORT.undecodable(format!("{micros}µs is not a representable instant")) + }) + }) + .transpose()?, + })) + } + + /// Whether enough *recent* failures have accumulated to refuse a correct password. + /// + /// Recent is the operative word, and the decay is not a courtesy: nothing in this server can + /// clear a lockout otherwise. `login`, `reauthenticate` and `password` each ask the directory + /// first and refuse on `Locked` before verifying anything, there is no unlock operation on + /// any surface, and no operator command reaches this row — so a permanent lockout is a + /// permanently lost account. + fn locked(&self, held: &Held, now: Timestamp) -> bool { + held.failures >= self.lockout_attempts + && held + .last_failure_at + .is_some_and(|at| now.duration_since(at) < self.lockout_window) + } + + /// Record the outcome of a credential presentation, in one statement. + /// + /// The `CASE` is the decay: a failure whose predecessor is older than the window starts a + /// fresh run at 1 rather than tipping a stale count over, so ten mistypes spread over a year + /// never lock an account. Done in SQL rather than by reading, deciding and writing, because + /// the read-decide-write is exactly the gap the in-memory adapter documents as the price of + /// being a development double. + async fn record(&self, user: &UserId, granted: bool, now: Timestamp) -> Result<(), StoreError> { + let statement = if granted { + Statement::from_sql_and_values( + DbBackend::Postgres, + "UPDATE accounts SET failures = 0, last_failure_at = NULL WHERE user_id = $1", + [Value::from(user.as_str().to_owned())], + ) + } else { + Statement::from_sql_and_values( + DbBackend::Postgres, + "UPDATE accounts SET \ + failures = CASE \ + WHEN last_failure_at IS NOT NULL AND $2 - last_failure_at >= $3 THEN 1 \ + ELSE failures + 1 END, \ + last_failure_at = $2 \ + WHERE user_id = $1", + [ + Value::from(user.as_str().to_owned()), + Value::from(to_micros(now)), + Value::from(self.lockout_window.as_micros() as i64), + ], + ) + }; + self.connection + .execute(statement) + .await + .map_err(PORT.failing("recording a credential presentation"))?; + Ok(()) + } + + /// Decide `password` against a looked-up account, and record what happened. + /// + /// The shared body of the two [`AccountDirectory`] methods: they differ only in how they find + /// the account, and that is exactly the difference the port wants them to have. + async fn decide( + &self, + held: Option, + password: &str, + ) -> Result { + let now = self.clock.now(); + let Some(held) = held else { + // The timing-equalized miss. Refusing here without doing the work would leak the + // difference between an unknown address and a wrong password in the response time, + // whatever the body said. + self.credentials.absorb_miss(password); + return Ok(Authentication::Refused); + }; + if self.locked(&held, now) { + // Still absorbed: a locked account that returned instantly would tell an attacker + // which addresses they have already spent attempts on. Deliberately **not** + // recorded — an attempt made while locked must not extend the window, or anybody + // who can reach the endpoint can keep somebody else's account locked forever. + self.credentials.absorb_miss(password); + return Ok(Authentication::Locked); + } + let granted = self + .credentials + .verify(password, &held.stored) + .map_err(unavailable)?; + self.record(&held.user_id, granted, now) + .await + .map_err(store_unavailable)?; + if granted { + Ok(Authentication::Granted(held.user_id)) + } else { + Ok(Authentication::Refused) + } + } + + /// The profile of `user`, read through whatever connection the caller has. + async fn profile(&self, user: &UserId) -> Result, StoreError> { + let found = self + .connection + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT user_id, email, display_name, created_at FROM accounts \ + WHERE user_id = $1", + [Value::from(user.as_str().to_owned())], + )) + .await + .map_err(PORT.failing("reading a profile"))?; + found.as_ref().map(profile_from).transpose() + } +} + +/// The columns an authentication decision reads. +#[derive(Debug)] +struct Held { + user_id: UserId, + /// The Argon2id PHC string. Never a password, and never read above this adapter. + stored: String, + failures: u32, + last_failure_at: Option, +} + +/// Read one profile projection back. +fn profile_from(row: &sea_orm::QueryResult) -> Result { + let failed = PORT.failing("reading a profile row"); + let user_id: String = row.try_get("", "user_id").map_err(&failed)?; + let email: String = row.try_get("", "email").map_err(&failed)?; + let display_name: Option = row.try_get("", "display_name").map_err(&failed)?; + let created_at: i64 = row.try_get("", "created_at").map_err(&failed)?; + Ok(ProfileRecord { + user_id: UserId::new(user_id), + email, + display_name, + created_at: from_micros(created_at).ok_or_else(|| { + PORT.undecodable(format!("{created_at}µs is not a representable instant")) + })?, + }) +} + +/// A credential fault is a directory fault: no decision was reached. +fn unavailable(error: CredentialError) -> DirectoryError { + tracing::error!(%error, "a stored credential could not be processed"); + DirectoryError::Unavailable { + detail: error.to_string(), + } +} + +/// A store fault is a directory fault, for the same reason. +/// +/// [`DirectoryError`] has exactly one variant, and that is the port's decision rather than a +/// simplification here: *"whether the backend was down or merely angry changes nothing about the +/// response"*. The [`StoreError`] distinction is preserved in the log line the mapping already +/// emitted, which is where an operator reads it. +fn store_unavailable(error: StoreError) -> DirectoryError { + DirectoryError::Unavailable { + detail: error.to_string(), + } +} + +impl AccountDirectory for PostgresAccounts { + fn authenticate<'a>( + &'a self, + email: &'a str, + password: &'a str, + ) -> DirectoryFuture<'a, Authentication> { + Box::pin(async move { + let held = self + .lookup("email", email) + .await + .map_err(store_unavailable)?; + self.decide(held, password).await + }) + } + + fn authenticate_user<'a>( + &'a self, + user: &'a UserId, + password: &'a str, + ) -> DirectoryFuture<'a, Authentication> { + Box::pin(async move { + let held = self + .lookup("user_id", user.as_str()) + .await + .map_err(store_unavailable)?; + self.decide(held, password).await + }) + } +} + +impl AccountRegistry for PostgresAccounts { + fn create<'a>( + &'a self, + email: &'a str, + password: &'a str, + user: &'a UserId, + at: Timestamp, + ) -> DirectoryFuture<'a, Registration> { + Box::pin(async move { + // Hashed before the statement, so nothing holds a row while Argon2id runs. The cost + // of that ordering is a hash computed for an address that turns out to be taken, + // which is the cheap direction to be wrong in. + let stored = self.credentials.hash(password).map_err(unavailable)?; + // `ON CONFLICT DO NOTHING` on the unique index, so `AlreadyExists` is decided by the + // index and never by a read followed by a write: two registrations racing on one + // address must not both believe they own it. + let inserted = self + .connection + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "INSERT INTO accounts \ + (user_id, email, display_name, credential, failures, created_at, updated_at) \ + VALUES ($1, $2, NULL, $3, 0, $4, $4) \ + ON CONFLICT (email) DO NOTHING", + [ + Value::from(user.as_str().to_owned()), + Value::from(email.to_owned()), + Value::from(stored), + Value::from(to_micros(at)), + ], + )) + .await + .map_err(PORT.failing("creating an account")) + .map_err(store_unavailable)?; + if inserted.rows_affected() == 0 { + return Ok(Registration::AlreadyExists); + } + tracing::info!(%user, "an account was created"); + Ok(Registration::Created(user.clone())) + }) + } +} + +impl AccountProfiles for PostgresAccounts { + fn read<'a>(&'a self, user: &'a UserId) -> DirectoryFuture<'a, Option> { + Box::pin(async move { self.profile(user).await.map_err(store_unavailable) }) + } + + fn update<'a>( + &'a self, + user: &'a UserId, + update: &'a ProfileUpdate, + ) -> DirectoryFuture<'a, Option> { + Box::pin(async move { + // An empty update is a no-op the route answers with the current profile, and the + // port says so — which is why it is a read here rather than an `UPDATE` that sets + // every column to itself. + let Some(display_name) = update.display_name.clone() else { + return self.profile(user).await.map_err(store_unavailable); + }; + // One statement, as the port requires: a caller that read, changed a field and wrote + // the whole record back would clobber a concurrent edit from another device. + let updated = self + .connection + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "UPDATE accounts SET display_name = $2, updated_at = $3 WHERE user_id = $1 \ + RETURNING user_id, email, display_name, created_at", + [ + Value::from(user.as_str().to_owned()), + Value::from(display_name), + Value::from(to_micros(self.clock.now())), + ], + )) + .await + .map_err(PORT.failing("updating a profile")) + .map_err(store_unavailable)?; + updated + .as_ref() + .map(profile_from) + .transpose() + .map_err(store_unavailable) + }) + } +} + +impl PasswordChange for PostgresAccounts { + fn set_password<'a>( + &'a self, + user: &'a UserId, + password: &'a str, + at: Timestamp, + ) -> DirectoryFuture<'a, PasswordChanged> { + Box::pin(async move { + let stored = self.credentials.hash(password).map_err(unavailable)?; + // The lockout is cleared in the same statement, because the port requires it: a + // change is a successful credential presentation, and leaving the failure count + // behind would bar somebody from an account they just proved they own. + let changed = self + .connection + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "UPDATE accounts \ + SET credential = $2, failures = 0, last_failure_at = NULL, updated_at = $3 \ + WHERE user_id = $1", + [ + Value::from(user.as_str().to_owned()), + Value::from(stored), + Value::from(to_micros(at)), + ], + )) + .await + .map_err(PORT.failing("replacing a password")) + .map_err(store_unavailable)?; + if changed.rows_affected() == 0 { + return Ok(PasswordChanged::NoSuchAccount); + } + tracing::info!(%user, "an account's password was replaced"); + Ok(PasswordChanged::Yes) + }) + } +} + +#[cfg(test)] +mod tests { + /// The suite, against a real Postgres. + mod postgres_conformance { + use std::sync::Arc; + + use jiff::SignedDuration; + + use super::super::PostgresAccounts; + use crate::auth::accounts_memory::MAX_FAILED_ATTEMPTS; + use crate::auth::conformance::{self, Harness}; + use crate::auth::credential::Credentials; + use crate::auth::directory::AccountDirectory; + use crate::auth::profile::{AccountProfiles, PasswordChange}; + use crate::auth::registry::AccountRegistry; + use crate::postgres::testing; + use crate::store::StoreFuture; + use crate::store::memory::ManualClock; + + /// A fifteen-minute lockout window, as a deployment gets by default. + const WINDOW: SignedDuration = SignedDuration::from_mins(15); + + /// One adapter over one container, on a clock the suite drives. + /// + /// The clock is the whole reason the harness has an `advance`: the lockout cases assert a + /// fifteen-minute decay from both sides of its boundary, and a container-backed suite + /// that waited for it would take half an hour. + #[derive(Debug)] + struct PostgresHarness { + accounts: PostgresAccounts, + clock: Arc, + } + + impl Harness for PostgresHarness { + fn registry(&self) -> &dyn AccountRegistry { + &self.accounts + } + + fn directory(&self) -> &dyn AccountDirectory { + &self.accounts + } + + fn profiles(&self) -> &dyn AccountProfiles { + &self.accounts + } + + fn passwords(&self) -> &dyn PasswordChange { + &self.accounts + } + + fn lockout_attempts(&self) -> u32 { + MAX_FAILED_ATTEMPTS + } + + fn lockout_window(&self) -> SignedDuration { + WINDOW + } + + fn advance(&self, by: SignedDuration) -> StoreFuture<'_, ()> { + Box::pin(async move { + self.clock.advance(by); + Ok(()) + }) + } + } + + #[tokio::test] + async fn the_postgres_account_store_conforms() { + let Some(database) = testing::start("the Postgres account store").await else { + return; + }; + let clock = Arc::new(ManualClock::default()); + let harness = PostgresHarness { + accounts: PostgresAccounts::new( + database.connection().clone(), + Credentials::new().expect("the platform hashes"), + clock.clone(), + MAX_FAILED_ATTEMPTS, + WINDOW, + ), + clock, + }; + conformance::run_all(&harness).await; + } + } +} diff --git a/capsule-server/src/auth/conformance.rs b/capsule-server/src/auth/conformance.rs new file mode 100644 index 00000000..e579ff32 --- /dev/null +++ b/capsule-server/src/auth/conformance.rs @@ -0,0 +1,700 @@ +//! The one suite every account adapter must pass. +//! +//! # Why the four ports share one suite +//! +//! [`AccountRegistry`], [`AccountDirectory`], [`AccountProfiles`] and [`PasswordChange`] are +//! four ports because they answer four questions with four *disclosure* contracts — registration +//! must say whether an address is taken, authentication must not — and their own module docs +//! argue at length for keeping them apart. They are not four stores. Every adapter that exists +//! or is planned implements all four over one account row, and most of the properties worth +//! asserting cross them: a password change is only interesting because the *directory* then +//! grants the new password and refuses the old one, and a lockout is only interesting because a +//! password change clears it. +//! +//! So the harness hands out all four, and a case reaches for the ones its property spans. +//! +//! # What this suite cannot assert, stated rather than faked +//! +//! **The timing-equalized miss.** [`AccountDirectory`]'s contract is that no caller can tell an +//! unknown account from a wrong password, and half of that is a *response time*. A timing +//! assertion is flaky by nature and would be the `S-C35` mistake in a new place — a suite +//! claiming to prove a property under conditions it cannot create. What is asserted instead is +//! the consequence a suite *can* see: both answers are the same value, and neither path is +//! distinguishable through the port. The work itself is asserted where it is decidable, in +//! `credential`'s own tests (`absorbing_a_miss_costs_what_a_verification_costs`), and structurally +//! by the fact that the miss path calls the same verifier. +//! +//! **Concurrency.** `create` is required to be one operation against two racing registrations, +//! and a single-process suite cannot exhibit that race. What it asserts is the observable +//! consequence — the second registration is refused and the first account's credential still +//! works — and the structural guarantee stays in the adapter, as one lock here and as a unique +//! index in Postgres. +//! +//! # Reusing a harness +//! +//! Every case scopes its addresses and ids to itself, so cases may share one harness and +//! [`run_all`] does. A case may move the harness clock forward and never moves it back. + +use jiff::{SignedDuration, Timestamp}; + +use super::directory::{AccountDirectory, Authentication, DirectoryError}; +use super::profile::{ + AccountProfiles, PasswordChange, PasswordChanged, ProfileRecord, ProfileUpdate, +}; +use super::registry::{AccountRegistry, Registration}; +use crate::store::{StoreFuture, UserId}; + +/// The four account ports under test, plus the two things a suite cannot do through them. +/// +/// `lockout_attempts` is asked of the harness rather than read from a constant because the +/// threshold is a **deployment setting** (`LOCKOUT_MAX_ATTEMPTS`), so a harness built with a +/// tighter one must still pass — and a suite that hardcoded ten would silently stop testing the +/// ceiling the moment a deployment moved it. +pub trait Harness: Send + Sync { + /// Where accounts are created (`S-C53`). + fn registry(&self) -> &dyn AccountRegistry; + /// Who exists, and whether a presented password is theirs. + fn directory(&self) -> &dyn AccountDirectory; + /// The facts an account keeps about itself (`S-C54`). + fn profiles(&self) -> &dyn AccountProfiles; + /// Where a password is replaced (`S-C54`). + fn passwords(&self) -> &dyn PasswordChange; + + /// How many consecutive failures this harness locks an account out after. + fn lockout_attempts(&self) -> u32; + /// How long that lockout lasts. + fn lockout_window(&self) -> SignedDuration; + + /// Move the harness `by` forward in its own time. + /// + /// The seam that keeps the lockout cases backend-agnostic: the deterministic double advances + /// a manual clock, and a container-backed harness rebuilds its adapter over a clock it drives. + fn advance(&self, by: SignedDuration) -> StoreFuture<'_, ()>; +} + +/// Unwrap a directory result, failing with the operation that was expected to work. +#[track_caller] +fn ok(result: Result, doing: &str) -> T { + match result { + Ok(value) => value, + Err(error) => panic!("a conforming adapter must succeed at {doing}: {error}"), + } +} + +/// Unwrap an expected-present value. +#[track_caller] +fn present(value: Option, what: &str) -> T { + match value { + Some(value) => value, + None => panic!("{what} must be present"), + } +} + +/// The password every case registers with unless it is about passwords. +const PASSWORD: &str = "correct horse battery staple"; + +/// `case`'s own address, so cases may share a harness. +fn email(case: &str) -> String { + format!("{case}@example.test") +} + +/// `case`'s own account id. +/// +/// A UUID-shaped string rather than a bare word, because that is what `new_user_id` mints and +/// an adapter storing it in a typed column would notice the difference. +fn user(case: &str, n: u8) -> UserId { + UserId::new(format!( + "018f3f1e-4b7a-7c9d-8e2f-{:012x}", + u64::from(n) + hash(case) + )) +} + +/// A small stable spread so two cases' ids do not collide. +fn hash(case: &str) -> u64 { + case.bytes().fold(1_u64, |held, byte| { + held.wrapping_mul(131).wrapping_add(u64::from(byte)) & 0x0000_ffff_ffff + }) * 256 +} + +/// Register `case`'s account and return its id. +async fn seed(h: &dyn Harness, case: &str) -> (String, UserId) { + let address = email(case); + let id = user(case, 0); + assert_eq!( + ok( + h.registry() + .create(&address, PASSWORD, &id, Timestamp::UNIX_EPOCH) + .await, + "create an account", + ), + Registration::Created(id.clone()), + ); + (address, id) +} + +/// Present a wrong password `times` times against `address`. +async fn fail(h: &dyn Harness, address: &str, times: u32) { + for _ in 0..times { + let _ = h.directory().authenticate(address, "wrong").await; + } +} + +// =========================================================================================== +// Registration and sign-in +// =========================================================================================== + +/// The whole point of an account adapter: register, then sign in to what was registered. +pub async fn registering_then_signing_in_works(h: &dyn Harness) { + let (address, id) = seed(h, "signin").await; + assert_eq!( + ok( + h.directory().authenticate(&address, PASSWORD).await, + "authenticate", + ), + Authentication::Granted(id), + ); +} + +/// A taken address is reported, and the account behind it is untouched. +/// +/// The one disclosure this surface makes, and the alternative — answering success and creating +/// nothing — is worse in every way, because a client that believed it would then fail to sign in +/// with no explanation. +pub async fn a_taken_address_is_reported_and_nothing_is_written(h: &dyn Harness) { + let (address, first) = seed(h, "taken").await; + let second = user("taken", 1); + assert_eq!( + ok( + h.registry() + .create( + &address, + "a different password entirely", + &second, + Timestamp::UNIX_EPOCH, + ) + .await, + "create a second account for one address", + ), + Registration::AlreadyExists, + ); + // The first account's credential still works, so nothing was overwritten — which is what + // makes this a refusal rather than a silent takeover of somebody's address. + assert_eq!( + ok( + h.directory().authenticate(&address, PASSWORD).await, + "authenticate", + ), + Authentication::Granted(first), + ); +} + +/// An unknown address and a wrong password are one answer. +/// +/// The port collapses them into one value on purpose, so no caller *can* tell them apart and no +/// future caller can reintroduce an enumeration oracle by branching on something the port does +/// not offer. +pub async fn an_unknown_address_and_a_wrong_password_are_one_answer(h: &dyn Harness) { + let (address, _) = seed(h, "oracle").await; + assert_eq!( + ok( + h.directory() + .authenticate("nobody-oracle@example.test", PASSWORD) + .await, + "authenticate an unknown address", + ), + Authentication::Refused, + ); + assert_eq!( + ok( + h.directory() + .authenticate(&address, "the wrong password") + .await, + "authenticate with a wrong password", + ), + Authentication::Refused, + ); +} + +/// Addresses are compared verbatim. +/// +/// Case folding is a normalization policy no port describes, and a policy invented by one +/// adapter is a policy the others have to guess at — so `Foo@example.test` and +/// `foo@example.test` are two accounts until a slice says otherwise. Asserted rather than left +/// implicit because it is exactly the kind of thing a `citext` column or a `lower()` index +/// changes without anyone deciding to. +pub async fn addresses_are_compared_verbatim(h: &dyn Harness) { + let (address, _) = seed(h, "verbatim").await; + assert_eq!( + ok( + h.directory() + .authenticate(&address.to_uppercase(), PASSWORD) + .await, + "authenticate a differently-cased address", + ), + Authentication::Refused, + ); +} + +/// Re-authentication takes the account from the credential and never from the request. +/// +/// An operation that took an address it was handed would be usable to test another account's +/// password from inside any authenticated session. +pub async fn re_authentication_takes_the_account_from_the_credential(h: &dyn Harness) { + let (_, id) = seed(h, "reauth").await; + assert_eq!( + ok( + h.directory().authenticate_user(&id, PASSWORD).await, + "re-authenticate", + ), + Authentication::Granted(id.clone()), + ); + let stranger = user("reauth", 9); + assert_eq!( + ok( + h.directory().authenticate_user(&stranger, PASSWORD).await, + "re-authenticate as a stranger", + ), + Authentication::Refused, + ); +} + +// =========================================================================================== +// The lockout +// =========================================================================================== + +/// Enough failures lock the account, and a correct password is told so. +/// +/// `Locked` is the one refusal a *correct* password also receives, which is why it is a separate +/// value: a client showing "wrong password" here would send somebody round a loop that cannot +/// succeed. +pub async fn enough_failures_lock_the_account_and_a_correct_password_is_told_so(h: &dyn Harness) { + let (address, _) = seed(h, "lockout").await; + fail(h, &address, h.lockout_attempts()).await; + assert_eq!( + ok( + h.directory().authenticate(&address, PASSWORD).await, + "authenticate a locked account", + ), + Authentication::Locked, + ); +} + +/// A success before the ceiling clears the count. +pub async fn a_success_before_the_ceiling_clears_the_count(h: &dyn Harness) { + let (address, id) = seed(h, "clears").await; + fail(h, &address, h.lockout_attempts() - 1).await; + assert_eq!( + ok( + h.directory().authenticate(&address, PASSWORD).await, + "authenticate", + ), + Authentication::Granted(id.clone()), + ); + // Back to zero: the next wrong password does not tip an already-full counter over. + fail(h, &address, 1).await; + assert_eq!( + ok( + h.directory().authenticate(&address, PASSWORD).await, + "authenticate", + ), + Authentication::Granted(id), + ); +} + +/// The lockout decays, because nothing else in this server can clear it. +/// +/// There is no unlock operation on any surface, and `login`, `reauthenticate` and `password` all +/// refuse on `Locked` before they verify anything — so without a decay a lockout is a +/// permanently lost account rather than a throttle. The boundary is asserted from both sides +/// because an off-by-one here is a lockout that never engages. +pub async fn a_lockout_decays_because_nothing_else_can_clear_it(h: &dyn Harness) { + let (address, id) = seed(h, "decay").await; + fail(h, &address, h.lockout_attempts()).await; + assert_eq!( + ok( + h.directory().authenticate(&address, PASSWORD).await, + "authenticate a locked account", + ), + Authentication::Locked, + ); + + ok_store( + h.advance(h.lockout_window() - SignedDuration::from_secs(1)) + .await, + ); + assert_eq!( + ok( + h.directory().authenticate(&address, PASSWORD).await, + "authenticate one second inside the window", + ), + Authentication::Locked, + ); + + ok_store(h.advance(SignedDuration::from_secs(1)).await); + assert_eq!( + ok( + h.directory().authenticate(&address, PASSWORD).await, + "authenticate past the window", + ), + Authentication::Granted(id), + ); +} + +/// Attempts made during a lockout do not extend it. +/// +/// Deliberate, and the direction is not obvious. Extending the window on every attempt would +/// hand anybody who can reach the endpoint a way to keep somebody else's account locked forever +/// by hammering it — a denial of service on an account rather than a defence of it. So the +/// window runs from the last **counted** failure. +pub async fn attempts_during_a_lockout_do_not_extend_it(h: &dyn Harness) { + let (address, id) = seed(h, "hammer").await; + fail(h, &address, h.lockout_attempts()).await; + // Hammer it three times, each a little later, all inside the window. + let step = h.lockout_window() / 8; + for _ in 0..3 { + ok_store(h.advance(step).await); + fail(h, &address, 1).await; + } + assert_eq!( + ok( + h.directory().authenticate(&address, PASSWORD).await, + "authenticate inside the window", + ), + Authentication::Locked, + ); + ok_store(h.advance(h.lockout_window()).await); + assert_eq!( + ok( + h.directory().authenticate(&address, PASSWORD).await, + "authenticate past the window", + ), + Authentication::Granted(id), + "hammering a locked account must not move the deadline", + ); +} + +/// Failures spread wider than the window never accumulate. +/// +/// Ten mistypes over a year is not a guessing run, and counting them as one would lock an +/// account on a tenth attempt made months after the ninth. +pub async fn failures_spread_wider_than_the_window_never_accumulate(h: &dyn Harness) { + let (address, id) = seed(h, "spread").await; + for _ in 0..h.lockout_attempts() * 2 { + fail(h, &address, 1).await; + ok_store( + h.advance(h.lockout_window() + SignedDuration::from_secs(1)) + .await, + ); + } + assert_eq!( + ok( + h.directory().authenticate(&address, PASSWORD).await, + "authenticate", + ), + Authentication::Granted(id), + ); +} + +// =========================================================================================== +// Password change +// =========================================================================================== + +/// A password change replaces the credential, in both directions. +pub async fn a_password_change_grants_the_new_password_and_refuses_the_old(h: &dyn Harness) { + let (address, id) = seed(h, "rotate").await; + assert_eq!( + ok( + h.passwords() + .set_password(&id, "a brand new password", Timestamp::UNIX_EPOCH) + .await, + "replace a password", + ), + PasswordChanged::Yes, + ); + assert_eq!( + ok( + h.directory() + .authenticate(&address, "a brand new password") + .await, + "authenticate with the new password", + ), + Authentication::Granted(id), + ); + assert_eq!( + ok( + h.directory().authenticate(&address, PASSWORD).await, + "authenticate with the old password", + ), + Authentication::Refused, + ); +} + +/// A password change clears a lockout. +/// +/// The port requires it: a change is a successful credential presentation, and leaving the +/// failure count behind would bar somebody from an account they just proved they own. +pub async fn a_password_change_clears_a_lockout(h: &dyn Harness) { + let (address, id) = seed(h, "unlock").await; + fail(h, &address, h.lockout_attempts()).await; + assert_eq!( + ok( + h.passwords() + .set_password(&id, "a brand new password", Timestamp::UNIX_EPOCH) + .await, + "replace a password", + ), + PasswordChanged::Yes, + ); + assert_eq!( + ok( + h.directory() + .authenticate(&address, "a brand new password") + .await, + "authenticate after the change", + ), + Authentication::Granted(id), + ); +} + +/// Changing the password of an account that does not exist writes nothing. +pub async fn changing_a_password_for_an_absent_account_writes_nothing(h: &dyn Harness) { + let stranger = user("absent", 7); + assert_eq!( + ok( + h.passwords() + .set_password(&stranger, "irrelevant", Timestamp::UNIX_EPOCH) + .await, + "replace a stranger's password", + ), + PasswordChanged::NoSuchAccount, + ); + assert!( + ok(h.profiles().read(&stranger).await, "read a profile").is_none(), + "a password change must not create the account it could not find" + ); +} + +// =========================================================================================== +// Profiles +// =========================================================================================== + +/// A profile reads back what registration wrote, and takes an edit. +pub async fn a_profile_reads_back_what_registration_wrote_and_takes_an_edit(h: &dyn Harness) { + let (address, id) = seed(h, "profile").await; + let profile: ProfileRecord = present( + ok(h.profiles().read(&id).await, "read a profile"), + "the account's profile", + ); + assert_eq!(profile.user_id, id); + assert_eq!(profile.email, address); + assert_eq!(profile.display_name, None); + assert_eq!(profile.created_at, Timestamp::UNIX_EPOCH); + + let updated = present( + ok( + h.profiles() + .update( + &id, + &ProfileUpdate { + display_name: Some(Some("Ada Lovelace".to_owned())), + }, + ) + .await, + "update a profile", + ), + "the updated profile", + ); + assert_eq!(updated.display_name.as_deref(), Some("Ada Lovelace")); +} + +/// An absent field leaves the name alone; `Some(None)` clears it. +/// +/// The whole reason the field is a nested option: a flat one cannot express "leave it alone", so +/// every partial update would clear the name nobody mentioned. +pub async fn an_absent_field_and_a_cleared_one_are_different_updates(h: &dyn Harness) { + let (_, id) = seed(h, "nested").await; + let named = present( + ok( + h.profiles() + .update( + &id, + &ProfileUpdate { + display_name: Some(Some("Grace Hopper".to_owned())), + }, + ) + .await, + "set a display name", + ), + "the updated profile", + ); + assert_eq!(named.display_name.as_deref(), Some("Grace Hopper")); + + let untouched = present( + ok( + h.profiles().update(&id, &ProfileUpdate::default()).await, + "apply an empty update", + ), + "the profile", + ); + assert_eq!(untouched.display_name.as_deref(), Some("Grace Hopper")); + + let cleared = present( + ok( + h.profiles() + .update( + &id, + &ProfileUpdate { + display_name: Some(None), + }, + ) + .await, + "clear a display name", + ), + "the profile", + ); + assert_eq!(cleared.display_name, None); +} + +/// A profile for an account that does not exist is absent, not an error. +/// +/// Reachable with a perfectly valid credential: a session outlives the account row it names if +/// the account is deleted while a token is live. The route answers `404` rather than `500`, +/// because the server is working correctly and the account is gone. +pub async fn a_profile_for_an_absent_account_is_absent_and_not_an_error(h: &dyn Harness) { + let stranger = user("ghost", 5); + assert!( + ok( + h.profiles().read(&stranger).await, + "read a stranger's profile" + ) + .is_none(), + ); + assert!( + ok( + h.profiles() + .update(&stranger, &ProfileUpdate::default()) + .await, + "update a stranger's profile", + ) + .is_none(), + ); +} + +/// Unwrap the harness's own seam, which speaks `StoreError` rather than `DirectoryError`. +#[track_caller] +fn ok_store(result: Result<(), crate::store::StoreError>) { + if let Err(error) = result { + panic!("a conforming harness must be able to move its own clock: {error}"); + } +} + +// =========================================================================================== +// The whole suite +// =========================================================================================== + +/// Run every case above against one harness, in order. +pub async fn run_all(h: &dyn Harness) { + registering_then_signing_in_works(h).await; + a_taken_address_is_reported_and_nothing_is_written(h).await; + an_unknown_address_and_a_wrong_password_are_one_answer(h).await; + addresses_are_compared_verbatim(h).await; + re_authentication_takes_the_account_from_the_credential(h).await; + + enough_failures_lock_the_account_and_a_correct_password_is_told_so(h).await; + a_success_before_the_ceiling_clears_the_count(h).await; + a_lockout_decays_because_nothing_else_can_clear_it(h).await; + attempts_during_a_lockout_do_not_extend_it(h).await; + failures_spread_wider_than_the_window_never_accumulate(h).await; + + a_password_change_grants_the_new_password_and_refuses_the_old(h).await; + a_password_change_clears_a_lockout(h).await; + changing_a_password_for_an_absent_account_writes_nothing(h).await; + + a_profile_reads_back_what_registration_wrote_and_takes_an_edit(h).await; + an_absent_field_and_a_cleared_one_are_different_updates(h).await; + a_profile_for_an_absent_account_is_absent_and_not_an_error(h).await; +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use jiff::SignedDuration; + + use super::{Harness, run_all}; + use crate::auth::accounts_memory::{InMemoryAccounts, MAX_FAILED_ATTEMPTS}; + use crate::auth::credential::Credentials; + use crate::auth::directory::AccountDirectory; + use crate::auth::profile::{AccountProfiles, PasswordChange}; + use crate::auth::registry::AccountRegistry; + use crate::store::StoreFuture; + use crate::store::memory::ManualClock; + + /// A fifteen-minute lockout window, as a deployment gets by default. + const WINDOW: SignedDuration = SignedDuration::from_mins(15); + + /// The deterministic double, on a clock the suite drives. + #[derive(Debug)] + struct MemoryHarness { + accounts: InMemoryAccounts, + clock: Arc, + } + + impl Harness for MemoryHarness { + fn registry(&self) -> &dyn AccountRegistry { + &self.accounts + } + + fn directory(&self) -> &dyn AccountDirectory { + &self.accounts + } + + fn profiles(&self) -> &dyn AccountProfiles { + &self.accounts + } + + fn passwords(&self) -> &dyn PasswordChange { + &self.accounts + } + + fn lockout_attempts(&self) -> u32 { + MAX_FAILED_ATTEMPTS + } + + fn lockout_window(&self) -> SignedDuration { + WINDOW + } + + fn advance(&self, by: SignedDuration) -> StoreFuture<'_, ()> { + Box::pin(async move { + self.clock.advance(by); + Ok(()) + }) + } + } + + fn harness() -> MemoryHarness { + let clock = Arc::new(ManualClock::default()); + MemoryHarness { + accounts: InMemoryAccounts::new( + Credentials::new().expect("the platform hashes"), + clock.clone(), + MAX_FAILED_ATTEMPTS, + WINDOW, + ), + clock, + } + } + + /// The development profile's adapter is only a legitimate stand-in for Postgres to the + /// extent it passes this. + /// + /// One test rather than one per case, unlike [`crate::store::conformance`]'s: every case here + /// registers an account, which costs an Argon2id hash at the server-side parameter set, and + /// sixteen fresh harnesses would pay for sixteen decoys on top. The suite shares one harness + /// and every case scopes its own addresses, which is what makes that safe. + #[tokio::test] + async fn the_in_memory_account_store_conforms() { + run_all(&harness()).await; + } +} diff --git a/capsule-server/src/auth/mod.rs b/capsule-server/src/auth/mod.rs index 564b8010..9b051971 100644 --- a/capsule-server/src/auth/mod.rs +++ b/capsule-server/src/auth/mod.rs @@ -47,6 +47,8 @@ //! records: it is a pure function of a key and a clock. pub mod accounts_memory; +pub mod accounts_postgres; +pub mod conformance; pub mod credential; pub mod directory; pub mod profile; @@ -58,6 +60,7 @@ pub mod totp; use std::sync::Arc; pub use self::accounts_memory::InMemoryAccounts; +pub use self::accounts_postgres::PostgresAccounts; pub use self::credential::{CredentialError, Credentials}; pub use self::directory::{AccountDirectory, Authentication, DirectoryError, DirectoryFuture}; pub use self::profile::{ diff --git a/capsule-server/src/index/postgres.rs b/capsule-server/src/index/postgres.rs index 284a3f4f..b7473a58 100644 --- a/capsule-server/src/index/postgres.rs +++ b/capsule-server/src/index/postgres.rs @@ -18,7 +18,7 @@ //! # Why the mutations are written in Rust rather than in SQL //! //! Each write hydrates the whole [`AssetRow`] under the lock, applies the same free functions -//! the in-memory adapter applies ([`is_singular`], [`set_singular`], +//! the in-memory adapter applies (`is_singular`, `set_singular`, //! [`AssetRow::is_publishable`]), and writes the row back before committing. The alternative — //! expressing the state machine as a chain of `UPDATE … WHERE` statements — would be a *second* //! statement of rules the port already fixes, in a language where the conformance suite cannot From 26825e9ea2c5658f87f12109532a9039bd425d4d Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 02:23:30 -0400 Subject: [PATCH 093/243] feat(server): add the Postgres quota ledger and the quota suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ledger's cases lived in `quota/tests.rs` against `InMemoryQuota` only, which made the double an unproven stand-in for exactly the adapter that has to get concurrency right. They move to `quota/conformance.rs` and run against both. The pure half stays where it was: `state_of` and `admits` take no store, and a suite generic over an adapter cannot say anything about a function that takes none. Three cases are new rather than promoted. `an_uncharged_account_owes_nothing` covers the read path an adapter with a stored total would get wrong first. `the_already_attributed_answer_says_nothing_about_who_holds_it` asserts the disclosure property structurally — telling a caller "somebody else holds these bytes" would answer, from a quota endpoint, the cross-tenant question `AssetIndex::find_by_address` is owner-scoped to avoid. `a_collector_release_clears_the_over_limit_clock` was in `tests.rs` and is kept because both releases have to credit identically. In the adapter, `used` is `SUM(size)` over the attribution rows and never a stored column, for the reason `reference_count` is a query: a stored total is a second copy of a derivable fact, and one that drifts low hands somebody free storage. What cannot be derived is *when* an account crossed the hard limit and has not been under it since, so that single instant is the whole of `quota_usage`. `charge` is `INSERT … ON CONFLICT (address) DO NOTHING` and the row count is the answer, so two concurrent sessions for one address cannot both read "unattributed" and both debit — neither reads. The transaction around it is for the second half: a debit that crosses the limit has to re-total and stamp `over_since`, and a crash between the two would leave an account over its limit with no crossing recorded. `state_of` models that state deliberately, treating it as newly over rather than expired so a missing timestamp cannot lock somebody out of the writes that free space — but leaving it reachable when one statement away is a choice, not a fallback. `WHERE over_since IS NULL` is what stamps the crossing once, so a later charge while still over does not restart the grace window. Both releases clear the clock unconditionally, exactly as the in-memory ledger's one `credit` helper does: an account still over after a release gets a fresh window rather than inheriting a running one, and two copies of that rule would eventually disagree. Refs #402 --- capsule-server/src/quota/conformance.rs | 413 ++++++++++++++++++++++++ capsule-server/src/quota/mod.rs | 5 + capsule-server/src/quota/postgres.rs | 329 +++++++++++++++++++ capsule-server/src/quota/tests.rs | 199 +----------- 4 files changed, 758 insertions(+), 188 deletions(-) create mode 100644 capsule-server/src/quota/conformance.rs create mode 100644 capsule-server/src/quota/postgres.rs diff --git a/capsule-server/src/quota/conformance.rs b/capsule-server/src/quota/conformance.rs new file mode 100644 index 00000000..2f3ea2af --- /dev/null +++ b/capsule-server/src/quota/conformance.rs @@ -0,0 +1,413 @@ +//! The one suite every [`QuotaStore`] adapter must pass. +//! +//! # What is here and what stayed in `tests.rs` +//! +//! Most of the quota module is a **pure** state machine — [`state_of`](super::state_of) and +//! [`admits`](super::admits) — and those cases read as a table with nothing to mock. They stay +//! in `quota/tests.rs`, because a suite generic over a store cannot say anything about a +//! function that takes no store. +//! +//! What is here is everything the *ledger* owes: the dedup rule, the two releases, and the +//! over-limit clock. Every one of those was written against `InMemoryQuota` only, which made +//! the double an unproven stand-in for exactly the adapter that has to get concurrency right. +//! +//! # The rule the suite exists to protect +//! +//! Attribution is keyed on the **content address, globally**, and a blob shared between two +//! uploaders counts against only the first. That is not a courtesy: without it a malicious user +//! could exhaust another account's quota by re-uploading blobs whose addresses they already +//! know. [`charging_the_same_address_twice_debits_once`] is that rule, and it is the case an +//! adapter that reached for "check, then debit" would fail. +//! +//! # Reusing a harness +//! +//! Every case scopes its own users and addresses, so cases may share one ledger and [`run_all`] +//! does. + +use jiff::{SignedDuration, Timestamp}; + +use super::{ChargeOutcome, QuotaLimits, QuotaStore}; +use crate::blob::ContentAddress; +use crate::store::{StoreError, UserId}; + +/// The ledger under test. +/// +/// One accessor and no time seam: nothing in this port expires, and the one instant it stores — +/// the moment an account crossed its hard limit — is an **argument** to +/// [`QuotaStore::charge`] rather than something read from a clock. That is deliberate in the +/// port and it is why this harness is one method: an adapter cannot get the crossing's timestamp +/// wrong by reading the wrong clock, because it never reads one. +pub trait Harness: Send + Sync { + /// The ledger under test. + fn quotas(&self) -> &dyn QuotaStore; +} + +/// Unwrap a ledger result, failing with the operation that was expected to work. +#[track_caller] +fn ok(result: Result, doing: &str) -> T { + match result { + Ok(value) => value, + Err(error) => panic!("a conforming ledger must succeed at {doing}: {error}"), + } +} + +/// A deployment with real limits: 100 bytes soft, 200 hard, a 14-day grace. +fn limits() -> QuotaLimits { + QuotaLimits::new(100, 200, super::DEFAULT_GRACE_WINDOW) +} + +/// An instant `days` after the epoch. +fn day(days: i64) -> Timestamp { + Timestamp::UNIX_EPOCH + SignedDuration::from_hours(days * 24) +} + +/// `case`'s own account `n`. +fn user(case: &str, n: u8) -> UserId { + UserId::new(format!("{case}-user-{n}")) +} + +/// A deterministic content address for `case`'s blob `n`. +/// +/// Hashed rather than formatted because a content address is 64 lowercase-hex characters and +/// `ContentAddress::parse` is the only gate that says so. +fn address(case: &str, n: u8) -> ContentAddress { + let mut seed = case.as_bytes().to_vec(); + seed.push(n); + ContentAddress::parse(&capsule_core::crypto::hash::hash_bytes(&seed).to_hex()) + .expect("a digest is a content address") +} + +// =========================================================================================== +// Attribution +// =========================================================================================== + +/// A blob shared between two uploaders is charged to the first only. +/// +/// The rule that stops a malicious user exhausting another account's quota by re-uploading blobs +/// whose addresses they already know. The second uploader is a merge, not a second copy. +pub async fn charging_the_same_address_twice_debits_once(h: &dyn Harness) { + let first = user("shared", 1); + let second = user("shared", 2); + let shared = address("shared", 1); + + assert_eq!( + ok( + h.quotas() + .charge(&first, &shared, 64, day(0), limits()) + .await, + "charge an address", + ), + ChargeOutcome::Charged { used: 64 }, + ); + assert_eq!( + ok( + h.quotas() + .charge(&second, &shared, 64, day(0), limits()) + .await, + "charge an address a second account already holds", + ), + ChargeOutcome::AlreadyAttributed, + ); + assert_eq!( + ok(h.quotas().usage(&second).await, "read usage").used, + 0, + "the second uploader is a merge, not a second copy" + ); + assert_eq!( + ok(h.quotas().usage(&first).await, "read usage").used, + 64, + "and the first is still charged exactly once" + ); +} + +/// `AlreadyAttributed` is one value whoever holds the address. +/// +/// Telling a caller "somebody else already holds these bytes" would answer, from a quota +/// endpoint, the cross-tenant question `AssetIndex::find_by_address` is owner-scoped to avoid. +/// Structural — the variant has no payload — and asserted here so a richer one cannot be added +/// without a case going red. +pub async fn the_already_attributed_answer_says_nothing_about_who_holds_it(h: &dyn Harness) { + let mine = user("silent", 1); + let stranger = user("silent", 2); + let held = address("silent", 1); + ok( + h.quotas().charge(&mine, &held, 32, day(0), limits()).await, + "charge an address", + ); + + let outcome = ok( + h.quotas() + .charge(&stranger, &held, 32, day(0), limits()) + .await, + "charge an address another account holds", + ); + let rendered = format!("{outcome:?}"); + assert!( + !rendered.contains(mine.as_str()), + "the refusal named the account that holds the address: {rendered}" + ); +} + +/// Usage is per account, and an account that has been charged nothing owes nothing. +pub async fn an_uncharged_account_owes_nothing(h: &dyn Harness) { + let stranger = user("fresh", 1); + let usage = ok(h.quotas().usage(&stranger).await, "read usage"); + assert_eq!(usage.used, 0); + assert_eq!(usage.over_since, None); +} + +// =========================================================================================== +// Releases +// =========================================================================================== + +/// Releasing a reservation credits the bytes back, once, and frees the address. +pub async fn releasing_a_reservation_credits_the_bytes_back(h: &dyn Harness) { + let owner = user("release", 1); + let held = address("release", 1); + ok( + h.quotas().charge(&owner, &held, 64, day(0), limits()).await, + "charge an address", + ); + + assert!(ok(h.quotas().release(&owner, &held).await, "release")); + assert_eq!(ok(h.quotas().usage(&owner).await, "read usage").used, 0); + assert!( + !ok(h.quotas().release(&owner, &held).await, "release again"), + "a second release must not credit the bytes twice" + ); + + // And the address is free again, so a later uploader is charged for it. + assert_eq!( + ok( + h.quotas().charge(&owner, &held, 64, day(0), limits()).await, + "charge a released address", + ), + ChargeOutcome::Charged { used: 64 }, + ); +} + +/// Another account's reservation is not releasable. +/// +/// Releasing somebody else's attribution would let one account free bytes off another's ledger — +/// and, worse, tell them the address was attributed. +pub async fn another_accounts_reservation_is_not_releasable(h: &dyn Harness) { + let owner = user("steal", 1); + let other = user("steal", 2); + let held = address("steal", 1); + ok( + h.quotas().charge(&owner, &held, 64, day(0), limits()).await, + "charge an address", + ); + + assert!(!ok( + h.quotas().release(&other, &held).await, + "release another account's attribution", + )); + assert_eq!(ok(h.quotas().usage(&owner).await, "read usage").used, 64); +} + +/// The collector releases by address, and the ledger names the account (`S-C44`). +/// +/// A sweep knows an address and nothing else — attribution is global by content address, so the +/// blob it is deleting may be charged to an account with no remaining connection to the asset +/// whose purge exposed it. The collector cannot supply the user and must not guess one. +pub async fn the_collector_releases_by_address_and_the_ledger_names_the_account(h: &dyn Harness) { + let owner = user("sweep", 1); + let swept = address("sweep", 1); + ok( + h.quotas() + .charge( + &owner, + &swept, + 1_024, + Timestamp::UNIX_EPOCH, + QuotaLimits::unlimited(), + ) + .await, + "charge an address", + ); + + let released = ok( + h.quotas().release_attribution(&swept).await, + "release by address", + ); + assert_eq!(released, Some((owner.clone(), 1_024))); + assert_eq!(ok(h.quotas().usage(&owner).await, "read usage").used, 0); +} + +/// Releasing an address the ledger never saw is `None` rather than an error. +/// +/// The ordinary case for a blob the ledger never saw. A sweep that treated it as a failure would +/// stall on the first one. +pub async fn releasing_an_unattributed_address_is_none_rather_than_an_error(h: &dyn Harness) { + assert_eq!( + ok( + h.quotas().release_attribution(&address("unseen", 1)).await, + "release an unattributed address", + ), + None, + ); +} + +// =========================================================================================== +// The over-limit clock +// =========================================================================================== + +/// The crossing is stamped once and cleared by going under. +/// +/// `over_since` is kept by the store rather than derived, because "how long have you been over" +/// cannot be computed from a current total. A later charge while still over must not restamp it, +/// or the grace window would never expire for an account that keeps trying to upload. +pub async fn the_crossing_is_stamped_once_and_cleared_by_going_under(h: &dyn Harness) { + let owner = user("clock", 1); + ok( + h.quotas() + .charge(&owner, &address("clock", 1), 150, day(0), limits()) + .await, + "charge an address", + ); + assert_eq!( + ok(h.quotas().usage(&owner).await, "read usage").over_since, + None, + "150 is over the soft limit and under the hard one" + ); + + ok( + h.quotas() + .charge(&owner, &address("clock", 2), 100, day(1), limits()) + .await, + "charge an address", + ); + let over = ok(h.quotas().usage(&owner).await, "read usage"); + assert_eq!(over.used, 250); + assert_eq!(over.over_since, Some(day(1))); + + ok( + h.quotas() + .charge(&owner, &address("clock", 3), 10, day(9), limits()) + .await, + "charge an address while over", + ); + assert_eq!( + ok(h.quotas().usage(&owner).await, "read usage").over_since, + Some(day(1)), + "a later charge while still over must not restamp the clock" + ); + + // Going back under stops the clock, so a later crossing gets a fresh window rather than + // inheriting an expired one. + ok( + h.quotas().release(&owner, &address("clock", 2)).await, + "release", + ); + assert_eq!( + ok(h.quotas().usage(&owner).await, "read usage").over_since, + None, + ); +} + +/// A collector release clears the over-limit clock exactly like a user release. +/// +/// Both releases credit the same way, which is why the in-memory adapter shares one helper: a +/// second copy would eventually forget `over_since`, leaving an account back under its limit +/// still carrying the clock that decides when a soft limit becomes a hard one. +pub async fn a_collector_release_clears_the_over_limit_clock(h: &dyn Harness) { + let owner = user("sweepclock", 1); + let held = address("sweepclock", 1); + let tight = QuotaLimits::new(1_000, 1_500, SignedDuration::from_hours(24)); + ok( + h.quotas() + .charge(&owner, &held, 2_000, Timestamp::UNIX_EPOCH, tight) + .await, + "charge an address", + ); + assert!( + ok(h.quotas().usage(&owner).await, "read usage") + .over_since + .is_some() + ); + + ok( + h.quotas().release_attribution(&held).await, + "release by address", + ); + assert_eq!( + ok(h.quotas().usage(&owner).await, "read usage").over_since, + None, + "back under the limit, so a later crossing gets a fresh window" + ); +} + +// =========================================================================================== +// The whole suite +// =========================================================================================== + +/// Run every case above against one harness, in order. +pub async fn run_all(h: &dyn Harness) { + charging_the_same_address_twice_debits_once(h).await; + the_already_attributed_answer_says_nothing_about_who_holds_it(h).await; + an_uncharged_account_owes_nothing(h).await; + + releasing_a_reservation_credits_the_bytes_back(h).await; + another_accounts_reservation_is_not_releasable(h).await; + the_collector_releases_by_address_and_the_ledger_names_the_account(h).await; + releasing_an_unattributed_address_is_none_rather_than_an_error(h).await; + + the_crossing_is_stamped_once_and_cleared_by_going_under(h).await; + a_collector_release_clears_the_over_limit_clock(h).await; +} + +#[cfg(test)] +mod tests { + use super::{Harness, run_all}; + use crate::quota::{InMemoryQuota, QuotaStore}; + + /// The deterministic ledger. + #[derive(Debug, Default)] + struct MemoryHarness { + quotas: InMemoryQuota, + } + + impl Harness for MemoryHarness { + fn quotas(&self) -> &dyn QuotaStore { + &self.quotas + } + } + + /// Declares one `#[tokio::test]` per conformance case. + /// + /// One test each, on a fresh ledger each: a failure names the property that broke, and no + /// case can pass because a previous one left the ledger in a convenient state. + macro_rules! conformance_cases { + ($($case:ident),+ $(,)?) => { + $( + #[tokio::test] + async fn $case() { + super::$case(&MemoryHarness::default()).await; + } + )+ + }; + } + + conformance_cases! { + charging_the_same_address_twice_debits_once, + the_already_attributed_answer_says_nothing_about_who_holds_it, + an_uncharged_account_owes_nothing, + releasing_a_reservation_credits_the_bytes_back, + another_accounts_reservation_is_not_releasable, + the_collector_releases_by_address_and_the_ledger_names_the_account, + releasing_an_unattributed_address_is_none_rather_than_an_error, + the_crossing_is_stamped_once_and_cleared_by_going_under, + a_collector_release_clears_the_over_limit_clock, + } + + /// The whole suite, in one pass on one ledger. + /// + /// The entry point a container-backed adapter uses, so it is exercised here too — otherwise + /// the first time anyone ran it would be against Postgres, where a failure is hardest to + /// read. It also proves the cases really are independent. + #[tokio::test] + async fn the_in_memory_ledger_conforms() { + run_all(&MemoryHarness::default()).await; + } +} diff --git a/capsule-server/src/quota/mod.rs b/capsule-server/src/quota/mod.rs index 1b2110fd..fff21a2a 100644 --- a/capsule-server/src/quota/mod.rs +++ b/capsule-server/src/quota/mod.rs @@ -491,5 +491,10 @@ pub async fn current_state( )) } +pub mod conformance; +pub mod postgres; + +pub use self::postgres::PostgresQuota; + #[cfg(test)] mod tests; diff --git a/capsule-server/src/quota/postgres.rs b/capsule-server/src/quota/postgres.rs new file mode 100644 index 00000000..6603cf42 --- /dev/null +++ b/capsule-server/src/quota/postgres.rs @@ -0,0 +1,329 @@ +//! [`PostgresQuota`] — the durable quota ledger (`S-C6`, #402). +//! +//! # The total is derived, and the clock is not +//! +//! `quota_attributions` holds one row per charged content address. A user's `used` is +//! `SUM(size)` over their rows and is **never** a stored column, for the reason +//! [`crate::index::AssetIndex::reference_count`] is a query: a stored total is a second copy of +//! a derivable fact, and one that drifts low hands somebody free storage. +//! +//! What cannot be derived is *when* an account crossed the hard limit and has not been under it +//! since — a current total says nothing about how long it has been that total. That single +//! instant is the whole of `quota_usage`. +//! +//! # Why `charge` is a transaction and not one statement +//! +//! The port's requirement is that the check and the debit are atomic against two concurrent +//! sessions for one address, and the `ON CONFLICT (address) DO NOTHING` insert is exactly that on +//! its own: the primary key decides, and the row count is the answer. What needs the transaction +//! is the *second* half — after a successful debit the adapter has to re-total the account and +//! stamp `over_since` if that debit crossed the limit, and a crash between the two would leave an +//! account over its limit with no crossing recorded. The port models that state +//! (`state_of` treats "over with no recorded crossing" as newly over rather than expired, +//! deliberately, so the missing timestamp cannot lock somebody out of the writes that free +//! space) — but leaving it reachable when one statement away is a choice, not a fallback. + +use jiff::Timestamp; +use sea_orm::{ + ConnectionTrait, DatabaseConnection, DatabaseTransaction, DbBackend, Statement, + TransactionTrait, Value, +}; + +use super::{ChargeOutcome, QuotaLimits, QuotaStore, StoredUsage}; +use crate::blob::ContentAddress; +use crate::postgres::error::Port; +use crate::postgres::time::{from_micros, to_micros}; +use crate::store::{StoreError, StoreFuture, UserId}; + +/// Which port is speaking, for every error this adapter raises. +const PORT: Port = Port { + store: "quota", + record: "StoredUsage", +}; + +/// The durable quota ledger. +#[derive(Debug, Clone)] +pub struct PostgresQuota { + connection: DatabaseConnection, +} + +impl PostgresQuota { + /// A ledger over `connection`. + pub fn new(connection: DatabaseConnection) -> Self { + Self { connection } + } +} + +/// A byte count as the column holds it. +fn size_to_column(size: u64) -> Result { + i64::try_from(size).map_err(|_| StoreError::Rejected { + store: PORT.store, + detail: format!("{size} bytes is past what a BIGINT column holds"), + }) +} + +/// A byte count as the port speaks it. +fn size_from(value: i64) -> Result { + u64::try_from(value).map_err(|_| PORT.undecodable(format!("{value} is not a byte count"))) +} + +/// The account's total and crossing, read through `connection`. +/// +/// One statement with two scalar subqueries rather than two round trips, so the total and the +/// clock come from one snapshot: read separately, a concurrent release between them could report +/// a total that is already under the limit beside a crossing that has already been cleared. +async fn usage_of( + connection: &C, + user: &UserId, +) -> Result { + let read = connection + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT \ + (SELECT COALESCE(SUM(size), 0)::bigint FROM quota_attributions WHERE user_id = $1) \ + AS used, \ + (SELECT over_since FROM quota_usage WHERE user_id = $1) AS over_since", + [Value::from(user.as_str().to_owned())], + )) + .await + .map_err(PORT.failing("reading an account's usage"))? + .ok_or_else(|| StoreError::Rejected { + store: PORT.store, + detail: "the usage query returned no row".to_owned(), + })?; + let failed = PORT.failing("reading an account's usage"); + let used: i64 = read.try_get("", "used").map_err(&failed)?; + let over_since: Option = read.try_get("", "over_since").map_err(&failed)?; + Ok(StoredUsage { + used: size_from(used)?, + over_since: over_since + .map(|micros| { + from_micros(micros).ok_or_else(|| { + PORT.undecodable(format!("{micros}µs is not a representable instant")) + }) + }) + .transpose()?, + }) +} + +/// Give an account's bytes back, and stop its over-limit clock. +/// +/// The clock is cleared **unconditionally**, exactly as the in-memory ledger's `credit` does, and +/// that is the contract rather than an approximation: an account that is still over after a +/// release gets a *fresh* window rather than inheriting a running one. Two copies of this rule +/// would eventually disagree, which is why the in-memory adapter has one helper and this has one +/// statement. +async fn stop_the_clock( + transaction: &DatabaseTransaction, + user: &UserId, +) -> Result<(), StoreError> { + transaction + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "UPDATE quota_usage SET over_since = NULL WHERE user_id = $1", + [Value::from(user.as_str().to_owned())], + )) + .await + .map_err(PORT.failing("clearing an account's over-limit clock"))?; + Ok(()) +} + +/// Begin a transaction, or say why not. +async fn begin(connection: &DatabaseConnection) -> Result { + connection + .begin() + .await + .map_err(PORT.failing("opening a transaction")) +} + +/// Commit, or say why not. +async fn commit(transaction: DatabaseTransaction) -> Result<(), StoreError> { + transaction + .commit() + .await + .map_err(PORT.failing("committing a transaction")) +} + +impl QuotaStore for PostgresQuota { + fn usage<'a>(&'a self, user: &'a UserId) -> StoreFuture<'a, StoredUsage> { + Box::pin(async move { usage_of(&self.connection, user).await }) + } + + fn charge<'a>( + &'a self, + user: &'a UserId, + address: &'a ContentAddress, + size: u64, + at: Timestamp, + limits: QuotaLimits, + ) -> StoreFuture<'a, ChargeOutcome> { + Box::pin(async move { + let size = size_to_column(size)?; + let transaction = begin(&self.connection).await?; + + // The primary key decides, and the row count is the answer. Two concurrent sessions + // for one address cannot both read "unattributed" and both debit, because neither + // reads: they both insert, and one of them affects no row. + let debited = transaction + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "INSERT INTO quota_attributions (address, user_id, size, charged_at) \ + VALUES ($1, $2, $3, $4) ON CONFLICT (address) DO NOTHING", + [ + Value::from(address.as_str().to_owned()), + Value::from(user.as_str().to_owned()), + Value::from(size), + Value::from(to_micros(at)), + ], + )) + .await + .map_err(PORT.failing("charging an address"))?; + if debited.rows_affected() == 0 { + // Already attributed — to this account or to another, and the port answers one + // value for both so a quota endpoint cannot become a cross-tenant oracle. + return Ok(ChargeOutcome::AlreadyAttributed); + } + + // The account's row in `quota_usage` exists from its first charge onwards, and holds + // nothing but the clock. + transaction + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "INSERT INTO quota_usage (user_id, over_since) VALUES ($1, NULL) \ + ON CONFLICT (user_id) DO NOTHING", + [Value::from(user.as_str().to_owned())], + )) + .await + .map_err(PORT.failing("opening an account's usage row"))?; + + let used = usage_of(&transaction, user).await?.used; + if used >= limits.hard_limit { + // `WHERE over_since IS NULL` is what stamps the crossing **once**: a later charge + // while still over must not restamp it, or the grace window would never expire + // for an account that keeps trying to upload. + let stamped = transaction + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "UPDATE quota_usage SET over_since = $2 \ + WHERE user_id = $1 AND over_since IS NULL", + [ + Value::from(user.as_str().to_owned()), + Value::from(to_micros(at)), + ], + )) + .await + .map_err(PORT.failing("stamping an account's hard-limit crossing"))?; + if stamped.rows_affected() == 1 { + tracing::info!(%user, used, "an account crossed its hard quota limit"); + } + } + + commit(transaction).await?; + Ok(ChargeOutcome::Charged { used }) + }) + } + + fn release<'a>( + &'a self, + user: &'a UserId, + address: &'a ContentAddress, + ) -> StoreFuture<'a, bool> { + Box::pin(async move { + let transaction = begin(&self.connection).await?; + // Scoped to the account in the `DELETE` itself. Releasing somebody else's + // attribution would let one account free bytes off another's ledger — and a + // read-then-check would answer whether the address was attributed at all, which is + // the disclosure the port refuses. + let released = transaction + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "DELETE FROM quota_attributions WHERE address = $1 AND user_id = $2", + [ + Value::from(address.as_str().to_owned()), + Value::from(user.as_str().to_owned()), + ], + )) + .await + .map_err(PORT.failing("releasing an attribution"))?; + if released.rows_affected() == 0 { + return Ok(false); + } + stop_the_clock(&transaction, user).await?; + commit(transaction).await?; + Ok(true) + }) + } + + fn release_attribution<'a>( + &'a self, + address: &'a ContentAddress, + ) -> StoreFuture<'a, Option<(UserId, u64)>> { + Box::pin(async move { + let transaction = begin(&self.connection).await?; + // The collector's release (`S-C44`): a sweep knows an address and nothing else, so + // the ledger names the account rather than the caller guessing one. `RETURNING` is + // what makes the delete and the answer one operation. + let released = transaction + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "DELETE FROM quota_attributions WHERE address = $1 RETURNING user_id, size", + [Value::from(address.as_str().to_owned())], + )) + .await + .map_err(PORT.failing("releasing an attribution by address"))?; + let Some(released) = released else { + // The ordinary case for a blob the ledger never saw, and not an error: a sweep + // that treated it as one would stall on the first. + return Ok(None); + }; + let failed = PORT.failing("reading a released attribution"); + let owner: String = released.try_get("", "user_id").map_err(&failed)?; + let size: i64 = released.try_get("", "size").map_err(&failed)?; + let owner = UserId::new(owner); + stop_the_clock(&transaction, &owner).await?; + commit(transaction).await?; + let size = size_from(size)?; + tracing::info!( + user = %owner, + %address, + size, + "a swept blob's bytes were credited back to the account they were charged to" + ); + Ok(Some((owner, size))) + }) + } +} + +#[cfg(test)] +mod tests { + /// The suite, against a real Postgres. + mod postgres_conformance { + use super::super::PostgresQuota; + use crate::postgres::testing; + use crate::quota::QuotaStore; + use crate::quota::conformance::{self, Harness}; + + /// A ledger over one container. + #[derive(Debug)] + struct PostgresHarness { + quotas: PostgresQuota, + } + + impl Harness for PostgresHarness { + fn quotas(&self) -> &dyn QuotaStore { + &self.quotas + } + } + + #[tokio::test] + async fn the_postgres_ledger_conforms() { + let Some(database) = testing::start("the Postgres quota ledger").await else { + return; + }; + let harness = PostgresHarness { + quotas: PostgresQuota::new(database.connection().clone()), + }; + conformance::run_all(&harness).await; + } + } +} diff --git a/capsule-server/src/quota/tests.rs b/capsule-server/src/quota/tests.rs index 8e6a04a5..25521b33 100644 --- a/capsule-server/src/quota/tests.rs +++ b/capsule-server/src/quota/tests.rs @@ -1,7 +1,15 @@ -//! The quota port's own suite. +//! The quota module's own suite: the **pure** half. //! -//! Most of it is the state machine, which is pure — so the cases that matter read as a table of -//! "this much used, this long over, this kind of write" and there is nothing to mock. +//! [`state_of`] and [`admits`] take no store, so the cases that matter read as a table of "this +//! much used, this long over, this kind of write" and there is nothing to mock. A suite generic +//! over an adapter cannot say anything about a function that takes none, which is why these +//! stayed here when the ledger's cases moved. +//! +//! Everything the *ledger* owes — the dedup rule, the two releases and the over-limit clock — +//! is in [`super::conformance`] (#402), so it runs against `InMemoryQuota` and against the +//! Postgres adapter from one list. Those cases used to live here against the double only, which +//! made the double an unproven stand-in for exactly the adapter that has to get concurrency +//! right. use super::*; @@ -112,188 +120,3 @@ fn an_unlimited_deployment_never_leaves_ok() { limits )); } - -#[tokio::test] -async fn a_shared_blob_is_charged_to_its_first_uploader_only() { - let quotas = InMemoryQuota::new(); - let first = user(); - let second = UserId::new("01937b7c-0000-7000-8000-000000000002"); - let shared = address(1); - - assert_eq!( - quotas - .charge(&first, &shared, 64, day(0), limits()) - .await - .expect("charge"), - ChargeOutcome::Charged { used: 64 }, - ); - assert_eq!( - quotas - .charge(&second, &shared, 64, day(0), limits()) - .await - .expect("charge"), - ChargeOutcome::AlreadyAttributed, - "without this a malicious user could exhaust another account's quota by re-uploading \ - blobs whose addresses they already know", - ); - assert_eq!( - quotas.usage(&second).await.expect("usage").used, - 0, - "the second uploader is a merge, not a second copy" - ); -} - -#[tokio::test] -async fn releasing_a_reservation_credits_the_bytes_back() { - let quotas = InMemoryQuota::new(); - let user = user(); - let held = address(2); - quotas - .charge(&user, &held, 64, day(0), limits()) - .await - .expect("charge"); - - assert!(quotas.release(&user, &held).await.expect("release")); - assert_eq!(quotas.usage(&user).await.expect("usage").used, 0); - assert!( - !quotas.release(&user, &held).await.expect("release"), - "a second release must not credit the bytes twice" - ); - - // And the address is free again, so a later uploader is charged for it. - assert_eq!( - quotas - .charge(&user, &held, 64, day(0), limits()) - .await - .expect("charge"), - ChargeOutcome::Charged { used: 64 }, - ); -} - -#[tokio::test] -async fn another_users_reservation_is_not_releasable() { - let quotas = InMemoryQuota::new(); - let owner = user(); - let other = UserId::new("01937b7c-0000-7000-8000-000000000002"); - let held = address(3); - quotas - .charge(&owner, &held, 64, day(0), limits()) - .await - .expect("charge"); - - assert!( - !quotas.release(&other, &held).await.expect("release"), - "releasing somebody else's attribution would let one account free bytes off another's \ - ledger — and, worse, tell them the address was attributed" - ); - assert_eq!(quotas.usage(&owner).await.expect("usage").used, 64); -} - -#[tokio::test] -async fn the_crossing_is_stamped_once_and_cleared_by_going_under() { - let quotas = InMemoryQuota::new(); - let user = user(); - quotas - .charge(&user, &address(4), 150, day(0), limits()) - .await - .expect("charge"); - assert_eq!(quotas.usage(&user).await.expect("usage").over_since, None); - - quotas - .charge(&user, &address(5), 100, day(1), limits()) - .await - .expect("charge"); - let over = quotas.usage(&user).await.expect("usage"); - assert_eq!(over.used, 250); - assert_eq!(over.over_since, Some(day(1))); - - // A later charge while still over must not restamp the clock, or the grace window would - // never expire for an account that keeps trying to upload. - quotas - .charge(&user, &address(6), 10, day(9), limits()) - .await - .expect("charge"); - assert_eq!( - quotas.usage(&user).await.expect("usage").over_since, - Some(day(1)), - ); - - // Going back under stops the clock, so a later crossing gets a fresh window rather than - // inheriting an expired one. - quotas.release(&user, &address(5)).await.expect("release"); - assert_eq!(quotas.usage(&user).await.expect("usage").over_since, None); -} - -#[tokio::test] -async fn the_collector_releases_by_address_and_the_ledger_names_the_account() { - // `S-C44`. A sweep knows an address and nothing else — attribution is global by content - // address, so the blob it is deleting may be charged to an account with no remaining - // connection to the asset whose purge exposed it. The collector must not guess a user. - let ledger = InMemoryQuota::new(); - let address = address(41); - ledger - .charge( - &user(), - &address, - 1_024, - Timestamp::UNIX_EPOCH, - QuotaLimits::unlimited(), - ) - .await - .expect("the ledger charges"); - - let released = ledger - .release_attribution(&address) - .await - .expect("the ledger answers") - .expect("the address was attributed"); - assert_eq!(released, (user(), 1_024)); - assert_eq!(ledger.usage(&user()).await.expect("usage").used, 0); -} - -#[tokio::test] -async fn releasing_an_unattributed_address_is_none_rather_than_an_error() { - // The ordinary case for a blob the ledger never saw. A sweep that treated it as a failure - // would stall on the first one. - let ledger = InMemoryQuota::new(); - assert_eq!( - ledger - .release_attribution(&address(42)) - .await - .expect("the ledger answers"), - None - ); -} - -#[tokio::test] -async fn a_collector_release_clears_the_over_limit_clock_like_a_user_release() { - // Both releases credit the same way, which is why they share one helper: a second copy - // would eventually forget `over_since`, leaving an account back under its limit still - // carrying the clock that decides when a soft limit becomes a hard one. - let ledger = InMemoryQuota::new(); - let limits = QuotaLimits::new(1_000, 1_500, SignedDuration::from_hours(24)); - ledger - .charge(&user(), &address(43), 2_000, Timestamp::UNIX_EPOCH, limits) - .await - .expect("the ledger charges"); - assert!( - ledger - .usage(&user()) - .await - .expect("usage") - .over_since - .is_some() - ); - - ledger - .release_attribution(&address(43)) - .await - .expect("the ledger answers") - .expect("attributed"); - - assert_eq!( - ledger.usage(&user()).await.expect("usage").over_since, - None, - "back under the limit, so a later crossing gets a fresh window" - ); -} From 22ce8402b0b2e3774cff60598bc4e2ca8ff5deb2 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 02:25:23 -0400 Subject: [PATCH 094/243] fix(docs): make the reference generator fail where it would otherwise mislead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review repairs on the two reference surfaces, all of the same shape: where the generator's silent answer would be a confident lie, it now stops and names what it cannot render. Fatal, added to the module header's list and enforced in `readOpenApiDocument`: - A request or response carrier offering more than one media type. The renderer shows one body per carrier, so a second was dropped silently and the page claimed an endpoint accepting JSON and CBOR accepted only JSON. - A schema composing with `oneOf`, `allOf`, or `anyOf`. A property table cannot express a union or an intersection, and rendered one as an empty model. - A setext heading in artifact prose. Leaving it undemoted put an h1 in the page body — the exact defect demotion exists to prevent — and published looking fine. ATX in the doc comment is the fix, and now the build says so. None is reachable on the committed document; each is how the first one to appear gets handled instead of shipped. Also: - Table cells are escaped once, over the assembled cell, rather than fragment by fragment. Escaping only the help text left `Values:` and `Default:` raw, so a default of `a|b` opened a column of its own — the defect already fixed for types, reintroduced one layer down. - The schema-appendix walk keys its cycle guard on the shallowest depth a model was reached at, not on having seen it. Keyed on the latter, the answer depended on traversal order: reach a model deep first and its children are cut, and the shallower path that would have expanded them is refused as already-seen. - The `$ref` bound rises to 4, above the committed document's deepest chain of 3. `WireBlobRole` was named on `/reference/api/sync/` and defined nowhere; the whole closure costs one further model across all eleven pages. The page prose now states the rule the code implements. - Schema-level descriptions go through `rewriteLinks` like every other prose site. A model's doc comment cites design documents as freely as a handler's. - `escapeCell` records that artifact prose is trusted Markdown: it comes from reviewed Rust source, so emphasis and links are the author's intent. The two escapes repair characters whose meaning changes inside a table; they are not a sanitizer, and an unclosed `<` outside a code span is the author's bug. - The repo-level test renders into a temp root seeded from the committed artifacts. Generating into the working tree raced the Astro build reading it, since `check-docs` runs `test-docs` and `build-docs` in parallel and `generate` clears its output first. - The two-locale test puts `LC_ALL`/`LANG` back. `gen_cli_surface` prints plain English again. `xtask i18n-guard` gains a `NEVER_SCANNED` carve-out for `capsule-cli/src/bin/`, which is build tooling run from mise and CI and never installed — an audience carve-out, the distinction that module is built on, not a narrowing of the rule for the `capsule` surface. Removing the carve-out catches all three of the binary's lines, so it is doing the work. --- capsule-cli/src/bin/gen_cli_surface.rs | 31 +- capsule-cli/src/cli/mod.rs | 20 + capsule-docs/scripts/gen-reference.mjs | 382 +++++++++++++++----- capsule-docs/scripts/gen-reference.test.mjs | 221 ++++++++++- xtask/src/i18n_guard.rs | 19 + 5 files changed, 553 insertions(+), 120 deletions(-) diff --git a/capsule-cli/src/bin/gen_cli_surface.rs b/capsule-cli/src/bin/gen_cli_surface.rs index 7641ba13..6551eb4b 100644 --- a/capsule-cli/src/bin/gen_cli_surface.rs +++ b/capsule-cli/src/bin/gen_cli_surface.rs @@ -15,17 +15,15 @@ //! `clap::Command` built from compile-time attributes. That is what lets `--check` run in the //! Rust check gate beside `openapi-check-kynos`. //! -//! ## Why this binary prints no prose +//! ## Its output is English on purpose //! -//! `xtask i18n-guard` scans `capsule-cli/src/**` for string literals passed to -//! `print`/`println`/`eprint`/`eprintln`/`eyre`/`bail`, and `locales/i18n-guard-allowlist.txt` -//! says in as many words not to add a CLI line to make new output pass. That rule is right for -//! the `capsule` binary, which renders prose to a user in their own language. This binary is CI -//! tooling: its audience is a developer reading a task's output, and routing a build tool's -//! status line through `locales/` would put a string no user can reach into every translation -//! catalog. So it says what it has to say with a path and an exit code — success writes the -//! path it wrote, `--check` is silent on success as `cargo fmt --check` is, and the stale-file -//! message is built with `format!` and carried by the `Result` that `color_eyre` reports. +//! `xtask i18n-guard` scans `capsule-cli/src/**` because the `capsule` binary renders prose to +//! a user in their own language. This binary does not: it runs from `mise run cli-surface` and +//! from CI, is never installed, and its reader is a developer looking at a task's output. +//! `xtask::i18n_guard::NEVER_SCANNED` carves `capsule-cli/src/bin/` out of that root for +//! exactly that reason, so the lines below are plain English and stay that way — routing a +//! build tool's status line through `locales/` would put a string no user can reach into every +//! translation catalog. //! //! Usage: //! - `gen_cli_surface [FILE]` writes the document (default `capsule-cli/cli-surface.json`). @@ -34,7 +32,7 @@ use std::path::PathBuf; use clap::Parser; -use color_eyre::eyre::{Context, Report, Result}; +use color_eyre::eyre::{Context, Result, bail}; #[derive(Parser)] #[command(author, version, about, long_about = None)] @@ -63,12 +61,13 @@ fn main() -> Result<()> { format!("cannot read committed document at {}", cli.output.display()) })?; if committed != json { - return Err(Report::msg(format!( - "the command-tree document at {} is out of sync with the `capsule` argument \ - surface; run `mise run cli-surface` and commit the result", + bail!( + "command tree at {} is out of sync with the `capsule` argument surface; run \ + `mise run cli-surface` and commit the result", cli.output.display() - ))); + ); } + println!("command tree is up to date: {}", cli.output.display()); } else { if let Some(parent) = cli.output.parent() { std::fs::create_dir_all(parent) @@ -76,7 +75,7 @@ fn main() -> Result<()> { } std::fs::write(&cli.output, &json) .wrap_err_with(|| format!("writing {}", cli.output.display()))?; - println!("{}", cli.output.display()); + println!("Wrote {}", cli.output.display()); } Ok(()) diff --git a/capsule-cli/src/cli/mod.rs b/capsule-cli/src/cli/mod.rs index 6e713e0b..0b5b7612 100644 --- a/capsule-cli/src/cli/mod.rs +++ b/capsule-cli/src/cli/mod.rs @@ -422,6 +422,15 @@ mod tests { /// its own process, which is what makes mutating the environment here safe. #[test] fn the_tree_is_identical_under_two_different_locales() { + // Captured and put back below. `nextest` gives each test its own process, so this + // cannot reach another test — but a leaked `LC_ALL=tr_TR` would still be visible to + // anything else in *this* process, and a test that changes global state and does not + // change it back is one a reader has to prove harmless every time they see it. + let saved: Vec<(&str, Option)> = ["LC_ALL", "LANG"] + .iter() + .map(|name| (*name, std::env::var(name).ok())) + .collect(); + let render = |locale: &str| { // SAFETY: single-threaded test body in a process nextest gives this test alone. unsafe { @@ -433,6 +442,17 @@ mod tests { let english = render("en_US.UTF-8"); let turkish = render("tr_TR.UTF-8"); let japanese = render("ja_JP.UTF-8"); + + for (name, value) in saved { + // SAFETY: as above. + unsafe { + match value { + Some(value) => std::env::set_var(name, value), + None => std::env::remove_var(name), + } + } + } + assert_eq!(english, turkish); assert_eq!(english, japanese); // Guards against the whole comparison passing because every render was empty. diff --git a/capsule-docs/scripts/gen-reference.mjs b/capsule-docs/scripts/gen-reference.mjs index ca9e971c..4e634150 100644 --- a/capsule-docs/scripts/gen-reference.mjs +++ b/capsule-docs/scripts/gen-reference.mjs @@ -25,7 +25,19 @@ * 2. an artifact's `schema` is one this script was not written against — exit rather than * render a half-understood document; * 3. an operation matches no group in `reference-groups.mjs` — exit naming it, so a new - * endpoint family cannot publish unlisted. + * endpoint family cannot publish unlisted; + * 4. a request or response carrier offers **more than one media type** — this renderer + * shows one body per carrier, so a second would be dropped silently and the page would + * claim an endpoint accepts only JSON when it also accepts CBOR; + * 5. a schema composes with **`oneOf`, `allOf`, or `anyOf`** — this renderer flattens a + * schema to a property table, which cannot express a union or an intersection, and + * would render one as an empty or a half-true model; + * 6. artifact prose carries a **setext heading** — see [`demoteHeadings`]. + * + * The last three are unreachable on the committed document today. They are fatal rather + * than deferred because each is a case where the renderer's *silent* answer is a confident + * lie, and a build failure naming the operation is how the first one to appear gets + * handled instead of shipped. * * Usage: `bun capsule-docs/scripts/gen-reference.mjs` from anywhere; `package.json` runs it * before `astro dev` and `astro build`. @@ -72,8 +84,21 @@ const METHODS = [ 'trace', ]; -/** How deep a `$ref` chain is followed before deeper types become anchor links only. */ -const MAX_SCHEMA_DEPTH = 2; +/** + * How deep a `$ref` chain is followed before deeper models are named but not expanded. + * + * Set above the committed document's needs, not at them. The deepest chain any group + * reaches is 3 — `SyncPageResponse` → `SyncEntry` → `SyncBlobRef` → `WireBlobRole`, on + * `/reference/api/sync/` — so at the previous value of 2 that enum was named on the page + * and defined nowhere. Four documents the complete closure of every group today with a + * level of headroom; the measured cost of the whole closure over the bounded walk is one + * additional schema across all eleven pages. + * + * A model reachable only deeper than this is still *named* on the page, as bare code rather + * than a link, so the bound degrades to "less detail" and never to a link that goes nowhere. + * That is why exceeding it is not fatal, unlike the cases in the module header. + */ +const MAX_SCHEMA_DEPTH = 4; /** The banner every generated page carries, as an HTML comment and as prose. */ const GENERATED_BY = 'capsule-docs/scripts/gen-reference.mjs'; @@ -84,6 +109,9 @@ const FENCE_OPEN = /^\s*(`{3,}|~{3,})/; /** A line that *closes* one: the same run with nothing after it but whitespace. */ const FENCE_CLOSE = /^\s*(`{3,}|~{3,})\s*$/; +/** A setext underline: a run of `=` or `-` alone on its line. */ +const SETEXT_UNDERLINE = /^\s{0,3}(={2,}|-{2,})\s*$/; + /** * Shift every ATX heading in `markdown` down by `offset` levels, clamped at h6. * @@ -95,12 +123,15 @@ const FENCE_CLOSE = /^\s*(`{3,}|~{3,})\s*$/; * Fenced blocks are skipped: a `#` on the first column of a shell example is a comment, not * a heading, and demoting it would corrupt the example. * - * **ATX only.** A setext heading (`Title` over `=====`) is left alone. Neither committed - * artifact uses one — verified across all 971 descriptions in the OpenAPI document — and - * rewriting a line based on the line below it is a different and more fragile - * transformation than prefixing hashes: a `---` under a paragraph is a thematic break, and - * over one it is frontmatter. The limitation is tested, so it fails visibly if it stops - * being acceptable. + * **ATX only, and a setext heading is fatal.** Rewriting a line based on the line below it + * is a different and more fragile transformation than prefixing hashes — a `---` under a + * paragraph is a thematic break, and over one it is frontmatter — so this function does not + * attempt it. Silently leaving one alone is worse than not supporting it: an `=====` + * underline in an operation description would put an undemoted h1 in the page body, which + * is the exact defect demotion exists to prevent, and it would publish looking fine. + * Neither committed artifact uses one today (verified across all 971 descriptions in the + * OpenAPI document), so the first one to appear stops the build and gets ATX in its doc + * comment. * * @param {string} markdown Prose that may contain headings. * @param {number} offset Levels to add. @@ -108,39 +139,68 @@ const FENCE_CLOSE = /^\s*(`{3,}|~{3,})\s*$/; */ export function demoteHeadings(markdown, offset) { let fence = null; - return markdown - .split('\n') - .map((line) => { - const fenceMatch = FENCE_OPEN.exec(line); - if (fence === null) { - if (fenceMatch) { - fence = { - char: fenceMatch[1][0], - length: fenceMatch[1].length, - }; - return line; - } - } else { - // A *closing* fence carries no info string. Without that anchor a - // ```` ```js ```` line nested inside a ```` ```sh ```` example closes the - // block early, which both demotes the `#` comments inside the example and - // leaves every real heading after it untouched. - const closer = FENCE_CLOSE.exec(line); - if ( - closer && - closer[1][0] === fence.char && - closer[1].length >= fence.length - ) { - fence = null; - } - return line; + /** @type {string[]} */ + const lines = []; + /** Indices of lines that sat inside a fenced block, which is code, not prose. */ + const fenced = new Set(); + markdown.split('\n').forEach((line) => { + const fenceMatch = FENCE_OPEN.exec(line); + if (fence === null) { + if (fenceMatch) { + fence = { + char: fenceMatch[1][0], + length: fenceMatch[1].length, + }; + lines.push(line); + fenced.add(lines.length - 1); + return; + } + } else { + // A *closing* fence carries no info string. Without that anchor a + // ```` ```js ```` line nested inside a ```` ```sh ```` example closes the + // block early, which both demotes the `#` comments inside the example and + // leaves every real heading after it untouched. + const closer = FENCE_CLOSE.exec(line); + if ( + closer && + closer[1][0] === fence.char && + closer[1].length >= fence.length + ) { + fence = null; } - const heading = /^(#{1,6})(\s)/.exec(line); - if (!heading) return line; - const level = Math.min(6, heading[1].length + offset); - return '#'.repeat(level) + line.slice(heading[1].length); - }) - .join('\n'); + // Inside a fence, and pushed with a marker the setext scan below reads as + // "not prose": an `=====` in a code example is code. + lines.push(line); + fenced.add(lines.length - 1); + return; + } + const heading = /^(#{1,6})(\s)/.exec(line); + if (!heading) { + lines.push(line); + return; + } + const level = Math.min(6, heading[1].length + offset); + lines.push('#'.repeat(level) + line.slice(heading[1].length)); + }); + + // Checked after the pass so the fence state above decides what is prose. A setext + // underline is a run of `=` or `-` alone on a line, directly under a non-blank one that + // is not itself a heading, a list item, or a table row. + for (let i = 1; i < lines.length; i += 1) { + if (fenced.has(i) || fenced.has(i - 1)) continue; + if (!SETEXT_UNDERLINE.test(lines[i])) continue; + const above = lines[i - 1]; + if (above.trim() === '') continue; + if (/^\s*(#{1,6}\s|[-*+>|]|\d+[.)]\s)/.test(above)) continue; + throw new Error( + `artifact prose carries a setext heading ("${above.trim()}" underlined with ` + + `"${lines[i].trim()}"). This generator demotes ATX headings only, and an ` + + 'undemoted heading in a page body is the defect demotion exists to prevent. ' + + 'Rewrite it as an ATX heading (`## Title`) in the doc comment it comes from.', + ); + } + + return lines.join('\n'); } /** Where the site's content lives, for turning a repo path into a route. */ @@ -193,24 +253,62 @@ function rewriteLinks(markdown) { } /** - * Escape a string for a Markdown table cell: a literal `|` would otherwise open a new - * column, and a newline would end the row. + * Prepare artifact prose for a one-line context: rewrite its links, flatten it to a single + * line. Deliberately does **not** escape — see [`escapeCell`]. + * + * @param {string} text + * @returns {string} + */ +function prose(text) { + return rewriteLinks(text) + .replace(/\s*\n\s*/g, ' ') + .trim(); +} + +/** + * Escape one **finished** table cell — after every fragment that composes it has been + * assembled, never fragment by fragment. + * + * That ordering is the whole point. A cell is built from several sources — the help text, + * then `Values: …`, `Default: …`, `Example: …` appended after it — and escaping only the + * first leaves the others raw. A default of `a|b` then opens a column of its own and shifts + * every cell to its right, which is exactly the defect this function exists to prevent and + * exactly the one a per-fragment escape reintroduces. + * + * Two characters are escaped, and only these two: + * + * - `|`, which opens a column. GFM requires the escape inside a code span too, and renders + * it as a bare pipe, so `` `a\|b` `` shows the pipe the artifact meant. + * - `<`, because Markdown passes raw HTML through. `` — the most likely idiom in help + * text for a command line — parses as a tag and vanishes, taking everything up to the + * next `>` with it if it never closes. + * + * **Artifact prose is trusted Markdown.** It comes from Rust doc comments and `clap` + * annotations in this repository, reviewed like any other source, so emphasis, links, and + * inline code in it are the author's intent and are passed through rather than sanitized. + * These two escapes are not a security boundary; they repair characters whose meaning + * *changes* when prose written for a doc comment is republished inside a table. An unclosed + * `<` outside a code span in a doc comment is the author's bug, and it is fixed in the doc + * comment. + * + * Never apply this to generator-authored markup: the `
` in a response body cell is + * markup this file wrote and means to keep. + * + * @param {string} text A fully assembled cell. + * @returns {string} + */ +function escapeCell(text) { + return text.replace(/\|/g, '\\|').replace(/` with - // it if it never closes. - .replace(/ `\`${value}\``).join(', ')}.`, ); } - return parts.join(' ') || '—'; + // One pass, over the assembled cell: a `|` in a default or an enumerated value is as + // capable of opening a column as one in the help text. + return escapeCell(parts.join(' ')) || '—'; } /** @@ -456,9 +556,9 @@ function renderCommand(command, path, level) { // Demoted relative to this command's own heading, so a doc comment that opens at `#` // nests under the command it describes instead of outranking it. - const prose = command.long_about ?? command.about; - if (prose) { - sections.push(demoteHeadings(rewriteLinks(prose), level), ''); + const about = command.long_about ?? command.about; + if (about) { + sections.push(demoteHeadings(rewriteLinks(about), level), ''); } const positionalTable = table( @@ -542,9 +642,65 @@ export function readOpenApiDocument(root) { if (!document.paths || typeof document.paths !== 'object') { throw new Error(`${OPENAPI_DOCUMENT} declares no paths.`); } + assertRenderable(document); return document; } +/** Schema keywords this renderer cannot express. */ +const COMPOSITION_KEYWORDS = ['oneOf', 'allOf', 'anyOf']; + +/** + * Fail on anything in the document this renderer would answer wrongly rather than not at + * all. See the fatal list in the module header for why each is fatal. + * + * @param {Record} document + * @throws {Error} naming the operation or the schema. + */ +function assertRenderable(document) { + for (const [path, item] of Object.entries(document.paths)) { + for (const method of METHODS) { + const operation = item?.[method]; + if (!operation) continue; + const at = `${method.toUpperCase()} ${path}`; + + const carriers = [ + ['request body', operation.requestBody], + ...Object.entries(operation.responses ?? {}).map( + ([status, response]) => [`response ${status}`, response], + ), + ]; + for (const [which, carrier] of carriers) { + const media = Object.keys(carrier?.content ?? {}); + if (media.length > 1) { + throw new Error( + `${at}: its ${which} offers ${media.length} media types ` + + `(${media.sort().join(', ')}), and this generator renders one body ` + + 'per carrier. Rendering it would document the endpoint as ' + + 'accepting only the first. Teach ' + + `${GENERATED_BY} to render every media type before the server ` + + 'starts offering a choice.', + ); + } + } + } + } + + for (const [name, schema] of Object.entries( + document.components?.schemas ?? {}, + )) { + const composed = COMPOSITION_KEYWORDS.filter((word) => schema?.[word]); + if (composed.length > 0) { + throw new Error( + `schema ${name} composes with ${composed.join(' and ')}, which this ` + + 'generator cannot express: it flattens a schema to a property table, ' + + 'and a union or an intersection is not a property table. It would ' + + `render as an empty or a half-true model. Teach ${GENERATED_BY} to ` + + 'render composition before the server starts emitting it.', + ); + } + } +} + /** * Bucket every operation in the document into its group, in a stable order. * @@ -625,16 +781,22 @@ function refName(ref) { * The set of schema names a page must document, walked from its operations to * `MAX_SCHEMA_DEPTH`. * - * Depth-bounded rather than exhaustive, and visited-set guarded, so a self-referential or - * mutually-referential schema cannot spin: the current document has no cycle, but a renderer - * that would hang on one is a renderer that fails the day someone adds a tree. + * Depth-bounded rather than exhaustive so a self-referential or mutually-referential schema + * cannot spin: the current document has no cycle, but a renderer that would hang on one is + * a renderer that fails the day someone adds a tree. + * + * The bound and the cycle guard interact, which is the subtle part — see the comment on + * `seen` below. The rule the page states, and the one implemented here, is: a schema is + * documented when some path reaches it within the bound, whichever path the walk takes + * first. * * @param {Record} document * @param {Array<{ operation: Record }>} operations * @returns {string[]} Schema names, sorted. */ function schemasUsedBy(document, operations) { - const seen = new Set(); + /** @type {Map} Schema name -> shallowest depth it was reached at. */ + const seen = new Map(); const visit = (schema, depth) => { if (!schema || typeof schema !== 'object' || depth > MAX_SCHEMA_DEPTH) @@ -645,8 +807,17 @@ function schemasUsedBy(document, operations) { } if (schema.$ref) { const name = refName(schema.$ref); - if (seen.has(name)) return; - seen.add(name); + // Keyed on the *shallowest* depth this name has been reached at, not on having + // been seen at all. A plain visited set makes the answer depend on traversal + // order: reach `SyncEntry` at depth 2 first and its children are cut by the + // bound, and the later path that reaches it at depth 1 — where its children are + // in range — is then refused as already-seen. `WireBlobRole` on + // `/reference/api/sync/` was documented or not according to which operation the + // walk happened to read first. Re-expanding on a shallower arrival still + // terminates: a name can only improve `MAX_SCHEMA_DEPTH + 1` times, and a cycle + // never arrives shallower twice. + if (seen.has(name) && seen.get(name) <= depth) return; + seen.set(name, depth); visit(document.components?.schemas?.[name], depth + 1); return; } @@ -658,7 +829,7 @@ function schemasUsedBy(document, operations) { visit(operation.responses ?? {}, 0); visit(operation.parameters ?? [], 0); } - return [...seen].sort(); + return [...seen.keys()].sort(); } /** @@ -670,6 +841,8 @@ function schemasUsedBy(document, operations) { function bodyOf(carrier) { const content = carrier?.content; if (!content) return null; + // Exactly one, or none: `assertRenderable` has already refused a carrier offering a + // choice, so `sort()[0]` is the only entry rather than an arbitrary pick. const mediaType = Object.keys(content).sort()[0]; if (!mediaType) return null; return { mediaType, schema: content[mediaType]?.schema ?? {} }; @@ -741,17 +914,19 @@ function renderOperation({ path, method, operation }, documented) { `\`${parameter.name}\``, `\`${parameter.in}\``, schemaLink(documented, parameter.schema ?? {}), - [ - parameter.required ? '**Required.**' : '', - parameter.description - ? sentence(cell(parameter.description)) - : '', - parameter.example === undefined - ? '' - : `Example: \`${parameter.example}\`.`, - ] - .filter(Boolean) - .join(' ') || '—', + escapeCell( + [ + parameter.required ? '**Required.**' : '', + parameter.description + ? sentence(prose(parameter.description)) + : '', + parameter.example === undefined + ? '' + : `Example: \`${parameter.example}\`.`, + ] + .filter(Boolean) + .join(' '), + ) || '—', ]), ), ); @@ -780,16 +955,18 @@ function renderOperation({ path, method, operation }, documented) { body ? `${schemaLink(documented, body.schema)}
\`${body.mediaType}\`` : '—', - [ - response.description - ? sentence(cell(response.description)) - : '', - headers.length > 0 - ? `Headers: ${headers.map((header) => `\`${header}\``).join(', ')}.` - : '', - ] - .filter(Boolean) - .join(' ') || '—', + escapeCell( + [ + response.description + ? sentence(prose(response.description)) + : '', + headers.length > 0 + ? `Headers: ${headers.map((header) => `\`${header}\``).join(', ')}.` + : '', + ] + .filter(Boolean) + .join(' '), + ) || '—', ]; }), ), @@ -811,8 +988,12 @@ function renderSchema(name, document, documented) { const schema = document.components?.schemas?.[name] ?? {}; const sections = [`### ${name}`]; - if (schema.description) - sections.push(demoteHeadings(schema.description, 3)); + // Through `rewriteLinks` like every other prose site: a model's own doc comment is as + // free to cite a design document by repo path, or an item by its rustdoc path, as a + // handler's is. `TokenResponse` is one of several that do. + if (schema.description) { + sections.push(demoteHeadings(rewriteLinks(schema.description), 3)); + } if (schema.enum) { sections.push( @@ -834,14 +1015,16 @@ function renderSchema(name, document, documented) { properties.map(([field, property]) => [ `\`${field}\``, schemaLink(documented, property), - [ - required.has(field) ? '**Required.**' : '', - property.description - ? sentence(cell(property.description)) - : '', - ] - .filter(Boolean) - .join(' ') || '—', + escapeCell( + [ + required.has(field) ? '**Required.**' : '', + property.description + ? sentence(prose(property.description)) + : '', + ] + .filter(Boolean) + .join(' '), + ) || '—', ]), ), ); @@ -886,8 +1069,9 @@ export function renderApiPage(group, operations, document) { '## Schemas', '', 'The models these endpoints carry. A field whose type names another model links', - 'to it; a model reached more than two references deep is named without being', - 'expanded here.', + 'to it when this page documents that model, which it does when some path from', + `an operation reaches it within ${MAX_SCHEMA_DEPTH} references. A model only`, + 'ever reached deeper than that is named without being expanded.', '', documented .map((name) => renderSchema(name, document, documented)) diff --git a/capsule-docs/scripts/gen-reference.test.mjs b/capsule-docs/scripts/gen-reference.test.mjs index 1770f4d2..1b2a1047 100644 --- a/capsule-docs/scripts/gen-reference.test.mjs +++ b/capsule-docs/scripts/gen-reference.test.mjs @@ -1,4 +1,5 @@ import { + copyFileSync, existsSync, mkdirSync, mkdtempSync, @@ -35,6 +36,7 @@ import { API_GROUPS, groupForPath } from './reference-groups.mjs'; function fixtureRoot() { const root = mkdtempSync(join(tmpdir(), 'gen-reference-')); mkdirSync(join(root, 'capsule-cli'), { recursive: true }); + mkdirSync(join(root, 'capsule-server'), { recursive: true }); mkdirSync(join(root, 'capsule-docs/src/content/docs/reference'), { recursive: true, }); @@ -277,10 +279,24 @@ describe('demoteHeadings', () => { ); }); - // Documented limitation, pinned so it fails visibly rather than silently: neither - // committed artifact uses a setext heading. - it('leaves a setext heading alone', () => { - expect(demoteHeadings('Title\n=====\n', 2)).toBe('Title\n=====\n'); + // Leaving one alone silently is worse than not supporting it: an undemoted h1 in the + // page body is the exact defect demotion exists to prevent, and it would publish + // looking fine. + it('refuses a setext heading rather than silently leaving it undemoted', () => { + expect(() => demoteHeadings('Title\n=====\n', 2)).toThrow(/setext/i); + expect(() => demoteHeadings('Title\n-----\n', 2)).toThrow(/Title/); + }); + + it('does not mistake a thematic break or a table for a setext underline', () => { + expect(() => demoteHeadings('para\n\n---\n', 2)).not.toThrow(); + expect(() => demoteHeadings('| a |\n| --- |\n', 2)).not.toThrow(); + expect(() => demoteHeadings('- item\n---\n', 2)).not.toThrow(); + }); + + it('does not mistake an underline inside a fenced example for one', () => { + expect(() => + demoteHeadings('```text\nTitle\n=====\n```\n', 2), + ).not.toThrow(); }); it('does not demote a hash inside a fenced block', () => { @@ -402,6 +418,30 @@ describe('renderCliPage', () => { expect(page).not.toContain('pass a here'); }); + // The defect a per-fragment escape reintroduces: the help text is escaped, the fragments + // appended after it are not, and a `|` in a default opens a column of its own. + it('escapes a pipe in a default value and in an enumerated value', () => { + const surface = structuredClone(MINIMAL_CLI); + surface.subcommands[0].args[1].possible_values = [{ name: 'x|y' }]; + surface.subcommands[0].args[1].default_values = ['a|b']; + const page = renderCliPage(surface); + expect(page).toContain('Values: `x\\|y`.'); + expect(page).toContain('Default: `a\\|b`.'); + expect(page).not.toContain('`x|y`'); + expect(page).not.toContain('`a|b`'); + }); + + it('keeps every table row at the width of its header', () => { + const surface = structuredClone(MINIMAL_CLI); + surface.subcommands[0].args[1].default_values = ['a|b']; + surface.subcommands[0].args[2].help = 'takes a | and a '; + for (const line of renderCliPage(surface).split('\n')) { + if (!line.startsWith('|')) continue; + const columns = line.replace(/\\\|/g, '').split('|').length; + expect(columns).toBe(4); + } + }); + it('does not append "Repeatable." when the help already says it', () => { const surface = structuredClone(MINIMAL_CLI); surface.subcommands[0].args[0].help = @@ -556,6 +596,25 @@ describe('link rewriting in artifact prose', () => { expect(rendered).not.toContain('capsule_core::crypto::revoke'); }); + // A model's own doc comment is as free to cite a design document by repo path, or an + // item by its rustdoc path, as a handler's is — and the schema appendix is a separate + // render path that had to be wired up for it. + it('rewrites links in a schema-level description too', () => { + const document = structuredClone(MINIMAL_OPENAPI); + document.components.schemas.LoginRequest.description = + 'Shaped by the [chunk contract](../../../capsule-docs/src/content/docs/design/import/upload-protocol.md) and signed with [`revoke_all_signing_bytes`](capsule_core::crypto::revoke::revoke_all_signing_bytes).'; + const rendered = renderApiPage( + API_GROUPS.find((entry) => entry.slug === 'auth'), + bucketOperations(document).get('auth'), + document, + ); + expect(rendered).toContain( + '[chunk contract](/design/import/upload-protocol/)', + ); + expect(rendered).toContain('signed with `revoke_all_signing_bytes`.'); + expect(rendered).not.toContain('capsule_core::crypto::revoke'); + }); + it('leaves an absolute URL, a site route, and an anchor alone', () => { const document = structuredClone(MINIMAL_OPENAPI); document.paths['/v1/auth/login'].post.description = @@ -636,6 +695,58 @@ describe('readOpenApiDocument', () => { writeOpenApi({ ...MINIMAL_OPENAPI, openapi: '3.1.0' }); expect(() => readOpenApiDocument(root)).toThrow(/3\.2/); }); + + // This renderer shows one body per carrier. Picking the first of several silently would + // document an endpoint that also accepts CBOR as accepting only JSON. + it('refuses a carrier offering more than one media type, naming the operation', () => { + const document = structuredClone(MINIMAL_OPENAPI); + document.paths['/v1/auth/login'].post.requestBody.content[ + 'application/cbor' + ] = { schema: { $ref: '#/components/schemas/LoginRequest' } }; + writeOpenApi(document); + expect(() => readOpenApiDocument(root)).toThrow( + /POST \/v1\/auth\/login/, + ); + expect(() => readOpenApiDocument(root)).toThrow(/media types/); + }); + + it('refuses a multi-media-type response too', () => { + const document = structuredClone(MINIMAL_OPENAPI); + document.paths['/v1/version'].get.responses[200].content[ + 'application/cbor' + ] = { schema: { $ref: '#/components/schemas/VersionResponse' } }; + writeOpenApi(document); + expect(() => readOpenApiDocument(root)).toThrow(/GET \/v1\/version/); + expect(() => readOpenApiDocument(root)).toThrow(/response 200/); + }); + + // A property table cannot express a union or an intersection, so a composed schema + // would render as an empty or a half-true model. + it.each([ + 'oneOf', + 'allOf', + 'anyOf', + ])('refuses a schema composed with %s, naming the schema', (keyword) => { + const document = structuredClone(MINIMAL_OPENAPI); + document.components.schemas.TokenResponse = { + title: 'TokenResponse', + [keyword]: [ + { $ref: '#/components/schemas/VersionResponse' }, + { type: 'object' }, + ], + }; + writeOpenApi(document); + expect(() => readOpenApiDocument(root)).toThrow(/TokenResponse/); + expect(() => readOpenApiDocument(root)).toThrow(new RegExp(keyword)); + }); + + it('accepts the committed document', () => { + expect(() => + readOpenApiDocument( + resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'), + ), + ).not.toThrow(); + }); }); describe('renderApiPage', () => { @@ -751,6 +862,88 @@ describe('renderApiPage', () => { }); }); +describe('the schema appendix', () => { + // The depth bound and the cycle guard interact. Keyed on "seen at all", the answer + // depends on traversal order: reach a model at depth 2 first and its children are cut + // by the bound, and the later path that reaches it at depth 1 is then refused as + // already-seen. Keyed on the shallowest depth, both paths get their chance. + it('expands a model when a shallower path reaches it after a deeper one', () => { + const document = structuredClone(MINIMAL_OPENAPI); + const schemas = document.components.schemas; + schemas.Leaf = { + type: 'object', + title: 'Leaf', + properties: { + mark: { type: 'string', description: 'The leaf mark.' }, + }, + }; + // A chain long enough that the deep path runs past MAX_SCHEMA_DEPTH exactly at + // `Tail`, so `Leaf` is out of reach along it. + schemas.Tail = { + type: 'object', + title: 'Tail', + properties: { leaf: { $ref: '#/components/schemas/Leaf' } }, + }; + for (const [name, next] of [ + ['Link3', 'Tail'], + ['Link2', 'Link3'], + ['Link1', 'Link2'], + ]) { + schemas[name] = { + type: 'object', + title: name, + properties: { next: { $ref: `#/components/schemas/${next}` } }, + }; + } + // Walked first (`requestBody` before `responses`): reaches `Tail` too deep to + // expand it. The response then reaches the same name at depth 0. + schemas.LoginRequest.properties.deep = { + $ref: '#/components/schemas/Link1', + }; + document.paths['/v1/auth/login'].post.responses[200].content[ + 'application/json' + ].schema = { $ref: '#/components/schemas/Tail' }; + + const rendered = renderApiPage( + API_GROUPS.find((entry) => entry.slug === 'auth'), + bucketOperations(document).get('auth'), + document, + ); + expect(rendered).toContain('### Tail'); + expect(rendered).toContain('### Leaf'); + expect(rendered).toContain('The leaf mark.'); + }); + + // The concrete instance of that bug in the committed document: `WireBlobRole` is + // reachable from `/reference/api/sync/` and was named on the page while defined nowhere. + it('documents every model the committed sync page links to', () => { + const repoRoot = resolve( + dirname(fileURLToPath(import.meta.url)), + '..', + '..', + ); + const document = readOpenApiDocument(repoRoot); + const group = API_GROUPS.find((entry) => entry.slug === 'sync'); + const rendered = renderApiPage( + group, + bucketOperations(document).get('sync'), + document, + ); + expect(rendered).toContain('### WireBlobRole'); + const headings = new Set( + [...rendered.matchAll(/^#{2,6} (.+)$/gm)].map((match) => + match[1] + .toLowerCase() + .replace(/[^a-z0-9 -]/g, '') + .replace(/ /g, '-'), + ), + ); + for (const [, anchor] of rendered.matchAll(/\]\(#([^)]+)\)/g)) { + expect(headings.has(anchor)).toBe(true); + } + }); +}); + describe('the committed artifacts', () => { // The assertion about *this repository* rather than about the generator: every // operation the server declares reaches a page. A group table that quietly stopped @@ -775,8 +968,21 @@ describe('the committed artifacts', () => { expect(bucketed).toBeGreaterThan(50); }); + // Renders into a temp root seeded from the two committed artifacts, never into the + // working tree. `check-docs` runs `test-docs` and `build-docs` in parallel, and + // `generate` clears its output directories before writing: generating into the real + // tree races the Astro build reading it, which fails intermittently and only under the + // gate. The artifacts are the committed ones, so the assertion is still about this + // repository. it('generate one page per group plus the CLI page', () => { - const written = generate(repoRoot); + copyFileSync(join(repoRoot, CLI_SURFACE), join(root, CLI_SURFACE)); + copyFileSync( + join(repoRoot, OPENAPI_DOCUMENT), + join(root, OPENAPI_DOCUMENT), + ); + + const written = generate(root); + expect(written).toContain( 'capsule-docs/src/content/docs/reference/cli/commands.md', ); @@ -785,6 +991,11 @@ describe('the committed artifacts', () => { `capsule-docs/src/content/docs/reference/api/${group.slug}.md`, ); } + // Every page the run reported is a page it actually wrote, under the temp root. + for (const path of written) { + expect(existsSync(join(root, path))).toBe(true); + } + expect(written).toHaveLength(API_GROUPS.length + 1); }); }); diff --git a/xtask/src/i18n_guard.rs b/xtask/src/i18n_guard.rs index 404795d3..8727de72 100644 --- a/xtask/src/i18n_guard.rs +++ b/xtask/src/i18n_guard.rs @@ -32,6 +32,8 @@ //! argument list of a terminal-output or error macro. See the rule below — the CLI is //! the one Rust surface that renders prose to a human, and it had never been scanned, //! which is how the entire `capsule import` arm printed hardcoded English for months. +//! [`NEVER_SCANNED`] carves out `capsule-cli/src/bin/`, which is build tooling rather +//! than that surface. //! //! ## What counts as user-facing in a Rust binary //! @@ -86,6 +88,20 @@ use eyre::{Context, ContextCompat, Result, bail}; use regex::Regex; use serde_json::Value; +/// Subtrees inside a scanned root that are not the user-facing surface the root stands for. +/// +/// `capsule-cli/src/bin/` holds description-artifact emitters — `gen_cli_surface`, and +/// whatever joins it — that run from `mise` tasks and CI and are never installed. Their +/// audience is a developer reading a task's output, not a user of `capsule`, so the rule +/// this module enforces ("every string a user reads is a catalog key") does not apply to +/// them: routing a build tool's status line through `locales/` would put a string no user +/// can reach into every translation catalog. +/// +/// This is a carve-out for an *audience*, which is the distinction the module doc is built +/// on, not a narrowing of the rule for the surface itself. `capsule-cli/src/**` outside +/// this prefix is scanned exactly as before. +const NEVER_SCANNED: &[&str] = &["capsule-cli/src/bin/"]; + /// Repo-relative path of the documented allowlist (one `path\tstring` per line; /// `#` comments and blank lines ignored). Entries suppress a single known, /// intentionally-untranslated finding at that file for that exact captured string. @@ -208,6 +224,9 @@ fn scan_surface( .unwrap_or(&path) .to_string_lossy() .replace('\\', "/"); + if NEVER_SCANNED.iter().any(|prefix| file.starts_with(prefix)) { + continue; + } for f in detect(&content) { if is_key(&f.text) { continue; From 97a6b5d02a409900f3764398261e005b5f9f9b67 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 02:25:23 -0400 Subject: [PATCH 095/243] docs(reference): correct the claims this change made stale or overstated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `developer-docs.md` still opened by saying every surface below was Planned or Blocked and that `reference/` held no generated content, and still explained why REST was blocked — three paragraphs contradicting the table two screens down that this branch had already flipped to Landed. They now describe the landed state and record how the block cleared, so the document does not argue with itself. `SLICES.md`'s blocked-row narrative counted `S-Z9` among the rows waiting on a decision. Moving that row to `done` made the sentence false; it now reads six and does not name `S-Z9`. The row-count paragraph is deliberately untouched — it is another lane's to recount. Two overstatements in the new prose, both found by reading it against the artifacts rather than against intent: - `reference/api.md` reproduced the six-row negotiation header table from `api-surfaces.md`. That table is a design commitment, not what every route implements today, and a reference page asserting it would be wrong now and drift again later. It links the contract and says that the parameters and response headers on the generated pages are what the wire carries at this commit — so when the full set lands, the generated pages show it and this page needs no edit. - `reference/cli.md` claimed every command that opens a library accepts `--passphrase-stdin`. Three do — `import`, `push`, `cull` — and the artifact says so. The `capsule library` subcommands are not among them because they read the version file, the sidecars, and the index, none of which is sealed. --- SLICES.md | 7 +++---- .../src/content/docs/design/developer-docs.md | 17 ++++++++++------- capsule-docs/src/content/docs/reference/api.md | 17 ++++++----------- capsule-docs/src/content/docs/reference/cli.md | 7 +++++-- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/SLICES.md b/SLICES.md index 710cea02..250f7ccc 100644 --- a/SLICES.md +++ b/SLICES.md @@ -413,12 +413,11 @@ area: **87 ACTIVE / 80 RETIRED / 38 MIXED**. By status: and `part 1 done`; they are counted together). Lanes are independent by construction; within a lane, "Depends on" is the only -ordering. Seven rows read `blocked`, and only two of them are waiting on code: +ordering. Six rows read `blocked`, and only two of them are waiting on code: `S-N2` behind `S-N1`, and `S-P4` behind `S-P2`/`S-P3`. The rest are waiting on a decision rather than on an implementation — `S-C47` is a legal question, `S-C49` -and `S-C51` each need a fact the slice that found them could not settle, `S-D24` -needs a design decision, and `S-Z9` needs the Kynos document -(`S-C27`/`S-D8`). `S-P1` landing freed the rest of lane P and `S-U19` with it; +and `S-C51` each need a fact the slice that found them could not settle, and +`S-D24` needs a design decision. `S-P1` landing freed the rest of lane P and `S-U19` with it; lane U was built so the other twenty-two Apple-client slices never waited on that chain in the first place. Everything else that once read `blocked` is startable: `S-A10` and `S-P7` are done (freeing `S-B10`, `S-D16`, `S-P1`, `S-Q5` — of diff --git a/capsule-docs/src/content/docs/design/developer-docs.md b/capsule-docs/src/content/docs/design/developer-docs.md index 2db425db..cad635c8 100644 --- a/capsule-docs/src/content/docs/design/developer-docs.md +++ b/capsule-docs/src/content/docs/design/developer-docs.md @@ -11,9 +11,10 @@ resulting page lands. It does not decide which surfaces exist: that is the obeys are [API Practices](/development/api-practices/). Implemented in `capsule-docs/` (Astro + Starlight) plus one description emitter per surface, living -in the crate that owns the surface and driven by its `mise` task. Every surface named below is -**Planned** or **Blocked** — `capsule-docs/src/content/docs/reference/` holds no generated content -today. +in the crate that owns the surface and driven by its `mise` task. Two surfaces have landed: the REST +contract at `/reference/api/` and the command line at `/reference/cli/`, each generated from a +committed description artifact by `capsule-docs/scripts/gen-reference.mjs` and each drift-gated in +the Rust gate. The rest are **Planned**. ## The Problem @@ -96,10 +97,12 @@ Each therefore needs a small committed dump alongside its existing generation st symbol-presence assertions already in `mise-tasks/gen-bindings` are the seed of that dump — they already enumerate the verbs each binding must export — but they assert, they do not yet emit. -**Why REST is blocked.** The committed `capsule-sdk/openapi.json` is emitted from the retired Salvo -server. Its Kynos replacement exposes `openapi() -> Document` but has a single route ported and no -emitter binary, so no Kynos document exists yet. The REST reference is generated from the Kynos -document when there is one; publishing the Salvo-derived file would document a server nothing runs. +**How REST got unblocked.** It was blocked on there being no Kynos document to publish: the +committed contract was emitted from the retired Salvo server, and publishing that would have +documented a server nothing runs. `capsule-server/openapi.json` is now the Kynos document — emitted +by `gen_openapi` from the route types, gated by `openapi-check-kynos` — and `/reference/api/` +renders it. The CLI followed the same shape rather than a second mechanism: a `command_tree()` dump, +a `gen_cli_surface` emitter, and `cli-surface-check` in the same gate. **What is deliberately not a reference surface:** diff --git a/capsule-docs/src/content/docs/reference/api.md b/capsule-docs/src/content/docs/reference/api.md index 4d0c7e44..3025628d 100644 --- a/capsule-docs/src/content/docs/reference/api.md +++ b/capsule-docs/src/content/docs/reference/api.md @@ -41,17 +41,12 @@ A `401` carries a `WWW-Authenticate` challenge, per RFC 9110. ## Negotiation -Every public route applies the same headers, which the generated pages do not repeat per -operation: - -| Header | Direction | -| --- | --- | -| `X-Capsule-Protocol` | request | -| `X-Capsule-Crypto-Suite` | request for writes | -| `X-Capsule-Sidecar-Schema` | request | -| `X-Capsule-Protocol-Min` | response | -| `X-Capsule-Protocol-Max` | response | -| `X-Capsule-Min-Client-Build` | response | +The protocol-negotiation header contract — what a client sends, what a server answers with — +is [API Surfaces](/design/api-surfaces/#negotiation-across-transports). It is a design +commitment, and it is not yet implemented on every route; what the generated pages show, +under each operation's parameters and response headers, is what the wire carries at this +commit. Read those for the truth about an endpoint today, and the design document for where +it is going. `GET /v1/version` is the unauthenticated reachability probe a client performs before the handshake. It has no failure variant by construction. What a server publishes about itself — diff --git a/capsule-docs/src/content/docs/reference/cli.md b/capsule-docs/src/content/docs/reference/cli.md index f4f0e990..6e92621e 100644 --- a/capsule-docs/src/content/docs/reference/cli.md +++ b/capsule-docs/src/content/docs/reference/cli.md @@ -33,8 +33,11 @@ A `capsule` invocation reads at most two pieces of durable state, and it helps t user's configuration directory. Every networked command reads it, and `capsule reset` removes it. -A library is opened with a passphrase. Each command that opens one accepts -`--passphrase-stdin`, so nothing in this reference requires a terminal. +Three commands unseal a library, and all three take its passphrase: `capsule import`, +`capsule push`, and `capsule cull`. Each accepts `--passphrase-stdin` as well as prompting, +so each runs unattended. The `capsule library` subcommands are not among them — `info` and +`rebuild` read the version file, the sidecars, and the index, none of which is sealed, so +they need no passphrase and offer no flag for one. ## Where the contract lives From 16fe2fa14ec4b4a3c3ee58e9892d7c01a13c8260 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 02:30:05 -0400 Subject: [PATCH 096/243] fix(core): project the live index row's capture time from the sidecar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `asset_row_from_state` indexed `capture_timestamp`/`capture_utc` from the in-memory `AssetState::capture_utc` shard, while `rebuild_index` projects them from the signed sidecar — and the rebuild's own comment says the two must agree. They were equal at import, so nothing observed the difference; a capture-time correction (S-B17) is exactly the write that separates them, because it re-signs the sidecar and deliberately leaves the `media/{YYYY}/{YYYY-MM}` shard where the files are. Without this, a correct repair would be invisible to the timeline until the next rebuild. Behaviour-neutral for every asset written today; an unparseable sidecar timestamp indexes as the epoch, as the rebuild already does. --- capsule-core/src/lifecycle/import.rs | 57 +++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/capsule-core/src/lifecycle/import.rs b/capsule-core/src/lifecycle/import.rs index 216a95ed..f3bf9d1c 100644 --- a/capsule-core/src/lifecycle/import.rs +++ b/capsule-core/src/lifecycle/import.rs @@ -157,11 +157,19 @@ fn asset_row_from_state(asset: &AssetState) -> AssetRow { .as_ref() .map_or((None, false), |s| (Some(s.stack_id.clone()), s.hidden)), }; + // Capture time is projected from the **signed sidecar**, not from `AssetState::capture_utc` + // — exactly as `library::rebuild::signed_asset_row` projects it, so the write path and a + // rebuild agree. The two values are equal at import; they part ways after a capture-time + // correction (`Workspace::set_capture_timestamp`, `S-B17`), which by design re-signs the + // sidecar and leaves the `media/{YYYY}/{YYYY-MM}` shard — and therefore `capture_utc`, + // which names it — where the files already are. Reading the shard here would keep the + // timeline on the wrong date until the next rebuild while the rebuild read the right one. + let capture_utc = rfc3339_to_secs(&asset.sidecar.capture_timestamp); AssetRow { uuid: asset.asset_id.to_string(), asset_type: asset_type_for(&asset.sidecar.content_type), - capture_timestamp: asset.capture_utc, - capture_utc: Some(asset.capture_utc), + capture_timestamp: capture_utc, + capture_utc: Some(capture_utc), capture_tz_source: None, import_timestamp: rfc3339_to_secs(&asset.sidecar.import_timestamp), hash_sha256: asset.sidecar.hash.to_hex(), @@ -998,4 +1006,49 @@ mod tests { ws.restore(&id).unwrap(); assert_eq!(ws.db().query_timeline(0, 100).unwrap().len(), 1); } + + // ── The index projection of capture time (S-B17 precondition) ─────────────── + + /// The live `assets` row is projected from the **signed sidecar's** capture timestamp, as + /// a rebuild projects it — not from the `capture_utc` shard, which is fixed at import and + /// stays put through a capture-time correction. Equal at import; this test drives the two + /// apart the way `set_capture_timestamp` will and asserts the row follows the sidecar. + #[test] + fn the_live_index_row_projects_capture_time_from_the_sidecar() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let img = src.path().join("p.jpg"); + fs::write(&img, b"\xFF\xD8\xFF capture projection bytes").unwrap(); + let mut ws = fast_workspace(lib.path()); + let album = ws.create_album("A").unwrap(); + let id = ws.import_asset(album, &img).unwrap(); + + // At import the shard and the sidecar name the same instant. + let asset = ws.assets.get(&id).unwrap(); + let row = asset_row_from_state(asset); + assert_eq!(row.capture_timestamp, asset.capture_utc); + assert_eq!(row.capture_utc, Some(asset.capture_utc)); + assert_eq!( + rfc3339_to_secs(&asset.sidecar.capture_timestamp), + asset.capture_utc + ); + + // Drive them apart in memory: the sidecar says 2001, the shard still says now. + let corrected = 1_000_000_000; + ws.assets.get_mut(&id).unwrap().sidecar.capture_timestamp = capture_rfc3339(corrected); + let asset = ws.assets.get(&id).unwrap(); + assert_ne!(asset.capture_utc, corrected, "the shard is untouched"); + let row = asset_row_from_state(asset); + assert_eq!( + row.capture_timestamp, corrected, + "the row follows the sidecar" + ); + assert_eq!(row.capture_utc, Some(corrected)); + + // An unparseable sidecar timestamp indexes as the epoch, as `rebuild_index` does. + ws.assets.get_mut(&id).unwrap().sidecar.capture_timestamp = "not a timestamp".into(); + let row = asset_row_from_state(ws.assets.get(&id).unwrap()); + assert_eq!(row.capture_timestamp, 0); + assert_eq!(row.capture_utc, Some(0)); + } } From 1717f856bfef8857e85ec1b1db1a1798cd87410e Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 02:32:06 -0400 Subject: [PATCH 097/243] feat(server): open the durable backend's Postgres half at boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Backends::Durable` refused before it did anything. It now demands `DATABASE_URL`, opens the pool, and refuses a database whose schema is not the one this binary was built for — naming `capsule-server-migration up`, because the server cannot migrate: it does not link the migrator (the `chrono` gate in `capsule-server/migration`'s manifest), and a server that migrated on start would run the same schema change once per replica during a rolling deploy. Then it still refuses, and that is the point rather than a shortfall. The ports it cannot fill — session state, upload sessions, the ceremony stores and the rate-limit counters — are the ones a server loses state without, and design/filesystem/server.md is explicit that required means required. So the arm does everything it honestly can, says exactly what is missing, and #403 turns the last `Err` into an `Ok`. The refusal now names only #403 rather than #402 as well. The four adapters are deliberately **not** constructed in `assemble`: production code that builds something it cannot use is theatre. What #403 needs to know — that its one hunk will type-check, and that the schema the migration applied is the schema the adapters query — is asserted instead by `every_postgres_adapter_composes_from_the_boot_configuration`, which builds all four from a real `Config` and asks each one a question through its port. `DATABASE_URL` is demanded by this path rather than by `Demands::Serve`, because `gc`, `purge` and `scrub` load the same configuration and need neither backend URL. `a_durable_backend_without_a_database_url_refuses_by_name` is the assertion that the demand is made rather than discovered as a `None` further in; the case it replaces asserted the old blanket refusal, which no longer happens. `MaintenanceNeedsMemory` said the only index adapter written was the in-memory one. That stopped being true with this change, so it now says what is actually missing on the workers' own side: the collector marks a blob on one pass and sweeps it on a later one, so a `CollectionStore` that forgets can only ever mark (#446); and the scrub reconciles the index against the upload sessions, which is how it tells a live transfer from an orphan (#403). It still says `--memory` and still never says `VALKEY_URL`, which is what its binary-smoke case asserts. `.env.example`'s Backends block said no adapter reads either URL. Its Postgres half is corrected and the migration command is written down where an operator setting `DATABASE_URL` will read it; the Valkey half is left for #403. The container harness grows `url()` and `roll_back()`, which is what lets the unmigrated-database case reach the state a deployment is in between `compose up` and the migration command. Refs #402 --- capsule-server/.env.example | 18 +- capsule-server/src/boot.rs | 306 +++++++++++++++++++++---- capsule-server/src/postgres/testing.rs | 23 ++ capsule-server/src/quota/tests.rs | 9 - 4 files changed, 297 insertions(+), 59 deletions(-) diff --git a/capsule-server/.env.example b/capsule-server/.env.example index 8fa7239c..fffa4840 100644 --- a/capsule-server/.env.example +++ b/capsule-server/.env.example @@ -61,10 +61,22 @@ SERVER_DOMAIN=localhost # ── Backends ───────────────────────────────────────────────────────────────────────────────── # -# Postgres and Valkey are required for a deployment, and **no adapter reads either URL yet** -# (#402, #403). Until they land: +# Postgres and Valkey are both required for a deployment. # -# - `capsule-server serve` with `VALKEY_URL` set refuses to boot and names the issue; +# **DATABASE_URL is read** (#402). A durable `serve` opens the pool from it, and refuses to start +# against a database whose schema is not the one the binary was built for — naming the command +# that fixes it. The server never migrates on its own: run +# +# capsule-server-migration up +# +# once per deployment, before the replicas roll. A server that migrated on start would run the +# same schema change once per replica during a rolling deploy. +# +# **VALKEY_URL is not read yet** (#403). Until it is: +# +# - `capsule-server serve` with `VALKEY_URL` set gets through the Postgres half — the pool +# opens and the schema is checked — and then refuses, naming the issue that owns the session, +# upload-session, ceremony and counter state; # - `capsule-server serve` with neither `VALKEY_URL` nor `--memory` refuses and names the # variable, which is the refusal `store/mod.rs` has always documented; # - `capsule-server serve --memory` (`mise run serve-memory`) runs on the in-crate in-memory diff --git a/capsule-server/src/boot.rs b/capsule-server/src/boot.rs index 9552433e..677bcd61 100644 --- a/capsule-server/src/boot.rs +++ b/capsule-server/src/boot.rs @@ -9,20 +9,30 @@ //! verify under" are all properties of *this function*, and they are asserted below rather than //! discovered on a deployment. //! -//! # The seam, and what #402 and #403 change +//! # The seam, and what #403 still changes //! //! Selection is a two-arm `match` on [`Backends`] and not a trait. The `Arc` fields in //! [`Modules`] already **are** the abstraction; a second one over the top would abstract the -//! composition root from itself. When the Postgres (#402) and Valkey (#403) adapters land they -//! fill the [`Backends::Durable`] arm, and nothing else here moves. +//! composition root from itself. //! -//! Today that arm refuses. `store/mod.rs` has said since `S-C29` that *"Valkey is required; the -//! server refuses to boot without `VALKEY_URL`"* and that the in-memory adapters are a test -//! double rather than a deployment profile — and nothing enforced either sentence, because there -//! was no boot path to enforce it in. Now there is: no `VALKEY_URL` and no `--memory` is a -//! configuration fault naming `VALKEY_URL` ([`Config::load`]), and `VALKEY_URL` set is -//! [`BootError::AdapterUnavailable`] naming the issue that will honour it. Neither ever silently -//! becomes an in-memory server. +//! The [`Backends::Durable`] arm now does its **Postgres half** (#402): it demands +//! `DATABASE_URL`, opens the pool, refuses to continue against a database whose schema is not +//! the one this binary was built for, and builds the four durable adapters — the asset index, +//! the account store, the device-cohort map and the quota ledger. Then it still refuses, because +//! the Valkey half does not exist: `AuthStateStore`, `UploadSessionStore`, the three ceremony +//! stores and the rate-limit counters are #403's, and five other durable ports are #446's. +//! +//! Refusing there rather than filling those ports with in-memory adapters is the whole point. +//! design/filesystem/server.md is explicit — *"Required means required"* — and a server that came +//! up holding session state it will lose on the next restart is worse than one that does not +//! start. So the arm builds everything it honestly can, says exactly what is missing, and +//! #403 turns the last `Err` into an `Ok`. +//! +//! `store/mod.rs` has said since `S-C29` that *"Valkey is required; the server refuses to boot +//! without `VALKEY_URL`"*, and nothing enforced it until there was a boot path to enforce it in. +//! Now: no `VALKEY_URL` and no `--memory` is a configuration fault naming `VALKEY_URL` +//! ([`Config::load`]), and `VALKEY_URL` set reaches [`BootError::AdapterUnavailable`] naming the +//! issue that will honour it. Neither ever silently becomes an in-memory server. //! //! # What the memory profile is, precisely //! @@ -35,7 +45,8 @@ //! will honestly report every blob as an orphan. //! - **The collector's marks do not survive either.** [`crate::gc::collect`] marks a blob on one //! pass and sweeps it on a later pass once the grace window has passed, so a fresh process can -//! only ever mark. Sweeping needs the durable mark store #402 brings. +//! only ever mark. Sweeping needs a durable `CollectionStore`, which is #446's — #402 landed +//! the durable index the collector *reads*, not the marks it writes. use std::sync::Arc; @@ -120,6 +131,15 @@ pub enum BootError { /// The algorithm's own description. detail: String, }, + /// The durable backend could not be opened, or is not the schema this binary expects. + /// + /// Never carries the connection URL: a `DATABASE_URL` holds a password, and a startup error + /// is the most-copied line in any incident channel. + #[error("the durable backend could not be opened: {detail}")] + Database { + /// The driver's own description, or which migration is missing. Never the URL. + detail: String, + }, /// A durable backend was selected and its adapter is not written yet. /// /// Named with the issue that will honour it, because "not implemented" without a pointer is @@ -131,14 +151,20 @@ pub enum BootError { /// Where the work is tracked. issue: &'static str, }, - /// An operator command was run without `--memory` and there is no durable index to read. + /// An operator command was run without `--memory` and one of the stores it reads has no + /// durable adapter. /// /// Deliberately not [`Self::AdapterUnavailable`]: that one names `VALKEY_URL`, which an /// operator running `capsule-server scrub` has typically never set, and pointing them at a /// variable that would not have helped is worse than saying nothing. + /// + /// The durable **index** exists as of #402, so this no longer says otherwise. What is still + /// missing is on the worker's own side: the collector marks a blob on one pass and sweeps it + /// on a later one, so a volatile `CollectionStore` means a process can only ever mark; and + /// the scrub reads the upload-session store to tell a live transfer apart from an orphan. #[error( - "this command needs `--memory`: it compares the index against the blob store, and the \ - only index adapter written is the in-memory one (see {issue})" + "this command needs `--memory`: it reads stores that have no durable adapter yet — the \ + collector's marks and the upload sessions the scrub reconciles against (see {issue})" )] MaintenanceNeedsMemory { /// Where the work is tracked. @@ -209,10 +235,51 @@ pub async fn assemble(config: &Config) -> Result { let stores = stores(config).await?; match config.backends { Backends::Memory => memory(config, stores), - Backends::Durable => Err(durable()), + Backends::Durable => { + // The Postgres half runs for real — `DATABASE_URL` is demanded, the pool is opened, + // and a schema that is not the one this binary was built for refuses here — and then + // the arm still refuses, because the ports it cannot fill are the ones a server + // loses state without. See the module docs. + // + // The connection is dropped rather than handed on. Constructing the four adapters + // and throwing them away would be theatre in production code; that they *do* + // compose out of exactly what this function has is asserted in + // `tests::postgres_conformance` instead, which is where an assertion belongs. + let _connection = open_durable_database(config).await?; + Err(durable_ports_owed()) + } } } +/// Open the durable pool and refuse a schema this binary was not built for. +/// +/// The check is **not** a migration. `capsule-server` cannot link the migrator — see +/// `capsule-server/migration`'s manifest for the `chrono` gate that forces it — and a server that +/// migrated on start would run the migration once per replica during a rolling deploy anyway. +/// What it does instead is read `seaql_migrations` and refuse, naming the command an operator has +/// to run. +/// +/// `DATABASE_URL` is demanded here rather than by [`crate::config::Demands`] because it is this +/// *path* that needs it: `gc`, `purge` and `scrub` never reach here, and `serve --memory` does +/// not either. +async fn open_durable_database(config: &Config) -> Result { + let database_url = config.database_url.as_ref().ok_or(BootError::Missing { + key: "DATABASE_URL", + })?; + let connection = crate::postgres::connect(database_url) + .await + .map_err(|error| BootError::Database { + detail: error.to_string(), + })?; + crate::postgres::assert_schema_current(&connection) + .await + .map_err(|error| BootError::Database { + detail: error.to_string(), + })?; + tracing::info!("the durable database is open and its schema is current"); + Ok(connection) +} + /// Assemble only what `gc`, `purge` and `scrub` read. /// /// # Errors @@ -231,27 +298,32 @@ pub async fn assemble_maintenance(config: &Config) -> Result Err(BootError::MaintenanceNeedsMemory { - issue: "#402 (the Postgres index)", + issue: "#446 (the collector's marks) and #403 (upload sessions)", }), } } -/// The refusal `store/mod.rs` documents. +/// The refusal `store/mod.rs` documents, now reached only for the ports #402 did not land. /// /// `Config::load` already turned "no `VALKEY_URL` and no `--memory`" into a configuration fault /// naming the variable, so reaching here means the operator *did* set it — and the honest answer -/// is that nothing reads it yet. -fn durable() -> BootError { +/// is that nothing reads it yet. The Postgres half of the arm has run by this point: the pool is +/// open and the schema has been checked, so a durable deployment now fails on the port that is +/// genuinely absent rather than on the first one anybody happened to write. +fn durable_ports_owed() -> BootError { BootError::AdapterUnavailable { key: "VALKEY_URL", - issue: "#403 (Valkey) and #402 (Postgres)", + issue: "#403 (session, upload-session, ceremony and counter state)", } } @@ -602,13 +674,34 @@ mod tests { } #[tokio::test] - async fn a_durable_backend_refuses_by_name_rather_than_falling_back() { - // The half of `store/mod.rs`'s claim that `Config::load` cannot make: the operator did - // set `VALKEY_URL`, and nothing reads it yet. Falling back to the in-memory adapters - // here is the one thing that must never happen. + async fn a_durable_backend_without_a_database_url_refuses_by_name() { + // `Config::load` demands `VALKEY_URL` for the durable path and deliberately does not + // demand `DATABASE_URL` — `gc`, `purge` and `scrub` load the same configuration and need + // neither. So the demand belongs to this *path*, and this is the assertion that it is + // made rather than discovered as a `None` somewhere further in. let root = tempfile::tempdir().expect("a scratch directory"); - let environment: BTreeMap = [ - ("BLOB_ROOT".to_owned(), root.path().display().to_string()), + let config = Config::load( + &durable_environment(root.path()), + &Overrides::default(), + Demands::Serve, + ) + .expect("it is well-formed"); + let error = assemble(&config).await.expect_err("it refuses"); + assert!( + matches!( + error, + BootError::Missing { + key: "DATABASE_URL" + } + ), + "{error:?}" + ); + } + + /// The environment a durable `serve` needs, minus the backend URLs a case adds itself. + fn durable_environment(root: &std::path::Path) -> BTreeMap { + [ + ("BLOB_ROOT".to_owned(), root.display().to_string()), ("JWT_ED25519_DER".to_owned(), EXAMPLE_DER.to_owned()), ("VALKEY_URL".to_owned(), "redis://127.0.0.1:6379".to_owned()), // A durable deployment supplies its own attestation identity rather than having one @@ -622,21 +715,140 @@ mod tests { ), ] .into_iter() - .collect(); - let config = Config::load(&environment, &Overrides::default(), Demands::Serve) - .expect("it is well-formed"); - let error = assemble(&config).await.expect_err("it refuses"); - assert!( - matches!( - error, - BootError::AdapterUnavailable { - key: "VALKEY_URL", - .. - } - ), - "{error:?}" - ); - assert!(format!("{error}").contains("#403"), "{error}"); + .collect() + } + + /// What a durable boot does once it can actually reach a database. + mod postgres_conformance { + use std::sync::Arc; + + use super::{ + BTreeMap, BootError, Config, Demands, Overrides, assemble, durable_environment, + }; + use crate::auth::{Credentials, PostgresAccounts}; + use crate::index::postgres::PostgresAssetIndex; + use crate::postgres::testing; + use crate::quota::PostgresQuota; + use crate::store::{PostgresCohorts, SystemClock}; + + /// A config pointing at `url`, with everything else a durable `serve` needs. + fn config_for(root: &std::path::Path, url: &str) -> Config { + let mut environment: BTreeMap = durable_environment(root); + environment.insert("DATABASE_URL".to_owned(), url.to_owned()); + Config::load(&environment, &Overrides::default(), Demands::Serve) + .expect("it is well-formed") + } + + /// A durable boot gets past Postgres and refuses on the ports that are genuinely absent. + /// + /// The property that matters is *which* refusal: falling back to the in-memory adapters + /// is the one thing that must never happen, and refusing on the first port anybody + /// happened to write would hide what is actually missing. Reaching + /// `AdapterUnavailable` proves the pool opened and the schema check passed. + #[tokio::test] + async fn the_durable_arm_clears_postgres_and_refuses_on_the_valkey_ports() { + let Some(database) = testing::start("the durable boot arm").await else { + return; + }; + let root = tempfile::tempdir().expect("a scratch directory"); + let config = config_for(root.path(), database.url()); + let error = assemble(&config).await.expect_err("it refuses"); + assert!( + matches!( + error, + BootError::AdapterUnavailable { + key: "VALKEY_URL", + .. + } + ), + "{error:?}" + ); + assert!(format!("{error}").contains("#403"), "{error}"); + } + + /// A database that has not been migrated refuses, and names the command that fixes it. + /// + /// The whole reason `serve` reads `seaql_migrations` rather than running + /// `Migrator::up`: the alternative is a rolling deploy in which every replica races to + /// apply the same schema change. + #[tokio::test] + async fn an_unmigrated_database_refuses_and_names_the_migration_command() { + let Some(database) = testing::start("an unmigrated durable boot").await else { + return; + }; + database.roll_back().await; + let root = tempfile::tempdir().expect("a scratch directory"); + let config = config_for(root.path(), database.url()); + let error = assemble(&config).await.expect_err("it refuses"); + assert!(matches!(error, BootError::Database { .. }), "{error:?}"); + let rendered = format!("{error}"); + assert!( + rendered.contains("capsule-server-migration up"), + "the refusal must name the command that fixes it, got {rendered}" + ); + assert!( + !rendered.contains(database.url()), + "a startup error must never carry the connection URL: {rendered}" + ); + } + + /// The four Postgres adapters compose out of exactly what the boot path has. + /// + /// Asserted here rather than by constructing them in `assemble` and throwing them away: + /// production code that builds something it cannot use is theatre, and what #403 needs + /// to know is that its one hunk will type-check. Every constructor takes the shared + /// connection plus values `Config` already carries. + #[tokio::test] + async fn every_postgres_adapter_composes_from_the_boot_configuration() { + let Some(database) = testing::start("the durable adapter set").await else { + return; + }; + let root = tempfile::tempdir().expect("a scratch directory"); + let config = config_for(root.path(), database.url()); + let connection = crate::postgres::connect(&config.database_url.clone().expect("set")) + .await + .expect("the pool opens"); + let credentials = Credentials::new().expect("the platform hashes"); + let clock = Arc::new(SystemClock); + + let index: Arc = + Arc::new(PostgresAssetIndex::new(connection.clone())); + let accounts = Arc::new(PostgresAccounts::new( + connection.clone(), + credentials, + clock, + config.lockout_attempts, + config.lockout_window, + )); + let cohorts: Arc = + Arc::new(PostgresCohorts::new(connection.clone())); + let quotas: Arc = + Arc::new(PostgresQuota::new(connection)); + + // Each one answers through its port, which is what makes this a boot check rather + // than a compile check: the schema the migration applied is the schema the adapters + // query. + let owner = crate::store::OwnerId::new("boot-probe-owner"); + assert_eq!(index.head_seq(&owner).await.expect("the index answers"), 0); + let user = crate::store::UserId::new("boot-probe-user"); + assert!( + crate::auth::AccountProfiles::read(accounts.as_ref(), &user) + .await + .expect("the account store answers") + .is_none() + ); + assert!( + cohorts + .cohorts_for_user(&user) + .await + .expect("the cohort map answers") + .is_empty() + ); + assert_eq!( + quotas.usage(&user).await.expect("the ledger answers").used, + 0 + ); + } } #[tokio::test] diff --git a/capsule-server/src/postgres/testing.rs b/capsule-server/src/postgres/testing.rs index ef148a58..2e8d9d76 100644 --- a/capsule-server/src/postgres/testing.rs +++ b/capsule-server/src/postgres/testing.rs @@ -84,6 +84,7 @@ const USERNS_MODE: &str = "CAPSULE_TEST_CONTAINER_USERNS"; pub(crate) struct TestDatabase { _container: ContainerAsync, connection: DatabaseConnection, + url: String, } impl TestDatabase { @@ -91,6 +92,27 @@ impl TestDatabase { pub(crate) fn connection(&self) -> &DatabaseConnection { &self.connection } + + /// The connection string, for a case that has to drive a boot path rather than an adapter. + pub(crate) fn url(&self) -> &str { + &self.url + } + + /// Undo every migration, leaving a reachable database with no schema. + /// + /// What `boot`'s unmigrated case needs: a database that answers and has nothing in it is the + /// state a deployment is in between `docker compose up` and the migration command, and it is + /// exactly the state `assert_schema_current` exists to refuse. + /// + /// # Panics + /// + /// Panics if the rollback fails; a harness that cannot reach the state the case is about has + /// nothing useful to report. + pub(crate) async fn roll_back(&self) { + server_migration::Migrator::down(&self.connection, None) + .await + .unwrap_or_else(|error| panic!("the schema could not be rolled back: {error}")); + } } /// Whether the container-backed cases are admitted. @@ -148,5 +170,6 @@ pub(crate) async fn start(case: &str) -> Option { Some(TestDatabase { _container: container, connection, + url, }) } diff --git a/capsule-server/src/quota/tests.rs b/capsule-server/src/quota/tests.rs index 25521b33..35d918c0 100644 --- a/capsule-server/src/quota/tests.rs +++ b/capsule-server/src/quota/tests.rs @@ -23,15 +23,6 @@ fn day(days: i64) -> Timestamp { Timestamp::UNIX_EPOCH + SignedDuration::from_hours(days * 24) } -fn user() -> UserId { - UserId::new("01937b7c-0000-7000-8000-000000000001") -} - -fn address(seed: u8) -> ContentAddress { - ContentAddress::parse(&capsule_core::crypto::hash::hash_bytes(&[seed; 8]).to_hex()) - .expect("a content address") -} - #[test] fn the_states_follow_the_thresholds() { let now = day(0); From 8718f2706ac7c7e7054135c393e66cecf38e998b Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 02:37:21 -0400 Subject: [PATCH 098/243] test(server): assert E2E case 11's crash boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finalization's order is the contract and it is the way round it is *because* of this case: the blob is committed onto its content address — a rename and an fsync, irreversible — and only then recorded against its asset. A crash in that window leaves a blob nothing references, which is the safe half of the trade; the other order produces a dangling reference the feed would serve and the scrub would report as an integrity error that is never auto-repaired. Until now that argument was a paragraph in `upload/finalize.rs` with nothing exercising it. The seam is the `AssetIndex` port itself, so no production code gains a test hook. `tests/support/fault.rs` wraps the index the fixture already builds and loses exactly one `record_blob` — a crash is a single transaction that never commits, not a database that stopped answering, and a fault that fired forever would be testing `SwitchableIndex`'s case instead. It is in the chain for every fixture rather than swapped in by a second constructor, disarmed, delegating. The case asserts the state a recovering operator would look at, in that order: the session is terminal and failed rather than claimed forever; the bytes are at their content address, because custody was taken before the window; the asset row is still `Pending` with no sequence number, so there is no zombie visible row; nothing references the blob — `find_reference` is `None` and `reference_count` is 0, which is the property the ordering exists to guarantee; the collector marks it and reports no dangling reference, so the orphan is reclaimable rather than permanent; and the client's retry publishes, because `BlobStore::commit` is idempotent on identical ciphertext and the second transfer lands on the occupied address. The fault's fire count is asserted before anything else. A fault that never fired would leave every assertion after it describing an ordinary successful upload, which is the way this kind of test rots. A *process*-level restart — a real kill, and a second process over the same blob root and database — belongs to the binary-smoke tier and is filed with the remaining durable adapters (#446). Refs #402 --- capsule-server/tests/support/fault.rs | 169 +++++++++++++++++++++++ capsule-server/tests/support/mod.rs | 26 +++- capsule-server/tests/upload.rs | 192 ++++++++++++++++++++++++++ 3 files changed, 383 insertions(+), 4 deletions(-) create mode 100644 capsule-server/tests/support/fault.rs diff --git a/capsule-server/tests/support/fault.rs b/capsule-server/tests/support/fault.rs new file mode 100644 index 00000000..08c25d00 --- /dev/null +++ b/capsule-server/tests/support/fault.rs @@ -0,0 +1,169 @@ +//! The crash-injection seam E2E case 11 needs. +//! +//! # Why a decorator and not a hook in the server +//! +//! Finalization's order is the contract (`upload/finalize.rs`): the blob is committed onto its +//! content address — a rename and an fsync, irreversible — and only then is it recorded against +//! its asset. The window between those two steps is what case 11 is about, and the seam that +//! reaches into it is the [`AssetIndex`] **port itself**. No production code gains a test hook; +//! this wraps the index the fixture already builds and fails one call. +//! +//! # Why it fails exactly once +//! +//! A decorator that failed forever would test an index that is permanently down, which the suite +//! already covers through `SwitchableIndex`. What case 11 is about is a crash — a single +//! transaction that never commits, followed by a server that comes back. So the fault arms, +//! fires once, and disarms itself, which lets the same case drive the retry that recovers. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + +use capsule_server::blob::ContentAddress; +use capsule_server::index::{ + AssetIndex, AssetRow, BlobOutcome, BlobRecord, BlobReference, FeedEntry, HoldOutcome, + IndexFuture, LifecycleOp, OpOutcome, PendingAsset, Reservation, ServingHold, +}; +use capsule_server::store::{AlbumId, AssetId, OwnerId, StoreError}; +use jiff::Timestamp; + +/// An [`AssetIndex`] that loses one `record_blob` transaction, then behaves. +/// +/// Every other operation delegates unconditionally: the crash this models is a process that died +/// with one transaction open, not a database that stopped answering. +#[derive(Debug)] +pub(crate) struct CrashBeforeCommit { + inner: Arc, + armed: AtomicBool, + fired: AtomicUsize, +} + +impl CrashBeforeCommit { + /// A disarmed decorator over `inner`. + pub(crate) fn new(inner: Arc) -> Self { + Self { + inner, + armed: AtomicBool::new(false), + fired: AtomicUsize::new(0), + } + } + + /// Lose the next `record_blob`. + pub(crate) fn arm(&self) { + self.armed.store(true, Ordering::SeqCst); + } + + /// How many times the fault has fired. + /// + /// Asserted by the case rather than assumed: a fault that never fired would leave every + /// assertion after it describing an ordinary successful upload. + pub(crate) fn fired(&self) -> usize { + self.fired.load(Ordering::SeqCst) + } + + /// Whether this call is the one that is lost. + fn takes_the_fault(&self) -> bool { + if self.armed.swap(false, Ordering::SeqCst) { + self.fired.fetch_add(1, Ordering::SeqCst); + return true; + } + false + } +} + +impl AssetIndex for CrashBeforeCommit { + fn record_blob<'a>( + &'a self, + asset: &'a AssetId, + blob: BlobRecord, + ) -> IndexFuture<'a, BlobOutcome> { + if self.takes_the_fault() { + // The transaction never commits. `Unavailable` is the honest shape: the index did + // not answer, so the caller cannot know whether the row was written — which is + // exactly the state a crashed process leaves behind. + return Box::pin(async { + Err(StoreError::Unavailable { + store: "asset-index", + detail: "the server died before the index transaction committed".to_owned(), + }) + }); + } + self.inner.record_blob(asset, blob) + } + + fn reserve(&self, asset: PendingAsset) -> IndexFuture<'_, Reservation> { + self.inner.reserve(asset) + } + + fn read<'a>(&'a self, asset: &'a AssetId) -> IndexFuture<'a, Option> { + self.inner.read(asset) + } + + fn tombstone<'a>( + &'a self, + asset: &'a AssetId, + at: Timestamp, + ) -> IndexFuture<'a, Option> { + self.inner.tombstone(asset, at) + } + + fn find_by_address<'a>( + &'a self, + owner: &'a OwnerId, + album: &'a AlbumId, + address: &'a ContentAddress, + ) -> IndexFuture<'a, Option> { + self.inner.find_by_address(owner, album, address) + } + + fn find_reference<'a>( + &'a self, + address: &'a ContentAddress, + ) -> IndexFuture<'a, Option> { + self.inner.find_reference(address) + } + + fn apply_op(&self, op: LifecycleOp) -> IndexFuture<'_, OpOutcome> { + self.inner.apply_op(op) + } + + fn set_hold<'a>( + &'a self, + asset: &'a AssetId, + hold: Option, + ) -> IndexFuture<'a, HoldOutcome> { + self.inner.set_hold(asset, hold) + } + + fn reference_count<'a>(&'a self, address: &'a ContentAddress) -> IndexFuture<'a, u64> { + self.inner.reference_count(address) + } + + fn rows<'a>( + &'a self, + after: Option<&'a AssetId>, + limit: usize, + ) -> IndexFuture<'a, Vec> { + self.inner.rows(after, limit) + } + + fn tombstoned(&self, limit: usize) -> IndexFuture<'_, Vec> { + self.inner.tombstoned(limit) + } + + fn purge<'a>(&'a self, asset: &'a AssetId) -> IndexFuture<'a, Option> { + self.inner.purge(asset) + } + + fn feed_page<'a>( + &'a self, + owner: &'a OwnerId, + after: u64, + limit: usize, + ) -> IndexFuture<'a, Vec> { + self.inner.feed_page(owner, after, limit) + } + + fn head_seq<'a>(&'a self, owner: &'a OwnerId) -> IndexFuture<'a, u64> { + self.inner.head_seq(owner) + } +} diff --git a/capsule-server/tests/support/mod.rs b/capsule-server/tests/support/mod.rs index d5aba864..64a2b0d4 100644 --- a/capsule-server/tests/support/mod.rs +++ b/capsule-server/tests/support/mod.rs @@ -23,6 +23,8 @@ reason = "each test binary uses a different part of the fixture" )] +pub(crate) mod fault; + use std::collections::BTreeMap; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; @@ -2275,6 +2277,12 @@ pub(crate) struct Fixture { pub(crate) authority: Arc, /// The durable asset index the feed reads from. pub(crate) index: Arc, + /// The crash seam wrapped around it, disarmed unless a case arms it (E2E case 11). + /// + /// Always in the chain rather than swapped in by a second constructor: a decorator that only + /// some fixtures carried would be a second wiring for the tests that carry it, and this one + /// delegates every call it is not armed for. + pub(crate) index_fault: Arc, /// The cursor codec the server mints with — the *same* one, so a test can mint a cursor /// the server will accept, or one it must not. pub(crate) cursors: Arc, @@ -2346,6 +2354,7 @@ impl Fixture { authority.add_device(&user(), device(), clock.now()); let index = Arc::new(SwitchableIndex::new()); + let index_fault = Arc::new(fault::CrashBeforeCommit::new(index.clone())); let cursors = Arc::new(CursorCodec::new(&CURSOR_KEY)); let directories = Arc::new(SwitchableDirectories::new()); let albums = Arc::new(SwitchableAlbums::new()); @@ -2386,23 +2395,31 @@ impl Fixture { tokens: tokens.clone(), clock: clock.clone(), }), + // Every module reads the index **through** the crash seam, so an armed fault is a + // property of the server rather than of one module's wiring — and a disarmed one is + // a delegating pass-through, which is what every other case sees. upload: UploadContext::new( uploads.clone(), blobs.clone(), - index.clone(), + index_fault.clone(), authority.clone(), clock.clone(), UploadPolicy::default(), ), - sync: SyncContext::new(index.clone(), blobs.clone(), cursors.clone()), + sync: SyncContext::new(index_fault.clone(), blobs.clone(), cursors.clone()), serve: ServeContext::new( - index.clone(), + index_fault.clone(), blobs.clone(), marks.clone(), uploads.clone(), capsule_server::serve::owned_assets(), ), - verify: VerifyContext::new(index.clone(), blobs.clone(), marks.clone(), clock.clone()), + verify: VerifyContext::new( + index_fault.clone(), + blobs.clone(), + marks.clone(), + clock.clone(), + ), directories: DeviceDirectoryContext::new(directories.clone(), clock.clone()), albums: AlbumContext::new(albums.clone(), clock.clone()), quota: QuotaContext::new(quotas.clone(), clock.clone(), quota_limits), @@ -2443,6 +2460,7 @@ impl Fixture { blobs, authority, index, + index_fault, cursors, directories, albums, diff --git a/capsule-server/tests/upload.rs b/capsule-server/tests/upload.rs index 3eb2211d..2d43cbe2 100644 --- a/capsule-server/tests/upload.rs +++ b/capsule-server/tests/upload.rs @@ -1079,3 +1079,195 @@ async fn an_authority_that_cannot_answer_refuses_rather_than_assuming() { "error.upload.unavailable" ); } + +// =========================================================================================== +// The crash boundary +// =========================================================================================== + +/// **E2E case 11.** A crash between the blob rename and the index commit leaves no dangling +/// reference, and the retry recovers. +/// +/// The order finalization runs in is the contract, and it is the way round it is *because* of +/// this case (`upload/finalize.rs`): the blob is committed onto its content address — a rename +/// and an fsync, irreversible — and only then is it recorded against its asset. A crash in that +/// window leaves a blob nothing references, which is the **safe** half of the trade: an orphan +/// is what refcount GC exists to collect, while an asset row naming a blob the store does not +/// hold is a dangling reference the feed would serve and the scrub would report as an integrity +/// error that is never auto-repaired. +/// +/// What is asserted, in the order a recovering operator would look at it: +/// +/// 1. the session is terminal and **failed**, not left claimed forever; +/// 2. the bytes are at their content address — custody was taken, and telling the client +/// otherwise would be a lie about something the server holds; +/// 3. the asset row is still `Pending` and holds no sequence number, so nothing was published +/// and there is no zombie visible row; +/// 4. **nothing references the blob** — `find_reference` is `None` and `reference_count` is 0 — +/// which is the property the whole ordering exists to guarantee; +/// 5. the collector marks it, so the orphan is reclaimable rather than permanent; +/// 6. and the retry publishes, because `BlobStore::commit` is idempotent on identical ciphertext. +/// +/// The crash is injected through the `AssetIndex` port itself (`support::fault`), so no +/// production code carries a test hook. A *process*-level restart — a real kill and a second +/// process over the same blob root and database — belongs to the binary-smoke tier and is filed +/// with the remaining durable adapters. +#[tokio::test] +async fn finalization_crash_between_rename_and_commit_leaves_no_dangling_reference() { + use capsule_server::blob::ContentAddress; + use capsule_server::gc::{CollectionContext, Mode, collect}; + use capsule_server::index::{AssetIndex, AssetState}; + use capsule_server::store::{AssetId, OwnerId}; + + let fixture = Fixture::working(); + let bearer = fixture.bearer().await; + let (first, second, whole) = blob(); + let id = fixture.open_session(&whole, "original", &bearer).await; + let address = ContentAddress::parse(&checksum(&whole)).expect("the digest is an address"); + // The asset the suite's manifests name; the session reserved its row when it opened. + let asset = AssetId::new("018f3f1e-4b7a-7c9d-8e2f-1a2b3c4d5e61"); + + fixture + .chunk(&id, 0, &first, &bearer) + .send() + .await + .assert_status(StatusCode::NO_CONTENT); + + // The last chunk completes the declared size, so this request is the one that finalizes. + fixture.index_fault.arm(); + let crashed = fixture.chunk(&id, 4096, &second, &bearer).send().await; + crashed.assert_status(StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!( + fixture.index_fault.fired(), + 1, + "the fault never fired, so everything below is describing an ordinary upload" + ); + + // 1. Terminal and failed. A claimed session that is never driven anywhere is the state the + // finalization state machine exists to make unreachable. + let record = fixture + .uploads + .read_for_test(&id) + .await + .expect("the session survives as a receipt"); + assert_eq!(record.status.as_str(), "failed_processing"); + + // 2. Custody was taken: the rename happened before the index call that was lost. + assert_eq!( + fixture.blobs.blob_for_test(&checksum(&whole)).await, + Some(whole.clone()), + "the bytes committed before the crash window are the bytes the server holds" + ); + + // 3. No zombie row: the asset is exactly where it was before the transfer. + let row = fixture + .index + .read(&asset) + .await + .expect("the index answers") + .expect("the session reserved a row when it opened"); + assert_eq!(row.state, AssetState::Pending); + assert_eq!(row.sync_seq, None, "a lost transaction published nothing"); + assert!(row.blobs.is_empty(), "and recorded no blob"); + + // 4. The property the ordering exists for. A dangling reference is the failure mode the + // other order would produce, and it is the one nothing can repair automatically. + assert_eq!( + fixture + .index + .find_reference(&address) + .await + .expect("the index answers"), + None, + "a blob nothing references must not be reachable through the serving path" + ); + assert_eq!( + fixture + .index + .reference_count(&address) + .await + .expect("the index answers"), + 0, + ); + assert!( + fixture + .index + .feed_page(&OwnerId::new(owner().as_str()), 0, 10) + .await + .expect("the index answers") + .is_empty(), + "nothing was published, so nothing reaches a client's feed" + ); + + // 5. The orphan is reclaimable. The collector marks a zero-reference blob on one pass and + // sweeps it on a later one once the grace window has passed, so a mark is the whole of + // what a first pass should do — and it is what makes "an orphan GC collects" true rather + // than a hope. + let collection = CollectionContext::new( + fixture.index.clone(), + fixture.blobs.clone(), + fixture.marks.clone(), + fixture.quotas.clone(), + fixture.clock.clone(), + capsule_server::gc::DEFAULT_GRACE_WINDOW, + ); + let report = collect(&collection, Mode::Apply) + .await + .expect("a collection pass runs"); + assert!( + report.marked.contains(&address), + "the crashed upload's blob must be collectable, got {report:?}" + ); + assert!( + report.dangling.is_empty(), + "a crash in this window must never produce a dangling reference: {report:?}" + ); + + // 6. And the client retries. `BlobStore::commit` is idempotent on identical ciphertext, so + // the second transfer lands on the occupied address and the asset finally publishes. + let retry = fixture.open_session(&whole, "original", &bearer).await; + fixture + .chunk(&retry, 0, &first, &bearer) + .send() + .await + .assert_status(StatusCode::NO_CONTENT); + fixture + .chunk(&retry, 4096, &second, &bearer) + .send() + .await + .assert_status(StatusCode::NO_CONTENT); + assert_eq!( + fixture + .uploads + .read_for_test(&retry) + .await + .expect("the retry's session survives") + .status + .as_str(), + "completed", + ); + let recovered = fixture + .index + .read(&asset) + .await + .expect("the index answers") + .expect("the row is still there"); + assert_eq!( + recovered.address_for(capsule_server::store::BlobRole::Original), + Some(&address), + "the retry recorded the blob the crash lost", + ); + assert_eq!( + fixture + .index + .reference_count(&address) + .await + .expect("the index answers"), + 1, + "and the orphan is an orphan no longer", + ); + assert_eq!( + fixture.blobs.blob_for_test(&checksum(&whole)).await, + Some(whole), + "the bytes are unchanged: an identical ciphertext is one object", + ); +} From 391a5fe5f42f6d9be8bc1642ac1a47e2ed74480f Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 02:39:08 -0400 Subject: [PATCH 099/243] feat(cli): add `capsule show`, the signed-sidecar read surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing in the CLI printed what the importer wrote into an asset's signed sidecar, so a user could not verify the Takeout enrichment (S-B10) that `--provider takeout` exists to deliver, and the migration guide had to say so instead of instructing the check (S-B18). - `capsule show --library ` resolves an asset id or a hex prefix (>= 8 chars) of the content hash — the SHA-256 a user already has from the guide's spot-hash step — and prints the sidecar projection: album, content type, hash, dimensions, capture and import instants, caption, rating, user and AI tags, the fix with its datum and source, cull flag, hidden, stack placement, LQIP presence, and the provenance record count. Every absent value is spelled out as unset. - An ambiguous prefix is refused with the match count; a 32-hex-digit prefix that parses as a bare UUID still reaches the prefix arm. - Every line is a `cli.show.*` catalog key (40 keys), including the list separator and the datum names, so a GCJ-02 fix stored verbatim is never read as WGS-84. - Smoke tests spawn the binary over a synthesized EXIF JPEG and over the Takeout fixture; the guide's metadata-sampling step is now executable and is asserted as written. `cli-surface.json` gains the verb; catalogs regenerated with `mise run i18n`. --- .../src/androidMain/res/values/strings.xml | 42 ++ capsule-cli/cli-surface.json | 38 + capsule-cli/src/cli/commands.rs | 14 + capsule-cli/src/i18n.rs | 41 ++ capsule-cli/src/lib.rs | 25 + capsule-cli/src/show.rs | 690 ++++++++++++++++++ capsule-cli/tests/show_and_repair.rs | 353 +++++++++ capsule-cli/tests/takeout_import.rs | 60 ++ .../docs/guides/google-photos-migration.md | 44 +- capsule-i18n/src/bundles/en.json | 42 ++ capsule-swift/Generated/Localizable.xcstrings | 420 +++++++++++ capsule-web/src/i18n/messages/en.json | 42 ++ locales/en.json | 168 +++++ 13 files changed, 1975 insertions(+), 4 deletions(-) create mode 100644 capsule-cli/src/show.rs create mode 100644 capsule-cli/tests/show_and_repair.rs diff --git a/capsule-android/src/androidMain/res/values/strings.xml b/capsule-android/src/androidMain/res/values/strings.xml index 33577e52..29e1c755 100644 --- a/capsule-android/src/androidMain/res/values/strings.xml +++ b/capsule-android/src/androidMain/res/values/strings.xml @@ -1775,6 +1775,10 @@ Reset data directory A command line interface for Capsule - the photo management platform Capsule CLI provides tools for managing your photos and albums:\n\n- Authentication management\n- Sync local and remote data\n- Check status and list files\n- Manage albums and collections + Show what an imported asset\'s signed sidecar records: caption, rating, tags, GPS, timestamps + The asset to show: its id, or a prefix of at least 8 hex characters of its SHA-256 content hash — the same digest `shasum -a 256` prints for the source file + Path to the Capsule library + Read the library passphrase from stdin instead of prompting, so the command works in scripts and CI where there is no terminal Show current status Sync local and remote data Perform a dry run without making changes @@ -1808,6 +1812,44 @@ Pushing %1$s asset(s) to %2$s… The library holds no assets to push. Everything in this library is already on the server. + Album: %s + %1$s matches %2$s assets; give more of the hash, or the full asset id. + Caption: %s + Captured: %s + Content type: %s + Cull flag: %s + Dimensions: %s + Show failed: %s + GPS: %s + GCJ-02 + WGS-84 + derived + EXIF + manual + SHA-256: %s + Asset %s + Hidden: %s + Imported: %s + %1$s is neither an asset id nor a hex prefix of at least %2$s characters of a content hash. + Placeholder: %s + Provenance: %s signed record(s) + Rating: %s + Stack: %s + member + primary + proxy + AI tags: %s + User tags: %s + No asset in this library matches %s. + %1$s×%2$s + %1$s, %2$s (%3$s, %4$s) + , + no + present + %s/5 + %1$s (%2$s) + (unset) + yes Sync complete: applied %1$s change(s) across %2$s album(s) in %3$s page(s). Dry run: no changes will be persisted. Sync failed: %s diff --git a/capsule-cli/cli-surface.json b/capsule-cli/cli-surface.json index 2a959b2e..e8065b37 100644 --- a/capsule-cli/cli-surface.json +++ b/capsule-cli/cli-surface.json @@ -507,6 +507,44 @@ ], "name": "reset" }, + { + "about": "Show what an imported asset's signed sidecar records: caption, rating, tags, GPS, timestamps", + "args": [ + { + "help": "The asset to show: its id, or a prefix of at least 8 hex characters of its SHA-256 content hash — the same digest `shasum -a 256` prints for the source file", + "id": "asset", + "positional": true, + "repeatable": false, + "required": true, + "takes_value": true, + "value_names": [ + "ASSET" + ] + }, + { + "help": "Path to the Capsule library", + "id": "library", + "long": "library", + "positional": false, + "repeatable": false, + "required": true, + "takes_value": true, + "value_names": [ + "PATH" + ] + }, + { + "help": "Read the library passphrase from stdin instead of prompting, so the command works in scripts and CI where there is no terminal", + "id": "passphrase_stdin", + "long": "passphrase-stdin", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + } + ], + "name": "show" + }, { "about": "Show current status", "name": "status" diff --git a/capsule-cli/src/cli/commands.rs b/capsule-cli/src/cli/commands.rs index e499fd84..8b054e77 100644 --- a/capsule-cli/src/cli/commands.rs +++ b/capsule-cli/src/cli/commands.rs @@ -127,6 +127,20 @@ pub(crate) enum Commands { #[arg(long, value_name = "DAYS", default_value_t = crate::cull::DEFAULT_RETAIN_DAYS)] retain_days: i64, }, + /// Show what an imported asset's signed sidecar records: caption, rating, tags, GPS, timestamps + Show { + /// The asset to show: its id, or a prefix of at least 8 hex characters of its SHA-256 + /// content hash — the same digest `shasum -a 256` prints for the source file + #[arg(value_name = "ASSET")] + asset: String, + /// Path to the Capsule library + #[arg(long, value_name = "PATH")] + library: PathBuf, + /// Read the library passphrase from stdin instead of prompting, so the command + /// works in scripts and CI where there is no terminal. + #[arg(long)] + passphrase_stdin: bool, + }, /// Manage the local library Library { #[command(subcommand)] diff --git a/capsule-cli/src/i18n.rs b/capsule-cli/src/i18n.rs index 795cf402..276d289c 100644 --- a/capsule-cli/src/i18n.rs +++ b/capsule-cli/src/i18n.rs @@ -104,4 +104,45 @@ pub mod keys { pub const CULL_FLAG_PICK: &str = "cli.cull.flag.pick"; pub const CULL_FLAG_NEUTRAL: &str = "cli.cull.flag.neutral"; pub const CULL_FLAG_REJECT: &str = "cli.cull.flag.reject"; + // `capsule show` — the signed-sidecar read surface (slice `S-B18`). One row key per + // field, each taking the rendered `{value}`; the `value.*` keys spell the composite and + // absent values that go into a row. + pub const SHOW_HEADER: &str = "cli.show.header"; + pub const SHOW_ALBUM: &str = "cli.show.album"; + pub const SHOW_CONTENT_TYPE: &str = "cli.show.content_type"; + pub const SHOW_HASH: &str = "cli.show.hash"; + pub const SHOW_DIMENSIONS: &str = "cli.show.dimensions"; + pub const SHOW_CAPTURED: &str = "cli.show.captured"; + pub const SHOW_IMPORTED: &str = "cli.show.imported"; + pub const SHOW_CAPTION: &str = "cli.show.caption"; + pub const SHOW_RATING: &str = "cli.show.rating"; + pub const SHOW_TAGS_USER: &str = "cli.show.tags_user"; + pub const SHOW_TAGS_AI: &str = "cli.show.tags_ai"; + pub const SHOW_GPS: &str = "cli.show.gps"; + pub const SHOW_CULL: &str = "cli.show.cull"; + pub const SHOW_HIDDEN: &str = "cli.show.hidden"; + pub const SHOW_STACK: &str = "cli.show.stack"; + pub const SHOW_LQIP: &str = "cli.show.lqip"; + pub const SHOW_PROVENANCE_RECORDS: &str = "cli.show.provenance_records"; + pub const SHOW_VALUE_UNSET: &str = "cli.show.value.unset"; + pub const SHOW_VALUE_YES: &str = "cli.show.value.yes"; + pub const SHOW_VALUE_NO: &str = "cli.show.value.no"; + pub const SHOW_VALUE_PRESENT: &str = "cli.show.value.present"; + pub const SHOW_VALUE_LIST_SEPARATOR: &str = "cli.show.value.list_separator"; + pub const SHOW_GPS_DATUM_WGS84: &str = "cli.show.gps_datum.wgs84"; + pub const SHOW_GPS_DATUM_GCJ02: &str = "cli.show.gps_datum.gcj02"; + pub const SHOW_VALUE_DIMENSIONS: &str = "cli.show.value.dimensions"; + pub const SHOW_VALUE_RATING: &str = "cli.show.value.rating"; + pub const SHOW_VALUE_GPS: &str = "cli.show.value.gps"; + pub const SHOW_VALUE_STACK: &str = "cli.show.value.stack"; + pub const SHOW_GPS_SOURCE_EXIF: &str = "cli.show.gps_source.exif"; + pub const SHOW_GPS_SOURCE_MANUAL: &str = "cli.show.gps_source.manual"; + pub const SHOW_GPS_SOURCE_DERIVED: &str = "cli.show.gps_source.derived"; + pub const SHOW_STACK_ROLE_PRIMARY: &str = "cli.show.stack_role.primary"; + pub const SHOW_STACK_ROLE_MEMBER: &str = "cli.show.stack_role.member"; + pub const SHOW_STACK_ROLE_PROXY: &str = "cli.show.stack_role.proxy"; + pub const SHOW_UNKNOWN_ASSET: &str = "cli.show.unknown_asset"; + pub const SHOW_AMBIGUOUS: &str = "cli.show.ambiguous"; + pub const SHOW_INVALID_SELECTOR: &str = "cli.show.invalid_selector"; + pub const SHOW_FAILED: &str = "cli.show.failed"; } diff --git a/capsule-cli/src/lib.rs b/capsule-cli/src/lib.rs index ecbd455b..5f7789ab 100644 --- a/capsule-cli/src/lib.rs +++ b/capsule-cli/src/lib.rs @@ -38,6 +38,7 @@ pub mod demo; pub mod i18n; pub mod remote; pub mod session; +pub mod show; pub mod status; pub mod syncstore; pub mod utils; @@ -507,6 +508,30 @@ async fn dispatch(cli: Cli) -> Result<()> { } } + // ── Show ────────────────────────────────────────────────────────── + Commands::Show { + asset, + library, + passphrase_stdin, + } => { + let bundle = i18n::cli_bundle(); + let ws = open_workspace(&library, passphrase_stdin)?; + let state = show::resolve(&ws, &asset).and_then(|id| { + ws.asset(&id) + .ok_or_else(|| show::ShowError::UnknownAsset(id.to_string())) + }); + match state { + Ok(state) => print!("{}", show::render(&bundle, &show::collect(state))), + Err(error) => { + let reason = show::describe_error(&bundle, &error); + return Err(eyre!( + "{}", + bundle.format(keys::SHOW_FAILED, &[("reason", Value::Str(&reason))]) + )); + } + } + } + // ── Demo ────────────────────────────────────────────────────────── Commands::Demo { workdir, image } => { demo::run(workdir, image)?; diff --git a/capsule-cli/src/show.rs b/capsule-cli/src/show.rs new file mode 100644 index 00000000..6f113890 --- /dev/null +++ b/capsule-cli/src/show.rs @@ -0,0 +1,690 @@ +//! `capsule show` — what an imported asset's signed sidecar actually records (slice `S-B18`). +//! +//! The importer folds a third-party export's caption, favourite, album membership, capture +//! time and GPS into each asset's **signed** sidecar (`S-B10`), and every one of those mappings +//! is lossy by design: a favourite becomes five stars, an album becomes a tag. Nothing in the +//! CLI printed the result back, so a user could not check a mapping they might disagree with +//! until correcting it cost a signed `metadata-update` per asset — and the migration guide had +//! to say so instead of instructing the check. This verb is that check. +//! +//! It reads the sidecar and the provenance chain off [`Workspace::asset`] and writes nothing of +//! its own; the only write on its path is the default-album resolution every library verb +//! shares when it opens the workspace. No index is queried. The shape mirrors `cull.rs` — a +//! selector → [`resolve`] → [`collect`] into a plain [`AssetView`] → [`render`] through the +//! catalog — so the projection and the rendering are unit-testable without a `clap` round trip +//! or a spawned binary. +//! +//! ## Naming an asset +//! +//! Nothing `capsule import` prints is an asset id, but a user following the migration guide +//! already holds the **source hashes** from its spot-hash step, and Capsule imports bytes +//! unchanged — so the sidecar's `hash` is the source file's SHA-256. The positional therefore +//! accepts either an asset id or a hex prefix of that hash (at least [`MIN_HASH_PREFIX`] +//! characters); a prefix matching several assets is refused with the count rather than +//! guessed at. There is no source-filename arm because the sidecar stores no filename. +//! +//! Every absent field is printed as *unset* rather than omitted: an absent caption is a fact +//! the user came to verify, not a row to hide. A fix is printed with its datum, because a +//! GCJ-02 coordinate is stored verbatim and would otherwise read as WGS-84. + +use capsule_core::domain::GpsDatum; +use capsule_core::lifecycle::{AssetState, Workspace}; +use capsule_core::sidecar::{CullFlag, GpsSource, StackRole}; +use capsule_i18n::Bundle; +use colored::Colorize as _; +use thiserror::Error; +use uuid::Uuid; + +use crate::i18n::{Value, keys}; + +/// The shortest hash prefix the selector accepts. Eight hex digits is 32 bits — ample against +/// a personal library, and short enough to type from a `shasum` listing. +pub const MIN_HASH_PREFIX: usize = 8; + +/// Why a selector did not resolve to exactly one asset. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ShowError { + /// Neither an asset id nor a hash prefix matched anything in the library. + #[error("no asset matches {0}")] + UnknownAsset(String), + /// A hash prefix matched more than one asset. Refused rather than guessed: printing the + /// wrong asset's metadata under a selector the user believes is unique would defeat the + /// verification this command exists for. + #[error("{selector} matches {count} assets")] + Ambiguous { + /// The prefix as given. + selector: String, + /// How many assets share it. + count: usize, + }, + /// The selector is neither a UUID nor a long-enough hex prefix. + #[error("{0} is neither an asset id nor a hash prefix")] + InvalidSelector(String), +} + +/// A location fix as the sidecar stores it: coordinates in their own datum, plus where the +/// fix came from. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Fix { + /// Latitude, in `datum`. + pub lat: f64, + /// Longitude, in `datum`. + pub lon: f64, + /// Provenance of the fix. + pub source: GpsSource, + /// The datum the coordinates are expressed in — stored verbatim, never converted. + pub datum: GpsDatum, +} + +/// The sidecar projection `capsule show` prints — a plain value so the rendering can be +/// tested against a hand-built one and the collection against a real [`AssetState`]. +#[derive(Debug, Clone, PartialEq)] +pub struct AssetView { + /// The asset id. + pub asset_id: Uuid, + /// The owning album. + pub album_id: Uuid, + /// The closed content-type string the importer derived from the extension. + pub content_type: String, + /// The plaintext SHA-256, lowercase hex — the same digest `shasum -a 256` prints for the + /// source file. + pub hash: String, + /// Pixel dimensions, when EXIF carried them. + pub dimensions: Option<(u32, u32)>, + /// The signed capture instant, RFC 3339. + pub capture_timestamp: String, + /// The signed import instant, RFC 3339. + pub import_timestamp: String, + /// The caption register's current value. + pub caption: Option, + /// The rating register's current value (0–5). + pub rating: Option, + /// User tags, sorted. + pub tags_user: Vec, + /// AI tag texts, sorted and deduplicated across model versions. + pub tags_ai: Vec, + /// The fix, when any. + pub gps: Option, + /// The culling flag (a never-written register reads as `Neutral`). + pub cull: CullFlag, + /// Whether the asset is hidden from default views. + pub hidden: bool, + /// Stack placement, when the asset is a stack member: `(stack_id, role)`. + pub stack: Option<(Uuid, StackRole)>, + /// Whether a display placeholder (LQIP) is stored. + pub lqip: bool, + /// How many signed records the provenance chain holds — the `create` plus every + /// lifecycle write since (metadata edits, repairs, derivative writes, trash moves). + pub provenance_records: usize, +} + +/// Resolve `selector` — an asset id, or a hex prefix of an asset's content hash — to exactly +/// one asset of `ws`. +#[tracing::instrument(skip(ws))] +pub fn resolve(ws: &Workspace, selector: &str) -> Result { + let candidates = ws + .asset_ids() + .into_iter() + .filter_map(|id| ws.asset(&id).map(|asset| (id, asset.sidecar.hash.to_hex()))); + resolve_among(candidates, selector) +} + +/// [`resolve`] over an explicit `(asset id, content hash hex)` listing, so the arms are +/// testable against hand-built candidates. +/// +/// A UUID is tried first; if no candidate carries that id and the text also qualifies as a +/// hash prefix (a 32-hex-digit hash prefix parses as a bare UUID), the prefix arm runs before +/// the id is reported unknown. +pub fn resolve_among( + candidates: impl IntoIterator, + selector: &str, +) -> Result { + let selector = selector.trim(); + let as_id = Uuid::parse_str(selector).ok(); + let prefix = selector.to_ascii_lowercase(); + let as_prefix = + prefix.len() >= MIN_HASH_PREFIX && prefix.bytes().all(|b| b.is_ascii_hexdigit()); + + let mut by_prefix: Vec = Vec::new(); + for (id, hash) in candidates { + if as_id == Some(id) { + tracing::debug!(asset_id = %id, "show: selector resolved as an asset id"); + return Ok(id); + } + if as_prefix && hash.starts_with(&prefix) { + by_prefix.push(id); + } + } + + if !as_prefix { + return Err(if as_id.is_some() { + ShowError::UnknownAsset(selector.to_owned()) + } else { + ShowError::InvalidSelector(selector.to_owned()) + }); + } + tracing::debug!( + prefix = %prefix, + matches = by_prefix.len(), + "show: selector matched as a hash prefix" + ); + match by_prefix.as_slice() { + [] => Err(ShowError::UnknownAsset(selector.to_owned())), + [id] => Ok(*id), + many => Err(ShowError::Ambiguous { + selector: selector.to_owned(), + count: many.len(), + }), + } +} + +/// Project a managed asset's in-memory state onto the view. Reads the signed sidecar and the +/// provenance chain only. +#[must_use] +pub fn collect(asset: &AssetState) -> AssetView { + let sidecar = &asset.sidecar; + let mut tags_user: Vec = sidecar.tags_user.value().into_iter().collect(); + tags_user.sort(); + let mut tags_ai: Vec = sidecar + .tags_ai + .value() + .into_iter() + .map(|tag| tag.tag) + .collect(); + tags_ai.sort(); + tags_ai.dedup(); + + AssetView { + asset_id: asset.asset_id, + album_id: asset.album_id, + content_type: sidecar.content_type.clone(), + hash: sidecar.hash.to_hex(), + dimensions: sidecar.dimensions.as_ref().map(|d| (d.width, d.height)), + capture_timestamp: sidecar.capture_timestamp.clone(), + import_timestamp: sidecar.import_timestamp.clone(), + caption: sidecar.caption.get().cloned(), + rating: sidecar.rating.get().copied(), + tags_user, + tags_ai, + gps: sidecar.gps.as_ref().map(|g| Fix { + lat: g.lat, + lon: g.lon, + source: g.source, + datum: g.datum, + }), + cull: sidecar.cull.get().copied().unwrap_or_default(), + hidden: sidecar.hidden.get().copied().unwrap_or(false), + stack: sidecar + .stack_membership + .get() + .and_then(Option::as_ref) + .map(|m| (m.stack_id, m.role)), + lqip: sidecar.lqip.is_some(), + provenance_records: asset.chain.records().len(), + } +} + +/// Localize a [`ShowError`] for the failure line. +pub fn describe_error(bundle: &Bundle, error: &ShowError) -> String { + match error { + ShowError::UnknownAsset(selector) => bundle.format( + keys::SHOW_UNKNOWN_ASSET, + &[("selector", Value::Str(selector))], + ), + ShowError::Ambiguous { selector, count } => bundle.format( + keys::SHOW_AMBIGUOUS, + &[ + ("selector", Value::Str(selector)), + ("count", Value::Int(*count as i64)), + ], + ), + ShowError::InvalidSelector(selector) => bundle.format( + keys::SHOW_INVALID_SELECTOR, + &[ + ("selector", Value::Str(selector)), + ("min", Value::Int(MIN_HASH_PREFIX as i64)), + ], + ), + } +} + +const fn gps_source_key(source: GpsSource) -> &'static str { + match source { + GpsSource::Exif => keys::SHOW_GPS_SOURCE_EXIF, + GpsSource::Manual => keys::SHOW_GPS_SOURCE_MANUAL, + GpsSource::Derived => keys::SHOW_GPS_SOURCE_DERIVED, + } +} + +const fn gps_datum_key(datum: GpsDatum) -> &'static str { + match datum { + GpsDatum::Wgs84 => keys::SHOW_GPS_DATUM_WGS84, + GpsDatum::Gcj02 => keys::SHOW_GPS_DATUM_GCJ02, + } +} + +const fn stack_role_key(role: StackRole) -> &'static str { + match role { + StackRole::Primary => keys::SHOW_STACK_ROLE_PRIMARY, + StackRole::Member => keys::SHOW_STACK_ROLE_MEMBER, + StackRole::Proxy => keys::SHOW_STACK_ROLE_PROXY, + } +} + +const fn cull_key(flag: CullFlag) -> &'static str { + match flag { + CullFlag::Pick => keys::CULL_FLAG_PICK, + CullFlag::Neutral => keys::CULL_FLAG_NEUTRAL, + CullFlag::Reject => keys::CULL_FLAG_REJECT, + } +} + +/// Render the view as the lines `capsule show` prints, one catalog message per line, every +/// absent value spelled out as unset. +#[must_use] +pub fn render(bundle: &Bundle, view: &AssetView) -> String { + let unset = bundle.format(keys::SHOW_VALUE_UNSET, &[]); + let separator = bundle.format(keys::SHOW_VALUE_LIST_SEPARATOR, &[]); + let yes_no = |flag: bool| { + bundle.format( + if flag { + keys::SHOW_VALUE_YES + } else { + keys::SHOW_VALUE_NO + }, + &[], + ) + }; + let or_unset = |value: Option| value.unwrap_or_else(|| unset.clone()); + let list = |items: &[String]| { + if items.is_empty() { + unset.clone() + } else { + items.join(&separator) + } + }; + + let dimensions = or_unset(view.dimensions.map(|(width, height)| { + bundle.format( + keys::SHOW_VALUE_DIMENSIONS, + &[ + ("width", Value::Int(i64::from(width))), + ("height", Value::Int(i64::from(height))), + ], + ) + })); + let rating = or_unset(view.rating.map(|stars| { + bundle.format( + keys::SHOW_VALUE_RATING, + &[("stars", Value::Int(i64::from(stars)))], + ) + })); + let gps = or_unset(view.gps.map(|fix| { + let source = bundle.format(gps_source_key(fix.source), &[]); + let datum = bundle.format(gps_datum_key(fix.datum), &[]); + bundle.format( + keys::SHOW_VALUE_GPS, + &[ + ("lat", Value::Str(&format!("{:.6}", fix.lat))), + ("lon", Value::Str(&format!("{:.6}", fix.lon))), + ("datum", Value::Str(&datum)), + ("source", Value::Str(&source)), + ], + ) + })); + let stack = or_unset(view.stack.map(|(stack_id, role)| { + let role = bundle.format(stack_role_key(role), &[]); + bundle.format( + keys::SHOW_VALUE_STACK, + &[ + ("stack_id", Value::Str(&stack_id.to_string())), + ("role", Value::Str(&role)), + ], + ) + })); + let lqip = if view.lqip { + bundle.format(keys::SHOW_VALUE_PRESENT, &[]) + } else { + unset.clone() + }; + let cull = bundle.format(cull_key(view.cull), &[]); + + let header = bundle.format( + keys::SHOW_HEADER, + &[("asset_id", Value::Str(&view.asset_id.to_string()))], + ); + let rows: [(&str, String); 16] = [ + (keys::SHOW_ALBUM, view.album_id.to_string()), + (keys::SHOW_CONTENT_TYPE, view.content_type.clone()), + (keys::SHOW_HASH, view.hash.clone()), + (keys::SHOW_DIMENSIONS, dimensions), + (keys::SHOW_CAPTURED, view.capture_timestamp.clone()), + (keys::SHOW_IMPORTED, view.import_timestamp.clone()), + (keys::SHOW_CAPTION, or_unset(view.caption.clone())), + (keys::SHOW_RATING, rating), + (keys::SHOW_TAGS_USER, list(&view.tags_user)), + (keys::SHOW_TAGS_AI, list(&view.tags_ai)), + (keys::SHOW_GPS, gps), + (keys::SHOW_CULL, cull), + (keys::SHOW_HIDDEN, yes_no(view.hidden)), + (keys::SHOW_STACK, stack), + (keys::SHOW_LQIP, lqip), + ( + keys::SHOW_PROVENANCE_RECORDS, + view.provenance_records.to_string(), + ), + ]; + + let mut out = format!("{}\n", header.green()); + for (key, value) in rows { + out.push_str(&bundle.format(key, &[("value", Value::Str(&value))])); + out.push('\n'); + } + out +} + +#[cfg(test)] +mod tests { + use capsule_core::crypto::primitives::Argon2Params; + + use super::*; + + const FAST_KDF: Argon2Params = Argon2Params { + mem_kib: 64, + t_cost: 1, + p_cost: 1, + }; + + /// A fast-cost workspace with `count` imported assets of distinct bytes, in a scratch + /// directory that is removed on drop. + struct Fixture { + dir: std::path::PathBuf, + ws: Workspace, + ids: Vec, + } + + impl Fixture { + fn with_assets(count: usize) -> Self { + let dir = std::env::temp_dir().join(format!("capsule-cli-show-{}", nanoid::nanoid!())); + let lib = dir.join("lib"); + std::fs::create_dir_all(&lib).expect("scratch library dir"); + let mut ws = + Workspace::create_with_params(&lib, b"pw", FAST_KDF).expect("create workspace"); + let album = ws.default_album_id(); + ws.create_album_with_id(album, "Imports") + .expect("create album"); + let ids = (0..count) + .map(|n| { + let src = dir.join(format!("photo-{n}.jpg")); + let mut bytes = b"\xFF\xD8\xFF".to_vec(); + bytes.extend_from_slice(format!(" asset {n}").as_bytes()); + std::fs::write(&src, bytes).expect("fixture file"); + ws.import_asset(album, &src).expect("import") + }) + .collect(); + Self { dir, ws, ids } + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.dir); + } + } + + fn bundle() -> Bundle { + Bundle::for_locale("en") + } + + /// Three candidates whose hashes share a 10-character prefix, so ambiguity is a fact + /// about the listing rather than a lottery over real digests. + fn candidates() -> Vec<(Uuid, String)> { + let ids = [Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()]; + vec![ + (ids[0], format!("0123456789{}", "a".repeat(54))), + (ids[1], format!("0123456789{}", "b".repeat(54))), + (ids[2], format!("fedcba9876{}", "c".repeat(54))), + ] + } + + #[test] + fn an_asset_id_resolves_to_itself_in_any_spelling() { + let list = candidates(); + let id = list[1].0; + assert_eq!(resolve_among(list.clone(), &id.to_string()), Ok(id)); + assert_eq!( + resolve_among(list.clone(), &format!(" {} ", id.simple())), + Ok(id), + "the simple (hyphen-less) spelling and surrounding whitespace are tolerated" + ); + assert_eq!( + resolve_among(list, &id.to_string().to_ascii_uppercase()), + Ok(id) + ); + } + + #[test] + fn a_hash_prefix_resolves_to_the_one_candidate_carrying_it() { + let list = candidates(); + assert_eq!(resolve_among(list.clone(), "0123456789a"), Ok(list[0].0)); + assert_eq!( + resolve_among(list.clone(), "0123456789B"), + Ok(list[1].0), + "case-folded" + ); + assert_eq!(resolve_among(list.clone(), "fedcba98"), Ok(list[2].0)); + let (expected, full) = list[2].clone(); + assert_eq!(resolve_among(list, &full), Ok(expected), "the whole hash"); + } + + #[test] + fn an_ambiguous_prefix_is_refused_with_the_count() { + let list = candidates(); + assert_eq!( + resolve_among(list, "01234567"), + Err(ShowError::Ambiguous { + selector: "01234567".into(), + count: 2 + }) + ); + } + + #[test] + fn a_short_or_non_hex_selector_is_invalid_and_a_missing_one_is_unknown() { + let list = candidates(); + assert_eq!( + resolve_among(list.clone(), "abc"), + Err(ShowError::InvalidSelector("abc".into())) + ); + assert_eq!( + resolve_among(list.clone(), "not-a-hash-or-id"), + Err(ShowError::InvalidSelector("not-a-hash-or-id".into())) + ); + assert_eq!( + resolve_among(list.clone(), "deadbeef00"), + Err(ShowError::UnknownAsset("deadbeef00".into())) + ); + let ghost = Uuid::now_v7(); + assert_eq!( + resolve_among(list, &ghost.to_string()), + Err(ShowError::UnknownAsset(ghost.to_string())) + ); + } + + /// A 32-hex-digit hash prefix parses as a bare UUID; it must still reach the prefix arm. + #[test] + fn a_thirty_two_digit_hash_prefix_is_not_mistaken_for_an_unknown_id() { + let list = candidates(); + let prefix = &list[2].1[..32]; + assert!( + Uuid::parse_str(prefix).is_ok(), + "the premise: it parses as a UUID" + ); + assert_eq!(resolve_among(list.clone(), prefix), Ok(list[2].0)); + } + + #[test] + fn resolve_reads_the_workspaces_sidecar_hashes() { + let fx = Fixture::with_assets(2); + for id in &fx.ids { + let hex = fx.ws.asset(id).expect("asset").sidecar.hash.to_hex(); + assert_eq!(resolve(&fx.ws, &hex[..MIN_HASH_PREFIX]), Ok(*id)); + assert_eq!(resolve(&fx.ws, &id.to_string()), Ok(*id)); + } + } + + #[test] + fn collect_projects_the_signed_sidecar_and_the_chain() { + let fx = Fixture::with_assets(1); + let id = fx.ids[0]; + let asset = fx.ws.asset(&id).expect("asset"); + let view = collect(asset); + assert_eq!(view.asset_id, id); + assert_eq!(view.album_id, fx.ws.default_album_id()); + assert_eq!(view.content_type, asset.sidecar.content_type); + assert_eq!(view.hash, asset.sidecar.hash.to_hex()); + assert_eq!(view.capture_timestamp, asset.sidecar.capture_timestamp); + assert_eq!(view.caption, None); + assert_eq!(view.rating, None); + assert!(view.tags_user.is_empty()); + assert_eq!(view.cull, CullFlag::Neutral); + assert!(!view.hidden); + assert_eq!(view.stack, None); + assert!(!view.lqip); + assert_eq!(view.provenance_records, asset.chain.records().len()); + } + + #[test] + fn collect_reflects_metadata_edits() { + let mut fx = Fixture::with_assets(1); + let id = fx.ids[0]; + let before = collect(fx.ws.asset(&id).expect("asset")).provenance_records; + fx.ws.set_caption(&id, "On the beach").expect("caption"); + fx.ws.tag_add(&id, "Vacation 2021").expect("tag"); + fx.ws.set_cull(&id, CullFlag::Pick).expect("cull"); + let view = collect(fx.ws.asset(&id).expect("asset")); + assert_eq!(view.caption.as_deref(), Some("On the beach")); + assert_eq!(view.tags_user, vec!["Vacation 2021".to_string()]); + assert_eq!(view.cull, CullFlag::Pick); + assert_eq!( + view.provenance_records, + before + 3, + "three signed metadata-updates on top of the create" + ); + } + + fn sample_view() -> AssetView { + AssetView { + asset_id: Uuid::nil(), + album_id: Uuid::max(), + content_type: "image/jpeg".into(), + hash: "ab".repeat(32), + dimensions: Some((8, 8)), + capture_timestamp: "2019-03-04T05:06:07Z".into(), + import_timestamp: "2026-09-02T00:00:00Z".into(), + caption: Some("On the beach".into()), + rating: Some(5), + tags_user: vec!["Vacation 2021".into(), "beach".into()], + tags_ai: vec![], + gps: Some(Fix { + lat: 10.0, + lon: 20.0, + source: GpsSource::Exif, + datum: GpsDatum::Wgs84, + }), + cull: CullFlag::Neutral, + hidden: false, + stack: None, + lqip: false, + provenance_records: 1, + } + } + + /// Every field the guide asks a user to verify is on the page, and every absent value is + /// spelled out rather than omitted. + #[test] + fn render_prints_every_field_and_spells_out_absent_values() { + let bundle = bundle(); + let page = render(&bundle, &sample_view()); + for expected in [ + "00000000-0000-0000-0000-000000000000", + "image/jpeg", + &"ab".repeat(32), + "8×8", + "2019-03-04T05:06:07Z", + "On the beach", + "5/5", + "Vacation 2021, beach", + "10.000000, 20.000000 (WGS-84, EXIF)", + "neutral", + ] { + assert!(page.contains(expected), "missing {expected:?} in:\n{page}"); + } + let unset = bundle.format(keys::SHOW_VALUE_UNSET, &[]); + assert_ne!(unset, keys::SHOW_VALUE_UNSET, "the key is in the catalog"); + // AI tags, stack and LQIP are absent in the sample: three unset rows. + assert_eq!(page.matches(&unset).count(), 3, "{page}"); + assert_eq!( + page.lines().count(), + 17, + "a header plus sixteen rows:\n{page}" + ); + assert!(!page.contains("cli.show."), "no raw key leaks:\n{page}"); + } + + #[test] + fn render_names_a_stack_placement_a_gcj02_manual_fix_and_the_flags() { + let bundle = bundle(); + let stack_id = Uuid::now_v7(); + let view = AssetView { + gps: Some(Fix { + lat: 39.9, + lon: 116.4, + source: GpsSource::Manual, + datum: GpsDatum::Gcj02, + }), + stack: Some((stack_id, StackRole::Primary)), + hidden: true, + lqip: true, + dimensions: None, + caption: None, + ..sample_view() + }; + let page = render(&bundle, &view); + assert!( + page.contains("39.900000, 116.400000 (GCJ-02, manual)"), + "a non-default datum is named, never silently read as WGS-84:\n{page}" + ); + assert!(page.contains(&format!("{stack_id} (primary)")), "{page}"); + let yes = bundle.format(keys::SHOW_VALUE_YES, &[]); + assert_eq!(page.matches(&yes).count(), 1, "hidden:\n{page}"); + let present = bundle.format(keys::SHOW_VALUE_PRESENT, &[]); + assert!(page.contains(&present), "{page}"); + } + + #[test] + fn describe_error_localizes_each_variant_with_its_detail() { + let bundle = bundle(); + let ambiguous = describe_error( + &bundle, + &ShowError::Ambiguous { + selector: "abcdef01".into(), + count: 3, + }, + ); + assert!( + ambiguous.contains("abcdef01") && ambiguous.contains('3'), + "{ambiguous}" + ); + let invalid = describe_error(&bundle, &ShowError::InvalidSelector("zz".into())); + assert!( + invalid.contains("zz") && invalid.contains(&MIN_HASH_PREFIX.to_string()), + "{invalid}" + ); + let unknown = describe_error(&bundle, &ShowError::UnknownAsset("deadbeef".into())); + assert!(unknown.contains("deadbeef"), "{unknown}"); + for text in [&ambiguous, &invalid, &unknown] { + assert!(!text.contains("cli.show."), "raw key leaked: {text}"); + } + } +} diff --git a/capsule-cli/tests/show_and_repair.rs b/capsule-cli/tests/show_and_repair.rs new file mode 100644 index 00000000..9d919410 --- /dev/null +++ b/capsule-cli/tests/show_and_repair.rs @@ -0,0 +1,353 @@ +//! Slice `S-B18` (`capsule show`) across a real process boundary, by spawning the `capsule` +//! binary the way `import_round_trip.rs` and `takeout_import.rs` do — and for the same +//! reason: `show` proves what a library *reopened from disk* says about an asset. +//! +//! **The fixture image** is a synthesized JPEG carrying a real EXIF APP1 segment with +//! `DateTimeOriginal` **and** `OffsetTimeOriginal`, so its capture time resolves to a fixed +//! UTC instant — the case the importer writes as the capture timestamp since `S-B16`. The +//! Argon2id seeding is the fast-cost trick `import_round_trip.rs` explains; nothing here +//! weakens what the spawned processes run. +//! +//! ## Test list +//! +//! - `show_prints_the_signed_sidecar_by_hash_prefix_or_id` — the guide's sampling step: +//! a hash prefix from `shasum` and the asset id both resolve, and the page carries the +//! EXIF-derived values. +//! - `show_refuses_a_malformed_or_unknown_selector` — non-zero exit with a localized reason. + +use std::io::Write as _; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; + +use capsule_core::crypto::hash; +use capsule_core::crypto::primitives::Argon2Params; +use capsule_core::lifecycle::Workspace; +use uuid::Uuid; + +const PASSPHRASE: &str = "show-and-repair-passphrase"; + +const FAST_KDF: Argon2Params = Argon2Params { + mem_kib: 64, + t_cost: 1, + p_cost: 1, +}; + +/// The EXIF capture instant baked into [`exif_jpeg`], as the sidecar renders it. +const EXIF_CAPTURE: &str = "2019-03-04T05:06:07Z"; + +// ── Scratch plumbing ───────────────────────────────────────────────────────── + +struct ScratchDir(PathBuf); + +impl ScratchDir { + fn new() -> Self { + let path = std::env::temp_dir().join(format!("capsule-cli-s-b17-{}", nanoid::nanoid!())); + std::fs::create_dir_all(&path).expect("create scratch dir"); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for ScratchDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn spawn(home: &Path, args: &[&str], passphrase_stdin: bool) -> Output { + let mut child = Command::new(env!("CARGO_BIN_EXE_capsule")) + .args(args) + .env("NO_COLOR", "1") + .env("RUST_LOG", "off") + .env("LC_ALL", "en_US.UTF-8") + .env("HOME", home) + .env("XDG_CONFIG_HOME", home.join("config")) + .env("XDG_DATA_HOME", home.join("data")) + .env("XDG_CACHE_HOME", home.join("cache")) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn capsule"); + if passphrase_stdin { + child + .stdin + .as_mut() + .expect("stdin piped") + .write_all(format!("{PASSPHRASE}\n").as_bytes()) + .expect("write passphrase"); + } + child.wait_with_output().expect("wait for capsule") +} + +/// The stdout of one successful `capsule` run. +fn capsule(home: &Path, args: &[&str], passphrase_stdin: bool) -> String { + let out = spawn(home, args, passphrase_stdin); + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + assert!( + out.status.success(), + "capsule {args:?} failed\nstdout:\n{stdout}\nstderr:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + stdout +} + +/// The combined output of one **failing** `capsule` run. +fn capsule_fails(home: &Path, args: &[&str], passphrase_stdin: bool) -> String { + let out = spawn(home, args, passphrase_stdin); + let text = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + assert!( + !out.status.success(), + "capsule {args:?} was expected to fail\noutput:\n{text}" + ); + text +} + +fn path(p: &Path) -> &str { + p.to_str().expect("scratch paths are UTF-8") +} + +// ── The fixture image ──────────────────────────────────────────────────────── + +/// A JPEG container holding one EXIF APP1 segment: `DateTimeOriginal` 2019-03-04 05:06:07 +/// with `OffsetTimeOriginal` +00:00, and 8×8 pixel dimensions. `salt` is appended after the +/// segment so several files share the EXIF and differ in bytes (and therefore in hash). +fn exif_jpeg(salt: &[u8]) -> Vec { + const DTO: &[u8] = b"2019:03:04 05:06:07\0"; + const OTO: &[u8] = b"+00:00\0"; + const IFD0_AT: u32 = 8; + // IFD0 holds one entry (the Exif pointer): 2 + 12 + 4 bytes. + const EXIF_IFD_AT: u32 = IFD0_AT + 2 + 12 + 4; + // The Exif IFD holds four entries. + const DATA_AT: u32 = EXIF_IFD_AT + 2 + 4 * 12 + 4; + const DTO_AT: u32 = DATA_AT; + const OTO_AT: u32 = DTO_AT + DTO.len() as u32; + + fn entry(tiff: &mut Vec, tag: u16, kind: u16, count: u32, value: [u8; 4]) { + tiff.extend_from_slice(&tag.to_be_bytes()); + tiff.extend_from_slice(&kind.to_be_bytes()); + tiff.extend_from_slice(&count.to_be_bytes()); + tiff.extend_from_slice(&value); + } + + let mut tiff = Vec::new(); + tiff.extend_from_slice(b"MM"); + tiff.extend_from_slice(&42u16.to_be_bytes()); + tiff.extend_from_slice(&IFD0_AT.to_be_bytes()); + + tiff.extend_from_slice(&1u16.to_be_bytes()); + entry(&mut tiff, 0x8769, 4, 1, EXIF_IFD_AT.to_be_bytes()); + tiff.extend_from_slice(&0u32.to_be_bytes()); + + tiff.extend_from_slice(&4u16.to_be_bytes()); + entry(&mut tiff, 0x9003, 2, DTO.len() as u32, DTO_AT.to_be_bytes()); + entry(&mut tiff, 0x9011, 2, OTO.len() as u32, OTO_AT.to_be_bytes()); + entry(&mut tiff, 0xA002, 4, 1, 8u32.to_be_bytes()); + entry(&mut tiff, 0xA003, 4, 1, 8u32.to_be_bytes()); + tiff.extend_from_slice(&0u32.to_be_bytes()); + + assert_eq!( + tiff.len() as u32, + DATA_AT, + "the IFDs end where the data starts" + ); + tiff.extend_from_slice(DTO); + tiff.extend_from_slice(OTO); + + let mut app1 = b"Exif\0\0".to_vec(); + app1.extend_from_slice(&tiff); + let mut jpeg = vec![0xFF, 0xD8]; + jpeg.extend_from_slice(&[0xFF, 0xE1]); + jpeg.extend_from_slice(&((app1.len() + 2) as u16).to_be_bytes()); + jpeg.extend_from_slice(&app1); + // A COM segment carrying the salt keeps the container well-formed while making each + // fixture's bytes (and hash) distinct. + jpeg.extend_from_slice(&[0xFF, 0xFE]); + jpeg.extend_from_slice(&((salt.len() + 2) as u16).to_be_bytes()); + jpeg.extend_from_slice(salt); + jpeg.extend_from_slice(&[0xFF, 0xD9]); + jpeg +} + +// ── The fixture ────────────────────────────────────────────────────────────── + +struct Fixture { + _scratch: ScratchDir, + home: PathBuf, + library: PathBuf, + /// The bytes of every imported fixture image, in source order. + images: Vec>, +} + +impl Fixture { + fn run(&self, args: &[&str]) -> String { + capsule(&self.home, args, true) + } + + fn run_fails(&self, args: &[&str]) -> String { + capsule_fails(&self.home, args, true) + } + + /// `capsule show --library `. + fn show(&self, selector: &str) -> String { + self.run(&[ + "show", + selector, + "--library", + path(&self.library), + "--passphrase-stdin", + ]) + } + + fn hash_hex(&self, image: &[u8]) -> String { + hash::hash_bytes(image).to_hex() + } + + /// The library reopened in this process, after every spawned `capsule` has exited. + fn reopen(&self) -> Workspace { + Workspace::open(&self.library, PASSPHRASE.as_bytes(), FAST_KDF).expect("reopen") + } + + /// The asset holding exactly `image`. + fn asset_for(&self, ws: &Workspace, image: &[u8]) -> Uuid { + ws.asset_ids() + .into_iter() + .find(|id| ws.read_plaintext(id).is_ok_and(|p| p == image)) + .expect("an imported asset holding these bytes") + } +} + +/// `capsule library init` + a fast-cost account + `capsule import` of `count` EXIF JPEGs. +fn fixture(count: usize) -> Fixture { + let scratch = ScratchDir::new(); + let home = scratch.path().join("home"); + let library = scratch.path().join("library"); + let source = scratch.path().join("source"); + std::fs::create_dir_all(&home).expect("create scratch home"); + std::fs::create_dir_all(&source).expect("create source dir"); + let images: Vec> = (0..count) + .map(|n| { + let image = exif_jpeg(format!("fixture {n}").as_bytes()); + std::fs::write(source.join(format!("photo-{n}.jpg")), &image).expect("fixture"); + image + }) + .collect(); + + let out = capsule( + &home, + &["library", "init", path(&library), "--name", "Repair"], + false, + ); + assert!(out.contains("Library created at"), "stdout:\n{out}"); + drop(Workspace::open(&library, PASSPHRASE.as_bytes(), FAST_KDF).expect("seed the account")); + + let out = capsule( + &home, + &[ + "import", + path(&source), + "--library", + path(&library), + "--passphrase-stdin", + ], + true, + ); + assert!( + out.contains(&format!( + "Done: {count} imported, 0 duplicate(s), 0 error(s)." + )), + "stdout:\n{out}" + ); + + Fixture { + _scratch: scratch, + home, + library, + images, + } +} + +// ── `capsule show` (S-B18) ─────────────────────────────────────────────────── + +/// The guide's metadata-sampling step, executed: the SHA-256 a user computed over the source +/// file (here, its first eight hex digits) resolves to the imported asset, and the page shows +/// the values that can only have come from the EXIF segment. The asset id resolves too. +#[test] +fn show_prints_the_signed_sidecar_by_hash_prefix_or_id() { + let fx = fixture(2); + let image = &fx.images[0]; + let hex = fx.hash_hex(image); + + let page = fx.show(&hex[..8]); + assert!(page.contains(&hex), "the full hash is printed:\n{page}"); + assert!( + page.contains(EXIF_CAPTURE), + "the EXIF capture instant:\n{page}" + ); + assert!(page.contains("8×8"), "the EXIF dimensions:\n{page}"); + assert!(page.contains("image/jpeg"), "{page}"); + assert!( + page.contains("(unset)"), + "absent fields are spelled out:\n{page}" + ); + assert!( + page.contains("Provenance: 1 signed record(s)"), + "a fresh import on this build is a chain of one:\n{page}" + ); + assert!(!page.contains("cli.show."), "no raw catalog key:\n{page}"); + + let ws = fx.reopen(); + let id = fx.asset_for(&ws, image); + assert!( + page.contains(&id.to_string()), + "the resolved asset's id:\n{page}" + ); + drop(ws); + let by_id = fx.show(&id.to_string()); + assert_eq!( + by_id, page, + "the id and the hash prefix name the same asset" + ); +} + +/// `show` refuses rather than guesses: a malformed selector, an unknown one, and a prefix +/// too short to be accepted each fail with a localized reason and a non-zero exit. +#[test] +fn show_refuses_a_malformed_or_unknown_selector() { + let fx = fixture(1); + let library = path(&fx.library); + + let malformed = fx.run_fails(&[ + "show", + "not-a-selector", + "--library", + library, + "--passphrase-stdin", + ]); + assert!( + malformed.contains("neither an asset id nor a hex prefix"), + "{malformed}" + ); + + let unknown = fx.run_fails(&[ + "show", + "0000000000000000", + "--library", + library, + "--passphrase-stdin", + ]); + assert!( + unknown.contains("No asset in this library matches"), + "{unknown}" + ); + + let short = fx.run_fails(&["show", "abcdef", "--library", library, "--passphrase-stdin"]); + assert!(short.contains("at least 8"), "{short}"); +} diff --git a/capsule-cli/tests/takeout_import.rs b/capsule-cli/tests/takeout_import.rs index dc169c73..ddf38a5d 100644 --- a/capsule-cli/tests/takeout_import.rs +++ b/capsule-cli/tests/takeout_import.rs @@ -38,6 +38,8 @@ //! - `without_the_provider_flag_the_exporter_metadata_is_not_applied` — the flag is what turns //! the adapter on: the same tree imported as a plain directory keeps the bytes and loses the //! exporter's record, which is what makes `--provider` observable rather than cosmetic. +//! - `the_guides_metadata_sampling_step_is_executable_with_show` — slice `S-B18`: the guide's +//! sampling step, run as written, over the hash a user computed on the source file. //! //! [Google Photos migration guide]: ../../capsule-docs/src/content/docs/guides/google-photos-migration.md @@ -635,3 +637,61 @@ fn the_import_arm_prints_catalog_messages_not_hardcoded_english() { "a plain import must not announce an export adapter\nstdout:\n{plain}" ); } + +/// Slice `S-B18`: the migration guide's metadata-sampling step, executed as written. The user +/// holds the source file's SHA-256 from the guide's spot-hash step; `capsule show` takes a +/// prefix of it and prints the exporter-authoritative values the import folded into the +/// signed sidecar — the caption, the favourite as five stars, the album as a user tag — and +/// the file's own EXIF fix, which beat the exporter's. +#[test] +fn the_guides_metadata_sampling_step_is_executable_with_show() { + let fx = fixture(); + let library = fx.library(); + fx.import(&library, true); + + // `shasum -a 256 beach.jpg`, as the guide instructs, and its first eight hex digits. + let hex = capsule_core::crypto::hash::hash_bytes(&fx.media.beach).to_hex(); + let page = capsule( + &fx.home, + &[ + "show", + &hex[..8], + "--library", + path(&library), + "--passphrase-stdin", + ], + ); + + for expected in [ + hex.as_str(), + "On the beach", + "5/5", + "Vacation 2021", + "10.000000, 20.000000 (WGS-84, EXIF)", + BEACH_EXIF_CIVIL, + ] { + assert!(page.contains(expected), "missing {expected:?} in:\n{page}"); + } + + // The exporter-filled fix is labelled as such, so a user can tell it apart from EXIF. + let plain_hex = capsule_core::crypto::hash::hash_bytes(&fx.media.plain).to_hex(); + let plain = capsule( + &fx.home, + &[ + "show", + &plain_hex[..8], + "--library", + path(&library), + "--passphrase-stdin", + ], + ); + assert!( + plain.contains("21.300000, -157.800000 (WGS-84, manual)"), + "{plain}" + ); + assert!(plain.contains("Snowy morning"), "{plain}"); + assert!( + plain.contains("Rating: (unset)"), + "an unstarred photo's rating is spelled out as unset:\n{plain}" + ); +} diff --git a/capsule-docs/src/content/docs/guides/google-photos-migration.md b/capsule-docs/src/content/docs/guides/google-photos-migration.md index 6c069d20..98c6d16b 100644 --- a/capsule-docs/src/content/docs/guides/google-photos-migration.md +++ b/capsule-docs/src/content/docs/guides/google-photos-migration.md @@ -267,10 +267,46 @@ Do the same for the **exporter-supplied** metadata, since that is what `--provider takeout` adds: pick a few photos you know are in an album, are favorited, or carry a typed caption in Google Photos, and note them down. -The current CLI has no command that prints an imported asset's caption, rating, -or tags back to you, so this sample is a record to reconcile later rather than -something you can diff today. That is exactly why the guide keeps insisting you -retain the Takeout archive and keep Google Photos alive through the cutover. +Then read each one back out of the library. `capsule show` prints what the +imported asset's **signed sidecar** records, and it takes the SHA-256 you already +have from the spot-hash step — Capsule imports bytes unchanged, so the source +file's hash is the asset's hash. A prefix of eight or more hex characters is +enough; if it happens to match more than one asset, the command refuses and asks +for more of the hash rather than guessing: + +```sh +capsule show --library ./my-library 3b2f9c1e +``` + +```text +Asset 019a2d3c-… + Album: … + Content type: image/jpeg + SHA-256: 3b2f9c1e… + Dimensions: 4032×3024 + Captured: 2021-07-04T18:22:09Z + Imported: 2026-09-02T10:15:42Z + Caption: Grandma's 80th + Rating: 5/5 + User tags: Family reunion 2021 + AI tags: (unset) + GPS: 37.774900, -122.419400 (WGS-84, manual) + Cull flag: neutral + … +``` + +Check the rows against your notes using the mapping table above: the caption is +the Google description, a favorite reads as `5/5`, each album the photo was in is +a user tag, and a GPS fix that came from the Takeout JSON rather than the file's +own EXIF is marked `manual` (a fix is always printed with its datum, so a GCJ-02 +coordinate stored verbatim is never mistaken for WGS-84). A field the export did not carry prints as +`(unset)` rather than being omitted, so a missing caption is something you can +see. A mapping you disagree with is worth catching here: the values live in a +signed sidecar, and changing one later is a signed metadata update per asset. + +Retain the Takeout archive and keep Google Photos alive through the cutover all +the same — the sample tells you the mapping is right, not that every one of tens +of thousands of assets is. ## After you've verified diff --git a/capsule-i18n/src/bundles/en.json b/capsule-i18n/src/bundles/en.json index 19b7824f..05902f46 100644 --- a/capsule-i18n/src/bundles/en.json +++ b/capsule-i18n/src/bundles/en.json @@ -1782,6 +1782,10 @@ "cli.help.reset.arg.data": "Reset data directory", "cli.help.root.about": "A command line interface for Capsule - the photo management platform", "cli.help.root.long_about": "Capsule CLI provides tools for managing your photos and albums:\n\n- Authentication management\n- Sync local and remote data\n- Check status and list files\n- Manage albums and collections", + "cli.help.show.about": "Show what an imported asset's signed sidecar records: caption, rating, tags, GPS, timestamps", + "cli.help.show.arg.asset": "The asset to show: its id, or a prefix of at least 8 hex characters of its SHA-256 content hash — the same digest `shasum -a 256` prints for the source file", + "cli.help.show.arg.library": "Path to the Capsule library", + "cli.help.show.arg.passphrase_stdin": "Read the library passphrase from stdin instead of prompting, so the command works in scripts and CI where there is no terminal", "cli.help.status.about": "Show current status", "cli.help.sync.about": "Sync local and remote data", "cli.help.sync.arg.dry_run": "Perform a dry run without making changes", @@ -1815,6 +1819,44 @@ "cli.push.in_progress": "Pushing {assets} asset(s) to {endpoint}…", "cli.push.nothing_to_push": "The library holds no assets to push.", "cli.push.up_to_date": "Everything in this library is already on the server.", + "cli.show.album": " Album: {value}", + "cli.show.ambiguous": "{selector} matches {count} assets; give more of the hash, or the full asset id.", + "cli.show.caption": " Caption: {value}", + "cli.show.captured": " Captured: {value}", + "cli.show.content_type": " Content type: {value}", + "cli.show.cull": " Cull flag: {value}", + "cli.show.dimensions": " Dimensions: {value}", + "cli.show.failed": "Show failed: {reason}", + "cli.show.gps": " GPS: {value}", + "cli.show.gps_datum.gcj02": "GCJ-02", + "cli.show.gps_datum.wgs84": "WGS-84", + "cli.show.gps_source.derived": "derived", + "cli.show.gps_source.exif": "EXIF", + "cli.show.gps_source.manual": "manual", + "cli.show.hash": " SHA-256: {value}", + "cli.show.header": "Asset {asset_id}", + "cli.show.hidden": " Hidden: {value}", + "cli.show.imported": " Imported: {value}", + "cli.show.invalid_selector": "{selector} is neither an asset id nor a hex prefix of at least {min} characters of a content hash.", + "cli.show.lqip": " Placeholder: {value}", + "cli.show.provenance_records": " Provenance: {value} signed record(s)", + "cli.show.rating": " Rating: {value}", + "cli.show.stack": " Stack: {value}", + "cli.show.stack_role.member": "member", + "cli.show.stack_role.primary": "primary", + "cli.show.stack_role.proxy": "proxy", + "cli.show.tags_ai": " AI tags: {value}", + "cli.show.tags_user": " User tags: {value}", + "cli.show.unknown_asset": "No asset in this library matches {selector}.", + "cli.show.value.dimensions": "{width}×{height}", + "cli.show.value.gps": "{lat}, {lon} ({datum}, {source})", + "cli.show.value.list_separator": ", ", + "cli.show.value.no": "no", + "cli.show.value.present": "present", + "cli.show.value.rating": "{stars}/5", + "cli.show.value.stack": "{stack_id} ({role})", + "cli.show.value.unset": "(unset)", + "cli.show.value.yes": "yes", "cli.sync.complete": "Sync complete: applied {applied} change(s) across {albums} album(s) in {pages} page(s).", "cli.sync.dry_run_notice": "Dry run: no changes will be persisted.", "cli.sync.failed": "Sync failed: {reason}", diff --git a/capsule-swift/Generated/Localizable.xcstrings b/capsule-swift/Generated/Localizable.xcstrings index e31245ed..5dc8916f 100644 --- a/capsule-swift/Generated/Localizable.xcstrings +++ b/capsule-swift/Generated/Localizable.xcstrings @@ -30651,6 +30651,46 @@ } } }, + "cli.help.show.about": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show what an imported asset's signed sidecar records: caption, rating, tags, GPS, timestamps" + } + } + } + }, + "cli.help.show.arg.asset": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The asset to show: its id, or a prefix of at least 8 hex characters of its SHA-256 content hash — the same digest `shasum -a 256` prints for the source file" + } + } + } + }, + "cli.help.show.arg.library": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Path to the Capsule library" + } + } + } + }, + "cli.help.show.arg.passphrase_stdin": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Read the library passphrase from stdin instead of prompting, so the command works in scripts and CI where there is no terminal" + } + } + } + }, "cli.help.status.about": { "localizations": { "en": { @@ -31413,6 +31453,386 @@ } } }, + "cli.show.album": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": " Album: %@" + } + } + } + }, + "cli.show.ambiguous": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ matches %2$@ assets; give more of the hash, or the full asset id." + } + } + } + }, + "cli.show.caption": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": " Caption: %@" + } + } + } + }, + "cli.show.captured": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": " Captured: %@" + } + } + } + }, + "cli.show.content_type": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": " Content type: %@" + } + } + } + }, + "cli.show.cull": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": " Cull flag: %@" + } + } + } + }, + "cli.show.dimensions": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": " Dimensions: %@" + } + } + } + }, + "cli.show.failed": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show failed: %@" + } + } + } + }, + "cli.show.gps": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": " GPS: %@" + } + } + } + }, + "cli.show.gps_datum.gcj02": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "GCJ-02" + } + } + } + }, + "cli.show.gps_datum.wgs84": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "WGS-84" + } + } + } + }, + "cli.show.gps_source.derived": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "derived" + } + } + } + }, + "cli.show.gps_source.exif": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "EXIF" + } + } + } + }, + "cli.show.gps_source.manual": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "manual" + } + } + } + }, + "cli.show.hash": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": " SHA-256: %@" + } + } + } + }, + "cli.show.header": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Asset %@" + } + } + } + }, + "cli.show.hidden": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": " Hidden: %@" + } + } + } + }, + "cli.show.imported": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": " Imported: %@" + } + } + } + }, + "cli.show.invalid_selector": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ is neither an asset id nor a hex prefix of at least %2$@ characters of a content hash." + } + } + } + }, + "cli.show.lqip": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": " Placeholder: %@" + } + } + } + }, + "cli.show.provenance_records": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": " Provenance: %@ signed record(s)" + } + } + } + }, + "cli.show.rating": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": " Rating: %@" + } + } + } + }, + "cli.show.stack": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": " Stack: %@" + } + } + } + }, + "cli.show.stack_role.member": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "member" + } + } + } + }, + "cli.show.stack_role.primary": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "primary" + } + } + } + }, + "cli.show.stack_role.proxy": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "proxy" + } + } + } + }, + "cli.show.tags_ai": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": " AI tags: %@" + } + } + } + }, + "cli.show.tags_user": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": " User tags: %@" + } + } + } + }, + "cli.show.unknown_asset": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No asset in this library matches %@." + } + } + } + }, + "cli.show.value.dimensions": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@×%2$@" + } + } + } + }, + "cli.show.value.gps": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@, %2$@ (%3$@, %4$@)" + } + } + } + }, + "cli.show.value.list_separator": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": ", " + } + } + } + }, + "cli.show.value.no": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "no" + } + } + } + }, + "cli.show.value.present": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "present" + } + } + } + }, + "cli.show.value.rating": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@/5" + } + } + } + }, + "cli.show.value.stack": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ (%2$@)" + } + } + } + }, + "cli.show.value.unset": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "(unset)" + } + } + } + }, + "cli.show.value.yes": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "yes" + } + } + } + }, "cli.sync.complete": { "localizations": { "ar": { diff --git a/capsule-web/src/i18n/messages/en.json b/capsule-web/src/i18n/messages/en.json index 19b7824f..05902f46 100644 --- a/capsule-web/src/i18n/messages/en.json +++ b/capsule-web/src/i18n/messages/en.json @@ -1782,6 +1782,10 @@ "cli.help.reset.arg.data": "Reset data directory", "cli.help.root.about": "A command line interface for Capsule - the photo management platform", "cli.help.root.long_about": "Capsule CLI provides tools for managing your photos and albums:\n\n- Authentication management\n- Sync local and remote data\n- Check status and list files\n- Manage albums and collections", + "cli.help.show.about": "Show what an imported asset's signed sidecar records: caption, rating, tags, GPS, timestamps", + "cli.help.show.arg.asset": "The asset to show: its id, or a prefix of at least 8 hex characters of its SHA-256 content hash — the same digest `shasum -a 256` prints for the source file", + "cli.help.show.arg.library": "Path to the Capsule library", + "cli.help.show.arg.passphrase_stdin": "Read the library passphrase from stdin instead of prompting, so the command works in scripts and CI where there is no terminal", "cli.help.status.about": "Show current status", "cli.help.sync.about": "Sync local and remote data", "cli.help.sync.arg.dry_run": "Perform a dry run without making changes", @@ -1815,6 +1819,44 @@ "cli.push.in_progress": "Pushing {assets} asset(s) to {endpoint}…", "cli.push.nothing_to_push": "The library holds no assets to push.", "cli.push.up_to_date": "Everything in this library is already on the server.", + "cli.show.album": " Album: {value}", + "cli.show.ambiguous": "{selector} matches {count} assets; give more of the hash, or the full asset id.", + "cli.show.caption": " Caption: {value}", + "cli.show.captured": " Captured: {value}", + "cli.show.content_type": " Content type: {value}", + "cli.show.cull": " Cull flag: {value}", + "cli.show.dimensions": " Dimensions: {value}", + "cli.show.failed": "Show failed: {reason}", + "cli.show.gps": " GPS: {value}", + "cli.show.gps_datum.gcj02": "GCJ-02", + "cli.show.gps_datum.wgs84": "WGS-84", + "cli.show.gps_source.derived": "derived", + "cli.show.gps_source.exif": "EXIF", + "cli.show.gps_source.manual": "manual", + "cli.show.hash": " SHA-256: {value}", + "cli.show.header": "Asset {asset_id}", + "cli.show.hidden": " Hidden: {value}", + "cli.show.imported": " Imported: {value}", + "cli.show.invalid_selector": "{selector} is neither an asset id nor a hex prefix of at least {min} characters of a content hash.", + "cli.show.lqip": " Placeholder: {value}", + "cli.show.provenance_records": " Provenance: {value} signed record(s)", + "cli.show.rating": " Rating: {value}", + "cli.show.stack": " Stack: {value}", + "cli.show.stack_role.member": "member", + "cli.show.stack_role.primary": "primary", + "cli.show.stack_role.proxy": "proxy", + "cli.show.tags_ai": " AI tags: {value}", + "cli.show.tags_user": " User tags: {value}", + "cli.show.unknown_asset": "No asset in this library matches {selector}.", + "cli.show.value.dimensions": "{width}×{height}", + "cli.show.value.gps": "{lat}, {lon} ({datum}, {source})", + "cli.show.value.list_separator": ", ", + "cli.show.value.no": "no", + "cli.show.value.present": "present", + "cli.show.value.rating": "{stars}/5", + "cli.show.value.stack": "{stack_id} ({role})", + "cli.show.value.unset": "(unset)", + "cli.show.value.yes": "yes", "cli.sync.complete": "Sync complete: applied {applied} change(s) across {albums} album(s) in {pages} page(s).", "cli.sync.dry_run_notice": "Dry run: no changes will be persisted.", "cli.sync.failed": "Sync failed: {reason}", diff --git a/locales/en.json b/locales/en.json index c7d3f306..22fe08f3 100644 --- a/locales/en.json +++ b/locales/en.json @@ -7131,6 +7131,22 @@ "message": "Capsule CLI provides tools for managing your photos and albums:\n\n- Authentication management\n- Sync local and remote data\n- Check status and list files\n- Manage albums and collections", "context": "clap --help: the long description of `capsule`, shown at the top of `capsule --help` instead of the one-line form. Keep the Markdown list markers: the documentation site renders this text as prose." }, + "cli.help.show.about": { + "message": "Show what an imported asset's signed sidecar records: caption, rating, tags, GPS, timestamps", + "context": "clap --help: the one-line description of `capsule show`. Shown in the parent's command list and at the top of its own --help." + }, + "cli.help.show.arg.asset": { + "message": "The asset to show: its id, or a prefix of at least 8 hex characters of its SHA-256 content hash — the same digest `shasum -a 256` prints for the source file", + "context": "clap --help: help for the `` positional of `capsule show`." + }, + "cli.help.show.arg.library": { + "message": "Path to the Capsule library", + "context": "clap --help: help for the `--library` option of `capsule show`." + }, + "cli.help.show.arg.passphrase_stdin": { + "message": "Read the library passphrase from stdin instead of prompting, so the command works in scripts and CI where there is no terminal", + "context": "clap --help: help for the `--passphrase-stdin` flag of `capsule show`." + }, "cli.help.status.about": { "message": "Show current status", "context": "clap --help: the one-line description of `capsule status`. Shown in the parent's command list and at the top of its own --help." @@ -7263,6 +7279,158 @@ "message": "Everything in this library is already on the server.", "context": "CLI line when `capsule push` finds every blob already held server-side." }, + "cli.show.album": { + "message": " Album: {value}", + "context": "CLI field row of `capsule show`: the owning album's UUID. Keep the label padded so values align in a monospace terminal." + }, + "cli.show.ambiguous": { + "message": "{selector} matches {count} assets; give more of the hash, or the full asset id.", + "context": "CLI failure detail when a `capsule show` hash prefix matches several assets. {count} is the number of matches." + }, + "cli.show.caption": { + "message": " Caption: {value}", + "context": "CLI field row of `capsule show`: the caption text, or the unset marker." + }, + "cli.show.captured": { + "message": " Captured: {value}", + "context": "CLI field row of `capsule show`: the signed capture timestamp (RFC 3339, UTC)." + }, + "cli.show.content_type": { + "message": " Content type: {value}", + "context": "CLI field row of `capsule show`: the asset's content type, e.g. image/jpeg." + }, + "cli.show.cull": { + "message": " Cull flag: {value}", + "context": "CLI field row of `capsule show`: the culling flag (a localized pick/neutral/reject name)." + }, + "cli.show.dimensions": { + "message": " Dimensions: {value}", + "context": "CLI field row of `capsule show`: pixel dimensions, or the unset marker." + }, + "cli.show.failed": { + "message": "Show failed: {reason}", + "context": "CLI failure line for `capsule show`. {reason} is a localized or English error detail." + }, + "cli.show.gps": { + "message": " GPS: {value}", + "context": "CLI field row of `capsule show`: the location fix with its source, or the unset marker." + }, + "cli.show.gps_datum.gcj02": { + "message": "GCJ-02", + "context": "GPS datum name in `capsule show`: China's obfuscated datum, stored verbatim and never converted. A standard's name, shown verbatim." + }, + "cli.show.gps_datum.wgs84": { + "message": "WGS-84", + "context": "GPS datum name in `capsule show`: the near-universal camera datum. A standard's name, shown verbatim." + }, + "cli.show.gps_source.derived": { + "message": "derived", + "context": "GPS fix source name in `capsule show`: inferred, e.g. from a nearby asset." + }, + "cli.show.gps_source.exif": { + "message": "EXIF", + "context": "GPS fix source name in `capsule show`: read from the file's own EXIF. EXIF is a standard's name, shown verbatim." + }, + "cli.show.gps_source.manual": { + "message": "manual", + "context": "GPS fix source name in `capsule show`: entered by a user or supplied by an exporter's record rather than the file's EXIF." + }, + "cli.show.hash": { + "message": " SHA-256: {value}", + "context": "CLI field row of `capsule show`: the original file's SHA-256 content hash in hex. SHA-256 is an algorithm name, shown verbatim." + }, + "cli.show.header": { + "message": "Asset {asset_id}", + "context": "CLI heading line of `capsule show`, above the field rows. {asset_id} is the asset's UUID." + }, + "cli.show.hidden": { + "message": " Hidden: {value}", + "context": "CLI field row of `capsule show`: whether the asset is hidden from default views (a localized yes/no)." + }, + "cli.show.imported": { + "message": " Imported: {value}", + "context": "CLI field row of `capsule show`: the signed import timestamp (RFC 3339, UTC)." + }, + "cli.show.invalid_selector": { + "message": "{selector} is neither an asset id nor a hex prefix of at least {min} characters of a content hash.", + "context": "CLI failure detail when the `capsule show` selector is malformed. {min} is the minimum prefix length." + }, + "cli.show.lqip": { + "message": " Placeholder: {value}", + "context": "CLI field row of `capsule show`: whether a low-quality display placeholder (LQIP) is stored — a localized present marker, or the unset marker." + }, + "cli.show.provenance_records": { + "message": " Provenance: {value} signed record(s)", + "context": "CLI field row of `capsule show`: how many signed records the asset's provenance chain holds — the create plus every lifecycle write since (metadata edits, repairs, derivative writes, trash moves)." + }, + "cli.show.rating": { + "message": " Rating: {value}", + "context": "CLI field row of `capsule show`: the star rating, or the unset marker." + }, + "cli.show.stack": { + "message": " Stack: {value}", + "context": "CLI field row of `capsule show`: the asset's stack placement (RAW+JPEG, Live Photo, edited pair), or the unset marker." + }, + "cli.show.stack_role.member": { + "message": "member", + "context": "Stack role name in `capsule show`: an ordinary stack member." + }, + "cli.show.stack_role.primary": { + "message": "primary", + "context": "Stack role name in `capsule show`: the stack's representative asset." + }, + "cli.show.stack_role.proxy": { + "message": "proxy", + "context": "Stack role name in `capsule show`: a proxy or optimized variant." + }, + "cli.show.tags_ai": { + "message": " AI tags: {value}", + "context": "CLI field row of `capsule show`: the AI-suggested tags, comma-separated, or the unset marker." + }, + "cli.show.tags_user": { + "message": " User tags: {value}", + "context": "CLI field row of `capsule show`: the user-authored tags, comma-separated, or the unset marker." + }, + "cli.show.unknown_asset": { + "message": "No asset in this library matches {selector}.", + "context": "CLI failure detail when the `capsule show` selector (an asset id or a content-hash prefix) matches nothing." + }, + "cli.show.value.dimensions": { + "message": "{width}×{height}", + "context": "CLI value in the `capsule show` dimensions row. {width} and {height} are pixel counts." + }, + "cli.show.value.gps": { + "message": "{lat}, {lon} ({datum}, {source})", + "context": "CLI value in the `capsule show` GPS row. {lat}/{lon} are decimal degrees in {datum}, a localized datum name (WGS-84/GCJ-02); {source} is a localized fix source (EXIF/manual/derived)." + }, + "cli.show.value.list_separator": { + "message": ", ", + "context": "CLI separator `capsule show` puts between list items (tags). Locales with their own enumeration comma (e.g. CJK 、) translate it." + }, + "cli.show.value.no": { + "message": "no", + "context": "CLI boolean value in `capsule show` rows (hidden, in trash)." + }, + "cli.show.value.present": { + "message": "present", + "context": "CLI value in the `capsule show` placeholder row when a display placeholder is stored." + }, + "cli.show.value.rating": { + "message": "{stars}/5", + "context": "CLI value in the `capsule show` rating row. {stars} is 0–5." + }, + "cli.show.value.stack": { + "message": "{stack_id} ({role})", + "context": "CLI value in the `capsule show` stack row. {stack_id} is the stack's UUID; {role} is a localized role name (primary/member/proxy)." + }, + "cli.show.value.unset": { + "message": "(unset)", + "context": "CLI marker `capsule show` prints in place of a value the sidecar does not carry." + }, + "cli.show.value.yes": { + "message": "yes", + "context": "CLI boolean value in `capsule show` rows (hidden, in trash)." + }, "cli.sync.complete": { "message": "Sync complete: applied {applied} change(s) across {albums} album(s) in {pages} page(s).", "context": "CLI summary after `capsule sync`. {applied}, {albums}, {pages} are counts." From 95fa2248f064da0ea2c06b096f5c02154e92c6ba Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 02:39:21 -0400 Subject: [PATCH 100/243] feat(core): correct a signed capture timestamp as a new revision Every asset imported before S-B16 carries its import time as its capture time inside the signed sidecar; the correct value is recoverable from the original's EXIF, but the wrong one is under signature, so correcting it is a `metadata-update` issued by a key-holding client, not an edit (S-B17). - `Workspace::set_capture_timestamp(asset_id, Timestamp)` appends one signed `metadata-update` through `append_lifecycle`: sidecar re-signed, blob re-sealed under a fresh nonce, binding self-checked, artifacts rewritten, index row re-projected. It takes a `jiff::Timestamp` so an out-of-range instant is unrepresentable at the call site. - The media bundle is deliberately not relocated: the sidecar is authoritative for the date and the `media/{YYYY}/{YYYY-MM}` directory is only the shard fixed at import; the design records bucket-vs-timestamp drift after a capture correction as expected, and `Workspace::open` already reconciles it by keeping the directory. - `Workspace::original_path(asset_id)` exposes the original's on-disk path so the repair pass can re-read EXIF without decrypting anything. The test imports, corrects, reopens from disk, and rebuilds the index, asserting a two-record chain that verifies, an unmoved bundle, and the same corrected instant from the live row and the rebuilt one. --- capsule-core/src/lifecycle/metadata.rs | 146 +++++++++++++++++++++++++ capsule-core/src/lifecycle/mod.rs | 14 +++ 2 files changed, 160 insertions(+) diff --git a/capsule-core/src/lifecycle/metadata.rs b/capsule-core/src/lifecycle/metadata.rs index 30e55dd8..e58dd9c2 100644 --- a/capsule-core/src/lifecycle/metadata.rs +++ b/capsule-core/src/lifecycle/metadata.rs @@ -5,6 +5,7 @@ //! `Workspace` directly, which — with this file importing `ml` — made the two modules mutually //! recursive; the trait inverts that edge without moving any behaviour. +use jiff::Timestamp; use uuid::Uuid; use super::{LifecycleError, Result, Workspace, now_rfc3339}; @@ -34,6 +35,45 @@ impl Workspace { }) } + /// Correct the asset's signed `capture_timestamp` to `capture`, as a new signed revision: + /// one `metadata-update` record, with the sidecar re-signed and its sealed blob re-sealed + /// under a fresh nonce, exactly as any other metadata edit lands (slice `S-B17`). + /// + /// The **media bundle is not relocated.** `capture_timestamp` is authoritative for the + /// asset's date; the `media/{YYYY}/{YYYY-MM}` directory is only the shard fixed at import + /// (`AssetState::capture_utc`), and the design treats bucket-vs-timestamp drift after a + /// capture correction as expected rather than as a fault (Maintenance — Structural + /// Validation). Moving the bundle here would orphan the old directory for every writer + /// that still resolves it, so the shard stays and every path keeps resolving; the index + /// row follows the sidecar (`asset_row_from_state`), so the timeline shows the corrected + /// instant immediately rather than after a rebuild. + /// + /// Takes a [`Timestamp`] rather than raw seconds so an out-of-range instant is + /// unrepresentable at the call site instead of being clamped to the epoch here. + #[tracing::instrument(skip(self), fields(asset_id = %asset_id, capture = %capture))] + pub fn set_capture_timestamp(&mut self, asset_id: &Uuid, capture: Timestamp) -> Result<()> { + let recorded = self + .assets + .get(asset_id) + .ok_or_else(|| LifecycleError::NotFound(format!("asset {asset_id}")))? + .sidecar + .capture_timestamp + .clone(); + let corrected = capture.to_string(); + self.append_lifecycle(asset_id, Action::MetadataUpdate, None, move |s, _add_id| { + s.capture_timestamp = corrected; + })?; + let head = self.assets[asset_id].chain.head(); + tracing::info!( + asset_id = %asset_id, + recorded = %recorded, + corrected = %capture, + chain_head = ?head, + "capture timestamp corrected as a signed metadata-update" + ); + Ok(()) + } + // ── AI metadata containment (SSoT: metadata § Tag Provenance, ai § AI Output Containment) ── // // AI outputs land in a namespace **structurally** separate from user-authored metadata: the @@ -175,6 +215,7 @@ mod tests { use super::super::fast_workspace; use super::*; + use crate::crypto::verify_asset::VerifyOutcome; use crate::ml::{CANONICAL_PARTITION, FixtureRunner, TaskKind}; use crate::sidecar::sidecar_v1::{SIDECAR_SCHEMA_V1, SidecarV1}; @@ -300,6 +341,111 @@ mod tests { assert_eq!(ws.ai_tags(&id).unwrap().len(), 2); } + // ── Capture-time correction (S-B17) ────────────────────────────────────────────────────── + + /// The `S-B17` write, end to end: the correction lands as a new signed revision, the bundle + /// stays in the directory the import chose, the index row follows the sidecar at once, and + /// a rebuild from disk agrees with the live row — the two projections + /// (`asset_row_from_state` and `rebuild::signed_asset_row`) name the same instant. + #[test] + fn a_capture_correction_is_a_signed_revision_that_leaves_the_bundle_in_place() { + use crate::library::{open_library, rebuild_index}; + + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (mut ws, id) = workspace_with(&lib, &src, b"\xFF\xD8\xFF capture correction bytes"); + + // No EXIF in those bytes, so the import stamped the import clock — the pre-`S-B16` + // shape every affected asset has. + let before = ws.asset(&id).unwrap(); + let shard = before.capture_utc; + let original = ws + .original_path(&id) + .expect("a managed asset has an original"); + assert!(original.is_file()); + assert_eq!(before.chain.records().len(), 1); + let recorded = before.sidecar.capture_timestamp.clone(); + + let corrected = Timestamp::from_second(1_000_000_000).unwrap(); + ws.set_capture_timestamp(&id, corrected).unwrap(); + + // A new signed revision: one more record, a `metadata-update`, and the asset verifies. + let after = ws.asset(&id).unwrap(); + assert_eq!(after.sidecar.capture_timestamp, "2001-09-09T01:46:40Z"); + assert_ne!(after.sidecar.capture_timestamp, recorded); + assert_eq!(after.chain.records().len(), 2); + assert_eq!( + after.chain.records().last().unwrap().manifest.core.action, + Action::MetadataUpdate + ); + assert!(after.sidecar.signature.is_some()); + assert_eq!(ws.verify(&id).unwrap(), VerifyOutcome::Accept); + + // The shard — and therefore every artifact path — is exactly where it was. + assert_eq!(after.capture_utc, shard, "the bundle is not relocated"); + assert_eq!(ws.original_path(&id).unwrap(), original); + assert!(original.is_file()); + assert!(!original.to_string_lossy().contains("2001-09")); + + // The live index row follows the sidecar immediately, not the shard. + let row = ws + .db() + .find_by_uuid(&id.to_string()) + .unwrap() + .expect("indexed"); + assert_eq!(row.capture_timestamp, 1_000_000_000); + assert_eq!(row.capture_utc, Some(1_000_000_000)); + + // Reopened from disk, the correction holds and the files stay reachable. + let root = lib.path().to_path_buf(); + drop(ws); + let ws = Workspace::open( + &root, + b"passphrase", + crate::crypto::primitives::Argon2Params { + mem_kib: 64, + t_cost: 1, + p_cost: 1, + }, + ) + .unwrap(); + let reopened = ws.asset(&id).unwrap(); + assert_eq!(reopened.sidecar.capture_timestamp, "2001-09-09T01:46:40Z"); + // `open` reconciles a sidecar that no longer names its directory by taking the shard + // from the directory itself (the month's first instant), which is the same directory. + assert_ne!( + reopened.capture_utc, 1_000_000_000, + "the shard is not the corrected instant" + ); + assert_eq!(ws.original_path(&id).unwrap(), original); + assert!(ws.read_plaintext(&id).is_ok()); + assert_eq!(ws.verify(&id).unwrap(), VerifyOutcome::Accept); + + // A rebuild from the artifacts on disk projects the same instant the live row did. + drop(ws); + std::fs::remove_file(root.join("index/library.sqlite")).unwrap(); + let library = open_library(&root).unwrap(); + rebuild_index(&library).unwrap(); + let rebuilt = library + .db + .find_by_uuid(&id.to_string()) + .unwrap() + .expect("back in the rebuilt index"); + assert_eq!(rebuilt.capture_timestamp, 1_000_000_000); + assert_eq!(rebuilt.capture_utc, Some(1_000_000_000)); + } + + #[test] + fn a_capture_correction_on_an_unknown_asset_is_refused() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (mut ws, _) = workspace_with(&lib, &src, b"\xFF\xD8\xFF some bytes"); + let err = ws + .set_capture_timestamp(&Uuid::now_v7(), Timestamp::UNIX_EPOCH) + .unwrap_err(); + assert!(matches!(err, LifecycleError::NotFound(_)), "{err:?}"); + } + // ── The inference seam (`ml::AiTagSink` / `ml::AssetSource`) ───────────────────────────── /// The trait forwards must land in exactly the same place the inherent methods do. This is diff --git a/capsule-core/src/lifecycle/mod.rs b/capsule-core/src/lifecycle/mod.rs index 4b3d7da9..3622e0b2 100644 --- a/capsule-core/src/lifecycle/mod.rs +++ b/capsule-core/src/lifecycle/mod.rs @@ -553,6 +553,20 @@ impl Workspace { self.assets.keys().copied().collect() } + /// The on-disk path of a managed asset's plaintext original, or `None` for an unknown id. + /// + /// The path is derived from the asset's **shard** (`AssetState::capture_utc`), which is + /// fixed at import — so it keeps resolving after a capture-time correction + /// ([`set_capture_timestamp`](Self::set_capture_timestamp)) even though the sidecar's + /// timestamp no longer names the month directory. Exposed for the repair pass, which has + /// to re-read each original's EXIF without loading every file through + /// [`read_plaintext`](Self::read_plaintext). + pub fn original_path(&self, asset_id: &Uuid) -> Option { + self.assets + .get(asset_id) + .map(|asset| self.media_path(asset)) + } + /// A managed asset's current state. pub fn asset(&self, asset_id: &Uuid) -> Option<&AssetState> { self.assets.get(asset_id) From fc7a6d17a20c56bd771890c087e184eaee09f1e6 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 02:39:32 -0400 Subject: [PATCH 101/243] fix(core): restore lifecycle::upload's test module declaration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `de756e90` rewrote the `read_derivative_bytes` doc comment by replacing the tail of the file from that comment onward, and the replacement did not carry the last two lines with it. `#[cfg(test)] mod tests;` was deleted, so `lifecycle/upload/tests.rs` stayed tracked, stayed green in review, and stopped being compiled at all. Thirteen tests went dark. Nine were the ones that prove this PR's central claims — the decision-18 KAT that a derivative ships as ciphertext and decrypts back to the bytes on disk, the tampered-derivative skip, the sentinel contributing no blob, both arms of the closed-format check, the missing-bytes skip, the pushed-thumbnail-differs assertion, the survives-a-reopen case (F3) and the two-formats-by-format case (F5). The last two were added in the same commit that deleted the declaration, so they had never been compiled even once. Four more were pre-existing S-D18 coverage that had passed at `4f8b8bda`. All thirteen pass unmodified against the current `derivative_blobs(&self, asset, album, epoch)` signature, so the tests were right and only their declaration was missing. This is the second time in this branch that a whole-region replacement silently dropped code — the same failure class recorded for `fe1e3c97`. The difference is that a lost `mod` declaration cannot be caught by reading the diff of the file it belongs to: it presents as a passing suite. `cargo nextest list` is the check that sees it, and its census for this module now goes in the pull request rather than a summary line. --- capsule-core/src/lifecycle/upload.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/capsule-core/src/lifecycle/upload.rs b/capsule-core/src/lifecycle/upload.rs index 3c5f88ba..43989bed 100644 --- a/capsule-core/src/lifecycle/upload.rs +++ b/capsule-core/src/lifecycle/upload.rs @@ -336,3 +336,6 @@ fn read_derivative_bytes( let extension = DerivativeFormat::parse(format)?.extension()?; fs::read(dir.join(format!("{stem}.{role_name}.{extension}"))).ok() } + +#[cfg(test)] +mod tests; From c359f105cdab69a407601bb984bcf0358fd3d04a Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 02:39:45 -0400 Subject: [PATCH 102/243] docs: correct the summaries this lane's adapters falsify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four documents said things that stopped being true, and two of them said things that were never true. **AGENTS.md** listed PostgreSQL among the adapters for authentication state and upload-session state. `filesystem/server.md` — the owner document — rejects a Postgres-resident session table outright: it "would be a second implementation of the same contract; Capsule ships exactly one", and it records the Postgres-instead-of-Valkey fallback as considered and rejected because emulating TTL and expiry in SQL is the generic TTL abstraction the module map declines to introduce. The bullet is a summary of that document and disagreed with it. It now says what PostgreSQL *is* the adapter for: the durable records. **module-map.md**'s register said the same thing in its PostgreSQL row ("default implementations of the two typed state ports") and had the `redis-rs` row promising parity "with the PostgreSQL and in-memory adapters" for ports that have no PostgreSQL adapter and are not getting one. Both rows are corrected, and the PostgreSQL row's acceptance gaps are marked discharged for the four adapters that now exist rather than left as a list nobody has walked. **`capsule-server/src/lib.rs` and its README** both said "every adapter is in-memory" and "no Postgres, Valkey or filesystem adapter is written". Four are now. They also say what has not changed and is the reason the ordering was deliberate: the suite still runs without a container, because every Postgres case is gated and prints one line naming itself when it skips. The README's operator synopsis said `--memory` is required because the only index adapter is the in-memory one, which is exactly what stopped being true; the real reason is the collector's marks (#446) and the upload sessions the scrub reconciles (#403). **SLICES.md**: `S-C2`, `S-C29` and `S-C37`. `S-C37`'s owed line said the Postgres adapter is "where the row lock this design depends on actually lives — the in-memory adapter's mutex stands in for it and proves nothing about it", which was the whole remainder and is closed; its status follows `S-C21`'s precedent in the same lane, a RETIRED row whose defect the rebuild closed. `S-C29`'s owed line now names only the Valkey adapters, and records the `Harness`/`CohortHarness` split and the `store/mod.rs` correction the cohort map's adapter forced. `S-C2` records that the feed's Postgres half is `index/postgres.rs` and that its paging and monotonicity cases run there unchanged, which is the point of the suite living in `src/`. Refs #402 --- AGENTS.md | 2 +- SLICES.md | 56 ++++++++++++----- .../src/content/docs/design/module-map.md | 4 +- capsule-server/README.md | 63 ++++++++++++------- capsule-server/src/lib.rs | 26 +++++--- 5 files changed, 105 insertions(+), 46 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ecdfc94a..6667d2c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,5 +41,5 @@ - Generate clients with Spargen from the checked-in Kynos OpenAPI contract. Do not use Progenitor. Everything that parses or serializes is generated — every body, every typed parameter, and the byte-serving endpoints. Only *orchestration over* generated calls is hand-written, and the resumable upload state machine (`S-D1`) is the whole of it; do not hand-write a second parser. - Rawshift is the intended owner of media decoding, metadata extraction, and derivative generation, consumed through `capsule-core::media` once Rawshift stabilizes. Neither exists today: Rawshift is a pinned submodule and not a workspace dependency, and `capsule-core::media` has no body to write until it is one — so nothing in Capsule decodes media right now. Capsule imports **Chromahash 0.7.1** directly, never through Rawshift, and LQIP encode/decode lives in its own `capsule-core::lqip` module (slice `S-B14`) so one implementation serves the import pipeline, the FFI, and `capsule-wasm`. **ThumbHash is retired**: neither the `thumbhash` crate nor the npm `thumbhash` package may be reintroduced. Contract: [Thumbnails — LQIP](capsule-docs/src/content/docs/design/thumbnails.md#lqip). - Blob storage and resumable encrypted upload remain Capsule-owned behind narrow, arbitrary-backend ports. Do not add `object_store` or generic CAS/transfer crates without revisiting the security contract. -- Keep authentication state and upload-session state as separate Capsule ports with PostgreSQL, `redis-rs`, and in-memory adapters. Do not introduce a generic TTL/CAS abstraction. +- Keep authentication state and upload-session state as separate Capsule ports. Their adapters are **`redis-rs` and in-memory**, not PostgreSQL: [Filesystem — Server](capsule-docs/src/content/docs/design/filesystem/server.md) rejects a Postgres-resident session table outright — it "would be a second implementation of the same contract; Capsule ships exactly one" — and records the Postgres-instead-of-Valkey fallback as considered and rejected, because emulating TTL and expiry in SQL is the generic TTL abstraction the module map declines to introduce. PostgreSQL is the adapter for the **durable** ports: the asset index, the account cluster, the device-cohort map, the quota ledger and the rest of the library's records. Do not introduce a generic TTL/CAS abstraction. - `legacy-review/` is non-buildable reference material. Restore code only after defining its contract and automated tests against the decisions above. diff --git a/SLICES.md b/SLICES.md index ce7d3266..9ac8c572 100644 --- a/SLICES.md +++ b/SLICES.md @@ -229,7 +229,7 @@ row's remainder now lives. | S-B16 | Every import stamped by import time, not capture time | media/import | — | S | ACTIVE | done | found by the CLI round-trip test | | S-B17 | Repair capture timestamps written before `S-B16` | media/import | S-B16 | M | ACTIVE | ready | the wrong value is in *signed* bytes | | S-C1 | Upload-server hardening (envelope gate + invariants) | server | — | L | RETIRED | done\* | discard worker, asset index and quota not ported | -| S-C2 | Key-free sync feed | server | S-C1 | L | RETIRED | done\* | ported to Kynos REST; Postgres adapter + cursor-key loading owed | +| S-C2 | Key-free sync feed | server | S-C1 | L | RETIRED | done\* | ported to Kynos REST over `S-C37`'s index, now Postgres-backed; cursor-key loading owed | | S-C3 | Storage-verification endpoint | server | S-C35, S-C37 | M | RETIRED | done\* | structural verdict only; the `deep` re-hash → `S-C41`; GC state → `S-C11` | | S-C4 | Share-link serving endpoints | server | S-A5 | M | RETIRED | done\* | the serve-path privacy strip is unimplementable on a key-free server → `S-C50`; limiters → `S-C32` | | S-C5 | Drop store, inbox, atomic adoption | server | S-A6, S-C1, S-C6 | L | RETIRED | done\* | adoption is a two-phase claim, not a transaction; limiters → `S-C32` | @@ -264,7 +264,7 @@ row's remainder now lives. | S-C34 | Nothing gates the Kynos OpenAPI document | server | — | S | RETIRED | done | two documents gated separately until parity | | S-C35 | The blob store port, sharded | server | S-C27 | L | RETIRED | done | wired by `S-C1`, which also found a missing operation | | S-C36 | Kynos's framework rejections carry no `error.*` code | server | S-C33 | M | RETIRED | done | a Capsule interceptor fills the member in; the upstream seam is still the better fix | -| S-C37 | The asset index port, one sequence instead of two | server | S-C27, S-C29 | L | RETIRED | done\* | Postgres adapter owed; absorbs `S-C21` and unblocks `S-C22` | +| S-C37 | The asset index port, one sequence instead of two | server | S-C27, S-C29 | L | RETIRED | done | Postgres adapter landed under the row lock the design rests on; absorbs `S-C21`, unblocks `S-C22` | | S-C38 | Problem extensions are absent from the OpenAPI document | server | S-C34 | M | RETIRED | done\* | `code` is universal and derived; the sixteen other members ride a small table | | S-C39 | Blob fetch has no read authority, so its `403` is unwritable | server | S-C10 | M | RETIRED | part | the authority lands and owner-scopes the path; the `403` needs a membership fact → `S-C51` | | S-C40 | `awaiting-original` is not observable on the blob path | server | S-C10, S-C37 | M | RETIRED | done | the promise is the open upload session, so it needed no lifetime of its own | @@ -1146,9 +1146,14 @@ Lane D while indexing it `server`; it is filed correctly here, in numeric order. entry rather than a forbidden one. The owner is therefore MAC **input**, not a field beside the MAC, and `another_owners_cursor_is_refused_even_under_the_right_key` is the case that says so. The retired implementation never had the property its own design doc claimed. -- **Owed:** the Postgres adapter behind `S-C37`, and key loading — nothing reads - `SYNC_CURSOR_MAC_KEY` or `JWT_ED25519_DER` yet, so the codec is constructed from a literal at - every call site including the tests. +- **The Postgres adapter behind it landed 2026-09-02 (#402).** The feed reads through + `AssetIndex`, so the whole of what the sync surface needed from Postgres is + `index/postgres.rs`: a page is `WHERE owner_id = $1 AND sync_seq > $2 ORDER BY sync_seq`, and + the `ChangeKind` rule that makes a `Created` for one reader an `Updated` for another is the + same shared `entry_for` both adapters render an entry with. The suite's paging and + monotonicity cases run against Postgres unchanged, which is the point of their being in `src/`. +- **Owed:** key loading — nothing reads `SYNC_CURSOR_MAC_KEY` or `JWT_ED25519_DER` yet, so the + codec is constructed from a literal at every call site including the tests. ### S-C3 — Storage-verification endpoint @@ -2330,9 +2335,18 @@ working on a surface written after it. serializable payload, that TTL is a property of the store rather than an argument, and that a session record and its per-user index entry cannot be addressed separately — which is what made the `revoke_all_for_user` over-count unrepresentable rather than fixed twice. -- **Owed:** the Valkey and Postgres adapters. Every port has an in-memory adapter and one shared - conformance suite, so "the double behaves like Valkey" is an assertion rather than an - assumption. Counters were deliberately excluded and became `S-C32`. +- **The cohort map's durable adapter landed 2026-09-02 (#402).** `CohortStore` is the one port + here that is not Valkey's, and the module's own docs say why: a session store forgets a cohort + exactly when "have I seen this device before?" becomes worth asking, so the map has to outlive + the sessions that carried it. The suite's `Harness` was split for it — `CohortHarness` carries + the cohort map and the time seam, `Harness` extends it with the five volatile stores — because + a Postgres-backed harness would otherwise have to implement five adapters it will never have. + The same change corrected `store/mod.rs`'s claim that three adapters were planned per port, + which was never true of any port here and was the one line in the tree pointing at the Postgres + session table the module's own rejection paragraph refuses. +- **Owed:** the Valkey adapters for the five volatile stores (#403). Every port has an in-memory + adapter and one shared conformance suite, so "the double behaves like Valkey" is an assertion + rather than an assumption. Counters were deliberately excluded and became `S-C32`. - **Done when:** ✅ the conformance suite passes against the in-memory adapter, case by case and in one pass. **Tier:** Unit. @@ -2545,12 +2559,26 @@ carry a schema, and `S-C48` wants a `503` an `Authenticator` can render. One sea - **Done when:** every adapter passes one conformance suite; an upload becomes visible on the feed of the account that made it and on no other; and no sequence number the index mints is unreachable through paging. **Tier:** Unit + conformance. -- **Landed to the in-memory tier — 2026-08-30 (`done\*`).** `capsule-server/src/index/`, 17 - conformance cases, wired into both the upload path (reserve at create, record at finalize) and - the feed, so "upload it, then read it back" is a test of the server rather than of two - disconnected doubles. **Owed:** the Postgres adapter, which is where the row lock this design - depends on actually lives — the in-memory adapter's mutex stands in for it and proves nothing - about it. +- **Landed to the in-memory tier — 2026-08-30.** `capsule-server/src/index/`, conformance cases + wired into both the upload path (reserve at create, record at finalize) and the feed, so + "upload it, then read it back" is a test of the server rather than of two disconnected doubles. +- **Landed to Postgres — 2026-09-02 (#402), which closes the row it was owed.** + `index/postgres.rs` puts the sequence mint inside the transaction that makes the row readable: + `SELECT … FOR UPDATE` on the asset row, then an upsert on `owner_sequences`, then the state + flip, then `COMMIT`. Never a `SEQUENCE` or a `bigserial` — `nextval` is non-transactional and + hands 5 and 6 to two concurrent finalizations without rolling back, which is the skip window + `S-C21` is about. The in-memory adapter's mutex stood in for that lock and proved nothing about + it; the lock now exists, and both adapters pass one case list. +- **What the shared suite found while the adapter was written.** `AssetIndex::rows` — the + scrub's walk — had no conformance case at all, so two cases were added: that it covers pending, + visible and tombstoned rows and resumes at any page size, and that it orders by the + identifier's own bytes. The second is why the adapter pins `COLLATE "C"`: asset ids are the + manifest's client-chosen `file_id` and are full of punctuation, a glibc PostgreSQL ignores `-` + at the primary collation level, and a cursor handed between two adapters that disagree about + that skips rows. +- **`S-C11`'s remainder is not this row's.** The collector reads this index and writes its marks + to `CollectionStore`, which has no durable adapter yet (#446), so `gc`/`purge`/`scrub` still + require `--memory`. ### S-C38 — problem extensions are absent from the OpenAPI document diff --git a/capsule-docs/src/content/docs/design/module-map.md b/capsule-docs/src/content/docs/design/module-map.md index 0f50199e..5dc3c76a 100644 --- a/capsule-docs/src/content/docs/design/module-map.md +++ b/capsule-docs/src/content/docs/design/module-map.md @@ -108,8 +108,8 @@ the named acceptance gaps are verified with contract fixtures or a minimal spike | Rawshift | Media detection, decoding/encoding, metadata normalization, derivatives, previews, and video processing | Required format/codec matrix; bounded memory and concurrency; cancellation/progress; malformed-input isolation; deterministic orientation/color/HDR behavior; normalized metadata provenance; mobile/desktop targets; no Chromahash API | | Chromahash **0.7.1** | LQIP encode/decode only, imported directly by Capsule | Deterministic output; wide-gamut/HDR fixtures; decoder fallback behavior; supported FFI targets. The pin and the retired ThumbHash decision are [Dependencies](/design/dependencies/#rust) | | OpenMLS | MLS protocol and cryptographic state transitions | Required cipher suites and credential model; deterministic persistence/restore; external signer integration; epoch/exporter behavior; cross-platform size/performance; Capsule-owned album policy and provenance stay outside it | -| PostgreSQL driver/ORM | Durable server index and default implementations of the two typed state ports | Transactions needed for finalization, row locking, migration strategy, cancellation, typed error mapping, tracing, and adapter conformance. Select the narrowest mature stack after Kynos integration is proven | -| `redis-rs` | Required Valkey adapters for `AuthStateStore` and `UploadSessionStore` | Atomic compare/update and expiry primitives required by each port; cluster behavior; cancellation/timeouts; tracing; behavioural parity with the PostgreSQL and in-memory adapters under one conformance suite — parity is what lets the in-memory double be trusted in tests, not a claim that Valkey is [substitutable](/design/filesystem/server/#required-services) | +| PostgreSQL driver/ORM (`sea-orm`/`sqlx-postgres`) | The durable server records: the asset index, the account cluster, the device-cohort map, the quota ledger and the library's remaining rows. **Not** the two typed state ports — a Postgres-resident session table is [rejected](/design/filesystem/server/#required-services) as a second implementation of one contract | Transactions needed for finalization, row locking, migration strategy, cancellation, typed error mapping, tracing, and adapter conformance — all discharged for the first four adapters, whose suites run against the deterministic double and against a container under `CAPSULE_TEST_POSTGRES=1`. Migrations are applied by a separate binary and `serve` refuses to boot against a schema it was not built for | +| `redis-rs` | Required Valkey adapters for `AuthStateStore`, `UploadSessionStore` and the ceremony stores — the volatile half, and the only production adapter any of them gets | Atomic compare/update and expiry primitives required by each port; cluster behavior; cancellation/timeouts; tracing; behavioural parity with the in-memory double under one conformance suite — parity is what lets that double be trusted in tests, not a claim that Valkey is [substitutable](/design/filesystem/server/#required-services). Parity with the PostgreSQL adapters is not a goal, because no port has both | | RustCrypto, `ciborium`, `rusqlite`, `sqlite-vec`, UniFFI, `wasm-bindgen` | Existing crypto primitives, canonical serialization, local catalog and vector index, native bindings, and the browser boundary | Continue vectors, canonical-byte tests, migration tests, and binding smoke tests; these libraries do not own Capsule protocols or schemas | Explicit non-dependencies: no generic CAS crate, `object_store`, resumable-transfer library, generic diff --git a/capsule-server/README.md b/capsule-server/README.md index 7d7f373d..f746cbb8 100644 --- a/capsule-server/README.md +++ b/capsule-server/README.md @@ -25,20 +25,35 @@ microservices. `routes` is the only module that knows about HTTP; everything und framework-free and testable without a router, which is why the operator workers (`gc`, `scrub`) have no wire surface at all. -Authentication state and upload-session state stay behind separate Capsule-owned ports with -Postgres, Valkey and deterministic in-memory adapters. There is no generic CAS, transfer or TTL +Authentication state and upload-session state stay behind separate Capsule-owned ports whose +adapters are Valkey and a deterministic in-memory double — not Postgres, which +[Filesystem — Server](../capsule-docs/src/content/docs/design/filesystem/server.md) rejects for a +session table. The durable records go to Postgres. There is no generic CAS, transfer or TTL abstraction, and none is planned. -## Every adapter is in-memory +## Every port has a double, and four of them have Postgres besides -Every port here has a deterministic in-memory adapter and a conformance suite, and **no Postgres, -Valkey or filesystem adapter is written** except the blob store's. That is an ordering rather than -an omission: the contract and its suite are what a real adapter is written *against*, and a port -with two implementations before it has one suite is a port whose implementations will disagree. +Every port here has a deterministic in-memory adapter and a conformance suite, and that ordering +was deliberate rather than an omission: the contract and its suite are what a real adapter is +written *against*, and a port with two implementations before it has one suite is a port whose +implementations will disagree. -It is also why this crate's whole test suite runs without a container. +Four ports now have the second implementation (#402) — the asset index, the account cluster, the +device-cohort map and the quota ledger — each passing the same case list as its double, against a +Postgres container. The remaining durable ports are #446's; the volatile ones are Valkey's (#403). -Two of those adapters live beside the ports rather than in `tests/support/`: `auth::accounts_memory` +**The test suite still runs without a container.** Every Postgres case is gated on +`CAPSULE_TEST_POSTGRES=1` and prints one line naming itself when it is skipped: + +```sh +cargo nextest run -p capsule-server # green, and says what it did not prove +CAPSULE_TEST_POSTGRES=1 cargo nextest run -p capsule-server -E 'test(postgres_conformance)' +``` + +On a rootless podman host that also needs `DOCKER_HOST` pointing at the user socket and +`CAPSULE_TEST_CONTAINER_USERNS=keep-id`; `capsule_server::postgres::testing` says why. + +Two of the in-memory adapters live beside the ports rather than in `tests/support/`: `auth::accounts_memory` and `auth::totp`'s `InMemoryTotp`. The account ports' docs say a double in `src/` would be "a fake credential directory shipped inside the server binary", and that reasoning is about a **double** — `tests/support/mod.rs`'s, which accepts whatever password it was told to accept. These verify with @@ -86,9 +101,11 @@ capsule-server [--config PATH] scrub [--deep] [--budget BYTES] --memory --blob-root PATH gen-openapi [FILE] [--check] -`--memory` is written as required on the three operator commands because today it is: they -compare the index against the blob store, and the only index adapter written is the in-memory -one. Without it they refuse and say so. It becomes optional when #402 lands. +`--memory` is written as required on the three operator commands because today it is — and the +durable index is no longer why. Both workers read a store that has no durable adapter: the +collector marks a blob on one pass and sweeps it on a later one, so a mark store that forgets can +only ever mark (#446), and the scrub reconciles the index against the upload sessions, which is +how it tells a live transfer from an orphan (#403). Without `--memory` they refuse and say so. ``` `config` reads every setting an operator decides — command-line flag over environment over @@ -114,12 +131,16 @@ the two that write; `scrub` mutates nothing and exits non-zero on a non-empty re ## What is owed -**No Postgres or Valkey adapter.** `DATABASE_URL` and `VALKEY_URL` are read into the -configuration and no adapter consumes either, so `serve` without `--memory` refuses and names -the issue that will honour it: the account, album and index adapters are one issue and the -session and upload-session adapters another. - -That ordering is deliberate rather than unfinished, for the reason above: the contract and its -conformance suite are what a real adapter is written *against*. What `--memory` therefore buys is -not durability — the blob store is the only durable half — but a running surface to write those -adapters against and to point a client at. +**No Valkey adapter, and nine durable ports still without a Postgres one.** `DATABASE_URL` is +read: a durable `serve` opens the pool from it and refuses a schema it was not built for, naming +`capsule-server-migration up`. `VALKEY_URL` is not read yet, so a durable `serve` gets through +the Postgres half and then refuses, naming #403 — the session, upload-session, ceremony and +counter state. The remaining durable adapters (albums, the device directory, moderation, shares, +drops, escrow, revocations, the collector's marks, receipts, TOTP) are #446. + +Refusing rather than filling those ports with in-memory adapters is the point: +[Filesystem — Server](../capsule-docs/src/content/docs/design/filesystem/server.md) says required +means required, and a server that came up holding session state it will lose on the next restart +is worse than one that does not start. What `--memory` therefore buys is not durability — the +blob store and, on the durable path, Postgres are the durable halves — but a running surface to +write the remaining adapters against and to point a client at. diff --git a/capsule-server/src/lib.rs b/capsule-server/src/lib.rs index 32377fa4..3f8c00ca 100644 --- a/capsule-server/src/lib.rs +++ b/capsule-server/src/lib.rs @@ -21,8 +21,10 @@ //! //! One application composed from cohesive internal modules — not separate public transports or //! microservices (design/module-map.md, "Planned Server Modules"). Authentication state and -//! upload-session state stay behind separate Capsule-owned ports with Postgres, Valkey and -//! deterministic in-memory adapters; no generic CAS, transfer or TTL abstraction is planned. +//! upload-session state stay behind separate Capsule-owned ports whose adapters are Valkey and a +//! deterministic in-memory double — **not** Postgres, which design/filesystem/server.md rejects +//! for a session table — while the durable records go to Postgres. No generic CAS, transfer or +//! TTL abstraction is planned. //! //! Each module owns one port and, where it has one, the surface over it. [`routes`] is the only //! module that knows about HTTP: everything under it — [`album`], [`directory`], [`discovery`], @@ -40,14 +42,22 @@ //! tests here and by the binary identically, so "the server can be built at all" is an //! assertion rather than something discovered on a deployment. //! -//! # Every adapter is in-memory +//! # Every port has a double, and four of them have Postgres besides //! //! Every port in this crate has a deterministic in-memory adapter and a conformance suite, and -//! **no Postgres, Valkey or filesystem adapter is written** except the blob store's. That is a -//! deliberate ordering rather than an omission: the contract and its suite are what a real -//! adapter is written *against*, and a port with two implementations before it has one suite is -//! a port whose two implementations will disagree. It is also why this crate's whole test suite -//! runs without a container. +//! that ordering was deliberate rather than an omission: the contract and its suite are what a +//! real adapter is written *against*, and a port with two implementations before it has one +//! suite is a port whose two implementations will disagree. +//! +//! Four ports now have the second implementation (#402) — [`index`]'s asset index, [`auth`]'s +//! account cluster, [`store`]'s device-cohort map and [`quota`]'s ledger — each passing the same +//! case list as its double. The remaining durable ports are #446's and the volatile ones are +//! Valkey's (#403). +//! +//! **The suite still runs without a container.** Every Postgres case is gated on +//! `CAPSULE_TEST_POSTGRES=1` and prints one line naming itself when it is skipped, so +//! `cargo nextest run` on a machine with no container runtime is green and says what it did not +//! prove — which is the acceptance gap design/module-map.md sets for the framework. pub mod album; pub mod app; From 5a486852e2c2f8dd75a8997e13dccb081e594c97 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 02:47:20 -0400 Subject: [PATCH 103/243] fix(core): refuse a reused derivative nonce prefix, and stop two panics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 2, findings M2-M4 and L5-L10. **M4 — the reuse refusal now exists.** The encryption doc is normative: "the writer additionally refuses to emit a `nonce_prefix` it has already used for that `file_id` … the same rule governs derivative re-encryption". The sealer passed `replaces: None`, so nothing was ever refused and the sentence was false for every derivative. It now carries the set of prefixes already spent on this `file_id` — the original's, plus every prefix in the existing bundle, which the bundle reader already had to open — redraws a collision, and adds each sealed prefix before the next seal. An exhausted draw is `MediaError::Sign`: a 1-in-2^56 collision eight times running is a broken CSPRNG, which is a workspace fault, and decision 22 propagates those rather than writing one derivative fewer. A prefix is folded into the file-key salt, so reusing one reuses the *key* — two blobs under one keystream, which is what the construction exists to prevent. **M2 — an unheld epoch no longer panics the bundle.** `file_key` indexes `album.amks[&epoch]` with an epoch read off an unverified `.cbor`, inside a function contracted never to fail. It needs no tampering to reach: an album recovered from a backup holds only the epochs it escrowed. It is now the fifth skip reason, and the rustdoc enumerates five. **M3 — embedding-role manifests are named out of scope.** `F5` keyed the reader by `(role, format)`, and `embedding/{model_id}` parses to no still format, so every embedding manifest fell through to "no bytes on disk" — a misleading warning for an artefact with no writer, since `crate::ml` produces none. They are skipped at `debug!` and the doc says why. **L9/L10 — the two failure paths are tested through the real import.** The previous F2 test called `guarded` itself, so deleting every production call site left it green. Both now drive a fault through `Workspace::import_asset_with` via a `#[cfg(test)]` hook inside the sealer — absent from a release build, not merely disabled — and assert what actually matters: `DecodeFailed` reported, and the original committed, signed and self-verifying, with real dimensions and a real placeholder. Both were mutation-checked: reverting the match arm to `?` fails the first; removing the guard aborts the second. L5-L8 are doc corrections: the closed set is named at `crate::derivative_format` and described as linkable without `media`; the two `# Errors` blocks route sealing to `Sign`; the `{uuid}.{role}.` prefix-scan description is replaced by the exact-path composition that superseded it; and two WebP leftovers in the tests. --- .../src/crypto/provenance/manifest.rs | 17 +- capsule-core/src/lifecycle/derivatives.rs | 457 ++++++++++++++++-- capsule-core/src/lifecycle/upload.rs | 51 +- capsule-core/src/lifecycle/upload/tests.rs | 102 +++- capsule-core/src/media/derivative.rs | 6 +- capsule-core/src/media/tests.rs | 6 +- 6 files changed, 581 insertions(+), 58 deletions(-) diff --git a/capsule-core/src/crypto/provenance/manifest.rs b/capsule-core/src/crypto/provenance/manifest.rs index 787a8fa1..217b26f6 100644 --- a/capsule-core/src/crypto/provenance/manifest.rs +++ b/capsule-core/src/crypto/provenance/manifest.rs @@ -261,12 +261,17 @@ pub struct DerivativeCore { /// /// The closed set is enforced at the two boundaries instead: production, because /// `media::generate_still_derivatives` only ever writes - /// `media::DerivativeFormat::mime`; and verification, via `media::verify_still_format`, - /// which rejects a still-role manifest whose value is outside the set and leaves the - /// embedding-role grammar alone. Both live behind the `media` feature, which is where the - /// tier table's format column belongs; this field stays feature-independent because - /// `capsule-server` and `capsule-wasm` must be able to *read* a manifest without linking a - /// codec. SSoT: [Thumbnails](https://docs/design/thumbnails/). + /// [`DerivativeFormat::mime`](crate::derivative_format::DerivativeFormat::mime); and + /// verification, via + /// [`verify_still_format`](crate::derivative_format::verify_still_format), which rejects a + /// still-role manifest whose value is outside the set and leaves the embedding-role grammar + /// alone. + /// + /// Both live in [`crate::derivative_format`], which is **unconditional** — deliberately not + /// behind the `media` feature. `capsule-server` and `capsule-wasm` build + /// `default-features = false`, and they are exactly the crates that receive a manifest they + /// did not author, so a check they could not link would be a closed set only its producer + /// could evaluate. SSoT: [Thumbnails](https://docs/design/thumbnails/). pub format: String, /// Content-address digest over the derivative ciphertext. pub ciphertext_hash: Hash32, diff --git a/capsule-core/src/lifecycle/derivatives.rs b/capsule-core/src/lifecycle/derivatives.rs index d7bb2e16..54bff749 100644 --- a/capsule-core/src/lifecycle/derivatives.rs +++ b/capsule-core/src/lifecycle/derivatives.rs @@ -19,7 +19,8 @@ //! that mean the *workspace* is broken — a missing album, a signer that refused — not the ones //! that mean the pixels were unreadable. -use std::collections::HashMap; +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; use std::fs; use std::path::Path; @@ -27,12 +28,13 @@ use uuid::Uuid; use super::{AssetState, DerivativeStatus, LifecycleError, Result, Workspace, media_dir}; use crate::cbor; -use crate::crypto::encryption::encrypt_asset_rekey; -use crate::crypto::encryption::stream::AssetEncryption; +use crate::crypto::encryption::rekey::encrypt_asset_rekey_with_prefix; +use crate::crypto::encryption::stream::{AssetEncryption, NONCE_PREFIX_LEN}; use crate::crypto::hash::{self, Hash32}; use crate::crypto::keys::{Amk, AmkVersion}; use crate::crypto::primitives::{CRYPTO_SUITE_ID, PROTOCOL_VERSION}; use crate::crypto::provenance::{DerivativeManifest, DerivativeRole}; +use crate::crypto::rng; use crate::exif::extract::ExifExtract; use crate::lqip::Lqip; use crate::media::{ @@ -177,31 +179,57 @@ fn lqip_from(decoded: &DecodedImage, src: &Path) -> Option { } } -/// The current head of each derivative role's chain for `asset_id`, read off the persisted -/// bundle. +/// What an asset's existing derivative bundle constrains about the next generation. /// -/// Empty when the asset has no bundle yet, which is every import: a create starts each role's -/// chain. It is a **regeneration** — the `#437` backfill that adds a second format to an asset -/// that already has one — that needs this, and it needs it to be right the first time, because a -/// forked chain is not something a later run can repair. +/// One read, two facts, because both come off the same file and both are needed together. +pub(super) struct ExistingDerivatives { + /// The current head of each role's chain. Empty when the asset has no bundle yet, which is + /// every import: a create starts each role's chain. It is a **regeneration** — the `#437` + /// backfill that adds a second format to an asset that already has one — that needs this, + /// and it needs it to be right the first time, because a forked chain is not something a + /// later run can repair. + /// + /// The link is SHA-256 over the manifest's canonical CBOR, signatures included: the same + /// content-hash link the asset provenance chain uses. + pub(super) heads: HashMap, + /// Every `nonce_prefix` already used for this `file_id` by a derivative. + /// + /// The encryption doc makes the refusal normative: "the writer additionally refuses to emit + /// a `nonce_prefix` it has already used for that `file_id` … the same rule governs + /// derivative re-encryption". A prefix is folded into the file-key salt, so reusing one + /// reuses the *key* as well as the nonce — the keystream separation the whole construction + /// rests on. + pub(super) used_prefixes: HashSet<[u8; NONCE_PREFIX_LEN]>, +} + +/// Read `asset_id`'s persisted derivative bundle, if it has one. /// -/// The link is SHA-256 over the manifest's canonical CBOR, signatures included: the same -/// content-hash link the asset provenance chain uses. -pub(super) fn chain_heads(dir: &Path, asset_id: Uuid) -> HashMap { +/// A bundle that does not decode yields the empty answer *and a warning*: treating an +/// unreadable bundle as "no constraints" is the safe direction for the chain (a role restarts) +/// but the unsafe one for prefixes, so the warning says which risk is being taken. +pub(super) fn existing_derivatives(dir: &Path, asset_id: Uuid) -> ExistingDerivatives { + let empty = ExistingDerivatives { + heads: HashMap::new(), + used_prefixes: HashSet::new(), + }; let path = dir.join(format!("{}.derivatives.cbor", asset_id.simple())); let Ok(bytes) = fs::read(&path) else { - return HashMap::new(); + return empty; }; let Ok(manifests) = cbor::from_slice::>(&bytes) else { tracing::warn!( path = %path.display(), - "derivatives: undecodable bundle; treating every role's chain as unstarted" + "derivatives: undecodable bundle; every role's chain restarts and no previously used \ + nonce prefix can be excluded from the next draw" ); - return HashMap::new(); + return empty; }; + // Generation order is the chain order, so the last manifest of a role is that role's head. let mut heads = HashMap::new(); + let mut used_prefixes = HashSet::new(); for manifest in &manifests { + used_prefixes.insert(manifest.core.nonce_prefix); match cbor::to_canonical_vec(manifest) { Ok(canonical) => { heads.insert(manifest.core.role, hash::hash_bytes(&canonical)); @@ -213,33 +241,129 @@ pub(super) fn chain_heads(dir: &Path, asset_id: Uuid) -> HashMap { amk: &'a Amk, asset_id: Uuid, + /// Prefixes already spoken for on this `file_id`. `RefCell` because [`DerivativeSealer`] + /// takes `&self` — the seam is shared, and each seal has to see what the last one used. + used: RefCell>, + /// Where a candidate prefix comes from: the OS CSPRNG in production, forced in the test + /// that proves the refusal fires. + draw: &'a dyn Fn() -> [u8; NONCE_PREFIX_LEN], +} + +/// How many times a collision is redrawn before the draw itself is called broken. +/// +/// A 7-byte prefix collides by chance at about 1 in 2^56, so a run of eight is not bad luck — +/// it is a CSPRNG returning something it should not, which is a **workspace** fault and not +/// this asset's. Hence `MediaError::Sign`, which decision 22 routes to a propagated error +/// rather than to a missing thumbnail: an import that cannot draw a safe nonce must stop, not +/// quietly write one derivative fewer. +const MAX_PREFIX_DRAWS: usize = 8; + +/// Test-only fault injection for the sealer. +/// +/// The two failure paths decision 22 and decision 23 turn on — a codec refusing a frame the +/// decoder accepted, and a codec *panicking* on one — cannot be produced from real bytes on +/// demand, and testing them anywhere but through `import_asset_with` proves nothing about the +/// property that matters: that **the asset still commits**. So the fault is injected at the one +/// point inside the real import path where a codec failure originates. +/// +/// `#[cfg(test)]`, so it does not exist in a release build at all — not a disabled branch, not a +/// dead field, absent. A thread-local rather than a parameter because threading an `Option<&dyn +/// DerivativeSealer>` through `prepare_still` would put a test seam in a production signature; +/// nextest runs each test in its own process, so there is nothing for it to leak into. +#[cfg(test)] +#[derive(Clone, Copy, Debug)] +pub(super) enum SealerFault { + /// A codec refuses the frame — an `Encode`-class error, which decision 22 degrades. + Refuse, + /// A pre-1.0 codec panics — which decision 23's guard has to catch. + Panic, +} + +#[cfg(test)] +thread_local! { + static SEALER_FAULT: RefCell> = const { RefCell::new(None) }; +} + +/// Run `body` with `fault` injected into every seal, restoring the previous state after. +#[cfg(test)] +pub(super) fn with_sealer_fault(fault: SealerFault, body: impl FnOnce() -> T) -> T { + SEALER_FAULT.with(|slot| *slot.borrow_mut() = Some(fault)); + let out = body(); + SEALER_FAULT.with(|slot| *slot.borrow_mut() = None); + out } impl DerivativeSealer for AlbumSealer<'_> { fn seal(&self, plaintext: &[u8]) -> std::result::Result { - let (enc, _ciphertext, _file_key) = - encrypt_asset_rekey(self.amk, &self.asset_id, plaintext, None).map_err(|e| { - MediaError::Sign { - detail: format!("sealing the derivative: {e}"), + #[cfg(test)] + if let Some(fault) = SEALER_FAULT.with(|slot| *slot.borrow()) { + match fault { + // Deliberately **not** `Sign`: this stands in for a codec refusing pixels, which + // decision 22 degrades to `DecodeFailed` rather than propagating. + SealerFault::Refuse => { + return Err(MediaError::Encode { + format: crate::media::DerivativeFormat::Jxl, + detail: "injected codec refusal".into(), + }); } - })?; - Ok(SealedDerivative { - ciphertext_hash: enc.ciphertext_hash, - nonce_prefix: enc.nonce_prefix, + SealerFault::Panic => panic!("injected codec panic on an accepted frame"), + } + } + + for attempt in 0..MAX_PREFIX_DRAWS { + let prefix = (self.draw)(); + if self.used.borrow().contains(&prefix) { + tracing::warn!( + asset_id = %self.asset_id, + attempt, + "derivatives: drew a nonce prefix already used for this file_id; redrawing" + ); + continue; + } + let (enc, _ciphertext, _file_key) = + encrypt_asset_rekey_with_prefix(self.amk, &self.asset_id, plaintext, prefix, None) + .map_err(|e| MediaError::Sign { + detail: format!("sealing the derivative: {e}"), + })?; + self.used.borrow_mut().insert(enc.nonce_prefix); + return Ok(SealedDerivative { + ciphertext_hash: enc.ciphertext_hash, + nonce_prefix: enc.nonce_prefix, + }); + } + Err(MediaError::Sign { + detail: format!( + "could not draw an unused nonce prefix for {} in {MAX_PREFIX_DRAWS} attempts", + self.asset_id + ), }) } } @@ -313,6 +437,16 @@ impl Workspace { let lqip = lqip_from(&decoded, src); let album = self.album(&album_id)?; + let ExistingDerivatives { + heads, + mut used_prefixes, + } = existing_derivatives( + &media_dir(&self.root, capture_utc).join("derivatives"), + asset_id, + ); + // The original's prefix is spoken for too: it is a prefix used for this `file_id`. + used_prefixes.insert(original.nonce_prefix); + let ctx = DerivativeContext { source_asset_id: asset_id, crypto_suite_id: CRYPTO_SUITE_ID, @@ -323,12 +457,16 @@ impl Workspace { generated_at: super::now_rfc3339(), device_signer: self.device_signer.as_ref(), write_tier_signer: album.write_tier_signer()?, - sealer: &AlbumSealer { amk, asset_id }, - // Empty on a create; a regeneration continues each role's chain from here. - prior_heads: &chain_heads( - &media_dir(&self.root, capture_utc).join("derivatives"), + sealer: &AlbumSealer { + amk, asset_id, - ), + // Seeded with the original's own prefix and every prefix the existing bundle + // already spent on this `file_id`. + used: RefCell::new(used_prefixes), + draw: &rng::random_array::, + }, + // Empty on a create; a regeneration continues each role's chain from here. + prior_heads: &heads, // The `original` sentinel references the original blob rather than encrypting // anything, so it signs what the original's own manifest signs. original: SealedDerivative { @@ -402,9 +540,10 @@ impl Workspace { /// Write the generated derivative bytes plus their signed manifest bundle under the asset's /// media directory: `derivatives/{uuid}.{role}.{ext}` and `{uuid}.derivatives.cbor`. /// - /// The layout is the one the upload bundle reader already looks for - /// ([`Workspace::upload_bundle`](Workspace::upload_bundle) finds a derivative's bytes by the - /// `{uuid}.{role}.` prefix), so persisting here needs no change on the read side. + /// The layout is the one the upload bundle reader already looks for: since `F5` it composes + /// a derivative's exact path from the manifest's `(role, format)` pair rather than scanning + /// for a `{uuid}.{role}.` prefix, so two formats of one role cannot be mistaken for each + /// other. /// /// Called **after** the asset's own files are durable: a derivative is regenerable and must /// never be able to fail an import that has already committed. A write error is therefore @@ -828,6 +967,87 @@ mod tests { ); } + /// **Decision 22's degradation path, through the real import.** A codec that refuses a frame + /// the decoder accepted costs this asset its thumbnail and **nothing else**: the original + /// commits, signed and self-verifying, with real pixel dimensions and a real placeholder, + /// and the run reports `DecodeFailed` so somebody can look at it. + /// + /// Reverting `prepare_still`'s match arm to a bare `?` must fail this test — that is what it + /// is for. Before the review round the code did exactly that, and because the failure + /// happened *before* `write_asset_files`, an encoder refusal lost the original from the + /// backup outright. + #[test] + fn a_codec_refusal_costs_the_thumbnail_and_not_the_backup() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (mut ws, album) = workspace(lib.path()); + let path = src.path().join("photo.png"); + fs::write(&path, png(512, 384)).unwrap(); + + let receipt = super::with_sealer_fault(super::SealerFault::Refuse, || { + ws.import_asset_with(album, &path, &SignedImportOptions::default()) + .expect("a codec refusal must never fail the import") + }); + + assert_eq!( + receipt.derivatives, + DerivativeStatus::DecodeFailed, + "reported as a real problem rather than an expected gap" + ); + assert_eq!(receipt.deferred_formats, 0); + + // The half that must not be lost: a signed, encrypted, self-verifying original. + assert_eq!( + ws.verify(&receipt.asset_id).unwrap(), + crate::crypto::verify_asset::VerifyOutcome::Accept + ); + let sidecar = sidecar_of(lib.path(), receipt.asset_id); + let dimensions = sidecar.dimensions.as_ref().expect("real pixel dimensions"); + assert_eq!((dimensions.width, dimensions.height), (512, 384)); + assert_eq!( + sidecar.lqip.as_ref().map(|l| l.chromahash.len()), + Some(32), + "the placeholder came from the decode, which succeeded" + ); + assert!( + !derivatives_dir(lib.path(), receipt.asset_id).exists(), + "and no derivative was written" + ); + } + + /// **Decision 23's guard, through the real import.** A codec that *panics* on a frame the + /// decoder accepted is caught, and the import still commits. + /// + /// Driven through `import_asset_with` rather than by calling `guarded` directly: a test that + /// calls the guard itself stays green even if every production call site is deleted, which + /// is precisely the hole this replaces. Deleting the guard around generation makes this test + /// abort the process rather than fail — which is the failure mode it exists to prevent. + #[test] + fn a_codec_panic_is_caught_and_the_import_still_commits() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (mut ws, album) = workspace(lib.path()); + let path = src.path().join("photo.png"); + fs::write(&path, png(512, 384)).unwrap(); + + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(|_| {})); + let receipt = super::with_sealer_fault(super::SealerFault::Panic, || { + ws.import_asset_with(album, &path, &SignedImportOptions::default()) + .expect("a codec panic must never fail the import") + }); + std::panic::set_hook(previous); + + assert_eq!(receipt.derivatives, DerivativeStatus::DecodeFailed); + assert_eq!( + ws.verify(&receipt.asset_id).unwrap(), + crate::crypto::verify_asset::VerifyOutcome::Accept, + "one panicking photo does not cost the asset, let alone the rest of the run" + ); + let sidecar = sidecar_of(lib.path(), receipt.asset_id); + assert!(sidecar.lqip.is_some(), "the placeholder still landed"); + } + /// A format with no codec here, and bytes that are no still at all: both import as signed, /// verifiable originals, with EXIF-or-nothing dimensions, no placeholder, no derivative /// files, and the reason recorded (slice `S-B13`). @@ -884,3 +1104,164 @@ mod tests { } } } + +// ── The nonce-prefix reuse refusal (encryption.md, "Re-keying on Rewrite") ─── + +#[cfg(test)] +mod sealer_tests { + use super::*; + + /// A draw that hands back a fixed sequence, so a collision can be *forced* rather than + /// waited for — a real 7-byte collision is a 1-in-2^56 event. + struct ScriptedDraw { + prefixes: RefCell>, + } + + impl ScriptedDraw { + fn next(&self) -> [u8; NONCE_PREFIX_LEN] { + let mut queue = self.prefixes.borrow_mut(); + if queue.len() == 1 { + queue[0] + } else { + queue.remove(0) + } + } + } + + fn sealer<'a>( + amk: &'a Amk, + used: HashSet<[u8; NONCE_PREFIX_LEN]>, + draw: &'a dyn Fn() -> [u8; NONCE_PREFIX_LEN], + ) -> AlbumSealer<'a> { + AlbumSealer { + amk, + asset_id: Uuid::from_u128(0xDEF), + used: RefCell::new(used), + draw, + } + } + + /// **The normative refusal.** A draw that keeps returning a prefix already used for this + /// `file_id` is refused rather than accepted, and the refusal is a `Sign`-class fault so + /// decision 22 propagates it instead of silently writing one derivative fewer. + /// + /// A prefix is folded into the file-key salt, so reusing one reuses the **key**: two blobs + /// under one keystream, which is exactly what the encryption doc's "defense in depth on top + /// of the CSPRNG draw" exists to prevent. + #[test] + fn a_prefix_already_used_for_this_file_id_is_refused() { + let amk = Amk::from_bytes([0x11; 32]); + let original = [1, 2, 3, 4, 5, 6, 7]; + let mut used = HashSet::new(); + used.insert(original); + + // The RNG is forced to keep offering the original's prefix. + let scripted = ScriptedDraw { + prefixes: RefCell::new(vec![original]), + }; + let draw = || scripted.next(); + let error = sealer(&amk, used, &draw) + .seal(b"derivative plaintext") + .expect_err("a reused prefix is refused"); + assert!( + matches!(error, MediaError::Sign { .. }), + "an exhausted draw is a workspace fault, not a missing thumbnail: {error:?}" + ); + } + + /// A collision is **redrawn**, not fatal: the first candidate is taken, the second is used. + #[test] + fn a_collision_is_redrawn_and_the_next_candidate_is_accepted() { + let amk = Amk::from_bytes([0x22; 32]); + let taken = [9, 9, 9, 9, 9, 9, 9]; + let fresh = [8, 7, 6, 5, 4, 3, 2]; + let mut used = HashSet::new(); + used.insert(taken); + + let scripted = ScriptedDraw { + prefixes: RefCell::new(vec![taken, fresh]), + }; + let draw = || scripted.next(); + let sealed = sealer(&amk, used, &draw) + .seal(b"derivative plaintext") + .expect("the redraw succeeds"); + assert_eq!( + sealed.nonce_prefix, fresh, + "the colliding candidate is skipped and the next one is used" + ); + } + + /// Each sealed prefix **joins** the set, so two derivatives of one asset cannot collide with + /// each other either — not only with what was already on disk. + #[test] + fn a_freshly_sealed_prefix_is_spoken_for_by_the_next_seal() { + let amk = Amk::from_bytes([0x33; 32]); + let first = [1, 1, 1, 1, 1, 1, 1]; + let second = [2, 2, 2, 2, 2, 2, 2]; + + // The draw offers `first`, then `first` again (a collision with what was just sealed), + // then `second`. + let scripted = ScriptedDraw { + prefixes: RefCell::new(vec![first, first, second]), + }; + let draw = || scripted.next(); + let sealer = sealer(&amk, HashSet::new(), &draw); + + assert_eq!(sealer.seal(b"one").expect("first seal").nonce_prefix, first); + assert_eq!( + sealer.seal(b"two").expect("second seal").nonce_prefix, + second, + "the prefix the first seal used is refused for the second" + ); + } + + /// The bundle reader hands the sealer every prefix already spent on this `file_id`. + #[test] + fn existing_derivatives_reports_every_persisted_prefix() { + use crate::crypto::keys::HybridSigningKey; + use crate::crypto::provenance::manifest::{DERIVATIVE_MANIFEST_VERSION, DerivativeCore}; + + let dir = tempfile::tempdir().expect("scratch"); + let asset_id = Uuid::from_u128(0xFEED); + let device = HybridSigningKey::from_seed_bytes(&[31; 32], &[32; 32]); + let write = HybridSigningKey::from_seed_bytes(&[33; 32], &[34; 32]); + + let manifest = |role, prefix: [u8; NONCE_PREFIX_LEN]| { + DerivativeCore { + version: DERIVATIVE_MANIFEST_VERSION.into(), + crypto_suite_id: CRYPTO_SUITE_ID, + protocol_version: Some(PROTOCOL_VERSION.into()), + amk_version: Some(AmkVersion(1)), + source_asset_id: asset_id, + role, + format: "image/jxl".into(), + ciphertext_hash: hash::hash_bytes(b"bytes"), + nonce_prefix: prefix, + generated_by_device: Uuid::from_u128(0xD1), + generated_by_client: "capsule-core/test".into(), + model_id: None, + model_version: None, + generated_at: "2026-09-02T00:00:00Z".into(), + prior_provenance_hash: None, + } + .sign(&device, &write) + .expect("signing") + }; + let manifests = vec![ + manifest(DerivativeRole::Thumbnail, [1, 1, 1, 1, 1, 1, 1]), + manifest(DerivativeRole::Preview, [2, 2, 2, 2, 2, 2, 2]), + ]; + fs::write( + dir.path() + .join(format!("{}.derivatives.cbor", asset_id.simple())), + cbor::to_canonical_vec(&manifests).unwrap(), + ) + .unwrap(); + + let existing = existing_derivatives(dir.path(), asset_id); + assert!(existing.used_prefixes.contains(&[1, 1, 1, 1, 1, 1, 1])); + assert!(existing.used_prefixes.contains(&[2, 2, 2, 2, 2, 2, 2])); + assert_eq!(existing.used_prefixes.len(), 2); + assert_eq!(existing.heads.len(), 2, "and both roles have a chain head"); + } +} diff --git a/capsule-core/src/lifecycle/upload.rs b/capsule-core/src/lifecycle/upload.rs index 43989bed..0ae3da29 100644 --- a/capsule-core/src/lifecycle/upload.rs +++ b/capsule-core/src/lifecycle/upload.rs @@ -198,14 +198,21 @@ impl Workspace { /// name is now true of it. A thumbnail is a recognisable low-resolution copy of a private /// photo; the encryption doc admits no exception for it. /// - /// Four reasons a manifest is skipped rather than shipped, and only one of them is quiet: + /// **Still roles only.** An embedding-role manifest is skipped at `debug!` and named as out + /// of scope: its `embedding/{model_id}` grammar is not in the still format set, and + /// `crate::ml` produces no derivative manifest for this reader to have an opinion about. + /// + /// Five reasons a manifest is skipped rather than shipped, and two of them are quiet: /// /// - the `original` sentinel, which references the original blob and has no bytes of its /// own — an **expected** absence, logged at `debug!`; + /// - an embedding-role manifest, out of scope as above — also `debug!`; /// - a still-role `format` outside the closed set, which is the structural rejection the /// tier table specifies; - /// - bytes missing on disk for a manifest that should have them; - /// - bytes whose re-derived ciphertext does not match the signed content address. + /// - an `amk_version` naming an epoch this album does not hold, which would otherwise panic + /// on the key lookup; + /// - bytes missing on disk, or bytes whose re-derived ciphertext does not match the signed + /// content address. /// /// None of them fails the bundle: the original and its metadata are what a backup must not /// lose, and a stale thumbnail is regenerable. @@ -253,7 +260,21 @@ impl Workspace { ); continue; } - Ok(_) => {} + Ok(None) => { + // An embedding-role manifest. Its `embedding/{model_id}` grammar is not in + // the still format set, and nothing produces one yet: `crate::ml` writes no + // derivative manifest at all. Rather than invent a reader for an artefact + // with no writer, this reader says plainly that embeddings are out of its + // scope — at `debug!`, because encountering one is not a fault. + tracing::debug!( + asset_id = %asset.asset_id, + role = role_name, + "upload bundle: embedding-role derivatives are outside this reader's \ + scope until `crate::ml` produces manifests for them; skipping" + ); + continue; + } + Ok(Some(_)) => {} Err(format) => { tracing::warn!( asset_id = %asset.asset_id, @@ -280,7 +301,23 @@ impl Workspace { // Re-derive the ciphertext from the prefix the manifest signed. The prefix is // folded into the file-key salt, so it selects the key as well as the nonces — // there is exactly one ciphertext this manifest can be describing. + // + // The epoch is **checked**, not indexed. It comes off an unverified `.cbor` on + // disk, and `file_key` reaches `album.amks[&epoch]`, which panics on a missing key + // — inside a function whose whole contract is that it never fails the bundle. An + // album recovered from a backup holds only the epochs it escrowed, so a manifest + // naming one it does not hold is reachable without any tampering at all. let key_epoch = core.amk_version.map_or(epoch, |v| v.0); + if !album.amks.contains_key(&key_epoch) { + tracing::warn!( + asset_id = %asset.asset_id, + role = role_name, + epoch = key_epoch, + "upload bundle: derivative manifest names an AMK epoch this album does not \ + hold; skipping" + ); + continue; + } let file_key = self.file_key(album, key_epoch, &asset.asset_id, &core.nonce_prefix); let (_, ciphertext) = stream::encrypt_asset_vec_with_prefix(&file_key, core.nonce_prefix, &plaintext); @@ -323,8 +360,10 @@ fn derivative_role_name(role: DerivativeRole) -> &'static str { /// content-address it against the *other* manifest, so both would be skipped as mismatched. /// `#437` lands exactly that pair, so this is a latent break rather than a hypothetical one. /// -/// A format outside the closed set has no extension to look for and returns `None`; the caller -/// has already rejected that manifest, so this is belt and braces. A stale file left by a +/// Returns `None` for any `format` outside the closed set — including the embedding-role +/// grammar, which has no still extension. The caller has already skipped both cases, so reaching +/// this with one is not expected; it answers `None` rather than asserting, because a reader that +/// panics on a manifest it merely does not understand is worse than one that ships nothing. A stale file left by a /// retired format is simply never read — nothing enumerates the directory any more, so an /// orphan is inert rather than a candidate, and it is regenerable by design. fn read_derivative_bytes( diff --git a/capsule-core/src/lifecycle/upload/tests.rs b/capsule-core/src/lifecycle/upload/tests.rs index 54bcce9c..59cc9200 100644 --- a/capsule-core/src/lifecycle/upload/tests.rs +++ b/capsule-core/src/lifecycle/upload/tests.rs @@ -355,6 +355,18 @@ fn signed_derivative( role: DerivativeRole, format: &str, ciphertext_hash: crate::crypto::hash::Hash32, +) -> DerivativeManifest { + signed_derivative_at_epoch(asset_id, role, format, ciphertext_hash, 1) +} + +/// As [`signed_derivative`], with an explicit `amk_version` — so a manifest can name an epoch +/// the album does not hold. +fn signed_derivative_at_epoch( + asset_id: Uuid, + role: DerivativeRole, + format: &str, + ciphertext_hash: crate::crypto::hash::Hash32, + epoch: u32, ) -> DerivativeManifest { use crate::crypto::keys::{AmkVersion, HybridSigningKey}; use crate::crypto::primitives::{CRYPTO_SUITE_ID, PROTOCOL_VERSION}; @@ -366,7 +378,7 @@ fn signed_derivative( version: DERIVATIVE_MANIFEST_VERSION.into(), crypto_suite_id: CRYPTO_SUITE_ID, protocol_version: Some(PROTOCOL_VERSION.into()), - amk_version: Some(AmkVersion(1)), + amk_version: Some(AmkVersion(epoch)), source_asset_id: asset_id, role, format: format.into(), @@ -528,7 +540,11 @@ fn a_derivative_survives_a_reopen_and_still_reaches_the_bundle() { // A second `Workspace::open` — the S-A10 shape: nothing shared but the directory. let ws = Workspace::open(lib.path(), b"passphrase", FAST).unwrap(); let bundle = ws.upload_bundle(&asset_id).unwrap(); - assert_eq!(bundle.derivatives.len(), 1, "the derivative survives a reopen"); + assert_eq!( + bundle.derivatives.len(), + 1, + "the derivative survives a reopen" + ); let blob = &bundle.derivatives[0]; assert_eq!( hash::hash_bytes(&blob.bytes), @@ -599,3 +615,85 @@ fn two_formats_for_one_role_are_addressed_by_format_not_by_filename_order() { .collect(); assert_eq!(formats, vec!["image/jxl", "image/avif"]); } + +/// **A manifest naming an epoch the album does not hold is skipped, not a panic.** +/// +/// `file_key` reaches `album.amks[&epoch]`, which panics on a missing key — and the epoch comes +/// off an unverified `.cbor` on disk, inside a function whose whole contract is that it never +/// fails the bundle. This is reachable without any tampering at all: an album recovered from a +/// backup holds only the epochs it escrowed. +#[test] +fn a_derivative_naming_an_unheld_epoch_is_skipped_rather_than_panicking() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (_album, asset_id) = library_with_a_thumbnailed_asset(&lib, &src); + let ws = Workspace::open(lib.path(), b"passphrase", FAST).unwrap(); + + rewrite_bundle( + &lib, + &ws, + asset_id, + &[signed_derivative_at_epoch( + asset_id, + DerivativeRole::Thumbnail, + "image/jxl", + hash::hash_bytes(b"whatever"), + 9_999, + )], + ); + + // The assertion is as much that this returns at all as that it returns nothing. + let bundle = ws + .upload_bundle(&asset_id) + .expect("an unheld epoch must not fail the bundle"); + assert!( + bundle.derivatives.is_empty(), + "a derivative whose key epoch is absent is skipped" + ); + assert!( + !bundle.ciphertext.is_empty(), + "and the original is still shipped" + ); +} + +/// An embedding-role manifest is **out of this reader's scope**, and says so quietly. +/// +/// Its `embedding/{model_id}` grammar is not in the still format set and `crate::ml` produces no +/// derivative manifest at all, so the reader neither ships it nor complains about it — the +/// previous behaviour logged "no bytes on disk" for a manifest it had already decided not to +/// handle, which is a misleading warning for an artefact with no writer. +#[test] +fn an_embedding_role_manifest_is_out_of_scope_and_skipped() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (_album, asset_id) = library_with_a_thumbnailed_asset(&lib, &src); + let ws = Workspace::open(lib.path(), b"passphrase", FAST).unwrap(); + + rewrite_bundle( + &lib, + &ws, + asset_id, + &[signed_derivative( + asset_id, + DerivativeRole::Embedding, + "embedding/mobileclip-b", + hash::hash_bytes(b"an embedding"), + )], + ); + + let bundle = ws.upload_bundle(&asset_id).unwrap(); + assert!( + bundle.derivatives.is_empty(), + "embeddings are not this reader's business until something produces them" + ); + assert_eq!( + verify_still_format(&signed_derivative( + asset_id, + DerivativeRole::Embedding, + "embedding/mobileclip-b", + hash::hash_bytes(b"an embedding"), + )), + Ok(None), + "and the closed-set check reports them as out of scope rather than rejecting them" + ); +} diff --git a/capsule-core/src/media/derivative.rs b/capsule-core/src/media/derivative.rs index a1859899..c56797ff 100644 --- a/capsule-core/src/media/derivative.rs +++ b/capsule-core/src/media/derivative.rs @@ -86,9 +86,9 @@ impl DerivativeTier { } } - /// The role's on-disk name — mirrors - /// [`derivative_role_name`](crate::lifecycle) in the upload bundle reader, which finds a - /// derivative's bytes by this prefix. + /// The role's on-disk name — mirrors `derivative_role_name` in the upload bundle reader, + /// which composes a derivative's exact path from this **and** the manifest's format, so two + /// formats of one role stay distinguishable. pub const fn role_name(self) -> &'static str { match self { Self::Thumbnail => "thumbnail", diff --git a/capsule-core/src/media/tests.rs b/capsule-core/src/media/tests.rs index 56f50642..67e6756d 100644 --- a/capsule-core/src/media/tests.rs +++ b/capsule-core/src/media/tests.rs @@ -1125,10 +1125,10 @@ fn a_source_within_the_cap_signs_the_original_sentinel() { /// signatures included — the same append-only link the asset provenance chain uses. /// /// Exercised through [`sign_derivative`](super::derivative::sign_derivative) rather than -/// through [`generate_still_derivatives`], and deliberately: only WebP is encodable today, so a +/// through [`generate_still_derivatives`], and deliberately: only JXL is encodable today, so a /// single call produces one manifest per role and the multi-link case — the half that can /// actually be wrong — is unreachable from the public entry point until a second encoder lands -/// (the filed `S-B1` remainder). +/// (the filed `S-B1` remainder, #437). #[test] fn manifests_of_one_role_form_an_append_only_chain() { let (device, write_tier) = signers(); @@ -1234,7 +1234,7 @@ fn each_tier_starts_its_own_role_chain() { .find(|d| d.tier == DerivativeTier::Preview) .expect("a preview was generated"); let back = RawshiftDecoder - .decode(&previewed.bytes, "webp") + .decode(&previewed.bytes, "jxl") .expect("the preview decodes"); assert_eq!((back.width(), back.height()), (512, 384)); } From 00f8dce4e75c204aa519d753f57a6c55b18f5586 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 02:49:39 -0400 Subject: [PATCH 104/243] fix(docs): make the fatal checks match the grammar they claim to enforce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both defects were in the guards added last round, and both had the same shape: the check was narrower than the rule it was written to enforce, so the input it existed to catch walked through it. CommonMark's setext underline is `= +` or `- +`, not two or more. Requiring two meant `Title` over a lone `-` was not recognised, and shipped an undemoted heading into a page body — the exact defect the check exists to prevent, arriving through the check against it. The fence, table, list, and thematic-break exclusions are unchanged, and are covered. The composition check read `oneOf`/`allOf`/`anyOf` at the root of a named schema only. A composition one level down, in `properties.` or in `items`, fell through `typeOf` to `object` and would have rendered as a row calling the field a plain object — a confident lie about a union, which is what the check is for. It now walks inline `properties`, `items`, and `additionalProperties`, and names the property path as well as the schema. A `$ref` is not followed: its target is a named schema this scan reaches on its own pass, and following it would report one composition once per reference. Neither reaches the committed document: a recursive scan of `capsule-server/openapi.json` finds no composition anywhere, and `readOpenApiDocument` still accepts it. The module header said three fatal modes and listed six. --- capsule-docs/scripts/gen-reference.mjs | 75 +++++++++++++++++---- capsule-docs/scripts/gen-reference.test.mjs | 51 ++++++++++++++ 2 files changed, 113 insertions(+), 13 deletions(-) diff --git a/capsule-docs/scripts/gen-reference.mjs b/capsule-docs/scripts/gen-reference.mjs index 4e634150..2bddd21b 100644 --- a/capsule-docs/scripts/gen-reference.mjs +++ b/capsule-docs/scripts/gen-reference.mjs @@ -17,7 +17,7 @@ * edited, so committing one only creates a copy that can disagree with its source. Fix the * clap `about` or the schema description and regenerate. * - * Three failure modes are deliberately fatal rather than degraded, because + * Six failure modes are deliberately fatal rather than degraded, because * `developer-docs.md` calls a stale reference page worse than a missing one — a missing page * is obvious and a wrong one is believed: * @@ -109,8 +109,18 @@ const FENCE_OPEN = /^\s*(`{3,}|~{3,})/; /** A line that *closes* one: the same run with nothing after it but whitespace. */ const FENCE_CLOSE = /^\s*(`{3,}|~{3,})\s*$/; -/** A setext underline: a run of `=` or `-` alone on its line. */ -const SETEXT_UNDERLINE = /^\s{0,3}(={2,}|-{2,})\s*$/; +/** + * A setext underline: a run of `=` or `-` alone on its line. + * + * **One character is enough.** CommonMark's `setext heading underline` is `= +` or `- +`, + * not two or more, so `Title` over a lone `-` is an h2 exactly as `Title` over `---` is. + * Requiring two let a single-character underline through the check that exists to catch it, + * which shipped an undemoted heading into a page body — the defect, arriving through the + * guard against the defect. + * + * Up to three leading spaces, and trailing whitespace, both per the spec. + */ +const SETEXT_UNDERLINE = /^\s{0,3}(={1,}|-{1,})\s*$/; /** * Shift every ATX heading in `markdown` down by `offset` levels, clamped at h6. @@ -688,16 +698,55 @@ function assertRenderable(document) { for (const [name, schema] of Object.entries( document.components?.schemas ?? {}, )) { - const composed = COMPOSITION_KEYWORDS.filter((word) => schema?.[word]); - if (composed.length > 0) { - throw new Error( - `schema ${name} composes with ${composed.join(' and ')}, which this ` + - 'generator cannot express: it flattens a schema to a property table, ' + - 'and a union or an intersection is not a property table. It would ' + - `render as an empty or a half-true model. Teach ${GENERATED_BY} to ` + - 'render composition before the server starts emitting it.', - ); - } + assertNoComposition(name, name, schema); + } +} + +/** + * Refuse `oneOf`/`allOf`/`anyOf` anywhere in a named schema, not only at its root. + * + * Checking the root alone is the shape of the bug it was meant to prevent: a composition one + * level down, in `properties.` or in `items`, falls through `typeOf` to the string + * `object` and renders as a row claiming the field is a plain object. That is a confident + * lie about a union, which is exactly what this check exists to stop, and it passed. + * + * Only *inline* subschemas are walked. A `$ref` is not followed, because its target is a + * named schema that this scan reaches on its own pass — following it would report the same + * composition once per reference, under whichever name happened to be scanned first. + * + * @param {string} name The named schema this subtree belongs to. + * @param {string} at A readable path to the subschema, for the error. + * @param {unknown} schema The subschema. + * @throws {Error} naming the schema and the path within it. + */ +function assertNoComposition(name, at, schema) { + if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return; + if (schema.$ref) return; + + const composed = COMPOSITION_KEYWORDS.filter((word) => schema[word]); + if (composed.length > 0) { + throw new Error( + `schema ${name} composes with ${composed.join(' and ')} at ${at}, which this ` + + 'generator cannot express: it flattens a schema to a property table, ' + + 'and a union or an intersection is not a property table. It would ' + + `render as an empty or a half-true model. Teach ${GENERATED_BY} to ` + + 'render composition before the server starts emitting it.', + ); + } + + for (const [property, subschema] of Object.entries( + schema.properties ?? {}, + )) { + assertNoComposition(name, `${at}.${property}`, subschema); + } + assertNoComposition(name, `${at}[]`, schema.items); + // `additionalProperties` is a schema when it is an object, and `true`/`false` otherwise. + if (typeof schema.additionalProperties === 'object') { + assertNoComposition( + name, + `${at}.additionalProperties`, + schema.additionalProperties, + ); } } diff --git a/capsule-docs/scripts/gen-reference.test.mjs b/capsule-docs/scripts/gen-reference.test.mjs index 1b2a1047..7628c65d 100644 --- a/capsule-docs/scripts/gen-reference.test.mjs +++ b/capsule-docs/scripts/gen-reference.test.mjs @@ -287,6 +287,19 @@ describe('demoteHeadings', () => { expect(() => demoteHeadings('Title\n-----\n', 2)).toThrow(/Title/); }); + // CommonMark's underline is `= +` / `- +`, not two or more. Requiring two let the + // single-character form through the check that exists to catch it — the defect arriving + // through the guard against the defect. + it('refuses a single-character setext underline', () => { + expect(() => demoteHeadings('Title\n-\n', 2)).toThrow(/setext/i); + expect(() => demoteHeadings('Title\n=\n', 2)).toThrow(/setext/i); + }); + + it('refuses an indented or trailing-spaced single-character underline', () => { + expect(() => demoteHeadings('Title\n -\n', 2)).toThrow(/setext/i); + expect(() => demoteHeadings('Title\n= \n', 2)).toThrow(/setext/i); + }); + it('does not mistake a thematic break or a table for a setext underline', () => { expect(() => demoteHeadings('para\n\n---\n', 2)).not.toThrow(); expect(() => demoteHeadings('| a |\n| --- |\n', 2)).not.toThrow(); @@ -740,6 +753,44 @@ describe('readOpenApiDocument', () => { expect(() => readOpenApiDocument(root)).toThrow(new RegExp(keyword)); }); + // Checking only the root is the shape of the bug it prevents: a composition one level + // down falls through `typeOf` to `object` and renders as a row claiming the field is a + // plain object — a confident lie about a union. + it.each([ + 'properties.choice', + 'items', + 'additionalProperties', + ])('refuses a composition nested at %s, naming the schema and the path', (where) => { + const document = structuredClone(MINIMAL_OPENAPI); + const composed = { + oneOf: [{ type: 'string' }, { type: 'integer' }], + }; + const target = { type: 'object', title: 'TokenResponse' }; + if (where === 'properties.choice') { + target.properties = { choice: composed }; + } else if (where === 'items') { + target.items = composed; + } else { + target.additionalProperties = composed; + } + document.components.schemas.TokenResponse = target; + writeOpenApi(document); + expect(() => readOpenApiDocument(root)).toThrow(/TokenResponse/); + expect(() => readOpenApiDocument(root)).toThrow(/oneOf/); + }); + + // A `$ref` is scanned on the target's own pass. Following it would report the same + // composition once per reference, under whichever name was scanned first. + it('reports a composed schema once, under its own name', () => { + const document = structuredClone(MINIMAL_OPENAPI); + document.components.schemas.TokenKind = { + title: 'TokenKind', + oneOf: [{ type: 'string' }, { type: 'integer' }], + }; + writeOpenApi(document); + expect(() => readOpenApiDocument(root)).toThrow(/schema TokenKind/); + }); + it('accepts the committed document', () => { expect(() => readOpenApiDocument( From 8eee2131f8f1c87f0b6682c2884551776ef1bdef Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 04:52:14 -0400 Subject: [PATCH 105/243] feat(server): advertise and gate the protocol handshake from one interceptor pair The design puts three request headers and three response headers on every route; the server declared the request half on four upload operations and never sent the response half at all. A Kynos ApiError has no response-header seam, so routes/upload.rs rode X-Capsule-Protocol-Min/-Max as problem extension members while capsule-sdk reads them from headers and got None. The seam is on the interceptor. negotiation.rs adds two: Negotiation, mounted router-wide outside the body-size limit, attaches the window to every response the chain produces, errors and short-circuits included; ProtocolGate, on a Group, reads the three request headers and refuses 426 or 400 before the handler runs. Both read the one UploadPolicy window, which gains the advisory min_client_build. The group holds the four upload session operations that enforced the handshake per route until now; routes/upload.rs loses that duplication, and the 426 keeps no window members in its body. openapi::describe_negotiation_headers files the three response headers under every response of every operation, since Kynos describes an interceptor's headers on success responses only. The test fixture's client sends the handshake on every request with raw() for its absence, and conformance.rs gains a document census and a document-driven wire census that pin the gated and exempt sets. Refs #404 --- capsule-server/openapi.json | 16886 +++++++++++++++++++++----- capsule-server/src/lib.rs | 59 +- capsule-server/src/negotiation.rs | 729 ++ capsule-server/src/openapi/mod.rs | 91 +- capsule-server/src/routes/upload.rs | 270 +- capsule-server/src/upload/policy.rs | 41 +- capsule-server/tests/conformance.rs | 303 +- capsule-server/tests/support/mod.rs | 84 +- capsule-server/tests/upload.rs | 29 +- 9 files changed, 15097 insertions(+), 3395 deletions(-) create mode 100644 capsule-server/src/negotiation.rs diff --git a/capsule-server/openapi.json b/capsule-server/openapi.json index 3e80f9f5..36320df6 100644 --- a/capsule-server/openapi.json +++ b/capsule-server/openapi.json @@ -5,66 +5,92 @@ "version": "0.0.0" }, "paths": { - "/v1/version": { - "get": { - "summary": "Reports the server's name and version.", - "description": "Unauthenticated and side-effect free. Clients use it as a reachability probe before\nattempting a protocol handshake, so it must stay cheap and must never fail for a reason\nthe caller could act on — there is no failure variant, and the return type says so.", - "operationId": "get_version", - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/VersionResponse" - } - } + "/v1/upload": { + "post": { + "summary": "Open an upload session for one blob of an asset bundle.", + "description": "Runs the refuse-by-default envelope battery — invariants 1–8 and the top-level↔envelope\nconsistency family — **before** anything is written, then stages the session's file and\nrecords the session. A request whose `(owner, hash, album)` tuple already has an active\nsession gets that session back rather than a second one.", + "operationId": "create_upload", + "parameters": [ + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, - "413": { - "description": "the request body exceeds the configured limit" + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } } - } - } - }, - "/v1/auth/register": { - "post": { - "summary": "Create an account, and open its first session.", - "description": "# Why it signs you in\n\nThe alternative is `201` with no body and a client that immediately posts the same\ncredentials to `/v1/auth/login`, which is one more round trip for one more chance to fail and\nnothing gained. It also makes the CLI's `capsule register` mean what a person expects: after\nit, you are registered *and* signed in.\n\n# What it does not do\n\n**It does not publish a device directory**, and the account is therefore unable to upload\nuntil its client publishes one. That is not an omission here: `S-C20` removed the\naccount-creation fallback for invariant 7's floor precisely so that \"was this device in the\ndirectory\" has an honest answer for a brand-new account, and the honest answer is *no*. A\nclient's first action after registering is `POST /v1/auth/devices/directory`.\n\n**It is not rate-limited**, and that is a real gap rather than an oversight — see\n[`crate::auth::registry`] for the fact the limiter is waiting on. This is the one\nunauthenticated write on the surface.\n\n# `200`, where Salvo answered `201`\n\nKynos's `Created` requires a `Location` — a `201` that does not say *where* tells a client\nsomething exists and not how to reach it, which is a defect the type refuses to let you\ncommit. This server exposes no URL for an account: `GET /v1/auth/profile` is among the\noperations `S-C53` records as unported. Inventing a location to satisfy a status would be\ninventing a surface, so the status moved instead. What a caller actually needs — the token\npair — is in the body either way.", - "operationId": "register_user", + ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RegisterRequest" + "$ref": "#/components/schemas/CreateUploadRequest" } } }, "required": true }, "responses": { - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "415": { - "description": "Unsupported Media Type", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "422": { - "description": "Unprocessable Entity", + }, "content": { "application/problem+json": { "schema": { @@ -73,28 +99,34 @@ } } }, - "200": { - "description": "OK", - "content": { - "application/json": { + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/TokenResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "409": { - "description": "Account already exists", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "500": { - "description": "Internal server error", + }, "content": { "application/problem+json": { "schema": { @@ -103,50 +135,34 @@ } } }, - "413": { - "description": "the request body exceeds the configured limit" - } - } - } - }, - "/v1/auth/login": { - "post": { - "summary": "Exchange an email and password for a session — or for a second-factor challenge.", - "description": "The two advisory identifiers a client may send — `cohort_hash` and `device_id` — are recorded\non the session for the devices listing and gate nothing; an unusable one is dropped rather\nthan refused.\n\n# Two statuses, because there are two outcomes\n\nAn account with a confirmed second factor (`S-C55`) gets **`202`** and a short-lived\nchallenge: the credentials were accepted and the request is not complete. No session is\nopened, no cohort is recorded and no refresh token is minted, because none of those may exist\nfor an authentication that has not finished — and the client's advisory identifiers ride the\n*completing* request instead, since that is what creates the session they describe.\n\nThe retired surface got this wrong in the most consequential way available: it had all four\nTOTP operations and its login never issued a challenge, so a confirmed second factor gated\nnothing at all.", - "operationId": "login_user", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LoginRequest" - } - } - }, - "required": true - }, - "responses": { "400": { "description": "Bad Request", - "content": { - "application/problem+json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "415": { - "description": "Unsupported Media Type", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "422": { - "description": "Unprocessable Entity", + }, "content": { "application/problem+json": { "schema": { @@ -155,38 +171,34 @@ } } }, - "200": { - "description": "A session was opened; here is its token pair.", - "content": { - "application/json": { + "415": { + "description": "Unsupported Media Type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/TokenResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "202": { - "description": "The password verified; a second factor is required to finish.", - "content": { - "application/json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/SecondFactorChallenge" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "401": { - "description": "Invalid credentials", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "423": { - "description": "Account locked", + }, "content": { "application/problem+json": { "schema": { @@ -195,50 +207,34 @@ } } }, - "500": { - "description": "Internal server error", - "content": { - "application/problem+json": { + "422": { + "description": "Unprocessable Entity", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "413": { - "description": "the request body exceeds the configured limit" - } - } - } - }, - "/v1/auth/refresh": { - "post": { - "summary": "Exchange a refresh token for a new pair, rotating the session.", - "description": "The presented session is **closed** and a new one opened in its place, so a refresh token is\ngood exactly once. The session's advisory provenance — its cohort hash and device id — is\ncarried across the rotation, or the devices listing would lose track of a device every time\nits tokens turned over.", - "operationId": "refresh_token", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RefreshRequest" - } - } - }, - "required": true - }, - "responses": { - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "415": { - "description": "Unsupported Media Type", + }, "content": { "application/problem+json": { "schema": { @@ -247,93 +243,210 @@ } } }, - "422": { - "description": "Unprocessable Entity", - "content": { - "application/problem+json": { + "201": { + "description": "Upload session created", + "headers": { + "Location": { + "description": "Where the session lives.", + "required": false, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": [ + "string", + "null" + ] + } + }, + "X-Capsule-Suggested-Chunk-Size": { + "description": "The starting chunk size.", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0.0, + "format": "uint64" + } + }, + "X-Capsule-Offset": { + "description": "The authoritative offset, on a resumed session.", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0.0, + "format": "uint64" + } + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "200": { - "description": "OK", + }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TokenResponse" + "$ref": "#/components/schemas/CreateUploadResponse" } } } }, - "401": { - "description": "Session expired", - "content": { - "application/problem+json": { + "200": { + "description": "The active session for these bytes, to resume", + "headers": { + "Location": { + "description": "Where the session lives.", + "required": false, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": [ + "string", + "null" + ] + } + }, + "X-Capsule-Suggested-Chunk-Size": { + "description": "The starting chunk size.", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0.0, + "format": "uint64" + } + }, + "X-Capsule-Offset": { + "description": "The authoritative offset, on a resumed session.", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0.0, + "format": "uint64" + } + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "500": { - "description": "Internal server error", + }, "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/CodedProblem" + "$ref": "#/components/schemas/CreateUploadResponse" } } } }, - "413": { - "description": "the request body exceeds the configured limit" - } - } - } - }, - "/v1/auth/logout": { - "post": { - "summary": "End the session the presented access token was issued against.", - "description": "Idempotent: a session that is already closed, expired, or was never opened produces the same\nanswer, because \"there is no longer a session\" is what the caller asked for.", - "operationId": "logout", - "responses": { - "401": { - "description": "Unauthorized", + "409": { + "description": "Album quiescing", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/CodedProblem" + "$ref": "#/components/schemas/DuplicateBlobProblem" } } } }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "204": { - "description": "the request succeeded and there is no content to send" - }, - "500": { - "description": "Internal server error", + }, "content": { "application/problem+json": { "schema": { @@ -343,32 +456,31 @@ } }, "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] - } - ] - } - }, - "/v1/auth/logout/all/challenge": { - "post": { - "summary": "Issue a single-use challenge for a global sign-out.", - "description": "**Authenticated by a session token, unlike the revoke itself.** That is not a contradiction\nof the ceremony's asymmetry: a challenge is worthless without the identity key, so handing\none to a stolen token costs nothing — while issuing them unauthenticated would make this an\noracle for whether an account exists. The account comes from the credential and never from a\nrequest field, so a caller cannot ask for somebody else's challenge.", - "operationId": "revoke_all_challenge", - "responses": { - "401": { - "description": "Unauthorized", + "description": "File too large", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -379,28 +491,34 @@ } } }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "200": { - "description": "OK", - "content": { - "application/json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/RevokeChallengeResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "500": { - "description": "Internal server error", + }, "content": { "application/problem+json": { "schema": { @@ -408,9 +526,6 @@ } } } - }, - "413": { - "description": "the request body exceeds the configured limit" } }, "security": [ @@ -420,24 +535,91 @@ ] } }, - "/v1/auth/logout/all": { - "post": { - "summary": "Close every session for the account the proof establishes.", - "description": "**No `Auth`, deliberately.** design/authentication.md gates this on proof of master-key\npossession *instead of* a session token, and the reason is the damage scenario: an attacker\nholding a stolen token could otherwise invoke \"log out of all devices\" and lock the\nlegitimate user out of every device they own. Requiring the identity key means a stolen\ntoken can revoke only itself. The account is established by the burned challenge, so there\nis no account field for a caller to aim at either.\n\nThe caller's own session goes with the rest. That is the ceremony, not an oversight.", - "operationId": "revoke_all", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RevokeAllRequest" - } + "/v1/upload/{id}": { + "delete": { + "summary": "Cancel a session: its record, its accepted chunks and its staged bytes, together.", + "description": "Refused while finalization is running — it is not interruptible — and refused once the\nsession is terminal, because there is nothing left to cancel and the receipt is what a\nclient should read instead.", + "operationId": "cancel_upload", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The session's identifier, as `POST /v1/upload` returned it.", + "required": true, + "schema": { + "type": "string" } }, - "required": true - }, + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], "responses": { - "400": { - "description": "Bad Request", + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -446,8 +628,34 @@ } } }, - "415": { - "description": "Unsupported Media Type", + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -456,8 +664,34 @@ } } }, - "422": { - "description": "Unprocessable Entity", + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -466,28 +700,63 @@ } } }, - "200": { - "description": "OK", - "content": { - "application/json": { + "204": { + "description": "the request succeeded and there is no content to send", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/RevokeAllResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } }, - "401": { - "description": "Master-key proof required", - "content": { - "application/problem+json": { + "404": { + "description": "Upload session not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "500": { - "description": "Internal server error", + }, "content": { "application/problem+json": { "schema": { @@ -496,28 +765,32 @@ } } }, - "413": { - "description": "the request body exceeds the configured limit" - } - } - } - }, - "/v1/auth/devices": { - "get": { - "summary": "List the caller's live sessions and the cohorts they group under.", - "description": "Scoped by credential with no path parameter, for the same reason the escrow is: the only\naccount entitled to a session ledger is its own, and making that structural beats enforcing\nit.", - "operationId": "list_devices", - "responses": { - "401": { - "description": "Unauthorized", + "409": { + "description": "Session not active", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -528,8 +801,34 @@ } } }, - "403": { - "description": "Forbidden", + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -538,18 +837,63 @@ } } }, - "200": { - "description": "OK", - "content": { - "application/json": { + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/DevicesResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } }, - "500": { - "description": "Internal server error", + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -557,9 +901,6 @@ } } } - }, - "413": { - "description": "the request body exceeds the configured limit" } }, "security": [ @@ -567,22 +908,52 @@ "bearer": [] } ] - } - }, - "/v1/auth/devices/{session_id}": { - "delete": { - "summary": "Revoke one of the caller's sessions.", - "description": "Any live token may do this, including for the session making the request — signing this\ndevice out is a legitimate thing to ask for, and refusing it would only push a client into\ncalling `logout` and hoping the two behave the same.\n\n**Only the caller's own sessions.** The ownership check is against the record the store\nreturns rather than against a separate lookup, so there is no window between checking and\nclosing, and a session id belonging to another account answers exactly as an unknown one\ndoes.", - "operationId": "revoke_session", + }, + "head": { + "summary": "Report a session's progress and state.", + "description": "The resumption primitive: a client that lost a connection, an acknowledgement, or a process\nasks here and learns the authoritative offset, the declared length and the session's state.\nThe answer carries **no body** — HTTP forbids one on `HEAD`, which is why the protocol puts\nall three on headers.", + "operationId": "head_upload", "parameters": [ { - "name": "session_id", + "name": "id", "in": "path", - "description": "The session's identifier.", + "description": "The session's identifier, as `POST /v1/upload` returned it.", "required": true, "schema": { "type": "string" } + }, + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } } ], "responses": { @@ -596,6 +967,30 @@ "type": "string" }, "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -608,6 +1003,32 @@ }, "403": { "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -618,29 +1039,32 @@ }, "400": { "description": "Bad Request", - "content": { - "application/problem+json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "204": { - "description": "the request succeeded and there is no content to send" - }, - "404": { - "description": "Not found", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "500": { - "description": "Internal server error", + }, "content": { "application/problem+json": { "schema": { @@ -649,120 +1073,131 @@ } } }, - "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] - } - ] - } - }, - "/v1/auth/devices/directory": { - "post": { - "summary": "Publish the caller's signed device directory.", - "description": "The bytes are stored verbatim; the server decodes them to read `directory_version` and\nnothing else. The monotonicity comparison is the store's, not this handler's — see\n[`crate::directory`] for why a read-compare-write here would be a rollback window.", - "operationId": "publish_device_directory", - "parameters": [ - { - "name": "X-Capsule-Identity-Key", - "in": "header", - "description": "The account's identity public key, standard base64 over the hybrid `classical ‖ ml`\nlayout. Required: invariant 23's second clause is undefined without it.", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } - } - ], - "requestBody": { - "content": { - "application/cbor": { - "schema": { - "type": "string", - "format": "binary" - } - } - }, - "required": true - }, - "responses": { - "401": { - "description": "Unauthorized", + "200": { + "description": "Progress and state on X-Capsule-* headers, no body", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Offset": { + "description": "The next byte the server expects.", + "required": true, + "schema": { + "type": "integer", + "minimum": 0.0, + "format": "uint64" + } + }, + "X-Capsule-Content-Length": { + "description": "The declared total, fixed at creation.", + "required": true, + "schema": { + "type": "integer", + "minimum": 0.0, + "format": "uint64" + } + }, + "X-Capsule-Upload-Status": { + "description": "Where the session is in its state machine.", "required": true, "schema": { "type": "string" - }, - "example": "Bearer" - } - }, - "content": { - "application/problem+json": { + } + }, + "Cache-Control": { + "description": "`no-store`: progress is not cacheable.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string" } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "415": { - "description": "Unsupported media type", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } }, - "200": { - "description": "OK", - "content": { - "application/json": { + "404": { + "description": "Upload session not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/PublishDirectoryResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "409": { - "description": "Directory version conflict", + }, "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/DirectoryConflictProblem" + "$ref": "#/components/schemas/CodedProblem" } } } }, "500": { "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -772,96 +1207,62 @@ } }, "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] - } - ] - } - }, - "/v1/auth/devices/directory/{user_id}": { - "get": { - "summary": "Fetch a user's signed device directory, verbatim.", - "description": "The response body is the exact bytes the owner signed. Re-encoding them would detach the\ndocument from its signature, and the failure would look like the *publisher's* bug.", - "operationId": "fetch_device_directory", - "parameters": [ - { - "name": "user_id", - "in": "path", - "description": "The account id.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "401": { - "description": "Unauthorized", + "description": "the request body exceeds the configured limit", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" - } - }, - "content": { - "application/problem+json": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } }, - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "200": { - "description": "OK", - "content": { - "application/cbor": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { "type": "string", - "format": "binary" + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "500": { - "description": "Internal server error", + }, "content": { "application/problem+json": { "schema": { @@ -869,9 +1270,6 @@ } } } - }, - "413": { - "description": "the request body exceeds the configured limit" } }, "security": [ @@ -879,89 +1277,78 @@ "bearer": [] } ] - } - }, - "/v1/auth/escrow": { - "get": { - "summary": "Fetch the caller's wrapped master key, verbatim.", - "description": "The bytes are what a client runs its KDF against, so they come back exactly as they went in.\nThe server never derives, unwraps or re-encodes: a re-encoded wrap is a wrap that no longer\nopens, and the failure would look like a lost master key.", - "operationId": "fetch_escrow", - "responses": { - "401": { - "description": "Unauthorized", - "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", - "required": true, - "schema": { - "type": "string" - }, - "example": "Bearer" - } - }, - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/CodedProblem" - } - } + }, + "patch": { + "summary": "Append a chunk, and finalize when it completes the declared size.", + "description": "Every rule the [chunk\ncontract](../../../capsule-docs/src/content/docs/design/import/upload-protocol.md) fixes is\nchecked before a byte is written, and the checksum is verified against the received bytes\n*first*, so a chunk corrupted in transit persists nothing.", + "operationId": "append_chunk", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The session's identifier, as `POST /v1/upload` returned it.", + "required": true, + "schema": { + "type": "string" } }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/CodedProblem" - } - } + { + "name": "X-Capsule-Offset", + "in": "header", + "description": "Where in the blob this chunk starts.", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] } }, - "200": { - "description": "OK", - "content": { - "application/octet-stream": { - "schema": { - "type": "string", - "format": "binary" - } - } + { + "name": "X-Capsule-Checksum", + "in": "header", + "description": "The chunk's SHA-256, bare lowercase hex. Required: the idempotency tuple is undefined\nwithout it.", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] } }, - "404": { - "description": "Not found", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/CodedProblem" - } - } + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, - "500": { - "description": "Internal server error", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/CodedProblem" - } - } + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 } }, - "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ { - "bearer": [] + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } } - ] - }, - "put": { - "summary": "Store the caller's wrapped master key, replacing whatever they had.", - "description": "`PUT`, because there is exactly one escrow per account and this is its address. Storing over\nan existing escrow is the guided re-wrap, and it deletes the old blob in the same operation —\nthe lost recovery secret must stop working, which is the entire point of rotating.", - "operationId": "store_escrow", + ], "requestBody": { "content": { "application/octet-stream": { @@ -984,6 +1371,30 @@ "type": "string" }, "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -996,16 +1407,32 @@ }, "403": { "description": "Forbidden", - "content": { - "application/problem+json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "415": { - "description": "Unsupported media type", + }, "content": { "application/problem+json": { "schema": { @@ -1015,27 +1442,33 @@ } }, "400": { - "description": "Malformed request", - "content": { - "application/problem+json": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "200": { - "description": "OK", - "content": { - "application/json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/StoreEscrowResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "500": { - "description": "Internal server error", + }, "content": { "application/problem+json": { "schema": { @@ -1044,43 +1477,32 @@ } } }, - "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] - } - ] - } - }, - "/v1/auth/reauthenticate": { - "post": { - "summary": "Prove a credential again on the current session, without opening a new one.", - "description": "**The only way to satisfy the freshness gate `S-C7` enforces**, and it exists because\nwithout it the gate is unusable: `authenticated_at` is deliberately *not* reset by a refresh,\nso a user signed in an hour ago would otherwise have to sign out entirely to add a device —\nand the session they abandoned would linger in their own devices listing.\n\nIt does not mint tokens and does not rotate the session. The caller keeps the credential\nthey already hold; what changes is one timestamp on the record behind it.\n\n# Errors\n\nThe same refusals as a sign-in, for the same reasons: a wrong password is\n`401 error.auth.invalid_credentials`, a locked account is `403`, and the account directory\nfailing is `500`. A caller that guessed a password here learns exactly what it would learn\nat `/v1/auth/login`, and no more.", - "operationId": "reauthenticate", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReauthenticateRequest" - } - } - }, - "required": true - }, - "responses": { - "401": { - "description": "Unauthorized", + "415": { + "description": "Unsupported media type", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -1091,18 +1513,72 @@ } } }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { + "204": { + "description": "the request succeeded and there is no content to send", + "headers": { + "X-Capsule-Offset": { + "description": "The next byte the server expects.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "integer", + "minimum": 0.0, + "format": "uint64" + } + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } }, - "400": { - "description": "Bad Request", + "404": { + "description": "Upload session not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -1111,18 +1587,70 @@ } } }, - "415": { - "description": "Unsupported Media Type", + "409": { + "description": "Offset mismatch", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/CodedProblem" + "$ref": "#/components/schemas/OffsetMismatchProblem" } } } }, - "422": { - "description": "Unprocessable Entity", + "413": { + "description": "Chunk too large", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -1131,18 +1659,34 @@ } } }, - "200": { - "description": "OK", - "content": { - "application/json": { + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/ReauthenticateResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "423": { - "description": "Account locked", + }, "content": { "application/problem+json": { "schema": { @@ -1151,8 +1695,34 @@ } } }, - "500": { - "description": "Internal server error", + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -1160,9 +1730,6 @@ } } } - }, - "413": { - "description": "the request body exceeds the configured limit" } }, "security": [ @@ -1172,129 +1739,124 @@ ] } }, - "/v1/auth/profile": { + "/v1/version": { "get": { - "summary": "The caller's own profile.", - "description": "There is no `{user_id}` segment, for the reason the escrow surface has none: the account\ncomes from the credential, so reading somebody else's profile is not a forbidden request but\nan unrepresentable one. A directory of *other* people's public facts already exists and is a\ndifferent surface — `GET /v1/auth/devices/directory/{user_id}` — which publishes keys and\nnothing else.", - "operationId": "get_profile", + "summary": "Reports the server's name and version.", + "description": "Unauthenticated and side-effect free. Clients use it as a reachability probe before\nattempting a protocol handshake, so it must stay cheap and must never fail for a reason\nthe caller could act on — there is no failure variant, and the return type says so.", + "operationId": "get_version", "responses": { - "401": { - "description": "Unauthorized", + "200": { + "description": "OK", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" - } - }, - "content": { - "application/problem+json": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "200": { - "description": "OK", + }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProfileResponse" + "$ref": "#/components/schemas/VersionResponse" } } } }, - "404": { - "description": "Not found", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/CodedProblem" + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "500": { - "description": "Internal server error", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } - }, - "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] } - ] - }, - "patch": { - "summary": "Edit the caller's own profile.", - "description": "`PATCH`, because the body is a partial: what it does not mention, it does not change. An\nempty body is a valid request and answers `200` with the profile unchanged — a client that\nsent nothing asked for nothing, and refusing it would make \"save\" fail on a form nobody\nedited.", - "operationId": "update_profile", + } + } + }, + "/v1/auth/register": { + "post": { + "summary": "Create an account, and open its first session.", + "description": "# Why it signs you in\n\nThe alternative is `201` with no body and a client that immediately posts the same\ncredentials to `/v1/auth/login`, which is one more round trip for one more chance to fail and\nnothing gained. It also makes the CLI's `capsule register` mean what a person expects: after\nit, you are registered *and* signed in.\n\n# What it does not do\n\n**It does not publish a device directory**, and the account is therefore unable to upload\nuntil its client publishes one. That is not an omission here: `S-C20` removed the\naccount-creation fallback for invariant 7's floor precisely so that \"was this device in the\ndirectory\" has an honest answer for a brand-new account, and the honest answer is *no*. A\nclient's first action after registering is `POST /v1/auth/devices/directory`.\n\n**It is not rate-limited**, and that is a real gap rather than an oversight — see\n[`crate::auth::registry`] for the fact the limiter is waiting on. This is the one\nunauthenticated write on the surface.\n\n# `200`, where Salvo answered `201`\n\nKynos's `Created` requires a `Location` — a `201` that does not say *where* tells a client\nsomething exists and not how to reach it, which is a defect the type refuses to let you\ncommit. This server exposes no URL for an account: `GET /v1/auth/profile` is among the\noperations `S-C53` records as unported. Inventing a location to satisfy a status would be\ninventing a surface, so the status moved instead. What a caller actually needs — the token\npair — is in the body either way.", + "operationId": "register_user", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateProfileRequest" + "$ref": "#/components/schemas/RegisterRequest" } } }, "required": true }, "responses": { - "401": { - "description": "Unauthorized", + "400": { + "description": "Bad Request", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" - } - }, - "content": { - "application/problem+json": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "400": { - "description": "Bad Request", + }, "content": { "application/problem+json": { "schema": { @@ -1305,6 +1867,32 @@ }, "415": { "description": "Unsupported Media Type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -1315,6 +1903,32 @@ }, "422": { "description": "Unprocessable Entity", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -1325,16 +1939,68 @@ }, "200": { "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProfileResponse" + "$ref": "#/components/schemas/TokenResponse" } } } }, - "404": { - "description": "Not found", + "409": { + "description": "Account already exists", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -1345,6 +2011,32 @@ }, "500": { "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -1354,64 +2046,81 @@ } }, "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } } - ] + } } }, - "/v1/auth/password": { + "/v1/auth/login": { "post": { - "summary": "Replace the password this account's sessions are opened with.", - "description": "# Every *other* session ends\n\nA password change whose point is that a credential has leaked would be worthless if the\nsessions opened with the leaked credential kept working. So the change closes every session\nof the account — and then re-opens the caller's own, **under its own session id**, so the\nperson doing the rotation is not signed out of the device they are doing it on while\neverybody else is.\n\nRe-opening the same id rather than minting a new one is what lets this answer `204` with no\nbody: the caller's existing token pair keeps working, because the session it names is still\nthere. Returning a fresh pair was considered and rejected — it would make this a second token\nmint with none of `POST /v1/auth/refresh`'s rotation discipline, for no gain.\n\nThe re-opened record's `authenticated_at` is **now**, and that is not bookkeeping: presenting\nthe current password *is* a credential presentation, so a freshness gate (`S-C7`) measuring\nfrom anything earlier would be measuring from the wrong moment.\n\n# Why the order is verify, write, revoke\n\nVerification first, because a wrong current password must change nothing. The write next,\nbecause a revocation that ran before it would sign everybody out and then fail. The\nrevocation last, and its failure is **logged and not returned**: the password is already\nchanged, so answering `500` would tell the caller the rotation did not happen when it did,\nand they would try again with a current password that is no longer current.", - "operationId": "change_password", + "summary": "Exchange an email and password for a session — or for a second-factor challenge.", + "description": "The two advisory identifiers a client may send — `cohort_hash` and `device_id` — are recorded\non the session for the devices listing and gate nothing; an unusable one is dropped rather\nthan refused.\n\n# Two statuses, because there are two outcomes\n\nAn account with a confirmed second factor (`S-C55`) gets **`202`** and a short-lived\nchallenge: the credentials were accepted and the request is not complete. No session is\nopened, no cohort is recorded and no refresh token is minted, because none of those may exist\nfor an authentication that has not finished — and the client's advisory identifiers ride the\n*completing* request instead, since that is what creates the session they describe.\n\nThe retired surface got this wrong in the most consequential way available: it had all four\nTOTP operations and its login never issued a challenge, so a confirmed second factor gated\nnothing at all.", + "operationId": "login_user", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ChangePasswordRequest" + "$ref": "#/components/schemas/LoginRequest" } } }, "required": true }, "responses": { - "401": { - "description": "Unauthorized", + "400": { + "description": "Bad Request", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" - } - }, - "content": { - "application/problem+json": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "400": { - "description": "Bad Request", + }, "content": { "application/problem+json": { "schema": { @@ -1422,6 +2131,32 @@ }, "415": { "description": "Unsupported Media Type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -1432,19 +2167,32 @@ }, "422": { "description": "Unprocessable Entity", - "content": { - "application/problem+json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "204": { - "description": "the request succeeded and there is no content to send" - }, - "423": { - "description": "Account locked", + }, "content": { "application/problem+json": { "schema": { @@ -1453,65 +2201,106 @@ } } }, - "404": { - "description": "Not found", - "content": { - "application/problem+json": { + "200": { + "description": "A session was opened; here is its token pair.", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "500": { - "description": "Internal server error", + }, "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/CodedProblem" + "$ref": "#/components/schemas/TokenResponse" } } } }, - "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] - } - ] - } - }, - "/v1/auth/totp/enroll": { - "post": { - "summary": "Start enrolling an authenticator.", - "description": "Answers the `otpauth://` URI the app scans. Nothing is gated yet: until a code confirms the\nsecret, sign-in is unchanged — which is what stops a mis-scanned QR code from locking\nsomebody out of their own account.", - "operationId": "totp_enroll", - "responses": { - "401": { - "description": "Unauthorized", + "202": { + "description": "The password verified; a second factor is required to finish.", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/CodedProblem" + "$ref": "#/components/schemas/SecondFactorChallenge" } } } }, - "403": { - "description": "Forbidden", + "401": { + "description": "Invalid credentials", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -1520,18 +2309,34 @@ } } }, - "200": { - "description": "OK", - "content": { - "application/json": { + "423": { + "description": "Account locked", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/EnrollmentResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "409": { - "description": "Already active", + }, "content": { "application/problem+json": { "schema": { @@ -1542,6 +2347,32 @@ }, "500": { "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -1551,64 +2382,81 @@ } }, "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } } - ] + } } }, - "/v1/auth/totp/verify-enrollment": { + "/v1/auth/refresh": { "post": { - "summary": "Confirm an enrollment with a live code.", - "description": "The confirming code is **spent**: its step goes straight into the replay ledger, so it cannot\nalso complete a sign-in a moment later. That is the one place the ledger's first entry comes\nfrom, and skipping it would leave the newest code in the account's history unused.", - "operationId": "totp_verify_enrollment", + "summary": "Exchange a refresh token for a new pair, rotating the session.", + "description": "The presented session is **closed** and a new one opened in its place, so a refresh token is\ngood exactly once. The session's advisory provenance — its cohort hash and device id — is\ncarried across the rotation, or the devices listing would lose track of a device every time\nits tokens turned over.", + "operationId": "refresh_token", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CodeRequest" + "$ref": "#/components/schemas/RefreshRequest" } } }, "required": true }, "responses": { - "401": { - "description": "Unauthorized", + "400": { + "description": "Bad Request", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" - } - }, - "content": { - "application/problem+json": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "400": { - "description": "Bad Request", + }, "content": { "application/problem+json": { "schema": { @@ -1619,29 +2467,32 @@ }, "415": { "description": "Unsupported Media Type", - "content": { - "application/problem+json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "422": { - "description": "Unprocessable Entity", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "204": { - "description": "the request succeeded and there is no content to send" - }, - "409": { - "description": "Nothing pending", + }, "content": { "application/problem+json": { "schema": { @@ -1650,53 +2501,32 @@ } } }, - "500": { - "description": "Internal server error", - "content": { - "application/problem+json": { + "422": { + "description": "Unprocessable Entity", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] - } - ] - } - }, - "/v1/auth/totp/disable": { - "post": { - "summary": "Remove the second factor, on presentation of a live code.", - "description": "**A session is not enough.** The whole point of the factor is that a stolen access token is\ninsufficient, and a disable that took only a token would let the token turn off the control\nthat makes it insufficient.", - "operationId": "totp_disable", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CodeRequest" - } - } - }, - "required": true - }, - "responses": { - "401": { - "description": "Unauthorized", - "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -1707,108 +2537,70 @@ } } }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "415": { - "description": "Unsupported Media Type", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "422": { - "description": "Unprocessable Entity", + }, "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/CodedProblem" + "$ref": "#/components/schemas/TokenResponse" } } } }, - "204": { - "description": "the request succeeded and there is no content to send" - }, - "409": { - "description": "Not enrolled", - "content": { - "application/problem+json": { + "401": { + "description": "Session expired", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "500": { - "description": "Internal server error", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] - } - ] - } - }, - "/v1/auth/login/verify-totp": { - "post": { - "summary": "Complete a sign-in with a code.", - "description": "This is where the session is opened — not `POST /v1/auth/login`, which for an account with a\nsecond factor opens nothing. The advisory `cohort_hash` and `device_id` ride *this* request\nfor the same reason: the session they describe is created here.", - "operationId": "totp_verify_login", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/VerifyLoginRequest" - } - } - }, - "required": true - }, - "responses": { - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "415": { - "description": "Unsupported Media Type", + }, "content": { "application/problem+json": { "schema": { @@ -1817,38 +2609,34 @@ } } }, - "422": { - "description": "Unprocessable Entity", - "content": { - "application/problem+json": { + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "200": { - "description": "OK", - "content": { - "application/json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/TokenResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "401": { - "description": "Challenge expired", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "429": { - "description": "Too many attempts", + }, "content": { "application/problem+json": { "schema": { @@ -1857,27 +2645,43 @@ } } }, - "500": { - "description": "Internal server error", - "content": { - "application/problem+json": { + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } - }, - "413": { - "description": "the request body exceeds the configured limit" } } } }, - "/v1/auth/devices/enroll": { + "/v1/auth/logout": { "post": { - "summary": "Issue a one-time enrollment code for the caller's account.", - "description": "Gated on a recent credential presentation, not merely on a valid session — a stolen token\nmust not be able to enroll a rogue device. See [`crate::enrollment`] for exactly how much\nthat gate can mean.", - "operationId": "issue_enrollment_code", + "summary": "End the session the presented access token was issued against.", + "description": "Idempotent: a session that is already closed, expired, or was never opened produces the same\nanswer, because \"there is no longer a session\" is what the caller asked for.", + "operationId": "logout", "responses": { "401": { "description": "Unauthorized", @@ -1889,6 +2693,30 @@ "type": "string" }, "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -1901,26 +2729,97 @@ }, "403": { "description": "Forbidden", - "content": { - "application/problem+json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "200": { - "description": "OK", - "content": { - "application/json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/EnrollmentCodeResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "204": { + "description": "the request succeeded and there is no content to send", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } }, "500": { "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -1930,7 +2829,33 @@ } }, "413": { - "description": "the request body exceeds the configured limit" + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } } }, "security": [ @@ -1940,34 +2865,48 @@ ] } }, - "/v1/auth/devices/enroll/redeem": { + "/v1/auth/logout/all/challenge": { "post": { - "summary": "Redeem a code for a relay channel.", - "description": "**Unauthenticated, necessarily.** Device B has no account, no session and no key material —\nit is a phone that has just scanned a QR code. The code is the only thing it holds, so the\ncode is the credential.", - "operationId": "redeem_enrollment_code", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RedeemRequest" - } - } - }, - "required": true - }, + "summary": "Issue a single-use challenge for a global sign-out.", + "description": "**Authenticated by a session token, unlike the revoke itself.** That is not a contradiction\nof the ceremony's asymmetry: a challenge is worthless without the identity key, so handing\none to a stolen token costs nothing — while issuing them unauthenticated would make this an\noracle for whether an account exists. The account comes from the credential and never from a\nrequest field, so a caller cannot ask for somebody else's challenge.", + "operationId": "revoke_all_challenge", "responses": { - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "415": { - "description": "Unsupported Media Type", + }, "content": { "application/problem+json": { "schema": { @@ -1976,8 +2915,34 @@ } } }, - "422": { - "description": "Unprocessable Entity", + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -1988,98 +2953,68 @@ }, "200": { "description": "OK", - "content": { - "application/json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/ChannelResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "404": { - "description": "Code refused", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "429": { - "description": "Too many attempts", + }, "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/CodedProblem" + "$ref": "#/components/schemas/RevokeChallengeResponse" } } } }, "500": { "description": "Internal server error", - "content": { - "application/problem+json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "413": { - "description": "the request body exceeds the configured limit" - } - } - } - }, - "/v1/auth/devices/enroll/channel/{channel_id}": { - "get": { - "summary": "Take everything pending in one of a channel's mailboxes.", - "description": "Destructive: a relayed payload is delivered once. Draining one direction leaves the other\nuntouched, so the two devices do not consume each other's mail.", - "operationId": "drain_enrollment_channel", - "parameters": [ - { - "name": "channel_id", - "in": "path", - "description": "The handle a redeemed code returned.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "direction", - "in": "query", - "description": "`to_initiator` or `to_enrollee`.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "200": { - "description": "OK", - "content": { - "application/json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/DrainResponse" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "404": { - "description": "Channel not found", + }, "content": { "application/problem+json": { "schema": { @@ -2088,41 +3023,53 @@ } } }, - "500": { - "description": "Internal server error", - "content": { - "application/problem+json": { + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } - }, - "413": { - "description": "the request body exceeds the configured limit" } - } - }, - "post": { - "summary": "Append a payload to one of a channel's two mailboxes.", - "description": "Unauthenticated and gated by the handle alone. The relay is a dumb pipe by design — see\n[`crate::enrollment`] — and the safety-code check is what defends the ceremony.", - "operationId": "relay_enrollment_payload", - "parameters": [ + }, + "security": [ { - "name": "channel_id", - "in": "path", - "description": "The handle a redeemed code returned.", - "required": true, - "schema": { - "type": "string" - } + "bearer": [] } - ], + ] + } + }, + "/v1/auth/logout/all": { + "post": { + "summary": "Close every session for the account the proof establishes.", + "description": "**No `Auth`, deliberately.** design/authentication.md gates this on proof of master-key\npossession *instead of* a session token, and the reason is the damage scenario: an attacker\nholding a stolen token could otherwise invoke \"log out of all devices\" and lock the\nlegitimate user out of every device they own. Requiring the identity key means a stolen\ntoken can revoke only itself. The account is established by the burned challenge, so there\nis no account field for a caller to aim at either.\n\nThe caller's own session goes with the rest. That is the ceremony, not an oversight.", + "operationId": "revoke_all", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RelayRequest" + "$ref": "#/components/schemas/RevokeAllRequest" } } }, @@ -2131,6 +3078,32 @@ "responses": { "400": { "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -2141,6 +3114,32 @@ }, "415": { "description": "Unsupported Media Type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -2151,29 +3150,32 @@ }, "422": { "description": "Unprocessable Entity", - "content": { - "application/problem+json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "204": { - "description": "the request succeeded and there is no content to send" - }, - "404": { - "description": "Channel not found", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "500": { - "description": "Internal server error", + }, "content": { "application/problem+json": { "schema": { @@ -2182,72 +3184,70 @@ } } }, - "413": { - "description": "the request body exceeds the configured limit" - } - } - }, - "delete": { - "summary": "Close a channel and drop both mailboxes with it.", - "description": "**The initiator's, and authenticated.** A close is the one relay operation that is not\nidempotent from the other device's point of view — it ends the ceremony — so leaving it on\nthe handle alone would make an abandoned QR code a denial of service. The account is checked\nagainst the channel's recorded initiator, and a channel belonging to another account answers\nexactly as an unknown one does.", - "operationId": "close_enrollment_channel", - "parameters": [ - { - "name": "channel_id", - "in": "path", - "description": "The handle a redeemed code returned.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "401": { - "description": "Unauthorized", + "200": { + "description": "OK", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/CodedProblem" + "$ref": "#/components/schemas/RevokeAllResponse" } } } }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { + "401": { + "description": "Master-key proof required", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "204": { - "description": "the request succeeded and there is no content to send" - }, - "404": { - "description": "Channel not found", + }, "content": { "application/problem+json": { "schema": { @@ -2258,6 +3258,32 @@ }, "500": { "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -2267,31 +3293,42 @@ } }, "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } } - ] + } } }, - "/v1/albums": { - "post": { - "summary": "Bind an album id to the authenticated caller.", - "description": "Idempotent: the same id from a second device, or after a recovery, is a success that writes\nnothing.", - "operationId": "provision_album", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProvisionAlbumRequest" - } - } - }, - "required": true - }, + "/v1/auth/devices": { + "get": { + "summary": "List the caller's live sessions and the cohorts they group under.", + "description": "Scoped by credential with no path parameter, for the same reason the escrow is: the only\naccount entitled to a session ledger is its own, and making that structural beats enforcing\nit.", + "operationId": "list_devices", "responses": { "401": { "description": "Unauthorized", @@ -2303,6 +3340,30 @@ "type": "string" }, "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -2315,36 +3376,32 @@ }, "403": { "description": "Forbidden", - "content": { - "application/problem+json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "415": { - "description": "Unsupported Media Type", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "422": { - "description": "Unprocessable Entity", + }, "content": { "application/problem+json": { "schema": { @@ -2353,28 +3410,70 @@ } } }, - "201": { - "description": "The album was created and bound to the caller", - "content": { - "application/json": { + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/ProvisionAlbumResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "200": { - "description": "The album id was already provisioned to this account; nothing was written", + }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProvisionAlbumResponse" + "$ref": "#/components/schemas/DevicesResponse" } } } }, "500": { "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -2384,7 +3483,33 @@ } }, "413": { - "description": "the request body exceeds the configured limit" + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } } }, "security": [ @@ -2394,16 +3519,16 @@ ] } }, - "/v1/albums/{album_id}/upgrade": { - "get": { - "summary": "Read the ceremony's phase and the drain count.", - "description": "The one call a proposer polls between steps 2 and 4. `in_flight` reaching zero is the signal\nthat the tombstone may be committed.", - "operationId": "album_upgrade_phase", + "/v1/auth/devices/{session_id}": { + "delete": { + "summary": "Revoke one of the caller's sessions.", + "description": "Any live token may do this, including for the session making the request — signing this\ndevice out is a legitimate thing to ask for, and refusing it would only push a client into\ncalling `logout` and hoping the two behave the same.\n\n**Only the caller's own sessions.** The ownership check is against the record the store\nreturns rather than against a separate lookup, so there is no window between checking and\nclosing, and a session id belonging to another account answers exactly as an unknown one\ndoes.", + "operationId": "revoke_session", "parameters": [ { - "name": "album_id", + "name": "session_id", "in": "path", - "description": "The album's id.", + "description": "The session's identifier.", "required": true, "schema": { "type": "string" @@ -2421,6 +3546,30 @@ "type": "string" }, "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -2433,6 +3582,32 @@ }, "403": { "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -2443,6 +3618,32 @@ }, "400": { "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -2451,18 +3652,63 @@ } } }, - "200": { - "description": "OK", - "content": { - "application/json": { + "204": { + "description": "the request succeeded and there is no content to send", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/UpgradePhaseResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } }, "404": { "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -2473,6 +3719,32 @@ }, "500": { "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -2482,7 +3754,33 @@ } }, "413": { - "description": "the request body exceeds the configured limit" + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } } }, "security": [ @@ -2490,19 +3788,24 @@ "bearer": [] } ] - }, + } + }, + "/v1/auth/devices/directory": { "post": { - "summary": "Put an album into upgrade quiescence.", - "description": "Idempotent under its own `intent_id`: versioning.md is explicit that the same `UpgradeIntent`\nnever produces two forks, and a proposer that lost an acknowledgement re-POSTs the same bytes.", - "operationId": "begin_album_upgrade", + "summary": "Publish the caller's signed device directory.", + "description": "The bytes are stored verbatim; the server decodes them to read `directory_version` and\nnothing else. The monotonicity comparison is the store's, not this handler's — see\n[`crate::directory`] for why a read-compare-write here would be a rollback window.", + "operationId": "publish_device_directory", "parameters": [ { - "name": "album_id", - "in": "path", - "description": "The album's id.", - "required": true, + "name": "X-Capsule-Identity-Key", + "in": "header", + "description": "The account's identity public key, standard base64 over the hybrid `classical ‖ ml`\nlayout. Required: invariant 23's second clause is undefined without it.", + "required": false, "schema": { - "type": "string" + "type": [ + "string", + "null" + ] } } ], @@ -2528,6 +3831,30 @@ "type": "string" }, "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -2540,8 +3867,34 @@ }, "403": { "description": "Forbidden", - "content": { - "application/problem+json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { "schema": { "$ref": "#/components/schemas/CodedProblem" } @@ -2550,6 +3903,32 @@ }, "400": { "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -2560,6 +3939,32 @@ }, "415": { "description": "Unsupported media type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -2570,36 +3975,104 @@ }, "200": { "description": "OK", - "content": { - "application/json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/UpgradePhaseResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "404": { - "description": "Not found", + }, "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/CodedProblem" + "$ref": "#/components/schemas/PublishDirectoryResponse" } } } }, "409": { - "description": "Upgrade in flight", + "description": "Directory version conflict", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/CodedProblem" + "$ref": "#/components/schemas/DirectoryConflictProblem" } } } }, "500": { "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -2609,7 +4082,33 @@ } }, "413": { - "description": "the request body exceeds the configured limit" + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } } }, "security": [ @@ -2617,25 +4116,18 @@ "bearer": [] } ] - }, - "delete": { - "summary": "Abort a ceremony, returning the album to normal operation.", - "description": "Named by `intent_id` in the path's own query so that aborting is a statement about *which*\nupgrade — a caller that does not hold the live id gets a `409` rather than the power to\ncancel somebody else's ceremony.", - "operationId": "abort_album_upgrade", + } + }, + "/v1/auth/devices/directory/{user_id}": { + "get": { + "summary": "Fetch a user's signed device directory, verbatim.", + "description": "The response body is the exact bytes the owner signed. Re-encoding them would detach the\ndocument from its signature, and the failure would look like the *publisher's* bug.", + "operationId": "fetch_device_directory", "parameters": [ { - "name": "album_id", + "name": "user_id", "in": "path", - "description": "The album's id.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "intent_id", - "in": "query", - "description": "The ceremony to abort.", + "description": "The account id.", "required": true, "schema": { "type": "string" @@ -2653,6 +4145,30 @@ "type": "string" }, "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -2665,6 +4181,32 @@ }, "403": { "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -2675,6 +4217,32 @@ }, "400": { "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -2685,26 +4253,69 @@ }, "200": { "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { - "application/json": { + "application/cbor": { "schema": { - "$ref": "#/components/schemas/UpgradePhaseResponse" + "type": "string", + "format": "binary" } } } }, "404": { "description": "Not found", - "content": { - "application/problem+json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "409": { - "description": "Upgrade in flight", + }, "content": { "application/problem+json": { "schema": { @@ -2715,16 +4326,68 @@ }, "500": { "description": "Internal server error", - "content": { - "application/problem+json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" } } } }, "413": { - "description": "the request body exceeds the configured limit" + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } } }, "security": [ @@ -2734,11 +4397,11 @@ ] } }, - "/v1/quota": { + "/v1/auth/escrow": { "get": { - "summary": "Report the authenticated uploader's storage-quota snapshot.", - "description": "Scoped to the caller, and to nobody else: quota is accounted to the *uploader*, and one\naccount's storage use is not another's business.", - "operationId": "get_quota", + "summary": "Fetch the caller's wrapped master key, verbatim.", + "description": "The bytes are what a client runs its KDF against, so they come back exactly as they went in.\nThe server never derives, unwraps or re-encodes: a re-encoded wrap is a wrap that no longer\nopens, and the failure would look like a lost master key.", + "operationId": "fetch_escrow", "responses": { "401": { "description": "Unauthorized", @@ -2750,6 +4413,30 @@ "type": "string" }, "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -2762,6 +4449,32 @@ }, "403": { "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -2772,16 +4485,105 @@ }, "200": { "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { - "application/json": { + "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/QuotaResponse" + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" } } } }, "500": { "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -2791,7 +4593,33 @@ } }, "413": { - "description": "the request body exceeds the configured limit" + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } } }, "security": [ @@ -2799,12 +4627,22 @@ "bearer": [] } ] - } - }, - "/v1/moderation/record": { - "get": { - "summary": "Serve the caller's own moderation record.", - "operationId": "moderation_record", + }, + "put": { + "summary": "Store the caller's wrapped master key, replacing whatever they had.", + "description": "`PUT`, because there is exactly one escrow per account and this is its address. Storing over\nan existing escrow is the guided re-wrap, and it deletes the old blob in the same operation —\nthe lost recovery secret must stop working, which is the entire point of rotating.", + "operationId": "store_escrow", + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "required": true + }, "responses": { "401": { "description": "Unauthorized", @@ -2816,6 +4654,30 @@ "type": "string" }, "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -2828,6 +4690,32 @@ }, "403": { "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -2836,18 +4724,34 @@ } } }, - "200": { - "description": "OK", - "content": { - "application/json": { + "415": { + "description": "Unsupported media type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/ModerationRecordResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "500": { - "description": "Internal server error", + }, "content": { "application/problem+json": { "schema": { @@ -2856,101 +4760,106 @@ } } }, - "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] - } - ] - } - }, - "/.well-known/capsule/attestation-keys": { - "get": { - "summary": "Serve this server's storage-attestation keys and their append-only history.", - "description": "Cacheable and unauthenticated. It changes only when a key rotates, and a client that pinned\na stale copy still resolves every receipt signed before it fetched — which is the property\nthe append-only ordering buys.", - "operationId": "attestation_keys", - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { + "400": { + "description": "Malformed request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/AttestationKeysResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "413": { - "description": "the request body exceeds the configured limit" - } - } - } - }, - "/.well-known/capsule/server-info": { - "get": { - "summary": "Serve this server's public, server-scoped facts.", - "description": "Unauthenticated by contract: a client deciding whether it can talk to this server at all has\nno credential yet, and a peer resolving the key that verifies a capability token must not\nneed one from the server whose claims it is checking.", - "operationId": "server_info", - "responses": { - "200": { - "description": "OK", + }, "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ServerInfoResponse" + "$ref": "#/components/schemas/CodedProblem" } } } }, - "413": { - "description": "the request body exceeds the configured limit" - } - } - } - }, - "/.well-known/capsule/deprecation": { - "get": { - "summary": "Serve the announced deprecation cutoffs.", - "description": "The same announcements `server-info` carries, at their own path because that is the URL the\n`Warning:` header on a below-cutoff response points a human at, and because a client polling\nfor a cutoff should not have to refetch the whole discovery record to find one.", - "operationId": "deprecation_announcements", - "responses": { "200": { "description": "OK", - "content": { - "application/json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/DeprecationsResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "413": { - "description": "the request body exceeds the configured limit" - } - } - } - }, - "/.well-known/capsule/revoked-jti": { - "get": { - "summary": "Serve the federation capability revocation list.", - "description": "Bounded by at most 24 hours of revocations, because an entry past the token's own `exp` is\npruned and a capability token cannot be minted to live longer than that. Public: a peer\nchecking whether a token it holds is still good is, by construction, not yet authenticated\nhere, and the record names no user — only opaque `jti`s.\n\n# Errors\n\nReturns `503` if the revocation list cannot be read. Deliberately *not* an empty list: an\nempty list is the strongest possible claim this endpoint can make — nothing is revoked — and\nserving it on a storage failure would turn an outage into a silent un-revocation of every\ntoken, which is exactly what the peer-side fail-closed rule exists to prevent.", - "operationId": "revoked_jti", - "responses": { - "200": { - "description": "OK", + }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RevokedJtiResponse" + "$ref": "#/components/schemas/StoreEscrowResponse" } } } }, - "503": { - "description": "Revocation list unavailable", + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -2960,35 +4869,52 @@ } }, "413": { - "description": "the request body exceeds the configured limit" + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } } - } + }, + "security": [ + { + "bearer": [] + } + ] } }, - "/v1/upload": { + "/v1/auth/reauthenticate": { "post": { - "summary": "Open an upload session for one blob of an asset bundle.", - "description": "Runs the refuse-by-default envelope battery — invariants 1–8 and the top-level↔envelope\nconsistency family — **before** anything is written, then stages the session's file and\nrecords the session. A request whose `(owner, hash, album)` tuple already has an active\nsession gets that session back rather than a second one.", - "operationId": "create_upload", - "parameters": [ - { - "name": "X-Capsule-Protocol", - "in": "header", - "description": "The protocol date the client speaks.\n\nRead as a string rather than a typed value so that a malformed one is *this* surface's\ncoded `400` rather than the framework's uncoded one.", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } - } - ], + "summary": "Prove a credential again on the current session, without opening a new one.", + "description": "**The only way to satisfy the freshness gate `S-C7` enforces**, and it exists because\nwithout it the gate is unusable: `authenticated_at` is deliberately *not* reset by a refresh,\nso a user signed in an hour ago would otherwise have to sign out entirely to add a device —\nand the session they abandoned would linger in their own devices listing.\n\nIt does not mint tokens and does not rotate the session. The caller keeps the credential\nthey already hold; what changes is one timestamp on the record behind it.\n\n# Errors\n\nThe same refusals as a sign-in, for the same reasons: a wrong password is\n`401 error.auth.invalid_credentials`, a locked account is `403`, and the account directory\nfailing is `500`. A caller that guessed a password here learns exactly what it would learn\nat `/v1/auth/login`, and no more.", + "operationId": "reauthenticate", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateUploadRequest" + "$ref": "#/components/schemas/ReauthenticateRequest" } } }, @@ -3005,6 +4931,30 @@ "type": "string" }, "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -3017,6 +4967,32 @@ }, "403": { "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -3027,6 +5003,32 @@ }, "400": { "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -3037,6 +5039,32 @@ }, "415": { "description": "Unsupported Media Type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -3047,6 +5075,32 @@ }, "422": { "description": "Unprocessable Entity", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -3055,120 +5109,106 @@ } } }, - "201": { - "description": "Upload session created", + "200": { + "description": "OK", "headers": { - "Location": { - "description": "Where the session lives.", - "required": false, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "type": [ - "string", - "null" - ] + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, - "X-Capsule-Suggested-Chunk-Size": { - "description": "The starting chunk size.", - "required": false, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0.0, - "format": "uint64" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, - "X-Capsule-Offset": { - "description": "The authoritative offset, on a resumed session.", - "required": false, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0.0, - "format": "uint64" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateUploadResponse" + "$ref": "#/components/schemas/ReauthenticateResponse" } } } }, - "200": { - "description": "The active session for these bytes, to resume", + "423": { + "description": "Account locked", "headers": { - "Location": { - "description": "Where the session lives.", - "required": false, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "type": [ - "string", - "null" - ] + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, - "X-Capsule-Suggested-Chunk-Size": { - "description": "The starting chunk size.", - "required": false, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0.0, - "format": "uint64" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, - "X-Capsule-Offset": { - "description": "The authoritative offset, on a resumed session.", - "required": false, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0.0, - "format": "uint64" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } }, "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/CreateUploadResponse" + "$ref": "#/components/schemas/CodedProblem" } } } }, - "409": { - "description": "Album quiescing", - "content": { - "application/problem+json": { + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/DuplicateBlobProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "426": { - "description": "Protocol version unsupported", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/ProtocolRangeProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "413": { - "description": "File too large", + }, "content": { "application/problem+json": { "schema": { @@ -3177,12 +5217,31 @@ } } }, - "500": { - "description": "Internal server error", - "content": { - "application/problem+json": { + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } @@ -3195,34 +5254,11 @@ ] } }, - "/v1/upload/{id}": { - "delete": { - "summary": "Cancel a session: its record, its accepted chunks and its staged bytes, together.", - "description": "Refused while finalization is running — it is not interruptible — and refused once the\nsession is terminal, because there is nothing left to cancel and the receipt is what a\nclient should read instead.", - "operationId": "cancel_upload", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "The session's identifier, as `POST /v1/upload` returned it.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "X-Capsule-Protocol", - "in": "header", - "description": "The protocol date the client speaks.\n\nRead as a string rather than a typed value so that a malformed one is *this* surface's\ncoded `400` rather than the framework's uncoded one.", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } - } - ], + "/v1/auth/profile": { + "get": { + "summary": "The caller's own profile.", + "description": "There is no `{user_id}` segment, for the reason the escrow surface has none: the account\ncomes from the credential, so reading somebody else's profile is not a forbidden request but\nan unrepresentable one. A directory of *other* people's public facts already exists and is a\ndifferent surface — `GET /v1/auth/devices/directory/{user_id}` — which publishes keys and\nnothing else.", + "operationId": "get_profile", "responses": { "401": { "description": "Unauthorized", @@ -3234,6 +5270,30 @@ "type": "string" }, "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -3246,16 +5306,32 @@ }, "403": { "description": "Forbidden", - "content": { - "application/problem+json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "400": { - "description": "Bad Request", + }, "content": { "application/problem+json": { "schema": { @@ -3264,31 +5340,70 @@ } } }, - "204": { - "description": "the request succeeded and there is no content to send" - }, - "426": { - "description": "Protocol version unsupported", + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/ProtocolRangeProblem" + "$ref": "#/components/schemas/ProfileResponse" } } } }, "404": { - "description": "Upload session not found", - "content": { - "application/problem+json": { + "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "409": { - "description": "Session not active", + }, "content": { "application/problem+json": { "schema": { @@ -3299,6 +5414,32 @@ }, "500": { "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -3308,7 +5449,33 @@ } }, "413": { - "description": "the request body exceeds the configured limit" + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } } }, "security": [ @@ -3317,33 +5484,20 @@ } ] }, - "head": { - "summary": "Report a session's progress and state.", - "description": "The resumption primitive: a client that lost a connection, an acknowledgement, or a process\nasks here and learns the authoritative offset, the declared length and the session's state.\nThe answer carries **no body** — HTTP forbids one on `HEAD`, which is why the protocol puts\nall three on headers.", - "operationId": "head_upload", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "The session's identifier, as `POST /v1/upload` returned it.", - "required": true, - "schema": { - "type": "string" + "patch": { + "summary": "Edit the caller's own profile.", + "description": "`PATCH`, because the body is a partial: what it does not mention, it does not change. An\nempty body is a valid request and answers `200` with the profile unchanged — a client that\nsent nothing asked for nothing, and refusing it would make \"save\" fail on a form nobody\nedited.", + "operationId": "update_profile", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateProfileRequest" + } } }, - { - "name": "X-Capsule-Protocol", - "in": "header", - "description": "The protocol date the client speaks.\n\nRead as a string rather than a typed value so that a malformed one is *this* surface's\ncoded `400` rather than the framework's uncoded one.", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } - } - ], + "required": true + }, "responses": { "401": { "description": "Unauthorized", @@ -3355,6 +5509,30 @@ "type": "string" }, "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -3367,6 +5545,32 @@ }, "403": { "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -3377,6 +5581,32 @@ }, "400": { "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -3385,55 +5615,142 @@ } } }, - "200": { - "description": "Progress and state on X-Capsule-* headers, no body", + "415": { + "description": "Unsupported Media Type", "headers": { - "X-Capsule-Offset": { - "description": "The next byte the server expects.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "integer", - "minimum": 0.0, - "format": "uint64" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, - "X-Capsule-Content-Length": { - "description": "The declared total, fixed at creation.", + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", "required": true, "schema": { - "type": "integer", - "minimum": 0.0, - "format": "uint64" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, - "X-Capsule-Upload-Status": { - "description": "Where the session is in its state machine.", + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, - "Cache-Control": { - "description": "`no-store`: progress is not cacheable.", + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" } } } }, - "426": { - "description": "Protocol version unsupported", + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/ProtocolRangeProblem" + "$ref": "#/components/schemas/ProfileResponse" } } } }, "404": { - "description": "Upload session not found", + "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -3444,6 +5761,32 @@ }, "500": { "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -3453,7 +5796,33 @@ } }, "413": { - "description": "the request body exceeds the configured limit" + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } } }, "security": [ @@ -3461,65 +5830,19 @@ "bearer": [] } ] - }, - "patch": { - "summary": "Append a chunk, and finalize when it completes the declared size.", - "description": "Every rule the [chunk\ncontract](../../../capsule-docs/src/content/docs/design/import/upload-protocol.md) fixes is\nchecked before a byte is written, and the checksum is verified against the received bytes\n*first*, so a chunk corrupted in transit persists nothing.", - "operationId": "append_chunk", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "The session's identifier, as `POST /v1/upload` returned it.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "X-Capsule-Protocol", - "in": "header", - "description": "The protocol date the client speaks.", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } - }, - { - "name": "X-Capsule-Offset", - "in": "header", - "description": "Where in the blob this chunk starts.", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } - }, - { - "name": "X-Capsule-Checksum", - "in": "header", - "description": "The chunk's SHA-256, bare lowercase hex. Required: the idempotency tuple is undefined\nwithout it.", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } - } - ], - "requestBody": { - "content": { - "application/octet-stream": { - "schema": { - "type": "string", - "format": "binary" - } + } + }, + "/v1/auth/password": { + "post": { + "summary": "Replace the password this account's sessions are opened with.", + "description": "# Every *other* session ends\n\nA password change whose point is that a credential has leaked would be worthless if the\nsessions opened with the leaked credential kept working. So the change closes every session\nof the account — and then re-opens the caller's own, **under its own session id**, so the\nperson doing the rotation is not signed out of the device they are doing it on while\neverybody else is.\n\nRe-opening the same id rather than minting a new one is what lets this answer `204` with no\nbody: the caller's existing token pair keeps working, because the session it names is still\nthere. Returning a fresh pair was considered and rejected — it would make this a second token\nmint with none of `POST /v1/auth/refresh`'s rotation discipline, for no gain.\n\nThe re-opened record's `authenticated_at` is **now**, and that is not bookkeeping: presenting\nthe current password *is* a credential presentation, so a freshness gate (`S-C7`) measuring\nfrom anything earlier would be measuring from the wrong moment.\n\n# Why the order is verify, write, revoke\n\nVerification first, because a wrong current password must change nothing. The write next,\nbecause a revocation that ran before it would sign everybody out and then fail. The\nrevocation last, and its failure is **logged and not returned**: the password is already\nchanged, so answering `500` would tell the caller the rotation did not happen when it did,\nand they would try again with a current password that is no longer current.", + "operationId": "change_password", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangePasswordRequest" + } } }, "required": true @@ -3535,6 +5858,30 @@ "type": "string" }, "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -3547,6 +5894,32 @@ }, "403": { "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -3557,6 +5930,32 @@ }, "400": { "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -3566,7 +5965,33 @@ } }, "415": { - "description": "Unsupported media type", + "description": "Unsupported Media Type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -3575,52 +6000,135 @@ } } }, - "204": { - "description": "the request succeeded and there is no content to send", + "422": { + "description": "Unprocessable Entity", "headers": { - "X-Capsule-Offset": { - "description": "The next byte the server expects.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "integer", - "minimum": 0.0, - "format": "uint64" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "426": { - "description": "Protocol version unsupported", + }, "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProtocolRangeProblem" + "$ref": "#/components/schemas/CodedProblem" } } } }, - "404": { - "description": "Upload session not found", - "content": { - "application/problem+json": { + "204": { + "description": "the request succeeded and there is no content to send", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } }, - "409": { - "description": "Offset mismatch", + "423": { + "description": "Account locked", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/OffsetMismatchProblem" + "$ref": "#/components/schemas/CodedProblem" } } } }, - "413": { - "description": "Chunk too large", + "404": { + "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -3631,6 +6139,32 @@ }, "500": { "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -3638,6 +6172,35 @@ } } } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } } }, "security": [ @@ -3647,25 +6210,11 @@ ] } }, - "/v1/upload/sessions": { - "get": { - "summary": "Every upload the caller can resume.", - "description": "Oldest first, which is the order the store promises and the order a client wants: the oldest\nin-flight session is the one closest to eviction.", - "operationId": "list_upload_sessions", - "parameters": [ - { - "name": "status", - "in": "query", - "description": "Return only sessions in this state.\n\nOne of `pending`, `uploading`, `waiting_for_processing`, `completed`,\n`failed_processing` — the same tokens the `X-Capsule-Upload-Status` header carries, so a\nclient filters on the value it was already given rather than on a second vocabulary.", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } - } - ], + "/v1/auth/totp/enroll": { + "post": { + "summary": "Start enrolling an authenticator.", + "description": "Answers the `otpauth://` URI the app scans. Nothing is gated yet: until a code confirms the\nsecret, sign-in is unchanged — which is what stops a mis-scanned QR code from locking\nsomebody out of their own account.", + "operationId": "totp_enroll", "responses": { "401": { "description": "Unauthorized", @@ -3677,18 +6226,32 @@ "type": "string" }, "example": "Bearer" - } - }, - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "403": { - "description": "Forbidden", + }, "content": { "application/problem+json": { "schema": { @@ -3697,8 +6260,34 @@ } } }, - "400": { - "description": "Bad Request", + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -3709,61 +6298,66 @@ }, "200": { "description": "OK", - "content": { - "application/json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/SessionsResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "500": { - "description": "Internal server error", + }, "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/CodedProblem" + "$ref": "#/components/schemas/EnrollmentResponse" } } } }, - "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] - } - ] - } - }, - "/v1/upload/{id}/receipt": { - "get": { - "summary": "Fetch the custody receipt for a finalized upload.", - "operationId": "get_upload_receipt", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "The session's identifier, as `POST /v1/upload` returned it.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "401": { - "description": "Unauthorized", + "409": { + "description": "Already active", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -3774,39 +6368,34 @@ } } }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "200": { - "description": "OK", - "content": { - "application/cbor": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { "type": "string", - "format": "binary" + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "404": { - "description": "Not found", + }, "content": { "application/problem+json": { "schema": { @@ -3815,28 +6404,34 @@ } } }, - "409": { - "description": "Receipt not available", - "content": { - "application/problem+json": { + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "500": { - "description": "Internal server error", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } - }, - "413": { - "description": "the request body exceeds the configured limit" } }, "security": [ @@ -3846,27 +6441,16 @@ ] } }, - "/v1/albums/{album_id}/ops": { + "/v1/auth/totp/verify-enrollment": { "post": { - "summary": "Apply one signed lifecycle manifest to an album's asset.", - "description": "The whole battery runs before anything is written, and a rejection writes nothing —\nincluding the blobs the bundle carries, which are stored only after the manifest has passed\nevery check the server can make without a key.", - "operationId": "album_lifecycle_op", - "parameters": [ - { - "name": "album_id", - "in": "path", - "description": "The album's identifier.", - "required": true, - "schema": { - "type": "string" - } - } - ], + "summary": "Confirm an enrollment with a live code.", + "description": "The confirming code is **spent**: its step goes straight into the replay ledger, so it cannot\nalso complete a sign-in a moment later. That is the one place the ledger's first entry comes\nfrom, and skipping it would leave the newest code in the account's history unused.", + "operationId": "totp_verify_enrollment", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OpRequest" + "$ref": "#/components/schemas/CodeRequest" } } }, @@ -3883,6 +6467,30 @@ "type": "string" }, "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -3895,6 +6503,32 @@ }, "403": { "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -3905,26 +6539,32 @@ }, "400": { "description": "Bad Request", - "content": { - "application/problem+json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "415": { - "description": "Unsupported Media Type", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "422": { - "description": "Unprocessable Entity", + }, "content": { "application/problem+json": { "schema": { @@ -3933,38 +6573,171 @@ } } }, - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/OpResponse" + "415": { + "description": "Unsupported Media Type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" } } } }, - "426": { - "description": "Upgrade required", + "422": { + "description": "Unprocessable Entity", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProtocolRangeProblem" + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "204": { + "description": "the request succeeded and there is no content to send", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } }, "409": { - "description": "Stale revival", + "description": "Nothing pending", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/StaleRevivalProblem" + "$ref": "#/components/schemas/CodedProblem" } } } }, "500": { "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -3974,7 +6747,33 @@ } }, "413": { - "description": "the request body exceeds the configured limit" + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } } }, "security": [ @@ -3984,40 +6783,21 @@ ] } }, - "/v1/sync": { - "get": { - "summary": "Returns the changes in the caller's library after `cursor`.", - "description": "Read-only and idempotent: two calls with the same cursor return the same page, because the\ncursor names a position rather than consuming one. That is what makes a lost response\nharmless and a retry free.", - "operationId": "sync_feed", - "parameters": [ - { - "name": "cursor", - "in": "query", - "description": "The opaque cursor a previous page returned. Absent means \"from the beginning\".", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] + "/v1/auth/totp/disable": { + "post": { + "summary": "Remove the second factor, on presentation of a live code.", + "description": "**A session is not enough.** The whole point of the factor is that a stolen access token is\ninsufficient, and a disable that took only a token would let the token turn off the control\nthat makes it insufficient.", + "operationId": "totp_disable", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CodeRequest" + } } }, - { - "name": "page_size", - "in": "query", - "description": "How many entries to return. Clamped into the range this server serves.\n\n`u32` and not `usize`: Kynos refuses to describe a platform-width integer, and it is\nright to — a schema whose bounds depend on the server's pointer size is a schema no\nclient can rely on.", - "required": false, - "schema": { - "type": [ - "integer", - "null" - ], - "maximum": 4294967295.0, - "minimum": 0.0, - "format": "uint32" - } - } - ], + "required": true + }, "responses": { "401": { "description": "Unauthorized", @@ -4029,6 +6809,30 @@ "type": "string" }, "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -4041,6 +6845,32 @@ }, "403": { "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -4051,6 +6881,32 @@ }, "400": { "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -4059,18 +6915,34 @@ } } }, - "200": { - "description": "OK", - "content": { - "application/json": { + "415": { + "description": "Unsupported Media Type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/SyncPageResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "500": { - "description": "Internal server error", + }, "content": { "application/problem+json": { "schema": { @@ -4079,100 +6951,34 @@ } } }, - "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] - } - ] - } - }, - "/v1/blob/{hash}": { - "get": { - "summary": "Fetch a ciphertext blob by its content address, ranged.", - "description": "Opaque octets: the server holds no key and this route never learns what it is serving. Any\nauthenticated account may fetch any live address — see [`crate::serve`] for why that is a\ncapability model rather than a hole, and for the `403` the contract describes and nothing\nimplements.\n\nThe one answer that *is* account-scoped is the transient `409`: it reports the caller's own\nin-flight upload and nobody else's (`S-C40`).", - "operationId": "get_blob", - "parameters": [ - { - "name": "hash", - "in": "path", - "description": "The blob's ciphertext content address, lowercase hex.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "Range", - "in": "header", - "description": "The part of the representation to transfer, per RFC 9110 section 14.2. A field this operation cannot apply is ignored and the whole representation is sent.", - "schema": { - "type": "string", - "pattern": "^bytes=(?:\\d+-\\d*|-\\d+)(?:\\s*,\\s*(?:\\d+-\\d*|-\\d+)){0,7}$" - }, - "example": "bytes=0-1023" - }, - { - "name": "If-Range", - "in": "header", - "description": "The entity tag the client's partial copy came from, per RFC 9110 section 13.1.5. The `Range` is honoured only if it matches this representation under the strong comparison; otherwise the whole representation is sent.", - "schema": { - "type": "string" - } - }, - { - "name": "If-None-Match", - "in": "header", - "description": "The entity tag the client already holds, per RFC 9110 section 13.1.2", - "schema": { - "type": "string" - } - }, - { - "name": "If-Modified-Since", - "in": "header", - "description": "The date the client's copy carries, per RFC 9110 section 13.1.3", - "schema": { - "type": "string" - } - } - ], - "responses": { - "401": { - "description": "Unauthorized", + "422": { + "description": "Unprocessable Entity", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" - } - }, - "content": { - "application/problem+json": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "400": { - "description": "Bad Request", + }, "content": { "application/problem+json": { "schema": { @@ -4181,101 +6987,99 @@ } } }, - "200": { - "description": "the whole representation", + "204": { + "description": "the request succeeded and there is no content to send", "headers": { - "Accept-Ranges": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, - "ETag": { + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, - "Last-Modified": { - "schema": { - "type": "string" - } - } - }, - "content": { - "application/octet-stream": { + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { "type": "string", - "format": "binary" + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } }, - "206": { - "description": "the part the request asked for", + "409": { + "description": "Not enrolled", "headers": { - "Accept-Ranges": { - "schema": { - "type": "string" - } - }, - "ETag": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, - "Last-Modified": { + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, - "Content-Range": { - "description": "The part of the representation enclosed, and its complete length, per RFC 9110 section 14.4.", + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", "required": true, - "content": { - "text/plain": { - "schema": { - "type": "string", - "pattern": "^bytes \\d+-\\d+/\\d+$" - } - } + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } }, "content": { - "application/octet-stream": { + "application/problem+json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/CodedProblem" } } } }, - "304": { - "description": "the client's copy is current", + "500": { + "description": "Internal server error", "headers": { - "ETag": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, - "Last-Modified": { + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "409": { - "description": "Upload in progress", + }, "content": { "application/problem+json": { "schema": { @@ -4284,28 +7088,34 @@ } } }, - "410": { - "description": "Gone", - "content": { - "application/problem+json": { + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "500": { - "description": "Internal server error", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } - }, - "413": { - "description": "the request body exceeds the configured limit" } }, "security": [ @@ -4315,54 +7125,50 @@ ] } }, - "/v1/storage/verify": { + "/v1/auth/login/verify-totp": { "post": { - "summary": "Confirm that the server holds the copies a client is about to stop holding.", - "description": "A pure read: it writes no blob, no index row and no verdict. Soundness against a racing\ncollection comes from the standing GC grace window rather than from a per-request lease,\nwhich is why nothing here takes one.", - "operationId": "verify_storage", + "summary": "Complete a sign-in with a code.", + "description": "This is where the session is opened — not `POST /v1/auth/login`, which for an account with a\nsecond factor opens nothing. The advisory `cohort_hash` and `device_id` ride *this* request\nfor the same reason: the session they describe is created here.", + "operationId": "totp_verify_login", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StorageVerifyRequest" + "$ref": "#/components/schemas/VerifyLoginRequest" } } }, "required": true }, "responses": { - "401": { - "description": "Unauthorized", + "400": { + "description": "Bad Request", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" - } - }, - "content": { - "application/problem+json": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "400": { - "description": "Bad Request", + }, "content": { "application/problem+json": { "schema": { @@ -4373,6 +7179,32 @@ }, "415": { "description": "Unsupported Media Type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -4383,26 +7215,32 @@ }, "422": { "description": "Unprocessable Entity", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/CodedProblem" + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "200": { - "description": "OK", - "content": { - "application/json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/StorageVerifyResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "500": { - "description": "Internal server error", + }, "content": { "application/problem+json": { "schema": { @@ -4411,65 +7249,70 @@ } } }, - "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] - } - ] - } - }, - "/v1/assets/{asset_id}/receipts": { - "get": { - "summary": "Fetch every custody receipt covering one asset.", - "operationId": "get_asset_receipts", - "parameters": [ - { - "name": "asset_id", - "in": "path", - "description": "The asset id.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "401": { - "description": "Unauthorized", + "200": { + "description": "OK", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/CodedProblem" + "$ref": "#/components/schemas/TokenResponse" } } } }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { + "401": { + "description": "Challenge expired", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "400": { - "description": "Bad Request", + }, "content": { "application/problem+json": { "schema": { @@ -4478,18 +7321,34 @@ } } }, - "200": { - "description": "OK", - "content": { - "application/json": { + "429": { + "description": "Too many attempts", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/AssetReceiptsResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "404": { - "description": "Not found", + }, "content": { "application/problem+json": { "schema": { @@ -4500,6 +7359,32 @@ }, "500": { "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -4509,30 +7394,42 @@ } }, "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } } - ] + } } }, - "/v1/shares": { + "/v1/auth/devices/enroll": { "post": { - "summary": "Register a share link the caller's client has issued.", - "operationId": "issue_share", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/IssueShareRequest" - } - } - }, - "required": true - }, + "summary": "Issue a one-time enrollment code for the caller's account.", + "description": "Gated on a recent credential presentation, not merely on a valid session — a stolen token\nmust not be able to enroll a rogue device. See [`crate::enrollment`] for exactly how much\nthat gate can mean.", + "operationId": "issue_enrollment_code", "responses": { "401": { "description": "Unauthorized", @@ -4544,6 +7441,30 @@ "type": "string" }, "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -4556,26 +7477,32 @@ }, "403": { "description": "Forbidden", - "content": { - "application/problem+json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "415": { - "description": "Unsupported Media Type", + }, "content": { "application/problem+json": { "schema": { @@ -4584,30 +7511,72 @@ } } }, - "422": { - "description": "Unprocessable Entity", - "content": { - "application/problem+json": { + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "201": { - "description": "The share link is registered and servable", + }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/IssueShareResponse" + "$ref": "#/components/schemas/EnrollmentCodeResponse" } } } }, "500": { "description": "Internal server error", - "content": { - "application/problem+json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { "schema": { "$ref": "#/components/schemas/CodedProblem" } @@ -4615,7 +7584,33 @@ } }, "413": { - "description": "the request body exceeds the configured limit" + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } } }, "security": [ @@ -4625,33 +7620,48 @@ ] } }, - "/v1/shares/{opaque_id}": { - "delete": { - "summary": "Revoke one of the caller's links.", - "description": "Idempotent from the caller's side and **indistinguishable**: a link that was never theirs, a\nlink that does not exist, and a link they already revoked are all `204`. Revocation is the\none operation where saying \"there was nothing to revoke\" would be a lookup.", - "operationId": "revoke_share", - "parameters": [ - { - "name": "opaque_id", - "in": "path", - "description": "The opaque id.", - "required": true, - "schema": { - "type": "string" + "/v1/auth/devices/enroll/redeem": { + "post": { + "summary": "Redeem a code for a relay channel.", + "description": "**Unauthenticated, necessarily.** Device B has no account, no session and no key material —\nit is a phone that has just scanned a QR code. The code is the only thing it holds, so the\ncode is the credential.", + "operationId": "redeem_enrollment_code", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RedeemRequest" + } } - } - ], + }, + "required": true + }, "responses": { - "401": { - "description": "Unauthorized", + "400": { + "description": "Bad Request", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -4662,18 +7672,34 @@ } } }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { + "415": { + "description": "Unsupported Media Type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "400": { - "description": "Bad Request", + }, "content": { "application/problem+json": { "schema": { @@ -4682,48 +7708,34 @@ } } }, - "204": { - "description": "the request succeeded and there is no content to send" - }, - "500": { - "description": "Internal server error", - "content": { - "application/problem+json": { + "422": { + "description": "Unprocessable Entity", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] - } - ] - } - }, - "/s/{opaque_id}": { - "get": { - "summary": "What a viewer needs to begin, for a live link.", - "operationId": "share_metadata", - "parameters": [ - { - "name": "opaque_id", - "in": "path", - "description": "The opaque id.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "400": { - "description": "Bad Request", + }, "content": { "application/problem+json": { "schema": { @@ -4734,16 +7746,68 @@ }, "200": { "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SharedMetadataResponse" + "$ref": "#/components/schemas/ChannelResponse" } } } }, "404": { - "description": "Not found", + "description": "Code refused", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -4753,7 +7817,33 @@ } }, "429": { - "description": "Too many requests", + "description": "Too many attempts", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -4764,6 +7854,32 @@ }, "500": { "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -4773,21 +7889,56 @@ } }, "413": { - "description": "the request body exceeds the configured limit" + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } } } } }, - "/s/{opaque_id}/wrapped-secret": { + "/v1/auth/devices/enroll/channel/{channel_id}": { "get": { - "summary": "The passphrase-wrapped scope material, when there is one.", - "description": "A link with no passphrase answers `404` rather than `204` or an empty body: whether a link is\npassphrase-protected is already disclosed by the metadata record, and a *second* way to ask\nthe same question with a different shape is a second thing to keep consistent.", - "operationId": "share_wrapped_secret", + "summary": "Take everything pending in one of a channel's mailboxes.", + "description": "Destructive: a relayed payload is delivered once. Draining one direction leaves the other\nuntouched, so the two devices do not consume each other's mail.", + "operationId": "drain_enrollment_channel", "parameters": [ { - "name": "opaque_id", + "name": "channel_id", "in": "path", - "description": "The opaque id.", + "description": "The handle a redeemed code returned.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "direction", + "in": "query", + "description": "`to_initiator` or `to_enrollee`.", "required": true, "schema": { "type": "string" @@ -4797,6 +7948,32 @@ "responses": { "400": { "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -4807,27 +7984,68 @@ }, "200": { "description": "OK", - "content": { - "application/octet-stream": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { "type": "string", - "format": "binary" + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "404": { - "description": "Not found", + }, "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/CodedProblem" + "$ref": "#/components/schemas/DrainResponse" } } } }, - "429": { - "description": "Too many requests", + "404": { + "description": "Channel not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -4838,6 +8056,32 @@ }, "500": { "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -4847,73 +8091,90 @@ } }, "413": { - "description": "the request body exceeds the configured limit" + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } } } - } - }, - "/s/{opaque_id}/blob/{hash}": { - "get": { - "summary": "Ciphertext for one of the link's blobs, ranged.", - "description": "The membership check is the security property: a link serves the addresses its record\nenumerates and nothing else, so it cannot be walked sideways into the album's unstripped\nmetadata. A blob the link does not name is the same `404` as a link that does not exist.", - "operationId": "share_blob", + }, + "post": { + "summary": "Append a payload to one of a channel's two mailboxes.", + "description": "Unauthenticated and gated by the handle alone. The relay is a dumb pipe by design — see\n[`crate::enrollment`] — and the safety-code check is what defends the ceremony.", + "operationId": "relay_enrollment_payload", "parameters": [ { - "name": "opaque_id", - "in": "path", - "description": "The opaque id.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "hash", + "name": "channel_id", "in": "path", - "description": "The blob's content address.", + "description": "The handle a redeemed code returned.", "required": true, "schema": { "type": "string" } - }, - { - "name": "Range", - "in": "header", - "description": "The part of the representation to transfer, per RFC 9110 section 14.2. A field this operation cannot apply is ignored and the whole representation is sent.", - "schema": { - "type": "string", - "pattern": "^bytes=(?:\\d+-\\d*|-\\d+)(?:\\s*,\\s*(?:\\d+-\\d*|-\\d+)){0,7}$" - }, - "example": "bytes=0-1023" - }, - { - "name": "If-Range", - "in": "header", - "description": "The entity tag the client's partial copy came from, per RFC 9110 section 13.1.5. The `Range` is honoured only if it matches this representation under the strong comparison; otherwise the whole representation is sent.", - "schema": { - "type": "string" - } - }, - { - "name": "If-None-Match", - "in": "header", - "description": "The entity tag the client already holds, per RFC 9110 section 13.1.2", - "schema": { - "type": "string" - } - }, - { - "name": "If-Modified-Since", - "in": "header", - "description": "The date the client's copy carries, per RFC 9110 section 13.1.3", - "schema": { - "type": "string" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RelayRequest" + } + } + }, + "required": true + }, "responses": { "400": { "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -4922,150 +8183,133 @@ } } }, - "200": { - "description": "the whole representation", + "415": { + "description": "Unsupported Media Type", "headers": { - "Accept-Ranges": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, - "ETag": { + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, - "Last-Modified": { + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } }, "content": { - "application/octet-stream": { + "application/problem+json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/CodedProblem" } } } }, - "206": { - "description": "the part the request asked for", + "422": { + "description": "Unprocessable Entity", "headers": { - "Accept-Ranges": { - "schema": { - "type": "string" - } - }, - "ETag": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, - "Last-Modified": { + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, - "Content-Range": { - "description": "The part of the representation enclosed, and its complete length, per RFC 9110 section 14.4.", + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", "required": true, - "content": { - "text/plain": { - "schema": { - "type": "string", - "pattern": "^bytes \\d+-\\d+/\\d+$" - } - } + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } }, "content": { - "application/octet-stream": { + "application/problem+json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/CodedProblem" } } } }, - "304": { - "description": "the client's copy is current", + "204": { + "description": "the request succeeded and there is no content to send", "headers": { - "ETag": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, - "Last-Modified": { + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } }, - "429": { - "description": "Too many requests", - "content": { - "application/problem+json": { + "404": { + "description": "Channel not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "500": { - "description": "Internal server error", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "413": { - "description": "the request body exceeds the configured limit" - } - } - } - }, - "/v1/drops/links": { - "post": { - "summary": "Provision an upload link.", - "operationId": "provision_link", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProvisionLinkRequest" - } - } - }, - "required": true - }, - "responses": { - "401": { - "description": "Unauthorized", - "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -5076,38 +8320,34 @@ } } }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "415": { - "description": "Unsupported Media Type", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "422": { - "description": "Unprocessable Entity", + }, "content": { "application/problem+json": { "schema": { @@ -5116,47 +8356,46 @@ } } }, - "201": { - "description": "The upload link is provisioned and accepting drops", - "content": { - "application/json": { + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/ProvisionLinkResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "500": { - "description": "Internal server error", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } - }, - "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] } - ] - } - }, - "/v1/drops/links/{opaque_id}": { + } + }, "delete": { - "summary": "Revoke one of the caller's links.", - "description": "Indistinguishable and idempotent, for the same reason a share revocation is: saying \"there\nwas nothing to revoke\" would be a lookup.", - "operationId": "revoke_link", + "summary": "Close a channel and drop both mailboxes with it.", + "description": "**The initiator's, and authenticated.** A close is the one relay operation that is not\nidempotent from the other device's point of view — it ends the ceremony — so leaving it on\nthe handle alone would make an abandoned QR code a denial of service. The account is checked\nagainst the channel's recorded initiator, and a channel belonging to another account answers\nexactly as an unknown one does.", + "operationId": "close_enrollment_channel", "parameters": [ { - "name": "opaque_id", + "name": "channel_id", "in": "path", - "description": "The opaque id.", + "description": "The handle a redeemed code returned.", "required": true, "schema": { "type": "string" @@ -5174,6 +8413,30 @@ "type": "string" }, "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -5186,6 +8449,32 @@ }, "403": { "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -5196,6 +8485,32 @@ }, "400": { "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -5205,10 +8520,62 @@ } }, "204": { - "description": "the request succeeded and there is no content to send" + "description": "the request succeeded and there is no content to send", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } }, - "500": { - "description": "Internal server error", + "404": { + "description": "Channel not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -5217,56 +8584,131 @@ } } }, - "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + }, + "security": [ + { "bearer": [] } ] } }, - "/d/{opaque_id}": { + "/v1/albums": { "post": { - "summary": "Open a drop session through a link.", - "description": "Invariants 26–30 in order: the link admits the file and reserves its caps in one store\noperation, then the owner's quota is charged, then the declaration is checked.", - "operationId": "create_drop", - "parameters": [ - { - "name": "opaque_id", - "in": "path", - "description": "The opaque id.", - "required": true, - "schema": { - "type": "string" - } - } - ], + "summary": "Bind an album id to the authenticated caller.", + "description": "Idempotent: the same id from a second device, or after a recovery, is a success that writes\nnothing.", + "operationId": "provision_album", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateDropRequest" + "$ref": "#/components/schemas/ProvisionAlbumRequest" } } }, "required": true }, "responses": { - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "415": { - "description": "Unsupported Media Type", + }, "content": { "application/problem+json": { "schema": { @@ -5275,8 +8717,34 @@ } } }, - "422": { - "description": "Unprocessable Entity", + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -5285,18 +8753,34 @@ } } }, - "201": { - "description": "A drop session is open and accepting chunks", - "content": { - "application/json": { + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CreateDropResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "404": { - "description": "Not found", + }, "content": { "application/problem+json": { "schema": { @@ -5305,8 +8789,34 @@ } } }, - "403": { - "description": "Passphrase required", + "415": { + "description": "Unsupported Media Type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -5315,8 +8825,34 @@ } } }, - "409": { - "description": "Link capacity exhausted", + "422": { + "description": "Unprocessable Entity", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -5325,156 +8861,106 @@ } } }, - "413": { - "description": "File too large", + "201": { + "description": "The album was created and bound to the caller", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/FileTooLargeProblem" + "$ref": "#/components/schemas/ProvisionAlbumResponse" } } } }, - "429": { - "description": "Too many requests", + "200": { + "description": "The album id was already provisioned to this account; nothing was written", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/CodedProblem" + "$ref": "#/components/schemas/ProvisionAlbumResponse" } } } }, "500": { "description": "Internal server error", - "content": { - "application/problem+json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - } - } - } - }, - "/d/{opaque_id}/{upload_id}": { - "patch": { - "summary": "Append one chunk to a drop session.", - "description": "The link is the credential: possession of the opaque id, plus a session that belongs to it.\nEverything after that is [`crate::upload::chunk::append`] — the album path's own function.", - "operationId": "append_drop_chunk", - "parameters": [ - { - "name": "opaque_id", - "in": "path", - "description": "The opaque id of the link the session belongs to.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "upload_id", - "in": "path", - "description": "The session id.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "X-Capsule-Offset", - "in": "header", - "description": "Where in the blob this chunk starts.", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } - }, - { - "name": "X-Capsule-Checksum", - "in": "header", - "description": "The chunk's SHA-256, bare lowercase hex. Required: the idempotency tuple is undefined\nwithout it.", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } - } - ], - "requestBody": { - "content": { - "application/octet-stream": { - "schema": { - "type": "string", - "format": "binary" - } - } - }, - "required": true - }, - "responses": { - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/CodedProblem" - } - } - } - }, - "415": { - "description": "Unsupported media type", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/CodedProblem" - } - } - } - }, - "204": { - "description": "the request succeeded and there is no content to send", - "headers": { - "X-Capsule-Offset": { - "description": "Where the session is now.", + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", "required": true, "schema": { - "type": "integer", - "minimum": 0.0, - "format": "uint64" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "409": { - "description": "Chunk refused", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "500": { - "description": "Internal server error", + }, "content": { "application/problem+json": { "schema": { @@ -5484,68 +8970,33 @@ } }, "413": { - "description": "the request body exceeds the configured limit" - } - } - } - }, - "/v1/drops": { - "get": { - "summary": "The caller's pending drops.", - "operationId": "list_inbox", - "responses": { - "401": { - "description": "Unauthorized", + "description": "the request body exceeds the configured limit", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" - } - }, - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/CodedProblem" - } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "200": { - "description": "OK", - "content": { - "application/json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/InboxResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "500": { - "description": "Internal server error", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } - }, - "413": { - "description": "the request body exceeds the configured limit" } }, "security": [ @@ -5555,32 +9006,22 @@ ] } }, - "/v1/drops/{drop_id}/adopt": { - "post": { - "summary": "Adopt a pending drop into an album.", - "description": "Invariant 32. The manifest re-runs the create battery — a drop that skipped it would be the\none write on this server that entered an album unvalidated — and its `ciphertext_hash` must\nname a blob in **the caller's own inbox**, which is what stops an adoption from minting an\nasset over somebody else's bytes.\n\nThe row is **claimed, written, then settled**. Across two ports there is no transaction, and\nthe two failure directions are not equal: writing first and deleting after can duplicate a\nphoto, taking first and failing to write loses one. A claim leaves a crash visible in the\nowner's own inbox instead, marked `adopting`.", - "operationId": "adopt_drop", + "/v1/albums/{album_id}/upgrade": { + "get": { + "summary": "Read the ceremony's phase and the drain count.", + "description": "The one call a proposer polls between steps 2 and 4. `in_flight` reaching zero is the signal\nthat the tombstone may be committed.", + "operationId": "album_upgrade_phase", "parameters": [ { - "name": "drop_id", + "name": "album_id", "in": "path", - "description": "The drop's identifier.", + "description": "The album's id.", "required": true, "schema": { "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdoptRequest" - } - } - }, - "required": true - }, "responses": { "401": { "description": "Unauthorized", @@ -5592,6 +9033,30 @@ "type": "string" }, "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -5604,6 +9069,32 @@ }, "403": { "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -5614,26 +9105,32 @@ }, "400": { "description": "Bad Request", - "content": { - "application/problem+json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "415": { - "description": "Unsupported Media Type", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "422": { - "description": "Unprocessable Entity", + }, "content": { "application/problem+json": { "schema": { @@ -5644,16 +9141,68 @@ }, "200": { "description": "OK", - "content": { - "application/json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/AdoptResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpgradePhaseResponse" } } } }, "404": { "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -5664,6 +9213,32 @@ }, "500": { "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -5673,7 +9248,33 @@ } }, "413": { - "description": "the request body exceeds the configured limit" + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } } }, "security": [ @@ -5681,24 +9282,33 @@ "bearer": [] } ] - } - }, - "/v1/drops/{drop_id}": { - "delete": { - "summary": "Discard a pending drop.", - "description": "The bytes become unreferenced and the collector reclaims them; the link's cap is **not**\nrefunded, because the drop did happen — a guest deposited a file and the owner chose not to\nkeep it, which is not the same as a link slot never having been used.", - "operationId": "discard_drop", + }, + "post": { + "summary": "Put an album into upgrade quiescence.", + "description": "Idempotent under its own `intent_id`: versioning.md is explicit that the same `UpgradeIntent`\nnever produces two forks, and a proposer that lost an acknowledgement re-POSTs the same bytes.", + "operationId": "begin_album_upgrade", "parameters": [ { - "name": "drop_id", + "name": "album_id", "in": "path", - "description": "The drop's identifier.", + "description": "The album's id.", "required": true, "schema": { "type": "string" } } ], + "requestBody": { + "content": { + "application/cbor": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "required": true + }, "responses": { "401": { "description": "Unauthorized", @@ -5710,6 +9320,30 @@ "type": "string" }, "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -5722,6 +9356,32 @@ }, "403": { "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -5732,6 +9392,32 @@ }, "400": { "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -5740,11 +9426,106 @@ } } }, - "204": { - "description": "the request succeeded and there is no content to send" + "415": { + "description": "Unsupported media type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpgradePhaseResponse" + } + } + } }, "404": { "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -5753,8 +9534,34 @@ } } }, - "500": { - "description": "Internal server error", + "409": { + "description": "Upgrade in flight", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -5763,20 +9570,7155 @@ } } }, - "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] - } - ] - } - } - }, - "components": { - "schemas": { + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + }, + "delete": { + "summary": "Abort a ceremony, returning the album to normal operation.", + "description": "Named by `intent_id` in the path's own query so that aborting is a statement about *which*\nupgrade — a caller that does not hold the live id gets a `409` rather than the power to\ncancel somebody else's ceremony.", + "operationId": "abort_album_upgrade", + "parameters": [ + { + "name": "album_id", + "in": "path", + "description": "The album's id.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "intent_id", + "in": "query", + "description": "The ceremony to abort.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpgradePhaseResponse" + } + } + } + }, + "404": { + "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "409": { + "description": "Upgrade in flight", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/v1/quota": { + "get": { + "summary": "Report the authenticated uploader's storage-quota snapshot.", + "description": "Scoped to the caller, and to nobody else: quota is accounted to the *uploader*, and one\naccount's storage use is not another's business.", + "operationId": "get_quota", + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QuotaResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/v1/moderation/record": { + "get": { + "summary": "Serve the caller's own moderation record.", + "operationId": "moderation_record", + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModerationRecordResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/.well-known/capsule/attestation-keys": { + "get": { + "summary": "Serve this server's storage-attestation keys and their append-only history.", + "description": "Cacheable and unauthenticated. It changes only when a key rotates, and a client that pinned\na stale copy still resolves every receipt signed before it fetched — which is the property\nthe append-only ordering buys.", + "operationId": "attestation_keys", + "responses": { + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AttestationKeysResponse" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + } + } + }, + "/.well-known/capsule/server-info": { + "get": { + "summary": "Serve this server's public, server-scoped facts.", + "description": "Unauthenticated by contract: a client deciding whether it can talk to this server at all has\nno credential yet, and a peer resolving the key that verifies a capability token must not\nneed one from the server whose claims it is checking.", + "operationId": "server_info", + "responses": { + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServerInfoResponse" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + } + } + }, + "/.well-known/capsule/deprecation": { + "get": { + "summary": "Serve the announced deprecation cutoffs.", + "description": "The same announcements `server-info` carries, at their own path because that is the URL the\n`Warning:` header on a below-cutoff response points a human at, and because a client polling\nfor a cutoff should not have to refetch the whole discovery record to find one.", + "operationId": "deprecation_announcements", + "responses": { + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeprecationsResponse" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + } + } + }, + "/.well-known/capsule/revoked-jti": { + "get": { + "summary": "Serve the federation capability revocation list.", + "description": "Bounded by at most 24 hours of revocations, because an entry past the token's own `exp` is\npruned and a capability token cannot be minted to live longer than that. Public: a peer\nchecking whether a token it holds is still good is, by construction, not yet authenticated\nhere, and the record names no user — only opaque `jti`s.\n\n# Errors\n\nReturns `503` if the revocation list cannot be read. Deliberately *not* an empty list: an\nempty list is the strongest possible claim this endpoint can make — nothing is revoked — and\nserving it on a storage failure would turn an outage into a silent un-revocation of every\ntoken, which is exactly what the peer-side fail-closed rule exists to prevent.", + "operationId": "revoked_jti", + "responses": { + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RevokedJtiResponse" + } + } + } + }, + "503": { + "description": "Revocation list unavailable", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + } + } + }, + "/v1/upload/sessions": { + "get": { + "summary": "Every upload the caller can resume.", + "description": "Oldest first, which is the order the store promises and the order a client wants: the oldest\nin-flight session is the one closest to eviction.", + "operationId": "list_upload_sessions", + "parameters": [ + { + "name": "status", + "in": "query", + "description": "Return only sessions in this state.\n\nOne of `pending`, `uploading`, `waiting_for_processing`, `completed`,\n`failed_processing` — the same tokens the `X-Capsule-Upload-Status` header carries, so a\nclient filters on the value it was already given rather than on a second vocabulary.", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + } + ], + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionsResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/v1/upload/{id}/receipt": { + "get": { + "summary": "Fetch the custody receipt for a finalized upload.", + "operationId": "get_upload_receipt", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The session's identifier, as `POST /v1/upload` returned it.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/cbor": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "409": { + "description": "Receipt not available", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/v1/albums/{album_id}/ops": { + "post": { + "summary": "Apply one signed lifecycle manifest to an album's asset.", + "description": "The whole battery runs before anything is written, and a rejection writes nothing —\nincluding the blobs the bundle carries, which are stored only after the manifest has passed\nevery check the server can make without a key.", + "operationId": "album_lifecycle_op", + "parameters": [ + { + "name": "album_id", + "in": "path", + "description": "The album's identifier.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpRequest" + } + } + }, + "required": true + }, + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "415": { + "description": "Unsupported Media Type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpResponse" + } + } + } + }, + "426": { + "description": "Upgrade required", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProtocolRangeProblem" + } + } + } + }, + "409": { + "description": "Stale revival", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/StaleRevivalProblem" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/v1/sync": { + "get": { + "summary": "Returns the changes in the caller's library after `cursor`.", + "description": "Read-only and idempotent: two calls with the same cursor return the same page, because the\ncursor names a position rather than consuming one. That is what makes a lost response\nharmless and a retry free.", + "operationId": "sync_feed", + "parameters": [ + { + "name": "cursor", + "in": "query", + "description": "The opaque cursor a previous page returned. Absent means \"from the beginning\".", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "page_size", + "in": "query", + "description": "How many entries to return. Clamped into the range this server serves.\n\n`u32` and not `usize`: Kynos refuses to describe a platform-width integer, and it is\nright to — a schema whose bounds depend on the server's pointer size is a schema no\nclient can rely on.", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "maximum": 4294967295.0, + "minimum": 0.0, + "format": "uint32" + } + } + ], + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SyncPageResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/v1/blob/{hash}": { + "get": { + "summary": "Fetch a ciphertext blob by its content address, ranged.", + "description": "Opaque octets: the server holds no key and this route never learns what it is serving. Any\nauthenticated account may fetch any live address — see [`crate::serve`] for why that is a\ncapability model rather than a hole, and for the `403` the contract describes and nothing\nimplements.\n\nThe one answer that *is* account-scoped is the transient `409`: it reports the caller's own\nin-flight upload and nobody else's (`S-C40`).", + "operationId": "get_blob", + "parameters": [ + { + "name": "hash", + "in": "path", + "description": "The blob's ciphertext content address, lowercase hex.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "Range", + "in": "header", + "description": "The part of the representation to transfer, per RFC 9110 section 14.2. A field this operation cannot apply is ignored and the whole representation is sent.", + "schema": { + "type": "string", + "pattern": "^bytes=(?:\\d+-\\d*|-\\d+)(?:\\s*,\\s*(?:\\d+-\\d*|-\\d+)){0,7}$" + }, + "example": "bytes=0-1023" + }, + { + "name": "If-Range", + "in": "header", + "description": "The entity tag the client's partial copy came from, per RFC 9110 section 13.1.5. The `Range` is honoured only if it matches this representation under the strong comparison; otherwise the whole representation is sent.", + "schema": { + "type": "string" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "The entity tag the client already holds, per RFC 9110 section 13.1.2", + "schema": { + "type": "string" + } + }, + { + "name": "If-Modified-Since", + "in": "header", + "description": "The date the client's copy carries, per RFC 9110 section 13.1.3", + "schema": { + "type": "string" + } + } + ], + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "200": { + "description": "the whole representation", + "headers": { + "Accept-Ranges": { + "schema": { + "type": "string" + } + }, + "ETag": { + "schema": { + "type": "string" + } + }, + "Last-Modified": { + "schema": { + "type": "string" + } + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "206": { + "description": "the part the request asked for", + "headers": { + "Accept-Ranges": { + "schema": { + "type": "string" + } + }, + "ETag": { + "schema": { + "type": "string" + } + }, + "Last-Modified": { + "schema": { + "type": "string" + } + }, + "Content-Range": { + "description": "The part of the representation enclosed, and its complete length, per RFC 9110 section 14.4.", + "required": true, + "content": { + "text/plain": { + "schema": { + "type": "string", + "pattern": "^bytes \\d+-\\d+/\\d+$" + } + } + } + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "304": { + "description": "the client's copy is current", + "headers": { + "ETag": { + "schema": { + "type": "string" + } + }, + "Last-Modified": { + "schema": { + "type": "string" + } + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "404": { + "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "409": { + "description": "Upload in progress", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "410": { + "description": "Gone", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/v1/storage/verify": { + "post": { + "summary": "Confirm that the server holds the copies a client is about to stop holding.", + "description": "A pure read: it writes no blob, no index row and no verdict. Soundness against a racing\ncollection comes from the standing GC grace window rather than from a per-request lease,\nwhich is why nothing here takes one.", + "operationId": "verify_storage", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageVerifyRequest" + } + } + }, + "required": true + }, + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "415": { + "description": "Unsupported Media Type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageVerifyResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/v1/assets/{asset_id}/receipts": { + "get": { + "summary": "Fetch every custody receipt covering one asset.", + "operationId": "get_asset_receipts", + "parameters": [ + { + "name": "asset_id", + "in": "path", + "description": "The asset id.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetReceiptsResponse" + } + } + } + }, + "404": { + "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/v1/shares": { + "post": { + "summary": "Register a share link the caller's client has issued.", + "operationId": "issue_share", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IssueShareRequest" + } + } + }, + "required": true + }, + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "415": { + "description": "Unsupported Media Type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "201": { + "description": "The share link is registered and servable", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IssueShareResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/v1/shares/{opaque_id}": { + "delete": { + "summary": "Revoke one of the caller's links.", + "description": "Idempotent from the caller's side and **indistinguishable**: a link that was never theirs, a\nlink that does not exist, and a link they already revoked are all `204`. Revocation is the\none operation where saying \"there was nothing to revoke\" would be a lookup.", + "operationId": "revoke_share", + "parameters": [ + { + "name": "opaque_id", + "in": "path", + "description": "The opaque id.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "204": { + "description": "the request succeeded and there is no content to send", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/s/{opaque_id}": { + "get": { + "summary": "What a viewer needs to begin, for a live link.", + "operationId": "share_metadata", + "parameters": [ + { + "name": "opaque_id", + "in": "path", + "description": "The opaque id.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SharedMetadataResponse" + } + } + } + }, + "404": { + "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "429": { + "description": "Too many requests", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + } + } + }, + "/s/{opaque_id}/wrapped-secret": { + "get": { + "summary": "The passphrase-wrapped scope material, when there is one.", + "description": "A link with no passphrase answers `404` rather than `204` or an empty body: whether a link is\npassphrase-protected is already disclosed by the metadata record, and a *second* way to ask\nthe same question with a different shape is a second thing to keep consistent.", + "operationId": "share_wrapped_secret", + "parameters": [ + { + "name": "opaque_id", + "in": "path", + "description": "The opaque id.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "429": { + "description": "Too many requests", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + } + } + }, + "/s/{opaque_id}/blob/{hash}": { + "get": { + "summary": "Ciphertext for one of the link's blobs, ranged.", + "description": "The membership check is the security property: a link serves the addresses its record\nenumerates and nothing else, so it cannot be walked sideways into the album's unstripped\nmetadata. A blob the link does not name is the same `404` as a link that does not exist.", + "operationId": "share_blob", + "parameters": [ + { + "name": "opaque_id", + "in": "path", + "description": "The opaque id.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "hash", + "in": "path", + "description": "The blob's content address.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "Range", + "in": "header", + "description": "The part of the representation to transfer, per RFC 9110 section 14.2. A field this operation cannot apply is ignored and the whole representation is sent.", + "schema": { + "type": "string", + "pattern": "^bytes=(?:\\d+-\\d*|-\\d+)(?:\\s*,\\s*(?:\\d+-\\d*|-\\d+)){0,7}$" + }, + "example": "bytes=0-1023" + }, + { + "name": "If-Range", + "in": "header", + "description": "The entity tag the client's partial copy came from, per RFC 9110 section 13.1.5. The `Range` is honoured only if it matches this representation under the strong comparison; otherwise the whole representation is sent.", + "schema": { + "type": "string" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "The entity tag the client already holds, per RFC 9110 section 13.1.2", + "schema": { + "type": "string" + } + }, + { + "name": "If-Modified-Since", + "in": "header", + "description": "The date the client's copy carries, per RFC 9110 section 13.1.3", + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "200": { + "description": "the whole representation", + "headers": { + "Accept-Ranges": { + "schema": { + "type": "string" + } + }, + "ETag": { + "schema": { + "type": "string" + } + }, + "Last-Modified": { + "schema": { + "type": "string" + } + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "206": { + "description": "the part the request asked for", + "headers": { + "Accept-Ranges": { + "schema": { + "type": "string" + } + }, + "ETag": { + "schema": { + "type": "string" + } + }, + "Last-Modified": { + "schema": { + "type": "string" + } + }, + "Content-Range": { + "description": "The part of the representation enclosed, and its complete length, per RFC 9110 section 14.4.", + "required": true, + "content": { + "text/plain": { + "schema": { + "type": "string", + "pattern": "^bytes \\d+-\\d+/\\d+$" + } + } + } + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "304": { + "description": "the client's copy is current", + "headers": { + "ETag": { + "schema": { + "type": "string" + } + }, + "Last-Modified": { + "schema": { + "type": "string" + } + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "404": { + "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "429": { + "description": "Too many requests", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + } + } + }, + "/v1/drops/links": { + "post": { + "summary": "Provision an upload link.", + "operationId": "provision_link", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProvisionLinkRequest" + } + } + }, + "required": true + }, + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "415": { + "description": "Unsupported Media Type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "201": { + "description": "The upload link is provisioned and accepting drops", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProvisionLinkResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/v1/drops/links/{opaque_id}": { + "delete": { + "summary": "Revoke one of the caller's links.", + "description": "Indistinguishable and idempotent, for the same reason a share revocation is: saying \"there\nwas nothing to revoke\" would be a lookup.", + "operationId": "revoke_link", + "parameters": [ + { + "name": "opaque_id", + "in": "path", + "description": "The opaque id.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "204": { + "description": "the request succeeded and there is no content to send", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/d/{opaque_id}": { + "post": { + "summary": "Open a drop session through a link.", + "description": "Invariants 26–30 in order: the link admits the file and reserves its caps in one store\noperation, then the owner's quota is charged, then the declaration is checked.", + "operationId": "create_drop", + "parameters": [ + { + "name": "opaque_id", + "in": "path", + "description": "The opaque id.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateDropRequest" + } + } + }, + "required": true + }, + "responses": { + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "415": { + "description": "Unsupported Media Type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "201": { + "description": "A drop session is open and accepting chunks", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateDropResponse" + } + } + } + }, + "404": { + "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Passphrase required", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "409": { + "description": "Link capacity exhausted", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "File too large", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/FileTooLargeProblem" + } + } + } + }, + "429": { + "description": "Too many requests", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + } + } + } + }, + "/d/{opaque_id}/{upload_id}": { + "patch": { + "summary": "Append one chunk to a drop session.", + "description": "The link is the credential: possession of the opaque id, plus a session that belongs to it.\nEverything after that is [`crate::upload::chunk::append`] — the album path's own function.", + "operationId": "append_drop_chunk", + "parameters": [ + { + "name": "opaque_id", + "in": "path", + "description": "The opaque id of the link the session belongs to.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "upload_id", + "in": "path", + "description": "The session id.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Capsule-Offset", + "in": "header", + "description": "Where in the blob this chunk starts.", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "X-Capsule-Checksum", + "in": "header", + "description": "The chunk's SHA-256, bare lowercase hex. Required: the idempotency tuple is undefined\nwithout it.", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + } + ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "required": true + }, + "responses": { + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "415": { + "description": "Unsupported media type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "204": { + "description": "the request succeeded and there is no content to send", + "headers": { + "X-Capsule-Offset": { + "description": "Where the session is now.", + "required": true, + "schema": { + "type": "integer", + "minimum": 0.0, + "format": "uint64" + } + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "404": { + "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "409": { + "description": "Chunk refused", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + } + } + }, + "/v1/drops": { + "get": { + "summary": "The caller's pending drops.", + "operationId": "list_inbox", + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InboxResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/v1/drops/{drop_id}/adopt": { + "post": { + "summary": "Adopt a pending drop into an album.", + "description": "Invariant 32. The manifest re-runs the create battery — a drop that skipped it would be the\none write on this server that entered an album unvalidated — and its `ciphertext_hash` must\nname a blob in **the caller's own inbox**, which is what stops an adoption from minting an\nasset over somebody else's bytes.\n\nThe row is **claimed, written, then settled**. Across two ports there is no transaction, and\nthe two failure directions are not equal: writing first and deleting after can duplicate a\nphoto, taking first and failing to write loses one. A claim leaves a crash visible in the\nowner's own inbox instead, marked `adopting`.", + "operationId": "adopt_drop", + "parameters": [ + { + "name": "drop_id", + "in": "path", + "description": "The drop's identifier.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdoptRequest" + } + } + }, + "required": true + }, + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "415": { + "description": "Unsupported Media Type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdoptResponse" + } + } + } + }, + "404": { + "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/v1/drops/{drop_id}": { + "delete": { + "summary": "Discard a pending drop.", + "description": "The bytes become unreferenced and the collector reclaims them; the link's cap is **not**\nrefunded, because the drop did happen — a guest deposited a file and the owner chose not to\nkeep it, which is not the same as a link slot never having been used.", + "operationId": "discard_drop", + "parameters": [ + { + "name": "drop_id", + "in": "path", + "description": "The drop's identifier.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "204": { + "description": "the request succeeded and there is no content to send", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "404": { + "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + } + }, + "components": { + "schemas": { + "Problem": { + "properties": { + "type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "status": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0, + "format": "uint16" + }, + "detail": { + "type": "string" + }, + "instance": { + "type": "string" + } + }, + "additionalProperties": true, + "type": "object", + "required": [ + "type", + "status" + ], + "title": "Problem Details", + "description": "An RFC 9457 problem detail." + }, + "WireBlobRole": { + "type": "string", + "enum": [ + "original", + "derivative", + "metadata", + "provenance", + "backup" + ], + "description": "A blob's role in its asset bundle, as the wire spells it.\n\nA wire type of its own rather than a serde derive on [`BlobRole`]: the state ports'\nrecords deliberately derive no serde traits, so that a record cannot be smuggled through a\nstore built for another. The mapping is one `match` in one direction." + }, + "ManifestEnvelope": { + "properties": { + "crypto_suite_id": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0, + "format": "uint16", + "description": "The crypto suite the blob was sealed under. Must equal the top-level declaration." + }, + "protocol_version": { + "type": "string", + "description": "The protocol date the manifest was written under (`YYYY-MM-DD`)." + }, + "album_id": { + "type": [ + "string", + "null" + ], + "description": "The album the asset belongs to. Must equal the top-level declaration." + }, + "file_id": { + "type": "string", + "description": "The asset this blob belongs to — the same id across the bundle's members." + }, + "amk_version": { + "type": "integer", + "maximum": 4294967295.0, + "minimum": 0.0, + "format": "uint32", + "description": "The album-key epoch the manifest was written under." + }, + "ciphertext_hash": { + "type": "string", + "description": "The ciphertext content hash, lowercase hex. Must equal the top-level `hash`.\n\n**This names the blob this session is uploading, not the manifest's own\n`ciphertext_hash`.** For the original the two coincide; for a metadata or provenance\nsession they do not, and the projection reuses the manifest's field name for a per-blob\ndeclaration. Invisible for a `create`, because the bundle is assembled in a pending row\nnobody can see and no member has to name another. It is not invisible for a `replace`,\nwhich is why [`Self::original_blob_hash`] exists (`S-C43`)." + }, + "plaintext_size": { + "type": "integer", + "minimum": 0.0, + "format": "uint64", + "description": "The plaintext length the manifest commits to." + }, + "chunk_size": { + "type": "integer", + "maximum": 4294967295.0, + "minimum": 0.0, + "format": "uint32", + "description": "The STREAM plaintext chunk size." + }, + "key_mode": { + "type": "string", + "description": "`derived` or `wrapped`." + }, + "metadata_blob_hash": { + "type": [ + "string", + "null" + ], + "description": "The content hash of the bundle's metadata blob, when the manifest commits to one." + }, + "original_blob_hash": { + "type": [ + "string", + "null" + ], + "description": "The content hash of the bundle's **original** blob, when the manifest commits to one\n(`S-C43`).\n\nThe manifest's own `ciphertext_hash`, under a name that cannot be confused with\n[`Self::ciphertext_hash`]'s per-session meaning. Optional on the wire and **required on a\n`replace`**: a replace re-points roles that already have bytes, so it has to be applied\nas one act, and the only member of the bundle that can carry the whole change is the\nmanifest — which therefore has to be able to name the original it commits to.\n\nA `create` may omit it. Its bundle is assembled incrementally in a row nobody can see,\nso no member needs to name another and requiring it would be a wire change for no gain." + }, + "created_by_user": { + "type": "string", + "description": "The account that created the asset." + }, + "created_by_device": { + "type": "string", + "description": "The device that created it, as a UUID — invariant 7's subject." + }, + "client_version": { + "type": "string", + "description": "The client build that wrote the manifest." + }, + "timestamp": { + "type": "string", + "description": "The manifest's self-asserted RFC3339 timestamp — invariants 7 and 8's subject." + }, + "action": { + "type": "string", + "description": "The lifecycle action. `create` or `replace` on this surface — the two that move blob\nbytes — and see [`GateReject::ActionNotAllowed`] for the rest." + }, + "prior_provenance_hash": { + "type": [ + "string", + "null" + ], + "description": "The provenance chain position this write continues from." + }, + "retention_until": { + "type": [ + "string", + "null" + ], + "description": "The retention floor the manifest carries, when it carries one." + } + }, + "type": "object", + "required": [ + "crypto_suite_id", + "protocol_version", + "file_id", + "amk_version", + "ciphertext_hash", + "plaintext_size", + "chunk_size", + "key_mode", + "created_by_user", + "created_by_device", + "client_version", + "timestamp", + "action" + ], + "description": "The server-visible mirror of the signed manifest's envelope fields, as declared at\n`POST /v1/upload`.\n\nStrict (`deny_unknown_fields`) like the rest of the transport JSON. The Postel asymmetry\nthe design draws — tolerant inside documents that outlive us, strict on the wire we own —\nputs unknown-key tolerance in the *signed CBOR interiors*, never in this JSON projection." + }, + "CreateUploadRequest": { + "properties": { + "size": { + "type": "integer", + "minimum": 0.0, + "format": "uint64", + "description": "The ciphertext length in bytes. Immutable for the session's life." + }, + "hash": { + "type": "string", + "description": "The ciphertext content hash, lowercase hex; the digest length is the suite's." + }, + "content_type": { + "type": "string", + "description": "The media type, from the closed enum this protocol version fixes." + }, + "crypto_suite_id": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0, + "format": "uint16", + "description": "The crypto suite the blob was sealed under." + }, + "protocol_version": { + "type": "string", + "description": "The protocol date (`YYYY-MM-DD`) this session is pinned to." + }, + "blob_role": { + "$ref": "#/components/schemas/WireBlobRole", + "description": "The blob's role in its bundle." + }, + "manifest_envelope": { + "$ref": "#/components/schemas/ManifestEnvelope", + "description": "The unencrypted manifest fields the server validates." + }, + "album_id": { + "type": [ + "string", + "null" + ], + "description": "The album the asset is filed into.\n\nOptional on the wire because the contract reserves the shape for owner-scoped kinds and\nfor the album-upgrade ceremony; **required by this server**, which has no way to check\ninvariant 6 without one and refuses rather than skipping it." + }, + "owner_id": { + "type": [ + "string", + "null" + ], + "description": "The owner the asset is filed under, when it is not the uploader.\n\nRefused when it is anyone but the uploader: an on-behalf upload needs a verified\nrelationship, and the port that would answer for one does not exist here." + }, + "intent_id": { + "type": [ + "string", + "null" + ], + "description": "The album-upgrade intent this write belongs to, when it belongs to one.\n\nCarried onto the session verbatim and read by nobody in this port; the ceremony that\ngives it meaning is `S-C24`." + } + }, + "type": "object", + "required": [ + "size", + "hash", + "content_type", + "crypto_suite_id", + "protocol_version", + "blob_role", + "manifest_envelope" + ], + "description": "The body of `POST /v1/upload`.\n\nStrict (`deny_unknown_fields`): an unknown field is a client bug and is refused rather than\nignored. Plaintext metadata — a filename, a capture date, dimensions — is deliberately\nabsent: it rides the encrypted metadata blob and never the wire request." + }, + "CreateUploadResponse": { + "properties": { + "id": { + "type": "string", + "description": "The session's identifier." + }, + "upload_url": { + "type": "string", + "description": "Where to send chunks." + }, + "suggested_chunk_size": { + "type": "integer", + "minimum": 0.0, + "format": "uint64", + "description": "A starting chunk size. A suggestion only — the client owns adaptation." + } + }, + "type": "object", + "required": [ + "id", + "upload_url", + "suggested_chunk_size" + ], + "description": "What a client needs to start sending bytes." + }, "VersionResponse": { "properties": { "name": { @@ -5813,36 +16755,6 @@ ], "description": "The `POST /v1/auth/register` body.\n\nDeliberately the *smallest* thing that can create an account: an address and a password. No\ndisplay name, no profile, no invitation code — every one of those would be a field the server\nstores about a person, and this server's whole posture is that it stores as little as it can." }, - "Problem": { - "properties": { - "type": { - "type": "string" - }, - "title": { - "type": "string" - }, - "status": { - "type": "integer", - "maximum": 65535.0, - "minimum": 0.0, - "format": "uint16" - }, - "detail": { - "type": "string" - }, - "instance": { - "type": "string" - } - }, - "additionalProperties": true, - "type": "object", - "required": [ - "type", - "status" - ], - "title": "Problem Details", - "description": "An RFC 9457 problem detail." - }, "TokenResponse": { "properties": { "access_token": { @@ -6781,230 +17693,6 @@ ], "description": "The `.well-known/capsule/revoked-jti` record." }, - "WireBlobRole": { - "type": "string", - "enum": [ - "original", - "derivative", - "metadata", - "provenance", - "backup" - ], - "description": "A blob's role in its asset bundle, as the wire spells it.\n\nA wire type of its own rather than a serde derive on [`BlobRole`]: the state ports'\nrecords deliberately derive no serde traits, so that a record cannot be smuggled through a\nstore built for another. The mapping is one `match` in one direction." - }, - "ManifestEnvelope": { - "properties": { - "crypto_suite_id": { - "type": "integer", - "maximum": 65535.0, - "minimum": 0.0, - "format": "uint16", - "description": "The crypto suite the blob was sealed under. Must equal the top-level declaration." - }, - "protocol_version": { - "type": "string", - "description": "The protocol date the manifest was written under (`YYYY-MM-DD`)." - }, - "album_id": { - "type": [ - "string", - "null" - ], - "description": "The album the asset belongs to. Must equal the top-level declaration." - }, - "file_id": { - "type": "string", - "description": "The asset this blob belongs to — the same id across the bundle's members." - }, - "amk_version": { - "type": "integer", - "maximum": 4294967295.0, - "minimum": 0.0, - "format": "uint32", - "description": "The album-key epoch the manifest was written under." - }, - "ciphertext_hash": { - "type": "string", - "description": "The ciphertext content hash, lowercase hex. Must equal the top-level `hash`.\n\n**This names the blob this session is uploading, not the manifest's own\n`ciphertext_hash`.** For the original the two coincide; for a metadata or provenance\nsession they do not, and the projection reuses the manifest's field name for a per-blob\ndeclaration. Invisible for a `create`, because the bundle is assembled in a pending row\nnobody can see and no member has to name another. It is not invisible for a `replace`,\nwhich is why [`Self::original_blob_hash`] exists (`S-C43`)." - }, - "plaintext_size": { - "type": "integer", - "minimum": 0.0, - "format": "uint64", - "description": "The plaintext length the manifest commits to." - }, - "chunk_size": { - "type": "integer", - "maximum": 4294967295.0, - "minimum": 0.0, - "format": "uint32", - "description": "The STREAM plaintext chunk size." - }, - "key_mode": { - "type": "string", - "description": "`derived` or `wrapped`." - }, - "metadata_blob_hash": { - "type": [ - "string", - "null" - ], - "description": "The content hash of the bundle's metadata blob, when the manifest commits to one." - }, - "original_blob_hash": { - "type": [ - "string", - "null" - ], - "description": "The content hash of the bundle's **original** blob, when the manifest commits to one\n(`S-C43`).\n\nThe manifest's own `ciphertext_hash`, under a name that cannot be confused with\n[`Self::ciphertext_hash`]'s per-session meaning. Optional on the wire and **required on a\n`replace`**: a replace re-points roles that already have bytes, so it has to be applied\nas one act, and the only member of the bundle that can carry the whole change is the\nmanifest — which therefore has to be able to name the original it commits to.\n\nA `create` may omit it. Its bundle is assembled incrementally in a row nobody can see,\nso no member needs to name another and requiring it would be a wire change for no gain." - }, - "created_by_user": { - "type": "string", - "description": "The account that created the asset." - }, - "created_by_device": { - "type": "string", - "description": "The device that created it, as a UUID — invariant 7's subject." - }, - "client_version": { - "type": "string", - "description": "The client build that wrote the manifest." - }, - "timestamp": { - "type": "string", - "description": "The manifest's self-asserted RFC3339 timestamp — invariants 7 and 8's subject." - }, - "action": { - "type": "string", - "description": "The lifecycle action. `create` or `replace` on this surface — the two that move blob\nbytes — and see [`GateReject::ActionNotAllowed`] for the rest." - }, - "prior_provenance_hash": { - "type": [ - "string", - "null" - ], - "description": "The provenance chain position this write continues from." - }, - "retention_until": { - "type": [ - "string", - "null" - ], - "description": "The retention floor the manifest carries, when it carries one." - } - }, - "type": "object", - "required": [ - "crypto_suite_id", - "protocol_version", - "file_id", - "amk_version", - "ciphertext_hash", - "plaintext_size", - "chunk_size", - "key_mode", - "created_by_user", - "created_by_device", - "client_version", - "timestamp", - "action" - ], - "description": "The server-visible mirror of the signed manifest's envelope fields, as declared at\n`POST /v1/upload`.\n\nStrict (`deny_unknown_fields`) like the rest of the transport JSON. The Postel asymmetry\nthe design draws — tolerant inside documents that outlive us, strict on the wire we own —\nputs unknown-key tolerance in the *signed CBOR interiors*, never in this JSON projection." - }, - "CreateUploadRequest": { - "properties": { - "size": { - "type": "integer", - "minimum": 0.0, - "format": "uint64", - "description": "The ciphertext length in bytes. Immutable for the session's life." - }, - "hash": { - "type": "string", - "description": "The ciphertext content hash, lowercase hex; the digest length is the suite's." - }, - "content_type": { - "type": "string", - "description": "The media type, from the closed enum this protocol version fixes." - }, - "crypto_suite_id": { - "type": "integer", - "maximum": 65535.0, - "minimum": 0.0, - "format": "uint16", - "description": "The crypto suite the blob was sealed under." - }, - "protocol_version": { - "type": "string", - "description": "The protocol date (`YYYY-MM-DD`) this session is pinned to." - }, - "blob_role": { - "$ref": "#/components/schemas/WireBlobRole", - "description": "The blob's role in its bundle." - }, - "manifest_envelope": { - "$ref": "#/components/schemas/ManifestEnvelope", - "description": "The unencrypted manifest fields the server validates." - }, - "album_id": { - "type": [ - "string", - "null" - ], - "description": "The album the asset is filed into.\n\nOptional on the wire because the contract reserves the shape for owner-scoped kinds and\nfor the album-upgrade ceremony; **required by this server**, which has no way to check\ninvariant 6 without one and refuses rather than skipping it." - }, - "owner_id": { - "type": [ - "string", - "null" - ], - "description": "The owner the asset is filed under, when it is not the uploader.\n\nRefused when it is anyone but the uploader: an on-behalf upload needs a verified\nrelationship, and the port that would answer for one does not exist here." - }, - "intent_id": { - "type": [ - "string", - "null" - ], - "description": "The album-upgrade intent this write belongs to, when it belongs to one.\n\nCarried onto the session verbatim and read by nobody in this port; the ceremony that\ngives it meaning is `S-C24`." - } - }, - "type": "object", - "required": [ - "size", - "hash", - "content_type", - "crypto_suite_id", - "protocol_version", - "blob_role", - "manifest_envelope" - ], - "description": "The body of `POST /v1/upload`.\n\nStrict (`deny_unknown_fields`): an unknown field is a client bug and is refused rather than\nignored. Plaintext metadata — a filename, a capture date, dimensions — is deliberately\nabsent: it rides the encrypted metadata blob and never the wire request." - }, - "CreateUploadResponse": { - "properties": { - "id": { - "type": "string", - "description": "The session's identifier." - }, - "upload_url": { - "type": "string", - "description": "Where to send chunks." - }, - "suggested_chunk_size": { - "type": "integer", - "minimum": 0.0, - "format": "uint64", - "description": "A starting chunk size. A suggestion only — the client owns adaptation." - } - }, - "type": "object", - "required": [ - "id", - "upload_url", - "suggested_chunk_size" - ], - "description": "What a client needs to start sending bytes." - }, "SessionSummary": { "properties": { "id": { diff --git a/capsule-server/src/lib.rs b/capsule-server/src/lib.rs index 5d018f32..a015d44d 100644 --- a/capsule-server/src/lib.rs +++ b/capsule-server/src/lib.rs @@ -26,7 +26,8 @@ //! //! Each module owns one port and, where it has one, the surface over it. [`routes`] is the only //! module that knows about HTTP: everything under it — [`album`], [`directory`], [`discovery`], -//! [`enrollment`], [`escrow`], [`gc`], [`index`], [`moderation`], [`quota`], [`scrub`], [`serve`], +//! [`enrollment`], [`escrow`], [`gc`], [`index`], [`moderation`], [`negotiation`], [`quota`], +//! [`scrub`], [`serve`], //! [`share`], [`store`], //! [`sync`], [`upload`], //! [`verify`] — is framework-free and testable without a router, which is why the operator @@ -68,6 +69,7 @@ pub mod gc; pub mod index; pub mod limits; pub mod moderation; +pub mod negotiation; mod openapi; pub mod problem; pub mod quota; @@ -84,6 +86,7 @@ use kynos::middleware::catch_panic::Propagate; use kynos::middleware::limits::BodySize; use kynos::middleware::stack::Cons; use kynos::prelude::*; +use kynos::router::group::Group; use kynos::router::service::Service; pub use self::app::App; @@ -104,11 +107,47 @@ pub fn router() -> ServerRouter { // and the bearer scheme's `401`/`403` — and fills in the `error.*` code none of those // framework-owned types has a seam to carry. See [`problem`], and `S-C36`. .intercept(problem::CodedProblems::new()) + // Inside the coder and outside everything that can refuse: the protocol window rides + // **every** response — the body-size `413` below, an extractor's `400`, the bearer + // scheme's `401`, the gate's own `426` — because each of those is produced beneath this + // and passes back up through it. See [`negotiation`]. + .intercept(negotiation::Negotiation::new()) // Mounted on the whole router, not on the operations that happen to take a body today: // an oversized body is refused wherever it is sent, and the `413` that refusal produces // is declared on every operation it covers because Kynos derives the declaration from // the interceptor's own type. See [`limits`]. .intercept(limits::body_size()) + // The protocol gate is a `Group`, not a router interceptor, because the design exempts + // ten operations from it and a group is how Kynos spells "these and not those": an + // operation mounted inside is gated and declares the handshake parameters and the `426`; + // one mounted on the router below is not, and still carries the response headers. + // + // The exempt set the design names (`api-surfaces.md`, "Negotiation Across Transports"): + // `GET /v1/version` (the reachability probe a client hits before it knows the window), + // the four `/.well-known/capsule/*` records (public discovery, read before any + // handshake), the three `/s/{opaque_id}*` share reads (share-links.md requires an + // indistinguishable `404`, which a `426` would turn into a probing oracle), and the two + // `/d/{opaque_id}*` guest deposits (web-upload.md pins the protocol at link issuance, so + // a browser guest has nothing to assert). + // + // **What is gated today is the upload session's four operations** — the ones that + // enforced the handshake per route before this group existed. Every other non-exempt + // operation is still mounted on the router below, and moving it in is a one-line change + // here — deliberately not made yet, because `capsule-sdk` builds three separate + // `reqwest` clients (`auth.rs`, `sync.rs`, `net.rs`) and only the shared one in + // `client.rs` sends the handshake; widening the gate before the other two do would + // refuse every SDK sign-in. The census test in `tests/conformance.rs` pins the gated set + // so the widening is a deliberate edit to both, never an accident of a mount. + .group( + Group::::new("/") + .intercept(negotiation::ProtocolGate::new()) + .mount(kynos::routes![ + routes::upload::create_upload, + routes::upload::append_chunk, + routes::upload::head_upload, + routes::upload::cancel_upload, + ]), + ) // Seven `mount` calls, not one. Kynos's `EndpointSet` is implemented for tuples up to // sixteen and the seventeenth operation is a compile error, so a split is forced — but // grouping by surface rather than cutting at the arbitrary boundary is what makes the @@ -162,13 +201,10 @@ pub fn router() -> ServerRouter { routes::well_known::deprecation_announcements, routes::well_known::revoked_jti, ]) - // The asset surfaces: getting bytes in, changing what they mean, and reading them back. + // The asset surfaces beside the gated session operations above: changing what bytes + // mean, and reading them back. .mount(kynos::routes![ - routes::upload::create_upload, - routes::upload::append_chunk, routes::sessions::list_upload_sessions, - routes::upload::head_upload, - routes::upload::cancel_upload, routes::receipts::get_upload_receipt, routes::ops::apply_op, routes::sync::sync_feed, @@ -202,7 +238,11 @@ pub fn router() -> ServerRouter { /// two interceptors answering with one status a compile error rather than a runtime surprise — /// so mounting one changes this signature. That is a feature: the alias is the one place the /// server's middleware stack is written down. -pub type ServerRouter = Router>>; +pub type ServerRouter = Router< + App, + Propagate, + Cons>>, +>; /// Builds the service the server and the in-process tests both drive. /// @@ -254,5 +294,10 @@ pub fn openapi() -> kynos::Result { // a generator. Filled in with the binary marker so the SDK's client can be generated from // the whole document instead of most of it. openapi::describe_raw_byte_payloads(&mut document); + // Issue #404: Kynos describes an interceptor's response headers on success responses only, + // while [`negotiation::Negotiation`] attaches them to every response it forwards — errors + // included. The walk files the same three declarations under every other response, so the + // document promises exactly what the wire carries. + openapi::describe_negotiation_headers(&mut document); Ok(document) } diff --git a/capsule-server/src/negotiation.rs b/capsule-server/src/negotiation.rs new file mode 100644 index 00000000..388b11fd --- /dev/null +++ b/capsule-server/src/negotiation.rs @@ -0,0 +1,729 @@ +//! The protocol handshake, as one declaration for the whole router (issue #404). +//! +//! # The contract +//! +//! [Threat Model — Protocol and Capability +//! Negotiation](../../capsule-docs/src/content/docs/design/threat-model/validation.md) puts six +//! headers on the wire and says they are applied "by shared Kynos middleware to every public +//! route": three the client sends — `X-Capsule-Protocol`, `X-Capsule-Crypto-Suite`, +//! `X-Capsule-Sidecar-Schema` — and three the server answers with on **every** response of +//! every operation — +//! `X-Capsule-Protocol-Min`, `X-Capsule-Protocol-Max`, `X-Capsule-Min-Client-Build`. A +//! protocol outside the window is `426` with `error.protocol.version_unsupported`; a suite the +//! inventory does not name or a sidecar schema newer than this build knows is `400`. +//! +//! # Where it was broken, and why +//! +//! Before this module the handshake lived in `routes/upload.rs` as a per-route helper: four +//! operations read the request header, and the response window rode as problem *extension +//! members* because a Kynos `ApiError` "has no seam for a response header". Meanwhile +//! `capsule-sdk/src/upload.rs` reads the window **from headers** — and got `None` every time. +//! The `426` recovery path the design promises was dead on both ends, and no route outside the +//! upload surface advertised anything at all. +//! +//! Kynos *does* have the seam; it is just not on the error type. An [`Interceptor`]'s three +//! associated types are its declaration — `Reads` contributes request parameters, `Short` +//! contributes responses, `Adds` contributes response headers — and an interceptor sees every +//! response the chain beneath it produces, a short-circuit included. So the window belongs on an +//! interceptor mounted outside everything that can refuse, not on each refusal. +//! +//! # Two interceptors, deliberately +//! +//! - [`Negotiation`] **advertises**. `Reads = ()`, `Short = Infallible`, `Adds` the three +//! response headers. Mounted on the router, outside the body-size limit, so a `413`, a +//! `401`, a `426` and a `200` all leave with the window on them. It cannot refuse anything. +//! What it cannot reach is a response the router produced *before* choosing an operation — +//! an unrouted `404` or `405` — because Kynos runs interceptors per operation, after routing. +//! - [`ProtocolGate`] **refuses**. `Reads` the three request headers, `Adds = ()`, `Short` is +//! [`NegotiationRejection`]. Mounted on a `Group`, which is how an exemption is spelled: +//! an operation outside the group is not gated and still carries the response headers. +//! +//! One interceptor doing both would make the exemption impossible to express — the response +//! headers are wanted everywhere and the gate is not — and Kynos's conflict check would refuse +//! a second copy of either at a narrower scope. +//! +//! # What the gate reads, and how strictly +//! +//! `X-Capsule-Protocol` is required: absent is a `400`, not a date is a `400`, outside the +//! window is a `426`. The other two are validated **when present** — a suite the inventory does +//! not implement and a sidecar schema above [`MAX_KNOWN_SIDECAR_SCHEMA`] are each a `400` — and +//! their absence is not refused. The design scopes `X-Capsule-Crypto-Suite` to writes and +//! `X-Capsule-Sidecar-Schema` to metadata updates, every write already carries its suite in a +//! body the envelope gate checks, and a gate that demanded a header on a read that has no use +//! for it would refuse every client for a value nobody reads. +//! +//! All three are read as strings and parsed here rather than typed by the framework, so a +//! malformed value is *this* module's coded `400` and not the framework's uncoded one. +//! +//! # `X-Capsule-Min-Client-Build` is advisory +//! +//! The design says "advisory unless the path is hard-deprecated", and no path is. The header is +//! sent, the value is the policy's, and nothing refuses on it. A deployment that has announced +//! no cutoff publishes `0.0.0`, which every build satisfies — the honest spelling of "no cutoff", +//! rather than an absent header a client could not tell from a server that never speaks it. +//! +//! # The document +//! +//! `Reads` and `Short` describe themselves through the interceptor's types. `Adds` describes +//! itself only on success responses (Kynos attaches an interceptor's response headers at +//! `StatusPattern::Success`, `kynos/src/middleware/erased.rs`), so +//! [`crate::openapi`] walks the emitted document once and files the same three headers under +//! every other response. The names and schemas both come from [`response_header_declarations`], +//! so the document and the wire cannot disagree about what a header is called. + +use std::convert::Infallible; + +use capsule_core::validation::protocol::{ + HandshakeReject, check_sidecar_schema, check_suite, protocol_gate, +}; +use capsule_i18n::error_codes; +use kynos::di::Provides; +use kynos::error::rejection::HeaderRejection; +use kynos::extract::params::header::{DecodeHeaders, EncodeHeaders, HeaderParams}; +use kynos::http::{HeaderMap, HeaderName, HeaderValue, Request}; +use kynos::middleware::{Continued, Interceptor, Next}; +use kynos::openapi::{Header, Parameter, Schema}; +use kynos::prelude::*; +use kynos::schema::registry::Registry; + +use crate::upload::{UploadContext, UploadPolicy}; + +/// The request header carrying the `YYYY-MM-DD` protocol version the request is written against. +pub const PROTOCOL: &str = "X-Capsule-Protocol"; +/// The request header carrying the `u16` crypto suite id, on writes. +pub const CRYPTO_SUITE: &str = "X-Capsule-Crypto-Suite"; +/// The request header carrying the `u16` sidecar schema version, on metadata updates. +pub const SIDECAR_SCHEMA: &str = "X-Capsule-Sidecar-Schema"; +/// The response header carrying the oldest protocol version this server accepts. +pub const PROTOCOL_MIN: &str = "X-Capsule-Protocol-Min"; +/// The response header carrying the newest protocol version this server accepts. +pub const PROTOCOL_MAX: &str = "X-Capsule-Protocol-Max"; +/// The response header carrying the advisory semver deprecation cutoff. +pub const MIN_CLIENT_BUILD: &str = "X-Capsule-Min-Client-Build"; + +/// The newest sidecar schema this build indexes. +/// +/// `capsule-core` keeps `SIDECAR_SCHEMA_V1` crate-private behind its frozen barrel (`#399`), so +/// the server states the number it will acknowledge here. The server never parses a sidecar — +/// this is the Postel cross-version closure the threat model asks for: a write whose schema +/// number this build cannot index is refused rather than acknowledged and lost. +pub const MAX_KNOWN_SIDECAR_SCHEMA: u16 = 1; + +// =========================================================================================== +// The request half +// =========================================================================================== + +/// The three request headers the handshake reads. +/// +/// Every field is optional at the *type* level so that a missing or unreadable one is this +/// module's coded rejection rather than the framework's uncoded `HeaderRejection`; whether a +/// header is required is decided by [`negotiate`] and declared by [`HeaderParams::parameters`]. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ProtocolRequestHeaders { + /// `X-Capsule-Protocol`, verbatim. + pub protocol: Option, + /// `X-Capsule-Crypto-Suite`, verbatim. + pub crypto_suite: Option, + /// `X-Capsule-Sidecar-Schema`, verbatim. + pub sidecar_schema: Option, +} + +impl HeaderParams for ProtocolRequestHeaders { + // Lower-case, because these are what the conflict check compares and what a decoder looks + // up; the document spells them in their canonical case below. + const NAMES: &'static [&'static str] = &[ + "x-capsule-protocol", + "x-capsule-crypto-suite", + "x-capsule-sidecar-schema", + ]; + + fn parameters(registry: &mut Registry) -> Vec { + let _ = registry; + vec![ + Parameter::header(PROTOCOL, date_schema()) + .required(true) + .with_description( + "The `YYYY-MM-DD` protocol version this request is written against. \ + Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` \ + window the request is refused with `426`.", + ), + Parameter::header(CRYPTO_SUITE, u16_schema()) + .required(false) + .with_description( + "The crypto suite id from the primitives inventory. Sent on writes; a suite \ + this server does not implement is refused with `400`.", + ), + Parameter::header(SIDECAR_SCHEMA, u16_schema()) + .required(false) + .with_description( + "The sidecar schema version declared at `sidecar_schema` field 0. Sent on \ + metadata updates; a schema newer than this server indexes is refused with \ + `400`.", + ), + ] + } +} + +impl DecodeHeaders for ProtocolRequestHeaders { + fn decode(headers: &HeaderMap) -> Result { + Ok(Self { + protocol: read(headers, PROTOCOL)?, + crypto_suite: read(headers, CRYPTO_SUITE)?, + sidecar_schema: read(headers, SIDECAR_SCHEMA)?, + }) + } +} + +/// One header as text, or `None` when absent. +/// +/// The only failure is a value that is not visible ASCII, which is the one thing that cannot be +/// turned into a coded rejection here because it cannot be turned into a `String` at all. +fn read(headers: &HeaderMap, name: &str) -> Result, HeaderRejection> { + headers + .get(name) + .map(|value| { + value + .to_str() + .map(str::to_owned) + .map_err(|_| HeaderRejection::Invalid { + name: name.to_owned(), + detail: "the value is not printable ASCII".to_owned(), + }) + }) + .transpose() +} + +/// Why the handshake refused a request. +/// +/// The `426` carries the window in its `detail` for a human and **on the response headers** +/// for a client — [`Negotiation`] sits outside this gate, so the refusal leaves with +/// `X-Capsule-Protocol-Min`/`-Max` on it like every other response. No extension member +/// restates them: two spellings of one fact is the drift the census exists to prevent. +#[derive(Debug, thiserror::Error, ApiError)] +pub enum NegotiationRejection { + /// `X-Capsule-Protocol` is a date outside `[min, max]`. + #[error("this server accepts protocol versions [{protocol_min}, {protocol_max}]")] + #[problem(status = 426, title = "Protocol version unsupported")] + ProtocolUnsupported { + /// The lowest version this server accepts. + protocol_min: String, + /// The highest version this server accepts. + protocol_max: String, + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// A handshake header is missing, unreadable, or names something this server does not + /// implement. The `detail` says which. + #[error("{detail}")] + #[problem(status = 400, title = "Malformed handshake")] + Malformed { + /// What was wrong, in English. Reaches the client as the problem's `detail`. + detail: String, + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, +} + +impl NegotiationRejection { + fn malformed(detail: impl Into) -> Self { + Self::Malformed { + detail: detail.into(), + code: error_codes::REQUEST_MALFORMED, + } + } +} + +/// The one-shot handshake: a client either speaks a version this server accepts, or it is +/// refused before any state is read or written. There is no negotiation and no degrade. +/// +/// Pure, so the three outcomes are unit-tested without a router. +/// +/// # Errors +/// +/// `426` for a protocol outside the window; `400` for a missing or non-date protocol, a suite +/// the inventory does not name, or a sidecar schema above [`MAX_KNOWN_SIDECAR_SCHEMA`]. +pub fn negotiate( + policy: &UploadPolicy, + headers: &ProtocolRequestHeaders, +) -> Result<(), NegotiationRejection> { + let Some(protocol) = headers.protocol.as_deref() else { + return Err(NegotiationRejection::malformed(format!( + "{PROTOCOL} is required on this operation" + ))); + }; + match protocol_gate(protocol, policy.protocol_min(), policy.protocol_max()) { + Ok(()) => {} + Err(HandshakeReject::ProtocolOutOfRange) => { + tracing::info!( + presented = protocol, + min = policy.protocol_min(), + max = policy.protocol_max(), + "a request was refused: protocol version outside the accepted window" + ); + return Err(NegotiationRejection::ProtocolUnsupported { + protocol_min: policy.protocol_min().to_owned(), + protocol_max: policy.protocol_max().to_owned(), + code: error_codes::PROTOCOL_VERSION_UNSUPPORTED, + }); + } + Err(_) => { + tracing::debug!( + presented = protocol, + "a request was refused: protocol is not a date" + ); + return Err(NegotiationRejection::malformed(format!( + "{PROTOCOL} is not a YYYY-MM-DD date" + ))); + } + } + + if let Some(suite) = headers.crypto_suite.as_deref() { + let id = suite.trim().parse::().map_err(|_| { + NegotiationRejection::malformed(format!("{CRYPTO_SUITE} is not a u16 suite id")) + })?; + if check_suite(id).is_err() { + tracing::debug!( + suite = id, + "a request was refused: crypto suite not implemented" + ); + return Err(NegotiationRejection::malformed(format!( + "{CRYPTO_SUITE} {id} is not in this server's inventory" + ))); + } + } + + if let Some(schema) = headers.sidecar_schema.as_deref() { + let version = schema.trim().parse::().map_err(|_| { + NegotiationRejection::malformed(format!("{SIDECAR_SCHEMA} is not a u16 schema version")) + })?; + if check_sidecar_schema(version, MAX_KNOWN_SIDECAR_SCHEMA).is_err() { + tracing::debug!( + schema = version, + max_known = MAX_KNOWN_SIDECAR_SCHEMA, + "a request was refused: sidecar schema newer than this server indexes" + ); + return Err(NegotiationRejection::malformed(format!( + "{SIDECAR_SCHEMA} {version} is newer than this server indexes \ + ({MAX_KNOWN_SIDECAR_SCHEMA})" + ))); + } + } + + Ok(()) +} + +// =========================================================================================== +// The response half +// =========================================================================================== + +/// The three response headers every response carries. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NegotiationResponseHeaders { + /// `X-Capsule-Protocol-Min`. + pub protocol_min: String, + /// `X-Capsule-Protocol-Max`. + pub protocol_max: String, + /// `X-Capsule-Min-Client-Build`. + pub min_client_build: String, +} + +impl NegotiationResponseHeaders { + /// The window a policy advertises — the same values it enforces, read from one place. + #[must_use] + pub fn advertise(policy: &UploadPolicy) -> Self { + Self { + protocol_min: policy.protocol_min().to_owned(), + protocol_max: policy.protocol_max().to_owned(), + min_client_build: policy.min_client_build().to_owned(), + } + } +} + +/// The response headers, as `(name, schema, description)`, in wire order. +/// +/// The one source for the document's two spellings of them — the header parameters Kynos +/// describes on success responses through [`HeaderParams::parameters`], and the response headers +/// the post-emit walk in [`crate::openapi`] files under every other response — so the two cannot +/// disagree about what a header is called or what it carries. +fn declarations() -> [(&'static str, Schema, &'static str); 3] { + [ + ( + PROTOCOL_MIN, + date_schema(), + "The oldest protocol version this server accepts.", + ), + ( + PROTOCOL_MAX, + date_schema(), + "The newest protocol version this server accepts.", + ), + ( + MIN_CLIENT_BUILD, + semver_schema(), + "The semver client build below which this server will stop answering. Advisory: \ + `0.0.0` when no cutoff has been announced.", + ), + ] +} + +/// The response headers as a response's `headers` map declares them: `(name, header)`. +#[must_use] +pub fn response_header_declarations() -> Vec<(&'static str, Header)> { + declarations() + .into_iter() + .map(|(name, schema, description)| { + ( + name, + Header::new(schema) + .required(true) + .with_description(description), + ) + }) + .collect() +} + +impl HeaderParams for NegotiationResponseHeaders { + const NAMES: &'static [&'static str] = &[ + "x-capsule-protocol-min", + "x-capsule-protocol-max", + "x-capsule-min-client-build", + ]; + + fn parameters(registry: &mut Registry) -> Vec { + let _ = registry; + declarations() + .into_iter() + .map(|(name, schema, description)| { + Parameter::header(name, schema) + .required(true) + .with_description(description) + }) + .collect() + } +} + +impl EncodeHeaders for NegotiationResponseHeaders { + fn encode(&self) -> Vec<(HeaderName, HeaderValue)> { + [ + ("x-capsule-protocol-min", self.protocol_min.as_str()), + ("x-capsule-protocol-max", self.protocol_max.as_str()), + ("x-capsule-min-client-build", self.min_client_build.as_str()), + ] + .into_iter() + .filter_map(|(name, value)| match HeaderValue::from_str(value) { + Ok(value) => Some((HeaderName::from_static(name), value)), + Err(error) => { + // A policy value that is not a header value is a deployment fault, not a request + // fault; the response goes out without it rather than not at all, and the log + // says why. Config checks only that the window is ordered, not that its ends + // are header values, so this branch is reachable from a misconfiguration. + tracing::error!(%error, name, value, "a protocol window value is not a header value"); + None + } + }) + .collect() + } +} + +// =========================================================================================== +// The interceptors +// =========================================================================================== + +/// Advertises the protocol window on every response. +/// +/// Mounted on the whole router and outside the body-size limit, so nothing that refuses a +/// request — the framework's `413`, the bearer scheme's `401`, [`ProtocolGate`]'s `426` — can +/// answer without it. Cannot refuse: `Short` is [`Infallible`]. +#[derive(Debug, Clone, Copy, Default)] +pub struct Negotiation; + +impl Negotiation { + /// The interceptor. + #[must_use] + pub fn new() -> Self { + Self + } +} + +impl Interceptor for Negotiation +where + C: Provides + Sync + 'static, +{ + type Reads = (); + type Adds = NegotiationResponseHeaders; + /// Advertising never refuses. + type Short = Infallible; + + async fn intercept( + &self, + request: Request, + (): (), + context: &C, + next: Next<'_, C>, + ) -> Result, Infallible> { + let upload: UploadContext = context.provide(); + let window = NegotiationResponseHeaders::advertise(upload.policy()); + Ok(next.run(request).await.with_headers(window)) + } +} + +/// Refuses a request whose handshake this server cannot honour, before the handler runs. +/// +/// Mounted on a `Group` rather than the router, because the exemptions the design names — the +/// reachability probe, public discovery, share reads, guest deposits — are expressed by mounting +/// those operations outside the group. See `lib.rs::router` for the list and the reasons. +#[derive(Debug, Clone, Copy, Default)] +pub struct ProtocolGate; + +impl ProtocolGate { + /// The interceptor. + #[must_use] + pub fn new() -> Self { + Self + } +} + +impl Interceptor for ProtocolGate +where + C: Provides + Sync + 'static, +{ + type Reads = ProtocolRequestHeaders; + type Adds = (); + type Short = NegotiationRejection; + + async fn intercept( + &self, + request: Request, + reads: ProtocolRequestHeaders, + context: &C, + next: Next<'_, C>, + ) -> Result, NegotiationRejection> { + let upload: UploadContext = context.provide(); + negotiate(upload.policy(), &reads)?; + Ok(next.run(request).await) + } +} + +// =========================================================================================== +// Schemas +// =========================================================================================== + +/// A `YYYY-MM-DD` protocol date. +fn date_schema() -> Schema { + serde_json::from_value(serde_json::json!({ + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$", + })) + .expect("a literal date schema is a schema") +} + +/// A `u16`, as a header carries it. +fn u16_schema() -> Schema { + serde_json::from_value(serde_json::json!({ + "type": "integer", + "minimum": 0, + "maximum": 65535, + })) + .expect("a literal integer schema is a schema") +} + +/// A semver build. +fn semver_schema() -> Schema { + serde_json::from_value(serde_json::json!({ + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$", + })) + .expect("a literal semver schema is a schema") +} + +#[cfg(test)] +mod tests { + use kynos::response::ShortCircuit as _; + + use super::*; + + fn headers( + protocol: Option<&str>, + suite: Option<&str>, + schema: Option<&str>, + ) -> ProtocolRequestHeaders { + ProtocolRequestHeaders { + protocol: protocol.map(str::to_owned), + crypto_suite: suite.map(str::to_owned), + sidecar_schema: schema.map(str::to_owned), + } + } + + fn policy() -> UploadPolicy { + UploadPolicy::default().with_protocol_window("2026-01-01", "2026-12-31") + } + + fn code(rejection: &NegotiationRejection) -> &'static str { + match rejection { + NegotiationRejection::ProtocolUnsupported { code, .. } + | NegotiationRejection::Malformed { code, .. } => code, + } + } + + #[test] + fn a_protocol_inside_the_window_passes() { + assert!(negotiate(&policy(), &headers(Some("2026-05-31"), None, None)).is_ok()); + // Both ends are inclusive. + assert!(negotiate(&policy(), &headers(Some("2026-01-01"), None, None)).is_ok()); + assert!(negotiate(&policy(), &headers(Some("2026-12-31"), None, None)).is_ok()); + } + + #[test] + fn a_protocol_outside_the_window_is_426_with_the_window() { + for presented in ["2025-12-31", "2027-01-01"] { + let refused = negotiate(&policy(), &headers(Some(presented), None, None)) + .expect_err("outside the window"); + assert!( + matches!( + &refused, + NegotiationRejection::ProtocolUnsupported { protocol_min, protocol_max, .. } + if protocol_min == "2026-01-01" && protocol_max == "2026-12-31" + ), + "{presented}: {refused:?}" + ); + assert_eq!(code(&refused), error_codes::PROTOCOL_VERSION_UNSUPPORTED); + } + } + + #[test] + fn a_missing_or_non_date_protocol_is_400_not_426() { + for presented in [None, Some("yesterday"), Some("2026/05/31"), Some("")] { + let refused = + negotiate(&policy(), &headers(presented, None, None)).expect_err("malformed"); + assert!( + matches!(refused, NegotiationRejection::Malformed { .. }), + "{presented:?}: {refused:?}" + ); + assert_eq!(code(&refused), error_codes::REQUEST_MALFORMED); + } + } + + #[test] + fn the_suite_and_the_sidecar_schema_are_checked_when_present() { + let ok = Some("2026-05-31"); + let suite = capsule_core::crypto::primitives::CRYPTO_SUITE_ID.to_string(); + assert!(negotiate(&policy(), &headers(ok, Some(&suite), Some("1"))).is_ok()); + assert!(negotiate(&policy(), &headers(ok, Some(&suite), Some("0"))).is_ok()); + + for (suite, schema) in [ + (Some("9999"), None), + (Some("not a number"), None), + (None, Some("2")), + (None, Some("v1")), + ] { + let refused = negotiate(&policy(), &headers(ok, suite, schema)).expect_err("refused"); + assert!( + matches!(refused, NegotiationRejection::Malformed { .. }), + "suite {suite:?}, schema {schema:?}: {refused:?}" + ); + assert_eq!(code(&refused), error_codes::REQUEST_MALFORMED); + } + } + + #[test] + fn the_rejection_declares_exactly_the_two_statuses_it_renders() { + let mut statuses = NegotiationRejection::STATUSES.to_vec(); + statuses.sort_unstable(); + assert_eq!(statuses, [400, 426]); + } + + #[test] + fn the_response_group_encodes_what_it_declares() { + let window = NegotiationResponseHeaders { + protocol_min: "2026-01-01".to_owned(), + protocol_max: "2026-12-31".to_owned(), + min_client_build: "0.0.0".to_owned(), + }; + let encoded: Vec<(String, String)> = window + .encode() + .into_iter() + .map(|(name, value)| { + ( + name.as_str().to_owned(), + value.to_str().expect("ascii").to_owned(), + ) + }) + .collect(); + assert_eq!( + encoded, + [ + ("x-capsule-protocol-min".to_owned(), "2026-01-01".to_owned()), + ("x-capsule-protocol-max".to_owned(), "2026-12-31".to_owned()), + ("x-capsule-min-client-build".to_owned(), "0.0.0".to_owned()), + ] + ); + + // The declared names, the encoded names and the documented names are one list. + let declared: Vec = response_header_declarations() + .into_iter() + .map(|(name, _)| name.to_ascii_lowercase()) + .collect(); + assert_eq!(declared, NegotiationResponseHeaders::NAMES); + let documented: Vec = + NegotiationResponseHeaders::parameters(&mut Registry::default()) + .into_iter() + .map(|parameter| parameter.name.to_ascii_lowercase()) + .collect(); + assert_eq!(documented, NegotiationResponseHeaders::NAMES); + } + + #[test] + fn a_window_value_that_is_not_a_header_value_is_dropped_not_panicked() { + let window = NegotiationResponseHeaders { + protocol_min: "2026-01-01".to_owned(), + protocol_max: "bad\nvalue".to_owned(), + min_client_build: "0.0.0".to_owned(), + }; + assert_eq!(window.encode().len(), 2); + } + + #[test] + fn the_request_group_documents_the_protocol_as_required_and_the_rest_as_optional() { + let parameters = ProtocolRequestHeaders::parameters(&mut Registry::default()); + let required: Vec<(&str, Option)> = parameters + .iter() + .map(|parameter| (parameter.name.as_str(), parameter.required)) + .collect(); + assert_eq!( + required, + [ + (PROTOCOL, Some(true)), + (CRYPTO_SUITE, Some(false)), + (SIDECAR_SCHEMA, Some(false)), + ] + ); + let declared: Vec = parameters + .iter() + .map(|parameter| parameter.name.to_ascii_lowercase()) + .collect(); + assert_eq!(declared, ProtocolRequestHeaders::NAMES); + } + + #[test] + fn decoding_reads_each_header_verbatim_and_tolerates_absence() { + let mut map = HeaderMap::new(); + assert_eq!( + ProtocolRequestHeaders::decode(&map).expect("absent is fine"), + ProtocolRequestHeaders::default() + ); + map.insert("x-capsule-protocol", HeaderValue::from_static("2026-05-31")); + map.insert("x-capsule-crypto-suite", HeaderValue::from_static("1")); + assert_eq!( + ProtocolRequestHeaders::decode(&map).expect("decodes"), + headers(Some("2026-05-31"), Some("1"), None) + ); + map.insert( + "x-capsule-sidecar-schema", + HeaderValue::from_bytes(b"\xff").expect("opaque bytes are a header value"), + ); + assert!(ProtocolRequestHeaders::decode(&map).is_err()); + } +} diff --git a/capsule-server/src/openapi/mod.rs b/capsule-server/src/openapi/mod.rs index f3415629..0df402c1 100644 --- a/capsule-server/src/openapi/mod.rs +++ b/capsule-server/src/openapi/mod.rs @@ -46,8 +46,8 @@ //! new `#[problem(extension)]` field that is not `code` will not appear in the document until //! somebody adds a row, and nothing here fails when they forget. //! -//! Three things bound that. The table is small — nine rows against sixteen non-`code` fields -//! across six enums — and `every_row_names_a_response_that_exists` fails on a row that has gone +//! Three things bound that. The table is small — six rows against the non-`code` fields +//! across the rejection enums — and `every_row_names_a_response_that_exists` fails on a row that has gone //! stale, so it cannot rot in the other direction. The `code` member, which is the one the i18n //! contract turns on and 104 of the 120 extension fields on this surface, needs no table at all. //! And the real fix is upstream: `#[problem(extension)]` should carry a schema, which is the @@ -110,30 +110,6 @@ struct Extra { /// /// See the module docs for why this is a table and what bounds the risk of one. const EXTRAS: &[Extra] = &[ - Extra { - component: "ProtocolRangeProblem", - operation: "create_upload", - status: 426, - members: PROTOCOL_RANGE, - }, - Extra { - component: "ProtocolRangeProblem", - operation: "append_chunk", - status: 426, - members: PROTOCOL_RANGE, - }, - Extra { - component: "ProtocolRangeProblem", - operation: "head_upload", - status: 426, - members: PROTOCOL_RANGE, - }, - Extra { - component: "ProtocolRangeProblem", - operation: "cancel_upload", - status: 426, - members: PROTOCOL_RANGE, - }, Extra { component: "ProtocolRangeProblem", operation: "album_lifecycle_op", @@ -209,7 +185,14 @@ const EXTRAS: &[Extra] = &[ }, ]; -/// The protocol window a `426` publishes, shared by every operation that pins one. +/// The protocol window a **body-level** `426` publishes as extension members. +/// +/// One row is left: `album_lifecycle_op` refuses a manifest envelope pinned outside the window +/// and still renders the range in the body. The four upload operations no longer do — since +/// issue #404 the window rides `X-Capsule-Protocol-Min`/`-Max` on every response, header-gated +/// and body-gated `426`s alike, which is where the SDK reads it; a second spelling in the body +/// is the drift the census exists to prevent. The remaining row goes when `routes/ops.rs` +/// drops its members. const PROTOCOL_RANGE: &[Member] = &[ Member { name: "protocol_min", @@ -308,6 +291,60 @@ fn fill_binary(schema: &mut Option) { ); } +/// Files the protocol window's three response headers under every response (issue #404). +/// +/// [`crate::negotiation::Negotiation`] attaches `X-Capsule-Protocol-Min`, `-Max` and +/// `X-Capsule-Min-Client-Build` to **every** response it forwards, and it forwards everything — +/// a short-circuit from an inner interceptor, an extractor's rejection, a handler's answer. +/// Kynos describes an interceptor's `Adds` at `StatusPattern::Success` only +/// (`kynos/src/middleware/erased.rs`), so without this the document would promise the headers +/// on a `200` and stay silent on the `426` where a client most needs them. +/// +/// The declarations come from [`crate::negotiation::response_header_declarations`] — the same +/// source the interceptor's own description uses — and a response that already declares a +/// header under one of these names is left exactly as the router emitted it, so this can never +/// overwrite what Kynos said. +pub(crate) fn describe_negotiation_headers(document: &mut Document) { + let declarations = crate::negotiation::response_header_declarations(); + for item in document.paths.items.values_mut() { + let slots: Vec<&mut Option>> = vec![ + &mut item.get, + &mut item.put, + &mut item.post, + &mut item.delete, + &mut item.options, + &mut item.head, + &mut item.patch, + &mut item.trace, + &mut item.query, + ]; + for operation in slots.into_iter().filter_map(|slot| slot.as_deref_mut()) { + let responses = operation + .responses + .responses + .values_mut() + .chain(operation.responses.default_response.iter_mut()); + for response in responses { + let kynos::openapi::RefOr::Item(response) = response else { + continue; + }; + for (name, header) in &declarations { + let declared = response + .headers + .keys() + .any(|existing| existing.eq_ignore_ascii_case(name)); + if !declared { + response.headers.insert( + (*name).to_owned(), + kynos::openapi::RefOr::Item(header.clone()), + ); + } + } + } + } + } +} + pub(crate) fn describe_problem_extensions(document: &mut Document) { let Some(base) = document.components.schemas.get(BASE).cloned() else { return; diff --git a/capsule-server/src/routes/upload.rs b/capsule-server/src/routes/upload.rs index cd13c4fe..bbd02eb3 100644 --- a/capsule-server/src/routes/upload.rs +++ b/capsule-server/src/routes/upload.rs @@ -20,7 +20,7 @@ //! | create `403` | kept — album access, device authorization, on-behalf refusal | //! | create `409 duplicate_blob` | **restored, with `S-C22`'s structured `existing_asset`.** It was deleted while this crate had no asset index, because it must name the existing asset and answering from blob presence alone would tell one account what another holds. `S-C37` answers it honestly and owner-scoped | //! | create `413` | kept — the declared size past the deployment ceiling | -//! | create `426` | kept — the protocol handshake, now with the accepted window as problem extensions | +//! | create `426` | kept — the manifest envelope's `protocol_version` pin, refused by the envelope gate. The *header* handshake is no longer this surface's: [`crate::negotiation::ProtocolGate`] answers it before the handler runs, and the accepted window rides `X-Capsule-Protocol-Min`/`-Max` on every response | //! | create `500` | kept — a collaborator that could not answer, with `error.upload.unavailable` | //! | chunk `204` | kept — with the authoritative `X-Capsule-Offset` | //! | chunk `400` | kept, and now *coded*: missing offset, missing checksum, checksum mismatch, empty chunk, misalignment, size exceeded, and the two finalization failures each carry their own `error.upload.*` | @@ -32,7 +32,7 @@ //! | chunk `409 finalize_in_progress` | **deleted.** Losing the finalize claim is a normal race and the chunk that triggered it was still accepted, so it answers `204`. Telling a client its accepted chunk failed was the Salvo behaviour and it was wrong | //! | chunk `500` | kept — storage inconsistency (the stage disagreeing with the counter) and collaborator failure | //! | head `200` | kept — [`HeadReply::Progress`], carrying offset, declared length and state on headers, with `Cache-Control: no-store`. A `Reply` rather than a `NoContent`, because `200 with headers` and `204` are different answers | -//! | head `400` / `401` / `403` / `404` / `426` / `500` | kept; the `403` now covers the owner as well as the uploader, both of whom may look | +//! | head `400` / `401` / `403` / `404` / `426` / `500` | kept; the `403` now covers the owner as well as the uploader, both of whom may look. The `400` and `426` are the handshake's, declared by the gate rather than by this surface | //! | head `409` | **deleted as unreachable.** `HEAD` reports a state, it does not require one. It would have been declared for free by sharing a rejection type with `DELETE`, which is why they are two types | //! | delete `204` | kept | //! | delete `409` | kept — finalization is not interruptible, and a terminal session has nothing left to cancel | @@ -43,15 +43,19 @@ //! Every status above is produced by a test in `tests/upload.rs`, because //! `assert_declared_responses_covered` fails on any the document promises and none produced. //! -//! # Two places the protocol asks for a header this surface cannot send +//! # One place the protocol asks for a header this surface cannot send //! //! A Kynos `ApiError` renders an RFC 9457 problem and has **no seam for a response header**, so -//! the two headers the protocol's census puts on *rejections* — `X-Capsule-Offset` on a `409` -//! and `X-Capsule-Protocol-Min`/`-Max` on a `426` — ride as problem **extension members** -//! instead. The data a client needs to recover is there and is machine-readable; the spelling -//! is not the one the census names. The alternative was to render those two rejections as -//! plain-JSON `Reply` variants, which would have cost them their `error.*` code — a worse -//! trade, since the code is what a client switches on. Recorded rather than hidden. +//! the `X-Capsule-Offset` the protocol's census puts on a `409` rides as a problem **extension +//! member** instead. The data a client needs to recover is there and is machine-readable; the +//! spelling is not the one the census names. The alternative was to render the rejection as a +//! plain-JSON `Reply` variant, which would have cost it its `error.*` code — a worse trade, +//! since the code is what a client switches on. Recorded rather than hidden. +//! +//! The `X-Capsule-Protocol-Min`/`-Max` pair used to be the second such place. It is not any +//! more: issue #404 moved the handshake onto [`crate::negotiation`], whose advertising +//! interceptor sits outside every rejection and stamps the window on all of them. The seam an +//! `ApiError` lacks, an `Interceptor` has. //! //! # `409 duplicate_blob` refuses, and nothing yet adopts //! @@ -243,23 +247,13 @@ pub struct CreateHeaders { offset: Option, } -/// The `X-Capsule-Protocol` handshake header, on every upload request. -#[derive(HeaderParams)] -pub struct ProtocolHeader { - /// The protocol date the client speaks. - /// - /// Read as a string rather than a typed value so that a malformed one is *this* surface's - /// coded `400` rather than the framework's uncoded one. - #[header(rename = "X-Capsule-Protocol")] - protocol: Option, -} - /// The headers a chunk carries. +/// +/// The `X-Capsule-Protocol` handshake is not among them: [`crate::negotiation::ProtocolGate`] +/// reads and declares it for every operation on this surface, so a chunk handler only sees a +/// request the handshake already admitted. #[derive(HeaderParams)] pub struct ChunkHeaders { - /// The protocol date the client speaks. - #[header(rename = "X-Capsule-Protocol")] - protocol: Option, /// Where in the blob this chunk starts. #[header(rename = "X-Capsule-Offset")] offset: Option, @@ -347,19 +341,6 @@ pub enum CreateRejection { code: &'static str, }, - /// The request is not one this surface can read — a missing or unreadable handshake - /// header, most often. - #[error("the request is not a well-formed upload: {detail}")] - #[problem(status = 400, title = "Malformed request")] - MalformedRequest { - /// What was wrong, in English. Reaches the client as the problem's `detail`, via - /// `Display`, rather than as a second extension member saying the same thing. - detail: String, - /// The stable catalog code. - #[problem(extension)] - code: &'static str, - }, - /// The account is suspended (`S-C8`). /// /// Distinct from a quota refusal and from a permission one, deliberately: the three send a @@ -374,21 +355,16 @@ pub enum CreateRejection { code: &'static str, }, - /// Invariant 1: the protocol version is outside the window this server accepts. + /// Invariant 1: the manifest envelope pins a `protocol_version` outside the window this + /// server accepts. /// - /// The accepted range rides as problem extensions rather than as the - /// `X-Capsule-Protocol-Min`/`-Max` headers the protocol's census names: a Kynos `ApiError` - /// has no seam for a response header, and a client that cannot read the window cannot show - /// the actionable "update to keep uploading". Recorded as a deviation rather than dropped. - #[error("this server accepts protocol versions [{protocol_min}, {protocol_max}]")] + /// The header handshake never reaches here — the gate answered it — so this is the + /// *body's* pin, which an album carries for life. The accepted window is not restated as + /// extension members: it rides `X-Capsule-Protocol-Min`/`-Max` on this response like every + /// other, which is where the SDK reads it. + #[error("the envelope pins a protocol version this server does not accept")] #[problem(status = 426, title = "Protocol version unsupported")] ProtocolUnsupported { - /// The lowest version this server accepts. - #[problem(extension)] - protocol_min: String, - /// The highest version this server accepts. - #[problem(extension)] - protocol_max: String, /// The stable catalog code. #[problem(extension)] code: &'static str, @@ -471,9 +447,9 @@ pub enum CreateRejection { /// Why a chunk was not accepted, or the finalization it triggered did not commit. #[derive(Debug, thiserror::Error, ApiError)] pub enum ChunkRejection { - /// The request is not a well-formed chunk: a missing handshake header, a missing or - /// unreadable offset or checksum, an empty body, a misaligned chunk, a checksum that does - /// not match the bytes, or bytes past the declared size. + /// The request is not a well-formed chunk: a missing or unreadable offset or checksum, an + /// empty body, a misaligned chunk, a checksum that does not match the bytes, or bytes past + /// the declared size. #[error("{detail}")] #[problem(status = 400, title = "Invalid chunk")] Invalid { @@ -485,21 +461,6 @@ pub enum ChunkRejection { code: &'static str, }, - /// Invariant 1: the protocol version is outside the accepted window. - #[error("this server accepts protocol versions [{protocol_min}, {protocol_max}]")] - #[problem(status = 426, title = "Protocol version unsupported")] - ProtocolUnsupported { - /// The lowest version this server accepts. - #[problem(extension)] - protocol_min: String, - /// The highest version this server accepts. - #[problem(extension)] - protocol_max: String, - /// The stable catalog code. - #[problem(extension)] - code: &'static str, - }, - /// Only the uploader may append to a session. #[error("this session belongs to another uploader")] #[problem(status = 403, title = "Not the uploader")] @@ -618,30 +579,6 @@ pub enum ChunkRejection { /// afterwards. Two identical enums would be two places for the answers to drift apart. #[derive(Debug, thiserror::Error, ApiError)] pub enum SessionRejection { - /// The handshake header is missing or is not a protocol date. - #[error("X-Capsule-Protocol must be a YYYY-MM-DD date on every upload request")] - #[problem(status = 400, title = "Malformed request")] - MalformedRequest { - /// The stable catalog code. - #[problem(extension)] - code: &'static str, - }, - - /// Invariant 1: the protocol version is outside the accepted window. - #[error("this server accepts protocol versions [{protocol_min}, {protocol_max}]")] - #[problem(status = 426, title = "Protocol version unsupported")] - ProtocolUnsupported { - /// The lowest version this server accepts. - #[problem(extension)] - protocol_min: String, - /// The highest version this server accepts. - #[problem(extension)] - protocol_max: String, - /// The stable catalog code. - #[problem(extension)] - code: &'static str, - }, - /// The caller is neither the session's uploader nor the owner it files under. #[error("this session belongs to another account")] #[problem(status = 403, title = "Not this caller's session")] @@ -679,30 +616,6 @@ pub enum SessionRejection { /// exact `S-C28` defect this rebuild removes. #[derive(Debug, thiserror::Error, ApiError)] pub enum CancelRejection { - /// The handshake header is missing or is not a protocol date. - #[error("X-Capsule-Protocol must be a YYYY-MM-DD date on every upload request")] - #[problem(status = 400, title = "Malformed request")] - MalformedRequest { - /// The stable catalog code. - #[problem(extension)] - code: &'static str, - }, - - /// Invariant 1: the protocol version is outside the accepted window. - #[error("this server accepts protocol versions [{protocol_min}, {protocol_max}]")] - #[problem(status = 426, title = "Protocol version unsupported")] - ProtocolUnsupported { - /// The lowest version this server accepts. - #[problem(extension)] - protocol_min: String, - /// The highest version this server accepts. - #[problem(extension)] - protocol_max: String, - /// The stable catalog code. - #[problem(extension)] - code: &'static str, - }, - /// The caller is neither the session's uploader nor the owner it files under. #[error("this session belongs to another account")] #[problem(status = 403, title = "Not this caller's session")] @@ -763,16 +676,6 @@ impl CancelRejection { impl From for CancelRejection { fn from(rejection: SessionRejection) -> Self { match rejection { - SessionRejection::MalformedRequest { code } => Self::MalformedRequest { code }, - SessionRejection::ProtocolUnsupported { - protocol_min, - protocol_max, - code, - } => Self::ProtocolUnsupported { - protocol_min, - protocol_max, - code, - }, SessionRejection::Forbidden { code } => Self::Forbidden { code }, SessionRejection::SessionNotFound { code } => Self::SessionNotFound { code }, SessionRejection::Unavailable { code } => Self::Unavailable { code }, @@ -781,12 +684,6 @@ impl From for CancelRejection { } impl SessionRejection { - fn malformed_request() -> Self { - Self::MalformedRequest { - code: error_codes::UPLOAD_MALFORMED_REQUEST, - } - } - fn forbidden() -> Self { Self::Forbidden { code: error_codes::UPLOAD_FORBIDDEN, @@ -822,11 +719,8 @@ pub async fn create_upload( Inject(quota): Inject, Inject(moderation): Inject, Auth(credential): Auth, - Headers(handshake): Headers, Json(request): Json, ) -> Result, CreateRejection> { - handshake_ok(upload.policy(), handshake.protocol.as_deref())?; - let uploader = credential.user.clone(); // Account standing (`S-C8`), checked before anything is reserved. A suspension removes the @@ -1109,8 +1003,6 @@ pub async fn append_chunk( Headers(headers): Headers, body: ChunkBody, ) -> Result, ChunkRejection> { - chunk_handshake_ok(upload.policy(), headers.protocol.as_deref())?; - let id = UploadId::new(path.id); let record = upload .sessions() @@ -1228,15 +1120,8 @@ pub async fn head_upload( Inject(upload): Inject, Auth(credential): Auth, Path(path): Path, - Headers(handshake): Headers, ) -> Result, SessionRejection> { - let record = session_for( - &upload, - &path, - handshake.protocol.as_deref(), - &credential.user, - ) - .await?; + let record = session_for(&upload, &path, &credential.user).await?; Ok(WithHeaders::new( HeadReply::Progress, @@ -1262,15 +1147,8 @@ pub async fn cancel_upload( Inject(quota): Inject, Auth(credential): Auth, Path(path): Path, - Headers(handshake): Headers, ) -> Result { - let record = session_for( - &upload, - &path, - handshake.protocol.as_deref(), - &credential.user, - ) - .await?; + let record = session_for(&upload, &path, &credential.user).await?; if !record.status.is_active() || record.status == UploadSessionStatus::WaitingForProcessing { return Err(CancelRejection::not_active()); @@ -1315,23 +1193,8 @@ pub async fn cancel_upload( async fn session_for( upload: &UploadContext, path: &UploadPath, - presented: Option<&str>, caller: &UserId, ) -> Result { - match handshake(upload.policy(), presented) { - Handshake::Ok => {} - Handshake::Missing | Handshake::Malformed => { - return Err(SessionRejection::malformed_request()); - } - Handshake::OutOfRange => { - return Err(SessionRejection::ProtocolUnsupported { - protocol_min: upload.policy().protocol_min().to_owned(), - protocol_max: upload.policy().protocol_max().to_owned(), - code: error_codes::PROTOCOL_VERSION_UNSUPPORTED, - }); - } - } - let id = UploadId::new(path.id.clone()); let record = upload .sessions() @@ -1350,79 +1213,6 @@ async fn session_for( Ok(record) } -/// The handshake, for an operation that answers with [`CreateRejection`]. -fn handshake_ok( - policy: &crate::upload::UploadPolicy, - presented: Option<&str>, -) -> Result<(), CreateRejection> { - match handshake(policy, presented) { - Handshake::Ok => Ok(()), - Handshake::Missing => Err(CreateRejection::MalformedRequest { - detail: "X-Capsule-Protocol is required on every upload request".to_owned(), - code: error_codes::UPLOAD_MALFORMED_REQUEST, - }), - Handshake::Malformed => Err(CreateRejection::MalformedRequest { - detail: "X-Capsule-Protocol is not a YYYY-MM-DD date".to_owned(), - code: error_codes::UPLOAD_MALFORMED_REQUEST, - }), - Handshake::OutOfRange => Err(CreateRejection::ProtocolUnsupported { - protocol_min: policy.protocol_min().to_owned(), - protocol_max: policy.protocol_max().to_owned(), - code: error_codes::PROTOCOL_VERSION_UNSUPPORTED, - }), - } -} - -/// The handshake, for an operation that answers with [`ChunkRejection`]. -fn chunk_handshake_ok( - policy: &crate::upload::UploadPolicy, - presented: Option<&str>, -) -> Result<(), ChunkRejection> { - match handshake(policy, presented) { - Handshake::Ok => Ok(()), - Handshake::Missing | Handshake::Malformed => Err(ChunkRejection::Invalid { - detail: "X-Capsule-Protocol must be a YYYY-MM-DD date on every upload request" - .to_owned(), - code: error_codes::UPLOAD_MALFORMED_REQUEST, - }), - Handshake::OutOfRange => Err(ChunkRejection::ProtocolUnsupported { - protocol_min: policy.protocol_min().to_owned(), - protocol_max: policy.protocol_max().to_owned(), - code: error_codes::PROTOCOL_VERSION_UNSUPPORTED, - }), - } -} - -/// What the handshake header said. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Handshake { - /// Present and inside the accepted window. - Ok, - /// Absent. - Missing, - /// Present but not a `YYYY-MM-DD` date. - Malformed, - /// A date outside the accepted window. - OutOfRange, -} - -/// The one-shot compatibility gate: a client either speaks a version this server accepts, or -/// it does not upload. There is no negotiation and no degrade. -fn handshake(policy: &crate::upload::UploadPolicy, presented: Option<&str>) -> Handshake { - let Some(version) = presented else { - return Handshake::Missing; - }; - match capsule_core::validation::protocol_gate( - version, - policy.protocol_min(), - policy.protocol_max(), - ) { - Ok(()) => Handshake::Ok, - Err(capsule_core::validation::HandshakeReject::ProtocolOutOfRange) => Handshake::OutOfRange, - Err(_) => Handshake::Malformed, - } -} - /// The owner an upload is filed under. /// /// An on-behalf upload needs a verified relationship between two accounts, and the port that @@ -1530,8 +1320,6 @@ impl CreateRejection { "protocol_version is not a YYYY-MM-DD date", ), GateReject::ProtocolOutOfRange => Self::ProtocolUnsupported { - protocol_min: String::new(), - protocol_max: String::new(), code: error_codes::PROTOCOL_VERSION_UNSUPPORTED, }, GateReject::UnknownCryptoSuite => invalid( diff --git a/capsule-server/src/upload/policy.rs b/capsule-server/src/upload/policy.rs index ad59c865..4ade86de 100644 --- a/capsule-server/src/upload/policy.rs +++ b/capsule-server/src/upload/policy.rs @@ -9,9 +9,14 @@ //! - **Protocol surface** — the 4 KiB alignment, the `[4 KiB, 16 MiB]` chunk range, the //! offset semantics — is *not* here. It is fixed for a protocol version, so it lives as //! constants in [`super::chunk`] where no deployment can move it. -//! - **Server-tunable** — the accepted protocol window, the per-file ceiling, the closed -//! `content_type` enum, the timestamp-drift bound, and the suggested chunk-size tiers — is -//! here, because a self-hosted deployment legitimately sets them differently. +//! - **Server-tunable** — the accepted protocol window and the client-build cutoff it +//! advertises beside it, the per-file ceiling, the closed `content_type` enum, the +//! timestamp-drift bound, and the suggested chunk-size tiers — is here, because a self-hosted +//! deployment legitimately sets them differently. +//! +//! The protocol window is read by more than the upload surface: [`crate::negotiation`] +//! advertises it on every response and gates every covered operation against it, from this one +//! value, so the window a client is told and the window it is held to cannot be two numbers. //! //! Every value carries the Salvo deployment's default, so the rebuild starts from the //! behaviour clients already see rather than from a fresh set of numbers. @@ -44,6 +49,14 @@ pub const DEFAULT_PROTOCOL_MIN: &str = "2026-01-01"; /// Highest protocol date this server accepts (`X-Capsule-Protocol-Max`). pub const DEFAULT_PROTOCOL_MAX: &str = "2026-12-31"; +/// The semver client build below which this server stops answering +/// (`X-Capsule-Min-Client-Build`). +/// +/// `0.0.0` is "no cutoff announced": every build satisfies it. The header is advisory until a +/// path is hard-deprecated (threat-model/validation.md), and no path is, so nothing refuses on +/// it — but it is sent on every response so a client that reads it today reads a real value. +pub const DEFAULT_MIN_CLIENT_BUILD: &str = "0.0.0"; + /// Gross-drift sanity bound for the envelope timestamp, in days (invariant 8). pub const DEFAULT_DRIFT_DAYS: i64 = 30; @@ -64,6 +77,8 @@ pub struct UploadPolicy { protocol_min: String, /// Highest accepted protocol date (`YYYY-MM-DD`). protocol_max: String, + /// The advisory semver deprecation cutoff advertised on every response. + min_client_build: String, /// The closed `content_type` allow-list (invariant 5). content_types: Vec, /// Gross-drift sanity bound in days for the envelope timestamp (invariant 8). @@ -77,6 +92,7 @@ impl Default for UploadPolicy { Self { protocol_min: DEFAULT_PROTOCOL_MIN.to_owned(), protocol_max: DEFAULT_PROTOCOL_MAX.to_owned(), + min_client_build: DEFAULT_MIN_CLIENT_BUILD.to_owned(), content_types: DEFAULT_CONTENT_TYPES .iter() .map(|kind| (*kind).to_owned()) @@ -98,6 +114,11 @@ impl UploadPolicy { &self.protocol_max } + /// The semver client build below which this server stops answering. + pub fn min_client_build(&self) -> &str { + &self.min_client_build + } + /// The closed `content_type` allow-list, as the shared predicate wants it. pub fn content_types(&self) -> Vec<&str> { self.content_types.iter().map(String::as_str).collect() @@ -121,6 +142,13 @@ impl UploadPolicy { self } + /// Announce a client-build cutoff. + #[must_use] + pub fn with_min_client_build(mut self, build: impl Into) -> Self { + self.min_client_build = build.into(); + self + } + /// Replace the closed `content_type` enum. #[must_use] pub fn with_content_types(mut self, kinds: I) -> Self @@ -170,6 +198,11 @@ mod tests { ); } + #[test] + fn no_cutoff_is_the_build_every_client_satisfies() { + assert_eq!(UploadPolicy::default().min_client_build(), "0.0.0"); + } + #[test] fn the_allow_list_carries_the_opaque_blob_type() { // Metadata, provenance and backup blobs all declare `application/octet-stream`; an @@ -185,12 +218,14 @@ mod tests { fn a_deployment_can_narrow_every_tunable() { let policy = UploadPolicy::default() .with_protocol_window("2026-06-01", "2026-06-30") + .with_min_client_build("1.2.3") .with_content_types(["image/jpeg"]) .with_max_file_bytes(1024) .with_drift_days(1); assert_eq!(policy.protocol_min(), "2026-06-01"); assert_eq!(policy.protocol_max(), "2026-06-30"); + assert_eq!(policy.min_client_build(), "1.2.3"); assert_eq!(policy.content_types(), vec!["image/jpeg"]); assert_eq!(policy.max_file_bytes(), 1024); assert_eq!(policy.drift_days(), 1); diff --git a/capsule-server/tests/conformance.rs b/capsule-server/tests/conformance.rs index 90f0434c..68e42281 100644 --- a/capsule-server/tests/conformance.rs +++ b/capsule-server/tests/conformance.rs @@ -417,8 +417,9 @@ async fn every_declared_response_is_exercised() { .await .assert_status(StatusCode::OK); - // POST 400 / 415 / 422 / 426 / 401 / 403 / 500. + // POST 400 / 415 / 422 / 426 / 401 / 403 / 500. The 400 is the gate's: no handshake at all. client + .raw() .post("/v1/upload") .header("authorization", &bearer) .json(&create_request(&fixture.clock, &whole, "original")) @@ -479,6 +480,7 @@ async fn every_declared_response_is_exercised() { .await .assert_status(StatusCode::OK); client + .raw() .head(&session) .header("authorization", &bearer) .send() @@ -612,6 +614,7 @@ async fn every_declared_response_is_exercised() { .await .assert_status(StatusCode::UPGRADE_REQUIRED); client + .raw() .delete(&session) .header("authorization", &bearer) .send() @@ -2496,6 +2499,296 @@ async fn every_declared_response_is_exercised() { client.assert_declared_responses_covered(); } +// =========================================================================================== +// The protocol handshake census (issue #404) +// =========================================================================================== + +/// The three response headers the design puts on **every** response. +const WINDOW_HEADERS: [&str; 3] = [ + "X-Capsule-Protocol-Min", + "X-Capsule-Protocol-Max", + "X-Capsule-Min-Client-Build", +]; + +/// The three request headers the gate reads. +const HANDSHAKE_HEADERS: [&str; 3] = [ + "X-Capsule-Protocol", + "X-Capsule-Crypto-Suite", + "X-Capsule-Sidecar-Schema", +]; + +/// The operations the protocol gate covers, by method and path template. +/// +/// **Pinned on purpose.** The gate is a Kynos `Group` in `lib.rs::router`, so an operation joins +/// or leaves it by a mount call moving — and a mount call moving must fail this test, because +/// widening the gate refuses every client that does not send the handshake and narrowing it +/// silently drops a fail-closed rule. Today the set is the upload session's four operations, +/// the ones that enforced the handshake per route before the gate existed; the design's target +/// is every operation except the ten `lib.rs` names as exempt, and the SDK's transports have +/// to send the handshake before that widening lands. +const GATED: &[(&str, &str)] = &[ + ("POST", "/v1/upload"), + ("PATCH", "/v1/upload/{id}"), + ("HEAD", "/v1/upload/{id}"), + ("DELETE", "/v1/upload/{id}"), +]; + +/// The operations the design exempts from the gate, whatever else is gated. +/// +/// Asserted **never** to declare the handshake: `/v1/version` is the reachability probe a +/// client hits before it knows the window; the `/.well-known/capsule/*` records are public +/// discovery; the `/s/{opaque_id}*` reads must answer an indistinguishable `404` +/// (share-links.md), which a `426` would turn into a probing oracle; the `/d/{opaque_id}*` +/// guest deposits have their protocol pinned at link issuance (web-upload.md). +const EXEMPT: &[(&str, &str)] = &[ + ("GET", "/v1/version"), + ("GET", "/.well-known/capsule/attestation-keys"), + ("GET", "/.well-known/capsule/server-info"), + ("GET", "/.well-known/capsule/deprecation"), + ("GET", "/.well-known/capsule/revoked-jti"), + ("GET", "/s/{opaque_id}"), + ("GET", "/s/{opaque_id}/wrapped-secret"), + ("GET", "/s/{opaque_id}/blob/{hash}"), + ("POST", "/d/{opaque_id}"), + ("PATCH", "/d/{opaque_id}/{upload_id}"), +]; + +/// The HTTP methods a path item may carry, as the document spells them. +const METHODS: [&str; 9] = [ + "get", "put", "post", "delete", "options", "head", "patch", "trace", "query", +]; + +/// Every `(METHOD, template, operation)` in the emitted document. +fn operations(document: &serde_json::Value) -> Vec<(String, String, serde_json::Value)> { + let mut found = Vec::new(); + for (path, item) in document["paths"].as_object().expect("paths") { + for (method, operation) in item.as_object().expect("a path item") { + if METHODS.contains(&method.as_str()) { + found.push((method.to_uppercase(), path.clone(), operation.clone())); + } + } + } + assert!(!found.is_empty(), "the document describes no operation"); + found +} + +/// A template with every `{variable}` replaced by a placeholder, so it can be requested. +fn concrete(template: &str) -> String { + let mut path = String::with_capacity(template.len()); + let mut rest = template; + while let Some(open) = rest.find('{') { + path.push_str(&rest[..open]); + path.push_str("anything"); + let close = rest[open..].find('}').expect("a balanced template") + open; + rest = &rest[close + 1..]; + } + path.push_str(rest); + path +} + +/// Whether `operation` declares a header parameter called `name`. +fn declares_header(operation: &serde_json::Value, name: &str) -> Option { + operation["parameters"] + .as_array() + .into_iter() + .flatten() + .find(|parameter| { + parameter["in"] == "header" + && parameter["name"] + .as_str() + .is_some_and(|declared| declared.eq_ignore_ascii_case(name)) + }) + .cloned() +} + +/// Every response of every operation declares the three window headers, required. +/// +/// Over the emitted document rather than per route, because the property is the router's: the +/// advertising interceptor is mounted once, and Kynos describes an interceptor's headers on +/// success responses only, so the walk in `capsule_server::openapi` is what puts them on the +/// `426` a client needs them on most. This is the test that fails if either half goes missing. +#[test] +fn every_response_of_every_operation_declares_the_protocol_window() { + let document = capsule_server::openapi().expect("router describes itself"); + let json = serde_json::to_value(&document).expect("a document serializes"); + + let mut responses = 0_usize; + for (method, path, operation) in operations(&json) { + for (status, response) in operation["responses"].as_object().expect("responses") { + for name in WINDOW_HEADERS { + let header = &response["headers"][name]; + assert!( + header.is_object(), + "{method} {path} -> {status} does not declare {name}" + ); + assert_eq!( + header["required"], true, + "{method} {path} -> {status} declares {name} as optional" + ); + } + responses += 1; + } + } + assert!(responses > 100, "only {responses} responses were walked"); +} + +/// The operations declaring the handshake are exactly [`GATED`], and none of [`EXEMPT`]. +#[test] +fn the_handshake_is_declared_on_exactly_the_gated_operations() { + let document = capsule_server::openapi().expect("router describes itself"); + let json = serde_json::to_value(&document).expect("a document serializes"); + + let mut gated: Vec<(String, String)> = Vec::new(); + for (method, path, operation) in operations(&json) { + let declared: Vec<&str> = HANDSHAKE_HEADERS + .into_iter() + .filter(|name| declares_header(&operation, name).is_some()) + .collect(); + if declared.is_empty() { + continue; + } + assert_eq!( + declared, HANDSHAKE_HEADERS, + "{method} {path} declares part of the handshake, which no interceptor does" + ); + let protocol = declares_header(&operation, "X-Capsule-Protocol").expect("declared"); + assert_eq!( + protocol["required"], true, + "{method} {path} declares X-Capsule-Protocol as optional and refuses without it" + ); + for status in ["400", "426"] { + assert!( + operation["responses"][status].is_object(), + "{method} {path} is gated and does not declare the gate's {status}" + ); + } + gated.push((method, path)); + } + gated.sort(); + + let mut expected: Vec<(String, String)> = GATED + .iter() + .map(|(method, path)| ((*method).to_owned(), (*path).to_owned())) + .collect(); + expected.sort(); + assert_eq!( + gated, expected, + "the gated set moved; `lib.rs::router` and this pin change together" + ); + + let all: Vec<(String, String)> = operations(&json) + .into_iter() + .map(|(method, path, _)| (method, path)) + .collect(); + for (method, path) in EXEMPT { + assert!( + all.contains(&((*method).to_owned(), (*path).to_owned())), + "{method} {path} is named exempt and is not in the document" + ); + assert!( + !gated.contains(&((*method).to_owned(), (*path).to_owned())), + "{method} {path} is exempt by design and is gated" + ); + } +} + +/// On the wire: every operation answers with the window, and the gated ones refuse on it. +/// +/// Driven by the document rather than a list, so an operation added tomorrow is walked +/// tomorrow. Path variables are filled with a placeholder; the gate runs before the path, +/// the credential or the body is looked at, so a `426` needs none of them to be right — and +/// an exempt operation answering anything *but* `426` to an ancient protocol is the exemption +/// observed rather than assumed. +#[tokio::test] +async fn the_protocol_window_rides_every_response_on_the_wire() { + use capsule_server::upload::policy::{ + DEFAULT_MIN_CLIENT_BUILD, DEFAULT_PROTOCOL_MAX, DEFAULT_PROTOCOL_MIN, + }; + + let fixture = Fixture::working(); + let document = capsule_server::openapi().expect("router describes itself"); + let json = serde_json::to_value(&document).expect("a document serializes"); + + let mut walked = 0_usize; + for (method, template, operation) in operations(&json) { + let path = concrete(&template); + let gated = declares_header(&operation, "X-Capsule-Protocol").is_some(); + let verb = kynos::http::Method::from_bytes(method.as_bytes()).expect("a method"); + + // Ancient, and therefore outside any window this server will ever accept. + let ancient = fixture + .client + .method(verb.clone(), &path) + .header("x-capsule-protocol", "2000-01-01") + .send() + .await; + for (name, expected) in [ + ("x-capsule-protocol-min", DEFAULT_PROTOCOL_MIN), + ("x-capsule-protocol-max", DEFAULT_PROTOCOL_MAX), + ("x-capsule-min-client-build", DEFAULT_MIN_CLIENT_BUILD), + ] { + assert_eq!( + ancient.header(name), + Some(expected), + "{method} {template} answered {} without {name}", + ancient.status() + ); + } + + if !gated { + assert_ne!( + ancient.status(), + StatusCode::UPGRADE_REQUIRED, + "{method} {template} is not gated and refused on the protocol" + ); + walked += 1; + continue; + } + + ancient.assert_status(StatusCode::UPGRADE_REQUIRED); + if method != "HEAD" { + let body: serde_json::Value = ancient.json(); + assert_eq!( + body["code"], "error.protocol.version_unsupported", + "{method} {template}: {body}" + ); + } + + // Each malformed spelling is the gate's coded 400, and each leaves with the window. + for (name, value) in [ + ("x-capsule-protocol", "yesterday"), + ("x-capsule-crypto-suite", "9999"), + ("x-capsule-crypto-suite", "one"), + ("x-capsule-sidecar-schema", "9"), + ] { + let refused = fixture + .client + .method(verb.clone(), &path) + .header(name, value) + .send() + .await; + refused.assert_status(StatusCode::BAD_REQUEST); + refused.assert_header("x-capsule-protocol-min", DEFAULT_PROTOCOL_MIN); + if method != "HEAD" { + let body: serde_json::Value = refused.json(); + assert_eq!( + body["code"], "error.request.malformed", + "{method} {template} with {name}: {value}: {body}" + ); + } + } + fixture + .client + .raw() + .method(verb, &path) + .send() + .await + .assert_status(StatusCode::BAD_REQUEST); + walked += 1; + } + assert_eq!(walked, operations(&json).len()); +} + /// The router builds and describes itself. /// /// `openapi()` is the only path from this code to a description — there is no document to @@ -2587,7 +2880,7 @@ fn the_document_declares_openapi_32() { /// On an account of its own, for the reason [`profile_block`] uses one: switching a second /// factor on for the fixture's shared account would make every later `POST /v1/auth/login` in the /// walk answer `202`. -async fn totp_block(client: &kynos::test::TestClient, fixture: &Fixture) { +async fn totp_block(client: &support::Client, fixture: &Fixture) { const OWN_EMAIL: &str = "totp-walk@example.test"; const OWN_PASSWORD: &str = "correct horse battery staple"; @@ -2865,7 +3158,7 @@ async fn totp_block(client: &kynos::test::TestClient, fixtu /// password change on this surface closes every other session of the account it acts on, so /// running it against the shared account would sign the rest of the walk out — and the walk /// would then be testing the bearer scheme instead of the operations it had reached. -async fn profile_block(client: &kynos::test::TestClient, fixture: &Fixture) { +async fn profile_block(client: &support::Client, fixture: &Fixture) { const OWN_EMAIL: &str = "profile-walk@example.test"; const OWN_PASSWORD: &str = "correct horse battery staple"; const OWN_NEW_PASSWORD: &str = "a different correct horse"; @@ -3110,7 +3403,7 @@ async fn profile_block(client: &kynos::test::TestClient, fi /// Extracted so [`every_declared_response_is_exercised`] does not build one generator larger /// than a thread stack. Same client, so the recorder still sees these. async fn drops_block( - client: &kynos::test::TestClient, + client: &support::Client, fixture: &Fixture, bearer: &str, refresh_token: &str, @@ -3570,7 +3863,7 @@ async fn drops_block( /// Its own function so the walk's generator stays inside the test thread's stack; see the drops /// block for the same note. async fn upgrade_block( - client: &kynos::test::TestClient, + client: &support::Client, fixture: &Fixture, bearer: &str, refresh_token: &str, diff --git a/capsule-server/tests/support/mod.rs b/capsule-server/tests/support/mod.rs index d5aba864..c18c9341 100644 --- a/capsule-server/tests/support/mod.rs +++ b/capsule-server/tests/support/mod.rs @@ -2249,13 +2249,83 @@ impl AssetIndex for SwitchableIndex { } } +/// The fixture's client: Kynos's in-process `TestClient`, sending the protocol handshake. +/// +/// Every request a real client makes carries `X-Capsule-Protocol` — the SDK sets it as a +/// default header on its transport — so the fixture does the same, once, here, rather than at +/// every one of the suite's several hundred request sites. A case about the handshake itself +/// overrides the header (a later `header` call replaces an earlier one) or reaches for +/// [`Client::raw`] to send none at all; the two are the only ways a request leaves without it, +/// which is what keeps "the gate refused this" a deliberate assertion rather than a fixture +/// accident. +/// +/// Deliberately not `Deref` to the inner client: a function taking `&TestClient` would +/// then accept this and silently drive the router without the handshake. +pub(crate) struct Client { + inner: TestClient, +} + +impl Client { + pub(crate) fn new(inner: TestClient) -> Self { + Self { inner } + } + + /// The bare client, for a request that must **not** carry the handshake. + pub(crate) fn raw(&self) -> &TestClient { + &self.inner + } + + fn handshake<'a>(request: TestRequest<'a, App>) -> TestRequest<'a, App> { + request.header("x-capsule-protocol", PROTOCOL_VERSION) + } + + pub(crate) fn get(&self, path: &str) -> TestRequest<'_, App> { + Self::handshake(self.inner.get(path)) + } + + pub(crate) fn post(&self, path: &str) -> TestRequest<'_, App> { + Self::handshake(self.inner.post(path)) + } + + pub(crate) fn put(&self, path: &str) -> TestRequest<'_, App> { + Self::handshake(self.inner.put(path)) + } + + pub(crate) fn patch(&self, path: &str) -> TestRequest<'_, App> { + Self::handshake(self.inner.patch(path)) + } + + pub(crate) fn delete(&self, path: &str) -> TestRequest<'_, App> { + Self::handshake(self.inner.delete(path)) + } + + pub(crate) fn head(&self, path: &str) -> TestRequest<'_, App> { + Self::handshake(self.inner.head(path)) + } + + /// A request with any method, for a walk driven by the document rather than by a verb. + pub(crate) fn method(&self, method: kynos::http::Method, path: &str) -> TestRequest<'_, App> { + Self::handshake(self.inner.method(method, path)) + } + + /// Every response this client observed was one the description predicts. + pub(crate) fn assert_conformance(&self) { + self.inner.assert_conformance(); + } + + /// Every response the description predicts was produced through this client. + pub(crate) fn assert_declared_responses_covered(&self) { + self.inner.assert_declared_responses_covered(); + } +} + /// A built server, plus handles on everything behind it. /// /// The handles matter: an assertion about a session is made against the store the server just /// wrote to, not against a second reading of the response body. pub(crate) struct Fixture { - /// The in-process client. No socket, no port, no runtime flavour. - pub(crate) client: TestClient, + /// The in-process client, sending the handshake on every request. No socket, no port. + pub(crate) client: Client, /// The context the client drives, for the one case that has to serve it on a socket. app: App, /// The store the server opened its sessions in. @@ -2431,9 +2501,9 @@ impl Fixture { }); Self { - client: TestClient::new( + client: Client::new(TestClient::new( capsule_server::service(app.clone()).expect("the router builds"), - ), + )), app, sessions, accounts, @@ -2653,7 +2723,6 @@ impl Fixture { .client .post("/v1/upload") .header("authorization", bearer) - .header("x-capsule-protocol", PROTOCOL_VERSION) .json(request) .send() .await @@ -2664,8 +2733,8 @@ impl Fixture { /// A well-formed `PATCH` of `payload` at `offset`. /// - /// Every header the protocol requires is set, so a test that wants one wrong overrides it - /// — the later `header` call wins. + /// Every header the protocol requires is set — the handshake by the client, the rest here — + /// so a test that wants one wrong overrides it: the later `header` call wins. pub(crate) fn chunk<'a>( &'a self, id: &str, @@ -2676,7 +2745,6 @@ impl Fixture { self.client .patch(&format!("/v1/upload/{id}")) .header("authorization", bearer) - .header("x-capsule-protocol", PROTOCOL_VERSION) .header("x-capsule-offset", &offset.to_string()) .header("x-capsule-checksum", &checksum(payload)) .body("application/octet-stream", payload.to_vec()) diff --git a/capsule-server/tests/upload.rs b/capsule-server/tests/upload.rs index 3eb2211d..52ea106e 100644 --- a/capsule-server/tests/upload.rs +++ b/capsule-server/tests/upload.rs @@ -329,9 +329,11 @@ async fn the_handshake_gates_every_upload_request() { let (_, _, whole) = blob(); let id = fixture.open_session(&whole, "original", &bearer).await; - // Missing: a coded 400, on every operation. + // Missing: a coded 400, on every operation — the gate's, not this surface's, which is why + // the code is the request-level one. `raw()` is the only way the fixture sends no handshake. let missing = fixture .client + .raw() .post("/v1/upload") .header("authorization", &bearer) .json(&create_request(&fixture.clock, &whole, "original")) @@ -340,18 +342,21 @@ async fn the_handshake_gates_every_upload_request() { missing.assert_status(StatusCode::BAD_REQUEST); assert_eq!( code(&missing.json::()), - "error.upload.malformed_request" + "error.request.malformed" ); let head = fixture .client + .raw() .head(&format!("/v1/upload/{id}")) .header("authorization", &bearer) .send() .await; head.assert_status(StatusCode::BAD_REQUEST); - // Out of the window: `426`, carrying the window a client can act on. + // Out of the window: `426`, carrying the window a client can act on — **on the headers**, + // which is where `capsule-sdk/src/upload.rs` reads it (issue #404). The body carries the + // code and no second spelling of the window. let refused = fixture .client .post("/v1/upload") @@ -361,10 +366,24 @@ async fn the_handshake_gates_every_upload_request() { .send() .await; refused.assert_status(StatusCode::UPGRADE_REQUIRED); + refused.assert_header("x-capsule-protocol-min", "2026-01-01"); + refused.assert_header("x-capsule-protocol-max", "2026-12-31"); + refused.assert_header("x-capsule-min-client-build", "0.0.0"); let body: serde_json::Value = refused.json(); assert_eq!(code(&body), "error.protocol.version_unsupported"); - assert_eq!(body["protocol_min"], "2026-01-01"); - assert_eq!(body["protocol_max"], "2026-12-31"); + assert!( + body.get("protocol_min").is_none() && body.get("protocol_max").is_none(), + "the window has one spelling, the headers: {body}" + ); + + // The gate runs before authentication: a client learns it must update without a token. + fixture + .client + .head(&format!("/v1/upload/{id}")) + .header("x-capsule-protocol", "2020-01-01") + .send() + .await + .assert_status(StatusCode::UPGRADE_REQUIRED); } // =========================================================================================== From bdf11286c854cd9262061c3d4b7a1e90ce8dd8fa Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 04:52:14 -0400 Subject: [PATCH 106/243] fix(server): enforce and advertise the configured protocol window boot::assemble built the upload policy from UploadPolicy::default() regardless of PROTOCOL_MIN and PROTOCOL_MAX, so a deployment that narrowed its window published one range on /.well-known/capsule/server-info and enforced another on POST /v1/upload. The policy is now built from the configured window, which is also what the negotiation interceptors advertise and refuse against. The new boot test reads both back through the surface. Refs #404 --- capsule-server/src/boot.rs | 61 +++++++++++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/capsule-server/src/boot.rs b/capsule-server/src/boot.rs index 9552433e..a362f9f9 100644 --- a/capsule-server/src/boot.rs +++ b/capsule-server/src/boot.rs @@ -428,7 +428,11 @@ fn memory(config: &Config, stores: Stores) -> Result { index.clone(), authority.clone(), clock.clone(), - UploadPolicy::default(), + // The window the operator configured, not the crate default: this policy is what + // the handshake enforces and what every response advertises (`negotiation`), and the + // discovery record above publishes the same two values. One window, three readers. + UploadPolicy::default() + .with_protocol_window(config.protocol_min.clone(), config.protocol_max.clone()), ), sync: SyncContext::new( index.clone(), @@ -696,6 +700,61 @@ mod tests { assert_eq!(body["api_base_url"], config.api_base_url); } + /// The window the handshake enforces and advertises is the configured one (issue #404). + /// + /// Before this the upload policy was `UploadPolicy::default()` regardless of `PROTOCOL_MIN` + /// and `PROTOCOL_MAX`, so a deployment that narrowed its window published one range on + /// `/.well-known/capsule/server-info` and enforced another on `POST /v1/upload`. + #[tokio::test] + async fn the_enforced_and_advertised_window_is_the_configured_one() { + let root = tempfile::tempdir().expect("a scratch directory"); + let config = memory_config(root.path()); + let assembled = assemble(&config).await.expect("it assembles"); + let client = kynos::test::TestClient::new(assembled.service().expect("the router builds")); + + // Every response advertises the window, an exempt read included. + let response = client + .get("/v1/version") + .header("accept", "application/json") + .send() + .await; + response.assert_status(kynos::http::StatusCode::OK); + assert_eq!( + response.header("x-capsule-protocol-min"), + Some(config.protocol_min.as_str()) + ); + assert_eq!( + response.header("x-capsule-protocol-max"), + Some(config.protocol_max.as_str()) + ); + assert_eq!( + response.header("x-capsule-min-client-build"), + Some(crate::upload::policy::DEFAULT_MIN_CLIENT_BUILD) + ); + + // And the gate holds a client to the same window: a version one day below the + // configured minimum is refused before authentication is even looked at. + let below = format!( + "{}", + config + .protocol_min + .parse::() + .expect("the configured minimum is a date") + .yesterday() + .expect("there is a day before it") + ); + let refused = client + .head("/v1/upload/anything") + .header("x-capsule-protocol", &below) + .send() + .await; + refused.assert_status(kynos::http::StatusCode::UPGRADE_REQUIRED); + assert_eq!( + refused.header("x-capsule-protocol-min"), + Some(config.protocol_min.as_str()) + ); + } + #[tokio::test] async fn an_account_can_be_registered_and_signed_in_to() { // The whole point of the amended deliverable boundary: `mise run serve-memory` is a From c739eccc28f9579e49047cf615a2b8260822df52 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 04:52:14 -0400 Subject: [PATCH 107/243] feat(sdk): send the protocol handshake as default headers Every gated operation requires X-Capsule-Protocol and refuses without it. The shared reqwest client behind the generated REST client now carries that header and X-Capsule-Crypto-Suite as defaults, from the build's own constants, so the generated operations and the hand-written paths over the same transport send them with no per-call argument. protocol_headers() is public so the SDK's other transports can carry the same handshake from the same source. Refs #404 --- capsule-sdk/src/client.rs | 79 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/capsule-sdk/src/client.rs b/capsule-sdk/src/client.rs index ca9f6971..e0fb8993 100644 --- a/capsule-sdk/src/client.rs +++ b/capsule-sdk/src/client.rs @@ -19,6 +19,8 @@ use std::ops::Deref; use std::sync::Arc; +use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; + use crate::auth::Session; use crate::rest::{self, Client, Credential}; @@ -126,10 +128,41 @@ fn build_client(base_url: &str, session: Session) -> Result Ok(client) } +/// The request half of the protocol handshake, as default headers for a `reqwest` client. +/// +/// Every route the server gates requires `X-Capsule-Protocol` and refuses without it (issue +/// #404: `capsule-server/src/negotiation.rs`), and `X-Capsule-Crypto-Suite` names the suite +/// this build seals under. Both are constants of the build, so they belong on the transport +/// once rather than on every call: a `reqwest` default header rides every request the client +/// sends, the spargen-generated operations included, with no per-operation argument for a +/// value no caller chooses. A header set explicitly on a request still wins, which is how the +/// hand-written upload path keeps pinning a per-transport protocol date. +/// +/// `X-Capsule-Sidecar-Schema` is deliberately absent: the design scopes it to metadata updates, +/// and a schema number is a property of one write rather than of the transport. +/// +/// Public so a transport this module does not build — the auth client's, the sync consumer's, +/// a platform app's own — can carry the same handshake from the same source. +#[must_use] +pub fn protocol_headers() -> HeaderMap { + let mut headers = HeaderMap::with_capacity(2); + headers.insert( + HeaderName::from_static("x-capsule-protocol"), + HeaderValue::from_static(capsule_core::crypto::primitives::PROTOCOL_VERSION), + ); + headers.insert( + HeaderName::from_static("x-capsule-crypto-suite"), + HeaderValue::from(capsule_core::crypto::primitives::CRYPTO_SUITE_ID), + ); + headers +} + /// The generated client's transport: rustls only (the SDK's `reqwest` has no default features -/// and only `rustls-tls`), matching the rest of the SDK's network stack. +/// and only `rustls-tls`), matching the rest of the SDK's network stack — and carrying the +/// protocol handshake on every request it sends. fn reqwest_client() -> reqwest::Client { reqwest::Client::builder() + .default_headers(protocol_headers()) .build() .expect("a default rustls reqwest client is always constructible") } @@ -154,6 +187,8 @@ mod tests { struct Recorded { path: String, authorization: Option, + protocol: Option, + crypto_suite: Option, } struct MockResponse { @@ -248,6 +283,8 @@ mod tests { requests.lock().unwrap().push(Recorded { path: path.clone(), authorization: headers.get("authorization").cloned(), + protocol: headers.get("x-capsule-protocol").cloned(), + crypto_suite: headers.get("x-capsule-crypto-suite").cloned(), }); let response = handler(path).await; @@ -320,6 +357,46 @@ mod tests { assert_eq!(version.version.as_str(), "9.9.9"); } + /// Every request the typed client sends carries the protocol handshake (issue #404) — + /// proving the transport-level default reaches the wire through the generated operation + /// with no argument at the call site, on an operation the server does not even gate. + #[tokio::test] + async fn every_request_carries_the_protocol_handshake() { + let handler: Handler = Arc::new(|_| { + Box::pin(async move { + MockResponse { + status: 200, + body: r#"{"name":"capsule-api","version":"9.9.9"}"#.to_string(), + } + }) + }); + let server = start_mock(handler).await; + let session = session_with(&server.base_url, "access-1", "refresh-1", far_future()); + let client = AuthenticatedClient::new(&server.base_url, session).unwrap(); + + client.get_version().await.unwrap(); + + let requests = server.requests.lock().unwrap(); + let version = requests + .iter() + .find(|r| r.path == "/v1/version") + .expect("version endpoint was hit"); + assert_eq!( + version.protocol.as_deref(), + Some(capsule_core::crypto::primitives::PROTOCOL_VERSION), + "the protocol date this build speaks must ride every request" + ); + assert_eq!( + version.crypto_suite.as_deref(), + Some( + capsule_core::crypto::primitives::CRYPTO_SUITE_ID + .to_string() + .as_str() + ), + "and so must the suite it seals under" + ); + } + /// An authenticated operation carries the session's access token as a bearer header — /// proving the token-provider seam attaches the credential the schema's `security` /// requirement names. From b4881d30ff13ca30efecd6b0560e904107dcb1fd Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 04:52:14 -0400 Subject: [PATCH 108/243] docs(design): record the negotiation carriage and the exempt operations Refs #404 --- .../src/content/docs/design/api-surfaces.md | 41 ++++++++++++++++--- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/capsule-docs/src/content/docs/design/api-surfaces.md b/capsule-docs/src/content/docs/design/api-surfaces.md index b3d664da..bf626370 100644 --- a/capsule-docs/src/content/docs/design/api-surfaces.md +++ b/capsule-docs/src/content/docs/design/api-surfaces.md @@ -123,12 +123,41 @@ Every public route applies the same headers: | Header | Direction | | --- | --- | -| `X-Capsule-Protocol` | request | -| `X-Capsule-Crypto-Suite` | request for writes | -| `X-Capsule-Sidecar-Schema` | request | -| `X-Capsule-Protocol-Min` | response | -| `X-Capsule-Protocol-Max` | response | -| `X-Capsule-Min-Client-Build` | response | +| `X-Capsule-Protocol` | request, required on every gated route | +| `X-Capsule-Crypto-Suite` | request for writes; validated when present | +| `X-Capsule-Sidecar-Schema` | request on metadata updates; validated when present | +| `X-Capsule-Protocol-Min` | response, on every response of every operation | +| `X-Capsule-Protocol-Max` | response, on every response of every operation | +| `X-Capsule-Min-Client-Build` | response, on every response of every operation; advisory (`0.0.0` = no cutoff) | + +The carriage is two Kynos interceptors in `capsule-server/src/negotiation.rs`, and the split +is the point: `Negotiation` is mounted on the whole router, outside everything that can refuse, +so the three response headers ride a `413`, a `401` and a `426` exactly as they ride a `200` +(an unrouted `404`/`405` is the router's own and carries none — Kynos runs interceptors per +operation, after routing); +`ProtocolGate` is mounted on a `Group`, so an operation is gated by being mounted inside it +and exempt by being mounted outside. Both read one protocol window — the upload policy's, +built from `PROTOCOL_MIN`/`PROTOCOL_MAX` at boot — so the window a client is told and the +window it is held to cannot be two numbers. A `426` carries the window on the headers and +the stable `error.protocol.version_unsupported` code in the body; nothing restates the window +as a body member. + +**Exempt from the request gate** (and still carrying the response headers), ten operations: + +- `GET /v1/version` — the reachability probe a client hits before it knows the window. +- `GET /.well-known/capsule/attestation-keys`, `GET /.well-known/capsule/server-info`, + `GET /.well-known/capsule/deprecation`, `GET /.well-known/capsule/revoked-jti` — public + discovery, read before any handshake. +- `GET /s/{opaque_id}`, `GET /s/{opaque_id}/wrapped-secret`, `GET /s/{opaque_id}/blob/{hash}` — + [Share Links](/design/share-links/) requires an indistinguishable `404` there, and a `426` + would be a probing oracle. +- `POST /d/{opaque_id}`, `PATCH /d/{opaque_id}/{upload_id}` — the link record pins + `protocol_version` and `crypto_suite_id` at issuance ([Web Upload](/design/web-upload/)), so a + browser guest has nothing to assert. + +`capsule-server/tests/conformance.rs` pins both the gated set and this exempt set against the +emitted document, and walks every operation on the wire, so a route cannot join or leave the +gate by accident. Credentials use `Authorization: Bearer`. Session access tokens and federation capabilities are different token types verified by their owning modules, even though both use the standard HTTP From 42d21ee524a2e50a46976c40e4a24b8ae1b33d85 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 05:01:51 -0400 Subject: [PATCH 109/243] docs(core): say which failures are Sign and which are Encode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both `# Errors` blocks in `media::derivative` still routed signing and sealing failures to `MediaError::Encode`. That has been untrue since the `Sign` variant was introduced: `sign_derivative` returns `Sign` for a signer refusal and for a manifest that will not serialise, and the only `DerivativeSealer` implementation returns `Sign` both when the encryption refuses and when it cannot draw an unused nonce prefix inside its retry budget. The distinction is the contract, not bookkeeping, which is why a stale doc here is worth a commit of its own: the import path **degrades** an `Encode` or `ZeroDimension` to "this asset has no thumbnail" and commits the original anyway, and **propagates** `Sign`, because a workspace that cannot author a signed record is broken in a way a missing derivative is not. A reader following the old text would have concluded the two were interchangeable. The trait block also drops its reference to "a drawn prefix that collides with the one being replaced": `replaces` is always `None` for a derivative, which supersedes nothing. Non-reuse is enforced against the set of prefixes already spent on that `file_id` instead. Documentation only; no behaviour change. Recorded because it is the third instance on this branch: these two edits were claimed in `5a486852`'s message and never landed. A batch script computed several replacements against one file and wrote once at the end, an `assert` on a later pattern aborted it, and every earlier in-memory edit to that file was discarded while an earlier *file*'s write had already succeeded — so the per-edit progress output looked like success. Each edit here was written and read back separately. --- capsule-core/src/media/derivative.rs | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/capsule-core/src/media/derivative.rs b/capsule-core/src/media/derivative.rs index c56797ff..71272e7c 100644 --- a/capsule-core/src/media/derivative.rs +++ b/capsule-core/src/media/derivative.rs @@ -151,8 +151,17 @@ pub trait DerivativeSealer { /// exactly as they do for the original. /// /// # Errors - /// [`MediaError::Encode`] when the encryption refuses (a drawn prefix that collides with - /// the one being replaced). + /// [`MediaError::Sign`], and only that variant. Two things can go wrong: the underlying + /// encryption refuses, or no unused nonce prefix can be drawn for this `file_id` inside the + /// implementation's retry budget. Both are **workspace** faults rather than pixel ones — a + /// broken signer or a broken CSPRNG, not a photo this build cannot render — so the import + /// path propagates them instead of degrading to a missing thumbnail. An implementation must + /// therefore not report either as [`MediaError::Encode`], which means a codec refused a + /// frame and nothing else. + /// + /// `replaces` plays no part here: a derivative supersedes nothing, so the production + /// implementation passes `None` and enforces non-reuse against the set of prefixes already + /// spent on this `file_id` instead. fn seal(&self, plaintext: &[u8]) -> Result; } @@ -246,9 +255,17 @@ pub struct StillDerivatives { /// provenance is append-only exactly like the asset's. /// /// # Errors -/// [`MediaError::Encode`] when a codec refuses the frame, and [`MediaError::ZeroDimension`] for -/// an empty source. A signing failure (a hardware device signer refusing) and a sealing failure -/// both surface as [`MediaError::Encode`] too, carrying the crypto error's message. +/// - [`MediaError::Encode`] — a codec refused the frame. That is the **only** thing this variant +/// means here. +/// - [`MediaError::ZeroDimension`] — the source frame has a zero dimension. +/// - [`MediaError::Sign`] — the device signer or the epoch write-tier signer refused, or +/// [`DerivativeContext::sealer`] failed (an encryption refusal, or an exhausted nonce-prefix +/// draw). +/// +/// The split is the contract rather than bookkeeping. The import path degrades the first two to +/// "this asset has no thumbnail" and commits the original anyway; it **propagates** `Sign`, +/// because a workspace that cannot author a signed record is broken in a way a missing +/// derivative is not — the same fault would stop the asset's own manifest. #[tracing::instrument( level = "debug", skip_all, From 1e6192ecf70d8bb0155deefcdbe4532d2ee9e5ab Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 05:26:53 -0400 Subject: [PATCH 110/243] feat(cli): add `capsule repair capture-time` for pre-S-B16 sidecars Every asset imported before S-B16 carries its import time as its capture time inside the signed sidecar. This is the pass that goes back to the original and asks (S-B17). - `capsule repair capture-time --library [--apply] [--limit N]` re-reads each original's EXIF under the importer's own resolution (`resolve_timezone` over `extract_exif`). An instant that disagrees with the sidecar is affected; a floating time or no EXIF resolves to nothing and is skipped rather than guessed as UTC, exactly as the importer skips it, which makes the pass a no-op on a post-S-B16 library by construction and leaves Takeout-folded captures alone. It never compares capture to import time. An unreadable original is reported as such, never as "no EXIF". - Dry run is the default: `push`/`sync` default to writing because they write to a re-drivable server; this appends an irreversible signed record per asset. `--apply` corrects each affected asset as its own `metadata-update` through `Workspace::set_capture_timestamp`, so an interrupted run leaves completed assets correct and a re-run skips them. `--limit` bounds one run's corrections; the report always covers the whole library. - Every printed line is a `cli.repair.*` key (12 keys). Unit tests cover the verdicts over synthesized EXIF, detect/apply/idempotence over a workspace, and the limit; the smoke tests spawn the binary through dry-run, apply, `show`, `library rebuild` and a second run. `cli-surface.json` gains the verb; catalogs regenerated with `mise run i18n`. --- .../src/androidMain/res/values/strings.xml | 18 + capsule-cli/cli-surface.json | 54 ++ capsule-cli/src/cli/commands.rs | 28 + capsule-cli/src/i18n.rs | 13 + capsule-cli/src/lib.rs | 27 +- capsule-cli/src/repair.rs | 771 ++++++++++++++++++ capsule-cli/tests/show_and_repair.rs | 184 ++++- capsule-i18n/src/bundles/en.json | 18 + capsule-swift/Generated/Localizable.xcstrings | 180 ++++ capsule-web/src/i18n/messages/en.json | 18 + locales/en.json | 72 ++ 11 files changed, 1376 insertions(+), 7 deletions(-) create mode 100644 capsule-cli/src/repair.rs diff --git a/capsule-android/src/androidMain/res/values/strings.xml b/capsule-android/src/androidMain/res/values/strings.xml index 29e1c755..561fa919 100644 --- a/capsule-android/src/androidMain/res/values/strings.xml +++ b/capsule-android/src/androidMain/res/values/strings.xml @@ -1768,6 +1768,12 @@ Path to the Capsule library to push Read the library passphrase from stdin instead of prompting, so pushes work in scripts and CI where there is no terminal Open the tier sessions in ladder order (index → preview → original), gating the above-index tiers on the connection class, instead of opening all eagerly + Repair a local library in place, one signed correction per affected asset + Re-read each original\'s EXIF capture time and report every asset whose signed capture timestamp disagrees with it; with --apply, correct each one as a signed metadata update + Write the corrections. Without this flag the pass only reports what it would change; each correction is an irreversible signed record on the asset\'s chain + Path to the Capsule library + Correct at most this many affected assets in one --apply run (the report still covers the whole library) + Read the library passphrase from stdin instead of prompting, so the command works in scripts and CI where there is no terminal Reset all local CLI data Reset all data Reset cache directory @@ -1812,6 +1818,18 @@ Pushing %1$s asset(s) to %2$s… The library holds no assets to push. Everything in this library is already on the server. + Checked %s asset(s) against the EXIF capture time of their originals. + %s: corrected + Corrected assets stay in the month directory they were imported into; that drift between directory and capture date is expected after a correction and is not a fault. + Dry run: nothing was written. Re-run with --apply to correct the affected sidecars. + Correcting %1$s failed: %2$s. Assets corrected before it stay corrected; nothing after it was touched. + Stopped after %s correction(s) because of --limit; re-run to continue. + Every capture timestamp agrees with its original\'s EXIF; nothing to repair. + %1$s: recorded %2$s, EXIF says %3$s (off by %4$s s) + %1$s: recorded %2$s (not a timestamp), EXIF says %3$s + Capture-time repair: %1$s affected, %2$s corrected, %3$s already correct, %4$s without a recoverable EXIF instant, %5$s unreadable. + %1$s: original could not be read (%2$s); skipped + Repair failed: %s Album: %s %1$s matches %2$s assets; give more of the hash, or the full asset id. Caption: %s diff --git a/capsule-cli/cli-surface.json b/capsule-cli/cli-surface.json index e8065b37..0ce5d746 100644 --- a/capsule-cli/cli-surface.json +++ b/capsule-cli/cli-surface.json @@ -465,6 +465,60 @@ ], "name": "push" }, + { + "about": "Repair a local library in place, one signed correction per affected asset", + "name": "repair", + "subcommands": [ + { + "about": "Re-read each original's EXIF capture time and report every asset whose signed capture timestamp disagrees with it; with --apply, correct each one as a signed metadata update", + "args": [ + { + "help": "Path to the Capsule library", + "id": "library", + "long": "library", + "positional": false, + "repeatable": false, + "required": true, + "takes_value": true, + "value_names": [ + "PATH" + ] + }, + { + "help": "Read the library passphrase from stdin instead of prompting, so the command works in scripts and CI where there is no terminal", + "id": "passphrase_stdin", + "long": "passphrase-stdin", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + }, + { + "help": "Write the corrections. Without this flag the pass only reports what it would change; each correction is an irreversible signed record on the asset's chain", + "id": "apply", + "long": "apply", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + }, + { + "help": "Correct at most this many affected assets in one --apply run (the report still covers the whole library)", + "id": "limit", + "long": "limit", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": true, + "value_names": [ + "COUNT" + ] + } + ], + "name": "capture-time" + } + ] + }, { "about": "Reset all local CLI data", "args": [ diff --git a/capsule-cli/src/cli/commands.rs b/capsule-cli/src/cli/commands.rs index 8b054e77..0e4bb9d6 100644 --- a/capsule-cli/src/cli/commands.rs +++ b/capsule-cli/src/cli/commands.rs @@ -146,6 +146,11 @@ pub(crate) enum Commands { #[command(subcommand)] command: LibraryCommands, }, + /// Repair a local library in place, one signed correction per affected asset + Repair { + #[command(subcommand)] + command: RepairCommands, + }, /// Run the offline end-to-end data-plane showcase (real cryptography, no network) Demo { /// Working directory for the demo libraries (a temp dir is used if omitted) @@ -217,6 +222,29 @@ pub(crate) enum LibraryCommands { }, } +#[derive(Subcommand, Debug)] +pub(crate) enum RepairCommands { + /// Re-read each original's EXIF capture time and report every asset whose signed capture + /// timestamp disagrees with it; with --apply, correct each one as a signed metadata update + CaptureTime { + /// Path to the Capsule library + #[arg(long, value_name = "PATH")] + library: PathBuf, + /// Read the library passphrase from stdin instead of prompting, so the command + /// works in scripts and CI where there is no terminal. + #[arg(long)] + passphrase_stdin: bool, + /// Write the corrections. Without this flag the pass only reports what it would + /// change; each correction is an irreversible signed record on the asset's chain. + #[arg(long)] + apply: bool, + /// Correct at most this many affected assets in one --apply run (the report still + /// covers the whole library) + #[arg(long, value_name = "COUNT")] + limit: Option, + }, +} + #[derive(Subcommand, Debug)] pub(crate) enum AuthCommands { /// Create a Capsule account and sign in diff --git a/capsule-cli/src/i18n.rs b/capsule-cli/src/i18n.rs index 276d289c..21eb6a0f 100644 --- a/capsule-cli/src/i18n.rs +++ b/capsule-cli/src/i18n.rs @@ -145,4 +145,17 @@ pub mod keys { pub const SHOW_AMBIGUOUS: &str = "cli.show.ambiguous"; pub const SHOW_INVALID_SELECTOR: &str = "cli.show.invalid_selector"; pub const SHOW_FAILED: &str = "cli.show.failed"; + // `capsule repair capture-time` — the capture-timestamp repair pass (slice `S-B17`). + pub const REPAIR_CAPTURE_TIME_CHECKED: &str = "cli.repair.capture_time.checked"; + pub const REPAIR_CAPTURE_TIME_DRY_RUN_NOTICE: &str = "cli.repair.capture_time.dry_run_notice"; + pub const REPAIR_CAPTURE_TIME_ROW: &str = "cli.repair.capture_time.row"; + pub const REPAIR_CAPTURE_TIME_ROW_UNPARSEABLE: &str = "cli.repair.capture_time.row_unparseable"; + pub const REPAIR_CAPTURE_TIME_UNREADABLE: &str = "cli.repair.capture_time.unreadable"; + pub const REPAIR_CAPTURE_TIME_CORRECTED: &str = "cli.repair.capture_time.corrected"; + pub const REPAIR_CAPTURE_TIME_LIMIT_NOTICE: &str = "cli.repair.capture_time.limit_notice"; + pub const REPAIR_CAPTURE_TIME_SUMMARY: &str = "cli.repair.capture_time.summary"; + pub const REPAIR_CAPTURE_TIME_NOTHING: &str = "cli.repair.capture_time.nothing"; + pub const REPAIR_CAPTURE_TIME_DRIFT_NOTICE: &str = "cli.repair.capture_time.drift_notice"; + pub const REPAIR_CAPTURE_TIME_FAILED_ASSET: &str = "cli.repair.capture_time.failed_asset"; + pub const REPAIR_FAILED: &str = "cli.repair.failed"; } diff --git a/capsule-cli/src/lib.rs b/capsule-cli/src/lib.rs index 5f7789ab..1037e88a 100644 --- a/capsule-cli/src/lib.rs +++ b/capsule-cli/src/lib.rs @@ -21,7 +21,7 @@ use capsule_core::library::{Library, LibraryError, init_library, open_library, r use capsule_core::lifecycle::Workspace; use capsule_core::metadata::FileMetadata; use capsule_sdk::net::ConnectionClass; -use cli::{AuthCommands, Cli, Commands, ImportProviderArg, LibraryCommands}; +use cli::{AuthCommands, Cli, Commands, ImportProviderArg, LibraryCommands, RepairCommands}; use colored::*; use dialoguer::{Confirm, Input, Password}; use eyre::{Result, eyre}; @@ -37,6 +37,7 @@ pub mod db; pub mod demo; pub mod i18n; pub mod remote; +pub mod repair; pub mod session; pub mod show; pub mod status; @@ -532,6 +533,30 @@ async fn dispatch(cli: Cli) -> Result<()> { } } + // ── Repair ──────────────────────────────────────────────────────── + Commands::Repair { command } => match command { + RepairCommands::CaptureTime { + library, + passphrase_stdin, + apply, + limit, + } => { + let bundle = i18n::cli_bundle(); + let request = repair::RepairRequest { apply, limit }; + let mut ws = open_workspace(&library, passphrase_stdin)?; + match repair::run(&mut ws, request) { + Ok(summary) => print!("{}", repair::render(&bundle, request, &summary)), + Err(error) => { + let reason = repair::describe_error(&bundle, &error); + return Err(eyre!( + "{}", + bundle.format(keys::REPAIR_FAILED, &[("reason", Value::Str(&reason))]) + )); + } + } + } + }, + // ── Demo ────────────────────────────────────────────────────────── Commands::Demo { workdir, image } => { demo::run(workdir, image)?; diff --git a/capsule-cli/src/repair.rs b/capsule-cli/src/repair.rs new file mode 100644 index 00000000..840d1144 --- /dev/null +++ b/capsule-cli/src/repair.rs @@ -0,0 +1,771 @@ +//! `capsule repair capture-time` — recover capture timestamps stamped with import time +//! (slice `S-B17`). +//! +//! Before `S-B16`, `extract_exif` could never parse a well-formed `DateTimeOriginal`, so every +//! import wrote its own clock as the asset's capture time — **inside the signed sidecar**, and +//! as the `media/{YYYY}/{YYYY-MM}` shard. Fixing the parser did not fix the data: the wrong +//! value is under signature, a rebuild reconstructs from those same sidecars and would +//! faithfully preserve it, and nothing else ever goes back to the original to ask. This pass +//! does. +//! +//! ## The rule +//! +//! For every managed asset, re-read the original's EXIF and resolve it exactly as the +//! importer does ([`resolve_timezone`] over [`extract_exif`]): +//! +//! - **No resolvable instant** — no EXIF, no `DateTimeOriginal`, or a floating one with +//! neither an `OffsetTimeOriginal` nor a fix to anchor it — is **skipped**. Nothing is +//! recoverable, and nothing is known to be broken: the importer would write the same value +//! today. (A floating time is not treated as UTC here for the same reason the importer does +//! not: that would be a guess signed as a fact.) +//! - **An instant equal to the recorded one** is skipped: the asset is correct. +//! - **An instant that differs** is *affected*. The comparison is against the sidecar's +//! capture timestamp only — never against import time — so an asset genuinely imported the +//! second it was taken is not reported as broken. +//! - **An original that cannot be read** is reported as unreadable, never as "no EXIF". +//! +//! By construction the pass is a no-op on a library imported after `S-B16`: that importer +//! already wrote the resolved instant wherever one existed, and where none existed this pass +//! skips. Assets whose capture time came from a Takeout record (`CaptureSource::Folded`) have +//! no EXIF instant of their own and are left alone. +//! +//! ## The write +//! +//! Dry run is the default; `--apply` issues one signed `metadata-update` per affected asset +//! through [`Workspace::set_capture_timestamp`], each an independent write — an interrupted +//! run leaves every completed asset correct and a re-run skips them, because their EXIF now +//! agrees. The media bundle is **not** relocated: the sidecar is authoritative for the date +//! and the month directory is only the shard fixed at import, which the design treats as +//! expected drift after a capture correction rather than as a fault. +//! +//! ## Not covered +//! +//! An asset whose capture time a user deliberately set to something other than its EXIF. +//! No such edit surface exists today — `set_capture_timestamp` is the first — so the +//! question does not arise; once one exists, the pass must skip assets whose chain carries a +//! capture correction that was not itself this repair. Recorded in `SLICES.md` under +//! `S-B17` rather than implemented against a surface that is not there. + +use std::path::Path; + +use capsule_core::exif::{extract_exif, resolve_timezone}; +use capsule_core::lifecycle::Workspace; +use capsule_i18n::Bundle; +use colored::Colorize as _; +use jiff::Timestamp; +use thiserror::Error; +use uuid::Uuid; + +use crate::i18n::{Value, keys}; + +/// One `capsule repair capture-time` invocation, independent of the argument parser. +#[derive(Debug, Clone, Copy, Default)] +pub struct RepairRequest { + /// Write the corrections. Without it the pass only reports. + pub apply: bool, + /// Correct at most this many affected assets in one `--apply` run. Detection still covers + /// the whole library; the dry-run report is unaffected. + pub limit: Option, +} + +/// What re-reading one original's EXIF says about its recorded capture time. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Verdict { + /// The recorded value disagrees with the instant recovered from EXIF. + Affected { + /// The sidecar's capture timestamp, parsed; `None` when it does not parse. + recorded: Option, + /// The instant the original's EXIF resolves to. + recovered: Timestamp, + }, + /// The recorded value equals the recovered instant. + Agrees, + /// The original carries no instant the importer could have resolved: nothing to recover. + NoInstant, + /// The original could not be read at all. + Unreadable(String), +} + +/// An affected asset: what the sidecar says, and what its original says. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Affected { + /// The asset. + pub asset_id: Uuid, + /// The sidecar's capture timestamp as written. + pub recorded_text: String, + /// The sidecar's capture timestamp, parsed; `None` when it does not parse. + pub recorded: Option, + /// The instant the original's EXIF resolves to. + pub recovered: Timestamp, +} + +impl Affected { + /// Seconds from the recorded instant to the recovered one, when the recorded one parses. + #[must_use] + pub fn delta_seconds(&self) -> Option { + self.recorded + .map(|recorded| self.recovered.as_second() - recorded.as_second()) + } +} + +/// The verdicts over a whole library, affected assets in ascending asset-id order. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Detection { + /// How many assets were examined. + pub scanned: usize, + /// Assets whose recorded capture time disagrees with their EXIF. + pub affected: Vec, + /// Assets whose recorded capture time equals their EXIF instant. + pub agrees: usize, + /// Assets with no recoverable instant. + pub no_instant: usize, + /// Assets whose original could not be read, with the reason. + pub unreadable: Vec<(Uuid, String)>, +} + +/// What one invocation did. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RepairSummary { + /// The detection the run acted on. + pub detection: Detection, + /// Whether `--apply` was given. + pub applied: bool, + /// The assets corrected, in the order they were written. Empty on a dry run. + pub corrected: Vec, + /// Whether `--limit` stopped the run before every affected asset was corrected. + pub limit_reached: bool, +} + +/// Why a repair could not complete. +#[derive(Debug, Error)] +pub enum RepairError { + /// The lifecycle refused a correction. The run stops at the first refusal rather than + /// continuing over a library whose sealing may be broken; everything corrected before it + /// stays corrected. + #[error("correcting {asset_id}: {detail}")] + Lifecycle { + /// The asset the write was for. + asset_id: Uuid, + /// The lifecycle's own description. + detail: String, + }, +} + +/// Compare one asset's recorded capture timestamp against what its original's EXIF resolves +/// to, applying the importer's own resolution. +#[tracing::instrument(skip_all, fields(original = %original.display()))] +pub fn detect_one(recorded: &str, original: &Path) -> Verdict { + // Readability is checked before EXIF is asked for, because `extract_exif` reports "no + // EXIF container" as an empty extract and an unreadable file must not be confused with it. + if let Err(error) = std::fs::File::open(original) { + tracing::warn!(%error, "repair: original unreadable"); + return Verdict::Unreadable(error.to_string()); + } + let exif = match extract_exif(original) { + Ok(exif) => exif, + Err(error) => { + tracing::warn!(%error, "repair: original unreadable while extracting EXIF"); + return Verdict::Unreadable(error.to_string()); + } + }; + let Some(secs) = resolve_timezone(&exif).capture_utc else { + tracing::debug!("repair: no resolvable EXIF instant; nothing to recover"); + return Verdict::NoInstant; + }; + let Ok(recovered) = Timestamp::from_second(secs) else { + tracing::warn!( + secs, + "repair: EXIF instant out of range; treated as unrecoverable" + ); + return Verdict::NoInstant; + }; + let parsed = recorded.parse::().ok(); + if parsed.is_some_and(|recorded| recorded.as_second() == secs) { + return Verdict::Agrees; + } + tracing::debug!(recorded, %recovered, "repair: recorded capture time disagrees with EXIF"); + Verdict::Affected { + recorded: parsed, + recovered, + } +} + +/// Examine every managed asset of `ws`. +#[tracing::instrument(skip_all, fields(assets = ws.asset_ids().len()))] +pub fn detect(ws: &Workspace) -> Detection { + let mut ids = ws.asset_ids(); + ids.sort_unstable(); + let mut detection = Detection::default(); + for id in ids { + let Some(asset) = ws.asset(&id) else { + continue; + }; + let Some(original) = ws.original_path(&id) else { + continue; + }; + detection.scanned += 1; + let recorded_text = asset.sidecar.capture_timestamp.clone(); + match detect_one(&recorded_text, &original) { + Verdict::Affected { + recorded, + recovered, + } => detection.affected.push(Affected { + asset_id: id, + recorded_text, + recorded, + recovered, + }), + Verdict::Agrees => detection.agrees += 1, + Verdict::NoInstant => detection.no_instant += 1, + Verdict::Unreadable(reason) => detection.unreadable.push((id, reason)), + } + } + tracing::info!( + scanned = detection.scanned, + affected = detection.affected.len(), + agrees = detection.agrees, + no_instant = detection.no_instant, + unreadable = detection.unreadable.len(), + "repair: capture-time detection complete" + ); + detection +} + +/// Correct the affected assets, at most `limit` of them, each as its own signed write. +#[tracing::instrument(skip_all, fields(affected = affected.len(), limit))] +pub fn apply( + ws: &mut Workspace, + affected: &[Affected], + limit: Option, +) -> Result, RepairError> { + let budget = limit.unwrap_or(affected.len()); + let mut corrected = Vec::new(); + for item in affected.iter().take(budget) { + ws.set_capture_timestamp(&item.asset_id, item.recovered) + .map_err(|error| RepairError::Lifecycle { + asset_id: item.asset_id, + detail: error.to_string(), + })?; + corrected.push(item.asset_id); + } + Ok(corrected) +} + +/// Detect, then (with `--apply`) correct. +pub fn run(ws: &mut Workspace, request: RepairRequest) -> Result { + let detection = detect(ws); + let mut summary = RepairSummary { + applied: request.apply, + ..Default::default() + }; + if request.apply { + summary.corrected = apply(ws, &detection.affected, request.limit)?; + summary.limit_reached = summary.corrected.len() < detection.affected.len(); + } + summary.detection = detection; + Ok(summary) +} + +/// Localize a [`RepairError`] for the failure line. +pub fn describe_error(bundle: &Bundle, error: &RepairError) -> String { + match error { + RepairError::Lifecycle { asset_id, detail } => bundle.format( + keys::REPAIR_CAPTURE_TIME_FAILED_ASSET, + &[ + ("asset_id", Value::Str(&asset_id.to_string())), + ("reason", Value::Str(detail)), + ], + ), + } +} + +/// Render the run as the lines `capsule repair capture-time` prints. +#[must_use] +pub fn render(bundle: &Bundle, request: RepairRequest, summary: &RepairSummary) -> String { + let detection = &summary.detection; + let mut out = String::new(); + let mut line = |text: String| { + out.push_str(&text); + out.push('\n'); + }; + + line( + bundle + .format( + keys::REPAIR_CAPTURE_TIME_CHECKED, + &[("count", Value::Int(detection.scanned as i64))], + ) + .cyan() + .to_string(), + ); + if !request.apply { + line( + bundle + .format(keys::REPAIR_CAPTURE_TIME_DRY_RUN_NOTICE, &[]) + .yellow() + .to_string(), + ); + } + + for item in &detection.affected { + let recovered = item.recovered.to_string(); + let asset_id = item.asset_id.to_string(); + line(match item.delta_seconds() { + Some(delta) => bundle.format( + keys::REPAIR_CAPTURE_TIME_ROW, + &[ + ("asset_id", Value::Str(&asset_id)), + ("recorded", Value::Str(&item.recorded_text)), + ("recovered", Value::Str(&recovered)), + ("delta", Value::Int(delta)), + ], + ), + None => bundle.format( + keys::REPAIR_CAPTURE_TIME_ROW_UNPARSEABLE, + &[ + ("asset_id", Value::Str(&asset_id)), + ("recorded", Value::Str(&item.recorded_text)), + ("recovered", Value::Str(&recovered)), + ], + ), + }); + } + for (asset_id, reason) in &detection.unreadable { + line( + bundle + .format( + keys::REPAIR_CAPTURE_TIME_UNREADABLE, + &[ + ("asset_id", Value::Str(&asset_id.to_string())), + ("reason", Value::Str(reason)), + ], + ) + .red() + .to_string(), + ); + } + for asset_id in &summary.corrected { + line( + bundle + .format( + keys::REPAIR_CAPTURE_TIME_CORRECTED, + &[("asset_id", Value::Str(&asset_id.to_string()))], + ) + .green() + .to_string(), + ); + } + if summary.limit_reached { + line( + bundle + .format( + keys::REPAIR_CAPTURE_TIME_LIMIT_NOTICE, + &[("corrected", Value::Int(summary.corrected.len() as i64))], + ) + .yellow() + .to_string(), + ); + } + + if detection.affected.is_empty() && detection.unreadable.is_empty() { + line( + bundle + .format(keys::REPAIR_CAPTURE_TIME_NOTHING, &[]) + .green() + .to_string(), + ); + } else { + line(bundle.format( + keys::REPAIR_CAPTURE_TIME_SUMMARY, + &[ + ("affected", Value::Int(detection.affected.len() as i64)), + ("corrected", Value::Int(summary.corrected.len() as i64)), + ("agrees", Value::Int(detection.agrees as i64)), + ("no_instant", Value::Int(detection.no_instant as i64)), + ("unreadable", Value::Int(detection.unreadable.len() as i64)), + ], + )); + } + if !summary.corrected.is_empty() { + line( + bundle + .format(keys::REPAIR_CAPTURE_TIME_DRIFT_NOTICE, &[]) + .dimmed() + .to_string(), + ); + } + out +} + +#[cfg(test)] +mod tests { + use capsule_core::crypto::primitives::Argon2Params; + use capsule_core::crypto::verify_asset::VerifyOutcome; + + use super::*; + + const FAST_KDF: Argon2Params = Argon2Params { + mem_kib: 64, + t_cost: 1, + p_cost: 1, + }; + + /// The instant `exif_jpeg` carries: 2019-03-04 05:06:07 at +00:00. + const EXIF_SECS: i64 = 1_551_675_967; + + /// A JPEG container holding one EXIF APP1 segment with `DateTimeOriginal` and, when + /// `with_offset`, `OffsetTimeOriginal` +00:00 — the pair the importer resolves to an + /// instant. Without the offset the time is floating, which the importer (and so this + /// pass) does not resolve. + fn exif_jpeg(with_offset: bool, salt: &[u8]) -> Vec { + const DTO: &[u8] = b"2019:03:04 05:06:07\0"; + const OTO: &[u8] = b"+00:00\0"; + let entries: u32 = if with_offset { 2 } else { 1 }; + let ifd0_at: u32 = 8; + let exif_ifd_at = ifd0_at + 2 + 12 + 4; + let data_at = exif_ifd_at + 2 + entries * 12 + 4; + let dto_at = data_at; + let oto_at = dto_at + DTO.len() as u32; + + fn entry(tiff: &mut Vec, tag: u16, kind: u16, count: u32, value: [u8; 4]) { + tiff.extend_from_slice(&tag.to_be_bytes()); + tiff.extend_from_slice(&kind.to_be_bytes()); + tiff.extend_from_slice(&count.to_be_bytes()); + tiff.extend_from_slice(&value); + } + + let mut tiff = Vec::new(); + tiff.extend_from_slice(b"MM"); + tiff.extend_from_slice(&42u16.to_be_bytes()); + tiff.extend_from_slice(&ifd0_at.to_be_bytes()); + tiff.extend_from_slice(&1u16.to_be_bytes()); + entry(&mut tiff, 0x8769, 4, 1, exif_ifd_at.to_be_bytes()); + tiff.extend_from_slice(&0u32.to_be_bytes()); + tiff.extend_from_slice(&(entries as u16).to_be_bytes()); + entry(&mut tiff, 0x9003, 2, DTO.len() as u32, dto_at.to_be_bytes()); + if with_offset { + entry(&mut tiff, 0x9011, 2, OTO.len() as u32, oto_at.to_be_bytes()); + } + tiff.extend_from_slice(&0u32.to_be_bytes()); + assert_eq!(tiff.len() as u32, data_at); + tiff.extend_from_slice(DTO); + if with_offset { + tiff.extend_from_slice(OTO); + } + + let mut app1 = b"Exif\0\0".to_vec(); + app1.extend_from_slice(&tiff); + let mut jpeg = vec![0xFF, 0xD8, 0xFF, 0xE1]; + jpeg.extend_from_slice(&((app1.len() + 2) as u16).to_be_bytes()); + jpeg.extend_from_slice(&app1); + jpeg.extend_from_slice(&[0xFF, 0xFE]); + jpeg.extend_from_slice(&((salt.len() + 2) as u16).to_be_bytes()); + jpeg.extend_from_slice(salt); + jpeg.extend_from_slice(&[0xFF, 0xD9]); + jpeg + } + + struct Scratch(std::path::PathBuf); + + impl Scratch { + fn new() -> Self { + let dir = + std::env::temp_dir().join(format!("capsule-cli-repair-{}", nanoid::nanoid!())); + std::fs::create_dir_all(&dir).expect("scratch dir"); + Self(dir) + } + + fn file(&self, name: &str, bytes: &[u8]) -> std::path::PathBuf { + let path = self.0.join(name); + std::fs::write(&path, bytes).expect("fixture file"); + path + } + + fn workspace(&self) -> Workspace { + let lib = self.0.join("lib"); + std::fs::create_dir_all(&lib).expect("library dir"); + let mut ws = + Workspace::create_with_params(&lib, b"pw", FAST_KDF).expect("create workspace"); + let album = ws.default_album_id(); + ws.create_album_with_id(album, "Imports") + .expect("create album"); + ws + } + } + + impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn ts(secs: i64) -> Timestamp { + Timestamp::from_second(secs).expect("in range") + } + + // ── detect_one ─────────────────────────────────────────────────────────── + + #[test] + fn an_agreeing_timestamp_is_not_affected() { + let scratch = Scratch::new(); + let original = scratch.file("a.jpg", &exif_jpeg(true, b"a")); + assert_eq!( + detect_one(&ts(EXIF_SECS).to_string(), &original), + Verdict::Agrees + ); + } + + #[test] + fn a_recorded_import_time_is_affected_with_the_exif_instant_recovered() { + let scratch = Scratch::new(); + let original = scratch.file("a.jpg", &exif_jpeg(true, b"a")); + let now = ts(Timestamp::now().as_second()); + assert_eq!( + detect_one(&now.to_string(), &original), + Verdict::Affected { + recorded: Some(now), + recovered: ts(EXIF_SECS), + } + ); + // An unparseable record is affected too, with nothing to compute a delta from. + assert_eq!( + detect_one("not a timestamp", &original), + Verdict::Affected { + recorded: None, + recovered: ts(EXIF_SECS), + } + ); + } + + /// The importer's own rule: a floating `DateTimeOriginal` resolves to no instant, so the + /// pass has nothing to compare and must not guess UTC. + #[test] + fn a_floating_exif_time_or_no_exif_is_no_instant_not_affected() { + let scratch = Scratch::new(); + let floating = scratch.file("floating.jpg", &exif_jpeg(false, b"f")); + assert_eq!( + detect_one(&Timestamp::now().to_string(), &floating), + Verdict::NoInstant + ); + let plain = scratch.file("plain.jpg", b"\xFF\xD8\xFF no exif at all"); + assert_eq!( + detect_one(&Timestamp::now().to_string(), &plain), + Verdict::NoInstant + ); + } + + #[test] + fn a_missing_original_is_unreadable_not_no_instant() { + let scratch = Scratch::new(); + let missing = scratch.0.join("gone.jpg"); + assert!(matches!( + detect_one("2019-03-04T05:06:07Z", &missing), + Verdict::Unreadable(_) + )); + } + + // ── detect / apply over a workspace ────────────────────────────────────── + + /// The `S-B17` acceptance case, in-process: a library whose sidecars carry the wrong + /// instant reports every affected asset, corrects them under `--apply` as one signed + /// revision each, and a second pass finds nothing — while a post-`S-B16` import is + /// untouched from the start. + #[test] + fn detect_and_apply_correct_only_the_assets_that_disagree_with_their_exif() { + let scratch = Scratch::new(); + let mut ws = scratch.workspace(); + let album = ws.default_album_id(); + let good = ws + .import_asset(album, &scratch.file("good.jpg", &exif_jpeg(true, b"good"))) + .expect("import"); + let broken = ws + .import_asset( + album, + &scratch.file("broken.jpg", &exif_jpeg(true, b"broken")), + ) + .expect("import"); + let floating = ws + .import_asset( + album, + &scratch.file("floating.jpg", &exif_jpeg(false, b"floating")), + ) + .expect("import"); + + // A post-S-B16 import already carries the EXIF instant: a no-op by construction. + assert_eq!( + ws.asset(&good).expect("asset").sidecar.capture_timestamp, + ts(EXIF_SECS).to_string() + ); + let clean = detect(&ws); + assert_eq!(clean.scanned, 3); + assert!(clean.affected.is_empty(), "{clean:?}"); + assert_eq!((clean.agrees, clean.no_instant), (2, 1)); + + // Reproduce the pre-S-B16 state on one asset: its sidecar says "now". + let wrong = Timestamp::now(); + ws.set_capture_timestamp(&broken, wrong).expect("stamp"); + + let detection = detect(&ws); + assert_eq!(detection.affected.len(), 1); + let item = &detection.affected[0]; + assert_eq!(item.asset_id, broken); + assert_eq!(item.recovered, ts(EXIF_SECS)); + assert_eq!(item.recorded_text, wrong.to_string()); + assert_eq!( + item.delta_seconds(), + Some(EXIF_SECS - wrong.as_second()), + "the delta is recovered minus recorded" + ); + + // A dry run writes nothing. + let dry = run(&mut ws, RepairRequest::default()).expect("dry run"); + assert!(!dry.applied); + assert!(dry.corrected.is_empty()); + assert_eq!(ws.asset(&broken).expect("asset").chain.records().len(), 2); + + // `--apply` corrects it as a third signed record, and only it. + let applied = run( + &mut ws, + RepairRequest { + apply: true, + limit: None, + }, + ) + .expect("apply"); + assert_eq!(applied.corrected, vec![broken]); + assert!(!applied.limit_reached); + let fixed = ws.asset(&broken).expect("asset"); + assert_eq!(fixed.sidecar.capture_timestamp, ts(EXIF_SECS).to_string()); + assert_eq!(fixed.chain.records().len(), 3); + assert_eq!(ws.verify(&broken).expect("verify"), VerifyOutcome::Accept); + assert_eq!(ws.asset(&good).expect("asset").chain.records().len(), 1); + assert_eq!(ws.asset(&floating).expect("asset").chain.records().len(), 1); + + // Idempotent: a second pass has nothing left to do. + let again = detect(&ws); + assert!(again.affected.is_empty(), "{again:?}"); + assert_eq!((again.agrees, again.no_instant), (2, 1)); + } + + #[test] + fn a_limit_stops_after_that_many_corrections_and_says_so() { + let scratch = Scratch::new(); + let mut ws = scratch.workspace(); + let album = ws.default_album_id(); + let ids: Vec = (0..3) + .map(|n| { + let file = scratch.file( + &format!("p{n}.jpg"), + &exif_jpeg(true, format!("asset {n}").as_bytes()), + ); + ws.import_asset(album, &file).expect("import") + }) + .collect(); + for id in &ids { + ws.set_capture_timestamp(id, Timestamp::now()) + .expect("stamp"); + } + + let first = run( + &mut ws, + RepairRequest { + apply: true, + limit: Some(2), + }, + ) + .expect("apply"); + assert_eq!(first.corrected.len(), 2); + assert!(first.limit_reached); + assert_eq!( + first.detection.affected.len(), + 3, + "detection is not limited" + ); + + let second = run( + &mut ws, + RepairRequest { + apply: true, + limit: Some(2), + }, + ) + .expect("apply"); + assert_eq!(second.corrected.len(), 1, "the one the limit left over"); + assert!(!second.limit_reached); + assert!(detect(&ws).affected.is_empty()); + } + + // ── render ─────────────────────────────────────────────────────────────── + + fn sample_summary(apply: bool, corrected: bool) -> RepairSummary { + let asset_id = Uuid::nil(); + RepairSummary { + detection: Detection { + scanned: 4, + affected: vec![Affected { + asset_id, + recorded_text: "2026-09-02T10:00:00Z".into(), + recorded: Some(ts(1_788_688_800)), + recovered: ts(EXIF_SECS), + }], + agrees: 2, + no_instant: 1, + unreadable: vec![], + }, + applied: apply, + corrected: if corrected { vec![asset_id] } else { vec![] }, + limit_reached: false, + } + } + + #[test] + fn a_dry_run_page_reports_the_affected_asset_and_says_nothing_was_written() { + let bundle = Bundle::for_locale("en"); + let page = render( + &bundle, + RepairRequest::default(), + &sample_summary(false, false), + ); + assert!(page.contains("Dry run"), "{page}"); + assert!(page.contains("--apply"), "{page}"); + assert!(page.contains("2026-09-02T10:00:00Z"), "{page}"); + assert!(page.contains("2019-03-04T05:06:07Z"), "{page}"); + assert!( + page.contains("1 affected, 0 corrected, 2 already correct, 1 without"), + "{page}" + ); + assert!(!page.contains("cli.repair."), "no raw key leaks:\n{page}"); + } + + #[test] + fn an_apply_page_names_the_corrected_asset_and_the_expected_drift() { + let bundle = Bundle::for_locale("en"); + let request = RepairRequest { + apply: true, + limit: None, + }; + let page = render(&bundle, request, &sample_summary(true, true)); + assert!(!page.contains("Dry run"), "{page}"); + assert!(page.contains(&Uuid::nil().to_string()), "{page}"); + assert!(page.contains("1 affected, 1 corrected"), "{page}"); + let drift = bundle.format(keys::REPAIR_CAPTURE_TIME_DRIFT_NOTICE, &[]); + assert!(page.contains(&drift), "{page}"); + } + + #[test] + fn a_clean_library_reports_nothing_to_repair() { + let bundle = Bundle::for_locale("en"); + let summary = RepairSummary { + detection: Detection { + scanned: 2, + agrees: 2, + ..Default::default() + }, + ..Default::default() + }; + let page = render(&bundle, RepairRequest::default(), &summary); + let nothing = bundle.format(keys::REPAIR_CAPTURE_TIME_NOTHING, &[]); + assert!(page.contains(¬hing), "{page}"); + assert!(!page.contains("affected,"), "{page}"); + } +} diff --git a/capsule-cli/tests/show_and_repair.rs b/capsule-cli/tests/show_and_repair.rs index 9d919410..6c786311 100644 --- a/capsule-cli/tests/show_and_repair.rs +++ b/capsule-cli/tests/show_and_repair.rs @@ -1,12 +1,25 @@ -//! Slice `S-B18` (`capsule show`) across a real process boundary, by spawning the `capsule` -//! binary the way `import_round_trip.rs` and `takeout_import.rs` do — and for the same -//! reason: `show` proves what a library *reopened from disk* says about an asset. +//! Slices `S-B18` (`capsule show`) and `S-B17` (`capsule repair capture-time`) across a real +//! process boundary, by spawning the `capsule` binary the way `import_round_trip.rs` and +//! `takeout_import.rs` do — and for the same reason: `show` proves what a library *reopened +//! from disk* says about an asset, and a repair that only looked right inside the process +//! that wrote it would prove nothing. //! //! **The fixture image** is a synthesized JPEG carrying a real EXIF APP1 segment with //! `DateTimeOriginal` **and** `OffsetTimeOriginal`, so its capture time resolves to a fixed -//! UTC instant — the case the importer writes as the capture timestamp since `S-B16`. The -//! Argon2id seeding is the fast-cost trick `import_round_trip.rs` explains; nothing here -//! weakens what the spawned processes run. +//! UTC instant — the case the importer writes as the capture timestamp since `S-B16`, and +//! therefore the case the repair can recover. The Argon2id seeding is the fast-cost trick +//! `import_round_trip.rs` explains; nothing here weakens what the spawned processes run. +//! +//! **The pre-`S-B16` library** the repair exists for is reproduced by importing under the +//! fixed parser and then stamping one asset's sidecar with the import clock through the very +//! API the repair uses (`Workspace::set_capture_timestamp`). That reproduces the half the +//! repair reads — a signed sidecar carrying `now` under a still-EXIF-bearing original — and it +//! is the only way to produce it from a build whose importer no longer has the bug. It does +//! **not** reproduce the other half of the old shape: the bug also sharded the bundle into the +//! import month, whereas here the shard is the EXIF month, so the post-repair +//! directory-vs-timestamp drift and `Workspace::open`'s reconciliation of it are exercised by +//! `capsule-core`'s `lifecycle::metadata` unit test (a no-EXIF import corrected to 2001), not +//! here. //! //! ## Test list //! @@ -14,6 +27,11 @@ //! a hash prefix from `shasum` and the asset id both resolve, and the page carries the //! EXIF-derived values. //! - `show_refuses_a_malformed_or_unknown_selector` — non-zero exit with a localized reason. +//! - `repair_is_a_no_op_on_a_library_imported_after_the_parser_fix` — the `S-B17` +//! "no-op post-`S-B16`" criterion. +//! - `repair_reports_by_default_and_corrects_under_apply` — detects the reproduced bug, +//! writes nothing without `--apply`, corrects with it, keeps the bundle in its original +//! month directory, survives `capsule library rebuild`, and finds nothing on a second run. use std::io::Write as _; use std::path::{Path, PathBuf}; @@ -351,3 +369,157 @@ fn show_refuses_a_malformed_or_unknown_selector() { let short = fx.run_fails(&["show", "abcdef", "--library", library, "--passphrase-stdin"]); assert!(short.contains("at least 8"), "{short}"); } + +// ── `capsule repair capture-time` (S-B17) ──────────────────────────────────── + +impl Fixture { + /// `capsule repair capture-time --library [--apply]`. + fn repair(&self, apply: bool) -> String { + let mut args = vec![ + "repair", + "capture-time", + "--library", + path(&self.library), + "--passphrase-stdin", + ]; + if apply { + args.push("--apply"); + } + self.run(&args) + } + + /// The month directory holding the asset's original, relative to the library root. + fn month_dir(&self, ws: &Workspace, id: Uuid) -> PathBuf { + ws.original_path(&id) + .expect("original") + .parent() + .expect("month directory") + .strip_prefix(&self.library) + .expect("inside the library") + .to_path_buf() + } +} + +/// **The "no-op after `S-B16`" half of the criterion.** Every asset here was imported by the +/// fixed parser, so each sidecar already carries its EXIF instant, and the pass reports nothing. +#[test] +fn repair_is_a_no_op_on_a_library_imported_after_the_parser_fix() { + let fx = fixture(2); + let out = fx.repair(false); + assert!(out.contains("Checked 2 asset(s)"), "{out}"); + assert!(out.contains("nothing to repair"), "{out}"); + assert!(!out.contains("affected,"), "{out}"); + let ws = fx.reopen(); + for image in &fx.images { + let id = fx.asset_for(&ws, image); + assert_eq!(ws.asset(&id).expect("asset").chain.records().len(), 1); + } +} + +/// **The repair itself**, across process boundaries: one of two assets is stamped with the +/// import clock (the pre-`S-B16` shape); a dry run reports it and writes nothing; `--apply` +/// corrects it as a signed record without moving the bundle; `capsule show` and a +/// `capsule library rebuild` both read the corrected instant; a second `--apply` finds nothing. +#[test] +fn repair_reports_by_default_and_corrects_under_apply() { + let fx = fixture(2); + let (broken_image, good_image) = (&fx.images[0], &fx.images[1]); + + // Reproduce the bug on one asset, in-process, and remember where its bundle lives. + let (broken, month_before) = { + let mut ws = fx.reopen(); + let broken = fx.asset_for(&ws, broken_image); + ws.set_capture_timestamp(&broken, jiff::Timestamp::now()) + .expect("reproduce the pre-S-B16 stamp"); + let month = fx.month_dir(&ws, broken); + (broken, month) + }; + assert!( + !fx.show(&broken.to_string()).contains(EXIF_CAPTURE), + "the reproduced stamp is no longer the EXIF instant" + ); + + // Dry run (the default): reported, not written. + let dry = fx.repair(false); + assert!(dry.contains("Checked 2 asset(s)"), "{dry}"); + assert!(dry.contains("Dry run"), "{dry}"); + assert!(dry.contains(&broken.to_string()), "{dry}"); + assert!( + dry.contains(EXIF_CAPTURE), + "the recovered instant is named:\n{dry}" + ); + assert!( + dry.contains("1 affected, 0 corrected, 1 already correct"), + "{dry}" + ); + { + let ws = fx.reopen(); + assert_eq!(ws.asset(&broken).expect("asset").chain.records().len(), 2); + assert!( + !ws.asset(&broken) + .expect("asset") + .sidecar + .capture_timestamp + .starts_with("2019") + ); + } + + // `--apply`: corrected as a third signed record; the bundle stays where it was. + let applied = fx.repair(true); + assert!(!applied.contains("Dry run"), "{applied}"); + assert!( + applied.contains("1 affected, 1 corrected, 1 already correct"), + "{applied}" + ); + assert!( + applied.contains("month directory"), + "the drift notice:\n{applied}" + ); + { + let ws = fx.reopen(); + let asset = ws.asset(&broken).expect("asset"); + assert_eq!(asset.sidecar.capture_timestamp, EXIF_CAPTURE); + assert_eq!(asset.chain.records().len(), 3); + assert_eq!( + fx.month_dir(&ws, broken), + month_before, + "the bundle is not relocated" + ); + assert!(ws.original_path(&broken).expect("original").is_file()); + let good = fx.asset_for(&ws, good_image); + assert_eq!( + ws.asset(&good).expect("asset").chain.records().len(), + 1, + "untouched" + ); + } + let page = fx.show(&broken.to_string()); + assert!( + page.contains(&format!("Captured: {EXIF_CAPTURE}")), + "{page}" + ); + assert!( + page.contains("Provenance: 3 signed record(s)"), + "{page}" + ); + + // The two index projections agree: a rebuild from disk reads the same corrected instant. + let rebuilt = capsule(&fx.home, &["library", "rebuild", path(&fx.library)], false); + assert!(rebuilt.contains("Index rebuilt successfully."), "{rebuilt}"); + { + let library = capsule_core::library::open_library(&fx.library).expect("open"); + let row = library + .db + .find_by_uuid(&broken.to_string()) + .expect("query") + .expect("indexed"); + assert_eq!(row.capture_timestamp, 1_551_675_967, "2019-03-04T05:06:07Z"); + } + assert!(fx.show(&broken.to_string()).contains(EXIF_CAPTURE)); + + // Idempotent: nothing left to do. + let again = fx.repair(true); + assert!(again.contains("nothing to repair"), "{again}"); + let ws = fx.reopen(); + assert_eq!(ws.asset(&broken).expect("asset").chain.records().len(), 3); +} diff --git a/capsule-i18n/src/bundles/en.json b/capsule-i18n/src/bundles/en.json index 05902f46..4360cd2c 100644 --- a/capsule-i18n/src/bundles/en.json +++ b/capsule-i18n/src/bundles/en.json @@ -1775,6 +1775,12 @@ "cli.help.push.arg.library": "Path to the Capsule library to push", "cli.help.push.arg.passphrase_stdin": "Read the library passphrase from stdin instead of prompting, so pushes work in scripts and CI where there is no terminal", "cli.help.push.arg.staged": "Open the tier sessions in ladder order (index → preview → original), gating the above-index tiers on the connection class, instead of opening all eagerly", + "cli.help.repair.about": "Repair a local library in place, one signed correction per affected asset", + "cli.help.repair.capture-time.about": "Re-read each original's EXIF capture time and report every asset whose signed capture timestamp disagrees with it; with --apply, correct each one as a signed metadata update", + "cli.help.repair.capture-time.arg.apply": "Write the corrections. Without this flag the pass only reports what it would change; each correction is an irreversible signed record on the asset's chain", + "cli.help.repair.capture-time.arg.library": "Path to the Capsule library", + "cli.help.repair.capture-time.arg.limit": "Correct at most this many affected assets in one --apply run (the report still covers the whole library)", + "cli.help.repair.capture-time.arg.passphrase_stdin": "Read the library passphrase from stdin instead of prompting, so the command works in scripts and CI where there is no terminal", "cli.help.reset.about": "Reset all local CLI data", "cli.help.reset.arg.all": "Reset all data", "cli.help.reset.arg.cache": "Reset cache directory", @@ -1819,6 +1825,18 @@ "cli.push.in_progress": "Pushing {assets} asset(s) to {endpoint}…", "cli.push.nothing_to_push": "The library holds no assets to push.", "cli.push.up_to_date": "Everything in this library is already on the server.", + "cli.repair.capture_time.checked": "Checked {count} asset(s) against the EXIF capture time of their originals.", + "cli.repair.capture_time.corrected": " {asset_id}: corrected", + "cli.repair.capture_time.drift_notice": "Corrected assets stay in the month directory they were imported into; that drift between directory and capture date is expected after a correction and is not a fault.", + "cli.repair.capture_time.dry_run_notice": "Dry run: nothing was written. Re-run with --apply to correct the affected sidecars.", + "cli.repair.capture_time.failed_asset": "Correcting {asset_id} failed: {reason}. Assets corrected before it stay corrected; nothing after it was touched.", + "cli.repair.capture_time.limit_notice": "Stopped after {corrected} correction(s) because of --limit; re-run to continue.", + "cli.repair.capture_time.nothing": "Every capture timestamp agrees with its original's EXIF; nothing to repair.", + "cli.repair.capture_time.row": " {asset_id}: recorded {recorded}, EXIF says {recovered} (off by {delta} s)", + "cli.repair.capture_time.row_unparseable": " {asset_id}: recorded {recorded} (not a timestamp), EXIF says {recovered}", + "cli.repair.capture_time.summary": "Capture-time repair: {affected} affected, {corrected} corrected, {agrees} already correct, {no_instant} without a recoverable EXIF instant, {unreadable} unreadable.", + "cli.repair.capture_time.unreadable": " {asset_id}: original could not be read ({reason}); skipped", + "cli.repair.failed": "Repair failed: {reason}", "cli.show.album": " Album: {value}", "cli.show.ambiguous": "{selector} matches {count} assets; give more of the hash, or the full asset id.", "cli.show.caption": " Caption: {value}", diff --git a/capsule-swift/Generated/Localizable.xcstrings b/capsule-swift/Generated/Localizable.xcstrings index 5dc8916f..3250250b 100644 --- a/capsule-swift/Generated/Localizable.xcstrings +++ b/capsule-swift/Generated/Localizable.xcstrings @@ -30581,6 +30581,66 @@ } } }, + "cli.help.repair.about": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Repair a local library in place, one signed correction per affected asset" + } + } + } + }, + "cli.help.repair.capture-time.about": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Re-read each original's EXIF capture time and report every asset whose signed capture timestamp disagrees with it; with --apply, correct each one as a signed metadata update" + } + } + } + }, + "cli.help.repair.capture-time.arg.apply": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Write the corrections. Without this flag the pass only reports what it would change; each correction is an irreversible signed record on the asset's chain" + } + } + } + }, + "cli.help.repair.capture-time.arg.library": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Path to the Capsule library" + } + } + } + }, + "cli.help.repair.capture-time.arg.limit": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Correct at most this many affected assets in one --apply run (the report still covers the whole library)" + } + } + } + }, + "cli.help.repair.capture-time.arg.passphrase_stdin": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Read the library passphrase from stdin instead of prompting, so the command works in scripts and CI where there is no terminal" + } + } + } + }, "cli.help.reset.about": { "localizations": { "en": { @@ -31453,6 +31513,126 @@ } } }, + "cli.repair.capture_time.checked": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Checked %@ asset(s) against the EXIF capture time of their originals." + } + } + } + }, + "cli.repair.capture_time.corrected": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": " %@: corrected" + } + } + } + }, + "cli.repair.capture_time.drift_notice": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Corrected assets stay in the month directory they were imported into; that drift between directory and capture date is expected after a correction and is not a fault." + } + } + } + }, + "cli.repair.capture_time.dry_run_notice": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dry run: nothing was written. Re-run with --apply to correct the affected sidecars." + } + } + } + }, + "cli.repair.capture_time.failed_asset": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Correcting %1$@ failed: %2$@. Assets corrected before it stay corrected; nothing after it was touched." + } + } + } + }, + "cli.repair.capture_time.limit_notice": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stopped after %@ correction(s) because of --limit; re-run to continue." + } + } + } + }, + "cli.repair.capture_time.nothing": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Every capture timestamp agrees with its original's EXIF; nothing to repair." + } + } + } + }, + "cli.repair.capture_time.row": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": " %1$@: recorded %2$@, EXIF says %3$@ (off by %4$@ s)" + } + } + } + }, + "cli.repair.capture_time.row_unparseable": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": " %1$@: recorded %2$@ (not a timestamp), EXIF says %3$@" + } + } + } + }, + "cli.repair.capture_time.summary": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Capture-time repair: %1$@ affected, %2$@ corrected, %3$@ already correct, %4$@ without a recoverable EXIF instant, %5$@ unreadable." + } + } + } + }, + "cli.repair.capture_time.unreadable": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": " %1$@: original could not be read (%2$@); skipped" + } + } + } + }, + "cli.repair.failed": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Repair failed: %@" + } + } + } + }, "cli.show.album": { "localizations": { "en": { diff --git a/capsule-web/src/i18n/messages/en.json b/capsule-web/src/i18n/messages/en.json index 05902f46..4360cd2c 100644 --- a/capsule-web/src/i18n/messages/en.json +++ b/capsule-web/src/i18n/messages/en.json @@ -1775,6 +1775,12 @@ "cli.help.push.arg.library": "Path to the Capsule library to push", "cli.help.push.arg.passphrase_stdin": "Read the library passphrase from stdin instead of prompting, so pushes work in scripts and CI where there is no terminal", "cli.help.push.arg.staged": "Open the tier sessions in ladder order (index → preview → original), gating the above-index tiers on the connection class, instead of opening all eagerly", + "cli.help.repair.about": "Repair a local library in place, one signed correction per affected asset", + "cli.help.repair.capture-time.about": "Re-read each original's EXIF capture time and report every asset whose signed capture timestamp disagrees with it; with --apply, correct each one as a signed metadata update", + "cli.help.repair.capture-time.arg.apply": "Write the corrections. Without this flag the pass only reports what it would change; each correction is an irreversible signed record on the asset's chain", + "cli.help.repair.capture-time.arg.library": "Path to the Capsule library", + "cli.help.repair.capture-time.arg.limit": "Correct at most this many affected assets in one --apply run (the report still covers the whole library)", + "cli.help.repair.capture-time.arg.passphrase_stdin": "Read the library passphrase from stdin instead of prompting, so the command works in scripts and CI where there is no terminal", "cli.help.reset.about": "Reset all local CLI data", "cli.help.reset.arg.all": "Reset all data", "cli.help.reset.arg.cache": "Reset cache directory", @@ -1819,6 +1825,18 @@ "cli.push.in_progress": "Pushing {assets} asset(s) to {endpoint}…", "cli.push.nothing_to_push": "The library holds no assets to push.", "cli.push.up_to_date": "Everything in this library is already on the server.", + "cli.repair.capture_time.checked": "Checked {count} asset(s) against the EXIF capture time of their originals.", + "cli.repair.capture_time.corrected": " {asset_id}: corrected", + "cli.repair.capture_time.drift_notice": "Corrected assets stay in the month directory they were imported into; that drift between directory and capture date is expected after a correction and is not a fault.", + "cli.repair.capture_time.dry_run_notice": "Dry run: nothing was written. Re-run with --apply to correct the affected sidecars.", + "cli.repair.capture_time.failed_asset": "Correcting {asset_id} failed: {reason}. Assets corrected before it stay corrected; nothing after it was touched.", + "cli.repair.capture_time.limit_notice": "Stopped after {corrected} correction(s) because of --limit; re-run to continue.", + "cli.repair.capture_time.nothing": "Every capture timestamp agrees with its original's EXIF; nothing to repair.", + "cli.repair.capture_time.row": " {asset_id}: recorded {recorded}, EXIF says {recovered} (off by {delta} s)", + "cli.repair.capture_time.row_unparseable": " {asset_id}: recorded {recorded} (not a timestamp), EXIF says {recovered}", + "cli.repair.capture_time.summary": "Capture-time repair: {affected} affected, {corrected} corrected, {agrees} already correct, {no_instant} without a recoverable EXIF instant, {unreadable} unreadable.", + "cli.repair.capture_time.unreadable": " {asset_id}: original could not be read ({reason}); skipped", + "cli.repair.failed": "Repair failed: {reason}", "cli.show.album": " Album: {value}", "cli.show.ambiguous": "{selector} matches {count} assets; give more of the hash, or the full asset id.", "cli.show.caption": " Caption: {value}", diff --git a/locales/en.json b/locales/en.json index 22fe08f3..9e4e1bda 100644 --- a/locales/en.json +++ b/locales/en.json @@ -7103,6 +7103,30 @@ "message": "Open the tier sessions in ladder order (index → preview → original), gating the above-index tiers on the connection class, instead of opening all eagerly", "context": "clap --help: help for the `--staged` flag of `capsule push`." }, + "cli.help.repair.about": { + "message": "Repair a local library in place, one signed correction per affected asset", + "context": "clap --help: the one-line description of `capsule repair`. Shown in the parent's command list and at the top of its own --help." + }, + "cli.help.repair.capture-time.about": { + "message": "Re-read each original's EXIF capture time and report every asset whose signed capture timestamp disagrees with it; with --apply, correct each one as a signed metadata update", + "context": "clap --help: the one-line description of `capsule repair capture-time`. Shown in the parent's command list and at the top of its own --help." + }, + "cli.help.repair.capture-time.arg.apply": { + "message": "Write the corrections. Without this flag the pass only reports what it would change; each correction is an irreversible signed record on the asset's chain", + "context": "clap --help: help for the `--apply` flag of `capsule repair capture-time`." + }, + "cli.help.repair.capture-time.arg.library": { + "message": "Path to the Capsule library", + "context": "clap --help: help for the `--library` option of `capsule repair capture-time`." + }, + "cli.help.repair.capture-time.arg.limit": { + "message": "Correct at most this many affected assets in one --apply run (the report still covers the whole library)", + "context": "clap --help: help for the `--limit` option of `capsule repair capture-time`." + }, + "cli.help.repair.capture-time.arg.passphrase_stdin": { + "message": "Read the library passphrase from stdin instead of prompting, so the command works in scripts and CI where there is no terminal", + "context": "clap --help: help for the `--passphrase-stdin` flag of `capsule repair capture-time`." + }, "cli.help.reset.about": { "message": "Reset all local CLI data", "context": "clap --help: the one-line description of `capsule reset`. Shown in the parent's command list and at the top of its own --help." @@ -7279,6 +7303,54 @@ "message": "Everything in this library is already on the server.", "context": "CLI line when `capsule push` finds every blob already held server-side." }, + "cli.repair.capture_time.checked": { + "message": "Checked {count} asset(s) against the EXIF capture time of their originals.", + "context": "CLI status line from `capsule repair capture-time` after every asset's original has been re-read. {count} is the number of assets examined." + }, + "cli.repair.capture_time.corrected": { + "message": " {asset_id}: corrected", + "context": "CLI one-line confirmation in `capsule repair capture-time --apply` that the asset's capture timestamp was rewritten as a signed metadata update." + }, + "cli.repair.capture_time.drift_notice": { + "message": "Corrected assets stay in the month directory they were imported into; that drift between directory and capture date is expected after a correction and is not a fault.", + "context": "CLI note printed after `capsule repair capture-time --apply` corrected at least one asset: the media files are not moved to a new date directory." + }, + "cli.repair.capture_time.dry_run_notice": { + "message": "Dry run: nothing was written. Re-run with --apply to correct the affected sidecars.", + "context": "CLI notice from `capsule repair capture-time` when run without --apply (the default). `--apply` is a command-line flag, shown verbatim." + }, + "cli.repair.capture_time.failed_asset": { + "message": "Correcting {asset_id} failed: {reason}. Assets corrected before it stay corrected; nothing after it was touched.", + "context": "CLI failure detail from `capsule repair capture-time --apply` when the library refuses a correction. {reason} is an English error detail." + }, + "cli.repair.capture_time.limit_notice": { + "message": "Stopped after {corrected} correction(s) because of --limit; re-run to continue.", + "context": "CLI notice from `capsule repair capture-time --apply --limit N` when affected assets remain after N corrections. `--limit` is a command-line flag, shown verbatim." + }, + "cli.repair.capture_time.nothing": { + "message": "Every capture timestamp agrees with its original's EXIF; nothing to repair.", + "context": "CLI line from `capsule repair capture-time` when no asset is affected and every original was readable." + }, + "cli.repair.capture_time.row": { + "message": " {asset_id}: recorded {recorded}, EXIF says {recovered} (off by {delta} s)", + "context": "CLI one-line report of one affected asset in `capsule repair capture-time`. {recorded} is the capture timestamp the signed sidecar holds, {recovered} the instant its original's EXIF resolves to, {delta} the difference in seconds (recovered minus recorded, may be negative)." + }, + "cli.repair.capture_time.row_unparseable": { + "message": " {asset_id}: recorded {recorded} (not a timestamp), EXIF says {recovered}", + "context": "CLI one-line report of one affected asset in `capsule repair capture-time` whose recorded capture timestamp does not parse, so no difference can be computed." + }, + "cli.repair.capture_time.summary": { + "message": "Capture-time repair: {affected} affected, {corrected} corrected, {agrees} already correct, {no_instant} without a recoverable EXIF instant, {unreadable} unreadable.", + "context": "CLI summary line of `capsule repair capture-time`. All placeholders are asset counts." + }, + "cli.repair.capture_time.unreadable": { + "message": " {asset_id}: original could not be read ({reason}); skipped", + "context": "CLI one-line report in `capsule repair capture-time` for an asset whose original file could not be opened. {reason} is an English I/O error detail. Distinct from an original that merely carries no EXIF, which is counted, not listed." + }, + "cli.repair.failed": { + "message": "Repair failed: {reason}", + "context": "CLI failure line for `capsule repair`. {reason} is a localized or English error detail." + }, "cli.show.album": { "message": " Album: {value}", "context": "CLI field row of `capsule show`: the owning album's UUID. Keep the label padded so values align in a monospace terminal." From c5139dbe161f98c96f18b238b5f353af1daa2432 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 05:26:53 -0400 Subject: [PATCH 111/243] docs(slices): record S-I8, S-B18 and S-B17 as landed, and file S-B11's remainder S-I8: help is localized via the cli.help.* keys; the ValueEnum residual stays English. S-B18: `capsule show`, selected by asset id or content-hash prefix; the guide's sampling step is executable. S-B17: `capsule repair capture-time`, dry run by default, over the importer's own resolution rule, with the index-projection precondition and the expected post-repair drift recorded. S-B11 stays done* and links #452, which carries the real-archive run this machine cannot perform. --- SLICES.md | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 61 insertions(+), 4 deletions(-) diff --git a/SLICES.md b/SLICES.md index 250f7ccc..9eba03cb 100644 --- a/SLICES.md +++ b/SLICES.md @@ -220,14 +220,14 @@ row's remainder now lives. | S-B8 | Immich importer | media/import | S-B6 | M | MIXED | post-v1 | | | S-B9 | Tethered camera import (PTP/IP) | media/import | S-B2 | L | MIXED | post-v1 | `ptpip-rs` gate | | S-B10 | Takeout metadata → signed sidecar enrichment | media/import | S-A10 | M | ACTIVE | done | four doc rows owed; streaming path → `S-B3`/`S-B11` | -| S-B11 | CLI `import --provider takeout` + real-archive run | media/import | S-B10 | S | ACTIVE | done\* | synthesized archive only; real export owed | -| S-B18 | No CLI surface shows what the importer actually wrote | media/import | S-B10 | S | ACTIVE | ready | users cannot verify enrichment | +| S-B11 | CLI `import --provider takeout` + real-archive run | media/import | S-B10 | S | ACTIVE | done\* | synthesized archive only; real export → #452 | +| S-B18 | No CLI surface shows what the importer actually wrote | media/import | S-B10 | S | ACTIVE | done | `capsule show`; the guide's sampling step is executable | | S-B12 | Base default-album resolution (`resolve_default_album`) | media/import | — | M | ACTIVE | done | scope-override + source-kind rows → post-v1 | | S-B13 | Codec stubs → typed `UnsupportedFormat` (no panics) | media/import | — | M | RETIRED | ready | | | S-B14 | LQIP on Chromahash 0.7.1 in `capsule-core::lqip` | media/import | — | M | ACTIVE | done | wasm entry point owed to the browser-`lqip` slice | | S-B15 | Importer-formed stacks exist only in the index | media/import | S-D21 | M | ACTIVE | done | rebuild guard kept as pre-`S-B15` compatibility | | S-B16 | Every import stamped by import time, not capture time | media/import | — | S | ACTIVE | done | found by the CLI round-trip test | -| S-B17 | Repair capture timestamps written before `S-B16` | media/import | S-B16 | M | ACTIVE | ready | the wrong value is in *signed* bytes | +| S-B17 | Repair capture timestamps written before `S-B16` | media/import | S-B16 | M | ACTIVE | done | `capsule repair capture-time`; dry run by default | | S-C1 | Upload-server hardening (envelope gate + invariants) | server | — | L | RETIRED | done\* | discard worker, asset index and quota not ported | | S-C2 | Key-free sync feed | server | S-C1 | L | RETIRED | done\* | ported to Kynos REST; Postgres adapter + cursor-key loading owed | | S-C3 | Storage-verification endpoint | server | S-C35, S-C37 | M | RETIRED | done\* | structural verdict only; the `deep` re-hash → `S-C41`; GC state → `S-C11` | @@ -348,7 +348,7 @@ row's remainder now lives. | S-I5 | The CLI import arm has no `cli.import.*` catalog namespace | i18n | — | M | ACTIVE | ready | `i18n-guard` never scanned the CLI | | S-I6 | Android ships raw ICU to users; the guard never fires | i18n | — | M | ACTIVE | done | `aapt2` unverified — owed-CI | | S-I7 | The Rust runtime formatter cannot do ICU plurals | i18n | — | M | ACTIVE | done\* | refuses now; evaluating plurals still owed | -| S-I8 | clap `--help` text is unreachable from the catalogs | i18n | — | S | ACTIVE | ready | found widening `i18n-guard` | +| S-I8 | clap `--help` text is unreachable from the catalogs | i18n | — | S | ACTIVE | done | help is localized via `cli.help.*`; `ValueEnum` variant help stays English | | S-N1 | OIDC relying party (server) | auth | — | L | RETIRED | ready | | | S-N2 | SDK/CLI OIDC login flows | auth | S-N1 | M | MIXED | blocked | | | S-N3 | `device_id` on session listing + ceremony cohorts | auth | — | S | RETIRED | done | the wire half lands with `S-C13`; the TOTP ceremony with `S-C55`; passkeys retire on `S-C56` | @@ -869,6 +869,10 @@ workspace at all**, so every still import is a `DeferredNoCodec` until Rawshift check against Google's own item count, real camera EXIF across the device long tail, real HEIC/MP4/Live Photo payloads, or how Google actually encodes non-ASCII filenames. There is no real Takeout archive on this machine and no claim is made about one. +- **The remainder is filed as #452** (2026-09-02, while landing `S-B18`/`S-B17`): the real-archive + run — magnitude against Google's item count, real camera EXIF across the device long tail, real + HEIC/MP4/Live Photo payloads, Google's actual filename encoding, scale — plus the guide's sampling + step, which `S-B18` made executable. The row stays `done*` until that issue closes. ### S-B18 — no CLI surface shows what the importer actually wrote @@ -885,6 +889,19 @@ workspace at all**, so every still import is a `DeferredNoCodec` until Rawshift do not exist yet (`S-I5`). - **Done when:** the guide's metadata-sampling step is executable as written. **Tier:** Smoke. +- **Landed 2026-09-02** as a new verb, `capsule show --library `, not an extension of + `capsule match` — `match` reads a *source file* and opens no library, so it was the wrong seam. + The positional takes an asset id **or a hex prefix (≥ 8 characters) of the content hash**, because + nothing `capsule import` prints is an asset id while the guide's spot-hash step already leaves the + user holding source SHA-256s, and Capsule imports bytes unchanged; an ambiguous prefix is refused + with the match count. It prints the signed sidecar's projection — album, content type, hash, + dimensions, capture/import instants, caption, rating, user and AI tags, the fix **with its datum** + and source (a GCJ-02 coordinate stored verbatim must not read as WGS-84), cull flag, hidden, stack + placement, LQIP presence, and the provenance record count — every absent value spelled out as + `(unset)`, every line a `cli.show.*` key. The guide's sampling step is rewritten as an executable + `capsule show` loop and asserted as written in `tests/takeout_import.rs`. No `--json`: a machine + shape would need its own compatibility contract, and the guide needs the human one. + ### S-B12 — Base default-album resolution - **Contract:** [Organization — The Default Album + Scope Grammar](capsule-docs/src/content/docs/design/organization.md) @@ -1079,6 +1096,31 @@ workspace at all**, so every still import is a `DeferredNoCodec` until Rawshift dates, reports correct capture timestamps after the pass, and the pass is a no-op on a library imported after `S-B16`. **Tier:** Unit + Smoke. +- **Landed 2026-09-02** as `capsule repair capture-time --library [--apply] [--limit N]` + over a new `Workspace::set_capture_timestamp(asset_id, jiff::Timestamp)` — one signed + `metadata-update` per asset through `append_lifecycle`, the bundle left in its import-time month + directory, `Workspace::open`'s existing reconciliation keeping the paths resolving. **Dry run is + the default**, unlike `push`/`sync`: those write to a re-drivable server, this appends an + irreversible signed record. **Detection is the importer's own rule**, `resolve_timezone` over + `extract_exif`: a resolvable instant that disagrees with the sidecar is affected; a floating + `DateTimeOriginal` (no `OffsetTimeOriginal`, no fix) resolves to nothing and is skipped rather + than guessed as UTC, exactly as the importer skips it — which is what makes the pass a no-op on a + post-`S-B16` library by construction, and leaves Takeout-folded captures alone. An unreadable + original is reported as such, never as "no EXIF". Each correction is an independent write, so an + interrupted `--apply` leaves completed assets correct and a re-run skips them. +- **Precondition fixed while here:** the live write path indexed `capture_timestamp` from the + in-memory `capture_utc` shard while `rebuild_index` projects it from the sidecar; equal at import, + they part ways at exactly this correction, so a correct repair would have been invisible to the + timeline until a rebuild. `asset_row_from_state` now projects from the sidecar too, and the test + asserts the live row and a rebuilt one name the same corrected instant. +- **Expected side effect:** a corrected asset's sidecar no longer names its month directory, so + every `Workspace::open` logs the reconciliation warning for it. That is the documented drift, not a + fault; the opportunistic rename bundle maintenance describes is not built. +- **Not covered:** an asset whose capture time a user deliberately set to something other than its + EXIF. No such edit surface exists — `set_capture_timestamp` is the first — so the question does + not arise; once one does, the pass must skip assets whose chain carries a capture correction that + was not itself this repair. + ## Lane C — server (key-free surfaces) Area: `RETIRED` throughout. Every slice here targets `capsule-api/**`, which is the @@ -4871,6 +4913,21 @@ lands on Kynos rather than on Salvo. contract stops overstating itself. - **Done when:** the design doc states the decision either way. **Tier:** docs or Unit. +- **Landed 2026-09-02 — help is localized.** `capsule_cli::cli::help::localize` walks the built + `clap::Command` tree and replaces every `about`/`long_about`/`help`/`long_help` from keys derived + from the tree — `cli.help..about`, `cli.help..arg.`, `…long_about`/`…long_help` + when the derive gives a distinct long form — through `Bundle::message`, so a missing key leaves + the derive text in place and a partial translation renders a mix, never a raw key. `run()` + applies it under the negotiated bundle; `command_tree()` (`S-Z8`) applies it under an explicitly + pinned `en` bundle, so `cli-surface.json` is locale-proof and byte-unchanged. The gate this + surface needed, since `i18n-guard` cannot see help text: a unit test that every `en` entry equals + the derive text it replaces and that localizing under `en` leaves every rendered page + byte-identical — a doc comment edited without its key fails `cargo test -p capsule-cli`. The + design doc records the decision and the residual: a `ValueEnum` variant's help (`--filter pick`) + stays English, because clap 4 re-words a possible value only by discarding the typed parser. + The twelve non-source locales carry no `cli.help.*` entries yet; translators fill them through + the documented `locales/` flow. + ### S-N1 — OIDC relying party (server) - **Contract:** [Authentication — Design Principles + Choosing an Auth Path](capsule-docs/src/content/docs/design/authentication.md). From a48875ba1a1df7a939f2950452b1e96f1ee36093 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 05:35:20 -0400 Subject: [PATCH 112/243] build(server): add redis-rs and the Valkey container test dependencies `redis` 1.2.2 (the version the workspace declares; the caret pin resolved to 1.6.0 and is locked back with `--precise`) with `tokio-rustls-comp` so a `rediss://` URL terminates TLS in rustls, and `script` for the Lua scripts every multi-key mutation becomes. `testcontainers` and `testcontainers-modules` (`valkey`) as dev-dependencies for the env-gated live suite. No `bb8`: one multiplexed `ConnectionManager` is the whole of what a server talking to one Valkey needs. The Volatile state row in design/dependencies.md records the scope, the primitives and the two rejections (a pool, Redis Cluster). Refs #403 --- Cargo.lock | 846 ++++++++++++++++-- .../src/content/docs/design/dependencies.md | 1 + capsule-server/Cargo.toml | 19 + 3 files changed, 814 insertions(+), 52 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 575bda5e..04a24a3f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -158,7 +158,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -169,7 +169,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -178,6 +178,21 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arcstr" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d" + [[package]] name = "argon2" version = "0.5.3" @@ -244,6 +259,22 @@ dependencies = [ "winnow 0.7.15", ] +[[package]] +name = "astral-tokio-tar" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec179a06c1769b1e42e1e2cbe74c7dcdb3d6383c838454d063eaac5bbb7ebbe5" +dependencies = [ + "filetime", + "futures-core", + "libc", + "portable-atomic", + "rustc-hash", + "tokio", + "tokio-stream", + "xattr", +] + [[package]] name = "async-compat" version = "0.2.5" @@ -257,6 +288,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -334,6 +376,58 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit 0.8.4", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", +] + [[package]] name = "backtrace" version = "0.3.76" @@ -367,6 +461,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -485,6 +585,83 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "bollard" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a52479c9237eb04047ddb94788c41ca0d26eaff8b697ecfbb4c32f7fdc3b1b" +dependencies = [ + "async-stream", + "base64 0.22.1", + "bitflags 2.13.0", + "bollard-buildkit-proto", + "bollard-stubs", + "bytes", + "chrono", + "futures-core", + "futures-util", + "hex", + "home", + "http", + "http-body-util", + "hyper", + "hyper-named-pipe", + "hyper-rustls", + "hyper-util", + "hyperlocal", + "log", + "num", + "pin-project-lite", + "rand 0.9.4", + "rustls", + "rustls-native-certs", + "rustls-pemfile", + "rustls-pki-types", + "serde", + "serde_derive", + "serde_json", + "serde_repr", + "serde_urlencoded", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tokio-util", + "tonic", + "tower-service", + "url", + "winapi", +] + +[[package]] +name = "bollard-buildkit-proto" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a885520bf6249ab931a764ffdb87b0ceef48e6e7d807cfdb21b751e086e1ad" +dependencies = [ + "prost 0.14.4", + "prost-types 0.14.4", + "tonic", + "tonic-prost", + "ureq", +] + +[[package]] +name = "bollard-stubs" +version = "1.49.1-rc.28.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5731fe885755e92beff1950774068e0cae67ea6ec7587381536fca84f1779623" +dependencies = [ + "base64 0.22.1", + "bollard-buildkit-proto", + "bytes", + "chrono", + "prost 0.14.4", + "serde", + "serde_json", + "serde_repr", + "serde_with", +] + [[package]] name = "borrow-or-share" version = "0.2.4" @@ -515,6 +692,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bstr" version = "1.12.1" @@ -590,7 +776,7 @@ checksum = "6b5271031022835ee8c7582fe67403bd6cb3d962095787af7921027234bab5bf" name = "capsule-cli" version = "0.1.0" dependencies = [ - "base64", + "base64 0.22.1", "capitalize", "capsule-cli-entity", "capsule-cli-migration", @@ -605,7 +791,7 @@ dependencies = [ "eyre", "futures", "humansize", - "indexmap", + "indexmap 2.14.0", "jiff", "nanoid", "sea-orm", @@ -657,7 +843,7 @@ dependencies = [ "hex", "hkdf", "hmac", - "indexmap", + "indexmap 2.14.0", "jiff", "kamadak-exif", "ml-dsa", @@ -720,7 +906,7 @@ dependencies = [ name = "capsule-sdk" version = "0.1.0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "capsule-core", "capsule-i18n", @@ -749,7 +935,7 @@ name = "capsule-server" version = "0.1.0" dependencies = [ "argon2", - "base64", + "base64 0.22.1", "bytes", "capsule-core", "capsule-i18n", @@ -761,12 +947,15 @@ dependencies = [ "jiff", "jsonwebtoken", "kynos", + "redis", "ring", "secrecy", "serde", "serde_json", "subtle", "tempfile", + "testcontainers", + "testcontainers-modules", "thiserror 2.0.20", "tokio", "totp-rs", @@ -779,7 +968,7 @@ dependencies = [ name = "capsule-wasm" version = "0.1.0" dependencies = [ - "base64", + "base64 0.22.1", "capsule-core", "hex", "uuid", @@ -1017,7 +1206,21 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", ] [[package]] @@ -1060,6 +1263,16 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -1235,8 +1448,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -1252,13 +1475,37 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", "quote", "syn 2.0.117", ] @@ -1406,7 +1653,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1420,6 +1667,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "docker_credential" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d" +dependencies = [ + "base64 0.22.1", + "serde", + "serde_json", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -1432,6 +1690,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "ecdsa" version = "0.16.9" @@ -1558,7 +1822,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1572,6 +1836,16 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + [[package]] name = "event-listener" version = "5.4.1" @@ -1583,6 +1857,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "eyre" version = "0.6.12" @@ -1622,6 +1906,17 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "ferroid" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb330bbd4cb7a5b9f559427f06f98a4f853a137c8298f3bd3f8ca57663e21986" +dependencies = [ + "portable-atomic", + "rand 0.9.4", + "web-time", +] + [[package]] name = "ff" version = "0.13.1" @@ -1974,7 +2269,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -2320,6 +2615,20 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-named-pipe" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fab3637d6b04a8037af8a266fdf6cf92ea957e8c53981a2bf6136572531025bf" +dependencies = [ + "hex", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-rustls" version = "0.27.9" @@ -2336,13 +2645,26 @@ dependencies = [ "webpki-roots 1.0.7", ] +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -2359,6 +2681,21 @@ dependencies = [ "tracing", ] +[[package]] +name = "hyperlocal" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" +dependencies = [ + "hex", + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -2504,6 +2841,17 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -2684,7 +3032,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" dependencies = [ "aws-lc-rs", - "base64", + "base64 0.22.1", "getrandom 0.2.17", "js-sys", "pem", @@ -2759,7 +3107,7 @@ dependencies = [ "jsonschema", "kynos-macros", "kynos-openapi", - "matchit", + "matchit 0.9.2", "percent-encoding", "serde", "serde_json", @@ -2787,7 +3135,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ce3286ca0f45019fd229a2a467a2d0ddeb074843614f09fee9b7370a0cb3eb9" dependencies = [ "http", - "indexmap", + "indexmap 2.14.0", "serde", "serde_json", "thiserror 2.0.20", @@ -3159,6 +3507,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "matchit" version = "0.9.2" @@ -3348,7 +3702,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3683,6 +4037,12 @@ dependencies = [ "tls_codec", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -3794,6 +4154,31 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "parse-display" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a1c2265c98e2446911282c6ac86d8524f495792c38c5bd884f80499c7538a" +dependencies = [ + "parse-display-derive", + "regex", + "regex-syntax", +] + +[[package]] +name = "parse-display-derive" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ae7800a4c974efd12df917266338e79a7a74415173caf7e70aa0a0707345281" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "regex-syntax", + "structmeta", + "syn 2.0.117", +] + [[package]] name = "password-hash" version = "0.5.0" @@ -3817,7 +4202,7 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] @@ -3843,7 +4228,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" dependencies = [ "fixedbitset", - "indexmap", + "indexmap 2.14.0", ] [[package]] @@ -3883,13 +4268,33 @@ version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "859d4117bd1b1dc5646359ee7243c50c5000c0920ea2d1fb120335a2f4c684b8" dependencies = [ - "base64", + "base64 0.22.1", "oid", "picky-asn1", "picky-asn1-der", "serde", ] +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -4080,7 +4485,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" dependencies = [ "bytes", - "prost-derive", + "prost-derive 0.13.5", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive 0.14.4", ] [[package]] @@ -4096,8 +4511,8 @@ dependencies = [ "once_cell", "petgraph", "prettyplease", - "prost", - "prost-types", + "prost 0.13.5", + "prost-types 0.13.5", "regex", "syn 2.0.117", "tempfile", @@ -4116,13 +4531,35 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "prost-types" version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" dependencies = [ - "prost", + "prost 0.13.5", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost 0.14.4", ] [[package]] @@ -4344,6 +4781,37 @@ dependencies = [ "yasna", ] +[[package]] +name = "redis" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a12e6b5f4d8ef33944e833e2b1859ad478deab6e431d7337b30ee2efe21f7543" +dependencies = [ + "arc-swap", + "arcstr", + "async-lock", + "backon", + "bytes", + "cfg-if", + "combine", + "futures-channel", + "futures-util", + "itoa", + "num-bigint", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-native-certs", + "ryu", + "sha1_smol", + "socket2", + "tokio", + "tokio-rustls", + "tokio-util", + "url", + "xxhash-rust", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -4460,7 +4928,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "futures-util", @@ -4672,7 +5140,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4681,6 +5149,7 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -4689,6 +5158,27 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -4731,6 +5221,39 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -4889,7 +5412,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bae0cbad6ab996955664982739354128c58d16e126114fe88c2a493642502aab" dependencies = [ - "darling", + "darling 0.20.11", "heck 0.4.1", "proc-macro2", "quote", @@ -4952,6 +5475,29 @@ dependencies = [ "zeroize", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -5015,6 +5561,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -5045,6 +5602,39 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "sha1" version = "0.10.6" @@ -5056,6 +5646,12 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" @@ -5212,7 +5808,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5222,7 +5818,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5baebe002620b367fea87f77f911a9de77031f7d9072d8da238624df2a7b413" dependencies = [ "camino", - "indexmap", + "indexmap 2.14.0", "jsonschema", "prettyplease", "proc-macro2", @@ -5298,7 +5894,7 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ - "base64", + "base64 0.22.1", "bigdecimal", "bytes", "chrono", @@ -5312,7 +5908,7 @@ dependencies = [ "futures-util", "hashbrown 0.15.5", "hashlink 0.10.0", - "indexmap", + "indexmap 2.14.0", "log", "memchr", "once_cell", @@ -5378,7 +5974,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bigdecimal", "bitflags 2.13.0", "byteorder", @@ -5425,14 +6021,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bigdecimal", "bitflags 2.13.0", "byteorder", "chrono", "crc", "dotenvy", - "etcetera", + "etcetera 0.8.0", "futures-channel", "futures-core", "futures-util", @@ -5517,6 +6113,29 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "structmeta" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e1575d8d40908d70f6fd05537266b90ae71b15dbbe7a8b7dffa2b759306d329" +dependencies = [ + "proc-macro2", + "quote", + "structmeta-derive", + "syn 2.0.117", +] + +[[package]] +name = "structmeta-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "strum" version = "0.26.3" @@ -5650,7 +6269,46 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", +] + +[[package]] +name = "testcontainers" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a81ec0158db5fbb9831e09d1813fe5ea9023a2b5e6e8e0a5fe67e2a820733629" +dependencies = [ + "astral-tokio-tar", + "async-trait", + "bollard", + "bytes", + "docker_credential", + "either", + "etcetera 0.11.0", + "ferroid", + "futures", + "itertools", + "log", + "memchr", + "parse-display", + "pin-project-lite", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tokio-util", + "url", +] + +[[package]] +name = "testcontainers-modules" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e75e78ff453128a2c7da9a5d5a3325ea34ea214d4bf51eab3417de23a4e5147" +dependencies = [ + "testcontainers", ] [[package]] @@ -5869,7 +6527,7 @@ version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde_core", "serde_spanned 1.1.1", "toml_datetime 0.7.5+spec-1.1.0", @@ -5911,7 +6569,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", @@ -5925,7 +6583,7 @@ version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ - "indexmap", + "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "winnow 1.0.3", @@ -5952,6 +6610,46 @@ version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost 0.14.4", + "tonic", +] + [[package]] name = "totp-rs" version = "5.7.1" @@ -5976,11 +6674,15 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", + "indexmap 2.14.0", "pin-project-lite", + "slab", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -6159,7 +6861,7 @@ dependencies = [ "bytes", "clap", "geometry-rs", - "prost", + "prost 0.13.5", "prost-build", "tzf-rel", ] @@ -6245,7 +6947,7 @@ dependencies = [ "glob", "goblin", "heck 0.5.0", - "indexmap", + "indexmap 2.14.0", "once_cell", "serde", "tempfile", @@ -6277,7 +6979,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4b42137524f4be6400fcaca9d02c1d4ecb6ad917e4013c0b93235526d8396e5" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -6320,7 +7022,7 @@ checksum = "761ef74f6175e15603d0424cc5f98854c5baccfe7bf4ccb08e5816f9ab8af689" dependencies = [ "anyhow", "heck 0.5.0", - "indexmap", + "indexmap 2.14.0", "tempfile", "uniffi_internal_macros", ] @@ -6359,6 +7061,33 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" +dependencies = [ + "base64 0.23.1", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -6369,6 +7098,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -6377,6 +7107,12 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -6583,7 +7319,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.14.0", "wasm-encoder", "wasmparser", ] @@ -6609,7 +7345,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.13.0", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", "semver", ] @@ -6692,7 +7428,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -7132,7 +7868,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck 0.5.0", - "indexmap", + "indexmap 2.14.0", "prettyplease", "syn 2.0.117", "wasm-metadata", @@ -7163,7 +7899,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.13.0", - "indexmap", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -7182,7 +7918,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.14.0", "log", "semver", "serde", @@ -7233,7 +7969,7 @@ dependencies = [ name = "xtask" version = "0.1.0" dependencies = [ - "base64", + "base64 0.22.1", "capsule-core", "eyre", "hex", @@ -7245,6 +7981,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "xxhash-rust" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" + [[package]] name = "yaml-rust2" version = "0.11.0" diff --git a/capsule-docs/src/content/docs/design/dependencies.md b/capsule-docs/src/content/docs/design/dependencies.md index 548962bc..1898ceef 100644 --- a/capsule-docs/src/content/docs/design/dependencies.md +++ b/capsule-docs/src/content/docs/design/dependencies.md @@ -38,6 +38,7 @@ Mechanically, every Rust version is pinned once in the root `Cargo.toml` `[works | Constant-time comparison | `subtle` | The one place a secret-derived value is compared byte for byte: the second factor's code check (`S-C55`). A hand-rolled fold is what an optimizer is free to short-circuit, and the resulting code looks correct forever. Already in the tree under `aes-gcm`, so this promotes a transitive dependency rather than adding one. | Password and manifest comparisons do not use it — a password never rises above its adapter, and signature verification is the signature crate's own constant-time path. | | WebAuthn | **none — passkeys are not in v1** | Slice `S-C56`. `webauthn-rs` survives only inside the retiring `capsule-server`, and leaves the workspace with it. | **This is why `openssl` leaves the tree.** `webauthn-attestation-ca` was the sole edge pulling it in, for attestation-certificate parsing — never as a TLS stack, which is the carve-out the TLS row recorded. With passkeys deferred, the carve-out is spent and the TLS rule holds without exception. A rebuild reopens both. | | ORM | `sea-orm` (`sqlx-postgres` on the server, `sqlx-sqlite` in the CLI) | The rebuildable index databases only — sidecars stay canonical per [Principles](/design/principles/). | — | +| Volatile state (Valkey) | `redis` (redis-rs) **1.2.2** — `tokio-comp`, `connection-manager`, `tokio-rustls-comp`, `script` | `capsule-server`'s `store::valkey` and `counter::valkey` (slice `S-C29`'s owed half, #403): the six state ports and the counter port over one `ConnectionManager` — a multiplexed, self-reconnecting connection, no pool. Primitives: hashes for records, sets and one sorted set for the derived indexes, lists for the relay mailboxes, `PEXPIRE` as the collector, and one Lua script (`EVALSHA`, `SCRIPT LOAD` on `NOSCRIPT`) per multi-key mutation or decide-and-write — `claim_finalize`, `consume`, `redeem`, the counter's `hit`. Expiry is decided by the injected `Clock` and written into each record, so the [conformance suite](/design/filesystem/server/#required-services) drives it exactly as it drives the in-memory double; TLS (`rediss://`) terminates in rustls. Container test: `testcontainers-modules` (`valkey`), env-gated. | No `bb8`/`bb8-redis` (pinned in the workspace, consumed nowhere): a pool buys `WATCH`/`MULTI`, which needs an exclusive connection across the read-then-write window the scripts exist to close. No Redis Cluster: scripts derive a few keys from records they read, which is legal only on a standalone server. No `object_store` or generic TTL/CAS crate, per the Rust Architecture Decisions. | | Embedded SQLite | `rusqlite` (`bundled`) | `capsule-core`'s `library.sqlite`. | — | | Vector index | `sqlite-vec` (`vec0`) | The client-local embedding index in `capsule-core`'s `library.sqlite` — per-task `vec0` virtual tables under the [embedding-provenance](/design/ai/#embedding-provenance) invariant. Optional + `native`-gated alongside `rusqlite` (registers as a SQLite auto-extension; not `wasm32`). | Server-side vector-DB idioms (pgvector/HNSW) do not apply — the index is client-local SQLite by design. | | LQIP placeholder codec | `chromahash` **0.7.1** | `capsule-core::lqip` (slice `S-B14`) — the only encoder/decoder for the signed sidecar `lqip` field. Imported **directly**, never through Rawshift (`AGENTS.md`), and deliberately outside `capsule-core::media` — the Rawshift-consuming module — so one implementation serves the import pipeline, the uniffi FFI, and `capsule-wasm`. The tier, byte width and versioned fallback are the contract at [Thumbnails — LQIP](/design/thumbnails/#lqip); this row owns only the pin. The `AGENTS.md` gate that read "after its v1 release" is **amended to 0.7.1** — the release the project accepts as ready — and `xtask`'s architecture check stopped forbidding the crate in `2f8beeb`, because a check that forbids an approved dependency has stopped describing a decision and started blocking one. | **`thumbhash` is retired, not excepted.** The Rust crate behind `capsule-core`'s `media` feature and the npm package in `capsule-web` both go; `thumbhash` stays in the architecture check's retired-dependency list so it cannot return. BlurHash was never adopted. | diff --git a/capsule-server/Cargo.toml b/capsule-server/Cargo.toml index c60c08da..25f900e2 100644 --- a/capsule-server/Cargo.toml +++ b/capsule-server/Cargo.toml @@ -124,6 +124,16 @@ subtle = { workspace = true } # parameter set rather than the escrow KDF's tiered one — see `auth::credential` for why those # are unrelated numbers. The Postgres adapter (#402) uses the same helper. argon2 = { workspace = true } +# The Valkey adapters behind the state ports and the counter port (`store::valkey`, +# `counter::valkey`; slice `S-C29`'s owed half, #403). `redis-rs` is the crate the Rust +# Architecture Decisions name for exactly this; the workspace pin carries `tokio-comp` and +# `connection-manager`, and two features are added here: `tokio-rustls-comp`, so a `rediss://` +# URL terminates TLS in rustls and never in native-tls, and `script`, because every multi-key +# mutation is one Lua script rather than a `WATCH`/`MULTI` round trip a multiplexed connection +# could not hold. No `bb8`: one `ConnectionManager` is a self-reconnecting multiplexed +# connection, which is the whole of what a server talking to one Valkey needs. See the Volatile +# state row in design/dependencies.md. +redis = { workspace = true, features = ["tokio-rustls-comp", "script"] } # The `capsule-server` binary: subcommand parsing, and the error report a startup failure prints. # `clap` and `color-eyre` were already here for the `gen_openapi` binary this replaces; the # binary is now one `capsule-server` with `serve | gc | purge | scrub | gen-openapi` @@ -175,3 +185,12 @@ totp-rs = { workspace = true } # rather than reading a checked-in PEM, because a private key in the repository is a private # key somebody eventually reuses. `ring` is a normal dependency since `S-C2` (the cursor MAC); # it stays listed here only so the intent of the test usage is recorded. + +# One Valkey container for `tests/valkey.rs`, which runs the `store::conformance` and +# `counter::conformance` suites — the same cases the in-memory doubles pass — against a live +# server. Env-gated (`CAPSULE_TEST_VALKEY=1`, or `CAPSULE_TEST_VALKEY_URL` for a server already +# running) so `mise run test-rust` still needs no podman; `.config/nextest.toml` puts the file +# in the one-thread `containers` group. Both crates are workspace pins already in the Test +# runner row of design/dependencies.md; the `valkey` module is the image the deployment runs. +testcontainers = { workspace = true } +testcontainers-modules = { workspace = true, features = ["valkey"] } From 872f89a57851c2add8b9d28bfb69b84a53fb6bdf Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 05:35:32 -0400 Subject: [PATCH 113/243] feat(server): Valkey adapters for the state ports and the counters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One adapter per port in `store::valkey` — sessions, upload sessions, the three ceremonies and the device-cohort map — and `counter::valkey` for the counter port, all over one multiplexed, self-reconnecting `ConnectionManager`. Every multi-key mutation or decide-and-write is one Lua script (`EVALSHA`, `SCRIPT LOAD` on `NOSCRIPT`): the finalize claim, the challenge consume, the enrollment redeem and the counter hit cannot be read-then-written because there is no read a caller performs separately. Every derived index — the per-user and per-uploader sets, the per-album and pending-address sets, the global progress sorted-set — resolves each member through its record inside the script and drops a stale one, so an expired record leaves no listing entry behind without a second lifetime on the index. Expiry is decided by the injected `Clock`, written into each record as `expires_at`; `PEXPIRE` on the same key is only the collector. That is what lets the shared conformance suite drive this adapter with a manual clock exactly as it drives the in-memory double, one nanosecond either side of a boundary, with no sleeps. The counter port's cases move from `counter/tests.rs` into `counter::conformance`, generic over the store and with a racing case on a multi-threaded runtime; the in-memory adapter runs them case by case and in one pass. `tests/valkey.rs` runs both suites, a contested finalize claim and a contested counter hit against a `valkey/valkey` container (`CAPSULE_TEST_VALKEY=1`) or a running server (`CAPSULE_TEST_VALKEY_URL`), and passes as skipped otherwise; `.config/nextest.toml` places it in the one-thread `containers` group. Refs #403 --- .config/nextest.toml | 25 +- capsule-server/src/counter/conformance.rs | 234 ++ capsule-server/src/counter/mod.rs | 22 +- capsule-server/src/counter/tests.rs | 226 +- capsule-server/src/counter/valkey.rs | 214 ++ capsule-server/src/store/mod.rs | 15 +- capsule-server/src/store/valkey.rs | 2395 +++++++++++++++++++++ capsule-server/tests/valkey.rs | 319 +++ 8 files changed, 3241 insertions(+), 209 deletions(-) create mode 100644 capsule-server/src/counter/conformance.rs create mode 100644 capsule-server/src/counter/valkey.rs create mode 100644 capsule-server/src/store/valkey.rs create mode 100644 capsule-server/tests/valkey.rs diff --git a/.config/nextest.toml b/.config/nextest.toml index febdae11..32bab806 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -21,17 +21,26 @@ slow-timeout = { period = "60s", terminate-after = 3 } [profile.ci.junit] path = "junit.xml" -# The container group is **empty as of `S-C59`**, and the declaration stays. +# The container group holds the adapter suites that stand up a real backing service. # # It existed for the retired Salvo auth crate, whose integration tests spun up shared Postgres # and Valkey containers and shared a tracing `Once` — running them concurrently was the main -# flakiness source, so the group was pinned to one thread with retries as the backstop. The -# rebuilt server has no container test at all: every port has a deterministic in-memory adapter -# and Kynos's `TestClient` drives a built service in-process, which is why `mise run test-rust` -# now needs no podman for anything above the storage ports. +# flakiness source, so the group was pinned to one thread with retries as the backstop. It was +# empty from `S-C59` until the first adapter arrived: every port has a deterministic in-memory +# adapter and Kynos's `TestClient` drives a built service in-process, which is why `mise run +# test-rust` needs no podman — the container suites below are env-gated and pass as skipped +# without their variable. # -# The group is kept rather than deleted because the first Postgres or Valkey *adapter* will need -# exactly it, and rediscovering the one-thread rule by watching CI flake is the expensive way to -# learn it. An empty test group costs nothing. +# `capsule-server/tests/valkey.rs` (#403) runs the store and counter conformance suites against a +# `valkey/valkey` container when `CAPSULE_TEST_VALKEY=1`. One thread, because each test starts +# its own container and the eviction view is one global sorted set. [test-groups.containers] max-threads = 1 + +[[profile.default.overrides]] +filter = 'package(capsule-server) and binary(valkey)' +test-group = 'containers' + +[[profile.ci.overrides]] +filter = 'package(capsule-server) and binary(valkey)' +test-group = 'containers' diff --git a/capsule-server/src/counter/conformance.rs b/capsule-server/src/counter/conformance.rs new file mode 100644 index 00000000..bfb2fb29 --- /dev/null +++ b/capsule-server/src/counter/conformance.rs @@ -0,0 +1,234 @@ +//! The one suite every counter adapter must pass. +//! +//! The property that matters is that charging and deciding are one operation. A single-threaded +//! sequence cannot exhibit the race that makes read-then-write wrong — the same limit `S-C21`'s +//! conformance note records — so most of what is asserted here is the *observable consequence*: +//! the n-th hit inside a window is refused, and no sequence of calls admits more than the budget. +//! [`concurrent_hits_admit_exactly_the_budget`] is the one case that does race, on a +//! multi-threaded runtime, and it is the case a Valkey adapter built on read-then-`INCR` fails. +//! +//! Like [`crate::store::conformance`], this lives in `src/` because it is part of the contract: +//! the in-memory double is a legitimate stand-in for Valkey only to the extent it passes the +//! same cases the container-backed adapter does. +//! +//! # Time +//! +//! [`CounterStore::hit`] takes `at`, so the suite moves time by passing later instants rather +//! than through a harness clock. That is a property of the port worth keeping: the window a +//! limit measures is a fact about the caller's clock, and an adapter that measured it with a +//! clock of its own would be a second clock for one fact. +//! +//! # Reusing a store +//! +//! Every case scopes its key to itself and resets it first, so cases may share one store — and +//! [`run_all`] does — and may be re-run against a server that still holds the previous run. + +use std::sync::Arc; + +use jiff::{SignedDuration, Timestamp}; + +use super::{Budget, CounterKey, CounterStore, Verdict}; +use crate::store::{StoreError, UserId, deadline}; + +/// Unwrap a store result, failing with the operation that was expected to work. +fn ok(result: Result, operation: &str) -> T { + match result { + Ok(value) => value, + Err(error) => panic!("a conforming adapter must succeed at {operation}: {error}"), + } +} + +/// A key scoped to one case. +fn key(case: &str) -> CounterKey { + CounterKey::LoginAttempts(UserId::new(format!("counter-conformance-{case}"))) +} + +/// Three hits per ten minutes. +fn budget() -> Budget { + Budget::new(3, SignedDuration::from_mins(10)) +} + +/// `mins` minutes after the epoch. +fn at(mins: i64) -> Timestamp { + deadline(Timestamp::UNIX_EPOCH, SignedDuration::from_mins(mins)) +} + +/// A fresh key for `case`: reset, so a store that remembers a previous run starts clean. +async fn fresh(counters: &dyn CounterStore, case: &str) -> CounterKey { + let key = key(case); + ok(counters.reset(&key).await, "reset"); + key +} + +/// A window admits exactly its budget, then refuses and says when to come back. +pub async fn a_window_admits_exactly_its_budget_and_then_refuses(counters: &dyn CounterStore) { + let key = fresh(counters, "budget").await; + + for expected_remaining in [2, 1, 0] { + assert_eq!( + ok(counters.hit(&key, budget(), at(0)).await, "hit"), + Verdict::Admitted { + remaining: expected_remaining + } + ); + } + + assert_eq!( + ok(counters.hit(&key, budget(), at(0)).await, "hit"), + Verdict::Limited { + retry_after: at(10) + }, + "the fourth hit is refused, and is told when to come back" + ); +} + +/// A refused hit does not extend the window. +/// +/// Otherwise an attacker who keeps hitting a limited key holds it limited forever, which turns a +/// rate limit into a denial of service against the legitimate user. +pub async fn a_refused_hit_does_not_extend_the_window(counters: &dyn CounterStore) { + let key = fresh(counters, "no-extend").await; + for _ in 0..3 { + ok(counters.hit(&key, budget(), at(0)).await, "hit"); + } + for minute in 1..8 { + assert!( + !ok(counters.hit(&key, budget(), at(minute)).await, "hit").admits(), + "still limited at minute {minute}" + ); + } + + assert!( + ok(counters.hit(&key, budget(), at(11)).await, "hit").admits(), + "the window still ends ten minutes after it opened, not ten after the last attempt" + ); +} + +/// A window is measured from its first hit, and a fresh window is a fresh budget. +pub async fn a_window_measures_from_its_first_hit(counters: &dyn CounterStore) { + let key = fresh(counters, "first-hit").await; + ok(counters.hit(&key, budget(), at(0)).await, "hit"); + ok(counters.hit(&key, budget(), at(9)).await, "hit"); + + assert!( + ok(counters.hit(&key, budget(), at(11)).await, "hit").admits(), + "a fresh window opens once the first one passes" + ); + for _ in 0..2 { + assert!(ok(counters.hit(&key, budget(), at(11)).await, "hit").admits()); + } + assert!( + !ok(counters.hit(&key, budget(), at(11)).await, "hit").admits(), + "and that fresh window is a fresh budget, not a continuation" + ); +} + +/// One account's failed sign-ins are not another's. +pub async fn counters_are_scoped_to_their_key(counters: &dyn CounterStore) { + let key = fresh(counters, "scoped-a").await; + let other = fresh(counters, "scoped-b").await; + for _ in 0..3 { + ok(counters.hit(&key, budget(), at(0)).await, "hit"); + } + + assert!( + ok(counters.hit(&other, budget(), at(0)).await, "hit").admits(), + "one account's failed sign-ins are not another's" + ); + assert!( + !ok(counters.hit(&key, budget(), at(0)).await, "hit").admits(), + "and the first is still limited" + ); +} + +/// A reset ends the run: what a successful sign-in does to a failed-attempt counter. +pub async fn a_reset_ends_the_run(counters: &dyn CounterStore) { + let key = fresh(counters, "reset").await; + for _ in 0..3 { + ok(counters.hit(&key, budget(), at(0)).await, "hit"); + } + assert!(!ok(counters.hit(&key, budget(), at(0)).await, "hit").admits()); + + ok(counters.reset(&key).await, "reset"); + assert_eq!( + ok(counters.hit(&key, budget(), at(0)).await, "hit"), + Verdict::Admitted { remaining: 2 } + ); +} + +/// Peeking charges nothing and answers a verdict, not a number. +pub async fn peeking_charges_nothing_and_answers_a_verdict(counters: &dyn CounterStore) { + let key = fresh(counters, "peek").await; + for _ in 0..5 { + assert_eq!( + ok(counters.peek(&key, budget(), at(0)).await, "peek"), + Verdict::Admitted { remaining: 3 }, + "peeking does not spend the budget" + ); + } + + for _ in 0..3 { + ok(counters.hit(&key, budget(), at(0)).await, "hit"); + } + assert_eq!( + ok(counters.peek(&key, budget(), at(0)).await, "peek"), + Verdict::Limited { + retry_after: at(10) + } + ); + assert!( + ok(counters.peek(&key, budget(), at(10)).await, "peek").admits(), + "a peek past the window sees a fresh budget" + ); +} + +/// No sequence of calls admits more than the budget, however peeks are interleaved. +pub async fn no_sequence_of_calls_admits_more_than_the_budget(counters: &dyn CounterStore) { + let key = fresh(counters, "sequence").await; + let mut admitted = 0; + for _ in 0..50 { + ok(counters.peek(&key, budget(), at(0)).await, "peek"); + if ok(counters.hit(&key, budget(), at(0)).await, "hit").admits() { + admitted += 1; + } + } + assert_eq!(admitted, 3); +} + +/// Racing hits admit exactly the budget — the property read-then-write does not have. +/// +/// Every task charges the same key at the same instant. An adapter that read the count, compared +/// it and then incremented would let a burst read the same under-limit value and admit them all; +/// one that charges and decides in one critical section (a mutex, a Lua script) admits three. +/// Meaningful on a multi-threaded runtime; on a single thread it degrades to the sequence case. +pub async fn concurrent_hits_admit_exactly_the_budget(counters: Arc) { + let key = fresh(&*counters, "concurrent").await; + let mut tasks = tokio::task::JoinSet::new(); + for _ in 0..24 { + let counters = Arc::clone(&counters); + let key = key.clone(); + tasks.spawn(async move { ok(counters.hit(&key, budget(), at(0)).await, "hit") }); + } + let mut admitted = 0; + while let Some(verdict) = tasks.join_next().await { + if verdict.expect("a hit task completes").admits() { + admitted += 1; + } + } + assert_eq!(admitted, 3, "racing hits must admit exactly the budget"); +} + +/// Run every case above against one store, in order. +/// +/// The entry point for a backend where standing up a store is expensive. A unit-tier adapter +/// should prefer one test per case, so a failure names the property that broke. +pub async fn run_all(counters: Arc) { + a_window_admits_exactly_its_budget_and_then_refuses(&*counters).await; + a_refused_hit_does_not_extend_the_window(&*counters).await; + a_window_measures_from_its_first_hit(&*counters).await; + counters_are_scoped_to_their_key(&*counters).await; + a_reset_ends_the_run(&*counters).await; + peeking_charges_nothing_and_answers_a_verdict(&*counters).await; + no_sequence_of_calls_admits_more_than_the_budget(&*counters).await; + concurrent_hits_admit_exactly_the_budget(counters).await; +} diff --git a/capsule-server/src/counter/mod.rs b/capsule-server/src/counter/mod.rs index be6601dd..0e41828b 100644 --- a/capsule-server/src/counter/mod.rs +++ b/capsule-server/src/counter/mod.rs @@ -100,6 +100,21 @@ impl CounterKey { Self::RegistrationSource(_) => "registration_source", } } + + /// What the key is scoped to — the account, the link, the address — for the backend key + /// that carries it. Paired with [`Self::as_str`], which names the kind. + pub fn scope(&self) -> &str { + match self { + Self::LoginAttempts(user) | Self::DeepVerify(user) => user.as_str(), + Self::EnrollmentRedemption(scope) + | Self::ShareLink(scope) + | Self::ShareSource(scope) + | Self::DropLink(scope) + | Self::DropSource(scope) + | Self::SecondFactor(scope) + | Self::RegistrationSource(scope) => scope, + } + } } /// How many, and over how long. @@ -150,8 +165,9 @@ pub trait CounterStore: std::fmt::Debug + Send + Sync { /// /// **The two together, never separately.** Read-then-increment lets every request in a burst /// read the same under-limit value, which is the burst the limiter exists to stop. Every - /// adapter owes this atomically; the in-memory one gets it from a mutex and Valkey from - /// `INCR` plus a first-hit `EXPIRE`. + /// adapter owes this atomically; the in-memory one gets it from a mutex and Valkey from one + /// Lua script that opens, charges or refuses the window in a single server-side step + /// ([`valkey::ValkeyCounters`]). fn hit<'a>( &'a self, key: &'a CounterKey, @@ -348,6 +364,8 @@ impl CounterContext { } pub mod budgets; +pub mod conformance; +pub mod valkey; #[cfg(test)] mod tests; diff --git a/capsule-server/src/counter/tests.rs b/capsule-server/src/counter/tests.rs index 0fdf9a78..7fc67068 100644 --- a/capsule-server/src/counter/tests.rs +++ b/capsule-server/src/counter/tests.rs @@ -1,209 +1,49 @@ -//! The counter port's own suite. +//! The in-memory adapter against the counter port's own suite. //! -//! The property that matters is that charging and deciding are one operation. A single-threaded -//! suite cannot exhibit the race that makes read-then-write wrong — the same limit `S-C21`'s -//! conformance note records — so what is asserted here is the *observable consequence*: the -//! n-th hit inside a window is refused, and no sequence of calls admits more than the budget. +//! One test per [`conformance`] case, on a fresh store each, so a failure names the property +//! that broke; and the whole suite in one pass, because that is the entry point the +//! container-backed adapter uses and it must be exercised where a failure is easiest to read. -use super::{budgets, *}; +use std::sync::Arc; -fn key() -> CounterKey { - CounterKey::LoginAttempts(UserId::new("01937b7c-0000-7000-8000-000000000001")) -} - -fn other() -> CounterKey { - CounterKey::LoginAttempts(UserId::new("01937b7c-0000-7000-8000-0000000000ff")) -} - -fn budget() -> Budget { - Budget::new(3, SignedDuration::from_mins(10)) -} +use super::{CounterStore, InMemoryCounters, budgets, conformance}; -fn at(mins: i64) -> Timestamp { - crate::store::deadline(Timestamp::UNIX_EPOCH, SignedDuration::from_mins(mins)) +fn counters() -> Arc { + Arc::new(InMemoryCounters::new()) } -#[tokio::test] -async fn a_window_admits_exactly_its_budget_and_then_refuses() { - let counters = InMemoryCounters::new(); - - for expected_remaining in [2, 1, 0] { - assert_eq!( - counters - .hit(&key(), budget(), at(0)) - .await - .expect("the store answers"), - Verdict::Admitted { - remaining: expected_remaining +/// Declares one `#[tokio::test]` per conformance case taking a borrowed store. +macro_rules! conformance_cases { + ($($case:ident),+ $(,)?) => { + $( + #[tokio::test] + async fn $case() { + conformance::$case(&*counters()).await; } - ); - } - - assert_eq!( - counters - .hit(&key(), budget(), at(0)) - .await - .expect("the store answers"), - Verdict::Limited { - retry_after: at(10) - }, - "the fourth hit is refused, and is told when to come back" - ); -} - -#[tokio::test] -async fn a_refused_hit_does_not_extend_the_window() { - // Otherwise an attacker who keeps hitting a limited key holds it limited forever, which - // turns a rate limit into a denial of service against the legitimate user. - let counters = InMemoryCounters::new(); - for _ in 0..3 { - counters.hit(&key(), budget(), at(0)).await.expect("hits"); - } - for minute in 1..8 { - assert!( - !counters - .hit(&key(), budget(), at(minute)) - .await - .expect("answers") - .admits() - ); - } - - assert!( - counters - .hit(&key(), budget(), at(11)) - .await - .expect("answers") - .admits(), - "the window still ends ten minutes after it opened, not ten after the last attempt" - ); + )+ + }; } -#[tokio::test] -async fn a_window_measures_from_its_first_hit() { - let counters = InMemoryCounters::new(); - counters.hit(&key(), budget(), at(0)).await.expect("hits"); - counters.hit(&key(), budget(), at(9)).await.expect("hits"); - - assert!( - counters - .hit(&key(), budget(), at(11)) - .await - .expect("answers") - .admits(), - "a fresh window opens once the first one passes" - ); - // And that fresh window is a fresh budget, not a continuation. - for _ in 0..2 { - assert!( - counters - .hit(&key(), budget(), at(11)) - .await - .expect("answers") - .admits() - ); - } - assert!( - !counters - .hit(&key(), budget(), at(11)) - .await - .expect("answers") - .admits() - ); -} - -#[tokio::test] -async fn counters_are_scoped_to_their_key() { - let counters = InMemoryCounters::new(); - for _ in 0..3 { - counters.hit(&key(), budget(), at(0)).await.expect("hits"); - } - - assert!( - counters - .hit(&other(), budget(), at(0)) - .await - .expect("answers") - .admits(), - "one account's failed sign-ins are not another's" - ); +conformance_cases! { + a_window_admits_exactly_its_budget_and_then_refuses, + a_refused_hit_does_not_extend_the_window, + a_window_measures_from_its_first_hit, + counters_are_scoped_to_their_key, + a_reset_ends_the_run, + peeking_charges_nothing_and_answers_a_verdict, + no_sequence_of_calls_admits_more_than_the_budget, } -#[tokio::test] -async fn a_reset_ends_the_run() { - // What a *successful* sign-in does to a failed-attempt counter: the policy counts - // consecutive failures, so a success is not one more event, it ends the run. - let counters = InMemoryCounters::new(); - for _ in 0..3 { - counters.hit(&key(), budget(), at(0)).await.expect("hits"); - } - assert!( - !counters - .hit(&key(), budget(), at(0)) - .await - .expect("answers") - .admits() - ); - - counters.reset(&key()).await.expect("resets"); - assert_eq!( - counters - .hit(&key(), budget(), at(0)) - .await - .expect("answers"), - Verdict::Admitted { remaining: 2 } - ); -} - -#[tokio::test] -async fn peeking_charges_nothing_and_answers_a_verdict() { - // It returns a `Verdict` rather than a count on purpose: handing back a number is handing - // back the read half of a read-then-write, and somebody would eventually build a limiter - // out of it. - let counters = InMemoryCounters::new(); - for _ in 0..5 { - assert!( - counters - .peek(&key(), budget(), at(0)) - .await - .expect("answers") - .admits(), - "peeking does not spend the budget" - ); - } - - for _ in 0..3 { - counters.hit(&key(), budget(), at(0)).await.expect("hits"); - } - assert!( - !counters - .peek(&key(), budget(), at(0)) - .await - .expect("answers") - .admits() - ); +/// The one case that races, on the runtime that lets it. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn concurrent_hits_admit_exactly_the_budget() { + conformance::concurrent_hits_admit_exactly_the_budget(counters()).await; } -#[tokio::test] -async fn no_sequence_of_calls_admits_more_than_the_budget() { - // The observable consequence of charging and deciding together. A single-threaded suite - // cannot exhibit the race that makes read-then-write wrong, so what is asserted is that the - // count admitted never exceeds the limit however the calls are interleaved with peeks. - let counters = InMemoryCounters::new(); - let mut admitted = 0; - for step in 0..50 { - counters.peek(&key(), budget(), at(0)).await.expect("peeks"); - if counters - .hit(&key(), budget(), at(0)) - .await - .expect("answers") - .admits() - { - admitted += 1; - } - let _ = step; - } - assert_eq!(admitted, 3); +/// The whole suite, in one pass on one store. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn the_whole_suite_passes_in_one_pass() { + conformance::run_all(counters()).await; } #[test] diff --git a/capsule-server/src/counter/valkey.rs b/capsule-server/src/counter/valkey.rs new file mode 100644 index 00000000..6fb1fd7d --- /dev/null +++ b/capsule-server/src/counter/valkey.rs @@ -0,0 +1,214 @@ +//! The Valkey [`CounterStore`] (#403). +//! +//! One hash per key — `capsule:counter:{kind}:{scope}` holding `hits` and `opened_at` — and one +//! Lua script that opens the window, or charges it, or refuses, in a single server-side step. +//! That is the atomicity the port demands: a burst of requests cannot read the same under-limit +//! count, because there is no read a caller performs separately from the charge. +//! +//! # Why a hash and a script rather than `INCR` + first-hit `EXPIRE` +//! +//! The port's window is measured from the caller's `at`, which is what lets the in-memory double +//! be deterministic and the [`conformance`](super::conformance) suite pass absolute instants. A +//! window that `EXPIRE` measured on the server's clock instead would be a second clock for one +//! fact, and the same suite could not drive both adapters. So `opened_at` is stored and compared +//! against `at` inside the script; `PEXPIRE` is set to the window as well, but only so a key +//! nobody hits again is collected — it never decides anything. +//! +//! An over-budget hit is refused **without** being counted, exactly as the double does it: the +//! window's end is fixed by its first hit, so a refused hit cannot extend it. + +use jiff::Timestamp; + +use super::{Budget, CounterKey, CounterStore, Verdict}; +use crate::store::valkey::{Valkey, from_micros, micros}; +use crate::store::{StoreError, StoreFuture}; + +/// The port name, for the log line and the error. +const COUNTERS: &str = "counters"; + +/// One Lua script, built on first use. Private to this module; `store::valkey` has its own. +struct Lua { + source: &'static str, + script: std::sync::OnceLock, +} + +impl Lua { + const fn new(source: &'static str) -> Self { + Self { + source, + script: std::sync::OnceLock::new(), + } + } + + fn script(&self) -> &redis::Script { + self.script.get_or_init(|| redis::Script::new(self.source)) + } +} + +// KEYS: counter. ARGV: at (µs), window (µs), limit, window (ms, for the collector). +// Returns {hits after this call, or -1 when refused; the instant the window ends (µs)}. +static HIT: Lua = Lua::new( + "local at = tonumber(ARGV[1]) +local window = tonumber(ARGV[2]) +local limit = tonumber(ARGV[3]) +local opened = redis.call('HGET', KEYS[1], 'opened_at') +if not opened or tonumber(opened) + window <= at then + redis.call('HSET', KEYS[1], 'hits', 1, 'opened_at', ARGV[1]) + redis.call('PEXPIRE', KEYS[1], ARGV[4]) + return {1, at + window} +end +local ends = tonumber(opened) + window +local hits = tonumber(redis.call('HGET', KEYS[1], 'hits') or 0) +if hits >= limit then return {-1, ends} end +hits = redis.call('HINCRBY', KEYS[1], 'hits', 1) +return {hits, ends}", +); + +// KEYS: counter. ARGV: at (µs), window (µs). Returns {hits in the open window, or 0; its end}. +static PEEK: Lua = Lua::new( + "local at = tonumber(ARGV[1]) +local window = tonumber(ARGV[2]) +local opened = redis.call('HGET', KEYS[1], 'opened_at') +if not opened or tonumber(opened) + window <= at then return {0, 0} end +return {tonumber(redis.call('HGET', KEYS[1], 'hits') or 0), tonumber(opened) + window}", +); + +/// Valkey [`CounterStore`]. +#[derive(Debug)] +pub struct ValkeyCounters { + valkey: Valkey, +} + +impl ValkeyCounters { + /// Counters on `valkey`. + pub fn new(valkey: Valkey) -> Self { + Self { valkey } + } + + async fn run( + &self, + script: &Lua, + key: &CounterKey, + args: &[String], + ) -> Result<(i64, i64), StoreError> { + let mut invocation = script.script().prepare_invoke(); + invocation.key(counter_key(key)); + for arg in args { + invocation.arg(arg.as_str()); + } + self.valkey.invoke(COUNTERS, &invocation).await + } +} + +/// `capsule:counter:{kind}:{scope}`. +fn counter_key(key: &CounterKey) -> String { + format!("capsule:counter:{}:{}", key.as_str(), key.scope()) +} + +/// A window as the microsecond string the scripts compare, and the millisecond string +/// `PEXPIRE` takes. +fn window_args(budget: Budget) -> (String, String) { + let micros = i64::try_from(budget.window.as_micros()).unwrap_or(i64::MAX); + let millis = budget.window.as_millis().max(1); + (micros.to_string(), millis.to_string()) +} + +fn admitted(limit: u32, hits: i64) -> Verdict { + Verdict::Admitted { + remaining: limit.saturating_sub(u32::try_from(hits).unwrap_or(u32::MAX)), + } +} + +impl CounterStore for ValkeyCounters { + fn hit<'a>( + &'a self, + key: &'a CounterKey, + budget: Budget, + at: Timestamp, + ) -> StoreFuture<'a, Verdict> { + Box::pin(async move { + let (window_micros, window_millis) = window_args(budget); + let (hits, ends) = self + .run( + &HIT, + key, + &[ + micros(at).to_string(), + window_micros, + budget.limit.to_string(), + window_millis, + ], + ) + .await?; + if hits < 0 { + let retry_after = from_micros(COUNTERS, "Window", ends)?; + tracing::info!(counter = key.as_str(), %retry_after, "a rate limit engaged"); + return Ok(Verdict::Limited { retry_after }); + } + tracing::trace!(counter = key.as_str(), hits, "charged a counter"); + Ok(admitted(budget.limit, hits)) + }) + } + + fn peek<'a>( + &'a self, + key: &'a CounterKey, + budget: Budget, + at: Timestamp, + ) -> StoreFuture<'a, Verdict> { + Box::pin(async move { + let (window_micros, _) = window_args(budget); + let (hits, ends) = self + .run(&PEEK, key, &[micros(at).to_string(), window_micros]) + .await?; + if hits >= i64::from(budget.limit) { + return Ok(Verdict::Limited { + retry_after: from_micros(COUNTERS, "Window", ends)?, + }); + } + Ok(admitted(budget.limit, hits)) + }) + } + + fn reset<'a>(&'a self, key: &'a CounterKey) -> StoreFuture<'a, ()> { + Box::pin(async move { + let mut cmd = redis::cmd("DEL"); + cmd.arg(counter_key(key)); + let removed: u64 = self.valkey.command(COUNTERS, cmd).await?; + if removed > 0 { + tracing::debug!(counter = key.as_str(), "a counter window was cleared"); + } + Ok(()) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::UserId; + + #[test] + fn a_counter_key_names_its_kind_and_its_scope() { + let key = CounterKey::ShareSource("203.0.113.7".to_owned()); + assert_eq!( + counter_key(&key), + "capsule:counter:share_source:203.0.113.7" + ); + let key = CounterKey::LoginAttempts(UserId::new("u1")); + assert_eq!(counter_key(&key), "capsule:counter:login_attempts:u1"); + } + + #[test] + fn a_window_is_passed_in_both_units() { + let (micros, millis) = window_args(Budget::new(3, jiff::SignedDuration::from_secs(2))); + assert_eq!(micros, "2000000"); + assert_eq!(millis, "2000"); + } + + #[test] + fn remaining_never_underflows() { + assert_eq!(admitted(3, 5), Verdict::Admitted { remaining: 0 }); + assert_eq!(admitted(3, 1), Verdict::Admitted { remaining: 2 }); + } +} diff --git a/capsule-server/src/store/mod.rs b/capsule-server/src/store/mod.rs index 23b33ae1..93f8931c 100644 --- a/capsule-server/src/store/mod.rs +++ b/capsule-server/src/store/mod.rs @@ -36,12 +36,14 @@ //! //! # Adapters, and what they are for //! -//! Three adapters are planned per port — Postgres, Valkey, and a deterministic in-memory one — -//! and **three adapters are not three deployment modes**. Valkey is required; the server -//! refuses to boot without `VALKEY_URL` (design/filesystem/server.md, "Required Services"). -//! The in-memory adapter in [`memory`] is a **test double**, never a deployment profile. The -//! rejected alternative was a Postgres fallback removing Valkey, which would mean emulating -//! TTL and expiry in SQL — the generic TTL abstraction this slice exists to delete. +//! Two adapters exist per port — the Valkey one in [`valkey`] and a deterministic in-memory +//! one in [`memory`] — and **two adapters are not two deployment modes**. Valkey is required; +//! the server refuses to boot without `VALKEY_URL` (design/filesystem/server.md, "Required +//! Services"), and [`crate::boot`] connects to it before anything else is assembled. The +//! in-memory adapter is a **test double**, never a deployment profile. The rejected alternative +//! was a Postgres fallback removing Valkey, which would mean emulating TTL and expiry in SQL — +//! the generic TTL abstraction this slice exists to delete. The one exception is the durable +//! device-cohort map, whose Valkey home is interim until the Postgres adapter (#402) carries it. //! //! Whichever adapter is in play, it must pass the one shared suite in [`conformance`]. That //! suite is what makes "the in-memory adapter behaves like Valkey" an assertion rather than an @@ -61,6 +63,7 @@ pub mod conformance; pub mod ids; pub mod memory; pub mod upload; +pub mod valkey; use std::fmt; use std::future::Future; diff --git a/capsule-server/src/store/valkey.rs b/capsule-server/src/store/valkey.rs new file mode 100644 index 00000000..3c547ed5 --- /dev/null +++ b/capsule-server/src/store/valkey.rs @@ -0,0 +1,2395 @@ +//! The Valkey adapters — the required backend behind every state port (slice `S-C29`'s owed +//! half, #403). +//! +//! # Shape +//! +//! One struct per port, mirroring [`super::memory`], sharing one [`Valkey`] handle: a single +//! `redis::aio::ConnectionManager`, which is a multiplexed, self-reconnecting connection. There +//! is no pool. A server talking to one Valkey needs exactly one connection that survives a +//! restart of the other side, and that is what the manager is. +//! +//! # Every multi-key mutation is one Lua script +//! +//! A multiplexed connection cannot hold `WATCH`: the optimistic transaction needs an exclusive +//! connection across the whole read-then-`MULTI` loop, which is the read-then-write window the +//! ports exist to close (`claim_finalize`, the counter's `hit`, `consume`, `redeem`). So every +//! operation that touches more than one key, or decides and writes, is a script — `EVALSHA` +//! with an automatic `SCRIPT LOAD` on `NOSCRIPT`, which is what `redis::Script` does — and the +//! server runs it atomically. The scripts are the whole of the CAS story here; each is a `const` +//! beside the method that invokes it. +//! +//! # Expiry: the injected clock is the contract, `PEXPIRE` is the collector +//! +//! Every record hash carries an adapter-internal `expires_at` written from the injected +//! [`Clock`] at the moment the record is opened, and every script that reads a record checks +//! it before answering — deleting the record when it has passed. Valkey's own `PEXPIRE` is set +//! on the same key with the same lifetime and does the collecting for keys nothing reads again. +//! +//! That is deliberately one fact with two enforcers, not two clocks. The port's `Clock` seam +//! is what makes expiry *testable*: the [`conformance`](super::conformance) suite advances a +//! manual clock to one nanosecond either side of a boundary and expects a different answer on +//! each side, which no harness can arrange against a real clock by sleeping. Gating on the +//! injected clock lets the same suite drive this adapter exactly as it drives the double, with +//! no sleeps and no margins, and in production both enforcers read the wall clock. +//! +//! # Indexes are derived, and heal on read +//! +//! The per-user session set, the per-uploader set, the per-album set, the pending-address set +//! and the global progress sorted-set are all derived from the record hashes. Every listing +//! resolves each member through its record inside the script and drops — `SREM`/`ZREM` — any +//! member whose record is gone or no longer matches. That is what makes +//! `an_expired_session_leaves_no_listing_entry_behind` hold without a second lifetime on the +//! index: the two independent TTLs the Salvo adapter kept were exactly what leaked. A heal is +//! logged at `warn`, because in production it is the signal that TTL and index have drifted. +//! +//! # Keys +//! +//! `capsule:` throughout, keeping the retired server's namespace: +//! +//! | key | type | lifetime | +//! |---|---|---| +//! | `capsule:session:{sid}` | hash | session TTL | +//! | `capsule:user_sessions:{uid}` | set | refreshed to the session TTL on every open | +//! | `capsule:upload:session:{id}` | hash | lifetime cap | +//! | `capsule:upload:chunks:{id}` | hash (`offset` → chunk) | the record's remaining TTL | +//! | `capsule:upload:uploader:{uid}` | set | refreshed on open | +//! | `capsule:upload:album:{album}` | set | refreshed on open | +//! | `capsule:upload:pending:{owner}:{hash}` | set | refreshed on open | +//! | `capsule:upload:progress` | zset (score = `last_progress_at` µs) | none — heals on read | +//! | `capsule:challenge:{token}` | hash | challenge TTL | +//! | `capsule:enroll:code:{spelling}` | hash, written under both spellings | code TTL | +//! | `capsule:enroll:channel:{id}` | hash | channel TTL | +//! | `capsule:enroll:mbox:{id}:{a\|b}` | list | the channel's remaining TTL | +//! | `capsule:cohorts:{uid}` | hash (`cohort_hash` → `first last`) | **none** | +//! +//! Scripts derive a few of these keys from a record they have just read (`SREM` the previous +//! user's index on close, for instance) rather than taking them as `KEYS`. That is legal on a +//! standalone server, which is the only topology this adapter supports; Redis Cluster is out of +//! scope by the #403 decision record. +//! +//! # Encoding +//! +//! One hash field per record field; timestamps as RFC 3339 (`jiff` round-trips them +//! nanosecond-exact); enums by their existing `as_str()`; `expires_at` and sorted-set scores as +//! integer microseconds, because a Lua number is a double and microseconds stay exact in one +//! until the year 2255 while nanoseconds do not. A field that will not parse is +//! [`StoreError::Corrupt`], which is the variant that exists for exactly this. + +use std::collections::BTreeMap; +use std::fmt; +use std::str::FromStr; +use std::sync::{Arc, OnceLock}; +use std::time::Duration; + +use jiff::{SignedDuration, Timestamp}; +use redis::aio::{ConnectionManager, ConnectionManagerConfig}; +use redis::{ + ErrorKind, FromRedisValue, RedisError, Script, ScriptInvocation, ServerErrorKind, Value, +}; +use uuid::Uuid; + +use super::auth::{AuthStateStore, CohortRecord, CohortStore, DEFAULT_SESSION_TTL, SessionRecord}; +use super::ceremony::{ + CHALLENGE_TTL, ChallengeStore, ChannelStore, Direction, DrainOutcome, ENROLLMENT_CODE_TTL, + EnrollmentStore, PendingEnrollment, RELAY_CHANNEL_TTL, RelayChannel, RelayOutcome, + RelayPayload, RevokeAllChallenge, +}; +use super::ids::{ + AlbumId, AssetId, ChallengeToken, ChannelId, EnrollmentCode, OwnerId, SessionId, UploadId, + UserId, +}; +use super::upload::{ + AcceptedChunk, BlobRole, FinalizeClaim, LIFETIME_CAP, UploadSessionRecord, UploadSessionStatus, + UploadSessionStore, +}; +use super::{Clock, StoreError, StoreFuture, deadline}; + +// =========================================================================================== +// The connection +// =========================================================================================== + +/// How long one command may take before the adapter reports the store unavailable. +/// +/// A hung Valkey must surface as [`StoreError::Unavailable`] — which every consumer treats as a +/// refusal — rather than as a request that never answers. +const RESPONSE_TIMEOUT: Duration = Duration::from_secs(5); + +/// How long one connection attempt may take. +const CONNECTION_TIMEOUT: Duration = Duration::from_secs(5); + +/// How many times a lost connection is retried, with exponential backoff, before a command +/// fails. Also the number of attempts the initial connection gets, so a server started a +/// moment before its Valkey (compose ordering) does not refuse for that alone. +const RECONNECT_ATTEMPTS: usize = 3; + +/// One Valkey server, reached over one multiplexed connection. +/// +/// `Clone` is a refcount bump: every adapter holds a clone of the same manager. +#[derive(Clone)] +pub struct Valkey { + manager: ConnectionManager, +} + +impl fmt::Debug for Valkey { + /// Never the URL: a `VALKEY_URL` carries the password. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Valkey()") + } +} + +impl Valkey { + /// Connect to `url` (`redis://` or `rediss://`, TLS in rustls) and prove it answers `PING`. + /// + /// # Errors + /// + /// [`StoreError::Unavailable`] if the URL is not one this adapter can open or the server + /// does not answer within the retry budget. The detail never quotes the URL. + pub async fn connect(url: &str) -> Result { + const STORE: &str = "valkey"; + let client = redis::Client::open(url).map_err(|_| StoreError::Unavailable { + store: STORE, + detail: "VALKEY_URL is not a redis:// or rediss:// URL this adapter can open" + .to_owned(), + })?; + let config = ConnectionManagerConfig::new() + .set_number_of_retries(RECONNECT_ATTEMPTS) + .set_min_delay(Duration::from_millis(200)) + .set_max_delay(Duration::from_secs(2)) + .set_connection_timeout(Some(CONNECTION_TIMEOUT)) + .set_response_timeout(Some(RESPONSE_TIMEOUT)); + let manager = ConnectionManager::new_with_config(client, config) + .await + .map_err(|error| classify(STORE, "connection", error))?; + let valkey = Self { manager }; + let pong: String = valkey.command(STORE, redis::cmd("PING")).await?; + if pong != "PONG" { + return Err(StoreError::Rejected { + store: STORE, + detail: format!("PING was answered with {pong:?}"), + }); + } + tracing::info!("connected to Valkey"); + Ok(valkey) + } + + /// Run one plain command. + pub(crate) async fn command( + &self, + store: &'static str, + cmd: redis::Cmd, + ) -> Result { + let mut connection = self.manager.clone(); + cmd.query_async(&mut connection) + .await + .map_err(|error| classify(store, "command", error)) + } + + /// Run one script, `EVALSHA` first and `SCRIPT LOAD` on a `NOSCRIPT`. + async fn eval( + &self, + store: &'static str, + script: &Lua, + keys: &[String], + args: &[String], + ) -> Result { + let mut invocation = script.script().prepare_invoke(); + for key in keys { + invocation.key(key.as_str()); + } + for arg in args { + invocation.arg(arg.as_str()); + } + self.invoke(store, &invocation).await + } + + /// Run one prepared script invocation, `EVALSHA` first and `SCRIPT LOAD` on a `NOSCRIPT`. + pub(crate) async fn invoke( + &self, + store: &'static str, + invocation: &ScriptInvocation<'_>, + ) -> Result { + let mut connection = self.manager.clone(); + invocation + .invoke_async(&mut connection) + .await + .map_err(|error| classify(store, "script", error)) + } +} + +/// Sort a driver error into the port's three failure modes. +/// +/// The line that matters is between *the operation certainly did not happen* and *the server +/// was reached and refused*: a caller that gets [`StoreError::Unavailable`] may retry, one that +/// gets [`StoreError::Rejected`] must not assume anything about state. +fn classify(store: &'static str, what: &'static str, error: RedisError) -> StoreError { + let unavailable = error.is_io_error() + || error.is_timeout() + || error.is_connection_dropped() + || matches!( + error.kind(), + ErrorKind::Server( + ServerErrorKind::BusyLoading + | ServerErrorKind::TryAgain + | ServerErrorKind::MasterDown + | ServerErrorKind::ClusterDown + ) | ErrorKind::ClusterConnectionNotFound + ); + if unavailable { + tracing::warn!(store, what, %error, "the Valkey store is unavailable"); + return StoreError::Unavailable { + store, + detail: error.to_string(), + }; + } + if matches!( + error.kind(), + ErrorKind::Parse | ErrorKind::UnexpectedReturnType + ) { + tracing::error!(store, what, %error, "the Valkey store answered a shape it should not"); + return StoreError::Corrupt { + store, + record: what, + detail: error.to_string(), + }; + } + tracing::error!(store, what, %error, "the Valkey store rejected an operation"); + StoreError::Rejected { + store, + detail: error.to_string(), + } +} + +// =========================================================================================== +// Encoding +// =========================================================================================== + +/// Microseconds since the epoch, the unit every Lua comparison and sorted-set score uses. +pub(crate) fn micros(at: Timestamp) -> i64 { + at.as_microsecond() +} + +/// A microsecond count back to a [`Timestamp`], for a value a script computed. +pub(crate) fn from_micros( + store: &'static str, + record: &'static str, + value: i64, +) -> Result { + Timestamp::from_microsecond(value).map_err(|error| StoreError::Corrupt { + store, + record, + detail: format!("microsecond timestamp {value} is out of range: {error}"), + }) +} + +/// The flat `field value field value …` list `HSET` takes and `HGETALL` returns. +#[derive(Debug, Default)] +struct Encoder(Vec); + +impl Encoder { + fn field(&mut self, name: &str, value: impl fmt::Display) -> &mut Self { + self.0.push(name.to_owned()); + self.0.push(value.to_string()); + self + } + + fn optional(&mut self, name: &str, value: Option) -> &mut Self { + if let Some(value) = value { + self.field(name, value); + } + self + } + + fn finish(self) -> Vec { + self.0 + } +} + +/// One record's hash, as read back, with typed accessors that fail as [`StoreError::Corrupt`]. +struct Fields { + store: &'static str, + record: &'static str, + map: BTreeMap, +} + +impl Fields { + fn from_flat( + store: &'static str, + record: &'static str, + flat: Vec, + ) -> Result { + if flat.len() % 2 != 0 { + return Err(StoreError::Corrupt { + store, + record, + detail: format!("a hash came back with {} entries", flat.len()), + }); + } + let mut map = BTreeMap::new(); + let mut items = flat.into_iter(); + while let (Some(name), Some(value)) = (items.next(), items.next()) { + map.insert(name, value); + } + Ok(Self { store, record, map }) + } + + fn corrupt(&self, detail: String) -> StoreError { + tracing::error!( + store = self.store, + record = self.record, + %detail, + "a stored record could not be decoded" + ); + StoreError::Corrupt { + store: self.store, + record: self.record, + detail, + } + } + + fn required(&self, name: &str) -> Result<&str, StoreError> { + self.map + .get(name) + .map(String::as_str) + .ok_or_else(|| self.corrupt(format!("field `{name}` is missing"))) + } + + fn optional(&self, name: &str) -> Option { + self.map.get(name).cloned() + } + + fn timestamp(&self, name: &str) -> Result { + let text = self.required(name)?; + text.parse() + .map_err(|error| self.corrupt(format!("field `{name}` is not a timestamp: {error}"))) + } + + fn number(&self, name: &str) -> Result + where + T::Err: fmt::Display, + { + let text = self.required(name)?; + text.parse() + .map_err(|error| self.corrupt(format!("field `{name}` is not a number: {error}"))) + } +} + +fn encode_session(record: &SessionRecord, expires_at: Timestamp) -> Vec { + let mut encoder = Encoder::default(); + encoder + .field("session_id", &record.session_id) + .field("user_id", &record.user_id) + .field("created_at", record.created_at) + .field("authenticated_at", record.authenticated_at) + .field("last_active_at", record.last_active_at) + .optional("user_agent", record.user_agent.as_deref()) + .optional("ip_address", record.ip_address.as_deref()) + .optional("cohort_hash", record.cohort_hash.as_deref()) + .optional("device_id", record.device_id) + .field("expires_at", micros(expires_at)); + encoder.finish() +} + +fn decode_session(flat: Vec) -> Result { + let fields = Fields::from_flat(AUTH, "SessionRecord", flat)?; + let device_id = match fields.optional("device_id") { + Some(text) => Some( + Uuid::parse_str(&text) + .map_err(|error| fields.corrupt(format!("device_id is not a uuid: {error}")))?, + ), + None => None, + }; + Ok(SessionRecord { + session_id: SessionId::new(fields.required("session_id")?), + user_id: UserId::new(fields.required("user_id")?), + created_at: fields.timestamp("created_at")?, + authenticated_at: fields.timestamp("authenticated_at")?, + last_active_at: fields.timestamp("last_active_at")?, + user_agent: fields.optional("user_agent"), + ip_address: fields.optional("ip_address"), + cohort_hash: fields.optional("cohort_hash"), + device_id, + }) +} + +fn parse_role(text: &str) -> Option { + [ + BlobRole::Original, + BlobRole::Derivative, + BlobRole::Metadata, + BlobRole::Provenance, + BlobRole::Backup, + ] + .into_iter() + .find(|role| role.as_str() == text) +} + +fn parse_status(text: &str) -> Option { + [ + UploadSessionStatus::Pending, + UploadSessionStatus::Uploading, + UploadSessionStatus::WaitingForProcessing, + UploadSessionStatus::Completed, + UploadSessionStatus::FailedProcessing, + ] + .into_iter() + .find(|status| status.as_str() == text) +} + +/// Whether a status keeps the session in the eviction view. +/// +/// Narrower than [`UploadSessionStatus::is_active`] on purpose: the port says a finalize claim +/// leaves the progress view "rather than being evicted out from under itself", so a +/// `WaitingForProcessing` session is in flight for every other purpose and exempt from this one. +fn is_evictable(status: UploadSessionStatus) -> bool { + matches!( + status, + UploadSessionStatus::Pending | UploadSessionStatus::Uploading + ) +} + +fn encode_upload(record: &UploadSessionRecord, expires_at: Timestamp) -> Vec { + let mut encoder = Encoder::default(); + encoder + .field("upload_id", &record.upload_id) + .field("asset_id", &record.asset_id) + .field("owner_id", &record.owner_id) + .field("upload_user_id", &record.upload_user_id) + .optional("album_id", record.album_id.as_ref()) + .optional("content_type", record.content_type.as_deref()) + .field("expected_hash", &record.expected_hash) + .field("crypto_suite_id", record.crypto_suite_id) + .field("protocol_version", &record.protocol_version) + .field("blob_role", record.blob_role.as_str()) + .optional("intent_id", record.intent_id.as_deref()) + .field("manifest_envelope", &record.manifest_envelope) + .field("received_bytes", record.received_bytes) + .field("total_size", record.total_size) + .field("status", record.status.as_str()) + .field("created_at", record.created_at) + .field("last_progress_at", record.last_progress_at) + .field("progress_score", micros(record.last_progress_at)) + .field("expires_at", micros(expires_at)); + encoder.finish() +} + +fn decode_upload(flat: Vec) -> Result { + let fields = Fields::from_flat(UPLOADS, "UploadSessionRecord", flat)?; + let blob_role = parse_role(fields.required("blob_role")?) + .ok_or_else(|| fields.corrupt("blob_role is not a known role".to_owned()))?; + let status = parse_status(fields.required("status")?) + .ok_or_else(|| fields.corrupt("status is not a known status".to_owned()))?; + Ok(UploadSessionRecord { + upload_id: UploadId::new(fields.required("upload_id")?), + asset_id: AssetId::new(fields.required("asset_id")?), + owner_id: OwnerId::new(fields.required("owner_id")?), + upload_user_id: UserId::new(fields.required("upload_user_id")?), + album_id: fields.optional("album_id").map(AlbumId::new), + content_type: fields.optional("content_type"), + expected_hash: fields.required("expected_hash")?.to_owned(), + crypto_suite_id: fields.number("crypto_suite_id")?, + protocol_version: fields.required("protocol_version")?.to_owned(), + blob_role, + intent_id: fields.optional("intent_id"), + manifest_envelope: fields.required("manifest_envelope")?.to_owned(), + received_bytes: fields.number("received_bytes")?, + total_size: fields.number("total_size")?, + status, + created_at: fields.timestamp("created_at")?, + last_progress_at: fields.timestamp("last_progress_at")?, + }) +} + +/// `chunk_hash \t next_offset \t accepted_at`: the hash is hex and a timestamp has no tab. +fn encode_chunk(chunk: &AcceptedChunk) -> String { + format!( + "{}\t{}\t{}", + chunk.chunk_hash, chunk.next_offset, chunk.accepted_at + ) +} + +fn decode_chunk(offset: u64, packed: &str) -> Result { + let corrupt = |detail: String| StoreError::Corrupt { + store: UPLOADS, + record: "AcceptedChunk", + detail, + }; + let mut parts = packed.split('\t'); + let chunk_hash = parts + .next() + .ok_or_else(|| corrupt("no chunk hash".to_owned()))? + .to_owned(); + let next_offset = parts + .next() + .ok_or_else(|| corrupt("no next offset".to_owned()))? + .parse() + .map_err(|error| corrupt(format!("next offset is not a number: {error}")))?; + let accepted_at = parts + .next() + .ok_or_else(|| corrupt("no accepted_at".to_owned()))? + .parse() + .map_err(|error| corrupt(format!("accepted_at is not a timestamp: {error}")))?; + Ok(AcceptedChunk { + offset, + chunk_hash, + next_offset, + accepted_at, + }) +} + +// =========================================================================================== +// Keys +// =========================================================================================== + +/// Port names, for the log line and the error. +const AUTH: &str = "auth"; +const UPLOADS: &str = "uploads"; +const CHALLENGES: &str = "challenges"; +const ENROLLMENTS: &str = "enrollments"; +const CHANNELS: &str = "channels"; +const COHORTS: &str = "cohorts"; + +/// The global eviction view. +const PROGRESS_KEY: &str = "capsule:upload:progress"; + +fn session_key(session: &SessionId) -> String { + format!("capsule:session:{session}") +} + +fn user_sessions_key(user: &UserId) -> String { + format!("capsule:user_sessions:{user}") +} + +fn upload_key(upload: &UploadId) -> String { + format!("capsule:upload:session:{upload}") +} + +fn chunks_key(upload: &UploadId) -> String { + format!("capsule:upload:chunks:{upload}") +} + +fn uploader_key(user: &UserId) -> String { + format!("capsule:upload:uploader:{user}") +} + +fn album_key(album: &AlbumId) -> String { + format!("capsule:upload:album:{album}") +} + +fn pending_key(owner: &OwnerId, expected_hash: &str) -> String { + format!("capsule:upload:pending:{owner}:{expected_hash}") +} + +fn challenge_key(token: &ChallengeToken) -> String { + format!("capsule:challenge:{}", token.as_str()) +} + +fn enrollment_key(code: &EnrollmentCode) -> String { + format!("capsule:enroll:code:{}", code.as_str()) +} + +fn channel_key(channel: &ChannelId) -> String { + format!("capsule:enroll:channel:{channel}") +} + +fn mailbox_key(channel: &ChannelId, direction: Direction) -> String { + format!("capsule:enroll:mbox:{channel}:{}", direction.as_str()) +} + +fn cohorts_key(user: &UserId) -> String { + format!("capsule:cohorts:{user}") +} + +// =========================================================================================== +// Scripts +// =========================================================================================== + +/// One Lua script: its source, and the `redis::Script` (SHA-1 for `EVALSHA`) built on first use. +/// +/// The source is kept beside the script so a unit test can assert that every key a script +/// derives from a record it read spells the same prefix the Rust key functions write. +struct Lua { + source: &'static str, + script: OnceLock