Skip to main content

dbmd_core/
linkmd.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! The **link.md client** — the five interconnect verbs `dbmd` speaks against
4//! a hub: `resolve`, `sync`, `grant`, `propose`, `subscribe`.
5//!
6//! One binary, two specs (the git precedent: one binary carries both the
7//! object format and the wire protocol). The db.md FORMAT is untouched by this
8//! module: a store never needs link.md to be valid db.md, record files stay
9//! plain markdown, and SPEC.md reserves only the `@brain/id` address *shape*.
10//! Everything with a wire or a trust boundary — addressing across stores,
11//! pulling/pushing a hosted copy, capability grants, the propose door, feed
12//! polling and signed-entry verification — lives here, as a *client
13//! capability*, never a format requirement.
14//!
15//! # What this client speaks
16//!
17//! The v0 HTTP binding a hub serves under its base URL:
18//!
19//! | verb | binding |
20//! | --- | --- |
21//! | `resolve` | `GET /api/hub/brains/<brain>` (the brain card); records are read from the exact signed snapshot pack named by that card's verified feed head |
22//! | `sync` (pull) | `GET /api/hub/brains/<brain>/export?format=pack&atSeq=<n>&feedHash=<hash>` — the exact verified snapshot |
23//! | `sync` (push) | `POST /api/hub/brains/<brain>/push` for small snapshots; presign/upload/commit for large snapshots |
24//! | `grant` | `GET` / `POST /api/hub/brains/<brain>/grants`, `DELETE /api/hub/brains/<brain>/grants/<id>` |
25//! | `propose` | `POST /api/hub/sites/<handle>/inbox` — evidence in, without trust (unauthenticated by design) |
26//! | `subscribe` | `GET /api/hub/brains/<brain>` + `/feed` for a locally verified signed head |
27//!
28//! # Configuration — no default hub, credential never in the store
29//!
30//! There is **no built-in hub endpoint**: the toolkit is neutral and a hub is
31//! whatever the user points it at. Resolution order for the hub URL:
32//!
33//! 1. the `--hub <URL>` flag,
34//! 2. the `DBMD_HUB_URL` environment variable,
35//! 3. the `hub = <URL>` line in the store-local `.dbmd/config` file
36//!    (toolkit state, not store content — the walkers already skip hidden
37//!    directories, so `.dbmd/` never syncs, indexes, or validates).
38//!
39//! The credential is the `DBMD_HUB_KEY` environment variable, full stop. It is
40//! deliberately **not** read from `.dbmd/config`: a secret inside the store
41//! tree is one commit or one push away from leaking. A store-selected hub may
42//! receive an ambient bearer or agent key only when
43//! `DBMD_HUB_CREDENTIAL_ORIGIN` binds it to that exact origin. Identity pins
44//! and monotonic feed checkpoints live in the user's global state directory,
45//! never in store-controlled `.dbmd/`.
46//!
47//! Non-HTTPS hubs are refused (the bearer key must never travel in cleartext)
48//! with a loopback exemption for local development.
49//!
50//! # v0 honesty
51//!
52//! This client binds to what a hub enforces **today**: grantees are hub
53//! principals (an email), grant scopes are store-path prefixes, pushes are
54//! whole-store snapshots, and `subscribe` reports feed-head movement. The hub
55//! signs each committed snapshot in a hash-chained feed with a per-brain
56//! Ed25519 identity. This client verifies the rotation chain, signer epochs,
57//! monotonic feed checkpoint, snapshot token, and content-addressed pack before
58//! untrusted bytes touch their destination.
59
60use std::io::{Cursor, Read, Write};
61use std::path::{Path, PathBuf};
62
63use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
64use ring::signature::{UnparsedPublicKey, ED25519};
65use serde::{Deserialize, Serialize};
66use serde_json::{json, Value};
67use sha2::{Digest, Sha256};
68
69use crate::store::Store;
70
71/// Environment variable naming the hub base URL (e.g. `https://hub.example.com`).
72pub const HUB_URL_ENV: &str = "DBMD_HUB_URL";
73
74/// Environment variable carrying the hub bearer credential. The bearer
75/// credential source — see the module docs for why it is never store-based.
76pub const HUB_KEY_ENV: &str = "DBMD_HUB_KEY";
77
78/// Explicit origin binding required before an ambient bearer or agent key may
79/// be sent to a hub selected by untrusted store-local configuration. The value
80/// is an origin (`https://host[:port]`), not an arbitrary URL path.
81pub const HUB_CREDENTIAL_ORIGIN_ENV: &str = "DBMD_HUB_CREDENTIAL_ORIGIN";
82
83/// Override for dbmd's user-owned trust/checkpoint root. Must be absolute.
84/// Intended for managed installations and hermetic tests; untrusted stores
85/// cannot select it.
86pub const STATE_DIR_ENV: &str = "DBMD_STATE_DIR";
87
88/// Explicit test/development escape hatch for registry homes on loopback or
89/// private networks. Production federation rejects every non-public resolved
90/// address and pins the validated DNS answer into the HTTP agent.
91pub const ALLOW_PRIVATE_REGISTRY_HOME_ENV: &str = "DBMD_ALLOW_PRIVATE_REGISTRY_HOME";
92
93/// Explicit development escape hatch for object-store URLs on loopback/private
94/// networks. Production hubs must return public HTTPS URLs. A local loopback hub
95/// is allowed to use local object URLs without this switch.
96pub const ALLOW_PRIVATE_OBJECT_URL_ENV: &str = "DBMD_ALLOW_PRIVATE_OBJECT_URL";
97
98/// Environment variable naming the PATH of a self-custodied BRAIN key file
99/// (link.md §2.4). When set, `sync --push` signs each feed entry locally and
100/// ships it through the pack flow — the hub verifies and stores the exact
101/// client bytes and can never sign for the brain. Same file format as agent
102/// keys (`dbmd key generate`).
103pub const BRAIN_KEY_FILE_ENV: &str = "DBMD_BRAIN_KEY_FILE";
104
105/// Environment variable naming the PATH of an agent signing key file
106/// (link.md §8 `LinkMD-Sig` proof of possession). When set, authenticated
107/// requests are signed per-request with the agent's Ed25519 key instead of
108/// carrying a bearer: the signature binds method + path + body + a ±60s
109/// window, so nothing reusable ever crosses the wire or lands in a log or an
110/// agent transcript. The file holds the base64url PKCS#8 key minted by
111/// `dbmd key generate`; the path is not a secret, the file is (mode 0600).
112pub const AGENT_KEY_FILE_ENV: &str = "DBMD_AGENT_KEY_FILE";
113
114/// The store-local config file, relative to the store root. Holds non-secret
115/// toolkit state (`hub = <URL>`); hidden, so every store walk skips it.
116pub const CONFIG_REL_PATH: &str = ".dbmd/config";
117
118/// Control-plane JSON is metadata, never the store itself. Exact snapshot
119/// bytes travel through the separately bounded pack lane.
120const MAX_RESPONSE_BYTES: u64 = 8 * 1024 * 1024;
121/// A feed page can legitimately carry a large snapshot manifest, but remains
122/// far below a full pack and is parsed with count-bounded sequence visitors.
123const MAX_FEED_RESPONSE_BYTES: u64 = 16 * 1024 * 1024;
124/// Foreign registry cards are identity metadata only.
125const MAX_REGISTRY_CARD_BYTES: u64 = 1024 * 1024;
126
127/// Direct JSON pushes stay below the serverless request-body cap. Larger
128/// snapshots switch to the bounded object-store pack lane.
129const MAX_PUSH_BYTES: usize = 4 * 1024 * 1024;
130
131/// Canonical raw ZIP32 uses a u16 entry count and stores each UTF-8 name twice.
132const MAX_PUSH_FILES: usize = u16::MAX as usize;
133const MAX_STORE_PATH_BYTES: usize = 1_024;
134const MAX_STORE_BYTES: u64 = 512 * 1024 * 1024;
135/// Exact worst case for the canonical STORED profile:
136/// payload + (local 30 + central 46 + name twice) per entry + EOCD 22.
137const MAX_PACK_BYTES: u64 =
138    MAX_STORE_BYTES + MAX_PUSH_FILES as u64 * (76 + 2 * MAX_STORE_PATH_BYTES) as u64 + 22;
139/// A legitimate brain should rotate rarely. Bound adversarial identity
140/// histories before allocating and repeatedly verifying an unbounded chain.
141const MAX_IDENTITY_ROTATIONS: usize = 1_024;
142/// A client never needs to replay an unbounded feed in one invocation. Large
143/// histories are mirrored incrementally; a first mirror beyond this cap needs a
144/// checkpoint/export rather than allocating attacker-controlled metadata.
145const MAX_FEED_REPLAY_ENTRIES: u64 = 100_000;
146const MAX_FEED_REPLAY_BYTES: u64 = 64 * 1024 * 1024;
147const FEED_PAGE_LIMIT: usize = 100;
148
149/// The hub's inbox cap on one `propose` submission body, mirrored client-side
150/// so an oversized body fails before the upload, not after (the same
151/// fail-before-upload contract as the push caps). Public so the CLI can
152/// pre-check a `--body-file` from file metadata without reading it.
153pub const MAX_PROPOSE_BYTES: u64 = 16 * 1024;
154
155/// Bounded connect so a dead hub fails fast; a generous read window so a
156/// large export on a slow link still completes.
157const CONNECT_TIMEOUT_SECS: u64 = 10;
158const READ_TIMEOUT_SECS: u64 = 120;
159/// Hard wall-clock budget for one HTTP attempt, spanning socket writes,
160/// response headers, and the complete response body. DNS performed for pinned
161/// agents has its own interruptible deadline below.
162const OVERALL_REQUEST_TIMEOUT_SECS: u64 = 120;
163const CONNECT_ATTEMPTS: usize = 3;
164const CONNECT_RETRY_BACKOFF_MS: [u64; CONNECT_ATTEMPTS - 1] = [100, 300];
165
166/// Everything that can go wrong on the wire or at its edges. Each variant maps
167/// onto one stable CLI error code; messages are single-line and never echo the
168/// credential.
169#[derive(Debug, thiserror::Error)]
170pub enum LinkError {
171    /// No hub URL was configured anywhere (flag, env, `.dbmd/config`).
172    #[error(
173        "no hub configured — pass --hub <URL>, set {HUB_URL_ENV}, or add `hub = <URL>` to {CONFIG_REL_PATH}"
174    )]
175    NoHub,
176
177    /// The verb needs a credential and none was present.
178    #[error("no hub credential — set {HUB_KEY_ENV} (credentials never live in {CONFIG_REL_PATH})")]
179    NoCredential,
180
181    /// The credential contains whitespace / non-ASCII (a paste artifact). The
182    /// key is deliberately not echoed.
183    #[error(
184        "the hub credential in {HUB_KEY_ENV} contains whitespace or non-ASCII characters — re-copy it (the key is not shown here on purpose)"
185    )]
186    BadKey,
187
188    /// A store selected the destination while an ambient credential was
189    /// present, but the operator did not bind that credential to the same
190    /// origin. This is a hard refusal, not an anonymous fallback: silently
191    /// dropping a credential can turn an intended private operation into a
192    /// confusing public one.
193    #[error(
194        "refusing to send an ambient credential to the hub selected by {CONFIG_REL_PATH} — set {HUB_CREDENTIAL_ORIGIN_ENV} to that exact origin, or choose the hub explicitly with --hub/{HUB_URL_ENV}"
195    )]
196    UnboundCredential,
197
198    /// The agent signing key file named by [`AGENT_KEY_FILE_ENV`] is missing,
199    /// unreadable, or not a valid Ed25519 PKCS#8 — key material is never
200    /// echoed.
201    #[error("invalid agent signing key ({message}) — mint one with `dbmd key generate`")]
202    BadAgentKey {
203        /// What failed, without any key material.
204        message: String,
205    },
206
207    /// A non-HTTPS hub outside loopback: the bearer key would travel in cleartext.
208    #[error("refusing non-HTTPS hub {hub} — the credential would travel in cleartext (localhost is exempt)")]
209    UnsafeHub {
210        /// The offending hub URL.
211        hub: String,
212    },
213
214    /// TCP/TLS-level failure: the hub never answered.
215    #[error("hub unreachable at {hub}: {message}")]
216    Transport {
217        /// The hub base URL.
218        hub: String,
219        /// The transport-layer error text.
220        message: String,
221    },
222
223    /// The hub answered with an HTTP error status.
224    #[error("{what} failed (HTTP {status}): {message}")]
225    Http {
226        /// What the client was doing (e.g. `"resolve"`, `"sync pull"`).
227        what: &'static str,
228        /// The HTTP status code.
229        status: u16,
230        /// The hub's own `error` string when it sent one, else a placeholder.
231        message: String,
232        /// The hub's machine `code` field when it sent one.
233        code: Option<String>,
234    },
235
236    /// A 2xx whose body is not JSON — a captive portal, a proxy, or a wrong
237    /// URL — refused here rather than deserializing into nothing downstream.
238    #[error("{what}: the hub answered HTTP {status} with a non-JSON body — check the hub URL")]
239    NotJson {
240        /// What the client was doing.
241        what: &'static str,
242        /// The (2xx) status that carried the non-JSON body.
243        status: u16,
244    },
245
246    /// The hub response exceeded the selected endpoint's byte cap.
247    #[error("hub response exceeded the {limit_bytes}-byte endpoint cap — refusing to buffer it")]
248    ResponseTooLarge {
249        /// The endpoint-specific cap applied before JSON parsing.
250        limit_bytes: u64,
251    },
252
253    /// A malformed `@brain/id` address.
254    #[error("invalid address `{given}`: {reason}")]
255    BadAddress {
256        /// The raw address as typed.
257        given: String,
258        /// Why it did not parse.
259        reason: String,
260    },
261
262    /// A grant id whose shape cannot travel as a URL path segment.
263    #[error(
264        "invalid grant id `{given}` — grant ids come from `grant list` (lowercase letters, digits, hyphens)"
265    )]
266    BadGrantId {
267        /// The raw id as typed.
268        given: String,
269    },
270
271    /// An exported file path that would escape or pollute the destination
272    /// (absolute, `..`, a dot-leading segment, or an illegal character). The
273    /// hub is not trusted with local path layout.
274    #[error("refusing unsafe path from the hub: `{path}`")]
275    UnsafePath {
276        /// The offending path as received.
277        path: String,
278    },
279
280    /// The store exceeds the hub's bounded whole-snapshot caps.
281    #[error(
282        "push too large ({detail}) — one snapshot caps at {} MB uncompressed, {} MB as a pack, and {MAX_PUSH_FILES} files",
283        MAX_STORE_BYTES / (1024 * 1024),
284        MAX_PACK_BYTES / (1024 * 1024)
285    )]
286    PushTooLarge {
287        /// Which cap was hit, human-readable.
288        detail: String,
289    },
290
291    /// The propose body exceeds the hub's inbox cap.
292    #[error(
293        "propose body too large ({bytes} bytes) — the hub's inbox caps one submission at {} KB",
294        MAX_PROPOSE_BYTES / 1024
295    )]
296    ProposeTooLarge {
297        /// The offending body size in bytes.
298        bytes: u64,
299    },
300
301    /// A store file that is not valid UTF-8 cannot travel the JSON push path.
302    #[error("store file `{path}` is not valid UTF-8 — the JSON push path carries text only")]
303    NotUtf8 {
304        /// The store-relative path of the offending file.
305        path: String,
306    },
307
308    /// A downloaded pack failed validation before any local write.
309    #[error("invalid store pack: {message}")]
310    InvalidPack {
311        /// Hash, ZIP, path, count, or expansion failure.
312        message: String,
313    },
314
315    /// A signed feed entry, hash chain, or advertised feed head did not verify.
316    #[error("invalid signed feed: {message}")]
317    InvalidFeed {
318        /// The failed integrity condition, without untrusted secret material.
319        message: String,
320    },
321
322    /// This build cannot provide the no-follow, directory-handle-relative
323    /// filesystem semantics required for trust/key/snapshot state.
324    #[error(
325        "{operation} is unavailable on this platform — use the official macOS/Linux build or WSL"
326    )]
327    UnsupportedPlatform {
328        /// The operation that requires hardened local filesystem primitives.
329        operation: &'static str,
330    },
331
332    /// Local filesystem failure while materializing a pull or reading a push.
333    #[error(transparent)]
334    Io(#[from] std::io::Error),
335
336    /// A store-level failure (walking the local store for a push).
337    #[error(transparent)]
338    Store(#[from] crate::StoreError),
339}
340
341/// Result alias for link.md client operations.
342pub type LinkResult<T> = std::result::Result<T, LinkError>;
343
344/// Security-sensitive link.md state and destination writes rely on Unix
345/// `openat`/`O_NOFOLLOW` semantics. Native Windows is not an official release
346/// target yet, so fail closed there instead of silently using a weaker
347/// path-based approximation.
348fn require_hardened_filesystem(operation: &'static str) -> LinkResult<()> {
349    #[cfg(any(target_os = "linux", target_os = "macos"))]
350    {
351        let _ = operation;
352        Ok(())
353    }
354    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
355    {
356        Err(LinkError::UnsupportedPlatform { operation })
357    }
358}
359
360// ─────────────────────────────────────────────────────────────────────────────
361// Addressing — `@brain[/id]`, the reserved shape (SPEC § Addressing)
362// ─────────────────────────────────────────────────────────────────────────────
363
364/// What the part after `@brain/` names.
365#[derive(Debug, Clone, PartialEq, Eq)]
366pub enum AddressTarget {
367    /// A record `id` — the db.md lowercase ULID (the reserved `@brain/id` shape).
368    Id(String),
369    /// A store-relative `.md` path — a client-side convenience the hub's
370    /// resolve endpoint also accepts (`?path=`). Not part of the reserved
371    /// shape; unambiguous because a ULID is never a path.
372    Path(String),
373}
374
375/// Why a brain reference failed [`is_safe_ref`] — shared by [`Address::parse`]
376/// and the per-verb entry gates so the two surfaces never drift.
377const BAD_BRAIN_REASON: &str =
378    "the brain reference must be a brain id (lowercase ULID) or a slug (lowercase letters, digits, hyphens)";
379
380/// Why an address target failed its shape check — shared by [`Address::parse`]
381/// and the [`resolve`] entry gate.
382const BAD_TARGET_REASON: &str =
383    "the part after `/` must be a record id (lowercase ULID) or a store-relative `.md` path";
384
385/// A parsed `@brain[/target]` address. `brain` is a hub brain reference — the
386/// brain's ULID id (works for any caller, including cross-party on a public
387/// brain) or a slug (which a hub resolves only against the caller's own
388/// brains; slugs are unique per owner, not globally).
389#[derive(Debug, Clone, PartialEq, Eq)]
390pub struct Address {
391    /// The brain reference (leading `@` stripped).
392    pub brain: String,
393    /// The record target, when the address names one.
394    pub target: Option<AddressTarget>,
395}
396
397impl Address {
398    /// Parse `@brain`, `@brain/<ulid>`, or `@brain/<store-path>.md`. The `@`
399    /// sigil is optional (an agent piping ids around should not have to quote
400    /// it back on). Whitespace and empty segments are malformed.
401    pub fn parse(raw: &str) -> LinkResult<Address> {
402        let bad = |reason: &str| LinkError::BadAddress {
403            given: raw.to_string(),
404            reason: reason.to_string(),
405        };
406
407        let trimmed = raw.trim();
408        let body = trimmed.strip_prefix('@').unwrap_or(trimmed);
409        if body.is_empty() {
410            return Err(bad("empty address"));
411        }
412
413        let (brain, rest) = match body.split_once('/') {
414            Some((b, r)) => (b, Some(r)),
415            None => (body, None),
416        };
417
418        if brain.is_empty() {
419            return Err(bad("missing brain reference before `/`"));
420        }
421        if !is_safe_ref(brain) {
422            return Err(bad(BAD_BRAIN_REASON));
423        }
424
425        let target = match rest {
426            None => None,
427            Some("") => return Err(bad("trailing `/` with no record id or path")),
428            Some(r) if crate::ulid::is_ulid(r) => Some(AddressTarget::Id(r.to_string())),
429            Some(r) => {
430                if !safe_store_rel_path(r) || !r.ends_with(".md") {
431                    return Err(bad(BAD_TARGET_REASON));
432                }
433                Some(AddressTarget::Path(r.to_string()))
434            }
435        };
436
437        Ok(Address {
438            brain: brain.to_string(),
439            target,
440        })
441    }
442}
443
444/// A brain reference safe to embed in a URL path segment: the shapes a hub
445/// accepts (ULID id or slug), which are also exactly URL-path-clean.
446fn is_safe_ref(s: &str) -> bool {
447    !s.is_empty()
448        && s.len() <= 64
449        && s.bytes()
450            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
451}
452
453/// A published-site handle (the `propose` target). Same lexical shape as a
454/// slug.
455pub fn is_valid_handle(s: &str) -> bool {
456    is_safe_ref(s)
457}
458
459/// True when `p` is a store-relative path this client will read from or write
460/// to disk: relative, no `..`, no empty or dot-leading segment (which shields
461/// `.dbmd/` and `.git/`), and only the hub-portable character set. Applied to
462/// every path an export hands us (the hub is not trusted with local layout)
463/// and to every path a push sends (mirroring the hub's own gate).
464pub fn safe_store_rel_path(p: &str) -> bool {
465    if p.is_empty() || p.len() > MAX_STORE_PATH_BYTES || p.starts_with('/') {
466        return false;
467    }
468    if !p
469        .bytes()
470        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/'))
471    {
472        return false;
473    }
474    p.split('/')
475        .all(|seg| !seg.is_empty() && seg != "." && seg != ".." && !seg.starts_with('.'))
476}
477
478/// Entry gate for every verb that embeds a caller-supplied brain reference in
479/// a URL path segment. `resolve` reaches the same check through
480/// [`Address::parse`]; the raw-ref verbs (`sync`, `grant`, `subscribe`) call
481/// this directly, so a ref carrying `/`, `..`, `?`, `#`, or any other
482/// URL-reshaping byte is refused before a request exists (the `url` crate
483/// normalizes dot segments, so an unvalidated ref would redirect the
484/// authenticated request to a different hub path).
485fn require_safe_ref(brain: &str) -> LinkResult<()> {
486    if is_safe_ref(brain) {
487        Ok(())
488    } else {
489        Err(LinkError::BadAddress {
490            given: brain.to_string(),
491            reason: BAD_BRAIN_REASON.to_string(),
492        })
493    }
494}
495
496/// Entry gate for the published-site handle `propose` embeds in its URL path.
497fn require_valid_handle(handle: &str) -> LinkResult<()> {
498    if is_valid_handle(handle) {
499        Ok(())
500    } else {
501        Err(LinkError::BadAddress {
502            given: handle.to_string(),
503            reason: "the site handle must be lowercase letters, digits, hyphens".to_string(),
504        })
505    }
506}
507
508/// Entry gate for the grant id `grant revoke` embeds in its URL path. Hub
509/// grant ids are lowercase ULIDs; the gate accepts the same URL-path-clean
510/// shape as a brain ref rather than pinning one mint scheme.
511fn require_safe_grant_id(id: &str) -> LinkResult<()> {
512    if is_safe_ref(id) {
513        Ok(())
514    } else {
515        Err(LinkError::BadGrantId {
516            given: id.to_string(),
517        })
518    }
519}
520
521// ─────────────────────────────────────────────────────────────────────────────
522// Configuration — flag > env > .dbmd/config; credential from env only
523// ─────────────────────────────────────────────────────────────────────────────
524
525/// The resolved client configuration for one invocation.
526#[derive(Debug, Clone)]
527pub struct HubConfig {
528    /// The hub base URL, trailing slash stripped, HTTPS-or-loopback enforced.
529    pub hub: String,
530    /// The bearer credential, when the environment carries one.
531    pub key: Option<String>,
532    /// The agent signing key, when [`AGENT_KEY_FILE_ENV`] names one. Wins
533    /// over the bearer for authenticated requests (link.md §8).
534    pub agent_key: Option<AgentSigningKey>,
535    /// The self-custodied brain signing key, when [`BRAIN_KEY_FILE_ENV`]
536    /// names one — `sync --push` then signs feed entries locally (§2.4).
537    pub brain_key: Option<AgentSigningKey>,
538    /// User-owned global toolkit state root. Identity pins and monotonic feed
539    /// checkpoints live below `<state_dir>/trust/`, never under a store.
540    pub state_dir: PathBuf,
541    /// True only when the origin came from untrusted store-local configuration.
542    /// Such origins are resolved and public-IP-pinned for every request.
543    store_selected: bool,
544}
545
546/// A loaded agent signing key: the PKCS#8 secret plus its derived public
547/// multikey. Debug never prints key material.
548#[derive(Clone)]
549pub struct AgentSigningKey {
550    pkcs8: Vec<u8>,
551    /// The key's public identity, `ed25519:<base64url sha256(SPKI)>`.
552    pub multikey: String,
553    /// The full public key, `base64url(SPKI DER)` — what feed entries carry.
554    pub public_key_spki: String,
555}
556
557impl std::fmt::Debug for AgentSigningKey {
558    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
559        f.debug_struct("AgentSigningKey")
560            .field("multikey", &self.multikey)
561            .field("pkcs8", &"<redacted>")
562            .finish()
563    }
564}
565
566impl HubConfig {
567    /// The credential, or the canonical "not configured" error. Verbs that
568    /// authenticate call this; `propose` never does.
569    pub fn require_key(&self) -> LinkResult<&str> {
570        self.key.as_deref().ok_or(LinkError::NoCredential)
571    }
572}
573
574/// Resolve the client configuration: `flag_hub` beats [`HUB_URL_ENV`] beats
575/// the `hub =` line in `<dir>/.dbmd/config`; no fallback default exists. The
576/// credential comes from [`HUB_KEY_ENV`] alone and is validated as a clean
577/// header token (never echoed on failure).
578pub fn hub_config(flag_hub: Option<&str>, dir: &Path) -> LinkResult<HubConfig> {
579    let explicit_hub = flag_hub
580        .map(str::to_string)
581        .or_else(|| env_nonempty(HUB_URL_ENV));
582    let selected_by_store = explicit_hub.is_none();
583    let hub = explicit_hub
584        .or_else(|| config_file_hub(&dir.join(CONFIG_REL_PATH)))
585        .ok_or(LinkError::NoHub)?;
586    let hub = hub.trim().trim_end_matches('/').to_string();
587    assert_safe_hub(&hub)?;
588    if selected_by_store {
589        let parsed =
590            url::Url::parse(&hub).map_err(|_| LinkError::UnsafeHub { hub: hub.clone() })?;
591        // A cloned store is not an operator opt-in to local-network access.
592        // Local/private development hubs must be selected explicitly by flag or
593        // environment, never by bytes inside the store.
594        if !parsed.scheme().eq_ignore_ascii_case("https")
595            || (parsed.path() != "/" && !parsed.path().is_empty())
596        {
597            return Err(LinkError::UnsafeHub { hub });
598        }
599    }
600
601    let key = match env_nonempty(HUB_KEY_ENV) {
602        Some(raw) => Some(clean_key(&raw)?),
603        None => None,
604    };
605
606    let agent_key = match env_nonempty(AGENT_KEY_FILE_ENV) {
607        Some(path) => Some(load_agent_key(Path::new(&path))?),
608        None => None,
609    };
610
611    let brain_key = match env_nonempty(BRAIN_KEY_FILE_ENV) {
612        Some(path) => Some(load_agent_key(Path::new(&path))?),
613        None => None,
614    };
615
616    // A cloned store controls `.dbmd/config`. It must never be able to point
617    // the process at an attacker origin and harvest the user's ambient account
618    // bearer, signed agent identity, or self-custodied brain signatures/content.
619    // Explicit --hub/DBMD_HUB_URL selection already pairs target + credential
620    // in the invocation environment; a store-selected target additionally
621    // needs an exact origin binding.
622    if selected_by_store && (key.is_some() || agent_key.is_some() || brain_key.is_some()) {
623        let bound = env_nonempty(HUB_CREDENTIAL_ORIGIN_ENV)
624            .and_then(|value| normalized_origin(&value).ok());
625        let selected_origin = normalized_origin(&hub)?;
626        if bound.as_deref() != Some(selected_origin.as_str()) {
627            return Err(LinkError::UnboundCredential);
628        }
629    }
630
631    Ok(HubConfig {
632        hub,
633        key,
634        agent_key,
635        brain_key,
636        state_dir: toolkit_state_dir()?,
637        store_selected: selected_by_store,
638    })
639}
640
641fn toolkit_state_dir() -> LinkResult<PathBuf> {
642    if let Some(path) = env_nonempty(STATE_DIR_ENV) {
643        let path = PathBuf::from(path);
644        if !path.is_absolute() {
645            return Err(LinkError::UnsafePath {
646                path: path.display().to_string(),
647            });
648        }
649        return Ok(path);
650    }
651    #[cfg(windows)]
652    if let Some(base) = env_nonempty("LOCALAPPDATA") {
653        let base = PathBuf::from(base);
654        if base.is_absolute() {
655            return Ok(base.join("dbmd").join("state"));
656        }
657    }
658    #[cfg(not(windows))]
659    if let Some(base) = env_nonempty("XDG_STATE_HOME") {
660        let base = PathBuf::from(base);
661        if base.is_absolute() {
662            return Ok(base.join("dbmd"));
663        }
664    }
665    let home = PathBuf::from(env_nonempty("HOME").ok_or_else(|| {
666        LinkError::Io(std::io::Error::new(
667            std::io::ErrorKind::NotFound,
668            format!("cannot locate user state; set {STATE_DIR_ENV}"),
669        ))
670    })?);
671    if !home.is_absolute() {
672        return Err(LinkError::UnsafePath {
673            path: home.display().to_string(),
674        });
675    }
676    #[cfg(target_os = "macos")]
677    {
678        Ok(home
679            .join("Library")
680            .join("Application Support")
681            .join("dbmd")
682            .join("state"))
683    }
684    #[cfg(all(not(target_os = "macos"), not(windows)))]
685    {
686        Ok(home.join(".local").join("state").join("dbmd"))
687    }
688}
689
690fn normalized_origin(value: &str) -> LinkResult<String> {
691    let parsed = url::Url::parse(value).map_err(|_| LinkError::UnsafeHub {
692        hub: value.to_string(),
693    })?;
694    if !(parsed.scheme().eq_ignore_ascii_case("https")
695        || parsed.scheme().eq_ignore_ascii_case("http"))
696        || !parsed.username().is_empty()
697        || parsed.password().is_some()
698        || (parsed.path() != "/" && !parsed.path().is_empty())
699        || parsed.query().is_some()
700        || parsed.fragment().is_some()
701    {
702        return Err(LinkError::UnsafeHub {
703            hub: value.to_string(),
704        });
705    }
706    let host = parsed.host_str().ok_or_else(|| LinkError::UnsafeHub {
707        hub: value.to_string(),
708    })?;
709    let host = if host.contains(':') {
710        format!("[{host}]")
711    } else {
712        host.to_ascii_lowercase()
713    };
714    let port = parsed
715        .port_or_known_default()
716        .ok_or_else(|| LinkError::UnsafeHub {
717            hub: value.to_string(),
718        })?;
719    let default = (parsed.scheme().eq_ignore_ascii_case("https") && port == 443)
720        || (parsed.scheme().eq_ignore_ascii_case("http") && port == 80);
721    Ok(format!(
722        "{}://{}{}",
723        parsed.scheme().to_ascii_lowercase(),
724        host,
725        if default {
726            String::new()
727        } else {
728            format!(":{port}")
729        }
730    ))
731}
732
733// ─────────────────────────────────────────────────────────────────────────────
734// Agent signing keys — link.md §8 `LinkMD-Sig` proof of possession
735// ─────────────────────────────────────────────────────────────────────────────
736
737/// The DER prefix that wraps a raw Ed25519 public key into a
738/// SubjectPublicKeyInfo (RFC 8410).
739const ED25519_SPKI_PREFIX: [u8; 12] = [
740    0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
741];
742
743fn bad_agent_key(message: &str) -> LinkError {
744    LinkError::BadAgentKey {
745        message: message.to_string(),
746    }
747}
748
749fn agent_keypair(pkcs8: &[u8]) -> LinkResult<ring::signature::Ed25519KeyPair> {
750    // `from_pkcs8` wants ring's own v2 encoding (private + public); keys from
751    // other tools are often PKCS#8 v1, which `maybe_unchecked` accepts by
752    // deriving the public half itself.
753    ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8)
754        .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(pkcs8))
755        .map_err(|_| bad_agent_key("not an Ed25519 PKCS#8 key"))
756}
757
758/// Derive `(publicKeySpki b64u, multikey)` from a keypair.
759fn public_identity_for(pair: &ring::signature::Ed25519KeyPair) -> (String, String) {
760    use ring::signature::KeyPair as _;
761    let mut spki = Vec::with_capacity(44);
762    spki.extend_from_slice(&ED25519_SPKI_PREFIX);
763    spki.extend_from_slice(pair.public_key().as_ref());
764    (
765        URL_SAFE_NO_PAD.encode(&spki),
766        format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&spki))),
767    )
768}
769
770/// Load and validate a signing-key file (agent or brain — same format):
771/// one base64url line of PKCS#8. Public so `dbmd key rotate` can load the
772/// old key explicitly.
773pub fn load_signing_key(path: &Path) -> LinkResult<AgentSigningKey> {
774    load_agent_key(path)
775}
776
777/// Load and validate the agent key file: one base64url line of PKCS#8.
778fn load_agent_key(path: &Path) -> LinkResult<AgentSigningKey> {
779    #[cfg(unix)]
780    let file = {
781        use std::os::fd::{AsRawFd as _, FromRawFd as _};
782        use std::os::unix::ffi::OsStrExt as _;
783        let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
784            .map_err(|e| bad_agent_key(&format!("cannot open the key parent: {e}")))?;
785        let leaf = path
786            .file_name()
787            .ok_or_else(|| bad_agent_key("the key path has no file name"))?;
788        let leaf = c_name(leaf.as_bytes(), &path.display().to_string())?;
789        let fd = unsafe {
790            libc::openat(
791                parent.as_raw_fd(),
792                leaf.as_ptr(),
793                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
794            )
795        };
796        if fd < 0 {
797            return Err(bad_agent_key(
798                "the key path must be an existing regular file without symlink ancestors",
799            ));
800        }
801        unsafe { std::fs::File::from_raw_fd(fd) }
802    };
803    #[cfg(not(unix))]
804    let file = std::fs::File::open(path)
805        .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
806    let metadata = file
807        .metadata()
808        .map_err(|e| bad_agent_key(&format!("cannot inspect the key file: {e}")))?;
809    if !metadata.is_file() {
810        return Err(bad_agent_key("the key path must be a regular file"));
811    }
812    #[cfg(unix)]
813    {
814        use std::os::unix::fs::PermissionsExt as _;
815        if metadata.permissions().mode() & 0o077 != 0 {
816            return Err(bad_agent_key(
817                "the key file is accessible to group/other; set mode 0600",
818            ));
819        }
820    }
821    let mut text = String::new();
822    file.take(1024 * 1024 + 1)
823        .read_to_string(&mut text)
824        .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
825    if text.len() > 1024 * 1024 {
826        return Err(bad_agent_key("the key file exceeds the size limit"));
827    }
828    let pkcs8 = URL_SAFE_NO_PAD
829        .decode(text.trim())
830        .map_err(|_| bad_agent_key("the key file is not one base64url line"))?;
831    let (public_key_spki, multikey) = public_identity_for(&agent_keypair(&pkcs8)?);
832    Ok(AgentSigningKey {
833        pkcs8,
834        multikey,
835        public_key_spki,
836    })
837}
838
839/// Durably create a new secret file without an exists→write race and without
840/// ever exposing a default-mode (commonly 0644) key between write and chmod.
841/// `create_new` also refuses a planted symlink. The file and its parent
842/// directory are synced before the caller can publish the corresponding
843/// public identity remotely.
844fn write_secret_new(path: &Path, bytes: &[u8]) -> LinkResult<()> {
845    #[cfg(unix)]
846    let (mut file, parent, leaf) = {
847        use std::os::fd::{AsRawFd as _, FromRawFd as _};
848        use std::os::unix::ffi::OsStrExt as _;
849        let parent = open_or_create_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))?;
850        let leaf_name = path
851            .file_name()
852            .ok_or_else(|| bad_agent_key("the output key path has no file name"))?;
853        let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
854        let fd = unsafe {
855            libc::openat(
856                parent.as_raw_fd(),
857                leaf.as_ptr(),
858                libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
859                0o600,
860            )
861        };
862        if fd < 0 {
863            let error = std::io::Error::last_os_error();
864            if error.kind() == std::io::ErrorKind::AlreadyExists {
865                return Err(bad_agent_key(
866                    "the output file already exists — refusing to overwrite a key",
867                ));
868            }
869            return Err(error.into());
870        }
871        (unsafe { std::fs::File::from_raw_fd(fd) }, parent, leaf)
872    };
873    #[cfg(not(unix))]
874    let mut file = std::fs::OpenOptions::new()
875        .write(true)
876        .create_new(true)
877        .open(path)
878        .map_err(|error| {
879            if error.kind() == std::io::ErrorKind::AlreadyExists {
880                bad_agent_key("the output file already exists — refusing to overwrite a key")
881            } else {
882                LinkError::Io(error)
883            }
884        })?;
885    if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_all()) {
886        drop(file);
887        #[cfg(unix)]
888        let _ =
889            unsafe { libc::unlinkat(std::os::fd::AsRawFd::as_raw_fd(&parent), leaf.as_ptr(), 0) };
890        #[cfg(not(unix))]
891        let _ = std::fs::remove_file(path);
892        return Err(LinkError::Io(error));
893    }
894    drop(file);
895    #[cfg(unix)]
896    parent.sync_all()?;
897    Ok(())
898}
899
900/// What `dbmd key generate` returns: the public identity to register plus
901/// where the secret landed.
902#[derive(Debug, Serialize)]
903pub struct GeneratedAgentKey {
904    /// `ed25519:<fingerprint>` — the grantable/registerable identity.
905    pub multikey: String,
906    /// base64url SPKI DER — what a hub's register endpoint takes.
907    #[serde(rename = "publicKeySpki")]
908    pub public_key_spki: String,
909    /// Where the PKCS#8 secret was written (mode 0600).
910    #[serde(rename = "keyFile")]
911    pub key_file: String,
912}
913
914/// Mint a fresh Ed25519 agent keypair. The secret is written to `out`
915/// (base64url PKCS#8, one line, 0600, refusing to overwrite); only public
916/// identity is returned. The private key never enters a store and never
917/// travels — requests carry per-request signatures instead (link.md §8).
918pub fn generate_agent_key(out: &Path) -> LinkResult<GeneratedAgentKey> {
919    require_hardened_filesystem("key generation")?;
920    let rng = ring::rand::SystemRandom::new();
921    let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
922        .map_err(|_| bad_agent_key("key generation failed"))?;
923    let pair = agent_keypair(pkcs8.as_ref())?;
924    let (spki_b64u, multikey) = public_identity_for(&pair);
925
926    write_secret_new(
927        out,
928        format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
929    )?;
930
931    Ok(GeneratedAgentKey {
932        multikey,
933        public_key_spki: spki_b64u,
934        key_file: out.display().to_string(),
935    })
936}
937
938/// Build the origin-bound `LinkMD-Sig` v2 header for one request:
939/// `canonical = "v2" LF origin LF METHOD LF path+query LF ts LF
940/// (sha256hex(body) | "-")`.
941///
942/// The origin is derived from the already-validated hub URL, never from
943/// attacker-controlled request metadata. Binding it closes the v1 replay
944/// class where a proof captured by one hub could be replayed to another hub
945/// serving the same path inside the timestamp window.
946fn linkmd_sig_header(
947    key: &AgentSigningKey,
948    origin: &str,
949    method: &str,
950    path: &str,
951    body: Option<&str>,
952) -> LinkResult<String> {
953    let ts = std::time::SystemTime::now()
954        .duration_since(std::time::UNIX_EPOCH)
955        .map_err(|_| bad_agent_key("system clock is before the epoch"))?
956        .as_secs();
957    let body_hash = match body {
958        Some(b) => format!("{:x}", Sha256::digest(b.as_bytes())),
959        None => "-".to_string(),
960    };
961    let canonical = format!(
962        "v2\n{}\n{}\n{}\n{}\n{}",
963        origin,
964        method.to_uppercase(),
965        path,
966        ts,
967        body_hash
968    );
969    let pair = agent_keypair(&key.pkcs8)?;
970    let sig = URL_SAFE_NO_PAD.encode(pair.sign(canonical.as_bytes()).as_ref());
971    let fingerprint = key.multikey.trim_start_matches("ed25519:");
972    Ok(format!(
973        "LinkMD-Sig v2,key=ed25519:{fingerprint},ts={ts},sig={sig}"
974    ))
975}
976
977// ─────────────────────────────────────────────────────────────────────────────
978// Self-custody feed entries — the client signs what the hub only verifies
979// ─────────────────────────────────────────────────────────────────────────────
980
981/// One `files` element of a wire-profile-v1 feed entry (SPEC §5.1: fields in
982/// exactly this order).
983#[derive(Serialize)]
984struct WireFeedFile {
985    path: String,
986    sha256: String,
987    bytes: u64,
988}
989
990/// The unsigned entry in the normative §5.1 field order — serde serializes
991/// struct fields in declaration order, which IS the wire contract.
992#[derive(Serialize)]
993struct UnsignedWireEntry<'a> {
994    v: u8,
995    seq: u64,
996    ts: String,
997    brain: &'a str,
998    public_key: &'a str,
999    kind: &'a str,
1000    op: &'a str,
1001    pack_sha256: &'a str,
1002    files: &'a [WireFeedFile],
1003    removed: &'a [String],
1004    prev_entry_hash: Option<&'a str>,
1005}
1006
1007/// Build and sign a wire-profile-v1 `push` feed entry with a self-custodied
1008/// brain key: serialize the unsigned entry compactly in the normative order,
1009/// Ed25519-sign those exact bytes, splice `sig` on as the final field. The
1010/// returned string is the exact serialization the hub stores verbatim (plus
1011/// one trailing newline) and every independent reader re-derives.
1012fn self_custody_entry(
1013    key: &AgentSigningKey,
1014    seq: u64,
1015    ts: String,
1016    pack_sha256: &str,
1017    files: &[WireFeedFile],
1018    prev_entry_hash: Option<&str>,
1019) -> LinkResult<String> {
1020    let removed: [String; 0] = [];
1021    let unsigned = serde_json::to_string(&UnsignedWireEntry {
1022        v: 1,
1023        seq,
1024        ts,
1025        brain: &key.multikey,
1026        public_key: &key.public_key_spki,
1027        kind: "push",
1028        op: "snapshot",
1029        pack_sha256,
1030        files,
1031        removed: &removed,
1032        prev_entry_hash,
1033    })
1034    .expect("serialize feed entry");
1035    let pair = agent_keypair(&key.pkcs8)?;
1036    let sig = URL_SAFE_NO_PAD.encode(pair.sign(unsigned.as_bytes()).as_ref());
1037    Ok(format!(
1038        "{},\"sig\":\"{}\"}}",
1039        &unsigned[..unsigned.len() - 1],
1040        sig
1041    ))
1042}
1043
1044/// An env var, treated as absent when unset or empty (an empty
1045/// `DBMD_HUB_KEY=` falls through rather than becoming an empty credential).
1046fn env_nonempty(name: &str) -> Option<String> {
1047    std::env::var(name).ok().filter(|v| !v.trim().is_empty())
1048}
1049
1050/// Read the `hub = <URL>` line out of a `.dbmd/config` file. The format is
1051/// deliberately minimal: `key = value` lines, `#` comments, unknown keys
1052/// ignored (forward-compatible). A missing or unreadable file is simply "not
1053/// configured here".
1054fn config_file_hub(path: &Path) -> Option<String> {
1055    const MAX_CONFIG_BYTES: u64 = 64 * 1024;
1056    #[cfg(unix)]
1057    let file = {
1058        use std::os::fd::{AsRawFd as _, FromRawFd as _};
1059        use std::os::unix::ffi::OsStrExt as _;
1060        let parent =
1061            open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new("."))).ok()?;
1062        let leaf = c_name(path.file_name()?.as_bytes(), &path.display().to_string()).ok()?;
1063        let fd = unsafe {
1064            libc::openat(
1065                parent.as_raw_fd(),
1066                leaf.as_ptr(),
1067                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1068            )
1069        };
1070        if fd < 0 {
1071            return None;
1072        }
1073        unsafe { std::fs::File::from_raw_fd(fd) }
1074    };
1075    #[cfg(not(unix))]
1076    let file = std::fs::File::open(path).ok()?;
1077    let metadata = file.metadata().ok()?;
1078    if !metadata.is_file() || metadata.len() > MAX_CONFIG_BYTES {
1079        return None;
1080    }
1081    let mut bytes = Vec::with_capacity(metadata.len() as usize);
1082    file.take(MAX_CONFIG_BYTES + 1)
1083        .read_to_end(&mut bytes)
1084        .ok()?;
1085    if bytes.len() as u64 > MAX_CONFIG_BYTES {
1086        return None;
1087    }
1088    let text = String::from_utf8(bytes).ok()?;
1089    for line in text.lines() {
1090        let line = line.trim();
1091        if line.is_empty() || line.starts_with('#') {
1092            continue;
1093        }
1094        if let Some((k, v)) = line.split_once('=') {
1095            if k.trim() == "hub" {
1096                let v = v.trim();
1097                if !v.is_empty() {
1098                    return Some(v.to_string());
1099                }
1100            }
1101        }
1102    }
1103    None
1104}
1105
1106/// The bearer key must never travel in cleartext; only loopback hosts may
1107/// skip TLS (local development against a hub on localhost).
1108fn assert_safe_hub(hub: &str) -> LinkResult<()> {
1109    let parsed = url::Url::parse(hub).map_err(|_| LinkError::UnsafeHub {
1110        hub: hub.to_string(),
1111    })?;
1112    if !(parsed.scheme().eq_ignore_ascii_case("https")
1113        || parsed.scheme().eq_ignore_ascii_case("http"))
1114        || !parsed.username().is_empty()
1115        || parsed.password().is_some()
1116        || (parsed.path() != "/" && !parsed.path().is_empty())
1117        || parsed.query().is_some()
1118        || parsed.fragment().is_some()
1119    {
1120        return Err(LinkError::UnsafeHub {
1121            hub: hub.to_string(),
1122        });
1123    }
1124    let loopback = match parsed.host() {
1125        Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
1126        Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
1127        Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
1128        None => false,
1129    };
1130    if parsed.scheme().eq_ignore_ascii_case("https") || loopback {
1131        Ok(())
1132    } else {
1133        Err(LinkError::UnsafeHub {
1134            hub: hub.to_string(),
1135        })
1136    }
1137}
1138
1139/// Trim paste artifacts and refuse anything outside the printable-ASCII token
1140/// range WITHOUT echoing the key — an HTTP library rejecting a bad header
1141/// value tends to echo the whole header line, credential included, so the
1142/// gate sits here instead.
1143fn clean_key(raw: &str) -> LinkResult<String> {
1144    let k = raw.trim();
1145    if k.is_empty() || k.bytes().any(|b| !(0x21..=0x7e).contains(&b)) {
1146        return Err(LinkError::BadKey);
1147    }
1148    Ok(k.to_string())
1149}
1150
1151// ─────────────────────────────────────────────────────────────────────────────
1152// Transport — one blocking agent, capped reads, the JSON-or-refuse contract
1153// ─────────────────────────────────────────────────────────────────────────────
1154
1155/// One hub response: the status plus the parsed JSON body when there was one.
1156#[derive(Debug)]
1157pub struct HubResponse {
1158    /// The HTTP status code.
1159    pub status: u16,
1160    /// The parsed JSON body, `None` when the body was empty or not JSON.
1161    pub body: Option<Value>,
1162}
1163
1164struct RawHubResponse {
1165    status: u16,
1166    body: Vec<u8>,
1167}
1168
1169/// Whether a request carries the bearer credential.
1170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1171enum Auth {
1172    /// Send `authorization: Bearer <key>`; error without a key.
1173    Required,
1174    /// Send no credential — the propose door is unauthenticated by design.
1175    None,
1176    /// Send the configured credential when one exists, otherwise nothing —
1177    /// brain-addressed propose works anonymously on public brains, and an
1178    /// authenticated caller earns a bigger actor-class budget.
1179    Optional,
1180}
1181
1182fn agent_builder_with_timeout(overall: std::time::Duration) -> ureq::AgentBuilder {
1183    ureq::AgentBuilder::new()
1184        .user_agent(concat!("dbmd/", env!("CARGO_PKG_VERSION")))
1185        // Never follow a redirect while a bearer, a store pack, or a signed
1186        // response is in flight. Callers see the 3xx as a non-success instead
1187        // of letting an origin steer sensitive material elsewhere.
1188        .redirects(0)
1189        .timeout_connect(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
1190        .timeout_read(std::time::Duration::from_secs(READ_TIMEOUT_SECS))
1191        .timeout_write(overall)
1192        .timeout(overall)
1193}
1194
1195fn agent_builder() -> ureq::AgentBuilder {
1196    agent_builder_with_timeout(std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS))
1197}
1198
1199fn agent() -> ureq::Agent {
1200    agent_builder().build()
1201}
1202
1203fn hub_agent(cfg: &HubConfig) -> LinkResult<ureq::Agent> {
1204    if !cfg.store_selected {
1205        return Ok(agent());
1206    }
1207    let parsed = url::Url::parse(&cfg.hub).map_err(|_| LinkError::UnsafeHub {
1208        hub: cfg.hub.clone(),
1209    })?;
1210    pinned_public_agent(&parsed, false, "store-selected hub")
1211}
1212
1213/// Perform one hub request. `path` is the binding path (starts with `/`);
1214/// `body` posts JSON. Transport failures, oversized bodies, and non-UTF-8 are
1215/// all surfaced as typed [`LinkError`]s; HTTP error statuses are returned in
1216/// the [`HubResponse`] for [`ensure_ok`] to shape.
1217fn request_raw(
1218    cfg: &HubConfig,
1219    method: &str,
1220    path: &str,
1221    body: Option<&Value>,
1222    auth: Auth,
1223    max_response_bytes: u64,
1224) -> LinkResult<RawHubResponse> {
1225    let url = format!("{}{}", cfg.hub, path);
1226    let encoded_body = body.map(Value::to_string);
1227    let origin = normalized_origin(&cfg.hub)?;
1228    // An agent signing key outranks the bearer: possession proofs put nothing
1229    // reusable on the wire, so when both are configured the stronger one wins.
1230    let credential = match auth {
1231        Auth::Required => Some(match &cfg.agent_key {
1232            Some(key) => linkmd_sig_header(key, &origin, method, path, encoded_body.as_deref())?,
1233            None => format!("Bearer {}", cfg.require_key()?),
1234        }),
1235        Auth::Optional => match &cfg.agent_key {
1236            Some(key) => Some(linkmd_sig_header(
1237                key,
1238                &origin,
1239                method,
1240                path,
1241                encoded_body.as_deref(),
1242            )?),
1243            None => cfg.key.as_deref().map(|k| format!("Bearer {k}")),
1244        },
1245        Auth::None => None,
1246    };
1247    let http = hub_agent(cfg)?;
1248    let result = with_connect_retries(|| {
1249        let mut req = http.request(method, &url);
1250        if let Some(value) = &credential {
1251            req = req.set("authorization", value);
1252        }
1253        match &encoded_body {
1254            Some(value) => req
1255                .set("content-type", "application/json")
1256                .send_string(value)
1257                .map_err(Box::new),
1258            None => req.call().map_err(Box::new),
1259        }
1260    });
1261    let resp = match result {
1262        Ok(resp) => resp,
1263        Err(error) => match *error {
1264            ureq::Error::Status(_, resp) => resp,
1265            ureq::Error::Transport(error) => {
1266                return Err(LinkError::Transport {
1267                    hub: cfg.hub.clone(),
1268                    message: error.to_string(),
1269                });
1270            }
1271        },
1272    };
1273
1274    let status = resp.status();
1275    let mut buf = Vec::new();
1276    resp.into_reader()
1277        .take(max_response_bytes + 1)
1278        .read_to_end(&mut buf)?;
1279    if buf.len() as u64 > max_response_bytes {
1280        return Err(LinkError::ResponseTooLarge {
1281            limit_bytes: max_response_bytes,
1282        });
1283    }
1284    Ok(RawHubResponse { status, body: buf })
1285}
1286
1287fn request_capped(
1288    cfg: &HubConfig,
1289    method: &str,
1290    path: &str,
1291    body: Option<&Value>,
1292    auth: Auth,
1293    max_response_bytes: u64,
1294) -> LinkResult<HubResponse> {
1295    let raw = request_raw(cfg, method, path, body, auth, max_response_bytes)?;
1296    let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
1297    Ok(HubResponse {
1298        status: raw.status,
1299        body: parsed,
1300    })
1301}
1302
1303fn request(
1304    cfg: &HubConfig,
1305    method: &str,
1306    path: &str,
1307    body: Option<&Value>,
1308    auth: Auth,
1309) -> LinkResult<HubResponse> {
1310    request_capped(cfg, method, path, body, auth, MAX_RESPONSE_BYTES)
1311}
1312
1313fn ensure_raw_ok(r: RawHubResponse, what: &'static str) -> LinkResult<Vec<u8>> {
1314    if (200..300).contains(&r.status) {
1315        return Ok(r.body);
1316    }
1317    ensure_ok(
1318        HubResponse {
1319            status: r.status,
1320            body: serde_json::from_slice(&r.body).ok(),
1321        },
1322        what,
1323    )
1324    .and_then(|_| Err(invalid_feed("a non-2xx response was accepted unexpectedly")))
1325}
1326
1327/// These failures happen before any HTTP request reaches the hub, so retrying
1328/// cannot duplicate a mutation. Mid-stream I/O is deliberately excluded: once
1329/// bytes may have crossed the wire, the caller must rely on the verb's own
1330/// idempotency contract instead of guessing.
1331fn is_pre_request_transport(kind: ureq::ErrorKind) -> bool {
1332    matches!(
1333        kind,
1334        ureq::ErrorKind::Dns | ureq::ErrorKind::ConnectionFailed | ureq::ErrorKind::ProxyConnect
1335    )
1336}
1337
1338fn with_connect_retries(
1339    mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
1340) -> Result<ureq::Response, Box<ureq::Error>> {
1341    let mut attempt = 0;
1342    loop {
1343        match send() {
1344            Err(error)
1345                if matches!(
1346                    error.as_ref(),
1347                    ureq::Error::Transport(transport)
1348                        if is_pre_request_transport(transport.kind())
1349                ) && attempt + 1 < CONNECT_ATTEMPTS =>
1350            {
1351                std::thread::sleep(std::time::Duration::from_millis(
1352                    CONNECT_RETRY_BACKOFF_MS[attempt],
1353                ));
1354                attempt += 1;
1355            }
1356            result => return result,
1357        }
1358    }
1359}
1360
1361fn hub_is_loopback(hub: &str) -> bool {
1362    url::Url::parse(hub).ok().is_some_and(|parsed| {
1363        parsed.host().is_some_and(|host| match host {
1364            url::Host::Domain(host) => host.eq_ignore_ascii_case("localhost"),
1365            url::Host::Ipv4(ip) => ip.is_loopback(),
1366            url::Host::Ipv6(ip) => ip.is_loopback(),
1367        })
1368    })
1369}
1370
1371fn presigned_agent(cfg: &HubConfig, raw: &str) -> LinkResult<ureq::Agent> {
1372    let parsed = url::Url::parse(raw).map_err(|_| LinkError::InvalidPack {
1373        message: "the hub returned an invalid object-store URL".to_string(),
1374    })?;
1375    let allow_private = hub_is_loopback(&cfg.hub)
1376        || env_nonempty(ALLOW_PRIVATE_OBJECT_URL_ENV).as_deref() == Some("1");
1377    if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1378        || !parsed.username().is_empty()
1379        || parsed.password().is_some()
1380        || parsed.fragment().is_some()
1381    {
1382        return Err(LinkError::InvalidPack {
1383            message: "the hub returned an unsafe object-store URL".to_string(),
1384        });
1385    }
1386    pinned_public_agent(&parsed, allow_private, "object-store URL").map_err(|_| {
1387        LinkError::InvalidPack {
1388            message: "the hub returned an object-store URL with an unsafe network target"
1389                .to_string(),
1390        }
1391    })
1392}
1393
1394fn put_presigned(cfg: &HubConfig, raw: &str, headers: &Value, bytes: &[u8]) -> LinkResult<()> {
1395    let http = presigned_agent(cfg, raw)?;
1396    let result = with_connect_retries(|| {
1397        let mut req = http.put(raw);
1398        if let Some(map) = headers.as_object() {
1399            for (name, value) in map {
1400                if let Some(value) = value.as_str() {
1401                    req = req.set(name, value);
1402                }
1403            }
1404        }
1405        req.send_bytes(bytes).map_err(Box::new)
1406    });
1407    match result {
1408        Ok(resp) if (200..300).contains(&resp.status()) => Ok(()),
1409        Ok(resp) => Err(LinkError::Http {
1410            what: "pack upload",
1411            status: resp.status(),
1412            message: "object store rejected the upload".to_string(),
1413            code: None,
1414        }),
1415        Err(error) => match *error {
1416            ureq::Error::Status(_, resp) => Err(LinkError::Http {
1417                what: "pack upload",
1418                status: resp.status(),
1419                message: "object store rejected the upload".to_string(),
1420                code: None,
1421            }),
1422            ureq::Error::Transport(err) => Err(LinkError::Transport {
1423                hub: "the object store".to_string(),
1424                message: err.to_string(),
1425            }),
1426        },
1427    }
1428}
1429
1430fn one_past_bounded_limit(max_bytes: u64) -> Option<u64> {
1431    max_bytes.checked_add(1)
1432}
1433
1434fn presigned_download_read_limit() -> u64 {
1435    one_past_bounded_limit(MAX_PACK_BYTES)
1436        .expect("the fixed presigned-download ceiling must leave room for the refusal byte")
1437}
1438
1439fn get_presigned(cfg: &HubConfig, raw: &str) -> LinkResult<Vec<u8>> {
1440    let http = presigned_agent(cfg, raw)?;
1441    let resp = match with_connect_retries(|| http.get(raw).call().map_err(Box::new)) {
1442        Ok(resp) => resp,
1443        Err(error) => match *error {
1444            ureq::Error::Status(_, resp) => {
1445                return Err(LinkError::Http {
1446                    what: "pack download",
1447                    status: resp.status(),
1448                    message: "object store rejected the download".to_string(),
1449                    code: None,
1450                });
1451            }
1452            ureq::Error::Transport(err) => {
1453                return Err(LinkError::Transport {
1454                    hub: "the object store".to_string(),
1455                    message: err.to_string(),
1456                });
1457            }
1458        },
1459    };
1460    if !(200..300).contains(&resp.status()) {
1461        return Err(LinkError::Http {
1462            what: "pack download",
1463            status: resp.status(),
1464            message: "object store rejected the download".to_string(),
1465            code: None,
1466        });
1467    }
1468    let mut bytes = Vec::new();
1469    resp.into_reader()
1470        .take(presigned_download_read_limit())
1471        .read_to_end(&mut bytes)?;
1472    if bytes.len() as u64 > MAX_PACK_BYTES {
1473        return Err(LinkError::InvalidPack {
1474            message: "download exceeds the compressed-size limit".to_string(),
1475        });
1476    }
1477    Ok(bytes)
1478}
1479
1480/// Unwrap a successful JSON body, or shape the failure: a >=400 surfaces the
1481/// hub's own `error` + `code`; a 2xx without JSON is refused as not a hub
1482/// answer.
1483fn ensure_ok(r: HubResponse, what: &'static str) -> LinkResult<Value> {
1484    if !(200..300).contains(&r.status) {
1485        let message = r
1486            .body
1487            .as_ref()
1488            .and_then(|b| b.get("error"))
1489            .and_then(Value::as_str)
1490            .unwrap_or("unknown error")
1491            .to_string();
1492        let code = r
1493            .body
1494            .as_ref()
1495            .and_then(|b| b.get("code"))
1496            .and_then(Value::as_str)
1497            .map(str::to_string);
1498        return Err(LinkError::Http {
1499            what,
1500            status: r.status,
1501            message,
1502            code,
1503        });
1504    }
1505    r.body.ok_or(LinkError::NotJson {
1506        what,
1507        status: r.status,
1508    })
1509}
1510
1511// ─────────────────────────────────────────────────────────────────────────────
1512// resolve — handle → brain card; @brain/id → the record
1513// ─────────────────────────────────────────────────────────────────────────────
1514
1515/// Resolve an address. A bare `@brain` returns the brain card (metadata +
1516/// index stats — the v0 form of the card; keys arrive with the protocol's
1517/// signing layer). `@brain/<id>` and `@brain/<path>.md` return the full
1518/// record, frontmatter + body.
1519fn is_public_registry_ip(ip: std::net::IpAddr) -> bool {
1520    match ip {
1521        std::net::IpAddr::V4(ip) => {
1522            let [a, b, c, _] = ip.octets();
1523            !(a == 0
1524                || a == 10
1525                || a == 127
1526                || (a == 100 && (64..=127).contains(&b))
1527                || (a == 169 && b == 254)
1528                || (a == 172 && (16..=31).contains(&b))
1529                || (a == 192 && b == 0 && c == 0)
1530                || (a == 192 && b == 0 && c == 2)
1531                || (a == 192 && b == 88 && c == 99)
1532                || (a == 192 && b == 168)
1533                || (a == 198 && (b == 18 || b == 19))
1534                || (a == 198 && b == 51 && c == 100)
1535                || (a == 203 && b == 0 && c == 113)
1536                || a >= 224)
1537        }
1538        std::net::IpAddr::V6(ip) => {
1539            let segments = ip.segments();
1540            // Conservatively accept only global unicast 2000::/3, excluding
1541            // special-purpose/transition blocks. In particular, 6to4 embeds
1542            // an IPv4 destination and must not tunnel a validated public
1543            // connect to 127/8 or RFC1918.
1544            (segments[0] & 0xe000) == 0x2000
1545                && !(segments[0] == 0x2001 && (segments[1] & 0xfe00) == 0)
1546                && !(segments[0] == 0x2001 && segments[1] == 0x0db8)
1547                && segments[0] != 0x2002
1548                && !(segments[0] == 0x3fff && (segments[1] & 0xf000) == 0)
1549        }
1550    }
1551}
1552
1553#[derive(Clone)]
1554struct PinnedRegistryResolver {
1555    netloc: String,
1556    addresses: Vec<std::net::SocketAddr>,
1557}
1558
1559impl ureq::Resolver for PinnedRegistryResolver {
1560    fn resolve(&self, requested: &str) -> std::io::Result<Vec<std::net::SocketAddr>> {
1561        if requested == self.netloc {
1562            Ok(self.addresses.clone())
1563        } else {
1564            Err(std::io::Error::new(
1565                std::io::ErrorKind::PermissionDenied,
1566                "registry request attempted to resolve an unvalidated authority",
1567            ))
1568        }
1569    }
1570}
1571
1572fn pinned_public_agent(
1573    url: &url::Url,
1574    allow_private: bool,
1575    label: &str,
1576) -> LinkResult<ureq::Agent> {
1577    let host = url
1578        .host_str()
1579        .ok_or_else(|| invalid_feed(format!("{label} has no host")))?;
1580    let port = url
1581        .port_or_known_default()
1582        .ok_or_else(|| invalid_feed(format!("{label} has no port")))?;
1583    let addresses = resolve_addresses_with_deadline(
1584        host,
1585        port,
1586        std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS),
1587    )
1588    .map_err(|error| invalid_feed(format!("{label} DNS resolution failed: {error}")))?;
1589    if addresses.is_empty() {
1590        return Err(invalid_feed(format!("{label} DNS returned no addresses")));
1591    }
1592    if !allow_private
1593        && addresses
1594            .iter()
1595            .any(|address| !is_public_registry_ip(address.ip()))
1596    {
1597        return Err(invalid_feed(format!(
1598            "{label} resolves to a non-public address"
1599        )));
1600    }
1601    let netloc = if host.contains(':') {
1602        format!("[{host}]:{port}")
1603    } else {
1604        format!("{host}:{port}")
1605    };
1606    Ok(agent_builder()
1607        .resolver(PinnedRegistryResolver { netloc, addresses })
1608        .build())
1609}
1610
1611/// Resolve one authority without allowing libc DNS to hold the caller forever.
1612/// `ToSocketAddrs` itself has no timeout API, so resolution runs in a detached
1613/// worker and only its result channel is awaited. A late resolver result has no
1614/// side effects and is discarded after the deadline.
1615fn resolve_addresses_with_deadline(
1616    host: &str,
1617    port: u16,
1618    timeout: std::time::Duration,
1619) -> std::io::Result<Vec<std::net::SocketAddr>> {
1620    use std::net::ToSocketAddrs as _;
1621
1622    let host = host.to_string();
1623    let (send, receive) = std::sync::mpsc::sync_channel(1);
1624    std::thread::Builder::new()
1625        .name("dbmd-dns".to_string())
1626        .spawn(move || {
1627            let result = (host.as_str(), port)
1628                .to_socket_addrs()
1629                .map(|addresses| addresses.collect());
1630            let _ = send.send(result);
1631        })
1632        .map_err(|error| std::io::Error::other(format!("cannot start resolver: {error}")))?;
1633    match receive.recv_timeout(timeout) {
1634        Ok(result) => result,
1635        Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(std::io::Error::new(
1636            std::io::ErrorKind::TimedOut,
1637            "resolution exceeded its deadline",
1638        )),
1639        Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(std::io::Error::other(
1640            "resolver stopped without returning a result",
1641        )),
1642    }
1643}
1644
1645fn registry_agent(url: &url::Url) -> LinkResult<ureq::Agent> {
1646    let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
1647    pinned_public_agent(url, allow_private, "registry home")
1648}
1649
1650/// GET an absolute URL as JSON with NO credential — used to fetch a brain card
1651/// from a FOREIGN home during registry resolution. DNS is resolved once,
1652/// every answer is required to be public, and that exact answer set is pinned
1653/// into a no-redirect HTTP agent to close private-network SSRF and rebinding.
1654fn get_json_absolute(url: &str) -> LinkResult<Value> {
1655    let parsed = url::Url::parse(url).map_err(|_| invalid_feed("invalid registry home URL"))?;
1656    let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
1657    if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1658        || !parsed.username().is_empty()
1659        || parsed.password().is_some()
1660        || parsed.query().is_some()
1661        || parsed.fragment().is_some()
1662    {
1663        return Err(invalid_feed("unsafe registry home URL"));
1664    }
1665    let http = registry_agent(&parsed)?;
1666    let resp = match with_connect_retries(|| http.get(url).call().map_err(Box::new)) {
1667        Ok(resp) => resp,
1668        Err(error) => match *error {
1669            ureq::Error::Status(status, resp) => {
1670                let _ = resp;
1671                return Err(LinkError::Http {
1672                    what: "registry home fetch",
1673                    status,
1674                    message: "the home node rejected the card request".to_string(),
1675                    code: None,
1676                });
1677            }
1678            ureq::Error::Transport(err) => {
1679                return Err(LinkError::Transport {
1680                    hub: url.to_string(),
1681                    message: err.to_string(),
1682                });
1683            }
1684        },
1685    };
1686    if !(200..300).contains(&resp.status()) {
1687        return Err(LinkError::Http {
1688            what: "registry home fetch",
1689            status: resp.status(),
1690            message: "the home node returned a redirect or error".to_string(),
1691            code: None,
1692        });
1693    }
1694    let mut buf = Vec::new();
1695    resp.into_reader()
1696        .take(MAX_REGISTRY_CARD_BYTES + 1)
1697        .read_to_end(&mut buf)?;
1698    if buf.len() as u64 > MAX_REGISTRY_CARD_BYTES {
1699        return Err(LinkError::ResponseTooLarge {
1700            limit_bytes: MAX_REGISTRY_CARD_BYTES,
1701        });
1702    }
1703    serde_json::from_slice(&buf).map_err(|_| LinkError::InvalidFeed {
1704        message: "the home node returned invalid JSON".to_string(),
1705    })
1706}
1707
1708/// Resolve a bare `@handle` through the federation registry (link.md §7.1,
1709/// E5): look the handle up in the hub's registry, fetch the brain card from
1710/// the returned HOME node, and PIN — the card's identity fingerprint must
1711/// equal the registry's, or resolution fails. Returns the card enriched with
1712/// the resolved `home`, or `Ok(None)` when the registry has no such handle
1713/// (so the caller can fall back to a direct lookup).
1714pub fn resolve_registry(cfg: &HubConfig, handle: &str) -> LinkResult<Option<Value>> {
1715    require_safe_ref(handle)?;
1716    // Hold the validated trust-directory capability before any network I/O.
1717    // Every lock/load/save below remains relative to this exact inode even if
1718    // an attacker swaps an ancestor while the registry or home is answering.
1719    let trust_directory = open_trust_dir(cfg)?;
1720    let reg = request_capped(
1721        cfg,
1722        "GET",
1723        &format!("/api/hub/registry/{handle}"),
1724        None,
1725        Auth::None,
1726        MAX_REGISTRY_CARD_BYTES,
1727    )?;
1728    if reg.status == 404 {
1729        return Ok(None);
1730    }
1731    let body = ensure_ok(reg, "registry resolve")?;
1732    let home = body
1733        .get("home")
1734        .and_then(Value::as_str)
1735        .ok_or_else(|| invalid_feed("registry entry has no home"))?;
1736    let brain = body
1737        .get("brain")
1738        .and_then(Value::as_str)
1739        .ok_or_else(|| invalid_feed("registry entry has no brain"))?;
1740    if !crate::ulid::is_ulid(brain) {
1741        return Err(invalid_feed(
1742            "registry entry brain is not a canonical lowercase ULID",
1743        ));
1744    }
1745    let want_fp = body
1746        .get("identity")
1747        .and_then(|i| i.get("fingerprint"))
1748        .and_then(Value::as_str)
1749        .ok_or_else(|| invalid_feed("registry entry has no identity fingerprint"))?;
1750
1751    let home = home.trim_end_matches('/');
1752    let origin = normalized_origin(home)?;
1753    if origin != home {
1754        return Err(invalid_feed(
1755            "registry home must be an origin without a path, query, or fragment",
1756        ));
1757    }
1758    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[handle, brain])?;
1759    let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, handle, brain)?;
1760    if let Some(binding) = &alias_binding {
1761        if binding
1762            .home
1763            .as_deref()
1764            .is_some_and(|pinned_home| pinned_home != home)
1765        {
1766            return Err(invalid_feed(
1767                "registry relocated a pinned handle to a different home",
1768            ));
1769        }
1770    }
1771    let card = get_json_absolute(&format!("{home}/api/hub/brains/{brain}"))?;
1772    if card.get("id").and_then(Value::as_str) != Some(brain) {
1773        return Err(invalid_feed(
1774            "the home node served a card for a different brain",
1775        ));
1776    }
1777    let identity: FeedIdentity = serde_json::from_value(
1778        card.get("identity")
1779            .cloned()
1780            .ok_or_else(|| invalid_feed("the home node served no identity"))?,
1781    )
1782    .map_err(|_| invalid_feed("the home node served an invalid identity"))?;
1783    let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
1784    let got_fp = card
1785        .get("identity")
1786        .and_then(|i| i.get("fingerprint"))
1787        .and_then(Value::as_str)
1788        .unwrap_or_default();
1789    if got_fp != want_fp {
1790        return Err(invalid_feed(
1791            "the home node served an identity that does not match the registry — refusing",
1792        ));
1793    }
1794    let current = format!("ed25519:{}", identity.fingerprint);
1795    let advertised_seq = card
1796        .get("headSeq")
1797        .and_then(Value::as_u64)
1798        .ok_or_else(|| invalid_feed("the home node served an invalid head sequence"))?;
1799    let advertised_hash = card.get("feedHash").and_then(Value::as_str);
1800    if (advertised_seq == 0 && !card.get("feedHash").is_some_and(Value::is_null))
1801        || (advertised_seq > 0 && advertised_hash.is_none_or(|hash| !is_sha256(hash)))
1802    {
1803        return Err(invalid_feed(
1804            "the home node served an invalid feed head boundary",
1805        ));
1806    }
1807    // This is required even on first contact. Otherwise a syntactically valid
1808    // identity may claim that a rotation happened after a head the home has
1809    // never reached, then become the permanent TOFU anchor.
1810    verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], advertised_seq)?;
1811    let registry_alias = AliasBinding {
1812        v: 1,
1813        origin: normalized_origin(&cfg.hub)?,
1814        requested: handle.to_string(),
1815        brain: brain.to_string(),
1816        home: Some(home.to_string()),
1817    };
1818    save_canonical_pin_and_alias(
1819        cfg,
1820        &trust_directory,
1821        handle,
1822        brain,
1823        TrustState {
1824            v: 2,
1825            origin: normalized_origin(&cfg.hub)?,
1826            requested: brain.to_string(),
1827            brain: brain.to_string(),
1828            home: None,
1829            anchor,
1830            current,
1831            head_seq: pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq),
1832            feed_hash: pinned
1833                .as_ref()
1834                .and_then(|checkpoint| checkpoint.feed_hash.clone()),
1835            rotations: identity.rotations.clone(),
1836        },
1837        Some(&registry_alias),
1838    )?;
1839    let mut out = card;
1840    if let Value::Object(map) = &mut out {
1841        map.insert("home".to_string(), Value::String(home.to_string()));
1842        map.insert(
1843            "resolvedVia".to_string(),
1844            Value::String("registry".to_string()),
1845        );
1846    }
1847    Ok(Some(out))
1848}
1849
1850pub fn resolve(cfg: &HubConfig, addr: &Address) -> LinkResult<Value> {
1851    // `Address::parse` refuses these shapes already, but `Address` has public
1852    // fields — re-assert at the wire so a hand-built address can never
1853    // reshape the request path.
1854    require_safe_ref(&addr.brain)?;
1855    if let Some(target) = &addr.target {
1856        let (given, ok) = match target {
1857            AddressTarget::Id(id) => (id, crate::ulid::is_ulid(id)),
1858            AddressTarget::Path(p) => (p, safe_store_rel_path(p) && p.ends_with(".md")),
1859        };
1860        if !ok {
1861            return Err(LinkError::BadAddress {
1862                given: given.clone(),
1863                reason: BAD_TARGET_REASON.to_string(),
1864            });
1865        }
1866    }
1867
1868    // A record is never accepted from the hub's mutable query/index response:
1869    // that response was not covered by the brain's feed signature and could
1870    // return arbitrary frontmatter/body while an unrelated signed head still
1871    // verified. Materialize the record from the exact content-addressed pack
1872    // named by the verified signed head instead.
1873    if let Some(target) = &addr.target {
1874        let remote = verified_remote_head(cfg, &addr.brain, false)?;
1875        if !remote.head.verified {
1876            return Err(invalid_feed(
1877                "a path-scoped feed cannot prove a record against the full signed snapshot",
1878            ));
1879        }
1880        if remote.head.seq == 0 {
1881            return Err(LinkError::Http {
1882                what: "resolve",
1883                status: 404,
1884                message: "record not found".to_string(),
1885                code: Some("NOT_FOUND".to_string()),
1886            });
1887        }
1888        let brain = remote.head.brain.clone();
1889        let pack = download_verified_snapshot_pack(cfg, &brain, &remote)?;
1890        return resolve_from_verified_pack(&brain, target, pack);
1891    }
1892
1893    let path = format!("/api/hub/brains/{}", addr.brain);
1894    // Direct first: the caller's own slug and hub-hosted public handles resolve
1895    // here unchanged. Only a bare `@handle` the hub can't resolve directly
1896    // (404) falls through to the federation registry — how a handle reaches a
1897    // brain on ANOTHER node (link.md §7.1, E5).
1898    let direct = request(cfg, "GET", &path, None, Auth::Required)?;
1899    if direct.status == 404 && addr.target.is_none() && !crate::ulid::is_ulid(&addr.brain) {
1900        if let Some(card) = resolve_registry(cfg, &addr.brain)? {
1901            return Ok(card);
1902        }
1903    }
1904    let resolved = ensure_ok(direct, "resolve")?;
1905    // A successful direct response is accepted only after the same centralized
1906    // identity/rotation/feed checkpoint verification used by sync and
1907    // subscribe. No verb gets a weaker ad-hoc pinning path.
1908    let remote = verified_remote_head(cfg, &addr.brain, false)?;
1909    if resolved.get("id").and_then(Value::as_str) != Some(remote.head.brain.as_str())
1910        || resolved.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
1911        || resolved.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
1912    {
1913        return Err(invalid_feed(
1914            "resolve card is not bound to the exact verified feed checkpoint",
1915        ));
1916    }
1917    let card_identity: FeedIdentity = serde_json::from_value(
1918        resolved
1919            .get("identity")
1920            .cloned()
1921            .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
1922    )
1923    .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
1924    if remote.identity.as_ref() != Some(&card_identity) {
1925        return Err(invalid_feed(
1926            "resolve card identity differs from the verified feed identity",
1927        ));
1928    }
1929    Ok(resolved)
1930}
1931
1932/// Resolve one record strictly from the exact signed snapshot pack. The hub's
1933/// mutable query/index result is intentionally not consulted: a signed pack
1934/// digest is the only cryptographic binding between a feed checkpoint and
1935/// record bytes in wire profile v1.
1936fn resolve_from_verified_pack(
1937    brain: &str,
1938    target: &AddressTarget,
1939    pack: Vec<u8>,
1940) -> LinkResult<Value> {
1941    let entries = parse_store_pack(pack)?;
1942    let mut matched: Option<(String, Vec<u8>)> = None;
1943
1944    for (path, bytes) in entries {
1945        let is_candidate = match target {
1946            AddressTarget::Path(want) => &path == want,
1947            AddressTarget::Id(_) => {
1948                path.ends_with(".md")
1949                    && (path.starts_with("records/") || path.starts_with("sources/"))
1950            }
1951        };
1952        if !is_candidate {
1953            continue;
1954        }
1955        let text = std::str::from_utf8(&bytes)
1956            .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
1957        let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
1958            .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
1959        if let AddressTarget::Id(want) = target {
1960            let frontmatter =
1961                crate::parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(&path))
1962                    .map_err(|_| {
1963                        invalid_feed(format!("signed snapshot record `{path}` is malformed"))
1964                    })?;
1965            if frontmatter.id.as_deref() != Some(want) {
1966                continue;
1967            }
1968        }
1969        if matched.is_some() {
1970            return Err(invalid_feed(
1971                "signed snapshot contains more than one record for the requested target",
1972            ));
1973        }
1974        matched = Some((path, bytes));
1975    }
1976
1977    let (path, bytes) = matched.ok_or_else(|| LinkError::Http {
1978        what: "resolve",
1979        status: 404,
1980        message: "record not found".to_string(),
1981        code: Some("NOT_FOUND".to_string()),
1982    })?;
1983    let text = std::str::from_utf8(&bytes)
1984        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
1985    let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
1986        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
1987    let frontmatter: Value = serde_norway::from_str(&parsed.frontmatter_yaml)
1988        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
1989    let Value::Object(fields) = frontmatter else {
1990        return Err(invalid_feed(format!(
1991            "signed snapshot record `{path}` frontmatter is not a mapping"
1992        )));
1993    };
1994    let mut document = serde_json::Map::new();
1995    document.insert("path".to_string(), Value::String(path));
1996    for (key, value) in fields {
1997        document.insert(key, value);
1998    }
1999    document.insert("body".to_string(), Value::String(parsed.body));
2000    document.insert(
2001        "contentSha".to_string(),
2002        Value::String(content_sha256(&bytes)),
2003    );
2004    Ok(json!({
2005        "brain": brain,
2006        "document": Value::Object(document),
2007    }))
2008}
2009
2010// ─────────────────────────────────────────────────────────────────────────────
2011// sync — pull the granted slice as files; push the local store as a snapshot
2012// ─────────────────────────────────────────────────────────────────────────────
2013
2014/// What a pull materialized.
2015#[derive(Debug, serde::Serialize)]
2016pub struct PullReport {
2017    /// The brain id the hub reported.
2018    pub brain: String,
2019    /// The brain's slug.
2020    pub slug: String,
2021    /// The hub's feed head at export time.
2022    #[serde(rename = "headSeq")]
2023    pub head_seq: u64,
2024    /// How many files were written.
2025    pub files: usize,
2026    /// Where they were written (as given or derived from the slug).
2027    pub dest: String,
2028    /// Local content files that the export did not carry — present so a
2029    /// caller sees divergence; nothing is ever deleted locally.
2030    #[serde(rename = "extraLocal")]
2031    pub extra_local: Vec<String>,
2032}
2033
2034fn download_verified_snapshot_pack(
2035    cfg: &HubConfig,
2036    brain: &str,
2037    remote: &VerifiedRemote,
2038) -> LinkResult<Vec<u8>> {
2039    let feed_hash = remote
2040        .head
2041        .feed_hash
2042        .as_deref()
2043        .ok_or_else(|| invalid_feed("non-empty snapshot has no verified feed hash"))?;
2044    let signed_head = remote
2045        .head_entry
2046        .as_ref()
2047        .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
2048    let expected = &signed_head.entry.pack_sha256;
2049    if !is_sha256(expected) {
2050        return Err(invalid_feed(
2051            "signed head carries an invalid snapshot pack digest",
2052        ));
2053    }
2054    let path = format!(
2055        "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={feed_hash}",
2056        remote.head.seq
2057    );
2058    let body = ensure_ok(
2059        request(cfg, "GET", &path, None, Auth::Required)?,
2060        "sync pull",
2061    )?;
2062    if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2063        || body.get("feedHash").and_then(Value::as_str) != Some(feed_hash)
2064        || body.get("brain").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2065        || body.get("sha256").and_then(Value::as_str) != Some(expected.as_str())
2066    {
2067        return Err(invalid_feed(
2068            "export response is not bound to the exact verified snapshot",
2069        ));
2070    }
2071    let url = body
2072        .get("url")
2073        .and_then(Value::as_str)
2074        .ok_or_else(|| invalid_feed("verified snapshot export carried no exact pack URL"))?;
2075    let bytes = get_presigned(cfg, url)?;
2076    if content_sha256(&bytes) != *expected {
2077        return Err(LinkError::InvalidPack {
2078            message: "downloaded pack does not match the signed snapshot digest".to_string(),
2079        });
2080    }
2081    let entries = parse_store_pack(bytes.clone())?;
2082    if signed_head.entry.kind == "push" {
2083        verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
2084    }
2085    Ok(bytes)
2086}
2087
2088/// Pull the granted slice of `brain` to `out` (default: `./<slug>`). Every
2089/// exported path is safety-gated before it touches disk; files are written
2090/// atomically; nothing local is ever deleted (locals the export lacks are
2091/// *reported* in `extra_local` instead). Returns the report; rebuilding the
2092/// local index catalog afterwards is the caller's (cheap, optional) step.
2093pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
2094    require_hardened_filesystem("sync pull")?;
2095    require_safe_ref(brain)?;
2096    let remote = verified_remote_head(cfg, brain, false)?;
2097    if !remote.head.verified {
2098        return Err(invalid_feed(
2099            "the grant exposes head movement but no signed snapshot; refusing an unverifiable pull",
2100        ));
2101    }
2102    let snapshot_hash = remote.head.feed_hash.as_deref().unwrap_or("none");
2103    let path = format!(
2104        "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={snapshot_hash}",
2105        remote.head.seq
2106    );
2107    let body = ensure_ok(
2108        request(cfg, "GET", &path, None, Auth::Required)?,
2109        "sync pull",
2110    )?;
2111    if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2112        || body.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
2113    {
2114        return Err(invalid_feed(
2115            "export response is not bound to the verified snapshot token",
2116        ));
2117    }
2118
2119    let remote_slug = body
2120        .get("slug")
2121        .and_then(Value::as_str)
2122        .filter(|slug| is_safe_slug(slug));
2123    let slug = remote_slug
2124        .or_else(|| is_safe_slug(brain).then_some(brain))
2125        .unwrap_or("brain")
2126        .to_string();
2127    let brain_id = body
2128        .get("brain")
2129        .and_then(Value::as_str)
2130        .unwrap_or(&remote.head.brain)
2131        .to_string();
2132    if brain_id != remote.head.brain {
2133        return Err(invalid_feed(
2134            "export response names a different brain than the verified head",
2135        ));
2136    }
2137    let head_seq = remote.head.seq;
2138    let dest: PathBuf = match out {
2139        Some(p) => p.to_path_buf(),
2140        None => PathBuf::from(&slug),
2141    };
2142    let entries = if head_seq == 0 {
2143        let files = body
2144            .get("files")
2145            .and_then(Value::as_array)
2146            .ok_or_else(|| invalid_feed("empty snapshot export did not carry an empty manifest"))?;
2147        if !files.is_empty() || body.get("url").is_some() {
2148            return Err(invalid_feed(
2149                "empty signed feed cannot authorize non-empty exported content",
2150            ));
2151        }
2152        Vec::new()
2153    } else {
2154        let signed_head = remote
2155            .head_entry
2156            .as_ref()
2157            .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
2158        let expected = &signed_head.entry.pack_sha256;
2159        if !is_sha256(expected) {
2160            return Err(invalid_feed(
2161                "signed head carries an invalid snapshot pack digest",
2162            ));
2163        }
2164        if let Some(url) = body.get("url").and_then(Value::as_str) {
2165            if body.get("sha256").and_then(Value::as_str) != Some(expected.as_str()) {
2166                return Err(invalid_feed(
2167                    "export pack digest does not match the signed head entry",
2168                ));
2169            }
2170            let bytes = get_presigned(cfg, url)?;
2171            let actual = format!("{:x}", Sha256::digest(&bytes));
2172            if actual != *expected {
2173                return Err(LinkError::InvalidPack {
2174                    message: "downloaded pack does not match the signed snapshot digest"
2175                        .to_string(),
2176                });
2177            }
2178            let entries = parse_store_pack(bytes)?;
2179            if signed_head.entry.kind == "push" {
2180                verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
2181            }
2182            entries
2183        } else {
2184            if signed_head.entry.kind != "push" {
2185                return Err(invalid_feed(
2186                    "delta snapshots must export the exact signed pack",
2187                ));
2188            }
2189            let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
2190                invalid_feed("verified snapshot export carried neither a pack nor files")
2191            })?;
2192            let mut entries = Vec::with_capacity(files.len());
2193            for file in files {
2194                let path = file
2195                    .get("path")
2196                    .and_then(Value::as_str)
2197                    .ok_or_else(|| invalid_feed("exported file path is not a string"))?;
2198                let content = file
2199                    .get("content")
2200                    .and_then(Value::as_str)
2201                    .ok_or_else(|| invalid_feed("exported inline content is not UTF-8 text"))?;
2202                entries.push((path.to_string(), content.as_bytes().to_vec()));
2203            }
2204            verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
2205            entries
2206        }
2207    };
2208
2209    // Gate the complete manifest before the first filesystem mutation.
2210    let mut seen = std::collections::HashSet::new();
2211    for (path, _) in &entries {
2212        if !safe_store_rel_path(path) {
2213            return Err(LinkError::UnsafePath { path: path.clone() });
2214        }
2215        if !seen.insert(path) {
2216            return Err(LinkError::InvalidPack {
2217                message: format!("duplicate path `{path}`"),
2218            });
2219        }
2220    }
2221    // Compute divergence against the still-live tree. The staged clone keeps
2222    // these extra locals byte-for-byte while overlaying the signed snapshot.
2223    let pulled: std::collections::BTreeSet<&str> =
2224        entries.iter().map(|(p, _)| p.as_str()).collect();
2225    let mut extra_local = Vec::new();
2226    if let Ok(store) = Store::open(&dest) {
2227        if let Ok(walked) = store.walk() {
2228            for rel in walked {
2229                let rel_str = rel.to_string_lossy().replace('\\', "/");
2230                if !pulled.contains(rel_str.as_str()) {
2231                    extra_local.push(rel_str);
2232                }
2233            }
2234        }
2235    }
2236    #[cfg(unix)]
2237    install_pulled_snapshot(&dest, &entries)?;
2238
2239    Ok(PullReport {
2240        brain: brain_id,
2241        slug,
2242        head_seq,
2243        files: entries.len(),
2244        dest: dest.to_string_lossy().into_owned(),
2245        extra_local,
2246    })
2247}
2248
2249#[cfg(unix)]
2250fn c_name(bytes: &[u8], display: &str) -> LinkResult<std::ffi::CString> {
2251    std::ffi::CString::new(bytes).map_err(|_| LinkError::UnsafePath {
2252        path: display.to_string(),
2253    })
2254}
2255
2256#[cfg(unix)]
2257fn open_dir_at(
2258    parent: std::os::fd::RawFd,
2259    name: &std::ffi::CStr,
2260    display: &str,
2261) -> LinkResult<std::fs::File> {
2262    use std::os::fd::FromRawFd as _;
2263    let fd = unsafe {
2264        libc::openat(
2265            parent,
2266            name.as_ptr(),
2267            libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
2268        )
2269    };
2270    if fd < 0 {
2271        return Err(LinkError::UnsafePath {
2272            path: display.to_string(),
2273        });
2274    }
2275    Ok(unsafe { std::fs::File::from_raw_fd(fd) })
2276}
2277
2278/// Open (and, where absent, create) a directory path without following a
2279/// symlink in any component. The returned directory capability remains bound
2280/// to the opened inode even if an attacker renames or replaces an ancestor.
2281#[cfg(unix)]
2282fn open_dir_path_nofollow(path: &Path, create: bool) -> LinkResult<std::fs::File> {
2283    use std::os::fd::AsRawFd as _;
2284
2285    // macOS exposes a few system-owned compatibility symlinks at the root.
2286    // Normalize only those fixed OS aliases; never canonicalize an arbitrary
2287    // caller-controlled ancestor.
2288    #[cfg(target_os = "macos")]
2289    let normalized = [("/var", "/private/var"), ("/tmp", "/private/tmp")]
2290        .into_iter()
2291        .find_map(|(alias, real)| {
2292            path.strip_prefix(alias)
2293                .ok()
2294                .map(|rest| Path::new(real).join(rest))
2295        })
2296        .unwrap_or_else(|| path.to_path_buf());
2297    #[cfg(not(target_os = "macos"))]
2298    let normalized = path.to_path_buf();
2299
2300    let start = if normalized.is_absolute() {
2301        std::fs::File::open("/")?
2302    } else {
2303        std::fs::File::open(".")?
2304    };
2305    let mut directory = start;
2306    for component in normalized.components() {
2307        use std::path::Component;
2308        let name = match component {
2309            Component::RootDir | Component::CurDir => continue,
2310            Component::Normal(name) => name,
2311            Component::ParentDir | Component::Prefix(_) => {
2312                return Err(LinkError::UnsafePath {
2313                    path: path.display().to_string(),
2314                });
2315            }
2316        };
2317        use std::os::unix::ffi::OsStrExt as _;
2318        let name = c_name(name.as_bytes(), &path.display().to_string())?;
2319        if create {
2320            let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
2321            if made != 0 {
2322                let error = std::io::Error::last_os_error();
2323                if error.raw_os_error() != Some(libc::EEXIST) {
2324                    return Err(error.into());
2325                }
2326            }
2327        }
2328        directory = open_dir_at(directory.as_raw_fd(), &name, &path.display().to_string())?;
2329    }
2330    Ok(directory)
2331}
2332
2333#[cfg(unix)]
2334fn open_or_create_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
2335    open_dir_path_nofollow(path, true)
2336}
2337
2338#[cfg(unix)]
2339fn open_existing_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
2340    open_dir_path_nofollow(path, false)
2341}
2342
2343#[cfg(unix)]
2344fn entry_is_dir_at(parent: std::os::fd::RawFd, name: &std::ffi::CStr) -> LinkResult<Option<bool>> {
2345    let mut stat: libc::stat = unsafe { std::mem::zeroed() };
2346    let result =
2347        unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) };
2348    if result == 0 {
2349        return Ok(Some((stat.st_mode & libc::S_IFMT) == libc::S_IFDIR));
2350    }
2351    let error = std::io::Error::last_os_error();
2352    if error.kind() == std::io::ErrorKind::NotFound {
2353        Ok(None)
2354    } else {
2355        Err(error.into())
2356    }
2357}
2358
2359#[cfg(unix)]
2360fn create_dir_exclusive_at(
2361    parent: std::os::fd::RawFd,
2362    name: &std::ffi::CStr,
2363    display: &str,
2364) -> LinkResult<std::fs::File> {
2365    let made = unsafe { libc::mkdirat(parent, name.as_ptr(), 0o700) };
2366    if made != 0 {
2367        return Err(LinkError::UnsafePath {
2368            path: display.to_string(),
2369        });
2370    }
2371    open_dir_at(parent, name, display)
2372}
2373
2374#[cfg(unix)]
2375fn directory_entry_names(directory: &std::fs::File) -> LinkResult<Vec<std::ffi::CString>> {
2376    use std::os::fd::AsRawFd as _;
2377
2378    let duplicate = unsafe { libc::dup(directory.as_raw_fd()) };
2379    if duplicate < 0 {
2380        return Err(std::io::Error::last_os_error().into());
2381    }
2382    let stream = unsafe { libc::fdopendir(duplicate) };
2383    if stream.is_null() {
2384        let error = std::io::Error::last_os_error();
2385        unsafe {
2386            libc::close(duplicate);
2387        }
2388        return Err(error.into());
2389    }
2390    let mut names = Vec::new();
2391    loop {
2392        let entry = unsafe { libc::readdir(stream) };
2393        if entry.is_null() {
2394            break;
2395        }
2396        let raw = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) };
2397        if raw.to_bytes() != b"." && raw.to_bytes() != b".." {
2398            names.push(raw.to_owned());
2399        }
2400    }
2401    if unsafe { libc::closedir(stream) } != 0 {
2402        return Err(std::io::Error::last_os_error().into());
2403    }
2404    Ok(names)
2405}
2406
2407/// Remove an entry tree relative to a held directory capability. Symlinks are
2408/// unlinked, never traversed, even when an old mirror contains hostile names.
2409#[cfg(unix)]
2410fn remove_tree_at(
2411    parent: std::os::fd::RawFd,
2412    name: &std::ffi::CStr,
2413    display: &str,
2414) -> LinkResult<()> {
2415    use std::os::fd::AsRawFd as _;
2416
2417    match entry_is_dir_at(parent, name)? {
2418        None => return Ok(()),
2419        Some(false) => {
2420            if unsafe { libc::unlinkat(parent, name.as_ptr(), 0) } != 0 {
2421                return Err(std::io::Error::last_os_error().into());
2422            }
2423        }
2424        Some(true) => {
2425            let directory = open_dir_at(parent, name, display)?;
2426            for child in directory_entry_names(&directory)? {
2427                let child_display =
2428                    format!("{display}/{}", String::from_utf8_lossy(child.to_bytes()));
2429                remove_tree_at(directory.as_raw_fd(), &child, &child_display)?;
2430            }
2431            drop(directory);
2432            if unsafe { libc::unlinkat(parent, name.as_ptr(), libc::AT_REMOVEDIR) } != 0 {
2433                return Err(std::io::Error::last_os_error().into());
2434            }
2435        }
2436    }
2437    Ok(())
2438}
2439
2440/// Clone a live destination into a private sibling stage without following
2441/// any symlink. Regular files are streamed between held descriptors; symlinks
2442/// are reproduced as links, never opened. Special files are refused.
2443#[cfg(unix)]
2444fn clone_tree_contents(
2445    source: &std::fs::File,
2446    destination: &std::fs::File,
2447    display: &str,
2448) -> LinkResult<()> {
2449    use std::os::fd::{AsRawFd as _, FromRawFd as _};
2450
2451    for name in directory_entry_names(source)? {
2452        let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
2453        let mut stat: libc::stat = unsafe { std::mem::zeroed() };
2454        if unsafe {
2455            libc::fstatat(
2456                source.as_raw_fd(),
2457                name.as_ptr(),
2458                &mut stat,
2459                libc::AT_SYMLINK_NOFOLLOW,
2460            )
2461        } != 0
2462        {
2463            return Err(std::io::Error::last_os_error().into());
2464        }
2465        match stat.st_mode & libc::S_IFMT {
2466            libc::S_IFDIR => {
2467                if unsafe {
2468                    libc::mkdirat(destination.as_raw_fd(), name.as_ptr(), stat.st_mode & 0o777)
2469                } != 0
2470                {
2471                    return Err(std::io::Error::last_os_error().into());
2472                }
2473                let source_child = open_dir_at(source.as_raw_fd(), &name, &child_display)?;
2474                let destination_child =
2475                    open_dir_at(destination.as_raw_fd(), &name, &child_display)?;
2476                clone_tree_contents(&source_child, &destination_child, &child_display)?;
2477                destination_child.sync_all()?;
2478            }
2479            libc::S_IFREG => {
2480                let source_fd = unsafe {
2481                    libc::openat(
2482                        source.as_raw_fd(),
2483                        name.as_ptr(),
2484                        libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
2485                    )
2486                };
2487                if source_fd < 0 {
2488                    return Err(std::io::Error::last_os_error().into());
2489                }
2490                let destination_fd = unsafe {
2491                    libc::openat(
2492                        destination.as_raw_fd(),
2493                        name.as_ptr(),
2494                        libc::O_WRONLY
2495                            | libc::O_CREAT
2496                            | libc::O_EXCL
2497                            | libc::O_CLOEXEC
2498                            | libc::O_NOFOLLOW,
2499                        (stat.st_mode & 0o777) as libc::c_uint,
2500                    )
2501                };
2502                if destination_fd < 0 {
2503                    unsafe {
2504                        libc::close(source_fd);
2505                    }
2506                    return Err(std::io::Error::last_os_error().into());
2507                }
2508                let mut input = unsafe { std::fs::File::from_raw_fd(source_fd) };
2509                let mut output = unsafe { std::fs::File::from_raw_fd(destination_fd) };
2510                std::io::copy(&mut input, &mut output)?;
2511                output.sync_all()?;
2512            }
2513            libc::S_IFLNK => {
2514                let mut target = vec![0_u8; 4097];
2515                let length = unsafe {
2516                    libc::readlinkat(
2517                        source.as_raw_fd(),
2518                        name.as_ptr(),
2519                        target.as_mut_ptr().cast(),
2520                        target.len(),
2521                    )
2522                };
2523                if length < 0 || length as usize >= target.len() {
2524                    return Err(LinkError::UnsafePath {
2525                        path: child_display,
2526                    });
2527                }
2528                target.truncate(length as usize);
2529                let target = c_name(&target, &child_display)?;
2530                if unsafe {
2531                    libc::symlinkat(target.as_ptr(), destination.as_raw_fd(), name.as_ptr())
2532                } != 0
2533                {
2534                    return Err(std::io::Error::last_os_error().into());
2535                }
2536            }
2537            _ => {
2538                return Err(LinkError::UnsafePath {
2539                    path: child_display,
2540                });
2541            }
2542        }
2543    }
2544    destination.sync_all()?;
2545    Ok(())
2546}
2547
2548#[cfg(target_os = "linux")]
2549fn install_stage_at(
2550    parent: std::os::fd::RawFd,
2551    stage: &std::ffi::CStr,
2552    dest: &std::ffi::CStr,
2553    dest_exists: bool,
2554) -> LinkResult<()> {
2555    let flags = if dest_exists {
2556        libc::RENAME_EXCHANGE
2557    } else {
2558        libc::RENAME_NOREPLACE
2559    };
2560    // `libc::renameat2` is not exported for the musl targets we ship. Invoke
2561    // the kernel ABI directly, as `fsx::renameat_noreplace` does, so the same
2562    // atomic exchange/no-replace boundary compiles for glibc and musl.
2563    let result = unsafe {
2564        libc::syscall(
2565            libc::SYS_renameat2,
2566            parent,
2567            stage.as_ptr(),
2568            parent,
2569            dest.as_ptr(),
2570            flags,
2571        )
2572    };
2573    if result == 0 {
2574        Ok(())
2575    } else {
2576        Err(std::io::Error::last_os_error().into())
2577    }
2578}
2579
2580#[cfg(target_os = "macos")]
2581fn install_stage_at(
2582    parent: std::os::fd::RawFd,
2583    stage: &std::ffi::CStr,
2584    dest: &std::ffi::CStr,
2585    dest_exists: bool,
2586) -> LinkResult<()> {
2587    let flags = if dest_exists {
2588        libc::RENAME_SWAP
2589    } else {
2590        libc::RENAME_EXCL
2591    };
2592    let result =
2593        unsafe { libc::renameatx_np(parent, stage.as_ptr(), parent, dest.as_ptr(), flags) };
2594    if result == 0 {
2595        Ok(())
2596    } else {
2597        Err(std::io::Error::last_os_error().into())
2598    }
2599}
2600
2601#[cfg(unix)]
2602fn write_pull_entries_beneath_dir(
2603    root: &std::fs::File,
2604    entries: &[(String, Vec<u8>)],
2605) -> LinkResult<()> {
2606    use std::os::fd::{AsRawFd as _, FromRawFd as _};
2607
2608    for (path, content) in entries {
2609        let components: Vec<&str> = path.split('/').collect();
2610        let (leaf, parents) = components
2611            .split_last()
2612            .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
2613        let mut directory = root.try_clone()?;
2614        for component in parents {
2615            let name = c_name(component.as_bytes(), path)?;
2616            let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
2617            if made != 0 {
2618                let error = std::io::Error::last_os_error();
2619                if error.raw_os_error() != Some(libc::EEXIST) {
2620                    return Err(error.into());
2621                }
2622            }
2623            directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
2624        }
2625
2626        let leaf_name = c_name(leaf.as_bytes(), path)?;
2627        let mut existing: libc::stat = unsafe { std::mem::zeroed() };
2628        let inspected = unsafe {
2629            libc::fstatat(
2630                directory.as_raw_fd(),
2631                leaf_name.as_ptr(),
2632                &mut existing,
2633                libc::AT_SYMLINK_NOFOLLOW,
2634            )
2635        };
2636        if inspected == 0 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK {
2637            return Err(LinkError::UnsafePath { path: path.clone() });
2638        }
2639
2640        let nonce = std::time::SystemTime::now()
2641            .duration_since(std::time::UNIX_EPOCH)
2642            .unwrap_or_default()
2643            .as_nanos();
2644        let temp_name = format!(
2645            ".dbmd-pull-{}-{nonce}-{}",
2646            std::process::id(),
2647            content_sha256(format!("{path}\0{}", content.len()).as_bytes())
2648        );
2649        let temp = c_name(temp_name.as_bytes(), path)?;
2650        let fd = unsafe {
2651            libc::openat(
2652                directory.as_raw_fd(),
2653                temp.as_ptr(),
2654                libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
2655                0o600,
2656            )
2657        };
2658        if fd < 0 {
2659            return Err(std::io::Error::last_os_error().into());
2660        }
2661        let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
2662        if let Err(error) = file.write_all(content).and_then(|_| file.sync_all()) {
2663            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
2664            return Err(error.into());
2665        }
2666        drop(file);
2667        let renamed = unsafe {
2668            libc::renameat(
2669                directory.as_raw_fd(),
2670                temp.as_ptr(),
2671                directory.as_raw_fd(),
2672                leaf_name.as_ptr(),
2673            )
2674        };
2675        if renamed != 0 {
2676            let error = std::io::Error::last_os_error();
2677            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
2678            return Err(error.into());
2679        }
2680        directory.sync_all()?;
2681    }
2682    root.sync_all()?;
2683    Ok(())
2684}
2685
2686#[cfg(unix)]
2687fn install_pulled_snapshot(dest: &Path, entries: &[(String, Vec<u8>)]) -> LinkResult<()> {
2688    use ring::rand::SecureRandom as _;
2689    use std::os::fd::AsRawFd as _;
2690    use std::os::unix::ffi::OsStrExt as _;
2691
2692    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
2693    let name = dest
2694        .file_name()
2695        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
2696        .ok_or_else(|| LinkError::UnsafePath {
2697            path: dest.display().to_string(),
2698        })?;
2699    let parent_dir = open_or_create_dir_nofollow(parent)?;
2700    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
2701    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
2702        None => false,
2703        Some(true) => true,
2704        Some(false) => {
2705            return Err(LinkError::UnsafePath {
2706                path: dest.display().to_string(),
2707            });
2708        }
2709    };
2710
2711    let mut nonce = [0_u8; 16];
2712    ring::rand::SystemRandom::new()
2713        .fill(&mut nonce)
2714        .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
2715    let stage_label = format!(
2716        ".{}.dbmd-pull-stage-{}",
2717        name.to_string_lossy(),
2718        URL_SAFE_NO_PAD.encode(nonce)
2719    );
2720    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
2721    let stage_dir = create_dir_exclusive_at(
2722        parent_dir.as_raw_fd(),
2723        &stage_name,
2724        &dest.display().to_string(),
2725    )?;
2726
2727    let prepared = (|| -> LinkResult<()> {
2728        if dest_exists {
2729            let live = open_dir_at(
2730                parent_dir.as_raw_fd(),
2731                &dest_name,
2732                &dest.display().to_string(),
2733            )?;
2734            clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
2735        }
2736        write_pull_entries_beneath_dir(&stage_dir, entries)?;
2737        stage_dir.sync_all()?;
2738        Ok(())
2739    })();
2740    if let Err(error) = prepared {
2741        let _ = remove_tree_at(
2742            parent_dir.as_raw_fd(),
2743            &stage_name,
2744            &dest.display().to_string(),
2745        );
2746        return Err(error);
2747    }
2748
2749    if let Err(error) =
2750        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
2751    {
2752        let _ = remove_tree_at(
2753            parent_dir.as_raw_fd(),
2754            &stage_name,
2755            &dest.display().to_string(),
2756        );
2757        return Err(error);
2758    }
2759    parent_dir.sync_all()?;
2760    if dest_exists {
2761        // Commit already happened atomically. Cleanup is best-effort so a
2762        // failure cannot be reported as a failed pull after the live tree
2763        // changed; a crash may leave only this private old-tree sibling.
2764        let _ = remove_tree_at(
2765            parent_dir.as_raw_fd(),
2766            &stage_name,
2767            &dest.display().to_string(),
2768        );
2769        let _ = parent_dir.sync_all();
2770    }
2771    Ok(())
2772}
2773
2774fn is_safe_slug(slug: &str) -> bool {
2775    !slug.is_empty()
2776        && slug.len() <= 63
2777        && !slug.starts_with('-')
2778        && !slug.ends_with('-')
2779        && slug
2780            .bytes()
2781            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
2782}
2783
2784fn le_u16(bytes: &[u8], at: usize) -> Option<u16> {
2785    Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
2786}
2787
2788fn le_u32(bytes: &[u8], at: usize) -> Option<u32> {
2789    Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
2790}
2791
2792fn le_u64(bytes: &[u8], at: usize) -> Option<u64> {
2793    Some(u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?))
2794}
2795
2796fn preflight_zip_central_directory(
2797    bytes: &[u8],
2798    offset: usize,
2799    size: usize,
2800    count: u64,
2801) -> LinkResult<()> {
2802    const CENTRAL_ENTRY_SIG: &[u8; 4] = b"PK\x01\x02";
2803    let end = offset
2804        .checked_add(size)
2805        .filter(|end| *end <= bytes.len())
2806        .ok_or_else(|| LinkError::InvalidPack {
2807            message: "ZIP central directory is out of bounds".to_string(),
2808        })?;
2809    let mut cursor = offset;
2810    for _ in 0..count {
2811        if bytes.get(cursor..cursor + 4) != Some(CENTRAL_ENTRY_SIG.as_slice()) {
2812            return Err(LinkError::InvalidPack {
2813                message: "ZIP central directory entry count is inconsistent".to_string(),
2814            });
2815        }
2816        if le_u16(bytes, cursor + 34) != Some(0) {
2817            return Err(LinkError::InvalidPack {
2818                message: "multi-disk ZIP archives are not supported".to_string(),
2819            });
2820        }
2821        let variable = [28, 30, 32].into_iter().try_fold(0usize, |total, at| {
2822            total.checked_add(le_u16(bytes, cursor + at)? as usize)
2823        });
2824        cursor = cursor
2825            .checked_add(46)
2826            .and_then(|fixed| fixed.checked_add(variable?))
2827            .filter(|cursor| *cursor <= end)
2828            .ok_or_else(|| LinkError::InvalidPack {
2829                message: "ZIP central directory entry is truncated".to_string(),
2830            })?;
2831    }
2832    if cursor != end {
2833        return Err(LinkError::InvalidPack {
2834            message: "ZIP central directory size is inconsistent".to_string(),
2835        });
2836    }
2837    Ok(())
2838}
2839
2840/// Read only the bounded ZIP trailer before `ZipArchive::new` allocates one
2841/// metadata object per central-directory entry. Supports ordinary EOCD and the
2842/// Zip64 locator/record emitted for >65,535-entry archives.
2843fn preflight_zip_entry_count(bytes: &[u8], max_entries: usize) -> LinkResult<()> {
2844    const EOCD_SIG: &[u8; 4] = b"PK\x05\x06";
2845    const ZIP64_LOCATOR_SIG: &[u8; 4] = b"PK\x06\x07";
2846    const ZIP64_EOCD_SIG: &[u8; 4] = b"PK\x06\x06";
2847    let search_start = bytes.len().saturating_sub(22 + u16::MAX as usize);
2848    let eocd = bytes[search_start..]
2849        .windows(4)
2850        .rposition(|window| window == EOCD_SIG)
2851        .map(|offset| search_start + offset)
2852        .ok_or_else(|| LinkError::InvalidPack {
2853            message: "ZIP has no end-of-central-directory record".to_string(),
2854        })?;
2855    let invalid_end = || LinkError::InvalidPack {
2856        message: "ZIP has an invalid end-of-central-directory structure".to_string(),
2857    };
2858    let comment_len = le_u16(bytes, eocd + 20).ok_or_else(invalid_end)? as usize;
2859    if eocd
2860        .checked_add(22)
2861        .and_then(|end| end.checked_add(comment_len))
2862        != Some(bytes.len())
2863    {
2864        // Do not fall back to an earlier signature. ZipArchive does, and a
2865        // fake low-count EOCD appended after a real Zip64 entry-count bomb
2866        // would otherwise bypass this allocation preflight.
2867        return Err(invalid_end());
2868    }
2869    let disk = le_u16(bytes, eocd + 4);
2870    let central_disk = le_u16(bytes, eocd + 6);
2871    if disk != Some(0) || central_disk != Some(0) {
2872        return Err(LinkError::InvalidPack {
2873            message: "multi-disk ZIP archives are not supported".to_string(),
2874        });
2875    }
2876    let entries_on_disk = le_u16(bytes, eocd + 8).ok_or_else(invalid_end)?;
2877    let ordinary = le_u16(bytes, eocd + 10).ok_or_else(invalid_end)?;
2878    if entries_on_disk != ordinary {
2879        return Err(LinkError::InvalidPack {
2880            message: "multi-disk ZIP archives are not supported".to_string(),
2881        });
2882    }
2883    let zip64_locator = eocd
2884        .checked_sub(20)
2885        .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_LOCATOR_SIG.as_slice()));
2886    let (count, central_offset, central_size) = if ordinary != u16::MAX || zip64_locator.is_none() {
2887        let central_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)? as usize;
2888        let central_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)? as usize;
2889        if central_offset
2890            .checked_add(central_size)
2891            .filter(|end| *end == eocd)
2892            .is_none()
2893        {
2894            return Err(invalid_end());
2895        }
2896        (ordinary as u64, central_offset, central_size)
2897    } else {
2898        let Some(locator) = zip64_locator else {
2899            return Err(invalid_end());
2900        };
2901        if le_u32(bytes, locator + 4) != Some(0) || le_u32(bytes, locator + 16) != Some(1) {
2902            return Err(LinkError::InvalidPack {
2903                message: "multi-disk ZIP64 archives are not supported".to_string(),
2904            });
2905        }
2906        let record = le_u64(bytes, locator + 8)
2907            .and_then(|offset| usize::try_from(offset).ok())
2908            .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_EOCD_SIG.as_slice()))
2909            .ok_or_else(|| LinkError::InvalidPack {
2910                message: "ZIP64 archive has an invalid end record".to_string(),
2911            })?;
2912        let record_size = le_u64(bytes, record + 4)
2913            .and_then(|size| usize::try_from(size).ok())
2914            .filter(|size| *size >= 44)
2915            .ok_or_else(invalid_end)?;
2916        if record
2917            .checked_add(12)
2918            .and_then(|end| end.checked_add(record_size))
2919            != Some(locator)
2920            || le_u32(bytes, record + 16) != Some(0)
2921            || le_u32(bytes, record + 20) != Some(0)
2922        {
2923            return Err(invalid_end());
2924        }
2925        let zip64_on_disk = le_u64(bytes, record + 24).ok_or_else(invalid_end)?;
2926        let zip64_total = le_u64(bytes, record + 32).ok_or_else(invalid_end)?;
2927        let central_size = le_u64(bytes, record + 40)
2928            .and_then(|size| usize::try_from(size).ok())
2929            .ok_or_else(invalid_end)?;
2930        let central_offset = le_u64(bytes, record + 48)
2931            .and_then(|offset| usize::try_from(offset).ok())
2932            .ok_or_else(invalid_end)?;
2933        if zip64_on_disk != zip64_total
2934            || central_offset
2935                .checked_add(central_size)
2936                .filter(|end| *end == record)
2937                .is_none()
2938        {
2939            return Err(invalid_end());
2940        }
2941        let legacy_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)?;
2942        let legacy_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)?;
2943        if (legacy_size != u32::MAX && legacy_size as usize != central_size)
2944            || (legacy_offset != u32::MAX && legacy_offset as usize != central_offset)
2945        {
2946            return Err(invalid_end());
2947        }
2948        (zip64_total, central_offset, central_size)
2949    };
2950    if count == 0 || count > max_entries as u64 {
2951        return Err(LinkError::InvalidPack {
2952            message: format!("invalid file count {count}"),
2953        });
2954    }
2955    preflight_zip_central_directory(bytes, central_offset, central_size, count)?;
2956    Ok(())
2957}
2958
2959fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
2960    preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)?;
2961    let mut archive =
2962        zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
2963            message: format!("ZIP parse failed: {err}"),
2964        })?;
2965    if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
2966        return Err(LinkError::InvalidPack {
2967            message: format!("invalid file count {}", archive.len()),
2968        });
2969    }
2970    let mut total = 0u64;
2971    let mut seen = std::collections::HashSet::new();
2972    let mut entries = Vec::with_capacity(archive.len());
2973    for index in 0..archive.len() {
2974        let mut file = archive
2975            .by_index(index)
2976            .map_err(|err| LinkError::InvalidPack {
2977                message: format!("ZIP entry failed: {err}"),
2978            })?;
2979        if file.is_dir() {
2980            continue;
2981        }
2982        let path = file.name().to_string();
2983        if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
2984            return Err(LinkError::UnsafePath { path });
2985        }
2986        if file
2987            .unix_mode()
2988            .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
2989        {
2990            return Err(LinkError::InvalidPack {
2991                message: format!("non-file entry `{path}`"),
2992            });
2993        }
2994        if !seen.insert(path.clone()) {
2995            return Err(LinkError::InvalidPack {
2996                message: format!("duplicate path `{path}`"),
2997            });
2998        }
2999        let remaining = MAX_STORE_BYTES.saturating_sub(total);
3000        if file.size() > remaining {
3001            return Err(LinkError::InvalidPack {
3002                message: "expanded content exceeds the 512 MB limit".to_string(),
3003            });
3004        }
3005        let mut content = Vec::new();
3006        (&mut file)
3007            .take(remaining + 1)
3008            .read_to_end(&mut content)
3009            .map_err(|err| LinkError::InvalidPack {
3010                message: format!("could not decompress `{path}`: {err}"),
3011            })?;
3012        if content.len() as u64 > remaining {
3013            return Err(LinkError::InvalidPack {
3014                message: "expanded content exceeds the 512 MB limit".to_string(),
3015            });
3016        }
3017        if content.len() as u64 != file.size() {
3018            return Err(LinkError::InvalidPack {
3019                message: format!("length mismatch for `{path}`"),
3020            });
3021        }
3022        total += content.len() as u64;
3023        entries.push((path, content));
3024    }
3025    if entries.is_empty() {
3026        return Err(LinkError::InvalidPack {
3027            message: "pack contains no files".to_string(),
3028        });
3029    }
3030    Ok(entries)
3031}
3032
3033fn verify_snapshot_manifest(entries: &[(String, Vec<u8>)], signed: &[FeedFile]) -> LinkResult<()> {
3034    let mut expected = std::collections::BTreeMap::new();
3035    for file in signed {
3036        if !safe_store_rel_path(&file.path) {
3037            return Err(LinkError::UnsafePath {
3038                path: file.path.clone(),
3039            });
3040        }
3041        if !is_sha256(&file.sha256)
3042            || expected
3043                .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
3044                .is_some()
3045        {
3046            return Err(invalid_feed(
3047                "signed snapshot manifest contains an invalid or duplicate file",
3048            ));
3049        }
3050    }
3051    if expected.len() != entries.len() {
3052        return Err(invalid_feed(
3053            "downloaded pack file set differs from the signed snapshot manifest",
3054        ));
3055    }
3056    for (path, bytes) in entries {
3057        let Some((sha256, declared_bytes)) = expected.get(path.as_str()) else {
3058            return Err(invalid_feed(format!(
3059                "downloaded pack contains unsigned path `{path}`"
3060            )));
3061        };
3062        if *declared_bytes != bytes.len() as u64
3063            || *sha256 != format!("{:x}", Sha256::digest(bytes))
3064        {
3065            return Err(invalid_feed(format!(
3066                "downloaded file `{path}` differs from its signed manifest"
3067            )));
3068        }
3069    }
3070    Ok(())
3071}
3072
3073/// Collect the files a push sends: the store's owned text — `DB.md`,
3074/// `assets.jsonl` when present, and every content `.md` under `records/` and
3075/// `sources/` (the store walk, which already excludes hidden dirs like
3076/// `.dbmd/`, the `log/` archive, and derived `index.*` catalogs; the hub
3077/// derives its own index, and local history stays local). Returns
3078/// `(store-relative path, content)` pairs, path-sorted.
3079pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
3080    require_hardened_filesystem("sync push")?;
3081    preflight_push_ownership(store)?;
3082    let mut out: Vec<(String, String)> = Vec::new();
3083    let mut total = 0u64;
3084
3085    let mut read_text = |rel: &str| -> LinkResult<String> {
3086        let bytes = store.read_bounded(Path::new(rel), MAX_STORE_BYTES.saturating_sub(total))?;
3087        total = total
3088            .checked_add(bytes.len() as u64)
3089            .ok_or_else(|| LinkError::PushTooLarge {
3090                detail: "uncompressed byte count overflow".to_string(),
3091            })?;
3092        if total > MAX_STORE_BYTES {
3093            return Err(LinkError::PushTooLarge {
3094                detail: format!("{total} uncompressed bytes"),
3095            });
3096        }
3097        String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
3098            path: rel.to_string(),
3099        })
3100    };
3101
3102    out.push(("DB.md".to_string(), read_text("DB.md")?));
3103    if store
3104        .regular_file_exists(Path::new("assets.jsonl"))
3105        .unwrap_or(false)
3106    {
3107        out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
3108    }
3109
3110    for rel in store.walk()? {
3111        let rel_str = rel.to_string_lossy().replace('\\', "/");
3112        if !safe_store_rel_path(&rel_str) {
3113            // A locally-legal name outside the hub's portable charset cannot
3114            // travel this wire; refusing beats silently dropping it.
3115            return Err(LinkError::UnsafePath { path: rel_str });
3116        }
3117        let content = read_text(&rel_str)?;
3118        out.push((rel_str, content));
3119    }
3120
3121    out.sort_by(|a, b| a.0.cmp(&b.0));
3122    Ok(out)
3123}
3124
3125/// Refuse to build a destructive whole-store snapshot from an ambiguous local
3126/// tree. Ordinary read-only walks safely prune foreign paths, but a push that
3127/// silently omitted them could delete the hosted copies of those paths.
3128fn preflight_push_ownership(store: &Store) -> LinkResult<()> {
3129    if let Some(nested) = store.nested_store_roots()?.into_iter().next() {
3130        return Err(LinkError::from(std::io::Error::new(
3131            std::io::ErrorKind::PermissionDenied,
3132            format!("cannot push: nested db.md store at {}", nested.display()),
3133        )));
3134    }
3135
3136    if let Some(symlink) = store.unowned_symlinks()?.into_iter().next() {
3137        return Err(LinkError::from(std::io::Error::new(
3138            std::io::ErrorKind::PermissionDenied,
3139            format!(
3140                "cannot push: {} is a symlink outside the store ownership model",
3141                symlink.display()
3142            ),
3143        )));
3144    }
3145    Ok(())
3146}
3147
3148/// Push `files` to `brain` as a whole-store snapshot — the hub's push
3149/// semantics: the hosted copy becomes exactly this set (pull first if the
3150/// hosted side may have records the local copy lacks). Client-side caps
3151/// mirror the hub's JSON-path limits so an oversized push fails before the
3152/// upload.
3153pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
3154    require_safe_ref(brain)?;
3155    let remote = verified_remote_head(cfg, brain, false)?;
3156    if files.len() > MAX_PUSH_FILES {
3157        return Err(LinkError::PushTooLarge {
3158            detail: format!("{} files", files.len()),
3159        });
3160    }
3161    let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
3162    if raw_total > MAX_STORE_BYTES {
3163        return Err(LinkError::PushTooLarge {
3164            detail: format!("{raw_total} uncompressed bytes"),
3165        });
3166    }
3167
3168    // Self-custody (a brain key is configured): the JSON fast path is
3169    // hub-signed by construction, so every push goes through the pack flow
3170    // with a locally signed entry — the hub verifies and can never sign.
3171    if cfg.brain_key.is_none() {
3172        let body = json!({
3173            "files": files
3174                .iter()
3175                .map(|(p, c)| json!({ "path": p, "content": c }))
3176                .collect::<Vec<_>>(),
3177        });
3178        if body.to_string().len() <= MAX_PUSH_BYTES {
3179            let path = format!("/api/hub/brains/{brain}/push");
3180            let pushed = ensure_ok(
3181                request(cfg, "POST", &path, Some(&body), Auth::Required)?,
3182                "sync push",
3183            )?;
3184            return Ok(pushed);
3185        }
3186    }
3187
3188    let pack = build_store_pack(files)?;
3189    if pack.len() as u64 > MAX_PACK_BYTES {
3190        return Err(LinkError::PushTooLarge {
3191            detail: format!("{} pack bytes", pack.len()),
3192        });
3193    }
3194    let sha256 = format!("{:x}", Sha256::digest(&pack));
3195    let mut meta = json!({ "sha256": sha256, "bytes": pack.len() });
3196    if let Some(key) = &cfg.brain_key {
3197        if !remote.head.verified {
3198            return Err(invalid_feed(
3199                "self-custody push requires a fully verified, unscoped feed head",
3200            ));
3201        }
3202        let identity = remote
3203            .identity
3204            .as_ref()
3205            .ok_or_else(|| invalid_feed("verified brain has no current identity"))?;
3206        let current_multikey = format!("ed25519:{}", identity.fingerprint);
3207        if key.multikey != current_multikey || key.public_key_spki != identity.public_key_spki {
3208            return Err(invalid_feed(
3209                "configured brain key is not the verified current brain identity",
3210            ));
3211        }
3212        // The verified state pins seq + prev; a concurrent writer surfaces as
3213        // the hub's 422 on commit (re-run to retry against the new head).
3214        let next_seq = remote
3215            .head
3216            .seq
3217            .checked_add(1)
3218            .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
3219        let mut manifest: Vec<WireFeedFile> = files
3220            .iter()
3221            .map(|(path, content)| WireFeedFile {
3222                path: path.clone(),
3223                sha256: format!("{:x}", Sha256::digest(content.as_bytes())),
3224                bytes: content.len() as u64,
3225            })
3226            .collect();
3227        manifest.sort_by(|a, b| a.path.cmp(&b.path));
3228        let ts = crate::now()
3229            .with_timezone(&chrono::Utc)
3230            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
3231            .to_string();
3232        let entry = self_custody_entry(
3233            key,
3234            next_seq,
3235            ts,
3236            &sha256,
3237            &manifest,
3238            remote.head.feed_hash.as_deref(),
3239        )?;
3240        meta["entry"] = Value::String(entry);
3241    }
3242    let presigned = ensure_ok(
3243        request(
3244            cfg,
3245            "POST",
3246            &format!("/api/hub/brains/{brain}/packs/presign"),
3247            Some(&meta),
3248            Auth::Required,
3249        )?,
3250        "prepare pack upload",
3251    )?;
3252    let url = presigned
3253        .get("url")
3254        .and_then(Value::as_str)
3255        .ok_or_else(|| LinkError::InvalidPack {
3256            message: "the hub returned no upload URL".to_string(),
3257        })?;
3258    put_presigned(
3259        cfg,
3260        url,
3261        presigned.get("headers").unwrap_or(&Value::Null),
3262        &pack,
3263    )?;
3264    let committed = ensure_ok(
3265        request(
3266            cfg,
3267            "POST",
3268            &format!("/api/hub/brains/{brain}/packs/commit"),
3269            Some(&meta),
3270            Auth::Required,
3271        )?,
3272        "commit pack",
3273    )?;
3274    Ok(committed)
3275}
3276
3277fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
3278    const LOCAL_HEADER: u32 = 0x0403_4b50;
3279    const CENTRAL_HEADER: u32 = 0x0201_4b50;
3280    const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
3281    const VERSION_20: u16 = 20;
3282    const MADE_BY_UNIX_20: u16 = (3 << 8) | VERSION_20;
3283    const UTF8_FLAG: u16 = 1 << 11;
3284    const STORED: u16 = 0;
3285    const DOS_TIME_MIDNIGHT: u16 = 0;
3286    const DOS_DATE_1980_01_01: u16 = (1 << 5) | 1;
3287    const UNIX_REGULAR_0600: u32 = 0o100600 << 16;
3288
3289    struct CentralEntry<'a> {
3290        name: &'a [u8],
3291        crc32: u32,
3292        size: u32,
3293        local_offset: u32,
3294    }
3295
3296    fn push_u16(out: &mut Vec<u8>, value: u16) {
3297        out.extend_from_slice(&value.to_le_bytes());
3298    }
3299
3300    fn push_u32(out: &mut Vec<u8>, value: u32) {
3301        out.extend_from_slice(&value.to_le_bytes());
3302    }
3303
3304    if files.is_empty() {
3305        return Err(LinkError::InvalidPack {
3306            message: "cannot create an empty snapshot pack".to_string(),
3307        });
3308    }
3309    if files.len() > u16::MAX as usize {
3310        return Err(LinkError::PushTooLarge {
3311            detail: format!(
3312                "{} files (canonical ZIP32 packs cap at {})",
3313                files.len(),
3314                u16::MAX
3315            ),
3316        });
3317    }
3318
3319    let mut sorted: Vec<_> = files.iter().collect();
3320    sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
3321    let mut previous: Option<&str> = None;
3322    for (path, content) in &sorted {
3323        if !safe_store_rel_path(path) {
3324            return Err(LinkError::UnsafePath {
3325                path: (*path).clone(),
3326            });
3327        }
3328        if previous == Some(path.as_str()) {
3329            return Err(LinkError::InvalidPack {
3330                message: format!("duplicate path `{path}`"),
3331            });
3332        }
3333        previous = Some(path.as_str());
3334        u32::try_from(content.len()).map_err(|_| LinkError::PushTooLarge {
3335            detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
3336        })?;
3337    }
3338
3339    let mut out = Vec::new();
3340    let mut central = Vec::with_capacity(sorted.len());
3341    for (path, content) in sorted {
3342        let name = path.as_bytes();
3343        let name_len = u16::try_from(name.len()).map_err(|_| LinkError::InvalidPack {
3344            message: format!("ZIP entry name is too long: `{path}`"),
3345        })?;
3346        let bytes = content.as_bytes();
3347        let size = u32::try_from(bytes.len()).map_err(|_| LinkError::PushTooLarge {
3348            detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
3349        })?;
3350        let local_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
3351            detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
3352        })?;
3353        let crc32 = crc32fast::hash(bytes);
3354
3355        // Canonical local header: sizes and CRC are known up front. Bit 3 is
3356        // deliberately clear, so there is no trailing data descriptor.
3357        push_u32(&mut out, LOCAL_HEADER);
3358        push_u16(&mut out, VERSION_20);
3359        push_u16(&mut out, UTF8_FLAG);
3360        push_u16(&mut out, STORED);
3361        push_u16(&mut out, DOS_TIME_MIDNIGHT);
3362        push_u16(&mut out, DOS_DATE_1980_01_01);
3363        push_u32(&mut out, crc32);
3364        push_u32(&mut out, size);
3365        push_u32(&mut out, size);
3366        push_u16(&mut out, name_len);
3367        push_u16(&mut out, 0); // no local extra data
3368        out.extend_from_slice(name);
3369        out.extend_from_slice(bytes);
3370
3371        central.push(CentralEntry {
3372            name,
3373            crc32,
3374            size,
3375            local_offset,
3376        });
3377    }
3378
3379    let central_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
3380        detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
3381    })?;
3382    for entry in &central {
3383        push_u32(&mut out, CENTRAL_HEADER);
3384        push_u16(&mut out, MADE_BY_UNIX_20);
3385        push_u16(&mut out, VERSION_20);
3386        push_u16(&mut out, UTF8_FLAG);
3387        push_u16(&mut out, STORED);
3388        push_u16(&mut out, DOS_TIME_MIDNIGHT);
3389        push_u16(&mut out, DOS_DATE_1980_01_01);
3390        push_u32(&mut out, entry.crc32);
3391        push_u32(&mut out, entry.size);
3392        push_u32(&mut out, entry.size);
3393        push_u16(&mut out, entry.name.len() as u16);
3394        push_u16(&mut out, 0); // no central extra data
3395        push_u16(&mut out, 0); // no file comment
3396        push_u16(&mut out, 0); // disk number
3397        push_u16(&mut out, 0); // internal attributes
3398        push_u32(&mut out, UNIX_REGULAR_0600);
3399        push_u32(&mut out, entry.local_offset);
3400        out.extend_from_slice(entry.name);
3401    }
3402    let central_size = u32::try_from(out.len())
3403        .ok()
3404        .and_then(|end| end.checked_sub(central_offset))
3405        .ok_or_else(|| LinkError::PushTooLarge {
3406            detail: "canonical ZIP32 central directory exceeds its limit".to_string(),
3407        })?;
3408    let entry_count = central.len() as u16;
3409
3410    push_u32(&mut out, END_OF_CENTRAL_DIRECTORY);
3411    push_u16(&mut out, 0); // this disk
3412    push_u16(&mut out, 0); // central directory disk
3413    push_u16(&mut out, entry_count);
3414    push_u16(&mut out, entry_count);
3415    push_u32(&mut out, central_size);
3416    push_u32(&mut out, central_offset);
3417    push_u16(&mut out, 0); // no archive comment
3418
3419    if out.len() > u32::MAX as usize {
3420        return Err(LinkError::PushTooLarge {
3421            detail: "canonical ZIP32 pack exceeds 4 GiB".to_string(),
3422        });
3423    }
3424    Ok(out)
3425}
3426
3427// ─────────────────────────────────────────────────────────────────────────────
3428// grant — issue / list / revoke capabilities (owner-side)
3429// ─────────────────────────────────────────────────────────────────────────────
3430
3431/// The two capabilities a v0 hub enforces.
3432#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3433pub enum Capability {
3434    /// Read the granted slice.
3435    Read,
3436    /// Read and push (whole-store; a path-scoped grant is read-only).
3437    Write,
3438}
3439
3440impl Capability {
3441    /// The wire form.
3442    pub fn as_str(self) -> &'static str {
3443        match self {
3444            Capability::Read => "read",
3445            Capability::Write => "write",
3446        }
3447    }
3448}
3449
3450/// Issue (or refresh) a grant on `brain` to `grantee` — a hub principal named
3451/// by email in v0 (the protocol's near-term simplification; key-named
3452/// grantees arrive with the signing layer). `scope` is a store-path prefix
3453/// (the hub's enforcement unit); `until` an ISO 8601 expiry, absent = until
3454/// revoked.
3455pub fn grant_issue(
3456    cfg: &HubConfig,
3457    brain: &str,
3458    grantee: &str,
3459    can: Capability,
3460    scope: Option<&str>,
3461    until: Option<&str>,
3462) -> LinkResult<Value> {
3463    require_safe_ref(brain)?;
3464    let _ = verified_remote_head(cfg, brain, false)?;
3465    // Grantee shape decides the axis: a base64url Ed25519 SPKI is a bare
3466    // multikey holder (link.md §6 cross-party keys — no hub account; the
3467    // printed `publicKeySpki` from `dbmd key generate`); anything else is a
3468    // hub principal named by email.
3469    let is_key_grantee = URL_SAFE_NO_PAD
3470        .decode(grantee)
3471        .map(|der| der.len() == 44 && der.starts_with(&ED25519_SPKI_PREFIX))
3472        .unwrap_or(false);
3473    let mut body = if is_key_grantee {
3474        json!({ "keySpki": grantee, "capability": can.as_str() })
3475    } else {
3476        json!({ "email": grantee, "capability": can.as_str() })
3477    };
3478    if let Some(s) = scope {
3479        body["scopePrefix"] = json!(s);
3480    }
3481    if let Some(u) = until {
3482        body["expiresAt"] = json!(u);
3483    }
3484    let path = format!("/api/hub/brains/{brain}/grants");
3485    ensure_ok(
3486        request(cfg, "POST", &path, Some(&body), Auth::Required)?,
3487        "grant issue",
3488    )
3489}
3490
3491/// List the active grants (and pending invites) on `brain`. Owner-side.
3492pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
3493    require_safe_ref(brain)?;
3494    let _ = verified_remote_head(cfg, brain, false)?;
3495    let path = format!("/api/hub/brains/{brain}/grants");
3496    ensure_ok(
3497        request(cfg, "GET", &path, None, Auth::Required)?,
3498        "grant list",
3499    )
3500}
3501
3502/// Revoke a grant (or cancel a pending invite) by id. Owner-side; revocation
3503/// is soft on the hub (the audit trail survives).
3504pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
3505    require_safe_ref(brain)?;
3506    require_safe_grant_id(grant_id)?;
3507    let _ = verified_remote_head(cfg, brain, false)?;
3508    let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
3509    ensure_ok(
3510        request(cfg, "DELETE", &path, None, Auth::Required)?,
3511        "grant revoke",
3512    )
3513}
3514
3515// ─────────────────────────────────────────────────────────────────────────────
3516// propose — write without trust: evidence into the owner's inbox
3517// ─────────────────────────────────────────────────────────────────────────────
3518
3519/// Submit `body` to the published site `handle`, addressed to its app page
3520/// `app` (a page that declares the `write-inbox` capability). Deliberately
3521/// unauthenticated — this is the cross-party door; the submission lands as
3522/// *evidence* in the owner's `sources/inbox/`, never as truth, and the
3523/// owner's curator accepts or rejects it. Returns the hub's `{id, path}`
3524/// receipt.
3525pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
3526    require_valid_handle(handle)?;
3527    if body.len() as u64 > MAX_PROPOSE_BYTES {
3528        return Err(LinkError::ProposeTooLarge {
3529            bytes: body.len() as u64,
3530        });
3531    }
3532    let payload = json!({ "app": app, "body": body });
3533    // A ULID-shaped target is a bare brain address (link.md §7.4's
3534    // generalization): the brain inbox door, open on public brains, where a
3535    // configured credential earns a bigger actor-class budget. Anything else
3536    // is a published-site handle: that door is unauthenticated by design.
3537    let (path, auth) = if crate::ulid::is_ulid(handle) {
3538        (format!("/api/hub/brains/{handle}/inbox"), Auth::Optional)
3539    } else {
3540        (format!("/api/hub/sites/{handle}/inbox"), Auth::None)
3541    };
3542    ensure_ok(
3543        request(cfg, "POST", &path, Some(&payload), auth)?,
3544        "propose",
3545    )
3546}
3547
3548// ─────────────────────────────────────────────────────────────────────────────
3549// subscribe — follow feed-head movement
3550// ─────────────────────────────────────────────────────────────────────────────
3551
3552/// One observation of a brain's feed head.
3553#[derive(Debug, serde::Serialize)]
3554pub struct Head {
3555    /// The brain id.
3556    pub brain: String,
3557    /// The hub's durable feed cursor — advances on every accepted write.
3558    pub seq: u64,
3559    /// The hub's `updatedAt` for the brain, when present.
3560    #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
3561    pub updated_at: Option<String>,
3562    /// SHA-256 of the exact signed head entry.
3563    #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
3564    pub feed_hash: Option<String>,
3565    /// Whether the head entry's content hash, identity, and Ed25519 signature
3566    /// were verified locally. Path-scoped grants get head movement only.
3567    pub verified: bool,
3568}
3569
3570struct BoundedVecVisitor<T, const MAX: usize> {
3571    label: &'static str,
3572    marker: std::marker::PhantomData<T>,
3573}
3574
3575impl<'de, T, const MAX: usize> serde::de::Visitor<'de> for BoundedVecVisitor<T, MAX>
3576where
3577    T: Deserialize<'de>,
3578{
3579    type Value = Vec<T>;
3580
3581    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3582        write!(formatter, "at most {MAX} {}", self.label)
3583    }
3584
3585    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
3586    where
3587        A: serde::de::SeqAccess<'de>,
3588    {
3589        if sequence.size_hint().is_some_and(|size| size > MAX) {
3590            return Err(serde::de::Error::custom(format!(
3591                "{} exceeds the {MAX}-item limit",
3592                self.label
3593            )));
3594        }
3595        let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX));
3596        while let Some(value) = sequence.next_element()? {
3597            if values.len() == MAX {
3598                return Err(serde::de::Error::custom(format!(
3599                    "{} exceeds the {MAX}-item limit",
3600                    self.label
3601                )));
3602            }
3603            values.push(value);
3604        }
3605        Ok(values)
3606    }
3607}
3608
3609fn deserialize_bounded_vec<'de, D, T, const MAX: usize>(
3610    deserializer: D,
3611    label: &'static str,
3612) -> Result<Vec<T>, D::Error>
3613where
3614    D: serde::Deserializer<'de>,
3615    T: Deserialize<'de>,
3616{
3617    deserializer.deserialize_seq(BoundedVecVisitor::<T, MAX> {
3618        label,
3619        marker: std::marker::PhantomData,
3620    })
3621}
3622
3623fn deserialize_feed_files<'de, D>(deserializer: D) -> Result<Vec<FeedFile>, D::Error>
3624where
3625    D: serde::Deserializer<'de>,
3626{
3627    deserialize_bounded_vec::<D, FeedFile, MAX_PUSH_FILES>(deserializer, "feed files")
3628}
3629
3630fn deserialize_removed_paths<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
3631where
3632    D: serde::Deserializer<'de>,
3633{
3634    deserialize_bounded_vec::<D, String, MAX_PUSH_FILES>(deserializer, "removed paths")
3635}
3636
3637fn deserialize_previous_identities<'de, D>(
3638    deserializer: D,
3639) -> Result<Vec<PreviousIdentity>, D::Error>
3640where
3641    D: serde::Deserializer<'de>,
3642{
3643    deserialize_bounded_vec::<D, PreviousIdentity, MAX_IDENTITY_ROTATIONS>(
3644        deserializer,
3645        "previous identities",
3646    )
3647}
3648
3649fn deserialize_rotations<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
3650where
3651    D: serde::Deserializer<'de>,
3652{
3653    deserialize_bounded_vec::<D, String, MAX_IDENTITY_ROTATIONS>(
3654        deserializer,
3655        "rotation statements",
3656    )
3657}
3658
3659fn deserialize_feed_items<'de, D>(deserializer: D) -> Result<Vec<FeedItem>, D::Error>
3660where
3661    D: serde::Deserializer<'de>,
3662{
3663    deserialize_bounded_vec::<D, FeedItem, FEED_PAGE_LIMIT>(deserializer, "feed entries")
3664}
3665
3666#[derive(Debug, Clone, Deserialize, Serialize)]
3667struct FeedFile {
3668    path: String,
3669    sha256: String,
3670    bytes: u64,
3671}
3672
3673#[derive(Debug, Clone, Deserialize, Serialize)]
3674struct FeedEntry {
3675    v: u8,
3676    seq: u64,
3677    ts: String,
3678    brain: String,
3679    public_key: String,
3680    kind: String,
3681    op: String,
3682    pack_sha256: String,
3683    #[serde(deserialize_with = "deserialize_feed_files")]
3684    files: Vec<FeedFile>,
3685    #[serde(deserialize_with = "deserialize_removed_paths")]
3686    removed: Vec<String>,
3687    prev_entry_hash: Option<String>,
3688    sig: String,
3689}
3690
3691#[derive(Serialize)]
3692struct UnsignedFeedEntry<'a> {
3693    v: u8,
3694    seq: u64,
3695    ts: &'a str,
3696    brain: &'a str,
3697    public_key: &'a str,
3698    kind: &'a str,
3699    op: &'a str,
3700    pack_sha256: &'a str,
3701    files: &'a [FeedFile],
3702    removed: &'a [String],
3703    prev_entry_hash: &'a Option<String>,
3704}
3705
3706#[derive(Debug, Clone, Deserialize, Serialize)]
3707struct FeedItem {
3708    hash: String,
3709    entry: FeedEntry,
3710}
3711
3712#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
3713struct FeedIdentity {
3714    fingerprint: String,
3715    #[serde(rename = "publicKeySpki")]
3716    public_key_spki: String,
3717    /// Rotation history (link.md §9.1): identities this brain previously
3718    /// signed as. Entries verify against current OR previous — rotation
3719    /// never invalidates history.
3720    #[serde(default, deserialize_with = "deserialize_previous_identities")]
3721    previous: Vec<PreviousIdentity>,
3722    /// Exact normative rotation statements, oldest first. A list of previous
3723    /// public keys without these old-key signatures is not a trust chain.
3724    #[serde(default, deserialize_with = "deserialize_rotations")]
3725    rotations: Vec<String>,
3726}
3727
3728#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
3729struct PreviousIdentity {
3730    fingerprint: String,
3731    #[serde(rename = "publicKeySpki")]
3732    public_key_spki: String,
3733}
3734
3735#[derive(Debug, Deserialize)]
3736struct FeedResponse {
3737    #[serde(rename = "headSeq")]
3738    head_seq: u64,
3739    #[serde(rename = "feedHash")]
3740    feed_hash: Option<String>,
3741    identity: Option<FeedIdentity>,
3742    #[serde(deserialize_with = "deserialize_feed_items")]
3743    entries: Vec<FeedItem>,
3744    #[serde(rename = "scopeLimited")]
3745    scope_limited: bool,
3746}
3747
3748#[derive(Debug, Deserialize, Serialize)]
3749#[serde(deny_unknown_fields)]
3750struct RotationStatement {
3751    v: u8,
3752    op: String,
3753    brain: String,
3754    public_key: String,
3755    new_brain: String,
3756    new_public_key: String,
3757    prior_head_seq: u64,
3758    prior_feed_hash: Option<String>,
3759    ts: String,
3760    sig: String,
3761}
3762
3763#[derive(Debug, Clone, Deserialize, Serialize)]
3764struct TrustState {
3765    v: u8,
3766    origin: String,
3767    /// The exact caller-visible ref whose resolution this checkpoint pins.
3768    /// Slugs/handles are mutable names at the hub; once observed, they may not
3769    /// silently resolve to a different canonical brain.
3770    #[serde(default)]
3771    requested: String,
3772    /// Canonical hub brain id returned for `requested`.
3773    brain: String,
3774    /// Federation home origin when this ref was learned from a registry.
3775    /// Once observed, the registry cannot silently relocate the same handle.
3776    #[serde(default, skip_serializing_if = "Option::is_none")]
3777    home: Option<String>,
3778    anchor: String,
3779    current: String,
3780    #[serde(rename = "headSeq")]
3781    head_seq: u64,
3782    #[serde(rename = "feedHash")]
3783    feed_hash: Option<String>,
3784    /// Exact accepted old-key-signed rotation statements, oldest first. v2
3785    /// checkpoints require this vector to be an immutable prefix of every
3786    /// subsequently served identity chain.
3787    #[serde(default)]
3788    rotations: Vec<String>,
3789}
3790
3791#[derive(Debug, Clone, Deserialize, Serialize)]
3792struct AliasBinding {
3793    v: u8,
3794    origin: String,
3795    requested: String,
3796    brain: String,
3797    #[serde(default, skip_serializing_if = "Option::is_none")]
3798    home: Option<String>,
3799}
3800
3801struct VerifiedRemote {
3802    head: Head,
3803    identity: Option<FeedIdentity>,
3804    head_entry: Option<FeedItem>,
3805    /// Present when the caller requested and verified the complete chain.
3806    entries: Vec<FeedItem>,
3807    anchor: Option<String>,
3808}
3809
3810fn invalid_feed(message: impl Into<String>) -> LinkError {
3811    LinkError::InvalidFeed {
3812        message: message.into(),
3813    }
3814}
3815
3816fn is_sha256(value: &str) -> bool {
3817    value.len() == 64
3818        && value
3819            .bytes()
3820            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
3821}
3822
3823fn identity_fingerprint(public_key_spki: &str) -> LinkResult<String> {
3824    let der = URL_SAFE_NO_PAD
3825        .decode(public_key_spki)
3826        .map_err(|_| invalid_feed("identity public key is not base64url"))?;
3827    if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
3828        return Err(invalid_feed(
3829            "identity public key is not a valid Ed25519 SPKI",
3830        ));
3831    }
3832    Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(&der)))
3833}
3834
3835/// Verify the hub's current identity and every old-key-signed rotation that
3836/// leads from the original TOFU anchor to it. `previous` alone is metadata,
3837/// never authority.
3838fn verify_identity_chain(
3839    identity: &FeedIdentity,
3840    pinned: Option<&TrustState>,
3841) -> LinkResult<String> {
3842    if identity.previous.len() > MAX_IDENTITY_ROTATIONS
3843        || identity.rotations.len() > MAX_IDENTITY_ROTATIONS
3844    {
3845        return Err(invalid_feed(
3846            "identity rotation history exceeds the client cap",
3847        ));
3848    }
3849    if identity.fingerprint != identity_fingerprint(&identity.public_key_spki)? {
3850        return Err(invalid_feed(
3851            "current identity fingerprint does not match its public key",
3852        ));
3853    }
3854    for previous in &identity.previous {
3855        if previous.fingerprint != identity_fingerprint(&previous.public_key_spki)? {
3856            return Err(invalid_feed(
3857                "previous identity fingerprint does not match its public key",
3858            ));
3859        }
3860    }
3861    if identity.rotations.len() != identity.previous.len() {
3862        return Err(invalid_feed(
3863            "identity history is missing an old-key-signed rotation statement",
3864        ));
3865    }
3866
3867    // The HTTP identity lists prior identities newest first. Rotation
3868    // statements are chronological, so verification walks the reversed list
3869    // and ends at the current identity.
3870    let mut chain: Vec<(&str, &str)> = identity
3871        .previous
3872        .iter()
3873        .rev()
3874        .map(|p| (p.fingerprint.as_str(), p.public_key_spki.as_str()))
3875        .collect();
3876    chain.push((&identity.fingerprint, &identity.public_key_spki));
3877
3878    for (index, raw) in identity.rotations.iter().enumerate() {
3879        let statement: RotationStatement = serde_json::from_str(raw)
3880            .map_err(|_| invalid_feed("rotation statement did not parse exactly"))?;
3881        let (old_fingerprint, old_spki) = chain[index];
3882        let (new_fingerprint, new_spki) = chain[index + 1];
3883        if statement.v != 1
3884            || statement.op != "rotate"
3885            || statement.brain != format!("ed25519:{old_fingerprint}")
3886            || statement.public_key != old_spki
3887            || statement.new_brain != format!("ed25519:{new_fingerprint}")
3888            || statement.new_public_key != new_spki
3889            || (statement.prior_head_seq == 0 && statement.prior_feed_hash.is_some())
3890            || (statement.prior_head_seq > 0
3891                && statement
3892                    .prior_feed_hash
3893                    .as_deref()
3894                    .is_none_or(|hash| !is_sha256(hash)))
3895        {
3896            return Err(invalid_feed(
3897                "rotation statement does not connect adjacent identities",
3898            ));
3899        }
3900        let unsigned = serde_json::to_string(&UnsignedRotation {
3901            v: statement.v,
3902            op: &statement.op,
3903            brain: &statement.brain,
3904            public_key: &statement.public_key,
3905            new_brain: &statement.new_brain,
3906            new_public_key: &statement.new_public_key,
3907            prior_head_seq: statement.prior_head_seq,
3908            prior_feed_hash: statement.prior_feed_hash.as_deref(),
3909            ts: statement.ts.clone(),
3910        })
3911        .map_err(|_| invalid_feed("could not canonicalize rotation statement"))?;
3912        let exact = format!(
3913            "{},\"sig\":\"{}\"}}",
3914            &unsigned[..unsigned.len() - 1],
3915            statement.sig
3916        );
3917        if exact != *raw {
3918            return Err(invalid_feed(
3919                "rotation statement is not in normative serialization",
3920            ));
3921        }
3922        let der = URL_SAFE_NO_PAD
3923            .decode(old_spki)
3924            .map_err(|_| invalid_feed("rotation public key is not base64url"))?;
3925        let signature = URL_SAFE_NO_PAD
3926            .decode(&statement.sig)
3927            .map_err(|_| invalid_feed("rotation signature is not base64url"))?;
3928        UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
3929            .verify(unsigned.as_bytes(), &signature)
3930            .map_err(|_| invalid_feed("rotation signature verification failed"))?;
3931        if index > 0 {
3932            let prior: RotationStatement = serde_json::from_str(&identity.rotations[index - 1])
3933                .map_err(|_| invalid_feed("prior rotation statement did not parse"))?;
3934            if statement.prior_head_seq < prior.prior_head_seq {
3935                return Err(invalid_feed("rotation feed boundaries move backward"));
3936            }
3937        }
3938    }
3939
3940    let anchor = format!("ed25519:{}", chain[0].0);
3941    let current = format!("ed25519:{}", identity.fingerprint);
3942    if let Some(pin) = pinned {
3943        if pin.anchor != anchor {
3944            return Err(invalid_feed(
3945                "served identity chain does not descend from the pinned anchor",
3946            ));
3947        }
3948        if !chain
3949            .iter()
3950            .any(|(fingerprint, _)| pin.current == format!("ed25519:{fingerprint}"))
3951        {
3952            return Err(invalid_feed(
3953                "served identity chain forked away from the last pinned identity",
3954            ));
3955        }
3956        if pin.current == current && pin.anchor != current && identity.rotations.is_empty() {
3957            return Err(invalid_feed("served identity discarded its rotation chain"));
3958        }
3959        if pin.v >= 2
3960            && (identity.rotations.len() < pin.rotations.len()
3961                || identity.rotations[..pin.rotations.len()] != pin.rotations)
3962        {
3963            return Err(invalid_feed(
3964                "served identity rewrote the locally accepted rotation history",
3965            ));
3966        }
3967    }
3968    Ok(anchor)
3969}
3970
3971fn verify_rotation_feed_boundaries(
3972    identity: &FeedIdentity,
3973    pinned: Option<&TrustState>,
3974    observed: &[FeedItem],
3975    advertised_seq: u64,
3976) -> LinkResult<()> {
3977    let mut chain: Vec<String> = identity
3978        .previous
3979        .iter()
3980        .rev()
3981        .map(|previous| format!("ed25519:{}", previous.fingerprint))
3982        .collect();
3983    chain.push(format!("ed25519:{}", identity.fingerprint));
3984    let pinned_index = pinned.and_then(|pin| chain.iter().position(|key| key == &pin.current));
3985
3986    for (index, raw) in identity.rotations.iter().enumerate() {
3987        let rotation: RotationStatement = serde_json::from_str(raw)
3988            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
3989        if rotation.prior_head_seq > advertised_seq {
3990            return Err(invalid_feed(
3991                "rotation claims a feed boundary beyond the advertised head",
3992            ));
3993        }
3994        if let (Some(pin), Some(pin_index)) = (pinned, pinned_index) {
3995            if index >= pin_index && rotation.prior_head_seq < pin.head_seq {
3996                return Err(invalid_feed(
3997                    "newly disclosed rotation predates the local feed checkpoint",
3998                ));
3999            }
4000        }
4001        let actual = if rotation.prior_head_seq == 0 {
4002            None
4003        } else if pinned.is_some_and(|pin| pin.head_seq == rotation.prior_head_seq) {
4004            pinned.and_then(|pin| pin.feed_hash.as_deref())
4005        } else {
4006            observed
4007                .iter()
4008                .find(|item| item.entry.seq == rotation.prior_head_seq)
4009                .map(|item| item.hash.as_str())
4010        };
4011        if let Some(actual) = actual {
4012            if rotation.prior_feed_hash.as_deref() != Some(actual) {
4013                return Err(invalid_feed(
4014                    "rotation statement does not commit the verified feed boundary",
4015                ));
4016            }
4017        } else if rotation.prior_head_seq == 0 {
4018            // The empty-feed boundary is represented by (0, null); there is
4019            // no entry hash to find in `observed`.
4020        } else if pinned.is_some_and(|pin| {
4021            pinned_index.is_some_and(|pin_index| index >= pin_index)
4022                || rotation.prior_head_seq >= pin.head_seq
4023        }) {
4024            return Err(invalid_feed(
4025                "rotation feed boundary was not present in the verified chain",
4026            ));
4027        }
4028    }
4029    Ok(())
4030}
4031
4032/// Once a client has checkpointed identity K, identities retired before K may
4033/// verify old history but can never regain authority over a later sequence.
4034/// This is also the safe migration rule for legacy v1 checkpoints that did not
4035/// persist the exact accepted rotation statements.
4036fn reject_retired_signer_after_checkpoint(
4037    identity: &FeedIdentity,
4038    pinned: Option<&TrustState>,
4039    item: &FeedItem,
4040) -> LinkResult<()> {
4041    let Some(pin) = pinned else {
4042        return Ok(());
4043    };
4044    if item.entry.seq <= pin.head_seq {
4045        return Ok(());
4046    }
4047    let mut chain: Vec<String> = identity
4048        .previous
4049        .iter()
4050        .rev()
4051        .map(|previous| format!("ed25519:{}", previous.fingerprint))
4052        .collect();
4053    chain.push(format!("ed25519:{}", identity.fingerprint));
4054    let pinned_index = chain
4055        .iter()
4056        .position(|key| key == &pin.current)
4057        .ok_or_else(|| invalid_feed("pinned identity is absent from the served chain"))?;
4058    let signer_index = chain
4059        .iter()
4060        .position(|key| key == &item.entry.brain)
4061        .ok_or_else(|| invalid_feed("feed signer is absent from the served identity chain"))?;
4062    if signer_index < pinned_index {
4063        return Err(invalid_feed(
4064            "a retired identity attempted to sign after the local checkpoint",
4065        ));
4066    }
4067    Ok(())
4068}
4069
4070fn trust_file_name(cfg: &HubConfig, brain: &str) -> LinkResult<String> {
4071    let origin = normalized_origin(&cfg.hub)?;
4072    let key = format!(
4073        "{:x}",
4074        Sha256::digest(format!("{origin}\0{brain}").as_bytes())
4075    );
4076    Ok(format!("{key}.json"))
4077}
4078
4079fn alias_file_name(cfg: &HubConfig, alias: &str) -> LinkResult<String> {
4080    let origin = normalized_origin(&cfg.hub)?;
4081    let key = format!(
4082        "{:x}",
4083        Sha256::digest(format!("{origin}\0alias\0{alias}").as_bytes())
4084    );
4085    Ok(format!("alias-{key}.json"))
4086}
4087
4088#[cfg(unix)]
4089struct TrustLock {
4090    _file: std::fs::File,
4091}
4092
4093#[cfg(unix)]
4094fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
4095    use std::os::fd::{AsRawFd as _, FromRawFd as _};
4096
4097    let lock_string = format!(".{state_name}.lock");
4098    let lock_name = c_name(lock_string.as_bytes(), &lock_string)?;
4099    let fd = unsafe {
4100        libc::openat(
4101            directory.as_raw_fd(),
4102            lock_name.as_ptr(),
4103            libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4104            0o600,
4105        )
4106    };
4107    if fd < 0 {
4108        return Err(std::io::Error::last_os_error().into());
4109    }
4110    let file = unsafe { std::fs::File::from_raw_fd(fd) };
4111    if !file.metadata()?.is_file() {
4112        return Err(LinkError::UnsafePath { path: lock_string });
4113    }
4114    if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
4115        return Err(std::io::Error::last_os_error().into());
4116    }
4117    Ok(TrustLock { _file: file })
4118}
4119
4120#[cfg(unix)]
4121fn lock_trust_many(
4122    cfg: &HubConfig,
4123    directory: &std::fs::File,
4124    refs: &[&str],
4125) -> LinkResult<Vec<TrustLock>> {
4126    let mut names = refs
4127        .iter()
4128        .map(|reference| trust_file_name(cfg, reference))
4129        .collect::<LinkResult<Vec<_>>>()?;
4130    names.sort();
4131    names.dedup();
4132    names
4133        .iter()
4134        .map(|name| lock_trust_name(directory, name))
4135        .collect()
4136}
4137
4138#[cfg(not(unix))]
4139fn lock_trust_many(
4140    _cfg: &HubConfig,
4141    _directory: &TrustDirectory,
4142    _refs: &[&str],
4143) -> LinkResult<Vec<()>> {
4144    Err(LinkError::UnsupportedPlatform {
4145        operation: "verified link.md state",
4146    })
4147}
4148
4149#[cfg(unix)]
4150type TrustDirectory = std::fs::File;
4151
4152#[cfg(not(unix))]
4153struct TrustDirectory;
4154
4155#[cfg(unix)]
4156fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
4157    use std::os::fd::AsRawFd as _;
4158
4159    let directory = open_or_create_dir_nofollow(&cfg.state_dir.join("trust"))?;
4160    if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
4161        return Err(std::io::Error::last_os_error().into());
4162    }
4163    directory.sync_all()?;
4164    Ok(directory)
4165}
4166
4167#[cfg(not(unix))]
4168fn open_trust_dir(_cfg: &HubConfig) -> LinkResult<TrustDirectory> {
4169    Err(LinkError::UnsupportedPlatform {
4170        operation: "verified link.md state",
4171    })
4172}
4173
4174#[cfg(unix)]
4175fn load_trust_in(
4176    cfg: &HubConfig,
4177    directory: &TrustDirectory,
4178    requested: &str,
4179) -> LinkResult<Option<TrustState>> {
4180    use std::os::fd::{AsRawFd as _, FromRawFd as _};
4181
4182    let name_string = trust_file_name(cfg, requested)?;
4183    let name = c_name(name_string.as_bytes(), &name_string)?;
4184    let fd = unsafe {
4185        libc::openat(
4186            directory.as_raw_fd(),
4187            name.as_ptr(),
4188            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4189        )
4190    };
4191    if fd < 0 {
4192        let error = std::io::Error::last_os_error();
4193        if error.kind() == std::io::ErrorKind::NotFound {
4194            return Ok(None);
4195        }
4196        return Err(LinkError::UnsafePath { path: name_string });
4197    }
4198    let file = unsafe { std::fs::File::from_raw_fd(fd) };
4199    if !file.metadata()?.is_file() {
4200        return Err(LinkError::UnsafePath { path: name_string });
4201    }
4202    let mut bytes = Vec::new();
4203    file.take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
4204    if bytes.len() > 1024 * 1024 {
4205        return Err(invalid_feed("local identity/feed checkpoint is oversized"));
4206    }
4207    let mut state: TrustState = serde_json::from_slice(&bytes)
4208        .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
4209    if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
4210        return Err(invalid_feed(
4211            "local identity/feed checkpoint does not match this hub and brain",
4212        ));
4213    }
4214    if state.v == 1 {
4215        // Legacy files were keyed by canonical brain id. They can migrate only
4216        // when the caller used that exact id; old slug lookups had no durable
4217        // alias binding and therefore cannot be guessed safely.
4218        if state.brain != requested {
4219            return Err(invalid_feed(
4220                "legacy checkpoint is not bound to the requested brain id",
4221            ));
4222        }
4223        state.requested = requested.to_string();
4224    } else if state.requested != requested {
4225        return Err(invalid_feed(
4226            "local identity/feed checkpoint is bound to a different requested ref",
4227        ));
4228    }
4229    Ok(Some(state))
4230}
4231
4232#[cfg(not(unix))]
4233fn load_trust_in(
4234    _cfg: &HubConfig,
4235    _directory: &TrustDirectory,
4236    _brain: &str,
4237) -> LinkResult<Option<TrustState>> {
4238    Err(LinkError::UnsupportedPlatform {
4239        operation: "verified link.md state",
4240    })
4241}
4242
4243#[cfg(all(test, unix))]
4244fn load_trust(cfg: &HubConfig, requested: &str) -> LinkResult<Option<TrustState>> {
4245    let directory = open_trust_dir(cfg)?;
4246    load_trust_in(cfg, &directory, requested)
4247}
4248
4249#[cfg(unix)]
4250fn save_trust_in(
4251    cfg: &HubConfig,
4252    directory: &TrustDirectory,
4253    state: &TrustState,
4254) -> LinkResult<()> {
4255    use std::os::fd::{AsRawFd as _, FromRawFd as _};
4256
4257    let name_string = trust_file_name(cfg, &state.requested)?;
4258    let name = c_name(name_string.as_bytes(), &name_string)?;
4259    let mut bytes = serde_json::to_vec(state)
4260        .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
4261    bytes.push(b'\n');
4262
4263    let nonce = std::time::SystemTime::now()
4264        .duration_since(std::time::UNIX_EPOCH)
4265        .unwrap_or_default()
4266        .as_nanos();
4267    let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
4268    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
4269    let fd = unsafe {
4270        libc::openat(
4271            directory.as_raw_fd(),
4272            temp.as_ptr(),
4273            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4274            0o600,
4275        )
4276    };
4277    if fd < 0 {
4278        return Err(std::io::Error::last_os_error().into());
4279    }
4280    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
4281    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
4282        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4283        return Err(error.into());
4284    }
4285    drop(file);
4286    if unsafe {
4287        libc::renameat(
4288            directory.as_raw_fd(),
4289            temp.as_ptr(),
4290            directory.as_raw_fd(),
4291            name.as_ptr(),
4292        )
4293    } != 0
4294    {
4295        let error = std::io::Error::last_os_error();
4296        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4297        return Err(error.into());
4298    }
4299    directory.sync_all()?;
4300    Ok(())
4301}
4302
4303#[cfg(not(unix))]
4304fn save_trust_in(
4305    _cfg: &HubConfig,
4306    _directory: &TrustDirectory,
4307    _state: &TrustState,
4308) -> LinkResult<()> {
4309    Err(LinkError::UnsupportedPlatform {
4310        operation: "verified link.md state",
4311    })
4312}
4313
4314#[cfg(unix)]
4315fn load_alias_in(
4316    cfg: &HubConfig,
4317    directory: &TrustDirectory,
4318    requested: &str,
4319) -> LinkResult<Option<AliasBinding>> {
4320    use std::os::fd::{AsRawFd as _, FromRawFd as _};
4321
4322    let name_string = alias_file_name(cfg, requested)?;
4323    let name = c_name(name_string.as_bytes(), &name_string)?;
4324    let fd = unsafe {
4325        libc::openat(
4326            directory.as_raw_fd(),
4327            name.as_ptr(),
4328            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4329        )
4330    };
4331    if fd < 0 {
4332        let error = std::io::Error::last_os_error();
4333        if error.kind() == std::io::ErrorKind::NotFound {
4334            return Ok(None);
4335        }
4336        return Err(LinkError::UnsafePath { path: name_string });
4337    }
4338    let file = unsafe { std::fs::File::from_raw_fd(fd) };
4339    if !file.metadata()?.is_file() {
4340        return Err(LinkError::UnsafePath { path: name_string });
4341    }
4342    let mut bytes = Vec::new();
4343    file.take(64 * 1024 + 1).read_to_end(&mut bytes)?;
4344    if bytes.len() > 64 * 1024 {
4345        return Err(invalid_feed("local alias binding is oversized"));
4346    }
4347    let alias: AliasBinding = serde_json::from_slice(&bytes)
4348        .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
4349    if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
4350    {
4351        return Err(invalid_feed(
4352            "local alias binding does not match this hub and requested ref",
4353        ));
4354    }
4355    Ok(Some(alias))
4356}
4357
4358#[cfg(not(unix))]
4359fn load_alias_in(
4360    _cfg: &HubConfig,
4361    _directory: &TrustDirectory,
4362    _requested: &str,
4363) -> LinkResult<Option<AliasBinding>> {
4364    Err(LinkError::UnsupportedPlatform {
4365        operation: "verified link.md state",
4366    })
4367}
4368
4369#[cfg(unix)]
4370fn save_alias_in(
4371    cfg: &HubConfig,
4372    directory: &TrustDirectory,
4373    alias: &AliasBinding,
4374) -> LinkResult<()> {
4375    use std::os::fd::{AsRawFd as _, FromRawFd as _};
4376
4377    let name_string = alias_file_name(cfg, &alias.requested)?;
4378    let name = c_name(name_string.as_bytes(), &name_string)?;
4379    let mut bytes = serde_json::to_vec(alias)
4380        .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
4381    bytes.push(b'\n');
4382    let nonce = std::time::SystemTime::now()
4383        .duration_since(std::time::UNIX_EPOCH)
4384        .unwrap_or_default()
4385        .as_nanos();
4386    let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
4387    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
4388    let fd = unsafe {
4389        libc::openat(
4390            directory.as_raw_fd(),
4391            temp.as_ptr(),
4392            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4393            0o600,
4394        )
4395    };
4396    if fd < 0 {
4397        return Err(std::io::Error::last_os_error().into());
4398    }
4399    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
4400    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
4401        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4402        return Err(error.into());
4403    }
4404    drop(file);
4405    if unsafe {
4406        libc::renameat(
4407            directory.as_raw_fd(),
4408            temp.as_ptr(),
4409            directory.as_raw_fd(),
4410            name.as_ptr(),
4411        )
4412    } != 0
4413    {
4414        let error = std::io::Error::last_os_error();
4415        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4416        return Err(error.into());
4417    }
4418    directory.sync_all()?;
4419    Ok(())
4420}
4421
4422#[cfg(not(unix))]
4423fn save_alias_in(
4424    _cfg: &HubConfig,
4425    _directory: &TrustDirectory,
4426    _alias: &AliasBinding,
4427) -> LinkResult<()> {
4428    Err(LinkError::UnsupportedPlatform {
4429        operation: "verified link.md state",
4430    })
4431}
4432
4433/// Load the canonical checkpoint shared by every spelling of one brain and
4434/// the separate alias binding. This also performs the one-way migration from
4435/// pre-v3 checkpoints that stored a full trust state under the alias itself.
4436/// Callers hold both alias and canonical locks before entering.
4437fn load_canonical_pin(
4438    cfg: &HubConfig,
4439    directory: &TrustDirectory,
4440    requested: &str,
4441    resolved_brain: &str,
4442) -> LinkResult<(Option<TrustState>, Option<AliasBinding>)> {
4443    let mut canonical = load_trust_in(cfg, directory, resolved_brain)?;
4444    if requested == resolved_brain {
4445        return Ok((canonical, None));
4446    }
4447
4448    let mut alias = load_alias_in(cfg, directory, requested)?;
4449    if let Some(binding) = &alias {
4450        if binding.brain != resolved_brain {
4451            return Err(invalid_feed(
4452                "requested brain alias now resolves to a different canonical brain",
4453            ));
4454        }
4455        return Ok((canonical, alias));
4456    }
4457
4458    // A v2 build stored the complete checkpoint under the requested slug.
4459    // Promote it to the canonical ULID key before creating the lightweight
4460    // alias binding. Never silently merge two independently advanced states.
4461    if let Some(legacy) = load_trust_in(cfg, directory, requested)? {
4462        if legacy.brain != resolved_brain {
4463            return Err(invalid_feed(
4464                "legacy alias checkpoint names a different canonical brain",
4465            ));
4466        }
4467        if let Some(existing) = &canonical {
4468            if existing.brain != legacy.brain
4469                || existing.anchor != legacy.anchor
4470                || existing.current != legacy.current
4471                || existing.head_seq != legacy.head_seq
4472                || existing.feed_hash != legacy.feed_hash
4473                || existing.rotations != legacy.rotations
4474            {
4475                return Err(invalid_feed(
4476                    "legacy alias checkpoint conflicts with the canonical checkpoint",
4477                ));
4478            }
4479        } else {
4480            let mut promoted = legacy.clone();
4481            promoted.requested = resolved_brain.to_string();
4482            promoted.home = None;
4483            save_trust_in(cfg, directory, &promoted)?;
4484            canonical = Some(promoted);
4485        }
4486        alias = Some(AliasBinding {
4487            v: 1,
4488            origin: normalized_origin(&cfg.hub)?,
4489            requested: requested.to_string(),
4490            brain: resolved_brain.to_string(),
4491            home: legacy.home,
4492        });
4493        save_alias_in(cfg, directory, alias.as_ref().expect("alias just created"))?;
4494    }
4495    Ok((canonical, alias))
4496}
4497
4498fn save_canonical_pin_and_alias(
4499    cfg: &HubConfig,
4500    directory: &TrustDirectory,
4501    requested: &str,
4502    resolved_brain: &str,
4503    mut state: TrustState,
4504    existing_alias: Option<&AliasBinding>,
4505) -> LinkResult<()> {
4506    state.requested = resolved_brain.to_string();
4507    state.brain = resolved_brain.to_string();
4508    state.home = None;
4509    save_trust_in(cfg, directory, &state)?;
4510    if requested != resolved_brain {
4511        save_alias_in(
4512            cfg,
4513            directory,
4514            &AliasBinding {
4515                v: 1,
4516                origin: normalized_origin(&cfg.hub)?,
4517                requested: requested.to_string(),
4518                brain: resolved_brain.to_string(),
4519                home: existing_alias.and_then(|alias| alias.home.clone()),
4520            },
4521        )?;
4522    }
4523    Ok(())
4524}
4525
4526fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
4527    const ED25519_SPKI_PREFIX: &[u8] = &[
4528        0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
4529    ];
4530    let entry = &item.entry;
4531    let public_der = URL_SAFE_NO_PAD
4532        .decode(&entry.public_key)
4533        .map_err(|_| invalid_feed("public key is not base64url"))?;
4534    if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
4535        || !public_der.starts_with(ED25519_SPKI_PREFIX)
4536    {
4537        return Err(invalid_feed("entry public key is not a valid Ed25519 SPKI"));
4538    }
4539    let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
4540    if entry.brain != format!("ed25519:{fingerprint}") {
4541        return Err(invalid_feed(
4542            "brain fingerprint does not match its public key",
4543        ));
4544    }
4545    // Verify the complete rotation authority before using any previous key.
4546    let _ = verify_identity_chain(identity, None)?;
4547    let mut chain: Vec<(&str, &str)> = identity
4548        .previous
4549        .iter()
4550        .rev()
4551        .map(|previous| {
4552            (
4553                previous.fingerprint.as_str(),
4554                previous.public_key_spki.as_str(),
4555            )
4556        })
4557        .collect();
4558    chain.push((&identity.fingerprint, &identity.public_key_spki));
4559    let signer_index = chain.iter().position(|(known_fingerprint, spki)| {
4560        *known_fingerprint == fingerprint && *spki == entry.public_key
4561    });
4562    let Some(signer_index) = signer_index else {
4563        return Err(invalid_feed(
4564            "entry signer is not this brain's identity (current or rotated-from)",
4565        ));
4566    };
4567    let lower_boundary = if signer_index == 0 {
4568        None
4569    } else {
4570        let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
4571            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
4572        Some(prior.prior_head_seq)
4573    };
4574    let upper_boundary = if signer_index == identity.rotations.len() {
4575        None
4576    } else {
4577        let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
4578            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
4579        Some(next.prior_head_seq)
4580    };
4581    if lower_boundary.is_some_and(|boundary| entry.seq <= boundary)
4582        || upper_boundary.is_some_and(|boundary| entry.seq > boundary)
4583    {
4584        return Err(invalid_feed(
4585            "entry signer is outside its authenticated rotation epoch",
4586        ));
4587    }
4588    let unsigned = UnsignedFeedEntry {
4589        v: entry.v,
4590        seq: entry.seq,
4591        ts: &entry.ts,
4592        brain: &entry.brain,
4593        public_key: &entry.public_key,
4594        kind: &entry.kind,
4595        op: &entry.op,
4596        pack_sha256: &entry.pack_sha256,
4597        files: &entry.files,
4598        removed: &entry.removed,
4599        prev_entry_hash: &entry.prev_entry_hash,
4600    };
4601    let message =
4602        serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
4603    let signature = URL_SAFE_NO_PAD
4604        .decode(&entry.sig)
4605        .map_err(|_| invalid_feed("signature is not base64url"))?;
4606    UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
4607        .verify(&message, &signature)
4608        .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
4609
4610    let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
4611    exact.push(b'\n');
4612    let actual_hash = format!("{:x}", Sha256::digest(&exact));
4613    if actual_hash != item.hash {
4614        return Err(invalid_feed("entry SHA-256 does not match"));
4615    }
4616    Ok(())
4617}
4618
4619// ─────────────────────────────────────────────────────────────────────────────
4620// key rotation — link.md §9.1: the new key, signed by the old one
4621// ─────────────────────────────────────────────────────────────────────────────
4622
4623/// The unsigned rotation statement in its normative field order.
4624#[derive(Serialize)]
4625struct UnsignedRotation<'a> {
4626    v: u8,
4627    op: &'a str,
4628    brain: &'a str,
4629    public_key: &'a str,
4630    new_brain: &'a str,
4631    new_public_key: &'a str,
4632    prior_head_seq: u64,
4633    prior_feed_hash: Option<&'a str>,
4634    ts: String,
4635}
4636
4637/// Durable intent for an in-flight key rotation. The hub's recovery contract
4638/// identifies an ambiguous retry by the exact statement bytes, so the
4639/// statement cannot be reconstructed from the key and feed boundary later:
4640/// its timestamp and signature would differ.
4641#[derive(Debug, Deserialize, Serialize)]
4642#[serde(deny_unknown_fields)]
4643struct RotationJournal {
4644    v: u8,
4645    origin: String,
4646    brain: String,
4647    old_brain: String,
4648    new_brain: String,
4649    prior_head_seq: u64,
4650    prior_feed_hash: Option<String>,
4651    statement: String,
4652}
4653
4654fn rotation_journal_path(key_path: &Path) -> PathBuf {
4655    let mut path = key_path.as_os_str().to_os_string();
4656    path.push(".rotation.json");
4657    PathBuf::from(path)
4658}
4659
4660fn read_rotation_journal(path: &Path) -> LinkResult<RotationJournal> {
4661    #[cfg(unix)]
4662    let file = {
4663        use std::os::fd::{AsRawFd as _, FromRawFd as _};
4664        use std::os::unix::ffi::OsStrExt as _;
4665        let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
4666            .map_err(|error| {
4667                bad_agent_key(&format!("cannot open the rotation journal parent: {error}"))
4668            })?;
4669        let leaf_name = path
4670            .file_name()
4671            .ok_or_else(|| bad_agent_key("the rotation journal path has no file name"))?;
4672        let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
4673        let fd = unsafe {
4674            libc::openat(
4675                parent.as_raw_fd(),
4676                leaf.as_ptr(),
4677                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4678            )
4679        };
4680        if fd < 0 {
4681            return Err(bad_agent_key(
4682                "the rotation journal must be an existing regular file without symlink ancestors",
4683            ));
4684        }
4685        unsafe { std::fs::File::from_raw_fd(fd) }
4686    };
4687    #[cfg(not(unix))]
4688    let file = std::fs::File::open(path)
4689        .map_err(|error| bad_agent_key(&format!("cannot read the rotation journal: {error}")))?;
4690    let metadata = file
4691        .metadata()
4692        .map_err(|error| bad_agent_key(&format!("cannot inspect the rotation journal: {error}")))?;
4693    if !metadata.is_file() || metadata.len() > MAX_REGISTRY_CARD_BYTES {
4694        return Err(bad_agent_key(
4695            "the rotation journal must be a bounded regular file",
4696        ));
4697    }
4698    #[cfg(unix)]
4699    {
4700        use std::os::unix::fs::PermissionsExt as _;
4701        if metadata.permissions().mode() & 0o077 != 0 {
4702            return Err(bad_agent_key(
4703                "the rotation journal is accessible to group/other; set mode 0600",
4704            ));
4705        }
4706    }
4707    serde_json::from_reader(file)
4708        .map_err(|_| bad_agent_key("the rotation journal is not valid exact JSON"))
4709}
4710
4711fn remove_rotation_journal(path: &Path) {
4712    #[cfg(unix)]
4713    {
4714        use std::os::fd::AsRawFd as _;
4715        use std::os::unix::ffi::OsStrExt as _;
4716        let Ok(parent) =
4717            open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
4718        else {
4719            return;
4720        };
4721        let Some(leaf_name) = path.file_name() else {
4722            return;
4723        };
4724        let Ok(leaf) = c_name(leaf_name.as_bytes(), &path.display().to_string()) else {
4725            return;
4726        };
4727        if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), 0) } == 0 {
4728            let _ = parent.sync_all();
4729        }
4730    }
4731    #[cfg(not(unix))]
4732    {
4733        let _ = std::fs::remove_file(path);
4734    }
4735}
4736
4737fn validate_rotation_journal(
4738    journal: &RotationJournal,
4739    cfg: &HubConfig,
4740    canonical_brain: &str,
4741    old_key: &AgentSigningKey,
4742    new_key: &AgentSigningKey,
4743    head: &Head,
4744) -> LinkResult<()> {
4745    if journal.v != 1
4746        || journal.origin != normalized_origin(&cfg.hub)?
4747        || journal.brain != canonical_brain
4748        || journal.old_brain != old_key.multikey
4749        || journal.new_brain != new_key.multikey
4750        || journal.prior_head_seq != head.seq
4751        || journal.prior_feed_hash != head.feed_hash
4752    {
4753        return Err(invalid_feed(
4754            "rotation journal does not match the verified key and feed boundary",
4755        ));
4756    }
4757    let statement: RotationStatement = serde_json::from_str(&journal.statement)
4758        .map_err(|_| invalid_feed("rotation journal statement did not parse exactly"))?;
4759    if statement.prior_head_seq != journal.prior_head_seq
4760        || statement.prior_feed_hash != journal.prior_feed_hash
4761        || statement.brain != old_key.multikey
4762        || statement.public_key != old_key.public_key_spki
4763        || statement.new_brain != new_key.multikey
4764        || statement.new_public_key != new_key.public_key_spki
4765    {
4766        return Err(invalid_feed(
4767            "rotation journal statement does not match its durable intent",
4768        ));
4769    }
4770    let identity = FeedIdentity {
4771        fingerprint: new_key.multikey.trim_start_matches("ed25519:").to_string(),
4772        public_key_spki: new_key.public_key_spki.clone(),
4773        previous: vec![PreviousIdentity {
4774            fingerprint: old_key.multikey.trim_start_matches("ed25519:").to_string(),
4775            public_key_spki: old_key.public_key_spki.clone(),
4776        }],
4777        rotations: vec![journal.statement.clone()],
4778    };
4779    verify_identity_chain(&identity, None)?;
4780    Ok(())
4781}
4782
4783/// What `dbmd key rotate` returns.
4784#[derive(Debug, Serialize)]
4785pub struct RotationReport {
4786    /// The brain id rotated.
4787    pub brain: String,
4788    /// The NEW identity the hub now serves.
4789    pub multikey: String,
4790    /// Where the new PKCS#8 secret landed (0600).
4791    #[serde(rename = "keyFile")]
4792    pub key_file: String,
4793    /// Prior identities (newest first) the feed still verifies against.
4794    pub previous: Vec<String>,
4795}
4796
4797/// Rotate a self-custodied brain's key: mint a fresh keypair, build the
4798/// §9.1 statement — the new key plus exact prior feed boundary, signed by the
4799/// OLD key in normative serialization — and send it to the hub. The new secret
4800/// is durably created at 0600 before the POST; an existing output is reused for
4801/// idempotent retry/reconciliation. The old key is left untouched.
4802pub fn rotate_brain_key(
4803    cfg: &HubConfig,
4804    brain: &str,
4805    old_key: &AgentSigningKey,
4806    out: &Path,
4807) -> LinkResult<RotationReport> {
4808    require_hardened_filesystem("key rotation")?;
4809    require_safe_ref(brain)?;
4810    // The new private key must be durable *before* the hub can accept its
4811    // public half. An existing file is the retry/reconciliation path after an
4812    // ambiguous network failure: reuse it, never generate another identity.
4813    let new_key = if out.exists() {
4814        load_signing_key(out)?
4815    } else {
4816        let rng = ring::rand::SystemRandom::new();
4817        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
4818            .map_err(|_| bad_agent_key("key generation failed"))?;
4819        let pair = agent_keypair(pkcs8.as_ref())?;
4820        let (public_key_spki, multikey) = public_identity_for(&pair);
4821        write_secret_new(
4822            out,
4823            format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
4824        )?;
4825        AgentSigningKey {
4826            pkcs8: pkcs8.as_ref().to_vec(),
4827            multikey,
4828            public_key_spki,
4829        }
4830    };
4831    let new_spki = new_key.public_key_spki.clone();
4832    let new_multikey = new_key.multikey.clone();
4833    let journal_path = rotation_journal_path(out);
4834    let before = verified_remote_head(cfg, brain, false)?;
4835    let served_identity = before
4836        .identity
4837        .as_ref()
4838        .ok_or_else(|| invalid_feed("cannot rotate a brain with no signed identity"))?;
4839    let served_multikey = format!("ed25519:{}", served_identity.fingerprint);
4840    if served_multikey == new_multikey {
4841        remove_rotation_journal(&journal_path);
4842        return Ok(RotationReport {
4843            brain: brain.to_string(),
4844            multikey: new_multikey,
4845            key_file: out.display().to_string(),
4846            previous: served_identity
4847                .previous
4848                .iter()
4849                .map(|identity| format!("ed25519:{}", identity.fingerprint))
4850                .collect(),
4851        });
4852    }
4853    if served_multikey != old_key.multikey {
4854        return Err(invalid_feed(
4855            "the supplied old key is not the brain's verified current identity",
4856        ));
4857    }
4858
4859    let journal = if journal_path.exists() {
4860        read_rotation_journal(&journal_path)?
4861    } else {
4862        let ts = crate::now()
4863            .with_timezone(&chrono::Utc)
4864            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
4865            .to_string();
4866        let unsigned = serde_json::to_string(&UnsignedRotation {
4867            v: 1,
4868            op: "rotate",
4869            brain: &old_key.multikey,
4870            public_key: &old_key.public_key_spki,
4871            new_brain: &new_multikey,
4872            new_public_key: &new_spki,
4873            prior_head_seq: before.head.seq,
4874            prior_feed_hash: before.head.feed_hash.as_deref(),
4875            ts,
4876        })
4877        .expect("serialize rotation");
4878        let old_pair = agent_keypair(&old_key.pkcs8)?;
4879        let sig = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
4880        let statement = format!("{},\"sig\":\"{}\"}}", &unsigned[..unsigned.len() - 1], sig);
4881        let journal = RotationJournal {
4882            v: 1,
4883            origin: normalized_origin(&cfg.hub)?,
4884            brain: before.head.brain.clone(),
4885            old_brain: old_key.multikey.clone(),
4886            new_brain: new_multikey.clone(),
4887            prior_head_seq: before.head.seq,
4888            prior_feed_hash: before.head.feed_hash.clone(),
4889            statement,
4890        };
4891        let mut exact = serde_json::to_vec(&journal)
4892            .map_err(|_| invalid_feed("could not serialize rotation journal"))?;
4893        exact.push(b'\n');
4894        if write_secret_new(&journal_path, &exact).is_err() {
4895            // A concurrent retry may have won the O_EXCL race. Only an exact,
4896            // fully validated journal is allowed to recover that race.
4897            read_rotation_journal(&journal_path)?
4898        } else {
4899            journal
4900        }
4901    };
4902    validate_rotation_journal(
4903        &journal,
4904        cfg,
4905        &before.head.brain,
4906        old_key,
4907        &new_key,
4908        &before.head,
4909    )?;
4910
4911    let body = json!({ "statement": journal.statement });
4912    let path = format!("/api/hub/brains/{brain}/rotate");
4913    let attempted = request(cfg, "POST", &path, Some(&body), Auth::Required);
4914    let attempted_failure = match attempted {
4915        Ok(response) if (200..300).contains(&response.status) => None,
4916        Ok(response) => Some(ensure_ok(response, "key rotate").unwrap_err()),
4917        Err(error) => Some(error),
4918    };
4919
4920    // A 2xx body is not authority, and a failed response may have followed a
4921    // committed mutation. In both cases success means the normal verifier sees
4922    // an append-only rotation chain ending at the durable new key.
4923    let after = match verified_remote_head(cfg, brain, false) {
4924        Ok(after) => after,
4925        Err(error) => return Err(attempted_failure.unwrap_or(error)),
4926    };
4927    let identity = after
4928        .identity
4929        .as_ref()
4930        .ok_or_else(|| invalid_feed("rotated brain has no verified identity"))?;
4931    if format!("ed25519:{}", identity.fingerprint) != new_multikey
4932        || identity.public_key_spki != new_spki
4933    {
4934        return Err(attempted_failure.unwrap_or_else(|| {
4935            invalid_feed("hub acknowledged rotation without committing the verified new identity")
4936        }));
4937    }
4938    let previous = identity
4939        .previous
4940        .iter()
4941        .map(|prior| format!("ed25519:{}", prior.fingerprint))
4942        .collect();
4943    remove_rotation_journal(&journal_path);
4944
4945    Ok(RotationReport {
4946        brain: brain.to_string(),
4947        multikey: new_multikey,
4948        key_file: out.display().to_string(),
4949        previous,
4950    })
4951}
4952
4953// ─────────────────────────────────────────────────────────────────────────────
4954// mirror — verified replication: the whole feed + files, re-servable
4955// ─────────────────────────────────────────────────────────────────────────────
4956
4957/// What `dbmd mirror` materialized.
4958#[derive(Debug, Serialize)]
4959pub struct MirrorReport {
4960    /// The brain id.
4961    pub brain: String,
4962    /// The mirrored feed head.
4963    #[serde(rename = "headSeq")]
4964    pub head_seq: u64,
4965    /// The head entry hash (the feed's advertised converged state).
4966    #[serde(rename = "feedHash")]
4967    pub feed_hash: Option<String>,
4968    /// Signed feed entries verified and stored.
4969    pub entries: u64,
4970    /// The brain's multikey, pinned in `.dbmd/config` (TOFU).
4971    pub pinned: String,
4972    /// Store files materialized by the pull.
4973    pub files: usize,
4974}
4975
4976/// The mirror state directory, relative to the mirror root.
4977pub const MIRROR_REL_DIR: &str = ".dbmd/mirror";
4978
4979/// Fully re-verified mirror material suitable for a read-only re-server.
4980#[derive(Debug)]
4981pub struct VerifiedMirrorMaterial {
4982    pub brain: String,
4983    pub head_seq: u64,
4984    pub feed_hash: Option<String>,
4985    pub identity: serde_json::Value,
4986    /// Sequence, exact normative entry JSON without newline, and entry hash.
4987    pub entries: Vec<(u64, String, String)>,
4988    pub pack_sha256: Option<String>,
4989}
4990
4991#[derive(Deserialize)]
4992#[serde(deny_unknown_fields)]
4993struct StoredMirrorHead {
4994    brain: String,
4995    #[serde(rename = "headSeq")]
4996    head_seq: u64,
4997    #[serde(rename = "feedHash")]
4998    feed_hash: Option<String>,
4999}
5000
5001/// Re-verify mirror metadata, every signature/hash/rotation boundary, and the
5002/// exact snapshot pack before `dbmd serve` exposes any bytes.
5003pub fn verify_mirror_material(
5004    head_bytes: &[u8],
5005    identity_bytes: &[u8],
5006    feed_bytes: &[Vec<u8>],
5007    snapshot_pack: Option<&[u8]>,
5008    expected_anchor: &str,
5009) -> LinkResult<VerifiedMirrorMaterial> {
5010    let snapshot_hash = snapshot_pack
5011        .filter(|pack| !pack.is_empty())
5012        .map(content_sha256);
5013    verify_mirror_material_with_pack_hash(
5014        head_bytes,
5015        identity_bytes,
5016        feed_bytes,
5017        snapshot_hash.as_deref(),
5018        expected_anchor,
5019    )
5020}
5021
5022/// Re-verify mirror metadata against a snapshot digest computed from a held
5023/// no-follow file capability. This lets `dbmd serve` authenticate and retain a
5024/// large pack without ever buffering the pack in process memory.
5025pub fn verify_mirror_material_with_pack_hash(
5026    head_bytes: &[u8],
5027    identity_bytes: &[u8],
5028    feed_bytes: &[Vec<u8>],
5029    snapshot_pack_sha256: Option<&str>,
5030    expected_anchor: &str,
5031) -> LinkResult<VerifiedMirrorMaterial> {
5032    let head: StoredMirrorHead = serde_json::from_slice(head_bytes)
5033        .map_err(|_| invalid_feed("stored mirror head did not parse exactly"))?;
5034    require_safe_ref(&head.brain)?;
5035    if head.head_seq > MAX_FEED_REPLAY_ENTRIES || feed_bytes.len() as u64 != head.head_seq {
5036        return Err(invalid_feed(
5037            "stored mirror feed count does not match its bounded head sequence",
5038        ));
5039    }
5040    let aggregate = feed_bytes
5041        .iter()
5042        .try_fold(0u64, |total, bytes| total.checked_add(bytes.len() as u64))
5043        .ok_or_else(|| invalid_feed("stored mirror feed size overflow"))?;
5044    if aggregate > MAX_FEED_REPLAY_BYTES {
5045        return Err(invalid_feed(
5046            "stored mirror feed metadata exceeds the aggregate limit",
5047        ));
5048    }
5049    let identity: FeedIdentity = serde_json::from_slice(identity_bytes)
5050        .map_err(|_| invalid_feed("stored mirror identity did not parse exactly"))?;
5051    let anchor = verify_identity_chain(&identity, None)?;
5052    if anchor != expected_anchor {
5053        return Err(invalid_feed(
5054            "stored mirror identity does not descend from the explicitly trusted anchor",
5055        ));
5056    }
5057
5058    let mut entries = Vec::with_capacity(feed_bytes.len());
5059    let mut items = Vec::with_capacity(feed_bytes.len());
5060    let mut previous_hash = None;
5061    let mut pack_sha256 = None;
5062    for (index, bytes) in feed_bytes.iter().enumerate() {
5063        let exact = bytes
5064            .strip_suffix(b"\n")
5065            .ok_or_else(|| invalid_feed("stored feed entry lacks its exact trailing newline"))?;
5066        if exact.ends_with(b"\n") {
5067            return Err(invalid_feed("stored feed entry has extra trailing bytes"));
5068        }
5069        let entry: FeedEntry = serde_json::from_slice(exact)
5070            .map_err(|_| invalid_feed("stored feed entry did not parse exactly"))?;
5071        let expected_seq = index as u64 + 1;
5072        if entry.seq != expected_seq || entry.prev_entry_hash != previous_hash {
5073            return Err(invalid_feed(
5074                "stored mirror feed is not contiguous and hash-chained",
5075            ));
5076        }
5077        let canonical = serde_json::to_vec(&entry)
5078            .map_err(|_| invalid_feed("could not canonicalize stored feed entry"))?;
5079        if canonical != exact {
5080            return Err(invalid_feed(
5081                "stored feed entry is not in normative serialization",
5082            ));
5083        }
5084        let hash = content_sha256(bytes);
5085        let item = FeedItem {
5086            hash: hash.clone(),
5087            entry,
5088        };
5089        verify_feed_item(&item, &identity)?;
5090        previous_hash = Some(hash.clone());
5091        if expected_seq == head.head_seq {
5092            pack_sha256 = Some(item.entry.pack_sha256.clone());
5093        }
5094        entries.push((
5095            expected_seq,
5096            std::str::from_utf8(exact)
5097                .map_err(|_| invalid_feed("stored feed entry is not UTF-8"))?
5098                .to_string(),
5099            hash,
5100        ));
5101        items.push(item);
5102    }
5103    if previous_hash != head.feed_hash {
5104        return Err(invalid_feed(
5105            "stored mirror feed does not converge on its advertised head",
5106        ));
5107    }
5108    verify_rotation_feed_boundaries(&identity, None, &items, head.head_seq)?;
5109    match (head.head_seq, snapshot_pack_sha256, pack_sha256.as_deref()) {
5110        (0, None, None) => {}
5111        (_, Some(actual), Some(expected)) if actual == expected => {}
5112        _ => {
5113            return Err(LinkError::InvalidPack {
5114                message: "stored snapshot pack does not match the signed head digest".to_string(),
5115            });
5116        }
5117    }
5118    let identity_value = serde_json::to_value(&identity)
5119        .map_err(|_| invalid_feed("could not serialize verified mirror identity"))?;
5120    Ok(VerifiedMirrorMaterial {
5121        brain: head.brain,
5122        head_seq: head.head_seq,
5123        feed_hash: head.feed_hash,
5124        identity: identity_value,
5125        entries,
5126        pack_sha256,
5127    })
5128}
5129
5130/// SHA-256 hex of one feed entry's stored bytes (`exact JSON + "\n"`) — the
5131/// entry hash every consumer recomputes (SPEC §5.3).
5132pub fn feed_entry_hash(exact_sans_newline: &str) -> String {
5133    format!(
5134        "{:x}",
5135        Sha256::digest(format!("{exact_sans_newline}\n").as_bytes())
5136    )
5137}
5138
5139/// SHA-256 hex for content a signed manifest names. Exposed for the thin
5140/// `dbmd serve` adapter; cryptographic verification remains centralized here.
5141pub fn content_sha256(bytes: &[u8]) -> String {
5142    format!("{:x}", Sha256::digest(bytes))
5143}
5144
5145/// SHA-256 a stream with a fixed-size working buffer.
5146pub fn content_sha256_reader(mut reader: impl Read) -> std::io::Result<String> {
5147    let mut digest = Sha256::new();
5148    let mut buffer = [0u8; 64 * 1024];
5149    loop {
5150        let read = reader.read(&mut buffer)?;
5151        if read == 0 {
5152            break;
5153        }
5154        digest.update(&buffer[..read]);
5155    }
5156    Ok(format!("{:x}", digest.finalize()))
5157}
5158
5159/// Replicate a brain with full verification (link.md §5.4 over the WHOLE
5160/// chain, not just the head): every entry's signature, hash, sequence
5161/// contiguity, prev-hash linkage, rotation chain, and exact signed pack are
5162/// checked in a sibling staging directory. Only then is the old mirror swapped
5163/// out through an atomic directory exchange. Every stage, install, and cleanup
5164/// operation is relative to one held no-follow parent-directory capability, so
5165/// renaming an ancestor cannot redirect any write or deletion.
5166pub fn mirror(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<MirrorReport> {
5167    require_hardened_filesystem("mirror")?;
5168    require_safe_ref(brain)?;
5169    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
5170    let name = dest
5171        .file_name()
5172        .and_then(|name| name.to_str())
5173        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
5174        .ok_or_else(|| LinkError::UnsafePath {
5175            path: dest.display().to_string(),
5176        })?;
5177    #[cfg(unix)]
5178    let parent_dir = open_or_create_dir_nofollow(parent)?;
5179    #[cfg(unix)]
5180    use std::os::fd::AsRawFd as _;
5181    #[cfg(unix)]
5182    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
5183    #[cfg(unix)]
5184    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
5185        None => false,
5186        Some(true) => true,
5187        Some(false) => {
5188            return Err(LinkError::UnsafePath {
5189                path: dest.display().to_string(),
5190            });
5191        }
5192    };
5193
5194    // A fixed backup was used by pre-hardening builds. Never interpret or
5195    // delete an attacker-planted entry at that name; require manual recovery.
5196    #[cfg(unix)]
5197    let legacy_backup_name = c_name(
5198        format!(".{name}.dbmd-backup").as_bytes(),
5199        &dest.display().to_string(),
5200    )?;
5201    #[cfg(unix)]
5202    if entry_is_dir_at(parent_dir.as_raw_fd(), &legacy_backup_name)?.is_some() {
5203        return Err(LinkError::UnsafePath {
5204            path: parent
5205                .join(format!(".{name}.dbmd-backup"))
5206                .display()
5207                .to_string(),
5208        });
5209    }
5210
5211    let nonce = std::time::SystemTime::now()
5212        .duration_since(std::time::UNIX_EPOCH)
5213        .unwrap_or_default()
5214        .as_nanos();
5215    let stage_label = format!(".{name}.dbmd-stage-{}-{nonce}", std::process::id());
5216    #[cfg(unix)]
5217    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
5218    #[cfg(unix)]
5219    let stage_dir = create_dir_exclusive_at(
5220        parent_dir.as_raw_fd(),
5221        &stage_name,
5222        &dest.display().to_string(),
5223    )?;
5224
5225    let assembled = (|| -> LinkResult<MirrorReport> {
5226        let remote = verified_remote_head(cfg, brain, true)?;
5227        let brain_id = remote.head.brain.clone();
5228        let identity = remote
5229            .identity
5230            .as_ref()
5231            .ok_or_else(|| invalid_feed("brain has no signed identity"))?;
5232        let anchor = remote
5233            .anchor
5234            .clone()
5235            .ok_or_else(|| invalid_feed("brain has no verified identity anchor"))?;
5236        let pack = download_verified_snapshot_pack(cfg, &brain_id, &remote)?;
5237        let snapshot_entries = parse_store_pack(pack.clone())?;
5238        let snapshot_count = snapshot_entries.len();
5239        let mut staged_entries = snapshot_entries;
5240        staged_entries.push((format!("{MIRROR_REL_DIR}/snapshot.pack"), pack));
5241        for item in &remote.entries {
5242            let mut exact = serde_json::to_vec(&item.entry)
5243                .map_err(|_| invalid_feed("could not serialize signed feed entry"))?;
5244            exact.push(b'\n');
5245            if format!("{:x}", Sha256::digest(&exact)) != item.hash {
5246                return Err(invalid_feed(
5247                    "serialized mirror entry differs from its verified hash",
5248                ));
5249            }
5250            staged_entries.push((
5251                format!("{MIRROR_REL_DIR}/feed/{}.json", item.entry.seq),
5252                exact,
5253            ));
5254        }
5255        let mut identity_bytes = serde_json::to_vec(identity)
5256            .map_err(|_| invalid_feed("could not serialize verified identity chain"))?;
5257        identity_bytes.push(b'\n');
5258        staged_entries.push((format!("{MIRROR_REL_DIR}/identity.json"), identity_bytes));
5259        let mut head_bytes = serde_json::to_vec(&json!({
5260            "brain": brain_id,
5261            "headSeq": remote.head.seq,
5262            "feedHash": remote.head.feed_hash,
5263        }))
5264        .map_err(|_| invalid_feed("could not serialize verified mirror head"))?;
5265        head_bytes.push(b'\n');
5266        staged_entries.push((format!("{MIRROR_REL_DIR}/head.json"), head_bytes));
5267        staged_entries.push((
5268            CONFIG_REL_PATH.to_string(),
5269            format!("hub = {}\npin = {anchor}\n", cfg.hub).into_bytes(),
5270        ));
5271        #[cfg(unix)]
5272        write_pull_entries_beneath_dir(&stage_dir, &staged_entries)?;
5273
5274        Ok(MirrorReport {
5275            brain: brain_id,
5276            head_seq: remote.head.seq,
5277            feed_hash: remote.head.feed_hash,
5278            entries: remote.entries.len() as u64,
5279            pinned: anchor,
5280            files: snapshot_count,
5281        })
5282    })();
5283
5284    let report = match assembled {
5285        Ok(report) => report,
5286        Err(error) => {
5287            #[cfg(unix)]
5288            let _ = remove_tree_at(
5289                parent_dir.as_raw_fd(),
5290                &stage_name,
5291                &dest.display().to_string(),
5292            );
5293            return Err(error);
5294        }
5295    };
5296
5297    #[cfg(unix)]
5298    if let Err(error) =
5299        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
5300    {
5301        let _ = remove_tree_at(
5302            parent_dir.as_raw_fd(),
5303            &stage_name,
5304            &dest.display().to_string(),
5305        );
5306        return Err(error);
5307    }
5308    // An exchange leaves the old mirror at the unique stage name. Cleanup is
5309    // capability-relative and never follows symlinks in hostile old content.
5310    #[cfg(unix)]
5311    if dest_exists {
5312        remove_tree_at(
5313            parent_dir.as_raw_fd(),
5314            &stage_name,
5315            &dest.display().to_string(),
5316        )?;
5317    }
5318    #[cfg(unix)]
5319    parent_dir.sync_all()?;
5320    Ok(report)
5321}
5322
5323fn verified_remote_head(
5324    cfg: &HubConfig,
5325    brain: &str,
5326    require_full_chain: bool,
5327) -> LinkResult<VerifiedRemote> {
5328    require_hardened_filesystem("verified link.md state")?;
5329    require_safe_ref(brain)?;
5330    // Refuse an unsafe state root before sending credentials or consulting an
5331    // untrusted card, and retain this exact directory inode for the complete
5332    // checkpoint transaction.
5333    let trust_directory = open_trust_dir(cfg)?;
5334    let path = format!("/api/hub/brains/{brain}");
5335    let body = ensure_ok(
5336        request(cfg, "GET", &path, None, Auth::Required)?,
5337        "subscribe",
5338    )?;
5339    let resolved_brain = body
5340        .get("id")
5341        .and_then(Value::as_str)
5342        .filter(|id| crate::ulid::is_ulid(id))
5343        .ok_or_else(|| invalid_feed("brain card has no canonical ULID id"))?
5344        .to_string();
5345    if crate::ulid::is_ulid(brain) && resolved_brain != brain {
5346        return Err(invalid_feed(
5347            "brain card id differs from the explicitly requested brain id",
5348        ));
5349    }
5350    // The card supplies the canonical ULID. Lock alias + canonical keys in
5351    // deterministic filename order, then hold both through verify + save.
5352    // Concurrent aliases for the same brain therefore converge on one
5353    // checkpoint instead of establishing independent TOFU universes.
5354    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &resolved_brain])?;
5355    let (pinned, alias_binding) =
5356        load_canonical_pin(cfg, &trust_directory, brain, &resolved_brain)?;
5357    let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
5358    let advertised_hash = body
5359        .get("feedHash")
5360        .and_then(Value::as_str)
5361        .map(str::to_string);
5362    let updated_at = body
5363        .get("updatedAt")
5364        .and_then(Value::as_str)
5365        .map(str::to_string);
5366    if let Some(pin) = &pinned {
5367        if seq < pin.head_seq {
5368            return Err(invalid_feed(format!(
5369                "feed rollback: hub advertised sequence {seq}, local checkpoint is {}",
5370                pin.head_seq
5371            )));
5372        }
5373        if seq == pin.head_seq && advertised_hash != pin.feed_hash {
5374            return Err(invalid_feed(
5375                "feed equivocation: the checkpoint sequence now has a different hash",
5376            ));
5377        }
5378    }
5379    if seq == 0 {
5380        if advertised_hash.is_some() {
5381            return Err(invalid_feed("an empty feed advertised a head hash"));
5382        }
5383        let identity: FeedIdentity = serde_json::from_value(
5384            body.get("identity")
5385                .cloned()
5386                .ok_or_else(|| invalid_feed("empty brain card has no signed identity"))?,
5387        )
5388        .map_err(|_| invalid_feed("empty brain card has an invalid identity"))?;
5389        let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
5390        // A valid old-key-signed rotation is still inconsistent if it commits
5391        // to history that the same card now claims never existed. Run the
5392        // identical feed-boundary proof used by non-empty heads before this
5393        // identity can become a durable TOFU checkpoint.
5394        verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], seq)?;
5395        save_canonical_pin_and_alias(
5396            cfg,
5397            &trust_directory,
5398            brain,
5399            &resolved_brain,
5400            TrustState {
5401                v: 2,
5402                origin: normalized_origin(&cfg.hub)?,
5403                requested: resolved_brain.clone(),
5404                brain: resolved_brain.clone(),
5405                home: None,
5406                anchor: anchor.clone(),
5407                current: format!("ed25519:{}", identity.fingerprint),
5408                head_seq: 0,
5409                feed_hash: None,
5410                rotations: identity.rotations.clone(),
5411            },
5412            alias_binding.as_ref(),
5413        )?;
5414        return Ok(VerifiedRemote {
5415            head: Head {
5416                brain: resolved_brain,
5417                seq,
5418                updated_at,
5419                feed_hash: None,
5420                verified: true,
5421            },
5422            identity: Some(identity),
5423            head_entry: None,
5424            entries: Vec::new(),
5425            anchor: Some(anchor),
5426        });
5427    }
5428    if advertised_hash.as_ref().is_none_or(|hash| !is_sha256(hash)) {
5429        return Err(invalid_feed(
5430            "non-empty feed did not advertise a valid SHA-256 head",
5431        ));
5432    }
5433
5434    // On first contact the signed head itself is the TOFU checkpoint. A full
5435    // history replay cannot add authority before an anchor exists; mirrors
5436    // still request the complete chain because they promise an archival copy.
5437    let replay_head_only = !require_full_chain
5438        && pinned
5439            .as_ref()
5440            .is_none_or(|checkpoint| checkpoint.head_seq == seq);
5441    let mut after = if replay_head_only {
5442        seq - 1
5443    } else if require_full_chain || pinned.is_none() {
5444        0
5445    } else {
5446        pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq)
5447    };
5448    let mut expected_seq = after + 1;
5449    let mut previous_hash = if require_full_chain || pinned.is_none() || replay_head_only {
5450        None
5451    } else {
5452        pinned
5453            .as_ref()
5454            .and_then(|checkpoint| checkpoint.feed_hash.clone())
5455    };
5456    let mut identity: Option<FeedIdentity> = None;
5457    let mut anchor: Option<String> = None;
5458    let mut head_entry: Option<FeedItem> = None;
5459    let mut all_entries = Vec::new();
5460    let mut observed_entries = Vec::new();
5461    let replay_count = seq
5462        .checked_sub(after)
5463        .ok_or_else(|| invalid_feed("feed replay range moved backward"))?;
5464    if replay_count > MAX_FEED_REPLAY_ENTRIES {
5465        return Err(invalid_feed(format!(
5466            "feed replay requires {replay_count} entries, over the client cap"
5467        )));
5468    }
5469    let mut replay_bytes = 0u64;
5470
5471    loop {
5472        let feed_bytes = ensure_raw_ok(
5473            request_raw(
5474                cfg,
5475                "GET",
5476                &format!("/api/hub/brains/{brain}/feed?after={after}&limit=100"),
5477                None,
5478                Auth::Required,
5479                MAX_FEED_RESPONSE_BYTES,
5480            )?,
5481            "subscribe feed",
5482        )?;
5483        let feed: FeedResponse = serde_json::from_slice(&feed_bytes)
5484            .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
5485        if feed.head_seq != seq || feed.feed_hash != advertised_hash {
5486            return Err(invalid_feed("brain card and feed head disagree"));
5487        }
5488        if feed.entries.len() > FEED_PAGE_LIMIT {
5489            return Err(invalid_feed("feed page exceeds the requested entry limit"));
5490        }
5491        if feed.scope_limited {
5492            if require_full_chain {
5493                return Err(invalid_feed(
5494                    "path-scoped grants cannot verify a full snapshot chain",
5495                ));
5496            }
5497            return Ok(VerifiedRemote {
5498                head: Head {
5499                    brain: resolved_brain,
5500                    seq,
5501                    updated_at,
5502                    feed_hash: advertised_hash,
5503                    verified: false,
5504                },
5505                identity: None,
5506                head_entry: None,
5507                entries: Vec::new(),
5508                anchor: None,
5509            });
5510        }
5511        let page_identity = feed
5512            .identity
5513            .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
5514        let page_anchor = verify_identity_chain(&page_identity, pinned.as_ref())?;
5515        if identity
5516            .as_ref()
5517            .is_some_and(|existing| existing != &page_identity)
5518        {
5519            return Err(invalid_feed("identity changed while reading the feed"));
5520        }
5521        if anchor
5522            .as_ref()
5523            .is_some_and(|existing| existing != &page_anchor)
5524        {
5525            return Err(invalid_feed(
5526                "identity anchor changed while reading the feed",
5527            ));
5528        }
5529        identity = Some(page_identity.clone());
5530        if anchor.is_none() {
5531            anchor = Some(page_anchor);
5532        }
5533        if feed.entries.is_empty() {
5534            return Err(invalid_feed("feed page was empty before the signed head"));
5535        }
5536
5537        for item in feed.entries {
5538            if item.entry.seq != expected_seq {
5539                return Err(invalid_feed(format!(
5540                    "expected entry {expected_seq}, feed served {}",
5541                    item.entry.seq
5542                )));
5543            }
5544            if item.entry.seq > seq {
5545                return Err(invalid_feed("feed advanced past the card snapshot"));
5546            }
5547            if !replay_head_only && item.entry.prev_entry_hash != previous_hash {
5548                return Err(invalid_feed(format!(
5549                    "entry {} does not chain to the local checkpoint",
5550                    item.entry.seq
5551                )));
5552            }
5553            verify_feed_item(&item, &page_identity)?;
5554            reject_retired_signer_after_checkpoint(&page_identity, pinned.as_ref(), &item)?;
5555            replay_bytes = replay_bytes.saturating_add(
5556                serde_json::to_vec(&item)
5557                    .map_err(|_| invalid_feed("could not size feed entry"))?
5558                    .len() as u64,
5559            );
5560            if replay_bytes > MAX_FEED_REPLAY_BYTES {
5561                return Err(invalid_feed("feed replay metadata exceeds the client cap"));
5562            }
5563            previous_hash = Some(item.hash.clone());
5564            after = item.entry.seq;
5565            expected_seq = expected_seq
5566                .checked_add(1)
5567                .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
5568            if require_full_chain {
5569                all_entries.push(item.clone());
5570            }
5571            observed_entries.push(item.clone());
5572            head_entry = Some(item);
5573        }
5574        if after == seq {
5575            break;
5576        }
5577    }
5578
5579    if head_entry.as_ref().map(|item| &item.hash) != advertised_hash.as_ref() {
5580        return Err(invalid_feed(
5581            "verified chain does not converge on the advertised head",
5582        ));
5583    }
5584    let identity = identity.ok_or_else(|| invalid_feed("feed has no brain identity"))?;
5585    let anchor = anchor.ok_or_else(|| invalid_feed("feed has no identity anchor"))?;
5586    verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &observed_entries, seq)?;
5587    save_canonical_pin_and_alias(
5588        cfg,
5589        &trust_directory,
5590        brain,
5591        &resolved_brain,
5592        TrustState {
5593            v: 2,
5594            origin: normalized_origin(&cfg.hub)?,
5595            requested: resolved_brain.clone(),
5596            brain: resolved_brain.clone(),
5597            home: None,
5598            anchor: anchor.clone(),
5599            current: format!("ed25519:{}", identity.fingerprint),
5600            head_seq: seq,
5601            feed_hash: advertised_hash.clone(),
5602            rotations: identity.rotations.clone(),
5603        },
5604        alias_binding.as_ref(),
5605    )?;
5606    Ok(VerifiedRemote {
5607        head: Head {
5608            brain: resolved_brain,
5609            seq,
5610            updated_at,
5611            feed_hash: advertised_hash,
5612            verified: true,
5613        },
5614        identity: Some(identity),
5615        head_entry,
5616        entries: all_entries,
5617        anchor: Some(anchor),
5618    })
5619}
5620
5621/// Read and locally verify the brain's current signed feed head. Identity
5622/// rotation is accepted only through an old-key-signed chain rooted at the
5623/// local TOFU anchor; sequence and hash checkpoints reject rollback and
5624/// equivocation across invocations.
5625pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
5626    Ok(verified_remote_head(cfg, brain, false)?.head)
5627}
5628
5629#[cfg(test)]
5630mod tests {
5631    use super::*;
5632
5633    const TEST_BRAIN_ID: &str = "01j5qc3v9k4ym8rwbn2tqe6f7d";
5634
5635    #[cfg(target_os = "linux")]
5636    #[test]
5637    fn linux_snapshot_install_is_atomic_for_create_and_exchange() {
5638        use std::os::fd::AsRawFd as _;
5639
5640        let sandbox = tempfile::TempDir::new().unwrap();
5641        let parent = std::fs::File::open(sandbox.path()).unwrap();
5642        let stage = std::ffi::CString::new("stage").unwrap();
5643        let destination = std::ffi::CString::new("brain").unwrap();
5644
5645        std::fs::create_dir(sandbox.path().join("stage")).unwrap();
5646        std::fs::write(sandbox.path().join("stage/value"), b"created").unwrap();
5647        install_stage_at(
5648            parent.as_raw_fd(),
5649            stage.as_c_str(),
5650            destination.as_c_str(),
5651            false,
5652        )
5653        .unwrap();
5654        assert!(!sandbox.path().join("stage").exists());
5655        assert_eq!(
5656            std::fs::read(sandbox.path().join("brain/value")).unwrap(),
5657            b"created"
5658        );
5659
5660        std::fs::create_dir(sandbox.path().join("stage")).unwrap();
5661        std::fs::write(sandbox.path().join("stage/value"), b"replacement").unwrap();
5662        install_stage_at(
5663            parent.as_raw_fd(),
5664            stage.as_c_str(),
5665            destination.as_c_str(),
5666            true,
5667        )
5668        .unwrap();
5669        assert_eq!(
5670            std::fs::read(sandbox.path().join("brain/value")).unwrap(),
5671            b"replacement"
5672        );
5673        assert_eq!(
5674            std::fs::read(sandbox.path().join("stage/value")).unwrap(),
5675            b"created",
5676            "RENAME_EXCHANGE must leave the predecessor at the unique stage name"
5677        );
5678    }
5679
5680    struct SignedRemoteFixture {
5681        card: String,
5682        feed: String,
5683        key: AgentSigningKey,
5684        identity: FeedIdentity,
5685    }
5686
5687    fn signed_remote_fixture() -> SignedRemoteFixture {
5688        let rng = ring::rand::SystemRandom::new();
5689        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
5690        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
5691        let (public_key, multikey) = public_identity_for(&pair);
5692        let identity = FeedIdentity {
5693            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
5694            public_key_spki: public_key.clone(),
5695            previous: Vec::new(),
5696            rotations: Vec::new(),
5697        };
5698        let mut entry = FeedEntry {
5699            v: 1,
5700            seq: 1,
5701            ts: "2026-07-30T12:00:00.000Z".to_string(),
5702            brain: multikey.clone(),
5703            public_key: public_key.clone(),
5704            kind: "push".to_string(),
5705            op: "snapshot".to_string(),
5706            pack_sha256: "a".repeat(64),
5707            files: Vec::new(),
5708            removed: Vec::new(),
5709            prev_entry_hash: None,
5710            sig: String::new(),
5711        };
5712        let unsigned = UnsignedFeedEntry {
5713            v: entry.v,
5714            seq: entry.seq,
5715            ts: &entry.ts,
5716            brain: &entry.brain,
5717            public_key: &entry.public_key,
5718            kind: &entry.kind,
5719            op: &entry.op,
5720            pack_sha256: &entry.pack_sha256,
5721            files: &entry.files,
5722            removed: &entry.removed,
5723            prev_entry_hash: &entry.prev_entry_hash,
5724        };
5725        entry.sig =
5726            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
5727        let mut exact = serde_json::to_vec(&entry).unwrap();
5728        exact.push(b'\n');
5729        let hash = content_sha256(&exact);
5730        let card = json!({
5731            "id": TEST_BRAIN_ID,
5732            "headSeq": 1,
5733            "feedHash": hash,
5734            "identity": identity.clone(),
5735        })
5736        .to_string();
5737        let feed = json!({
5738            "headSeq": 1,
5739            "feedHash": hash,
5740            "identity": identity.clone(),
5741            "entries": [{"hash": hash, "entry": entry}],
5742            "scopeLimited": false,
5743        })
5744        .to_string();
5745        SignedRemoteFixture {
5746            card,
5747            feed,
5748            key: AgentSigningKey {
5749                pkcs8: pkcs8.as_ref().to_vec(),
5750                multikey,
5751                public_key_spki: public_key,
5752            },
5753            identity,
5754        }
5755    }
5756
5757    #[test]
5758    fn wire_sequences_are_rejected_at_their_endpoint_count_limits() {
5759        let fixture = signed_remote_fixture();
5760        let feed: Value = serde_json::from_str(&fixture.feed).unwrap();
5761        let item = feed["entries"][0].to_string();
5762        let oversized_page = format!(
5763            "{{\"headSeq\":1,\"feedHash\":null,\"identity\":null,\"entries\":[{}],\"scopeLimited\":false}}",
5764            std::iter::repeat_n(item.as_str(), FEED_PAGE_LIMIT + 1)
5765                .collect::<Vec<_>>()
5766                .join(",")
5767        );
5768        assert!(serde_json::from_str::<FeedResponse>(&oversized_page).is_err());
5769
5770        let oversized_identity = format!(
5771            "{{\"fingerprint\":\"fp\",\"publicKeySpki\":\"spki\",\"previous\":[],\"rotations\":[{}]}}",
5772            std::iter::repeat_n("\"rotation\"", MAX_IDENTITY_ROTATIONS + 1)
5773                .collect::<Vec<_>>()
5774                .join(",")
5775        );
5776        assert!(serde_json::from_str::<FeedIdentity>(&oversized_identity).is_err());
5777
5778        let file = r#"{"path":"a","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bytes":1}"#;
5779        let oversized_entry = format!(
5780            "{{\"v\":1,\"seq\":1,\"ts\":\"t\",\"brain\":\"b\",\"public_key\":\"k\",\"kind\":\"push\",\"op\":\"snapshot\",\"pack_sha256\":\"{}\",\"files\":[{}],\"removed\":[],\"prev_entry_hash\":null,\"sig\":\"s\"}}",
5781            "a".repeat(64),
5782            std::iter::repeat_n(file, MAX_PUSH_FILES + 1)
5783                .collect::<Vec<_>>()
5784                .join(",")
5785        );
5786        assert!(serde_json::from_str::<FeedEntry>(&oversized_entry).is_err());
5787    }
5788
5789    fn scripted_json_hub(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle<()>) {
5790        use std::io::{BufRead as _, BufReader, Read as _, Write as _};
5791        use std::net::TcpListener;
5792
5793        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
5794        let url = format!("http://{}", listener.local_addr().unwrap());
5795        let handle = std::thread::spawn(move || {
5796            for (status, body) in responses {
5797                let (stream, _) = listener.accept().unwrap();
5798                let mut reader = BufReader::new(stream);
5799                let mut line = String::new();
5800                reader.read_line(&mut line).unwrap();
5801                let mut content_length = 0usize;
5802                loop {
5803                    line.clear();
5804                    reader.read_line(&mut line).unwrap();
5805                    if line == "\r\n" || line == "\n" || line.is_empty() {
5806                        break;
5807                    }
5808                    if let Some((name, value)) = line.split_once(':') {
5809                        if name.eq_ignore_ascii_case("content-length") {
5810                            content_length = value.trim().parse().unwrap();
5811                        }
5812                    }
5813                }
5814                let mut request_body = vec![0_u8; content_length];
5815                reader.read_exact(&mut request_body).unwrap();
5816                let response = format!(
5817                    "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
5818                    body.len()
5819                );
5820                reader.get_mut().write_all(response.as_bytes()).unwrap();
5821            }
5822        });
5823        (url, handle)
5824    }
5825
5826    fn routed_json_hub(
5827        requests: usize,
5828        mut respond: impl FnMut(&str) -> (u16, String) + Send + 'static,
5829    ) -> (String, std::thread::JoinHandle<()>) {
5830        use std::io::{BufRead as _, BufReader, Read as _, Write as _};
5831        use std::net::TcpListener;
5832
5833        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
5834        let url = format!("http://{}", listener.local_addr().unwrap());
5835        let handle = std::thread::spawn(move || {
5836            for _ in 0..requests {
5837                let (stream, _) = listener.accept().unwrap();
5838                let mut reader = BufReader::new(stream);
5839                let mut line = String::new();
5840                reader.read_line(&mut line).unwrap();
5841                let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
5842                let mut content_length = 0usize;
5843                loop {
5844                    line.clear();
5845                    reader.read_line(&mut line).unwrap();
5846                    if line == "\r\n" || line == "\n" || line.is_empty() {
5847                        break;
5848                    }
5849                    if let Some((name, value)) = line.split_once(':') {
5850                        if name.eq_ignore_ascii_case("content-length") {
5851                            content_length = value.trim().parse().unwrap();
5852                        }
5853                    }
5854                }
5855                let mut request_body = vec![0_u8; content_length];
5856                reader.read_exact(&mut request_body).unwrap();
5857                let (status, body) = respond(&path);
5858                let response = format!(
5859                    "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
5860                    body.len()
5861                );
5862                reader.get_mut().write_all(response.as_bytes()).unwrap();
5863            }
5864        });
5865        (url, handle)
5866    }
5867
5868    fn test_hub_config(hub: String, state_dir: PathBuf) -> HubConfig {
5869        HubConfig {
5870            hub,
5871            key: Some("test-key".to_string()),
5872            agent_key: None,
5873            brain_key: None,
5874            state_dir,
5875            store_selected: false,
5876        }
5877    }
5878
5879    #[test]
5880    fn linkmd_sig_v2_is_bound_to_the_exact_hub_origin() {
5881        use ring::signature::KeyPair as _;
5882
5883        let rng = ring::rand::SystemRandom::new();
5884        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
5885        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
5886        let (spki, multikey) = public_identity_for(&pair);
5887        let key = AgentSigningKey {
5888            pkcs8: pkcs8.as_ref().to_vec(),
5889            multikey,
5890            public_key_spki: spki,
5891        };
5892        let header = linkmd_sig_header(
5893            &key,
5894            "https://hub-a.example",
5895            "post",
5896            "/api/hub/brains/brain/push?mode=exact",
5897            Some("{\"ok\":true}"),
5898        )
5899        .unwrap();
5900        assert!(header.starts_with("LinkMD-Sig v2,"));
5901        let ts = header
5902            .split(",ts=")
5903            .nth(1)
5904            .unwrap()
5905            .split(',')
5906            .next()
5907            .unwrap();
5908        let signature = URL_SAFE_NO_PAD
5909            .decode(header.rsplit(",sig=").next().unwrap())
5910            .unwrap();
5911        let body_hash = format!("{:x}", Sha256::digest(b"{\"ok\":true}"));
5912        let accepted = format!(
5913            "v2\nhttps://hub-a.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
5914        );
5915        let replayed = format!(
5916            "v2\nhttps://hub-b.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
5917        );
5918        let public = pair.public_key().as_ref();
5919        assert!(UnparsedPublicKey::new(&ED25519, public)
5920            .verify(accepted.as_bytes(), &signature)
5921            .is_ok());
5922        assert!(
5923            UnparsedPublicKey::new(&ED25519, public)
5924                .verify(replayed.as_bytes(), &signature)
5925                .is_err(),
5926            "a proof captured at hub A must not authenticate at hub B"
5927        );
5928    }
5929
5930    #[test]
5931    fn explicit_brain_id_rejects_a_substituted_card_before_feed_trust() {
5932        let other = "01j5qc3v9k4ym8rwbn2tqe6f7e";
5933        let card = json!({
5934            "id": other,
5935            "headSeq": 0,
5936            "identity": signed_remote_fixture().identity,
5937        })
5938        .to_string();
5939        let (hub, server) = scripted_json_hub(vec![(200, card)]);
5940        let state = tempfile::tempdir().unwrap();
5941        let cfg = test_hub_config(hub, state.path().to_path_buf());
5942        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
5943        assert!(
5944            error.contains("differs from the explicitly requested"),
5945            "{error}"
5946        );
5947        server.join().unwrap();
5948    }
5949
5950    #[test]
5951    fn empty_brain_identity_is_pinned_and_cannot_be_substituted_later() {
5952        let first = signed_remote_fixture().identity;
5953        let second = signed_remote_fixture().identity;
5954        let card = |identity: FeedIdentity| {
5955            json!({
5956                "id": TEST_BRAIN_ID,
5957                "headSeq": 0,
5958                "identity": identity,
5959            })
5960            .to_string()
5961        };
5962        let (hub, server) = scripted_json_hub(vec![(200, card(first)), (200, card(second))]);
5963        let state = tempfile::tempdir().unwrap();
5964        let cfg = test_hub_config(hub, state.path().to_path_buf());
5965        assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
5966        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
5967        assert!(
5968            error.contains("pinned anchor") || error.contains("forked away"),
5969            "{error}"
5970        );
5971        server.join().unwrap();
5972    }
5973
5974    #[test]
5975    fn empty_head_rejects_a_rotation_that_commits_to_hidden_history() {
5976        let old = signed_remote_fixture();
5977        let new = signed_remote_fixture();
5978        let old_pair = ring::signature::Ed25519KeyPair::from_pkcs8(&old.key.pkcs8).unwrap();
5979        let unsigned = serde_json::to_string(&UnsignedRotation {
5980            v: 1,
5981            op: "rotate",
5982            brain: &old.key.multikey,
5983            public_key: &old.key.public_key_spki,
5984            new_brain: &new.key.multikey,
5985            new_public_key: &new.key.public_key_spki,
5986            prior_head_seq: 1,
5987            prior_feed_hash: Some(&"a".repeat(64)),
5988            ts: "2026-07-30T12:00:00.000Z".to_string(),
5989        })
5990        .unwrap();
5991        let signature = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
5992        let rotation = format!(
5993            "{},\"sig\":\"{}\"}}",
5994            &unsigned[..unsigned.len() - 1],
5995            signature
5996        );
5997        let identity = FeedIdentity {
5998            fingerprint: new.key.multikey.trim_start_matches("ed25519:").to_string(),
5999            public_key_spki: new.key.public_key_spki,
6000            previous: vec![PreviousIdentity {
6001                fingerprint: old.key.multikey.trim_start_matches("ed25519:").to_string(),
6002                public_key_spki: old.key.public_key_spki,
6003            }],
6004            rotations: vec![rotation],
6005        };
6006        let card = json!({
6007            "id": TEST_BRAIN_ID,
6008            "headSeq": 0,
6009            "feedHash": null,
6010            "identity": identity,
6011        })
6012        .to_string();
6013        let (hub, server) = scripted_json_hub(vec![(200, card)]);
6014        let state = tempfile::tempdir().unwrap();
6015        let cfg = test_hub_config(hub, state.path().to_path_buf());
6016        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
6017        assert!(
6018            error.contains("rotation claims a feed boundary beyond the advertised head"),
6019            "{error}"
6020        );
6021        assert!(
6022            load_trust(&cfg, TEST_BRAIN_ID).unwrap().is_none(),
6023            "an inconsistent empty-head identity must not become the TOFU checkpoint"
6024        );
6025        server.join().unwrap();
6026    }
6027
6028    #[test]
6029    fn trust_checkpoint_rejects_a_later_fork() {
6030        let fixture = signed_remote_fixture();
6031        let mut fork: Value = serde_json::from_str(&fixture.card).unwrap();
6032        fork["feedHash"] = Value::String("b".repeat(64));
6033        let (hub, server) = scripted_json_hub(vec![
6034            (200, fixture.card),
6035            (200, fixture.feed),
6036            (200, fork.to_string()),
6037        ]);
6038        let state = tempfile::tempdir().unwrap();
6039        let cfg = test_hub_config(hub, state.path().to_path_buf());
6040        assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
6041        assert!(head(&cfg, TEST_BRAIN_ID).is_err());
6042        server.join().unwrap();
6043    }
6044
6045    #[test]
6046    fn alias_and_canonical_id_share_one_identity_checkpoint() {
6047        let trusted = signed_remote_fixture();
6048        let attacker = signed_remote_fixture();
6049        let (hub, server) = scripted_json_hub(vec![
6050            (200, trusted.card),
6051            (200, trusted.feed),
6052            (200, attacker.card),
6053        ]);
6054        let state = tempfile::tempdir().unwrap();
6055        let cfg = test_hub_config(hub, state.path().to_path_buf());
6056        assert!(head(&cfg, "trusted-slug").unwrap().verified);
6057        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
6058        assert!(
6059            error.contains("equivocation")
6060                || error.contains("pinned")
6061                || error.contains("identity"),
6062            "{error}"
6063        );
6064        server.join().unwrap();
6065    }
6066
6067    #[test]
6068    fn concurrent_aliases_cannot_establish_conflicting_tofu_pins() {
6069        let alpha = signed_remote_fixture();
6070        let beta = signed_remote_fixture();
6071        let alpha_card = alpha.card.clone();
6072        let alpha_feed = alpha.feed.clone();
6073        let beta_card = beta.card.clone();
6074        let beta_feed = beta.feed.clone();
6075        let (hub, server) = routed_json_hub(3, move |path| {
6076            if path.contains("/alpha/feed?") {
6077                (200, alpha_feed.clone())
6078            } else if path.contains("/beta/feed?") {
6079                (200, beta_feed.clone())
6080            } else if path.ends_with("/alpha") {
6081                (200, alpha_card.clone())
6082            } else if path.ends_with("/beta") {
6083                (200, beta_card.clone())
6084            } else {
6085                (500, r#"{"error":"unexpected path"}"#.to_string())
6086            }
6087        });
6088        let state = tempfile::tempdir().unwrap();
6089        let cfg = test_hub_config(hub, state.path().to_path_buf());
6090        let alpha_cfg = cfg.clone();
6091        let beta_cfg = cfg;
6092        let first = std::thread::spawn(move || head(&alpha_cfg, "alpha"));
6093        let second = std::thread::spawn(move || head(&beta_cfg, "beta"));
6094        let results = [first.join().unwrap(), second.join().unwrap()];
6095        assert_eq!(
6096            results.iter().filter(|result| result.is_ok()).count(),
6097            1,
6098            "only one alias identity may establish canonical TOFU: {results:?}"
6099        );
6100        assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
6101        server.join().unwrap();
6102    }
6103
6104    #[cfg(unix)]
6105    #[test]
6106    fn trust_transaction_survives_an_ancestor_swap_without_writing_outside() {
6107        use std::os::unix::fs::symlink;
6108
6109        let fixture = signed_remote_fixture();
6110        let card = json!({
6111            "id": TEST_BRAIN_ID,
6112            "headSeq": 0,
6113            "feedHash": Value::Null,
6114            "identity": fixture.identity,
6115        })
6116        .to_string();
6117        let work = tempfile::tempdir().unwrap();
6118        let outside = tempfile::tempdir().unwrap();
6119        let state = work.path().join("state");
6120        let moved = work.path().join("state-held");
6121        let swap_state = state.clone();
6122        let swap_moved = moved.clone();
6123        let outside_path = outside.path().to_path_buf();
6124        let (hub, server) = routed_json_hub(1, move |_| {
6125            // The client has already opened state/trust before this response.
6126            std::fs::rename(&swap_state, &swap_moved).unwrap();
6127            symlink(&outside_path, &swap_state).unwrap();
6128            (200, card.clone())
6129        });
6130        let cfg = test_hub_config(hub, state);
6131
6132        let verified = verified_remote_head(&cfg, TEST_BRAIN_ID, false).unwrap();
6133        assert_eq!(verified.head.seq, 0);
6134        assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
6135        assert!(std::fs::read_dir(moved.join("trust"))
6136            .unwrap()
6137            .flatten()
6138            .any(|entry| entry.path().extension().is_some_and(|ext| ext == "json")));
6139        server.join().unwrap();
6140    }
6141
6142    #[test]
6143    fn self_custody_push_refuses_an_unrelated_key_before_requesting_an_upload() {
6144        let remote = signed_remote_fixture();
6145        let unrelated = signed_remote_fixture().key;
6146        let (hub, server) = scripted_json_hub(vec![(200, remote.card), (200, remote.feed)]);
6147        let state = tempfile::tempdir().unwrap();
6148        let mut cfg = test_hub_config(hub, state.path().to_path_buf());
6149        cfg.brain_key = Some(unrelated);
6150        let error = sync_push(
6151            &cfg,
6152            TEST_BRAIN_ID,
6153            &[("DB.md".to_string(), "signed local content".to_string())],
6154        )
6155        .unwrap_err()
6156        .to_string();
6157        assert!(
6158            error.contains("not the verified current brain identity"),
6159            "{error}"
6160        );
6161        server.join().unwrap();
6162    }
6163
6164    #[test]
6165    fn rotation_ignores_a_forged_2xx_body_and_requires_verified_postcondition() {
6166        let remote = signed_remote_fixture();
6167        let new = signed_remote_fixture().key;
6168        let state = tempfile::tempdir().unwrap();
6169        let new_file = state.path().join("new.key");
6170        std::fs::write(
6171            &new_file,
6172            format!("{}\n", URL_SAFE_NO_PAD.encode(&new.pkcs8)),
6173        )
6174        .unwrap();
6175        #[cfg(unix)]
6176        {
6177            use std::os::unix::fs::PermissionsExt as _;
6178            std::fs::set_permissions(&new_file, std::fs::Permissions::from_mode(0o600)).unwrap();
6179        }
6180        let forged = json!({
6181            "brain": TEST_BRAIN_ID,
6182            "identity": {
6183                "fingerprint": new.multikey.trim_start_matches("ed25519:"),
6184                "publicKeySpki": new.public_key_spki,
6185            }
6186        })
6187        .to_string();
6188        let (hub, server) = scripted_json_hub(vec![
6189            (200, remote.card.clone()),
6190            (200, remote.feed.clone()),
6191            (200, forged),
6192            (200, remote.card),
6193            (200, remote.feed),
6194        ]);
6195        let cfg = test_hub_config(hub, state.path().to_path_buf());
6196        let error = rotate_brain_key(&cfg, TEST_BRAIN_ID, &remote.key, &new_file)
6197            .unwrap_err()
6198            .to_string();
6199        assert!(
6200            error.contains("without committing the verified new identity"),
6201            "{error}"
6202        );
6203        server.join().unwrap();
6204    }
6205
6206    #[test]
6207    fn resolve_record_is_derived_from_signed_pack_not_a_query_response() {
6208        let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
6209        let raw = format!(
6210            "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
6211        );
6212        let pack = build_store_pack(&[
6213            (
6214                "DB.md".to_string(),
6215                "---\ntype: db-md\nscope: company\nowner: Test\n---\n".to_string(),
6216            ),
6217            ("records/clients/truth.md".to_string(), raw.clone()),
6218        ])
6219        .unwrap();
6220        let by_id = resolve_from_verified_pack(
6221            "01j5qc3v9k4ym8rwbn2tqe6f7d",
6222            &AddressTarget::Id(record_id.to_string()),
6223            pack.clone(),
6224        )
6225        .unwrap();
6226        assert_eq!(by_id["document"]["summary"], "Signed truth");
6227        assert_eq!(by_id["document"]["body"], "# Signed truth\n");
6228        assert_eq!(
6229            by_id["document"]["contentSha"],
6230            content_sha256(raw.as_bytes())
6231        );
6232
6233        let by_path = resolve_from_verified_pack(
6234            "01j5qc3v9k4ym8rwbn2tqe6f7d",
6235            &AddressTarget::Path("records/clients/truth.md".to_string()),
6236            pack,
6237        )
6238        .unwrap();
6239        assert_eq!(by_path["document"]["id"], record_id);
6240        assert_eq!(by_path["document"]["path"], "records/clients/truth.md");
6241    }
6242
6243    #[test]
6244    fn canonical_store_pack_matches_the_cross_language_zip32_golden() {
6245        let unsorted = vec![
6246            ("records/a.md".to_string(), "alpha\n".to_string()),
6247            ("DB.md".to_string(), "# db\n".to_string()),
6248        ];
6249        let sorted = vec![
6250            ("DB.md".to_string(), "# db\n".to_string()),
6251            ("records/a.md".to_string(), "alpha\n".to_string()),
6252        ];
6253        let pack = build_store_pack(&unsorted).unwrap();
6254
6255        // This digest is shared with the hub implementation. It locks every
6256        // byte of the wire profile: raw UTF-8 order, STORED payloads, fixed DOS
6257        // epoch, explicit CRC/sizes, Unix regular-0600 attributes, and the
6258        // absence of descriptors/extras/comments/ZIP64.
6259        assert_eq!(pack.len(), 219);
6260        assert_eq!(
6261            content_sha256(&pack),
6262            "972fb2045becaa21588baaf4b349e62a430687fa2c21167b53f4ca0efa6c9408"
6263        );
6264        assert_eq!(pack, build_store_pack(&sorted).unwrap());
6265        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x07\x08"));
6266        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x06"));
6267        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x07"));
6268
6269        assert_eq!(
6270            parse_store_pack(pack).unwrap(),
6271            vec![
6272                ("DB.md".to_string(), b"# db\n".to_vec()),
6273                ("records/a.md".to_string(), b"alpha\n".to_vec()),
6274            ]
6275        );
6276    }
6277
6278    #[test]
6279    fn canonical_store_pack_validates_every_path_before_writing() {
6280        let duplicate = vec![
6281            ("DB.md".to_string(), "first".to_string()),
6282            ("DB.md".to_string(), "second".to_string()),
6283        ];
6284        assert!(build_store_pack(&duplicate)
6285            .unwrap_err()
6286            .to_string()
6287            .contains("duplicate path"));
6288        assert!(matches!(
6289            build_store_pack(&[("../escape.md".to_string(), "no".to_string())]),
6290            Err(LinkError::UnsafePath { .. })
6291        ));
6292    }
6293
6294    #[test]
6295    fn zip64_preflight_rejects_an_entry_count_bomb_before_zip_parsing() {
6296        const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
6297        // One byte stands in for the central directory. The trailer itself is
6298        // structurally valid; the count is the sole reason for refusal.
6299        let mut bytes = vec![0_u8];
6300        let zip64_offset = bytes.len() as u64;
6301        bytes.extend_from_slice(b"PK\x06\x06");
6302        bytes.extend_from_slice(&44_u64.to_le_bytes());
6303        bytes.extend_from_slice(&[0_u8; 12]);
6304        bytes.extend_from_slice(&COUNT.to_le_bytes());
6305        bytes.extend_from_slice(&COUNT.to_le_bytes());
6306        bytes.extend_from_slice(&1_u64.to_le_bytes());
6307        bytes.extend_from_slice(&0_u64.to_le_bytes());
6308        bytes.extend_from_slice(b"PK\x06\x07");
6309        bytes.extend_from_slice(&0_u32.to_le_bytes());
6310        bytes.extend_from_slice(&zip64_offset.to_le_bytes());
6311        bytes.extend_from_slice(&1_u32.to_le_bytes());
6312        bytes.extend_from_slice(b"PK\x05\x06");
6313        bytes.extend_from_slice(&0_u16.to_le_bytes());
6314        bytes.extend_from_slice(&0_u16.to_le_bytes());
6315        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
6316        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
6317        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
6318        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
6319        bytes.extend_from_slice(&0_u16.to_le_bytes());
6320
6321        let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
6322            .unwrap_err()
6323            .to_string();
6324        assert!(error.contains("invalid file count"), "{error}");
6325    }
6326
6327    #[test]
6328    fn zip_preflight_rejects_a_fake_trailing_eocd_before_zip_parsing() {
6329        const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
6330        let mut bytes = vec![0_u8];
6331        let zip64_offset = bytes.len() as u64;
6332        bytes.extend_from_slice(b"PK\x06\x06");
6333        bytes.extend_from_slice(&44_u64.to_le_bytes());
6334        bytes.extend_from_slice(&[0_u8; 12]);
6335        bytes.extend_from_slice(&COUNT.to_le_bytes());
6336        bytes.extend_from_slice(&COUNT.to_le_bytes());
6337        bytes.extend_from_slice(&1_u64.to_le_bytes());
6338        bytes.extend_from_slice(&0_u64.to_le_bytes());
6339        bytes.extend_from_slice(b"PK\x06\x07");
6340        bytes.extend_from_slice(&0_u32.to_le_bytes());
6341        bytes.extend_from_slice(&zip64_offset.to_le_bytes());
6342        bytes.extend_from_slice(&1_u32.to_le_bytes());
6343        bytes.extend_from_slice(b"PK\x05\x06");
6344        bytes.extend_from_slice(&0_u16.to_le_bytes());
6345        bytes.extend_from_slice(&0_u16.to_le_bytes());
6346        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
6347        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
6348        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
6349        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
6350        bytes.extend_from_slice(&0_u16.to_le_bytes());
6351        // The old parser trusted this last low-count signature, while
6352        // ZipArchive fell back to the real Zip64 directory and allocated for
6353        // COUNT entries. Its central-directory offsets are deliberately fake.
6354        let fake_eocd = bytes.len() as u32;
6355        bytes.extend_from_slice(b"PK\x05\x06");
6356        bytes.extend_from_slice(&0_u16.to_le_bytes());
6357        bytes.extend_from_slice(&0_u16.to_le_bytes());
6358        bytes.extend_from_slice(&1_u16.to_le_bytes());
6359        bytes.extend_from_slice(&1_u16.to_le_bytes());
6360        bytes.extend_from_slice(&0_u32.to_le_bytes());
6361        bytes.extend_from_slice(&fake_eocd.to_le_bytes());
6362        bytes.extend_from_slice(&0_u16.to_le_bytes());
6363
6364        let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
6365            .unwrap_err()
6366            .to_string();
6367        assert!(error.contains("central directory"), "{error}");
6368    }
6369
6370    #[test]
6371    fn strict_http_status_handling_rejects_redirects_without_panicking() {
6372        let error = ensure_ok(
6373            HubResponse {
6374                status: 302,
6375                body: Some(json!({"redirect": "/elsewhere"})),
6376            },
6377            "mutation",
6378        )
6379        .unwrap_err();
6380        assert!(matches!(error, LinkError::Http { status: 302, .. }));
6381
6382        let error = ensure_raw_ok(
6383            RawHubResponse {
6384                status: 302,
6385                body: br#"{"redirect":"/elsewhere"}"#.to_vec(),
6386            },
6387            "feed",
6388        )
6389        .unwrap_err();
6390        assert!(matches!(error, LinkError::Http { status: 302, .. }));
6391    }
6392
6393    #[cfg(unix)]
6394    #[test]
6395    fn collect_push_files_refuses_external_symlink_and_nested_store() {
6396        use std::os::unix::fs::symlink;
6397
6398        let root = tempfile::tempdir().unwrap();
6399        std::fs::write(
6400            root.path().join("DB.md"),
6401            "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
6402        )
6403        .unwrap();
6404        std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
6405
6406        let external = tempfile::tempdir().unwrap();
6407        let secret = external.path().join("secret.md");
6408        std::fs::write(&secret, "TOP SECRET").unwrap();
6409        symlink(&secret, root.path().join("records/notes/secret.md")).unwrap();
6410
6411        let store = Store::open_strict(root.path()).unwrap();
6412        let err = collect_push_files(&store).unwrap_err().to_string();
6413        assert!(err.contains("cannot push"), "{err}");
6414        assert!(
6415            !err.contains("TOP SECRET"),
6416            "external bytes must never leak"
6417        );
6418
6419        std::fs::remove_file(root.path().join("records/notes/secret.md")).unwrap();
6420        let nested = root.path().join("records/nested");
6421        std::fs::create_dir_all(&nested).unwrap();
6422        std::fs::write(
6423            nested.join("DB.md"),
6424            "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
6425        )
6426        .unwrap();
6427        let err = collect_push_files(&store).unwrap_err().to_string();
6428        assert!(err.contains("nested db.md store"), "{err}");
6429    }
6430
6431    #[cfg(unix)]
6432    #[test]
6433    fn remote_push_uses_opened_root_after_path_replacement() {
6434        use std::os::unix::fs::symlink;
6435
6436        let sandbox = tempfile::tempdir().unwrap();
6437        let root = sandbox.path().join("store");
6438        std::fs::create_dir_all(root.join("records/notes")).unwrap();
6439        std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
6440        std::fs::write(
6441            root.join("records/notes/owned.md"),
6442            "---\ntype: note\nsummary: owned\n---\nowned upload\n",
6443        )
6444        .unwrap();
6445        let store = Store::open_strict(&root).unwrap();
6446        let detached = sandbox.path().join("detached");
6447        std::fs::rename(&root, &detached).unwrap();
6448
6449        let replacement = sandbox.path().join("replacement");
6450        std::fs::create_dir_all(replacement.join("records/notes")).unwrap();
6451        std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
6452        std::fs::write(
6453            replacement.join("records/notes/secret.md"),
6454            "---\ntype: note\nsummary: secret\n---\nreplacement sentinel\n",
6455        )
6456        .unwrap();
6457        symlink(&replacement, &root).unwrap();
6458
6459        let files = collect_push_files(&store).unwrap();
6460        let wire_text = files
6461            .iter()
6462            .map(|(path, content)| format!("{path}\n{content}"))
6463            .collect::<Vec<_>>()
6464            .join("\n");
6465        assert!(wire_text.contains("owned upload"));
6466        assert!(!wire_text.contains("replacement sentinel"));
6467        assert!(!wire_text.contains("records/notes/secret.md"));
6468
6469        let remote = signed_remote_fixture();
6470        let (hub, server) = scripted_json_hub(vec![
6471            (200, remote.card),
6472            (200, remote.feed),
6473            (200, json!({"ok": true}).to_string()),
6474        ]);
6475        let state = tempfile::tempdir().unwrap();
6476        let cfg = test_hub_config(hub, state.path().to_path_buf());
6477        let pushed = sync_push(&cfg, TEST_BRAIN_ID, &files).unwrap();
6478        assert_eq!(pushed, json!({"ok": true}));
6479        server.join().unwrap();
6480    }
6481
6482    #[test]
6483    fn signed_feed_item_verifies_identity_hash_and_signature() {
6484        use ring::rand::SystemRandom;
6485        use ring::signature::{Ed25519KeyPair, KeyPair};
6486
6487        const PREFIX: &[u8] = &[
6488            0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
6489        ];
6490        let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
6491        let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
6492        let mut spki = PREFIX.to_vec();
6493        spki.extend_from_slice(pair.public_key().as_ref());
6494        let public_key = URL_SAFE_NO_PAD.encode(&spki);
6495        let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
6496        let mut entry = FeedEntry {
6497            v: 1,
6498            seq: 1,
6499            ts: "2026-07-14T00:00:00.000Z".to_string(),
6500            brain: format!("ed25519:{fingerprint}"),
6501            public_key: public_key.clone(),
6502            kind: "push".to_string(),
6503            op: "snapshot".to_string(),
6504            pack_sha256: "a".repeat(64),
6505            files: vec![FeedFile {
6506                path: "DB.md".to_string(),
6507                sha256: "b".repeat(64),
6508                bytes: 3,
6509            }],
6510            removed: vec![],
6511            prev_entry_hash: None,
6512            sig: String::new(),
6513        };
6514        let unsigned = UnsignedFeedEntry {
6515            v: entry.v,
6516            seq: entry.seq,
6517            ts: &entry.ts,
6518            brain: &entry.brain,
6519            public_key: &entry.public_key,
6520            kind: &entry.kind,
6521            op: &entry.op,
6522            pack_sha256: &entry.pack_sha256,
6523            files: &entry.files,
6524            removed: &entry.removed,
6525            prev_entry_hash: &entry.prev_entry_hash,
6526        };
6527        entry.sig =
6528            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
6529        let mut exact = serde_json::to_vec(&entry).unwrap();
6530        exact.push(b'\n');
6531        let item = FeedItem {
6532            hash: format!("{:x}", Sha256::digest(&exact)),
6533            entry,
6534        };
6535        let identity = FeedIdentity {
6536            fingerprint,
6537            public_key_spki: public_key,
6538            previous: Vec::new(),
6539            rotations: Vec::new(),
6540        };
6541        assert!(verify_feed_item(&item, &identity).is_ok());
6542        let mut tampered = item;
6543        tampered.entry.pack_sha256 = "c".repeat(64);
6544        assert!(verify_feed_item(&tampered, &identity).is_err());
6545    }
6546
6547    #[test]
6548    fn a_self_custody_entry_verifies_like_any_hub_entry() {
6549        let rng = ring::rand::SystemRandom::new();
6550        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
6551        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
6552        let (spki, multikey) = public_identity_for(&pair);
6553        let key = AgentSigningKey {
6554            pkcs8: pkcs8.as_ref().to_vec(),
6555            multikey: multikey.clone(),
6556            public_key_spki: spki.clone(),
6557        };
6558        let files = vec![WireFeedFile {
6559            path: "DB.md".to_string(),
6560            sha256: "a".repeat(64),
6561            bytes: 3,
6562        }];
6563        let raw = self_custody_entry(
6564            &key,
6565            1,
6566            "2026-07-23T12:00:00.000Z".to_string(),
6567            &"c".repeat(64),
6568            &files,
6569            None,
6570        )
6571        .unwrap();
6572        // The exact client serialization parses as a feed entry and passes the
6573        // SAME verifier every subscribe read runs — the self-custody path
6574        // produces first-class wire-profile-v1 entries.
6575        let entry: FeedEntry = serde_json::from_str(&raw).unwrap();
6576        let hash = format!("{:x}", Sha256::digest(format!("{raw}\n").as_bytes()));
6577        let item = FeedItem { hash, entry };
6578        let identity = FeedIdentity {
6579            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
6580            public_key_spki: spki,
6581            previous: Vec::new(),
6582            rotations: Vec::new(),
6583        };
6584        assert!(verify_feed_item(&item, &identity).is_ok());
6585    }
6586
6587    #[test]
6588    fn identity_rotation_requires_old_key_signature_and_preserves_the_pin() {
6589        let rng = ring::rand::SystemRandom::new();
6590        let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
6591        let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
6592        let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
6593        let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
6594        let (old_spki, old_multikey) = public_identity_for(&old);
6595        let (new_spki, new_multikey) = public_identity_for(&new);
6596        let unsigned = serde_json::to_string(&UnsignedRotation {
6597            v: 1,
6598            op: "rotate",
6599            brain: &old_multikey,
6600            public_key: &old_spki,
6601            new_brain: &new_multikey,
6602            new_public_key: &new_spki,
6603            prior_head_seq: 1,
6604            prior_feed_hash: Some(&"a".repeat(64)),
6605            ts: "2026-07-30T12:00:00.000Z".to_string(),
6606        })
6607        .unwrap();
6608        let signature = URL_SAFE_NO_PAD.encode(old.sign(unsigned.as_bytes()).as_ref());
6609        let rotation = format!(
6610            "{},\"sig\":\"{}\"}}",
6611            &unsigned[..unsigned.len() - 1],
6612            signature
6613        );
6614        let identity = FeedIdentity {
6615            fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
6616            public_key_spki: new_spki,
6617            previous: vec![PreviousIdentity {
6618                fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
6619                public_key_spki: old_spki,
6620            }],
6621            rotations: vec![rotation],
6622        };
6623        let pin = TrustState {
6624            v: 2,
6625            origin: "https://hub.example".to_string(),
6626            requested: "brain".to_string(),
6627            brain: "brain".to_string(),
6628            home: None,
6629            anchor: old_multikey.clone(),
6630            current: old_multikey.clone(),
6631            head_seq: 1,
6632            feed_hash: Some("a".repeat(64)),
6633            rotations: Vec::new(),
6634        };
6635        assert_eq!(
6636            verify_identity_chain(&identity, Some(&pin)).unwrap(),
6637            old_multikey
6638        );
6639        let mut accepted = pin.clone();
6640        accepted.current = new_multikey.clone();
6641        accepted.rotations = identity.rotations.clone();
6642        let alternate_unsigned = serde_json::to_string(&UnsignedRotation {
6643            v: 1,
6644            op: "rotate",
6645            brain: &old_multikey,
6646            public_key: &identity.previous[0].public_key_spki,
6647            new_brain: &new_multikey,
6648            new_public_key: &identity.public_key_spki,
6649            prior_head_seq: 1,
6650            prior_feed_hash: Some(&"a".repeat(64)),
6651            ts: "2026-07-30T12:00:01.000Z".to_string(),
6652        })
6653        .unwrap();
6654        let alternate_signature =
6655            URL_SAFE_NO_PAD.encode(old.sign(alternate_unsigned.as_bytes()).as_ref());
6656        let mut rewritten = identity.clone();
6657        rewritten.rotations[0] = format!(
6658            "{},\"sig\":\"{}\"}}",
6659            &alternate_unsigned[..alternate_unsigned.len() - 1],
6660            alternate_signature
6661        );
6662        assert!(
6663            verify_identity_chain(&rewritten, Some(&accepted)).is_err(),
6664            "an alternate valid statement must not rewrite accepted history"
6665        );
6666
6667        let mut stale_entry = FeedEntry {
6668            v: 1,
6669            seq: 2,
6670            ts: "2026-07-30T12:01:00.000Z".to_string(),
6671            brain: pin.current.clone(),
6672            public_key: identity.previous[0].public_key_spki.clone(),
6673            kind: "push".to_string(),
6674            op: "snapshot".to_string(),
6675            pack_sha256: "b".repeat(64),
6676            files: Vec::new(),
6677            removed: Vec::new(),
6678            prev_entry_hash: pin.feed_hash.clone(),
6679            sig: String::new(),
6680        };
6681        let stale_unsigned = UnsignedFeedEntry {
6682            v: stale_entry.v,
6683            seq: stale_entry.seq,
6684            ts: &stale_entry.ts,
6685            brain: &stale_entry.brain,
6686            public_key: &stale_entry.public_key,
6687            kind: &stale_entry.kind,
6688            op: &stale_entry.op,
6689            pack_sha256: &stale_entry.pack_sha256,
6690            files: &stale_entry.files,
6691            removed: &stale_entry.removed,
6692            prev_entry_hash: &stale_entry.prev_entry_hash,
6693        };
6694        stale_entry.sig = URL_SAFE_NO_PAD.encode(
6695            old.sign(&serde_json::to_vec(&stale_unsigned).unwrap())
6696                .as_ref(),
6697        );
6698        let mut stale_exact = serde_json::to_vec(&stale_entry).unwrap();
6699        stale_exact.push(b'\n');
6700        let stale_item = FeedItem {
6701            hash: content_sha256(&stale_exact),
6702            entry: stale_entry,
6703        };
6704        assert!(
6705            reject_retired_signer_after_checkpoint(&identity, Some(&accepted), &stale_item)
6706                .is_err(),
6707            "a key retired before the checkpoint must never append after it"
6708        );
6709        assert!(
6710            verify_feed_item(&stale_item, &identity).is_err(),
6711            "an old key must never append after its signed rotation boundary"
6712        );
6713
6714        let mut missing = identity.clone();
6715        missing.rotations.clear();
6716        assert!(verify_identity_chain(&missing, Some(&pin)).is_err());
6717
6718        let mut tampered = identity;
6719        tampered.rotations[0] = tampered.rotations[0].replace("\"sig\":\"", "\"sig\":\"A");
6720        assert!(verify_identity_chain(&tampered, Some(&pin)).is_err());
6721    }
6722
6723    #[cfg(unix)]
6724    #[test]
6725    fn key_creation_refuses_a_planted_symlink_without_touching_its_target() {
6726        use std::os::unix::fs::symlink;
6727
6728        let dir = tempfile::tempdir().unwrap();
6729        let target = dir.path().join("valuable.txt");
6730        let planted = dir.path().join("agent.key");
6731        std::fs::write(&target, "do not overwrite").unwrap();
6732        symlink(&target, &planted).unwrap();
6733
6734        assert!(matches!(
6735            generate_agent_key(&planted),
6736            Err(LinkError::BadAgentKey { .. })
6737        ));
6738        assert_eq!(std::fs::read_to_string(target).unwrap(), "do not overwrite");
6739    }
6740
6741    #[cfg(unix)]
6742    #[test]
6743    fn key_creation_refuses_a_symlinked_parent_without_writing_through_it() {
6744        use std::os::unix::fs::symlink;
6745
6746        let root = tempfile::tempdir().unwrap();
6747        let outside = tempfile::tempdir().unwrap();
6748        symlink(outside.path(), root.path().join("redirect")).unwrap();
6749
6750        assert!(generate_agent_key(&root.path().join("redirect/agent.key")).is_err());
6751        assert!(!outside.path().join("agent.key").exists());
6752    }
6753
6754    // ── Address parsing ─────────────────────────────────────────────────────
6755
6756    #[test]
6757    fn address_bare_brain_with_and_without_sigil() {
6758        for raw in ["@acme-ops", "acme-ops"] {
6759            let a = Address::parse(raw).expect(raw);
6760            assert_eq!(a.brain, "acme-ops");
6761            assert_eq!(a.target, None);
6762        }
6763    }
6764
6765    #[test]
6766    fn address_ulid_target_parses_as_id() {
6767        let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
6768        assert_eq!(a.brain, "acme");
6769        assert_eq!(
6770            a.target,
6771            Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
6772        );
6773    }
6774
6775    #[test]
6776    fn address_md_path_target_parses_as_path() {
6777        let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
6778        assert_eq!(
6779            a.target,
6780            Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
6781        );
6782    }
6783
6784    #[test]
6785    fn address_rejects_malformed_forms() {
6786        for raw in [
6787            "",
6788            "@",
6789            "@/x",
6790            "@acme/",
6791            "@acme/../etc/passwd",
6792            "@acme/records/.hidden.md",
6793            "@ACME",             // uppercase is not a hub ref shape
6794            "@acme/notes/x.txt", // target is neither ULID nor .md path
6795            "@a b",              // whitespace
6796        ] {
6797            assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
6798        }
6799    }
6800
6801    // ── Path safety ─────────────────────────────────────────────────────────
6802
6803    #[test]
6804    fn safe_paths_accept_store_shapes_and_reject_escapes() {
6805        for ok in [
6806            "DB.md",
6807            "assets.jsonl",
6808            "records/clients/lumio.md",
6809            "sources/emails/2026/07/x.md",
6810        ] {
6811            assert!(safe_store_rel_path(ok), "should accept {ok:?}");
6812        }
6813        for bad in [
6814            "",
6815            "/etc/passwd",
6816            "../up.md",
6817            "records/../../up.md",
6818            "records//x.md",
6819            ".dbmd/config",
6820            "records/.hidden/x.md",
6821            "records/a b.md",
6822            "records\\win.md",
6823        ] {
6824            assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
6825        }
6826    }
6827
6828    #[cfg(unix)]
6829    #[test]
6830    fn opened_destination_capability_survives_an_ancestor_path_swap() {
6831        use std::os::unix::fs::symlink;
6832
6833        let work = tempfile::tempdir().unwrap();
6834        let outside = tempfile::tempdir().unwrap();
6835        let original = work.path().join("destination");
6836        let moved = work.path().join("destination-moved");
6837        let directory = open_or_create_dir_nofollow(&original).unwrap();
6838
6839        std::fs::rename(&original, &moved).unwrap();
6840        symlink(outside.path(), &original).unwrap();
6841        write_pull_entries_beneath_dir(
6842            &directory,
6843            &[("records/note.md".to_string(), b"held inode".to_vec())],
6844        )
6845        .unwrap();
6846
6847        assert_eq!(
6848            std::fs::read(moved.join("records/note.md")).unwrap(),
6849            b"held inode"
6850        );
6851        assert!(!outside.path().join("records/note.md").exists());
6852    }
6853
6854    // ── Config resolution (flag + file precedence; env is covered by the CLI
6855    //    integration tests, where a child process isolates it) ───────────────
6856
6857    #[test]
6858    fn hub_config_flag_beats_file_and_requires_some_source() {
6859        let dir = tempfile::tempdir().unwrap();
6860        std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
6861        std::fs::write(
6862            dir.path().join(CONFIG_REL_PATH),
6863            "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
6864        )
6865        .unwrap();
6866
6867        let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
6868        assert_eq!(from_flag.hub, "https://flag.example.com");
6869
6870        let from_file = hub_config(None, dir.path()).unwrap();
6871        assert_eq!(from_file.hub, "https://file.example.com");
6872
6873        let none = hub_config(None, tempfile::tempdir().unwrap().path());
6874        assert!(matches!(none, Err(LinkError::NoHub)));
6875    }
6876
6877    #[test]
6878    fn https_guard_allows_loopback_only_for_plain_http() {
6879        assert!(assert_safe_hub("https://hub.example.com").is_ok());
6880        assert!(assert_safe_hub("http://localhost:3000").is_ok());
6881        assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
6882        assert!(assert_safe_hub("http://[::1]:3000").is_ok());
6883        assert!(matches!(
6884            assert_safe_hub("http://hub.example.com"),
6885            Err(LinkError::UnsafeHub { .. })
6886        ));
6887        assert!(matches!(
6888            assert_safe_hub("hub.example.com"),
6889            Err(LinkError::UnsafeHub { .. })
6890        ));
6891        assert!(matches!(
6892            assert_safe_hub("http://localhost:80@127.0.0.1:1"),
6893            Err(LinkError::UnsafeHub { .. })
6894        ));
6895        assert!(matches!(
6896            assert_safe_hub("https://hub.example.com@attacker.example"),
6897            Err(LinkError::UnsafeHub { .. })
6898        ));
6899        assert!(matches!(
6900            assert_safe_hub("https://hub.example.com/base"),
6901            Err(LinkError::UnsafeHub { .. })
6902        ));
6903    }
6904
6905    #[test]
6906    fn registry_ssrf_classifier_rejects_local_private_and_documentation_ranges() {
6907        for blocked in [
6908            "127.0.0.1",
6909            "10.0.0.1",
6910            "100.64.0.1",
6911            "169.254.169.254",
6912            "172.16.0.1",
6913            "192.168.0.1",
6914            "192.88.99.1",
6915            "198.18.0.1",
6916            "203.0.113.1",
6917            "::1",
6918            "fe80::1",
6919            "fd00::1",
6920            "2001:db8::1",
6921            "2001:1::1",
6922            "2002:7f00:1::",
6923            "3fff::1",
6924        ] {
6925            assert!(
6926                !is_public_registry_ip(blocked.parse().unwrap()),
6927                "must block {blocked}"
6928            );
6929        }
6930        assert!(is_public_registry_ip("1.1.1.1".parse().unwrap()));
6931        assert!(is_public_registry_ip(
6932            "2606:4700:4700::1111".parse().unwrap()
6933        ));
6934        assert!(is_public_registry_ip("3fff:1000::1".parse().unwrap()));
6935    }
6936
6937    #[test]
6938    fn registry_dns_answer_is_pinned_and_cannot_rebind_or_change_authority() {
6939        use ureq::Resolver as _;
6940
6941        let pinned: std::net::SocketAddr = "1.1.1.1:443".parse().unwrap();
6942        let resolver = PinnedRegistryResolver {
6943            netloc: "home.example:443".to_string(),
6944            addresses: vec![pinned],
6945        };
6946        assert_eq!(resolver.resolve("home.example:443").unwrap(), vec![pinned]);
6947        assert!(resolver.resolve("127.0.0.1:443").is_err());
6948        assert_eq!(
6949            resolver.resolve("home.example:443").unwrap(),
6950            vec![pinned],
6951            "subsequent connects reuse the validated answer instead of DNS"
6952        );
6953    }
6954
6955    #[test]
6956    fn production_object_urls_and_store_selected_hubs_cannot_reach_private_ips() {
6957        let cfg = HubConfig {
6958            hub: "https://hub.example".to_string(),
6959            key: None,
6960            agent_key: None,
6961            brain_key: None,
6962            state_dir: tempfile::tempdir().unwrap().keep(),
6963            store_selected: false,
6964        };
6965        assert!(
6966            presigned_agent(&cfg, "https://127.0.0.1/private").is_err(),
6967            "a production hub must not turn its presigned URL into an SSRF primitive"
6968        );
6969
6970        let store_selected = HubConfig {
6971            hub: "https://127.0.0.1".to_string(),
6972            store_selected: true,
6973            ..cfg
6974        };
6975        assert!(
6976            hub_agent(&store_selected).is_err(),
6977            "bytes in a cloned store must not select a private-network hub"
6978        );
6979    }
6980
6981    #[test]
6982    fn presigned_download_ceiling_cannot_overflow_at_u64_max() {
6983        assert_eq!(
6984            one_past_bounded_limit(MAX_PACK_BYTES),
6985            Some(MAX_PACK_BYTES + 1),
6986            "the presigned reader consumes exactly one refusal byte beyond its fixed pack cap"
6987        );
6988        assert_eq!(
6989            presigned_download_read_limit(),
6990            MAX_PACK_BYTES + 1,
6991            "the presigned reader is capped by the client constant, not a hub response"
6992        );
6993        assert_eq!(
6994            one_past_bounded_limit(u64::MAX),
6995            None,
6996            "the former attacker-controlled u64::MAX + 1 shape must fail without overflow"
6997        );
6998    }
6999
7000    #[test]
7001    fn https_guard_matches_the_scheme_case_insensitively() {
7002        // RFC 3986 schemes are case-insensitive: an uppercase-scheme HTTPS
7003        // hub is still HTTPS, never a misleading non-HTTPS refusal.
7004        assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
7005        assert!(assert_safe_hub("Https://hub.example.com").is_ok());
7006        // And an uppercase plain-HTTP hub is still refused outside loopback.
7007        assert!(matches!(
7008            assert_safe_hub("HTTP://hub.example.com"),
7009            Err(LinkError::UnsafeHub { .. })
7010        ));
7011    }
7012
7013    #[test]
7014    fn clean_key_refuses_paste_artifacts_without_echoing() {
7015        assert_eq!(clean_key("  vc_account_abc  ").unwrap(), "vc_account_abc");
7016        for bad in ["vc account", "vc\naccount", "ключ", ""] {
7017            let err = clean_key(bad).unwrap_err();
7018            assert!(matches!(err, LinkError::BadKey));
7019            assert!(
7020                !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
7021                "error must not echo the key"
7022            );
7023        }
7024    }
7025
7026    // ── Verb entry gates: refs must never reshape the request path ──────────
7027
7028    /// A config whose hub passes the loopback guard but is never listened on:
7029    /// every refusal below must come from the entry gate BEFORE a request
7030    /// exists — a dial on this dead port would surface `Transport` instead.
7031    fn dead_hub() -> HubConfig {
7032        HubConfig {
7033            hub: "http://127.0.0.1:9".to_string(),
7034            key: Some("k".to_string()),
7035            agent_key: None,
7036            brain_key: None,
7037            state_dir: PathBuf::from("."),
7038            store_selected: false,
7039        }
7040    }
7041
7042    #[test]
7043    fn request_retries_a_connection_failure_before_sending() {
7044        use std::io::{Read as _, Write as _};
7045        use std::net::TcpListener;
7046        use std::thread;
7047        use std::time::Duration;
7048
7049        let probe = TcpListener::bind("127.0.0.1:0").unwrap();
7050        let address = probe.local_addr().unwrap();
7051        drop(probe);
7052        let server = thread::spawn(move || {
7053            thread::sleep(Duration::from_millis(40));
7054            let listener = TcpListener::bind(address).unwrap();
7055            let (mut stream, _) = listener.accept().unwrap();
7056            let mut request_bytes = [0_u8; 1024];
7057            let _ = stream.read(&mut request_bytes).unwrap();
7058            stream
7059                .write_all(
7060                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
7061                )
7062                .unwrap();
7063        });
7064        let cfg = HubConfig {
7065            hub: format!("http://{address}"),
7066            key: None,
7067            agent_key: None,
7068            brain_key: None,
7069            state_dir: tempfile::tempdir().unwrap().keep(),
7070            store_selected: false,
7071        };
7072
7073        let response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
7074        assert_eq!(response.status, 200);
7075        assert_eq!(response.body, Some(json!({ "ok": true })));
7076        server.join().unwrap();
7077    }
7078
7079    #[test]
7080    fn endpoint_cap_refuses_a_body_before_json_parsing() {
7081        let (hub, server) = scripted_json_hub(vec![(200, "x".repeat(2_048))]);
7082        let cfg = HubConfig {
7083            hub,
7084            key: None,
7085            agent_key: None,
7086            brain_key: None,
7087            state_dir: tempfile::tempdir().unwrap().keep(),
7088            store_selected: false,
7089        };
7090
7091        assert!(matches!(
7092            request_capped(&cfg, "GET", "/bounded", None, Auth::None, 1_024),
7093            Err(LinkError::ResponseTooLarge { .. })
7094        ));
7095        server.join().unwrap();
7096    }
7097
7098    #[test]
7099    fn overall_deadline_stops_a_dribbled_response_body() {
7100        use std::io::{Read as _, Write as _};
7101        use std::net::TcpListener;
7102        use std::time::{Duration, Instant};
7103
7104        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
7105        let url = format!("http://{}/dribble", listener.local_addr().unwrap());
7106        let server = std::thread::spawn(move || {
7107            let (mut stream, _) = listener.accept().unwrap();
7108            let mut request = [0_u8; 1024];
7109            let _ = stream.read(&mut request);
7110            stream
7111                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 32\r\nConnection: close\r\n\r\n")
7112                .unwrap();
7113            for byte in [b'x'; 32] {
7114                if stream.write_all(&[byte]).is_err() {
7115                    break;
7116                }
7117                std::thread::sleep(Duration::from_millis(40));
7118            }
7119        });
7120        let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
7121        let started = Instant::now();
7122        let response = http.get(&url).call().unwrap();
7123        let mut body = Vec::new();
7124        let error = response
7125            .into_reader()
7126            .read_to_end(&mut body)
7127            .expect_err("per-read progress must not reset the overall deadline");
7128        assert!(
7129            started.elapsed() < Duration::from_millis(700),
7130            "dribbled body exceeded the wall-clock budget: {error}"
7131        );
7132        server.join().unwrap();
7133    }
7134
7135    #[test]
7136    fn overall_deadline_stops_a_stalled_upload() {
7137        use std::net::TcpListener;
7138        use std::time::{Duration, Instant};
7139
7140        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
7141        let url = format!("http://{}/upload", listener.local_addr().unwrap());
7142        let server = std::thread::spawn(move || {
7143            let (_stream, _) = listener.accept().unwrap();
7144            // Never consume the request body. Once the kernel send buffer fills,
7145            // the client must leave on its absolute write deadline.
7146            std::thread::sleep(Duration::from_millis(600));
7147        });
7148        let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
7149        let body = vec![0x5a; 32 * 1024 * 1024];
7150        let started = Instant::now();
7151        let error = http
7152            .put(&url)
7153            .send_bytes(&body)
7154            .expect_err("stalled request-body writes must time out");
7155        assert!(
7156            started.elapsed() < Duration::from_millis(700),
7157            "stalled upload exceeded the wall-clock budget: {error}"
7158        );
7159        server.join().unwrap();
7160    }
7161
7162    #[test]
7163    fn verb_entry_gates_accept_the_hub_ref_shapes() {
7164        for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
7165            assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
7166            assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
7167            assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
7168        }
7169    }
7170
7171    #[test]
7172    fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
7173        let cfg = dead_hub();
7174        for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
7175            assert!(
7176                matches!(
7177                    sync_pull(&cfg, bad, None),
7178                    Err(LinkError::BadAddress { .. })
7179                ),
7180                "sync_pull must refuse {bad:?}"
7181            );
7182            assert!(
7183                matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
7184                "sync_push must refuse {bad:?}"
7185            );
7186            assert!(
7187                matches!(
7188                    grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
7189                    Err(LinkError::BadAddress { .. })
7190                ),
7191                "grant_issue must refuse {bad:?}"
7192            );
7193            assert!(
7194                matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
7195                "grant_list must refuse {bad:?}"
7196            );
7197            assert!(
7198                matches!(
7199                    grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
7200                    Err(LinkError::BadAddress { .. })
7201                ),
7202                "grant_revoke must refuse brain {bad:?}"
7203            );
7204            assert!(
7205                matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
7206                "head must refuse {bad:?}"
7207            );
7208        }
7209    }
7210
7211    #[test]
7212    fn grant_revoke_refuses_url_reshaping_grant_ids() {
7213        let cfg = dead_hub();
7214        for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
7215            assert!(
7216                matches!(
7217                    grant_revoke(&cfg, "acme", bad),
7218                    Err(LinkError::BadGrantId { .. })
7219                ),
7220                "grant_revoke must refuse grant id {bad:?}"
7221            );
7222        }
7223    }
7224
7225    #[test]
7226    fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
7227        let cfg = dead_hub();
7228        for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
7229            assert!(
7230                matches!(
7231                    propose(&cfg, bad, "intake", "hi"),
7232                    Err(LinkError::BadAddress { .. })
7233                ),
7234                "propose must refuse handle {bad:?}"
7235            );
7236        }
7237        let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
7238        assert!(matches!(
7239            propose(&cfg, "acme-site", "intake", &oversize),
7240            Err(LinkError::ProposeTooLarge { .. })
7241        ));
7242        // A clean handle + in-cap body passes both gates: the failure is now
7243        // the (dead) wire, proving the gates refuse shape, not the verb.
7244        assert!(matches!(
7245            propose(&cfg, "acme-site", "intake", "hi"),
7246            Err(LinkError::Transport { .. })
7247        ));
7248    }
7249
7250    #[test]
7251    fn resolve_refuses_a_hand_built_unsafe_address() {
7252        let cfg = dead_hub();
7253        for brain in ["../up", "a/b", "a?x", "a#f"] {
7254            let addr = Address {
7255                brain: brain.to_string(),
7256                target: None,
7257            };
7258            assert!(
7259                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
7260                "resolve must refuse brain {brain:?}"
7261            );
7262        }
7263        for target in [
7264            AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
7265            AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), // not the minted shape
7266            AddressTarget::Path("../up.md".to_string()),
7267            AddressTarget::Path("records/x.md#frag".to_string()),
7268        ] {
7269            let addr = Address {
7270                brain: "acme".to_string(),
7271                target: Some(target.clone()),
7272            };
7273            assert!(
7274                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
7275                "resolve must refuse target {target:?}"
7276            );
7277        }
7278    }
7279}