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/// Cold checkouts may need every visible blob, but short-lived direct object
166/// capabilities must not create an unbounded connection storm. Keep the
167/// verified downloads inside this fixed worker count.
168const V2_BLOB_DOWNLOAD_WORKERS: usize = 16;
169
170/// Everything that can go wrong on the wire or at its edges. Each variant maps
171/// onto one stable CLI error code; messages are single-line and never echo the
172/// credential.
173#[derive(Debug, thiserror::Error)]
174pub enum LinkError {
175    /// No hub URL was configured anywhere (flag, env, `.dbmd/config`).
176    #[error(
177        "no hub configured — pass --hub <URL>, set {HUB_URL_ENV}, or add `hub = <URL>` to {CONFIG_REL_PATH}"
178    )]
179    NoHub,
180
181    /// The verb needs a credential and none was present.
182    #[error("no hub credential — set {HUB_KEY_ENV} (credentials never live in {CONFIG_REL_PATH})")]
183    NoCredential,
184
185    /// The credential contains whitespace / non-ASCII (a paste artifact). The
186    /// key is deliberately not echoed.
187    #[error(
188        "the hub credential in {HUB_KEY_ENV} contains whitespace or non-ASCII characters — re-copy it (the key is not shown here on purpose)"
189    )]
190    BadKey,
191
192    /// A store selected the destination while an ambient credential was
193    /// present, but the operator did not bind that credential to the same
194    /// origin. This is a hard refusal, not an anonymous fallback: silently
195    /// dropping a credential can turn an intended private operation into a
196    /// confusing public one.
197    #[error(
198        "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}"
199    )]
200    UnboundCredential,
201
202    /// The agent signing key file named by [`AGENT_KEY_FILE_ENV`] is missing,
203    /// unreadable, or not a valid Ed25519 PKCS#8 — key material is never
204    /// echoed.
205    #[error("invalid agent signing key ({message}) — mint one with `dbmd key generate`")]
206    BadAgentKey {
207        /// What failed, without any key material.
208        message: String,
209    },
210
211    /// A non-HTTPS hub outside loopback: the bearer key would travel in cleartext.
212    #[error("refusing non-HTTPS hub {hub} — the credential would travel in cleartext (localhost is exempt)")]
213    UnsafeHub {
214        /// The offending hub URL.
215        hub: String,
216    },
217
218    /// TCP/TLS-level failure: the hub never answered.
219    #[error("hub unreachable at {hub}: {message}")]
220    Transport {
221        /// The hub base URL.
222        hub: String,
223        /// The transport-layer error text.
224        message: String,
225    },
226
227    /// The hub answered with an HTTP error status.
228    #[error("{what} failed (HTTP {status}): {message}")]
229    Http {
230        /// What the client was doing (e.g. `"resolve"`, `"sync pull"`).
231        what: &'static str,
232        /// The HTTP status code.
233        status: u16,
234        /// The hub's own `error` string when it sent one, else a placeholder.
235        message: String,
236        /// The hub's machine `code` field when it sent one.
237        code: Option<String>,
238        /// Permission-filtered structured refusal details, when supplied.
239        details: Option<Value>,
240    },
241
242    /// A 2xx whose body is not JSON — a captive portal, a proxy, or a wrong
243    /// URL — refused here rather than deserializing into nothing downstream.
244    #[error("{what}: the hub answered HTTP {status} with a non-JSON body — check the hub URL")]
245    NotJson {
246        /// What the client was doing.
247        what: &'static str,
248        /// The (2xx) status that carried the non-JSON body.
249        status: u16,
250    },
251
252    /// The hub response exceeded the selected endpoint's byte cap.
253    #[error("hub response exceeded the {limit_bytes}-byte endpoint cap — refusing to buffer it")]
254    ResponseTooLarge {
255        /// The endpoint-specific cap applied before JSON parsing.
256        limit_bytes: u64,
257    },
258
259    /// A malformed `@brain/id` address.
260    #[error("invalid address `{given}`: {reason}")]
261    BadAddress {
262        /// The raw address as typed.
263        given: String,
264        /// Why it did not parse.
265        reason: String,
266    },
267
268    /// A grant id whose shape cannot travel as a URL path segment.
269    #[error(
270        "invalid grant id `{given}` — grant ids come from `grant list` (lowercase letters, digits, hyphens)"
271    )]
272    BadGrantId {
273        /// The raw id as typed.
274        given: String,
275    },
276
277    /// An exported file path that would escape or pollute the destination
278    /// (absolute, `..`, a dot-leading segment, or an illegal character). The
279    /// hub is not trusted with local path layout.
280    #[error("refusing unsafe path from the hub: `{path}`")]
281    UnsafePath {
282        /// The offending path as received.
283        path: String,
284    },
285
286    /// The store exceeds the hub's bounded whole-snapshot caps.
287    #[error(
288        "push too large ({detail}) — one snapshot caps at {} MB uncompressed, {} MB as a pack, and {MAX_PUSH_FILES} files",
289        MAX_STORE_BYTES / (1024 * 1024),
290        MAX_PACK_BYTES / (1024 * 1024)
291    )]
292    PushTooLarge {
293        /// Which cap was hit, human-readable.
294        detail: String,
295    },
296
297    /// The propose body exceeds the hub's inbox cap.
298    #[error(
299        "propose body too large ({bytes} bytes) — the hub's inbox caps one submission at {} KB",
300        MAX_PROPOSE_BYTES / 1024
301    )]
302    ProposeTooLarge {
303        /// The offending body size in bytes.
304        bytes: u64,
305    },
306
307    /// A store file that is not valid UTF-8 cannot travel the JSON push path.
308    #[error("store file `{path}` is not valid UTF-8 — the JSON push path carries text only")]
309    NotUtf8 {
310        /// The store-relative path of the offending file.
311        path: String,
312    },
313
314    /// A downloaded pack failed validation before any local write.
315    #[error("invalid store pack: {message}")]
316    InvalidPack {
317        /// Hash, ZIP, path, count, or expansion failure.
318        message: String,
319    },
320
321    /// A signed feed entry, hash chain, or advertised feed head did not verify.
322    #[error("invalid signed feed: {message}")]
323    InvalidFeed {
324        /// The failed integrity condition, without untrusted secret material.
325        message: String,
326    },
327
328    /// Local and remote both changed one or more coordinates since the last
329    /// verified sync baseline. No side was overwritten.
330    #[error("sync conflict on {paths:?} — resolve the named files and retry")]
331    Conflict {
332        /// Bounded, portable paths safe to show to the agent/operator.
333        paths: Vec<String>,
334    },
335
336    /// `.sevralocal` newly made paths eligible to ride. Uploading them is an
337    /// explicit adoption boundary, never an accidental side effect of editing
338    /// or removing a local policy file.
339    #[error(
340        "local sync policy newly exposes {paths:?} — review and retry with --resume-local-policy"
341    )]
342    LocalPolicyTransition {
343        /// Bounded local-only paths. They are never sent in telemetry.
344        paths: Vec<String>,
345    },
346
347    /// A scoped checkout's generated store marker was edited or removed.
348    /// It is local projection metadata and is never accepted as brain data.
349    #[error(
350        "the generated DB.md for this scoped view was modified — clone a fresh scoped checkout"
351    )]
352    ScopedProjectionModified,
353
354    /// The effective permission slice changed after this checkout was pinned.
355    /// Reusing the directory could conceal removals or accidentally adopt files
356    /// revealed by a wider grant, so a new checkout is required.
357    #[error(
358        "the checkout's permission scope changed — clone into a new directory to accept the new view"
359    )]
360    ScopedViewChanged,
361
362    /// A v2 identity/head was previously accepted for this ref, but the hub
363    /// now hides it. Never reinterpret that as a v1 downgrade.
364    #[error("the previously verified brain is unavailable — access may have been revoked or the brain removed")]
365    BrainUnavailable,
366
367    /// The verified remote head advanced while a pull or post-commit barrier
368    /// was in flight. The old baseline is deliberately retained.
369    #[error(
370        "the remote brain advanced during sync — retry to converge from the new verified head"
371    )]
372    RemoteAdvancedDuringSync,
373
374    /// This build cannot provide the no-follow, directory-handle-relative
375    /// filesystem semantics required for trust/key/snapshot state.
376    #[error(
377        "{operation} is unavailable on this platform — use the official macOS/Linux build or WSL"
378    )]
379    UnsupportedPlatform {
380        /// The operation that requires hardened local filesystem primitives.
381        operation: &'static str,
382    },
383
384    /// Local filesystem failure while materializing a pull or reading a push.
385    #[error(transparent)]
386    Io(#[from] std::io::Error),
387
388    /// A store-level failure (walking the local store for a push).
389    #[error(transparent)]
390    Store(#[from] crate::StoreError),
391}
392
393/// Result alias for link.md client operations.
394pub type LinkResult<T> = std::result::Result<T, LinkError>;
395
396/// Security-sensitive link.md state and destination writes rely on Unix
397/// `openat`/`O_NOFOLLOW` semantics. Native Windows is not an official release
398/// target yet, so fail closed there instead of silently using a weaker
399/// path-based approximation.
400fn require_hardened_filesystem(operation: &'static str) -> LinkResult<()> {
401    #[cfg(any(target_os = "linux", target_os = "macos"))]
402    {
403        let _ = operation;
404        Ok(())
405    }
406    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
407    {
408        Err(LinkError::UnsupportedPlatform { operation })
409    }
410}
411
412// ─────────────────────────────────────────────────────────────────────────────
413// Addressing — `@brain[/id]`, the reserved shape (SPEC § Addressing)
414// ─────────────────────────────────────────────────────────────────────────────
415
416/// What the part after `@brain/` names.
417#[derive(Debug, Clone, PartialEq, Eq)]
418pub enum AddressTarget {
419    /// A record `id` — the db.md lowercase ULID (the reserved `@brain/id` shape).
420    Id(String),
421    /// A store-relative `.md` path — a client-side convenience the hub's
422    /// resolve endpoint also accepts (`?path=`). Not part of the reserved
423    /// shape; unambiguous because a ULID is never a path.
424    Path(String),
425}
426
427/// Why a brain reference failed [`is_safe_ref`] — shared by [`Address::parse`]
428/// and the per-verb entry gates so the two surfaces never drift.
429const BAD_BRAIN_REASON: &str =
430    "the brain reference must be a brain id (lowercase ULID) or a slug (lowercase letters, digits, hyphens)";
431
432/// Why an address target failed its shape check — shared by [`Address::parse`]
433/// and the [`resolve`] entry gate.
434const BAD_TARGET_REASON: &str =
435    "the part after `/` must be a record id (lowercase ULID) or a store-relative `.md` path";
436
437/// A parsed `@brain[/target]` address. `brain` is a hub brain reference — the
438/// brain's ULID id (works for any caller, including cross-party on a public
439/// brain) or a slug (which a hub resolves only against the caller's own
440/// brains; slugs are unique per owner, not globally).
441#[derive(Debug, Clone, PartialEq, Eq)]
442pub struct Address {
443    /// The brain reference (leading `@` stripped).
444    pub brain: String,
445    /// The record target, when the address names one.
446    pub target: Option<AddressTarget>,
447}
448
449impl Address {
450    /// Parse `@brain`, `@brain/<ulid>`, or `@brain/<store-path>.md`. The `@`
451    /// sigil is optional (an agent piping ids around should not have to quote
452    /// it back on). Whitespace and empty segments are malformed.
453    pub fn parse(raw: &str) -> LinkResult<Address> {
454        let bad = |reason: &str| LinkError::BadAddress {
455            given: raw.to_string(),
456            reason: reason.to_string(),
457        };
458
459        let trimmed = raw.trim();
460        let body = trimmed.strip_prefix('@').unwrap_or(trimmed);
461        if body.is_empty() {
462            return Err(bad("empty address"));
463        }
464
465        let (brain, rest) = match body.split_once('/') {
466            Some((b, r)) => (b, Some(r)),
467            None => (body, None),
468        };
469
470        if brain.is_empty() {
471            return Err(bad("missing brain reference before `/`"));
472        }
473        if !is_safe_ref(brain) {
474            return Err(bad(BAD_BRAIN_REASON));
475        }
476
477        let target = match rest {
478            None => None,
479            Some("") => return Err(bad("trailing `/` with no record id or path")),
480            Some(r) if crate::ulid::is_ulid(r) => Some(AddressTarget::Id(r.to_string())),
481            Some(r) => {
482                if !safe_store_rel_path(r) || !r.ends_with(".md") {
483                    return Err(bad(BAD_TARGET_REASON));
484                }
485                Some(AddressTarget::Path(r.to_string()))
486            }
487        };
488
489        Ok(Address {
490            brain: brain.to_string(),
491            target,
492        })
493    }
494}
495
496/// A brain reference safe to embed in a URL path segment: the shapes a hub
497/// accepts (ULID id or slug), which are also exactly URL-path-clean.
498fn is_safe_ref(s: &str) -> bool {
499    !s.is_empty()
500        && s.len() <= 64
501        && s.bytes()
502            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
503}
504
505/// A published-site handle (the `propose` target). Same lexical shape as a
506/// slug.
507pub fn is_valid_handle(s: &str) -> bool {
508    is_safe_ref(s)
509}
510
511/// True when `p` is a store-relative path this client will read from or write
512/// to disk: relative, no `..`, no empty or dot-leading segment (which shields
513/// `.dbmd/` and `.git/`), and only the hub-portable character set. Applied to
514/// every path an export hands us (the hub is not trusted with local layout)
515/// and to every path a push sends (mirroring the hub's own gate).
516pub fn safe_store_rel_path(p: &str) -> bool {
517    if p.is_empty() || p.len() > MAX_STORE_PATH_BYTES || p.starts_with('/') {
518        return false;
519    }
520    if !p
521        .bytes()
522        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/'))
523    {
524        return false;
525    }
526    p.split('/')
527        .all(|seg| !seg.is_empty() && seg != "." && seg != ".." && !seg.starts_with('.'))
528}
529
530/// Entry gate for every verb that embeds a caller-supplied brain reference in
531/// a URL path segment. `resolve` reaches the same check through
532/// [`Address::parse`]; the raw-ref verbs (`sync`, `grant`, `subscribe`) call
533/// this directly, so a ref carrying `/`, `..`, `?`, `#`, or any other
534/// URL-reshaping byte is refused before a request exists (the `url` crate
535/// normalizes dot segments, so an unvalidated ref would redirect the
536/// authenticated request to a different hub path).
537fn require_safe_ref(brain: &str) -> LinkResult<()> {
538    if is_safe_ref(brain) {
539        Ok(())
540    } else {
541        Err(LinkError::BadAddress {
542            given: brain.to_string(),
543            reason: BAD_BRAIN_REASON.to_string(),
544        })
545    }
546}
547
548/// Entry gate for the published-site handle `propose` embeds in its URL path.
549fn require_valid_handle(handle: &str) -> LinkResult<()> {
550    if is_valid_handle(handle) {
551        Ok(())
552    } else {
553        Err(LinkError::BadAddress {
554            given: handle.to_string(),
555            reason: "the site handle must be lowercase letters, digits, hyphens".to_string(),
556        })
557    }
558}
559
560/// Entry gate for the grant id `grant revoke` embeds in its URL path. Hub
561/// grant ids are lowercase ULIDs; the gate accepts the same URL-path-clean
562/// shape as a brain ref rather than pinning one mint scheme.
563fn require_safe_grant_id(id: &str) -> LinkResult<()> {
564    if is_safe_ref(id) {
565        Ok(())
566    } else {
567        Err(LinkError::BadGrantId {
568            given: id.to_string(),
569        })
570    }
571}
572
573// ─────────────────────────────────────────────────────────────────────────────
574// Configuration — flag > env > .dbmd/config; credential from env only
575// ─────────────────────────────────────────────────────────────────────────────
576
577/// The resolved client configuration for one invocation.
578#[derive(Debug, Clone)]
579pub struct HubConfig {
580    /// The hub base URL, trailing slash stripped, HTTPS-or-loopback enforced.
581    pub hub: String,
582    /// The bearer credential, when the environment carries one.
583    pub key: Option<String>,
584    /// The agent signing key, when [`AGENT_KEY_FILE_ENV`] names one. Wins
585    /// over the bearer for authenticated requests (link.md §8).
586    pub agent_key: Option<AgentSigningKey>,
587    /// The self-custodied brain signing key, when [`BRAIN_KEY_FILE_ENV`]
588    /// names one — `sync --push` then signs feed entries locally (§2.4).
589    pub brain_key: Option<AgentSigningKey>,
590    /// User-owned global toolkit state root. Identity pins and monotonic feed
591    /// checkpoints live below `<state_dir>/trust/`, never under a store.
592    pub state_dir: PathBuf,
593    /// True only when the origin came from untrusted store-local configuration.
594    /// Such origins are resolved and public-IP-pinned for every request.
595    store_selected: bool,
596}
597
598/// A loaded agent signing key: the PKCS#8 secret plus its derived public
599/// multikey. Debug never prints key material.
600#[derive(Clone)]
601pub struct AgentSigningKey {
602    pkcs8: Vec<u8>,
603    /// The key's public identity, `ed25519:<base64url sha256(SPKI)>`.
604    pub multikey: String,
605    /// The full public key, `base64url(SPKI DER)` — what feed entries carry.
606    pub public_key_spki: String,
607}
608
609impl std::fmt::Debug for AgentSigningKey {
610    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
611        f.debug_struct("AgentSigningKey")
612            .field("multikey", &self.multikey)
613            .field("pkcs8", &"<redacted>")
614            .finish()
615    }
616}
617
618impl HubConfig {
619    /// The credential, or the canonical "not configured" error. Verbs that
620    /// authenticate call this; `propose` never does.
621    pub fn require_key(&self) -> LinkResult<&str> {
622        self.key.as_deref().ok_or(LinkError::NoCredential)
623    }
624}
625
626/// Resolve the client configuration: `flag_hub` beats [`HUB_URL_ENV`] beats
627/// the `hub =` line in `<dir>/.dbmd/config`; no fallback default exists. The
628/// credential comes from [`HUB_KEY_ENV`] alone and is validated as a clean
629/// header token (never echoed on failure).
630pub fn hub_config(flag_hub: Option<&str>, dir: &Path) -> LinkResult<HubConfig> {
631    let explicit_hub = flag_hub
632        .map(str::to_string)
633        .or_else(|| env_nonempty(HUB_URL_ENV));
634    let selected_by_store = explicit_hub.is_none();
635    let hub = explicit_hub
636        .or_else(|| config_file_hub(&dir.join(CONFIG_REL_PATH)))
637        .ok_or(LinkError::NoHub)?;
638    let hub = hub.trim().trim_end_matches('/').to_string();
639    assert_safe_hub(&hub)?;
640    if selected_by_store {
641        let parsed =
642            url::Url::parse(&hub).map_err(|_| LinkError::UnsafeHub { hub: hub.clone() })?;
643        // A cloned store is not an operator opt-in to local-network access.
644        // Local/private development hubs must be selected explicitly by flag or
645        // environment, never by bytes inside the store.
646        if !parsed.scheme().eq_ignore_ascii_case("https")
647            || (parsed.path() != "/" && !parsed.path().is_empty())
648        {
649            return Err(LinkError::UnsafeHub { hub });
650        }
651    }
652
653    let key = match env_nonempty(HUB_KEY_ENV) {
654        Some(raw) => Some(clean_key(&raw)?),
655        None => None,
656    };
657
658    let agent_key = match env_nonempty(AGENT_KEY_FILE_ENV) {
659        Some(path) => Some(load_agent_key(Path::new(&path))?),
660        None => None,
661    };
662
663    let brain_key = match env_nonempty(BRAIN_KEY_FILE_ENV) {
664        Some(path) => Some(load_agent_key(Path::new(&path))?),
665        None => None,
666    };
667
668    // A cloned store controls `.dbmd/config`. It must never be able to point
669    // the process at an attacker origin and harvest the user's ambient account
670    // bearer, signed agent identity, or self-custodied brain signatures/content.
671    // Explicit --hub/DBMD_HUB_URL selection already pairs target + credential
672    // in the invocation environment; a store-selected target additionally
673    // needs an exact origin binding.
674    if selected_by_store && (key.is_some() || agent_key.is_some() || brain_key.is_some()) {
675        let bound = env_nonempty(HUB_CREDENTIAL_ORIGIN_ENV)
676            .and_then(|value| normalized_origin(&value).ok());
677        let selected_origin = normalized_origin(&hub)?;
678        if bound.as_deref() != Some(selected_origin.as_str()) {
679            return Err(LinkError::UnboundCredential);
680        }
681    }
682
683    Ok(HubConfig {
684        hub,
685        key,
686        agent_key,
687        brain_key,
688        state_dir: toolkit_state_dir()?,
689        store_selected: selected_by_store,
690    })
691}
692
693fn toolkit_state_dir() -> LinkResult<PathBuf> {
694    if let Some(path) = env_nonempty(STATE_DIR_ENV) {
695        let path = PathBuf::from(path);
696        if !path.is_absolute() {
697            return Err(LinkError::UnsafePath {
698                path: path.display().to_string(),
699            });
700        }
701        return Ok(path);
702    }
703    #[cfg(windows)]
704    if let Some(base) = env_nonempty("LOCALAPPDATA") {
705        let base = PathBuf::from(base);
706        if base.is_absolute() {
707            return Ok(base.join("dbmd").join("state"));
708        }
709    }
710    #[cfg(not(windows))]
711    if let Some(base) = env_nonempty("XDG_STATE_HOME") {
712        let base = PathBuf::from(base);
713        if base.is_absolute() {
714            return Ok(base.join("dbmd"));
715        }
716    }
717    let home = PathBuf::from(env_nonempty("HOME").ok_or_else(|| {
718        LinkError::Io(std::io::Error::new(
719            std::io::ErrorKind::NotFound,
720            format!("cannot locate user state; set {STATE_DIR_ENV}"),
721        ))
722    })?);
723    if !home.is_absolute() {
724        return Err(LinkError::UnsafePath {
725            path: home.display().to_string(),
726        });
727    }
728    #[cfg(target_os = "macos")]
729    {
730        Ok(home
731            .join("Library")
732            .join("Application Support")
733            .join("dbmd")
734            .join("state"))
735    }
736    #[cfg(all(not(target_os = "macos"), not(windows)))]
737    {
738        Ok(home.join(".local").join("state").join("dbmd"))
739    }
740}
741
742fn normalized_origin(value: &str) -> LinkResult<String> {
743    let parsed = url::Url::parse(value).map_err(|_| LinkError::UnsafeHub {
744        hub: value.to_string(),
745    })?;
746    if !(parsed.scheme().eq_ignore_ascii_case("https")
747        || parsed.scheme().eq_ignore_ascii_case("http"))
748        || !parsed.username().is_empty()
749        || parsed.password().is_some()
750        || (parsed.path() != "/" && !parsed.path().is_empty())
751        || parsed.query().is_some()
752        || parsed.fragment().is_some()
753    {
754        return Err(LinkError::UnsafeHub {
755            hub: value.to_string(),
756        });
757    }
758    let host = parsed.host_str().ok_or_else(|| LinkError::UnsafeHub {
759        hub: value.to_string(),
760    })?;
761    let host = if host.contains(':') {
762        format!("[{host}]")
763    } else {
764        host.to_ascii_lowercase()
765    };
766    let port = parsed
767        .port_or_known_default()
768        .ok_or_else(|| LinkError::UnsafeHub {
769            hub: value.to_string(),
770        })?;
771    let default = (parsed.scheme().eq_ignore_ascii_case("https") && port == 443)
772        || (parsed.scheme().eq_ignore_ascii_case("http") && port == 80);
773    Ok(format!(
774        "{}://{}{}",
775        parsed.scheme().to_ascii_lowercase(),
776        host,
777        if default {
778            String::new()
779        } else {
780            format!(":{port}")
781        }
782    ))
783}
784
785// ─────────────────────────────────────────────────────────────────────────────
786// Agent signing keys — link.md §8 `LinkMD-Sig` proof of possession
787// ─────────────────────────────────────────────────────────────────────────────
788
789/// The DER prefix that wraps a raw Ed25519 public key into a
790/// SubjectPublicKeyInfo (RFC 8410).
791const ED25519_SPKI_PREFIX: [u8; 12] = [
792    0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
793];
794
795fn bad_agent_key(message: &str) -> LinkError {
796    LinkError::BadAgentKey {
797        message: message.to_string(),
798    }
799}
800
801fn agent_keypair(pkcs8: &[u8]) -> LinkResult<ring::signature::Ed25519KeyPair> {
802    // `from_pkcs8` wants ring's own v2 encoding (private + public); keys from
803    // other tools are often PKCS#8 v1, which `maybe_unchecked` accepts by
804    // deriving the public half itself.
805    ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8)
806        .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(pkcs8))
807        .map_err(|_| bad_agent_key("not an Ed25519 PKCS#8 key"))
808}
809
810/// Derive `(publicKeySpki b64u, multikey)` from a keypair.
811fn public_identity_for(pair: &ring::signature::Ed25519KeyPair) -> (String, String) {
812    use ring::signature::KeyPair as _;
813    let mut spki = Vec::with_capacity(44);
814    spki.extend_from_slice(&ED25519_SPKI_PREFIX);
815    spki.extend_from_slice(pair.public_key().as_ref());
816    (
817        URL_SAFE_NO_PAD.encode(&spki),
818        format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&spki))),
819    )
820}
821
822/// Load and validate a signing-key file (agent or brain — same format):
823/// one base64url line of PKCS#8. Public so `dbmd key rotate` can load the
824/// old key explicitly.
825pub fn load_signing_key(path: &Path) -> LinkResult<AgentSigningKey> {
826    load_agent_key(path)
827}
828
829/// Load and validate the agent key file: one base64url line of PKCS#8.
830fn load_agent_key(path: &Path) -> LinkResult<AgentSigningKey> {
831    #[cfg(unix)]
832    let file = {
833        use std::os::fd::{AsRawFd as _, FromRawFd as _};
834        use std::os::unix::ffi::OsStrExt as _;
835        let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
836            .map_err(|e| bad_agent_key(&format!("cannot open the key parent: {e}")))?;
837        let leaf = path
838            .file_name()
839            .ok_or_else(|| bad_agent_key("the key path has no file name"))?;
840        let leaf = c_name(leaf.as_bytes(), &path.display().to_string())?;
841        let fd = unsafe {
842            libc::openat(
843                parent.as_raw_fd(),
844                leaf.as_ptr(),
845                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
846            )
847        };
848        if fd < 0 {
849            return Err(bad_agent_key(
850                "the key path must be an existing regular file without symlink ancestors",
851            ));
852        }
853        unsafe { std::fs::File::from_raw_fd(fd) }
854    };
855    #[cfg(not(unix))]
856    let file = std::fs::File::open(path)
857        .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
858    let metadata = file
859        .metadata()
860        .map_err(|e| bad_agent_key(&format!("cannot inspect the key file: {e}")))?;
861    if !metadata.is_file() {
862        return Err(bad_agent_key("the key path must be a regular file"));
863    }
864    #[cfg(unix)]
865    {
866        use std::os::unix::fs::PermissionsExt as _;
867        if metadata.permissions().mode() & 0o077 != 0 {
868            return Err(bad_agent_key(
869                "the key file is accessible to group/other; set mode 0600",
870            ));
871        }
872    }
873    let mut text = String::new();
874    file.take(1024 * 1024 + 1)
875        .read_to_string(&mut text)
876        .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
877    if text.len() > 1024 * 1024 {
878        return Err(bad_agent_key("the key file exceeds the size limit"));
879    }
880    let pkcs8 = URL_SAFE_NO_PAD
881        .decode(text.trim())
882        .map_err(|_| bad_agent_key("the key file is not one base64url line"))?;
883    let (public_key_spki, multikey) = public_identity_for(&agent_keypair(&pkcs8)?);
884    Ok(AgentSigningKey {
885        pkcs8,
886        multikey,
887        public_key_spki,
888    })
889}
890
891/// Durably create a new secret file without an exists→write race and without
892/// ever exposing a default-mode (commonly 0644) key between write and chmod.
893/// `create_new` also refuses a planted symlink. The file and its parent
894/// directory are synced before the caller can publish the corresponding
895/// public identity remotely.
896fn write_secret_new(path: &Path, bytes: &[u8]) -> LinkResult<()> {
897    #[cfg(unix)]
898    let (mut file, parent, leaf) = {
899        use std::os::fd::{AsRawFd as _, FromRawFd as _};
900        use std::os::unix::ffi::OsStrExt as _;
901        let parent = open_or_create_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))?;
902        let leaf_name = path
903            .file_name()
904            .ok_or_else(|| bad_agent_key("the output key path has no file name"))?;
905        let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
906        let fd = unsafe {
907            libc::openat(
908                parent.as_raw_fd(),
909                leaf.as_ptr(),
910                libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
911                0o600,
912            )
913        };
914        if fd < 0 {
915            let error = std::io::Error::last_os_error();
916            if error.kind() == std::io::ErrorKind::AlreadyExists {
917                return Err(bad_agent_key(
918                    "the output file already exists — refusing to overwrite a key",
919                ));
920            }
921            return Err(error.into());
922        }
923        (unsafe { std::fs::File::from_raw_fd(fd) }, parent, leaf)
924    };
925    #[cfg(not(unix))]
926    let mut file = std::fs::OpenOptions::new()
927        .write(true)
928        .create_new(true)
929        .open(path)
930        .map_err(|error| {
931            if error.kind() == std::io::ErrorKind::AlreadyExists {
932                bad_agent_key("the output file already exists — refusing to overwrite a key")
933            } else {
934                LinkError::Io(error)
935            }
936        })?;
937    if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_all()) {
938        drop(file);
939        #[cfg(unix)]
940        let _ =
941            unsafe { libc::unlinkat(std::os::fd::AsRawFd::as_raw_fd(&parent), leaf.as_ptr(), 0) };
942        #[cfg(not(unix))]
943        let _ = std::fs::remove_file(path);
944        return Err(LinkError::Io(error));
945    }
946    drop(file);
947    #[cfg(unix)]
948    parent.sync_all()?;
949    Ok(())
950}
951
952/// What `dbmd key generate` returns: the public identity to register plus
953/// where the secret landed.
954#[derive(Debug, Serialize)]
955pub struct GeneratedAgentKey {
956    /// `ed25519:<fingerprint>` — the grantable/registerable identity.
957    pub multikey: String,
958    /// base64url SPKI DER — what a hub's register endpoint takes.
959    #[serde(rename = "publicKeySpki")]
960    pub public_key_spki: String,
961    /// Where the PKCS#8 secret was written (mode 0600).
962    #[serde(rename = "keyFile")]
963    pub key_file: String,
964}
965
966/// Mint a fresh Ed25519 agent keypair. The secret is written to `out`
967/// (base64url PKCS#8, one line, 0600, refusing to overwrite); only public
968/// identity is returned. The private key never enters a store and never
969/// travels — requests carry per-request signatures instead (link.md §8).
970pub fn generate_agent_key(out: &Path) -> LinkResult<GeneratedAgentKey> {
971    require_hardened_filesystem("key generation")?;
972    let rng = ring::rand::SystemRandom::new();
973    let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
974        .map_err(|_| bad_agent_key("key generation failed"))?;
975    let pair = agent_keypair(pkcs8.as_ref())?;
976    let (spki_b64u, multikey) = public_identity_for(&pair);
977
978    write_secret_new(
979        out,
980        format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
981    )?;
982
983    Ok(GeneratedAgentKey {
984        multikey,
985        public_key_spki: spki_b64u,
986        key_file: out.display().to_string(),
987    })
988}
989
990/// Build the origin-bound `LinkMD-Sig` v2 header for one request:
991/// `canonical = "v2" LF origin LF METHOD LF path+query LF ts LF
992/// (sha256hex(body) | "-")`.
993///
994/// The origin is derived from the already-validated hub URL, never from
995/// attacker-controlled request metadata. Binding it closes the v1 replay
996/// class where a proof captured by one hub could be replayed to another hub
997/// serving the same path inside the timestamp window.
998fn linkmd_sig_header(
999    key: &AgentSigningKey,
1000    origin: &str,
1001    method: &str,
1002    path: &str,
1003    body: Option<&str>,
1004) -> LinkResult<String> {
1005    let ts = std::time::SystemTime::now()
1006        .duration_since(std::time::UNIX_EPOCH)
1007        .map_err(|_| bad_agent_key("system clock is before the epoch"))?
1008        .as_secs();
1009    let body_hash = match body {
1010        Some(b) => format!("{:x}", Sha256::digest(b.as_bytes())),
1011        None => "-".to_string(),
1012    };
1013    let canonical = format!(
1014        "v2\n{}\n{}\n{}\n{}\n{}",
1015        origin,
1016        method.to_uppercase(),
1017        path,
1018        ts,
1019        body_hash
1020    );
1021    let pair = agent_keypair(&key.pkcs8)?;
1022    let sig = URL_SAFE_NO_PAD.encode(pair.sign(canonical.as_bytes()).as_ref());
1023    let fingerprint = key.multikey.trim_start_matches("ed25519:");
1024    Ok(format!(
1025        "LinkMD-Sig v2,key=ed25519:{fingerprint},ts={ts},sig={sig}"
1026    ))
1027}
1028
1029// ─────────────────────────────────────────────────────────────────────────────
1030// Self-custody feed entries — the client signs what the hub only verifies
1031// ─────────────────────────────────────────────────────────────────────────────
1032
1033/// One `files` element of a wire-profile-v1 feed entry (SPEC §5.1: fields in
1034/// exactly this order).
1035#[derive(Serialize)]
1036struct WireFeedFile {
1037    path: String,
1038    sha256: String,
1039    bytes: u64,
1040}
1041
1042/// The unsigned entry in the normative §5.1 field order — serde serializes
1043/// struct fields in declaration order, which IS the wire contract.
1044#[derive(Serialize)]
1045struct UnsignedWireEntry<'a> {
1046    v: u8,
1047    seq: u64,
1048    ts: String,
1049    brain: &'a str,
1050    public_key: &'a str,
1051    kind: &'a str,
1052    op: &'a str,
1053    pack_sha256: &'a str,
1054    files: &'a [WireFeedFile],
1055    removed: &'a [String],
1056    prev_entry_hash: Option<&'a str>,
1057}
1058
1059/// Build and sign a wire-profile-v1 `push` feed entry with a self-custodied
1060/// brain key: serialize the unsigned entry compactly in the normative order,
1061/// Ed25519-sign those exact bytes, splice `sig` on as the final field. The
1062/// returned string is the exact serialization the hub stores verbatim (plus
1063/// one trailing newline) and every independent reader re-derives.
1064fn self_custody_entry(
1065    key: &AgentSigningKey,
1066    seq: u64,
1067    ts: String,
1068    pack_sha256: &str,
1069    files: &[WireFeedFile],
1070    prev_entry_hash: Option<&str>,
1071) -> LinkResult<String> {
1072    let removed: [String; 0] = [];
1073    let unsigned = serde_json::to_string(&UnsignedWireEntry {
1074        v: 1,
1075        seq,
1076        ts,
1077        brain: &key.multikey,
1078        public_key: &key.public_key_spki,
1079        kind: "push",
1080        op: "snapshot",
1081        pack_sha256,
1082        files,
1083        removed: &removed,
1084        prev_entry_hash,
1085    })
1086    .expect("serialize feed entry");
1087    let pair = agent_keypair(&key.pkcs8)?;
1088    let sig = URL_SAFE_NO_PAD.encode(pair.sign(unsigned.as_bytes()).as_ref());
1089    Ok(format!(
1090        "{},\"sig\":\"{}\"}}",
1091        &unsigned[..unsigned.len() - 1],
1092        sig
1093    ))
1094}
1095
1096/// An env var, treated as absent when unset or empty (an empty
1097/// `DBMD_HUB_KEY=` falls through rather than becoming an empty credential).
1098fn env_nonempty(name: &str) -> Option<String> {
1099    std::env::var(name).ok().filter(|v| !v.trim().is_empty())
1100}
1101
1102/// Read the `hub = <URL>` line out of a `.dbmd/config` file. The format is
1103/// deliberately minimal: `key = value` lines, `#` comments, unknown keys
1104/// ignored (forward-compatible). A missing or unreadable file is simply "not
1105/// configured here".
1106fn config_file_hub(path: &Path) -> Option<String> {
1107    const MAX_CONFIG_BYTES: u64 = 64 * 1024;
1108    #[cfg(unix)]
1109    let file = {
1110        use std::os::fd::{AsRawFd as _, FromRawFd as _};
1111        use std::os::unix::ffi::OsStrExt as _;
1112        let parent =
1113            open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new("."))).ok()?;
1114        let leaf = c_name(path.file_name()?.as_bytes(), &path.display().to_string()).ok()?;
1115        let fd = unsafe {
1116            libc::openat(
1117                parent.as_raw_fd(),
1118                leaf.as_ptr(),
1119                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1120            )
1121        };
1122        if fd < 0 {
1123            return None;
1124        }
1125        unsafe { std::fs::File::from_raw_fd(fd) }
1126    };
1127    #[cfg(not(unix))]
1128    let file = std::fs::File::open(path).ok()?;
1129    let metadata = file.metadata().ok()?;
1130    if !metadata.is_file() || metadata.len() > MAX_CONFIG_BYTES {
1131        return None;
1132    }
1133    let mut bytes = Vec::with_capacity(metadata.len() as usize);
1134    file.take(MAX_CONFIG_BYTES + 1)
1135        .read_to_end(&mut bytes)
1136        .ok()?;
1137    if bytes.len() as u64 > MAX_CONFIG_BYTES {
1138        return None;
1139    }
1140    let text = String::from_utf8(bytes).ok()?;
1141    for line in text.lines() {
1142        let line = line.trim();
1143        if line.is_empty() || line.starts_with('#') {
1144            continue;
1145        }
1146        if let Some((k, v)) = line.split_once('=') {
1147            if k.trim() == "hub" {
1148                let v = v.trim();
1149                if !v.is_empty() {
1150                    return Some(v.to_string());
1151                }
1152            }
1153        }
1154    }
1155    None
1156}
1157
1158/// The bearer key must never travel in cleartext; only loopback hosts may
1159/// skip TLS (local development against a hub on localhost).
1160fn assert_safe_hub(hub: &str) -> LinkResult<()> {
1161    let parsed = url::Url::parse(hub).map_err(|_| LinkError::UnsafeHub {
1162        hub: hub.to_string(),
1163    })?;
1164    if !(parsed.scheme().eq_ignore_ascii_case("https")
1165        || parsed.scheme().eq_ignore_ascii_case("http"))
1166        || !parsed.username().is_empty()
1167        || parsed.password().is_some()
1168        || (parsed.path() != "/" && !parsed.path().is_empty())
1169        || parsed.query().is_some()
1170        || parsed.fragment().is_some()
1171    {
1172        return Err(LinkError::UnsafeHub {
1173            hub: hub.to_string(),
1174        });
1175    }
1176    let loopback = match parsed.host() {
1177        Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
1178        Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
1179        Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
1180        None => false,
1181    };
1182    if parsed.scheme().eq_ignore_ascii_case("https") || loopback {
1183        Ok(())
1184    } else {
1185        Err(LinkError::UnsafeHub {
1186            hub: hub.to_string(),
1187        })
1188    }
1189}
1190
1191/// Trim paste artifacts and refuse anything outside the printable-ASCII token
1192/// range WITHOUT echoing the key — an HTTP library rejecting a bad header
1193/// value tends to echo the whole header line, credential included, so the
1194/// gate sits here instead.
1195fn clean_key(raw: &str) -> LinkResult<String> {
1196    let k = raw.trim();
1197    if k.is_empty() || k.bytes().any(|b| !(0x21..=0x7e).contains(&b)) {
1198        return Err(LinkError::BadKey);
1199    }
1200    Ok(k.to_string())
1201}
1202
1203// ─────────────────────────────────────────────────────────────────────────────
1204// Transport — one blocking agent, capped reads, the JSON-or-refuse contract
1205// ─────────────────────────────────────────────────────────────────────────────
1206
1207/// One hub response: the status plus the parsed JSON body when there was one.
1208#[derive(Debug)]
1209pub struct HubResponse {
1210    /// The HTTP status code.
1211    pub status: u16,
1212    /// The parsed JSON body, `None` when the body was empty or not JSON.
1213    pub body: Option<Value>,
1214}
1215
1216struct RawHubResponse {
1217    status: u16,
1218    body: Vec<u8>,
1219}
1220
1221/// Whether a request carries the bearer credential.
1222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1223enum Auth {
1224    /// Send `authorization: Bearer <key>`; error without a key.
1225    Required,
1226    /// Send no credential — the propose door is unauthenticated by design.
1227    None,
1228    /// Send the configured credential when one exists, otherwise nothing —
1229    /// brain-addressed propose works anonymously on public brains, and an
1230    /// authenticated caller earns a bigger actor-class budget.
1231    Optional,
1232}
1233
1234fn agent_builder_with_timeout(overall: std::time::Duration) -> ureq::AgentBuilder {
1235    ureq::AgentBuilder::new()
1236        .user_agent(concat!("dbmd/", env!("CARGO_PKG_VERSION")))
1237        // Never follow a redirect while a bearer, a store pack, or a signed
1238        // response is in flight. Callers see the 3xx as a non-success instead
1239        // of letting an origin steer sensitive material elsewhere.
1240        .redirects(0)
1241        .timeout_connect(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
1242        .timeout_read(std::time::Duration::from_secs(READ_TIMEOUT_SECS))
1243        .timeout_write(overall)
1244        .timeout(overall)
1245}
1246
1247fn agent_builder() -> ureq::AgentBuilder {
1248    agent_builder_with_timeout(std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS))
1249}
1250
1251fn agent() -> ureq::Agent {
1252    agent_builder().build()
1253}
1254
1255fn hub_agent(cfg: &HubConfig) -> LinkResult<ureq::Agent> {
1256    if !cfg.store_selected {
1257        return Ok(agent());
1258    }
1259    let parsed = url::Url::parse(&cfg.hub).map_err(|_| LinkError::UnsafeHub {
1260        hub: cfg.hub.clone(),
1261    })?;
1262    pinned_public_agent(&parsed, false, "store-selected hub")
1263}
1264
1265/// Perform one hub request. `path` is the binding path (starts with `/`);
1266/// `body` posts JSON. Transport failures, oversized bodies, and non-UTF-8 are
1267/// all surfaced as typed [`LinkError`]s; HTTP error statuses are returned in
1268/// the [`HubResponse`] for [`ensure_ok`] to shape.
1269fn request_raw(
1270    cfg: &HubConfig,
1271    method: &str,
1272    path: &str,
1273    body: Option<&Value>,
1274    auth: Auth,
1275    max_response_bytes: u64,
1276) -> LinkResult<RawHubResponse> {
1277    let http = hub_agent(cfg)?;
1278    request_raw_with_agent(cfg, &http, method, path, body, auth, max_response_bytes)
1279}
1280
1281fn request_raw_with_agent(
1282    cfg: &HubConfig,
1283    http: &ureq::Agent,
1284    method: &str,
1285    path: &str,
1286    body: Option<&Value>,
1287    auth: Auth,
1288    max_response_bytes: u64,
1289) -> LinkResult<RawHubResponse> {
1290    let url = format!("{}{}", cfg.hub, path);
1291    let encoded_body = body.map(Value::to_string);
1292    let origin = normalized_origin(&cfg.hub)?;
1293    // An agent signing key outranks the bearer: possession proofs put nothing
1294    // reusable on the wire, so when both are configured the stronger one wins.
1295    let credential = match auth {
1296        Auth::Required => Some(match &cfg.agent_key {
1297            Some(key) => linkmd_sig_header(key, &origin, method, path, encoded_body.as_deref())?,
1298            None => format!("Bearer {}", cfg.require_key()?),
1299        }),
1300        Auth::Optional => match &cfg.agent_key {
1301            Some(key) => Some(linkmd_sig_header(
1302                key,
1303                &origin,
1304                method,
1305                path,
1306                encoded_body.as_deref(),
1307            )?),
1308            None => cfg.key.as_deref().map(|k| format!("Bearer {k}")),
1309        },
1310        Auth::None => None,
1311    };
1312    let result = with_connect_retries(|| {
1313        let mut req = http.request(method, &url);
1314        if let Some(value) = &credential {
1315            req = req.set("authorization", value);
1316        }
1317        match &encoded_body {
1318            Some(value) => req
1319                .set("content-type", "application/json")
1320                .send_string(value)
1321                .map_err(Box::new),
1322            None => req.call().map_err(Box::new),
1323        }
1324    });
1325    let resp = match result {
1326        Ok(resp) => resp,
1327        Err(error) => match *error {
1328            ureq::Error::Status(_, resp) => resp,
1329            ureq::Error::Transport(error) => {
1330                return Err(LinkError::Transport {
1331                    hub: cfg.hub.clone(),
1332                    message: error.to_string(),
1333                });
1334            }
1335        },
1336    };
1337
1338    let status = resp.status();
1339    let mut buf = Vec::new();
1340    resp.into_reader()
1341        .take(max_response_bytes + 1)
1342        .read_to_end(&mut buf)?;
1343    if buf.len() as u64 > max_response_bytes {
1344        return Err(LinkError::ResponseTooLarge {
1345            limit_bytes: max_response_bytes,
1346        });
1347    }
1348    Ok(RawHubResponse { status, body: buf })
1349}
1350
1351fn request_capped(
1352    cfg: &HubConfig,
1353    method: &str,
1354    path: &str,
1355    body: Option<&Value>,
1356    auth: Auth,
1357    max_response_bytes: u64,
1358) -> LinkResult<HubResponse> {
1359    let raw = request_raw(cfg, method, path, body, auth, max_response_bytes)?;
1360    let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
1361    Ok(HubResponse {
1362        status: raw.status,
1363        body: parsed,
1364    })
1365}
1366
1367fn request(
1368    cfg: &HubConfig,
1369    method: &str,
1370    path: &str,
1371    body: Option<&Value>,
1372    auth: Auth,
1373) -> LinkResult<HubResponse> {
1374    request_capped(cfg, method, path, body, auth, MAX_RESPONSE_BYTES)
1375}
1376
1377fn ensure_raw_ok(r: RawHubResponse, what: &'static str) -> LinkResult<Vec<u8>> {
1378    if (200..300).contains(&r.status) {
1379        return Ok(r.body);
1380    }
1381    ensure_ok(
1382        HubResponse {
1383            status: r.status,
1384            body: serde_json::from_slice(&r.body).ok(),
1385        },
1386        what,
1387    )
1388    .and_then(|_| Err(invalid_feed("a non-2xx response was accepted unexpectedly")))
1389}
1390
1391/// These failures happen before any HTTP request reaches the hub, so retrying
1392/// cannot duplicate a mutation. Mid-stream I/O is deliberately excluded: once
1393/// bytes may have crossed the wire, the caller must rely on the verb's own
1394/// idempotency contract instead of guessing.
1395fn is_pre_request_transport(kind: ureq::ErrorKind) -> bool {
1396    matches!(
1397        kind,
1398        ureq::ErrorKind::Dns | ureq::ErrorKind::ConnectionFailed | ureq::ErrorKind::ProxyConnect
1399    )
1400}
1401
1402fn with_connect_retries(
1403    mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
1404) -> Result<ureq::Response, Box<ureq::Error>> {
1405    let mut attempt = 0;
1406    loop {
1407        match send() {
1408            Err(error)
1409                if matches!(
1410                    error.as_ref(),
1411                    ureq::Error::Transport(transport)
1412                        if is_pre_request_transport(transport.kind())
1413                ) && attempt + 1 < CONNECT_ATTEMPTS =>
1414            {
1415                std::thread::sleep(std::time::Duration::from_millis(
1416                    CONNECT_RETRY_BACKOFF_MS[attempt],
1417                ));
1418                attempt += 1;
1419            }
1420            result => return result,
1421        }
1422    }
1423}
1424
1425fn hub_is_loopback(hub: &str) -> bool {
1426    url::Url::parse(hub).ok().is_some_and(|parsed| {
1427        parsed.host().is_some_and(|host| match host {
1428            url::Host::Domain(host) => host.eq_ignore_ascii_case("localhost"),
1429            url::Host::Ipv4(ip) => ip.is_loopback(),
1430            url::Host::Ipv6(ip) => ip.is_loopback(),
1431        })
1432    })
1433}
1434
1435fn presigned_agent(cfg: &HubConfig, raw: &str) -> LinkResult<ureq::Agent> {
1436    let parsed = url::Url::parse(raw).map_err(|_| LinkError::InvalidPack {
1437        message: "the hub returned an invalid object-store URL".to_string(),
1438    })?;
1439    let allow_private = hub_is_loopback(&cfg.hub)
1440        || env_nonempty(ALLOW_PRIVATE_OBJECT_URL_ENV).as_deref() == Some("1");
1441    if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1442        || !parsed.username().is_empty()
1443        || parsed.password().is_some()
1444        || parsed.fragment().is_some()
1445    {
1446        return Err(LinkError::InvalidPack {
1447            message: "the hub returned an unsafe object-store URL".to_string(),
1448        });
1449    }
1450    pinned_public_agent(&parsed, allow_private, "object-store URL").map_err(|_| {
1451        LinkError::InvalidPack {
1452            message: "the hub returned an object-store URL with an unsafe network target"
1453                .to_string(),
1454        }
1455    })
1456}
1457
1458fn put_presigned(cfg: &HubConfig, raw: &str, headers: &Value, bytes: &[u8]) -> LinkResult<()> {
1459    let http = presigned_agent(cfg, raw)?;
1460    let result = with_connect_retries(|| {
1461        let mut req = http.put(raw);
1462        if let Some(map) = headers.as_object() {
1463            for (name, value) in map {
1464                if let Some(value) = value.as_str() {
1465                    req = req.set(name, value);
1466                }
1467            }
1468        }
1469        req.send_bytes(bytes).map_err(Box::new)
1470    });
1471    match result {
1472        Ok(resp) if (200..300).contains(&resp.status()) => Ok(()),
1473        Ok(resp) => Err(LinkError::Http {
1474            what: "pack upload",
1475            status: resp.status(),
1476            message: "object store rejected the upload".to_string(),
1477            code: None,
1478            details: None,
1479        }),
1480        Err(error) => match *error {
1481            // Immutable uploads use If-None-Match. A concurrent writer may
1482            // win the same content address; the commit/confirm endpoint reads
1483            // and hashes that object before accepting it, so 412 safely means
1484            // "continue to verification", never "trust the upload".
1485            ureq::Error::Status(412, _) => Ok(()),
1486            ureq::Error::Status(_, resp) => Err(LinkError::Http {
1487                what: "pack upload",
1488                status: resp.status(),
1489                message: "object store rejected the upload".to_string(),
1490                code: None,
1491                details: None,
1492            }),
1493            ureq::Error::Transport(err) => Err(LinkError::Transport {
1494                hub: "the object store".to_string(),
1495                message: err.to_string(),
1496            }),
1497        },
1498    }
1499}
1500
1501fn one_past_bounded_limit(max_bytes: u64) -> Option<u64> {
1502    max_bytes.checked_add(1)
1503}
1504
1505fn presigned_download_read_limit() -> u64 {
1506    one_past_bounded_limit(MAX_PACK_BYTES)
1507        .expect("the fixed presigned-download ceiling must leave room for the refusal byte")
1508}
1509
1510fn get_presigned(cfg: &HubConfig, raw: &str) -> LinkResult<Vec<u8>> {
1511    let http = presigned_agent(cfg, raw)?;
1512    let resp = match with_connect_retries(|| http.get(raw).call().map_err(Box::new)) {
1513        Ok(resp) => resp,
1514        Err(error) => match *error {
1515            ureq::Error::Status(_, resp) => {
1516                return Err(LinkError::Http {
1517                    what: "pack download",
1518                    status: resp.status(),
1519                    message: "object store rejected the download".to_string(),
1520                    code: None,
1521                    details: None,
1522                });
1523            }
1524            ureq::Error::Transport(err) => {
1525                return Err(LinkError::Transport {
1526                    hub: "the object store".to_string(),
1527                    message: err.to_string(),
1528                });
1529            }
1530        },
1531    };
1532    if !(200..300).contains(&resp.status()) {
1533        return Err(LinkError::Http {
1534            what: "pack download",
1535            status: resp.status(),
1536            message: "object store rejected the download".to_string(),
1537            code: None,
1538            details: None,
1539        });
1540    }
1541    let mut bytes = Vec::new();
1542    resp.into_reader()
1543        .take(presigned_download_read_limit())
1544        .read_to_end(&mut bytes)?;
1545    if bytes.len() as u64 > MAX_PACK_BYTES {
1546        return Err(LinkError::InvalidPack {
1547            message: "download exceeds the compressed-size limit".to_string(),
1548        });
1549    }
1550    Ok(bytes)
1551}
1552
1553/// Unwrap a successful JSON body, or shape the failure: a >=400 surfaces the
1554/// hub's own `error` + `code`; a 2xx without JSON is refused as not a hub
1555/// answer.
1556fn ensure_ok(r: HubResponse, what: &'static str) -> LinkResult<Value> {
1557    if !(200..300).contains(&r.status) {
1558        let message = r
1559            .body
1560            .as_ref()
1561            .and_then(|b| b.get("error"))
1562            .and_then(Value::as_str)
1563            .unwrap_or("unknown error")
1564            .to_string();
1565        let code = r
1566            .body
1567            .as_ref()
1568            .and_then(|b| b.get("code"))
1569            .and_then(Value::as_str)
1570            .map(str::to_string);
1571        let details = r.body.as_ref().and_then(|b| b.get("details")).cloned();
1572        return Err(LinkError::Http {
1573            what,
1574            status: r.status,
1575            message,
1576            code,
1577            details,
1578        });
1579    }
1580    r.body.ok_or(LinkError::NotJson {
1581        what,
1582        status: r.status,
1583    })
1584}
1585
1586// ─────────────────────────────────────────────────────────────────────────────
1587// resolve — handle → brain card; @brain/id → the record
1588// ─────────────────────────────────────────────────────────────────────────────
1589
1590/// Resolve an address. A bare `@brain` returns the brain card (metadata +
1591/// index stats — the v0 form of the card; keys arrive with the protocol's
1592/// signing layer). `@brain/<id>` and `@brain/<path>.md` return the full
1593/// record, frontmatter + body.
1594fn is_public_registry_ip(ip: std::net::IpAddr) -> bool {
1595    match ip {
1596        std::net::IpAddr::V4(ip) => {
1597            let [a, b, c, _] = ip.octets();
1598            !(a == 0
1599                || a == 10
1600                || a == 127
1601                || (a == 100 && (64..=127).contains(&b))
1602                || (a == 169 && b == 254)
1603                || (a == 172 && (16..=31).contains(&b))
1604                || (a == 192 && b == 0 && c == 0)
1605                || (a == 192 && b == 0 && c == 2)
1606                || (a == 192 && b == 88 && c == 99)
1607                || (a == 192 && b == 168)
1608                || (a == 198 && (b == 18 || b == 19))
1609                || (a == 198 && b == 51 && c == 100)
1610                || (a == 203 && b == 0 && c == 113)
1611                || a >= 224)
1612        }
1613        std::net::IpAddr::V6(ip) => {
1614            let segments = ip.segments();
1615            // Conservatively accept only global unicast 2000::/3, excluding
1616            // special-purpose/transition blocks. In particular, 6to4 embeds
1617            // an IPv4 destination and must not tunnel a validated public
1618            // connect to 127/8 or RFC1918.
1619            (segments[0] & 0xe000) == 0x2000
1620                && !(segments[0] == 0x2001 && (segments[1] & 0xfe00) == 0)
1621                && !(segments[0] == 0x2001 && segments[1] == 0x0db8)
1622                && segments[0] != 0x2002
1623                && !(segments[0] == 0x3fff && (segments[1] & 0xf000) == 0)
1624        }
1625    }
1626}
1627
1628#[derive(Clone)]
1629struct PinnedRegistryResolver {
1630    netloc: String,
1631    addresses: Vec<std::net::SocketAddr>,
1632}
1633
1634impl ureq::Resolver for PinnedRegistryResolver {
1635    fn resolve(&self, requested: &str) -> std::io::Result<Vec<std::net::SocketAddr>> {
1636        if requested == self.netloc {
1637            Ok(self.addresses.clone())
1638        } else {
1639            Err(std::io::Error::new(
1640                std::io::ErrorKind::PermissionDenied,
1641                "registry request attempted to resolve an unvalidated authority",
1642            ))
1643        }
1644    }
1645}
1646
1647fn pinned_public_agent(
1648    url: &url::Url,
1649    allow_private: bool,
1650    label: &str,
1651) -> LinkResult<ureq::Agent> {
1652    let host = url
1653        .host_str()
1654        .ok_or_else(|| invalid_feed(format!("{label} has no host")))?;
1655    let port = url
1656        .port_or_known_default()
1657        .ok_or_else(|| invalid_feed(format!("{label} has no port")))?;
1658    let addresses = resolve_addresses_with_deadline(
1659        host,
1660        port,
1661        std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS),
1662    )
1663    .map_err(|error| invalid_feed(format!("{label} DNS resolution failed: {error}")))?;
1664    if addresses.is_empty() {
1665        return Err(invalid_feed(format!("{label} DNS returned no addresses")));
1666    }
1667    if !allow_private
1668        && addresses
1669            .iter()
1670            .any(|address| !is_public_registry_ip(address.ip()))
1671    {
1672        return Err(invalid_feed(format!(
1673            "{label} resolves to a non-public address"
1674        )));
1675    }
1676    let netloc = if host.contains(':') {
1677        format!("[{host}]:{port}")
1678    } else {
1679        format!("{host}:{port}")
1680    };
1681    Ok(agent_builder()
1682        .resolver(PinnedRegistryResolver { netloc, addresses })
1683        .build())
1684}
1685
1686/// Resolve one authority without allowing libc DNS to hold the caller forever.
1687/// `ToSocketAddrs` itself has no timeout API, so resolution runs in a detached
1688/// worker and only its result channel is awaited. A late resolver result has no
1689/// side effects and is discarded after the deadline.
1690fn resolve_addresses_with_deadline(
1691    host: &str,
1692    port: u16,
1693    timeout: std::time::Duration,
1694) -> std::io::Result<Vec<std::net::SocketAddr>> {
1695    use std::net::ToSocketAddrs as _;
1696
1697    let host = host.to_string();
1698    let (send, receive) = std::sync::mpsc::sync_channel(1);
1699    std::thread::Builder::new()
1700        .name("dbmd-dns".to_string())
1701        .spawn(move || {
1702            let result = (host.as_str(), port)
1703                .to_socket_addrs()
1704                .map(|addresses| addresses.collect());
1705            let _ = send.send(result);
1706        })
1707        .map_err(|error| std::io::Error::other(format!("cannot start resolver: {error}")))?;
1708    match receive.recv_timeout(timeout) {
1709        Ok(result) => result,
1710        Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(std::io::Error::new(
1711            std::io::ErrorKind::TimedOut,
1712            "resolution exceeded its deadline",
1713        )),
1714        Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(std::io::Error::other(
1715            "resolver stopped without returning a result",
1716        )),
1717    }
1718}
1719
1720fn registry_agent(url: &url::Url) -> LinkResult<ureq::Agent> {
1721    let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
1722    pinned_public_agent(url, allow_private, "registry home")
1723}
1724
1725/// GET an absolute URL as JSON with NO credential — used to fetch a brain card
1726/// from a FOREIGN home during registry resolution. DNS is resolved once,
1727/// every answer is required to be public, and that exact answer set is pinned
1728/// into a no-redirect HTTP agent to close private-network SSRF and rebinding.
1729fn get_json_absolute(url: &str) -> LinkResult<Value> {
1730    let parsed = url::Url::parse(url).map_err(|_| invalid_feed("invalid registry home URL"))?;
1731    let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
1732    if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1733        || !parsed.username().is_empty()
1734        || parsed.password().is_some()
1735        || parsed.query().is_some()
1736        || parsed.fragment().is_some()
1737    {
1738        return Err(invalid_feed("unsafe registry home URL"));
1739    }
1740    let http = registry_agent(&parsed)?;
1741    let resp = match with_connect_retries(|| http.get(url).call().map_err(Box::new)) {
1742        Ok(resp) => resp,
1743        Err(error) => match *error {
1744            ureq::Error::Status(status, resp) => {
1745                let _ = resp;
1746                return Err(LinkError::Http {
1747                    what: "registry home fetch",
1748                    status,
1749                    message: "the home node rejected the card request".to_string(),
1750                    code: None,
1751                    details: None,
1752                });
1753            }
1754            ureq::Error::Transport(err) => {
1755                return Err(LinkError::Transport {
1756                    hub: url.to_string(),
1757                    message: err.to_string(),
1758                });
1759            }
1760        },
1761    };
1762    if !(200..300).contains(&resp.status()) {
1763        return Err(LinkError::Http {
1764            what: "registry home fetch",
1765            status: resp.status(),
1766            message: "the home node returned a redirect or error".to_string(),
1767            code: None,
1768            details: None,
1769        });
1770    }
1771    let mut buf = Vec::new();
1772    resp.into_reader()
1773        .take(MAX_REGISTRY_CARD_BYTES + 1)
1774        .read_to_end(&mut buf)?;
1775    if buf.len() as u64 > MAX_REGISTRY_CARD_BYTES {
1776        return Err(LinkError::ResponseTooLarge {
1777            limit_bytes: MAX_REGISTRY_CARD_BYTES,
1778        });
1779    }
1780    serde_json::from_slice(&buf).map_err(|_| LinkError::InvalidFeed {
1781        message: "the home node returned invalid JSON".to_string(),
1782    })
1783}
1784
1785/// Resolve a bare `@handle` through the federation registry (link.md §7.1,
1786/// E5): look the handle up in the hub's registry, fetch the brain card from
1787/// the returned HOME node, and PIN — the card's identity fingerprint must
1788/// equal the registry's, or resolution fails. Returns the card enriched with
1789/// the resolved `home`, or `Ok(None)` when the registry has no such handle
1790/// (so the caller can fall back to a direct lookup).
1791pub fn resolve_registry(cfg: &HubConfig, handle: &str) -> LinkResult<Option<Value>> {
1792    require_safe_ref(handle)?;
1793    // Hold the validated trust-directory capability before any network I/O.
1794    // Every lock/load/save below remains relative to this exact inode even if
1795    // an attacker swaps an ancestor while the registry or home is answering.
1796    let trust_directory = open_trust_dir(cfg)?;
1797    let reg = request_capped(
1798        cfg,
1799        "GET",
1800        &format!("/api/hub/registry/{handle}"),
1801        None,
1802        Auth::None,
1803        MAX_REGISTRY_CARD_BYTES,
1804    )?;
1805    if reg.status == 404 {
1806        return Ok(None);
1807    }
1808    let body = ensure_ok(reg, "registry resolve")?;
1809    let home = body
1810        .get("home")
1811        .and_then(Value::as_str)
1812        .ok_or_else(|| invalid_feed("registry entry has no home"))?;
1813    let brain = body
1814        .get("brain")
1815        .and_then(Value::as_str)
1816        .ok_or_else(|| invalid_feed("registry entry has no brain"))?;
1817    if !crate::ulid::is_ulid(brain) {
1818        return Err(invalid_feed(
1819            "registry entry brain is not a canonical lowercase ULID",
1820        ));
1821    }
1822    let want_fp = body
1823        .get("identity")
1824        .and_then(|i| i.get("fingerprint"))
1825        .and_then(Value::as_str)
1826        .ok_or_else(|| invalid_feed("registry entry has no identity fingerprint"))?;
1827
1828    let home = home.trim_end_matches('/');
1829    let origin = normalized_origin(home)?;
1830    if origin != home {
1831        return Err(invalid_feed(
1832            "registry home must be an origin without a path, query, or fragment",
1833        ));
1834    }
1835    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[handle, brain])?;
1836    let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, handle, brain)?;
1837    if let Some(binding) = &alias_binding {
1838        if binding
1839            .home
1840            .as_deref()
1841            .is_some_and(|pinned_home| pinned_home != home)
1842        {
1843            return Err(invalid_feed(
1844                "registry relocated a pinned handle to a different home",
1845            ));
1846        }
1847    }
1848    let card = get_json_absolute(&format!("{home}/api/hub/brains/{brain}"))?;
1849    if card.get("id").and_then(Value::as_str) != Some(brain) {
1850        return Err(invalid_feed(
1851            "the home node served a card for a different brain",
1852        ));
1853    }
1854    let identity: FeedIdentity = serde_json::from_value(
1855        card.get("identity")
1856            .cloned()
1857            .ok_or_else(|| invalid_feed("the home node served no identity"))?,
1858    )
1859    .map_err(|_| invalid_feed("the home node served an invalid identity"))?;
1860    let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
1861    let got_fp = card
1862        .get("identity")
1863        .and_then(|i| i.get("fingerprint"))
1864        .and_then(Value::as_str)
1865        .unwrap_or_default();
1866    if got_fp != want_fp {
1867        return Err(invalid_feed(
1868            "the home node served an identity that does not match the registry — refusing",
1869        ));
1870    }
1871    let current = format!("ed25519:{}", identity.fingerprint);
1872    let advertised_seq = card
1873        .get("headSeq")
1874        .and_then(Value::as_u64)
1875        .ok_or_else(|| invalid_feed("the home node served an invalid head sequence"))?;
1876    let advertised_hash = card.get("feedHash").and_then(Value::as_str);
1877    if (advertised_seq == 0 && !card.get("feedHash").is_some_and(Value::is_null))
1878        || (advertised_seq > 0 && advertised_hash.is_none_or(|hash| !is_sha256(hash)))
1879    {
1880        return Err(invalid_feed(
1881            "the home node served an invalid feed head boundary",
1882        ));
1883    }
1884    // This is required even on first contact. Otherwise a syntactically valid
1885    // identity may claim that a rotation happened after a head the home has
1886    // never reached, then become the permanent TOFU anchor.
1887    verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], advertised_seq)?;
1888    let registry_alias = AliasBinding {
1889        v: 1,
1890        origin: normalized_origin(&cfg.hub)?,
1891        requested: handle.to_string(),
1892        brain: brain.to_string(),
1893        home: Some(home.to_string()),
1894    };
1895    save_canonical_pin_and_alias(
1896        cfg,
1897        &trust_directory,
1898        handle,
1899        brain,
1900        TrustState {
1901            v: 2,
1902            origin: normalized_origin(&cfg.hub)?,
1903            requested: brain.to_string(),
1904            brain: brain.to_string(),
1905            home: None,
1906            anchor,
1907            current,
1908            head_seq: pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq),
1909            feed_hash: pinned
1910                .as_ref()
1911                .and_then(|checkpoint| checkpoint.feed_hash.clone()),
1912            rotations: identity.rotations.clone(),
1913            hub_signer: None,
1914            protocol_profile: None,
1915        },
1916        Some(&registry_alias),
1917    )?;
1918    let mut out = card;
1919    if let Value::Object(map) = &mut out {
1920        map.insert("home".to_string(), Value::String(home.to_string()));
1921        map.insert(
1922            "resolvedVia".to_string(),
1923            Value::String("registry".to_string()),
1924        );
1925    }
1926    Ok(Some(out))
1927}
1928
1929pub fn resolve(cfg: &HubConfig, addr: &Address) -> LinkResult<Value> {
1930    // `Address::parse` refuses these shapes already, but `Address` has public
1931    // fields — re-assert at the wire so a hand-built address can never
1932    // reshape the request path.
1933    require_safe_ref(&addr.brain)?;
1934    if let Some(target) = &addr.target {
1935        let (given, ok) = match target {
1936            AddressTarget::Id(id) => (id, crate::ulid::is_ulid(id)),
1937            AddressTarget::Path(p) => (p, safe_store_rel_path(p) && p.ends_with(".md")),
1938        };
1939        if !ok {
1940            return Err(LinkError::BadAddress {
1941                given: given.clone(),
1942                reason: BAD_TARGET_REASON.to_string(),
1943            });
1944        }
1945    }
1946
1947    // A record is never accepted from the hub's mutable query/index response:
1948    // that response was not covered by the brain's feed signature and could
1949    // return arbitrary frontmatter/body while an unrelated signed head still
1950    // verified. Materialize the record from the exact content-addressed pack
1951    // named by the verified signed head instead.
1952    if let Some(target) = &addr.target {
1953        let remote = verified_remote_head(cfg, &addr.brain, false)?;
1954        if !remote.head.verified {
1955            return Err(invalid_feed(
1956                "a path-scoped feed cannot prove a record against the full signed snapshot",
1957            ));
1958        }
1959        if remote.head.seq == 0 {
1960            return Err(LinkError::Http {
1961                what: "resolve",
1962                status: 404,
1963                message: "record not found".to_string(),
1964                code: Some("NOT_FOUND".to_string()),
1965                details: None,
1966            });
1967        }
1968        let brain = remote.head.brain.clone();
1969        let pack = download_verified_snapshot_pack(cfg, &brain, &remote)?;
1970        return resolve_from_verified_pack(&brain, target, pack);
1971    }
1972
1973    let path = format!("/api/hub/brains/{}", addr.brain);
1974    // Direct first: the caller's own slug and hub-hosted public handles resolve
1975    // here unchanged. Only a bare `@handle` the hub can't resolve directly
1976    // (404) falls through to the federation registry — how a handle reaches a
1977    // brain on ANOTHER node (link.md §7.1, E5).
1978    let direct = request(cfg, "GET", &path, None, Auth::Required)?;
1979    if direct.status == 404 && addr.target.is_none() && !crate::ulid::is_ulid(&addr.brain) {
1980        if let Some(card) = resolve_registry(cfg, &addr.brain)? {
1981            return Ok(card);
1982        }
1983    }
1984    let resolved = ensure_ok(direct, "resolve")?;
1985    // A successful direct response is accepted only after the same centralized
1986    // identity/rotation/feed checkpoint verification used by sync and
1987    // subscribe. No verb gets a weaker ad-hoc pinning path.
1988    let remote = verified_remote_head(cfg, &addr.brain, false)?;
1989    if resolved.get("id").and_then(Value::as_str) != Some(remote.head.brain.as_str())
1990        || resolved.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
1991        || resolved.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
1992    {
1993        return Err(invalid_feed(
1994            "resolve card is not bound to the exact verified feed checkpoint",
1995        ));
1996    }
1997    let card_identity: FeedIdentity = serde_json::from_value(
1998        resolved
1999            .get("identity")
2000            .cloned()
2001            .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2002    )
2003    .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2004    if remote.identity.as_ref() != Some(&card_identity) {
2005        return Err(invalid_feed(
2006            "resolve card identity differs from the verified feed identity",
2007        ));
2008    }
2009    Ok(resolved)
2010}
2011
2012/// Resolve one record strictly from the exact signed snapshot pack. The hub's
2013/// mutable query/index result is intentionally not consulted: a signed pack
2014/// digest is the only cryptographic binding between a feed checkpoint and
2015/// record bytes in wire profile v1.
2016fn resolve_from_verified_pack(
2017    brain: &str,
2018    target: &AddressTarget,
2019    pack: Vec<u8>,
2020) -> LinkResult<Value> {
2021    let entries = parse_store_pack(pack)?;
2022    let mut matched: Option<(String, Vec<u8>)> = None;
2023
2024    for (path, bytes) in entries {
2025        let is_candidate = match target {
2026            AddressTarget::Path(want) => &path == want,
2027            AddressTarget::Id(_) => {
2028                path.ends_with(".md")
2029                    && (path.starts_with("records/") || path.starts_with("sources/"))
2030            }
2031        };
2032        if !is_candidate {
2033            continue;
2034        }
2035        let text = std::str::from_utf8(&bytes)
2036            .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2037        let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2038            .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2039        if let AddressTarget::Id(want) = target {
2040            let frontmatter =
2041                crate::parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(&path))
2042                    .map_err(|_| {
2043                        invalid_feed(format!("signed snapshot record `{path}` is malformed"))
2044                    })?;
2045            if frontmatter.id.as_deref() != Some(want) {
2046                continue;
2047            }
2048        }
2049        if matched.is_some() {
2050            return Err(invalid_feed(
2051                "signed snapshot contains more than one record for the requested target",
2052            ));
2053        }
2054        matched = Some((path, bytes));
2055    }
2056
2057    let (path, bytes) = matched.ok_or_else(|| LinkError::Http {
2058        what: "resolve",
2059        status: 404,
2060        message: "record not found".to_string(),
2061        code: Some("NOT_FOUND".to_string()),
2062        details: None,
2063    })?;
2064    let text = std::str::from_utf8(&bytes)
2065        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2066    let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2067        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2068    let frontmatter: Value = serde_norway::from_str(&parsed.frontmatter_yaml)
2069        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2070    let Value::Object(fields) = frontmatter else {
2071        return Err(invalid_feed(format!(
2072            "signed snapshot record `{path}` frontmatter is not a mapping"
2073        )));
2074    };
2075    let mut document = serde_json::Map::new();
2076    document.insert("path".to_string(), Value::String(path));
2077    for (key, value) in fields {
2078        document.insert(key, value);
2079    }
2080    document.insert("body".to_string(), Value::String(parsed.body));
2081    document.insert(
2082        "contentSha".to_string(),
2083        Value::String(content_sha256(&bytes)),
2084    );
2085    Ok(json!({
2086        "brain": brain,
2087        "document": Value::Object(document),
2088    }))
2089}
2090
2091// ─────────────────────────────────────────────────────────────────────────────
2092// sync — pull the granted slice as files; push the local store as a snapshot
2093// ─────────────────────────────────────────────────────────────────────────────
2094
2095/// What a pull materialized.
2096#[derive(Debug, serde::Serialize)]
2097pub struct PullReport {
2098    /// The brain id the hub reported.
2099    pub brain: String,
2100    /// The brain's slug.
2101    pub slug: String,
2102    /// The hub's feed head at export time.
2103    #[serde(rename = "headSeq")]
2104    pub head_seq: u64,
2105    /// How many files were written.
2106    pub files: usize,
2107    /// Where they were written (as given or derived from the slug).
2108    pub dest: String,
2109    /// Local content files that the export did not carry — present so a
2110    /// caller sees divergence; nothing is ever deleted locally.
2111    #[serde(rename = "extraLocal")]
2112    pub extra_local: Vec<String>,
2113    /// Exact convergence result from the post-install barrier.
2114    #[serde(rename = "syncStatus")]
2115    pub sync_status: String,
2116}
2117
2118fn download_verified_snapshot_pack(
2119    cfg: &HubConfig,
2120    brain: &str,
2121    remote: &VerifiedRemote,
2122) -> LinkResult<Vec<u8>> {
2123    let feed_hash = remote
2124        .head
2125        .feed_hash
2126        .as_deref()
2127        .ok_or_else(|| invalid_feed("non-empty snapshot has no verified feed hash"))?;
2128    let signed_head = remote
2129        .head_entry
2130        .as_ref()
2131        .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
2132    let expected = &signed_head.entry.pack_sha256;
2133    if !is_sha256(expected) {
2134        return Err(invalid_feed(
2135            "signed head carries an invalid snapshot pack digest",
2136        ));
2137    }
2138    let path = format!(
2139        "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={feed_hash}",
2140        remote.head.seq
2141    );
2142    let body = ensure_ok(
2143        request(cfg, "GET", &path, None, Auth::Required)?,
2144        "sync pull",
2145    )?;
2146    if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2147        || body.get("feedHash").and_then(Value::as_str) != Some(feed_hash)
2148        || body.get("brain").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2149        || body.get("sha256").and_then(Value::as_str) != Some(expected.as_str())
2150    {
2151        return Err(invalid_feed(
2152            "export response is not bound to the exact verified snapshot",
2153        ));
2154    }
2155    let url = body
2156        .get("url")
2157        .and_then(Value::as_str)
2158        .ok_or_else(|| invalid_feed("verified snapshot export carried no exact pack URL"))?;
2159    let bytes = get_presigned(cfg, url)?;
2160    if content_sha256(&bytes) != *expected {
2161        return Err(LinkError::InvalidPack {
2162            message: "downloaded pack does not match the signed snapshot digest".to_string(),
2163        });
2164    }
2165    let entries = parse_store_pack(bytes.clone())?;
2166    if signed_head.entry.kind == "push" {
2167        verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
2168    }
2169    Ok(bytes)
2170}
2171
2172#[derive(Debug, Clone, Deserialize, Serialize)]
2173struct V2PointerBody {
2174    v: u8,
2175    brain: String,
2176    seq: u64,
2177    commit_hash: String,
2178    feed_hash: String,
2179    content_root: Option<String>,
2180    asset_root: Option<String>,
2181    materializer: String,
2182    signer_epoch: u64,
2183    control_revision: String,
2184    backup_preparation: String,
2185    prior_pointer_hash: Option<String>,
2186    signed_at: String,
2187}
2188
2189#[derive(Debug, Clone, Deserialize)]
2190struct V2SignedPointer {
2191    pointer: V2PointerBody,
2192    hub_public_key: String,
2193    hub_fingerprint: String,
2194    sig: String,
2195}
2196
2197#[derive(Debug, Clone, Deserialize)]
2198struct V2HeadIdentity {
2199    fingerprint: String,
2200    public_key_spki: String,
2201    #[serde(default)]
2202    previous: Vec<V2PreviousIdentity>,
2203    #[serde(default)]
2204    rotations: Vec<String>,
2205}
2206
2207#[derive(Debug, Clone, Deserialize)]
2208struct V2PreviousIdentity {
2209    fingerprint: String,
2210    public_key_spki: String,
2211}
2212
2213#[derive(Debug, Deserialize)]
2214struct V2HeadResponse {
2215    v: u8,
2216    brain_id: String,
2217    profile: String,
2218    view: Option<V2HeadView>,
2219    pointer: Option<V2SignedPointer>,
2220    identity: Option<V2HeadIdentity>,
2221}
2222
2223#[derive(Debug, Clone, Deserialize)]
2224struct V2HeadView {
2225    kind: String,
2226    control_revision: String,
2227}
2228
2229#[derive(Debug, Clone)]
2230struct V2VerifiedHead {
2231    requested: String,
2232    brain_id: String,
2233    view_kind: String,
2234    view_revision: String,
2235    pointer: Option<V2PointerBody>,
2236    trust: TrustState,
2237    alias: Option<AliasBinding>,
2238}
2239
2240fn verify_v2_spki_signature(
2241    public_key: &str,
2242    message: &[u8],
2243    signature: &str,
2244) -> LinkResult<Vec<u8>> {
2245    let der = URL_SAFE_NO_PAD
2246        .decode(public_key)
2247        .map_err(|_| invalid_feed("v2 signer public key is not base64url"))?;
2248    if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
2249        return Err(invalid_feed("v2 signer public key is not Ed25519 SPKI"));
2250    }
2251    let sig = URL_SAFE_NO_PAD
2252        .decode(signature)
2253        .map_err(|_| invalid_feed("v2 signature is not base64url"))?;
2254    UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
2255        .verify(message, &sig)
2256        .map_err(|_| invalid_feed("v2 Ed25519 signature failed"))?;
2257    Ok(der)
2258}
2259
2260fn verify_v2_pointer(pointer: &V2SignedPointer, expected_brain: &str) -> LinkResult<String> {
2261    if pointer.pointer.v != 2
2262        || pointer.pointer.brain != expected_brain
2263        || pointer.pointer.seq == 0
2264        || !is_sha256(&pointer.pointer.commit_hash)
2265        || !is_sha256(&pointer.pointer.feed_hash)
2266        || pointer
2267            .pointer
2268            .content_root
2269            .as_deref()
2270            .is_some_and(|hash| !is_sha256(hash))
2271        || !is_sha256(&pointer.pointer.backup_preparation)
2272    {
2273        return Err(invalid_feed("v2 pointer fields are invalid"));
2274    }
2275    let value = serde_json::to_value(&pointer.pointer)
2276        .map_err(|_| invalid_feed("v2 pointer could not be canonicalized"))?;
2277    let message = crate::linkmd_v2::canonical_bytes(&value)
2278        .map_err(|error| invalid_feed(error.to_string()))?;
2279    let der = verify_v2_spki_signature(&pointer.hub_public_key, &message, &pointer.sig)?;
2280    let fingerprint = format!("{:x}", Sha256::digest(&der));
2281    if fingerprint != pointer.hub_fingerprint {
2282        return Err(invalid_feed("v2 hub signer fingerprint mismatch"));
2283    }
2284    Ok(format!(
2285        "{}:{}",
2286        pointer.hub_fingerprint, pointer.hub_public_key
2287    ))
2288}
2289
2290fn v2_identity(identity: &V2HeadIdentity) -> FeedIdentity {
2291    FeedIdentity {
2292        fingerprint: identity.fingerprint.clone(),
2293        public_key_spki: identity.public_key_spki.clone(),
2294        previous: identity
2295            .previous
2296            .iter()
2297            .map(|previous| PreviousIdentity {
2298                fingerprint: previous.fingerprint.clone(),
2299                public_key_spki: previous.public_key_spki.clone(),
2300            })
2301            .collect(),
2302        rotations: identity.rotations.clone(),
2303    }
2304}
2305
2306fn verified_v2_commit_object(
2307    raw: &[u8],
2308    identity: &V2HeadIdentity,
2309) -> LinkResult<serde_json::Map<String, Value>> {
2310    let mut value: Value =
2311        serde_json::from_slice(raw).map_err(|_| invalid_feed("v2 commit is not JSON"))?;
2312    let canonical = crate::linkmd_v2::canonical_bytes(&value)
2313        .map_err(|error| invalid_feed(error.to_string()))?;
2314    if canonical != raw {
2315        return Err(invalid_feed("v2 commit is not canonical JSON"));
2316    }
2317    let object = value
2318        .as_object_mut()
2319        .ok_or_else(|| invalid_feed("v2 commit is not an object"))?;
2320    let sig = object
2321        .remove("sig")
2322        .and_then(|value| value.as_str().map(str::to_string))
2323        .ok_or_else(|| invalid_feed("v2 commit has no signature"))?;
2324    let public_key = object
2325        .get("public_key")
2326        .and_then(Value::as_str)
2327        .ok_or_else(|| invalid_feed("v2 commit has no public key"))?;
2328    if public_key != identity.public_key_spki {
2329        return Err(invalid_feed("v2 commit uses an unrecognized brain key"));
2330    }
2331    let der = URL_SAFE_NO_PAD
2332        .decode(public_key)
2333        .map_err(|_| invalid_feed("v2 brain public key is not base64url"))?;
2334    let expected_multikey = format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&der)));
2335    if object.get("brain").and_then(Value::as_str) != Some(expected_multikey.as_str())
2336        || identity.fingerprint != expected_multikey.trim_start_matches("ed25519:")
2337    {
2338        return Err(invalid_feed("v2 commit brain identity mismatch"));
2339    }
2340    let unsigned = crate::linkmd_v2::canonical_bytes(&Value::Object(object.clone()))
2341        .map_err(|error| invalid_feed(error.to_string()))?;
2342    verify_v2_spki_signature(public_key, &unsigned, &sig)?;
2343    Ok(object.clone())
2344}
2345
2346#[derive(Debug, Deserialize)]
2347struct V2FeedWireEntry {
2348    seq: u64,
2349    commit_hash: String,
2350    feed_hash: String,
2351    bytes_base64: String,
2352}
2353
2354#[derive(Debug, Deserialize)]
2355struct V2FeedPage {
2356    v: u8,
2357    head_seq: u64,
2358    head_commit_hash: String,
2359    head_feed_hash: String,
2360    entries: Vec<V2FeedWireEntry>,
2361    next_after: u64,
2362    complete: bool,
2363}
2364
2365fn replay_v2_feed(
2366    cfg: &HubConfig,
2367    brain: &str,
2368    pointer: &V2PointerBody,
2369    identity: &V2HeadIdentity,
2370    start_after: u64,
2371    start_feed: Option<String>,
2372) -> LinkResult<()> {
2373    let mut after = start_after;
2374    let mut prior_feed = start_feed;
2375    let mut final_object = None;
2376    let mut replayed_entries = 0_u64;
2377    let mut replayed_bytes = 0_u64;
2378    while after < pointer.seq {
2379        let path = format!("/api/hub/brains/{brain}/v2/feed?after={after}&limit=100");
2380        let value = ensure_ok(
2381            request_capped(
2382                cfg,
2383                "GET",
2384                &path,
2385                None,
2386                Auth::Required,
2387                MAX_FEED_REPLAY_BYTES,
2388            )?,
2389            "v2 feed replay",
2390        )?;
2391        let page: V2FeedPage = serde_json::from_value(value)
2392            .map_err(|_| invalid_feed("v2 feed page has an invalid shape"))?;
2393        if page.v != 2
2394            || page.head_seq != pointer.seq
2395            || page.head_commit_hash != pointer.commit_hash
2396            || page.head_feed_hash != pointer.feed_hash
2397            || page.entries.is_empty()
2398            || page.entries.len() > FEED_PAGE_LIMIT
2399        {
2400            return Err(invalid_feed("v2 feed page differs from the signed head"));
2401        }
2402        for entry in page.entries {
2403            if entry.seq != after + 1
2404                || !is_sha256(&entry.commit_hash)
2405                || !is_sha256(&entry.feed_hash)
2406            {
2407                return Err(invalid_feed("v2 feed sequence is not contiguous"));
2408            }
2409            let raw = base64::engine::general_purpose::STANDARD
2410                .decode(&entry.bytes_base64)
2411                .map_err(|_| invalid_feed("v2 feed bytes are not canonical base64"))?;
2412            replayed_entries = replayed_entries
2413                .checked_add(1)
2414                .ok_or_else(|| invalid_feed("v2 feed replay count overflow"))?;
2415            replayed_bytes = replayed_bytes
2416                .checked_add(raw.len() as u64)
2417                .ok_or_else(|| invalid_feed("v2 feed replay byte count overflow"))?;
2418            if replayed_entries > MAX_FEED_REPLAY_ENTRIES || replayed_bytes > MAX_FEED_REPLAY_BYTES
2419            {
2420                return Err(invalid_feed("v2 feed replay exceeds its safety bound"));
2421            }
2422            if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
2423                .map_err(|error| invalid_feed(error.to_string()))?
2424                != entry.commit_hash
2425                || content_sha256(&raw) != entry.feed_hash
2426            {
2427                return Err(invalid_feed("v2 feed entry address mismatch"));
2428            }
2429            let object = verified_v2_commit_object(&raw, identity)?;
2430            if object.get("seq").and_then(Value::as_u64) != Some(entry.seq)
2431                || object.get("prev_entry_hash").and_then(Value::as_str) != prior_feed.as_deref()
2432            {
2433                return Err(invalid_feed(
2434                    "v2 feed entry does not extend its predecessor",
2435                ));
2436            }
2437            after = entry.seq;
2438            prior_feed = Some(entry.feed_hash);
2439            final_object = Some((entry.commit_hash, object));
2440        }
2441        if page.next_after != after || (page.complete != (after == pointer.seq)) {
2442            return Err(invalid_feed("v2 feed page cursor is inconsistent"));
2443        }
2444    }
2445    let (final_hash, object) =
2446        final_object.ok_or_else(|| invalid_feed("v2 feed replay made no progress"))?;
2447    if final_hash != pointer.commit_hash
2448        || prior_feed.as_deref() != Some(pointer.feed_hash.as_str())
2449        || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
2450        || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
2451        || object.get("control_revision").and_then(Value::as_str)
2452            != Some(pointer.control_revision.as_str())
2453        || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
2454    {
2455        return Err(invalid_feed(
2456            "v2 replay did not converge on the signed pointer",
2457        ));
2458    }
2459    Ok(())
2460}
2461
2462fn verify_v2_commit(
2463    cfg: &HubConfig,
2464    brain: &str,
2465    pointer: &V2PointerBody,
2466    identity: &V2HeadIdentity,
2467    pinned: Option<&TrustState>,
2468) -> LinkResult<()> {
2469    let path = format!(
2470        "/api/hub/brains/{brain}/v2/commit?commit={}",
2471        pointer.commit_hash
2472    );
2473    let raw = ensure_raw_ok(
2474        request_raw(cfg, "GET", &path, None, Auth::Required, MAX_RESPONSE_BYTES)?,
2475        "v2 commit",
2476    )?;
2477    if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
2478        .map_err(|error| invalid_feed(error.to_string()))?
2479        != pointer.commit_hash
2480        || content_sha256(&raw) != pointer.feed_hash
2481    {
2482        return Err(invalid_feed("v2 commit address differs from the pointer"));
2483    }
2484    let object = verified_v2_commit_object(&raw, identity)?;
2485    if object.get("seq").and_then(Value::as_u64) != Some(pointer.seq)
2486        || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
2487        || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
2488        || object.get("control_revision").and_then(Value::as_str)
2489            != Some(pointer.control_revision.as_str())
2490        || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
2491    {
2492        return Err(invalid_feed("v2 commit fields differ from the pointer"));
2493    }
2494    if let Some(checkpoint) = pinned {
2495        if pointer.seq == checkpoint.head_seq + 1
2496            && object.get("prev_entry_hash").and_then(Value::as_str)
2497                != checkpoint.feed_hash.as_deref()
2498        {
2499            return Err(invalid_feed(
2500                "v2 commit does not extend the pinned feed hash",
2501            ));
2502        }
2503        if pointer.seq > checkpoint.head_seq + 1 {
2504            return replay_v2_feed(
2505                cfg,
2506                brain,
2507                pointer,
2508                identity,
2509                checkpoint.head_seq,
2510                checkpoint.feed_hash.clone(),
2511            );
2512        }
2513    } else if pointer.seq > 1 {
2514        return replay_v2_feed(cfg, brain, pointer, identity, 0, None);
2515    }
2516    Ok(())
2517}
2518
2519fn v2_verified_head(cfg: &HubConfig, brain: &str) -> LinkResult<Option<V2VerifiedHead>> {
2520    require_hardened_filesystem("verified link.md v2 state")?;
2521    require_safe_ref(brain)?;
2522    let path = format!("/api/hub/brains/{brain}/v2/head");
2523    let response = request(cfg, "GET", &path, None, Auth::Required)?;
2524    if response.status == 404 {
2525        if has_accepted_v2_ref(cfg, brain)? {
2526            return Err(LinkError::BrainUnavailable);
2527        }
2528        return Ok(None);
2529    }
2530    let body = ensure_ok(response, "v2 head")?;
2531    let head: V2HeadResponse = serde_json::from_value(body)
2532        .map_err(|_| invalid_feed("v2 head response has an invalid shape"))?;
2533    if head.v != 2 || !crate::ulid::is_ulid(&head.brain_id) {
2534        return Err(invalid_feed("v2 head has no canonical brain id"));
2535    }
2536    if crate::ulid::is_ulid(brain) && head.brain_id != brain {
2537        return Err(invalid_feed("v2 head resolved a different brain id"));
2538    }
2539    if head.profile == "v1" {
2540        return Ok(None);
2541    }
2542    if head.profile != "v2" && head.profile != "v2-empty" {
2543        return Err(invalid_feed("v2 head advertised an unknown profile"));
2544    }
2545    let view = head
2546        .view
2547        .as_ref()
2548        .ok_or_else(|| invalid_feed("v2 head has no permission view"))?;
2549    if !matches!(view.kind.as_str(), "full" | "scoped") || !is_sha256(&view.control_revision) {
2550        return Err(invalid_feed("v2 head has an invalid permission view"));
2551    }
2552    let view_kind = view.kind.clone();
2553    let view_revision = view.control_revision.clone();
2554    let identity = head
2555        .identity
2556        .as_ref()
2557        .ok_or_else(|| invalid_feed("v2 head has no brain identity"))?;
2558    let trust_directory = open_trust_dir(cfg)?;
2559    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &head.brain_id])?;
2560    let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, brain, &head.brain_id)?;
2561    let feed_identity = v2_identity(identity);
2562    let anchor = verify_identity_chain(&feed_identity, pinned.as_ref())?;
2563    let (seq, feed_hash, hub_signer) = match &head.pointer {
2564        None => {
2565            if head.profile != "v2-empty" {
2566                return Err(invalid_feed("initialized v2 head has no pointer"));
2567            }
2568            (
2569                0,
2570                None,
2571                pinned.as_ref().and_then(|state| state.hub_signer.clone()),
2572            )
2573        }
2574        Some(signed) => {
2575            let signer = verify_v2_pointer(signed, &head.brain_id)?;
2576            if pinned
2577                .as_ref()
2578                .and_then(|state| state.hub_signer.as_ref())
2579                .is_some_and(|known| known != &signer)
2580            {
2581                return Err(invalid_feed(
2582                    "v2 hub pointer signer changed without a trust transition",
2583                ));
2584            }
2585            if let Some(checkpoint) = &pinned {
2586                if signed.pointer.seq < checkpoint.head_seq
2587                    || (signed.pointer.seq == checkpoint.head_seq
2588                        && checkpoint.feed_hash.as_deref()
2589                            != Some(signed.pointer.feed_hash.as_str()))
2590                {
2591                    return Err(invalid_feed("v2 pointer rolled back or equivocated"));
2592                }
2593            }
2594            verify_v2_commit(
2595                cfg,
2596                &head.brain_id,
2597                &signed.pointer,
2598                identity,
2599                pinned.as_ref(),
2600            )?;
2601            (
2602                signed.pointer.seq,
2603                Some(signed.pointer.feed_hash.clone()),
2604                Some(signer),
2605            )
2606        }
2607    };
2608    let trust = TrustState {
2609        v: 2,
2610        origin: normalized_origin(&cfg.hub)?,
2611        requested: head.brain_id.clone(),
2612        brain: head.brain_id.clone(),
2613        home: None,
2614        anchor,
2615        current: format!("ed25519:{}", identity.fingerprint),
2616        head_seq: seq,
2617        feed_hash,
2618        rotations: identity.rotations.clone(),
2619        hub_signer,
2620        protocol_profile: Some("link-v2".to_string()),
2621    };
2622    Ok(Some(V2VerifiedHead {
2623        requested: brain.to_string(),
2624        brain_id: head.brain_id,
2625        view_kind,
2626        view_revision,
2627        pointer: head.pointer.map(|signed| signed.pointer),
2628        trust,
2629        alias: alias_binding,
2630    }))
2631}
2632
2633fn accept_v2_head(cfg: &HubConfig, head: &V2VerifiedHead) -> LinkResult<()> {
2634    let directory = open_trust_dir(cfg)?;
2635    let _locks = lock_trust_many(cfg, &directory, &[&head.requested, &head.brain_id])?;
2636    let (current, alias) = load_canonical_pin(cfg, &directory, &head.requested, &head.brain_id)?;
2637    if let Some(current) = current {
2638        if head.trust.head_seq < current.head_seq
2639            || (head.trust.head_seq == current.head_seq
2640                && head.trust.feed_hash != current.feed_hash)
2641            || head.trust.anchor != current.anchor
2642            || !head.trust.rotations.starts_with(&current.rotations)
2643            || current
2644                .hub_signer
2645                .as_ref()
2646                .is_some_and(|known| head.trust.hub_signer.as_ref() != Some(known))
2647        {
2648            return Err(invalid_feed(
2649                "v2 head cannot advance the currently accepted trust checkpoint",
2650            ));
2651        }
2652    }
2653    save_canonical_pin_and_alias(
2654        cfg,
2655        &directory,
2656        &head.requested,
2657        &head.brain_id,
2658        head.trust.clone(),
2659        alias.as_ref().or(head.alias.as_ref()),
2660    )
2661}
2662
2663#[derive(Debug, Clone, Deserialize, Serialize)]
2664struct V2BaselineFile {
2665    sha256: String,
2666    bytes: u64,
2667    #[serde(skip)]
2668    proof: Option<Vec<V2ProofStep>>,
2669}
2670
2671#[derive(Debug, Clone, Deserialize, Serialize)]
2672struct V2SyncBaseline {
2673    v: u8,
2674    origin: String,
2675    brain: String,
2676    commit_hash: Option<String>,
2677    content_root: Option<String>,
2678    #[serde(default)]
2679    view_kind: Option<String>,
2680    #[serde(default)]
2681    view_revision: Option<String>,
2682    #[serde(default)]
2683    projection_sha256: Option<String>,
2684    files: std::collections::BTreeMap<String, V2BaselineFile>,
2685    #[serde(default)]
2686    local_policy_digest: Option<String>,
2687    #[serde(default)]
2688    local_eligibility: std::collections::BTreeMap<String, bool>,
2689    #[serde(default)]
2690    remote_copy_remains: std::collections::BTreeMap<String, String>,
2691}
2692
2693struct V2LocalView {
2694    riding: std::collections::BTreeMap<String, (String, Vec<u8>)>,
2695    eligibility: std::collections::BTreeMap<String, bool>,
2696    policy: crate::linkmd_sync_policy::SyncPolicy,
2697}
2698
2699#[derive(Debug, Clone, Deserialize, Serialize)]
2700struct V2ProofStep {
2701    directory_root: String,
2702    component: String,
2703    proof: crate::linkmd_v2::HamtProof,
2704}
2705
2706#[derive(Debug, Deserialize)]
2707struct V2ManifestFile {
2708    path: String,
2709    sha256: String,
2710    bytes: u64,
2711    proof: Vec<V2ProofStep>,
2712}
2713
2714#[derive(Debug, Deserialize)]
2715struct V2ManifestPage {
2716    v: u8,
2717    commit: String,
2718    content_root: Option<String>,
2719    files: Vec<V2ManifestFile>,
2720    next_cursor: Option<String>,
2721}
2722
2723fn verify_v2_file_proof(root: &str, file: &V2ManifestFile) -> LinkResult<()> {
2724    let normalized = crate::linkmd_v2::normalize_path(&file.path)
2725        .map_err(|error| invalid_feed(error.to_string()))?;
2726    let components = normalized.split('/').collect::<Vec<_>>();
2727    if components.len() != file.proof.len() || !is_sha256(&file.sha256) {
2728        return Err(invalid_feed("v2 file proof has the wrong shape"));
2729    }
2730    let mut directory_root = root.to_string();
2731    for (index, step) in file.proof.iter().enumerate() {
2732        if step.directory_root != directory_root || step.component != components[index] {
2733            return Err(invalid_feed(
2734                "v2 file proof path chain differs from its manifest",
2735            ));
2736        }
2737        if !crate::linkmd_v2::verify_proof(&directory_root, &step.component, &step.proof)
2738            .map_err(|error| invalid_feed(error.to_string()))?
2739        {
2740            return Err(invalid_feed("v2 file proof failed verification"));
2741        }
2742        let entry = match &step.proof {
2743            crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry,
2744            crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
2745                return Err(invalid_feed("v2 manifest carried a non-inclusion proof"));
2746            }
2747        };
2748        if index + 1 == components.len() {
2749            if entry.kind != crate::linkmd_v2::EntryKind::Blob
2750                || entry.child_hash != file.sha256
2751                || entry.bytes != Some(file.bytes)
2752            {
2753                return Err(invalid_feed("v2 file proof leaf differs from its manifest"));
2754            }
2755        } else if entry.kind != crate::linkmd_v2::EntryKind::Tree {
2756            return Err(invalid_feed("v2 file proof traversed a non-directory"));
2757        } else {
2758            directory_root = entry.child_hash.clone();
2759        }
2760    }
2761    Ok(())
2762}
2763
2764fn v2_manifest(
2765    cfg: &HubConfig,
2766    brain: &str,
2767    pointer: Option<&V2PointerBody>,
2768) -> LinkResult<std::collections::BTreeMap<String, V2BaselineFile>> {
2769    let Some(pointer) = pointer else {
2770        return Ok(std::collections::BTreeMap::new());
2771    };
2772    let Some(root) = pointer.content_root.as_deref() else {
2773        return Ok(std::collections::BTreeMap::new());
2774    };
2775    let mut files = std::collections::BTreeMap::new();
2776    let mut after = String::new();
2777    loop {
2778        let encoded_after: String =
2779            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
2780        let path = format!(
2781            "/api/hub/brains/{brain}/v2/files?commit={}&limit=500&after={encoded_after}",
2782            pointer.commit_hash
2783        );
2784        let value = ensure_ok(
2785            request_capped(
2786                cfg,
2787                "GET",
2788                &path,
2789                None,
2790                Auth::Required,
2791                MAX_FEED_RESPONSE_BYTES,
2792            )?,
2793            "v2 file manifest",
2794        )?;
2795        let page: V2ManifestPage = serde_json::from_value(value)
2796            .map_err(|_| invalid_feed("v2 file manifest has an invalid shape"))?;
2797        if page.v != 2
2798            || page.commit != pointer.commit_hash
2799            || page.content_root.as_deref() != Some(root)
2800            || page.files.len() > 500
2801        {
2802            return Err(invalid_feed(
2803                "v2 file manifest is not bound to the verified head",
2804            ));
2805        }
2806        for file in page.files {
2807            verify_v2_file_proof(root, &file)?;
2808            if files
2809                .insert(
2810                    file.path.clone(),
2811                    V2BaselineFile {
2812                        sha256: file.sha256,
2813                        bytes: file.bytes,
2814                        proof: Some(file.proof),
2815                    },
2816                )
2817                .is_some()
2818            {
2819                return Err(invalid_feed("v2 file manifest repeats a path"));
2820            }
2821            if files.len() > MAX_PUSH_FILES {
2822                return Err(invalid_feed(
2823                    "v2 file manifest exceeds the file-count bound",
2824                ));
2825            }
2826        }
2827        match page.next_cursor {
2828            None => break,
2829            Some(next) if next > after => after = next,
2830            Some(_) => return Err(invalid_feed("v2 file manifest cursor did not advance")),
2831        }
2832    }
2833    Ok(files)
2834}
2835
2836fn v2_baseline_name(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<String> {
2837    let origin = normalized_origin(&cfg.hub)?;
2838    let absolute = if checkout.is_absolute() {
2839        checkout.to_path_buf()
2840    } else {
2841        std::env::current_dir()?.join(checkout)
2842    };
2843    Ok(format!(
2844        "sync-{}.json",
2845        content_sha256(format!("{origin}\0{brain}\0{}", absolute.display()).as_bytes())
2846    ))
2847}
2848
2849#[cfg(unix)]
2850fn lock_v2_sync_operation(cfg: &HubConfig, brain: &str) -> LinkResult<TrustLock> {
2851    let directory = open_trust_dir(cfg)?;
2852    let origin = normalized_origin(&cfg.hub)?;
2853    let name = format!(
2854        "operation-{}.lock",
2855        content_sha256(format!("{origin}\0{brain}").as_bytes())
2856    );
2857    lock_trust_name(&directory, &name)
2858}
2859
2860#[cfg(not(unix))]
2861fn lock_v2_sync_operation(_cfg: &HubConfig, _brain: &str) -> LinkResult<()> {
2862    Err(LinkError::UnsupportedPlatform {
2863        operation: "serialized link.md v2 sync",
2864    })
2865}
2866
2867fn same_v2_head(left: &V2VerifiedHead, right: &V2VerifiedHead) -> bool {
2868    left.brain_id == right.brain_id
2869        && left.view_kind == right.view_kind
2870        && left.view_revision == right.view_revision
2871        && match (&left.pointer, &right.pointer) {
2872            (None, None) => true,
2873            (Some(left), Some(right)) => {
2874                left.seq == right.seq
2875                    && left.commit_hash == right.commit_hash
2876                    && left.content_root == right.content_root
2877                    && left.feed_hash == right.feed_hash
2878            }
2879            _ => false,
2880        }
2881}
2882
2883fn scoped_projection_bytes(brain: &str) -> Vec<u8> {
2884    format!(
2885        "---\ntype: db-md\nscope: company\nowner: link.md scoped view\n---\n\n# Scoped brain view\n\nThis DB.md is generated locally by dbmd. It is not the brain's canonical contract and is never uploaded.\n\nCanonical brain: @{brain}\n"
2886    )
2887    .into_bytes()
2888}
2889
2890fn scoped_projection_sha256(brain: &str) -> String {
2891    content_sha256(&scoped_projection_bytes(brain))
2892}
2893
2894#[derive(Deserialize)]
2895struct LocalScopedViewMarker {
2896    v: u8,
2897    kind: String,
2898    authoritative: bool,
2899    brain: String,
2900    projection_sha256: String,
2901}
2902
2903/// True only when this store carries the exact generated marker for a local
2904/// link.md scoped view. This is presentation context, never authorization;
2905/// the hub remains the only authority for reads and writes.
2906pub fn has_verified_local_scoped_view(store: &Store) -> bool {
2907    let marker = store
2908        .read_bounded(Path::new(".dbmd/view.json"), 64 * 1024)
2909        .ok()
2910        .and_then(|bytes| serde_json::from_slice::<LocalScopedViewMarker>(&bytes).ok());
2911    let Some(marker) = marker else {
2912        return false;
2913    };
2914    if marker.v != 1
2915        || marker.kind != "link.md-scoped-view"
2916        || marker.authoritative
2917        || !crate::ulid::is_ulid(&marker.brain)
2918        || marker.projection_sha256 != scoped_projection_sha256(&marker.brain)
2919    {
2920        return false;
2921    }
2922    store
2923        .read_bounded(Path::new("DB.md"), crate::parser::MAX_DBMD_FILE_BYTES)
2924        .is_ok_and(|bytes| content_sha256(&bytes) == marker.projection_sha256)
2925}
2926
2927fn scoped_view_metadata(head: &V2VerifiedHead, files: usize) -> LinkResult<Vec<u8>> {
2928    let mut bytes = serde_json::to_vec_pretty(&json!({
2929        "v": 1,
2930        "kind": "link.md-scoped-view",
2931        "authoritative": false,
2932        "brain": head.brain_id,
2933        "view_revision": head.view_revision,
2934        "head_seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
2935        "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
2936        "content_root": head.pointer.as_ref().and_then(|pointer| pointer.content_root.as_ref()),
2937        "visible_files": files,
2938        "projection_sha256": scoped_projection_sha256(&head.brain_id),
2939    }))
2940    .map_err(|_| invalid_feed("could not serialize scoped view metadata"))?;
2941    bytes.push(b'\n');
2942    Ok(bytes)
2943}
2944
2945fn refresh_scoped_view_marker(
2946    store: &Store,
2947    head: &V2VerifiedHead,
2948    files: usize,
2949) -> LinkResult<()> {
2950    if head.view_kind == "scoped" {
2951        store.write_atomic(
2952            Path::new(".dbmd/view.json"),
2953            &scoped_view_metadata(head, files)?,
2954        )?;
2955    }
2956    Ok(())
2957}
2958
2959fn ensure_v2_view_compatible(
2960    head: &V2VerifiedHead,
2961    baseline: Option<&V2SyncBaseline>,
2962) -> LinkResult<()> {
2963    let Some(baseline) = baseline else {
2964        return Ok(());
2965    };
2966    match (
2967        baseline.view_kind.as_deref(),
2968        baseline.view_revision.as_deref(),
2969    ) {
2970        (None, None) if head.view_kind == "full" => Ok(()),
2971        (Some(kind), Some(revision))
2972            if kind == head.view_kind && revision == head.view_revision =>
2973        {
2974            Ok(())
2975        }
2976        _ => Err(LinkError::ScopedViewChanged),
2977    }
2978}
2979
2980fn remove_scoped_projection(
2981    head: &V2VerifiedHead,
2982    baseline: Option<&V2SyncBaseline>,
2983    view: &mut V2LocalView,
2984) -> LinkResult<()> {
2985    if head.view_kind != "scoped" {
2986        return Ok(());
2987    }
2988    let expected = scoped_projection_sha256(&head.brain_id);
2989    if baseline
2990        .and_then(|state| state.projection_sha256.as_deref())
2991        .is_some_and(|pinned| pinned != expected)
2992    {
2993        return Err(LinkError::ScopedViewChanged);
2994    }
2995    if view.riding.get("DB.md").map(|(sha256, _)| sha256.as_str()) != Some(expected.as_str()) {
2996        return Err(LinkError::ScopedProjectionModified);
2997    }
2998    view.riding.remove("DB.md");
2999    view.eligibility.remove("DB.md");
3000    Ok(())
3001}
3002
3003fn files_for_v2_view(
3004    head: &V2VerifiedHead,
3005    mut files: std::collections::BTreeMap<String, V2BaselineFile>,
3006) -> std::collections::BTreeMap<String, V2BaselineFile> {
3007    if head.view_kind == "scoped" {
3008        // Even when a grant explicitly includes canonical DB.md, a partial
3009        // checkout uses a generated marker. Canonical validation remains a
3010        // server-side operation over the complete brain.
3011        files.remove("DB.md");
3012    }
3013    files
3014}
3015
3016#[cfg(unix)]
3017fn load_v2_baseline(
3018    cfg: &HubConfig,
3019    brain: &str,
3020    checkout: &Path,
3021) -> LinkResult<Option<V2SyncBaseline>> {
3022    use std::os::fd::{AsRawFd as _, FromRawFd as _};
3023    let directory = open_trust_dir(cfg)?;
3024    let name_string = v2_baseline_name(cfg, brain, checkout)?;
3025    let _lock = lock_trust_name(&directory, &name_string)?;
3026    let name = c_name(name_string.as_bytes(), &name_string)?;
3027    let fd = unsafe {
3028        libc::openat(
3029            directory.as_raw_fd(),
3030            name.as_ptr(),
3031            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
3032        )
3033    };
3034    if fd < 0 {
3035        let error = std::io::Error::last_os_error();
3036        return if error.kind() == std::io::ErrorKind::NotFound {
3037            Ok(None)
3038        } else {
3039            Err(LinkError::UnsafePath { path: name_string })
3040        };
3041    }
3042    let file = unsafe { std::fs::File::from_raw_fd(fd) };
3043    let mut bytes = Vec::new();
3044    file.take(MAX_FEED_RESPONSE_BYTES + 1)
3045        .read_to_end(&mut bytes)?;
3046    if bytes.len() as u64 > MAX_FEED_RESPONSE_BYTES {
3047        return Err(invalid_feed("v2 sync baseline is oversized"));
3048    }
3049    let baseline: V2SyncBaseline =
3050        serde_json::from_slice(&bytes).map_err(|_| invalid_feed("v2 sync baseline is corrupt"))?;
3051    if baseline.v != 2
3052        || baseline.origin != normalized_origin(&cfg.hub)?
3053        || baseline.brain != brain
3054        || baseline
3055            .commit_hash
3056            .as_deref()
3057            .is_some_and(|hash| !is_sha256(hash))
3058        || baseline
3059            .content_root
3060            .as_deref()
3061            .is_some_and(|hash| !is_sha256(hash))
3062        || baseline
3063            .local_policy_digest
3064            .as_deref()
3065            .is_some_and(|hash| !is_sha256(hash))
3066        || baseline
3067            .view_kind
3068            .as_deref()
3069            .is_some_and(|kind| !matches!(kind, "full" | "scoped"))
3070        || baseline
3071            .view_revision
3072            .as_deref()
3073            .is_some_and(|hash| !is_sha256(hash))
3074        || baseline
3075            .projection_sha256
3076            .as_deref()
3077            .is_some_and(|hash| !is_sha256(hash))
3078        || (baseline.view_kind.as_deref() == Some("scoped")
3079            && (baseline.view_revision.is_none() || baseline.projection_sha256.is_none()))
3080        || baseline.files.len() > MAX_PUSH_FILES
3081        || baseline.local_eligibility.len() > MAX_PUSH_FILES
3082        || baseline.remote_copy_remains.len() > MAX_PUSH_FILES
3083        || baseline.files.iter().any(|(path, file)| {
3084            crate::linkmd_v2::normalize_path(path).is_err()
3085                || !is_sha256(&file.sha256)
3086                || file.bytes > MAX_STORE_BYTES
3087        })
3088        || baseline
3089            .local_eligibility
3090            .keys()
3091            .chain(baseline.remote_copy_remains.keys())
3092            .any(|path| crate::linkmd_v2::normalize_path(path).is_err())
3093        || baseline
3094            .remote_copy_remains
3095            .values()
3096            .any(|hash| !is_sha256(hash))
3097    {
3098        return Err(invalid_feed("v2 sync baseline failed validation"));
3099    }
3100    Ok(Some(baseline))
3101}
3102
3103#[cfg(not(unix))]
3104fn load_v2_baseline(
3105    _cfg: &HubConfig,
3106    _brain: &str,
3107    _checkout: &Path,
3108) -> LinkResult<Option<V2SyncBaseline>> {
3109    Err(LinkError::UnsupportedPlatform {
3110        operation: "verified link.md v2 baseline",
3111    })
3112}
3113
3114#[cfg(unix)]
3115fn save_v2_baseline(
3116    cfg: &HubConfig,
3117    brain: &str,
3118    checkout: &Path,
3119    baseline: &V2SyncBaseline,
3120) -> LinkResult<()> {
3121    use std::os::fd::{AsRawFd as _, FromRawFd as _};
3122    let directory = open_trust_dir(cfg)?;
3123    let name_string = v2_baseline_name(cfg, brain, checkout)?;
3124    let _lock = lock_trust_name(&directory, &name_string)?;
3125    let name = c_name(name_string.as_bytes(), &name_string)?;
3126    let mut bytes = serde_json::to_vec(baseline)
3127        .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
3128    bytes.push(b'\n');
3129    let temp_string = format!(
3130        ".{name_string}.tmp.{}-{}",
3131        std::process::id(),
3132        std::time::SystemTime::now()
3133            .duration_since(std::time::UNIX_EPOCH)
3134            .unwrap_or_default()
3135            .as_nanos()
3136    );
3137    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
3138    let fd = unsafe {
3139        libc::openat(
3140            directory.as_raw_fd(),
3141            temp.as_ptr(),
3142            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
3143            0o600,
3144        )
3145    };
3146    if fd < 0 {
3147        return Err(std::io::Error::last_os_error().into());
3148    }
3149    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
3150    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
3151        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
3152        return Err(error.into());
3153    }
3154    drop(file);
3155    if unsafe {
3156        libc::renameat(
3157            directory.as_raw_fd(),
3158            temp.as_ptr(),
3159            directory.as_raw_fd(),
3160            name.as_ptr(),
3161        )
3162    } != 0
3163    {
3164        let error = std::io::Error::last_os_error();
3165        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
3166        return Err(error.into());
3167    }
3168    directory.sync_all()?;
3169    Ok(())
3170}
3171
3172#[cfg(not(unix))]
3173fn save_v2_baseline(
3174    _cfg: &HubConfig,
3175    _brain: &str,
3176    _checkout: &Path,
3177    _baseline: &V2SyncBaseline,
3178) -> LinkResult<()> {
3179    Err(LinkError::UnsupportedPlatform {
3180        operation: "verified link.md v2 baseline",
3181    })
3182}
3183
3184fn v2_baseline_from_head(
3185    cfg: &HubConfig,
3186    head: &V2VerifiedHead,
3187    files: std::collections::BTreeMap<String, V2BaselineFile>,
3188    local: Option<&V2LocalView>,
3189) -> LinkResult<V2SyncBaseline> {
3190    let mut local_eligibility = local
3191        .map(|view| view.eligibility.clone())
3192        .unwrap_or_default();
3193    if let Some(view) = local {
3194        for path in files.keys() {
3195            local_eligibility
3196                .entry(path.clone())
3197                .or_insert_with(|| !view.policy.keeps_home(path));
3198        }
3199    }
3200    let remote_copy_remains = local_eligibility
3201        .iter()
3202        .filter(|(_, riding)| !**riding)
3203        .filter_map(|(path, _)| {
3204            files
3205                .get(path)
3206                .map(|file| (path.clone(), file.sha256.clone()))
3207        })
3208        .collect();
3209    Ok(V2SyncBaseline {
3210        v: 2,
3211        origin: normalized_origin(&cfg.hub)?,
3212        brain: head.brain_id.clone(),
3213        commit_hash: head
3214            .pointer
3215            .as_ref()
3216            .map(|pointer| pointer.commit_hash.clone()),
3217        content_root: head
3218            .pointer
3219            .as_ref()
3220            .and_then(|pointer| pointer.content_root.clone()),
3221        view_kind: Some(head.view_kind.clone()),
3222        view_revision: Some(head.view_revision.clone()),
3223        projection_sha256: (head.view_kind == "scoped")
3224            .then(|| scoped_projection_sha256(&head.brain_id)),
3225        files,
3226        local_policy_digest: local.map(|view| view.policy.digest.clone()),
3227        local_eligibility,
3228        remote_copy_remains,
3229    })
3230}
3231
3232fn v2_local_files(store: &Store) -> LinkResult<V2LocalView> {
3233    let policy = crate::linkmd_sync_policy::load(store)
3234        .map_err(|message| LinkError::InvalidPack { message })?;
3235    let mut result = std::collections::BTreeMap::new();
3236    let mut eligibility = std::collections::BTreeMap::new();
3237    let mut total = 0_u64;
3238    let mut paths = vec![PathBuf::from("DB.md")];
3239    paths.extend(store.walk()?);
3240    for relative in paths {
3241        let path = relative.to_string_lossy().replace('\\', "/");
3242        // v2 catalogs and asset inventory are materialized/signed separately.
3243        if matches!(path.as_str(), "assets.jsonl" | "index.md" | "index.jsonl") {
3244            continue;
3245        }
3246        crate::linkmd_v2::normalize_path(&path).map_err(|error| LinkError::UnsafePath {
3247            path: error.to_string(),
3248        })?;
3249        let riding = !policy.keeps_home(&path);
3250        eligibility.insert(path.clone(), riding);
3251        if !riding {
3252            continue;
3253        }
3254        let remaining = MAX_STORE_BYTES.saturating_sub(total);
3255        let bytes = store.read_bounded(&relative, remaining)?;
3256        total = total
3257            .checked_add(bytes.len() as u64)
3258            .ok_or_else(|| LinkError::PushTooLarge {
3259                detail: "v2 local byte count overflow".to_string(),
3260            })?;
3261        if total > MAX_STORE_BYTES {
3262            return Err(LinkError::PushTooLarge {
3263                detail: format!("{total} uncompressed bytes"),
3264            });
3265        }
3266        if std::str::from_utf8(&bytes).is_err() {
3267            return Err(LinkError::NotUtf8 { path });
3268        }
3269        result.insert(path, (content_sha256(&bytes), bytes));
3270    }
3271    Ok(V2LocalView {
3272        riding: result,
3273        eligibility,
3274        policy,
3275    })
3276}
3277
3278#[derive(Debug, Deserialize)]
3279struct V2DownloadItem {
3280    path: String,
3281    sha256: String,
3282    bytes: u64,
3283    url: String,
3284    method: String,
3285}
3286
3287#[derive(Debug, Deserialize)]
3288struct V2DownloadWindow {
3289    v: u8,
3290    commit: String,
3291    downloads: Vec<V2DownloadItem>,
3292}
3293
3294fn prepare_v2_downloads(
3295    cfg: &HubConfig,
3296    brain: &str,
3297    pointer: &V2PointerBody,
3298    pending: &[(&String, &V2BaselineFile)],
3299) -> LinkResult<Vec<V2DownloadItem>> {
3300    let mut result = Vec::with_capacity(pending.len());
3301    for chunk in pending.chunks(128) {
3302        let claims = chunk
3303            .iter()
3304            .map(|(path, file)| {
3305                Ok(json!({
3306                    "path": path,
3307                    "sha256": file.sha256,
3308                    "bytes": file.bytes,
3309                    "proof": file.proof.as_ref().ok_or_else(|| {
3310                        invalid_feed("v2 manifest omitted a download proof")
3311                    })?,
3312                }))
3313            })
3314            .collect::<LinkResult<Vec<_>>>()?;
3315        let value = ensure_ok(
3316            request_capped(
3317                cfg,
3318                "POST",
3319                &format!("/api/hub/brains/{brain}/v2/downloads"),
3320                Some(&json!({
3321                    "commit": pointer.commit_hash,
3322                    "files": claims,
3323                })),
3324                Auth::Required,
3325                MAX_FEED_RESPONSE_BYTES,
3326            )?,
3327            "prepare v2 blob downloads",
3328        )?;
3329        let window: V2DownloadWindow = serde_json::from_value(value)
3330            .map_err(|_| invalid_feed("v2 download window has an invalid shape"))?;
3331        if window.v != 2
3332            || window.commit != pointer.commit_hash
3333            || window.downloads.len() != chunk.len()
3334        {
3335            return Err(invalid_feed(
3336                "v2 download window is not bound to the requested files",
3337            ));
3338        }
3339        let mut by_path = window
3340            .downloads
3341            .into_iter()
3342            .map(|item| (item.path.clone(), item))
3343            .collect::<std::collections::BTreeMap<_, _>>();
3344        if by_path.len() != chunk.len() {
3345            return Err(invalid_feed("v2 download window repeats a path"));
3346        }
3347        for (path, file) in chunk {
3348            let item = by_path
3349                .remove(*path)
3350                .ok_or_else(|| invalid_feed("v2 download window omitted a path"))?;
3351            if item.method != "GET"
3352                || item.sha256 != file.sha256
3353                || item.bytes != file.bytes
3354                || item.url.is_empty()
3355            {
3356                return Err(invalid_feed(
3357                    "v2 download capability differs from its proven file",
3358                ));
3359            }
3360            result.push(item);
3361        }
3362    }
3363    Ok(result)
3364}
3365
3366fn download_v2_blob(cfg: &HubConfig, item: &V2DownloadItem) -> LinkResult<Vec<u8>> {
3367    let bytes = get_presigned(cfg, &item.url)?;
3368    if bytes.len() as u64 != item.bytes || content_sha256(&bytes) != item.sha256 {
3369        return Err(invalid_feed("v2 blob differs from its proven path entry"));
3370    }
3371    Ok(bytes)
3372}
3373
3374fn download_v2_blobs(
3375    cfg: &HubConfig,
3376    brain: &str,
3377    pointer: &V2PointerBody,
3378    pending: Vec<(&String, &V2BaselineFile)>,
3379) -> LinkResult<Vec<(String, Vec<u8>)>> {
3380    if pending.is_empty() {
3381        return Ok(Vec::new());
3382    }
3383    let downloads = prepare_v2_downloads(cfg, brain, pointer, &pending)?;
3384    let next = std::sync::atomic::AtomicUsize::new(0);
3385    let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
3386    let mut results = std::iter::repeat_with(|| None)
3387        .take(downloads.len())
3388        .collect::<Vec<Option<LinkResult<(String, Vec<u8>)>>>>();
3389    std::thread::scope(|scope| {
3390        let (sender, receiver) = std::sync::mpsc::channel();
3391        for _ in 0..worker_count {
3392            let sender = sender.clone();
3393            let downloads = &downloads;
3394            let next = &next;
3395            scope.spawn(move || loop {
3396                let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3397                let Some(item) = downloads.get(index) else {
3398                    break;
3399                };
3400                let result = download_v2_blob(cfg, item).map(|bytes| (item.path.clone(), bytes));
3401                if sender.send((index, result)).is_err() {
3402                    break;
3403                }
3404            });
3405        }
3406        drop(sender);
3407        for (index, result) in receiver {
3408            results[index] = Some(result);
3409        }
3410    });
3411    results
3412        .into_iter()
3413        .map(|result| {
3414            result.ok_or_else(|| LinkError::Transport {
3415                hub: cfg.hub.clone(),
3416                message: "a bounded v2 blob worker stopped before reporting its result".to_string(),
3417            })?
3418        })
3419        .collect()
3420}
3421
3422fn v2_sync_pull(
3423    cfg: &HubConfig,
3424    requested_brain: &str,
3425    head: V2VerifiedHead,
3426    out: Option<&Path>,
3427) -> LinkResult<PullReport> {
3428    let dest = out
3429        .map(Path::to_path_buf)
3430        .unwrap_or_else(|| PathBuf::from(requested_brain));
3431    let _operation_lock = lock_v2_sync_operation(cfg, &head.brain_id)?;
3432    let head = v2_verified_head(cfg, requested_brain)?
3433        .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
3434    let remote = files_for_v2_view(
3435        &head,
3436        v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
3437    );
3438    let baseline = load_v2_baseline(cfg, &head.brain_id, &dest)?;
3439    ensure_v2_view_compatible(&head, baseline.as_ref())?;
3440    let mut local_view = match Store::open_strict(&dest) {
3441        Ok(store) => Some(v2_local_files(&store)?),
3442        Err(_) => None,
3443    };
3444    if head.view_kind == "scoped" && baseline.is_none() && local_view.is_some() {
3445        return Err(LinkError::ScopedViewChanged);
3446    }
3447    if let Some(view) = local_view.as_mut() {
3448        remove_scoped_projection(&head, baseline.as_ref(), view)?;
3449    }
3450    let local = local_view
3451        .as_ref()
3452        .map(|view| &view.riding)
3453        .cloned()
3454        .unwrap_or_default();
3455    let kept_home = |path: &str| {
3456        local_view
3457            .as_ref()
3458            .is_some_and(|view| view.policy.keeps_home(path))
3459    };
3460    let base = baseline
3461        .as_ref()
3462        .map(|state| &state.files)
3463        .cloned()
3464        .unwrap_or_default();
3465    let all_paths = base
3466        .keys()
3467        .chain(remote.keys())
3468        .chain(local.keys())
3469        .cloned()
3470        .collect::<std::collections::BTreeSet<_>>();
3471    let mut conflicts = Vec::new();
3472    for path in &all_paths {
3473        if kept_home(path) {
3474            continue;
3475        }
3476        let base_hash = base.get(path).map(|file| file.sha256.as_str());
3477        let remote_hash = remote.get(path).map(|file| file.sha256.as_str());
3478        let local_hash = local.get(path).map(|file| file.0.as_str());
3479        if local_hash != base_hash && remote_hash != base_hash && local_hash != remote_hash {
3480            conflicts.push(path.clone());
3481        }
3482    }
3483    if !conflicts.is_empty() {
3484        conflicts.truncate(100);
3485        return Err(LinkError::Conflict { paths: conflicts });
3486    }
3487    let pointer = head.pointer.as_ref();
3488    let mut changed = match pointer {
3489        Some(pointer) => download_v2_blobs(
3490            cfg,
3491            &head.brain_id,
3492            pointer,
3493            remote
3494                .iter()
3495                .filter(|(path, file)| {
3496                    !kept_home(path)
3497                        && local.get(*path).map(|value| value.0.as_str())
3498                            != Some(file.sha256.as_str())
3499                })
3500                .collect(),
3501        )?,
3502        None => Vec::new(),
3503    };
3504    let deleted = base
3505        .iter()
3506        .filter(|(path, file)| {
3507            !remote.contains_key(*path)
3508                && !kept_home(path)
3509                && local.get(*path).map(|value| value.0.as_str()) == Some(file.sha256.as_str())
3510        })
3511        .map(|(path, _)| path.clone())
3512        .collect::<Vec<_>>();
3513    let extra_local = local
3514        .keys()
3515        .filter(|path| !remote.contains_key(*path) && !deleted.contains(path))
3516        .cloned()
3517        .collect::<Vec<_>>();
3518    if head.view_kind == "scoped" {
3519        changed.push(("DB.md".to_string(), scoped_projection_bytes(&head.brain_id)));
3520        changed.push((
3521            ".dbmd/view.json".to_string(),
3522            scoped_view_metadata(&head, remote.len())?,
3523        ));
3524    }
3525    #[cfg(unix)]
3526    install_pulled_delta(&dest, &changed, &deleted, true)?;
3527    let installed_store = Store::open_strict(&dest).map_err(|error| LinkError::InvalidPack {
3528        message: format!("installed v2 checkout is not a valid db.md store: {error}"),
3529    })?;
3530    let mut installed_local = v2_local_files(&installed_store)?;
3531    remove_scoped_projection(&head, baseline.as_ref(), &mut installed_local)?;
3532    let mut expected_local = local.clone();
3533    for (path, file) in &remote {
3534        if !kept_home(path) {
3535            expected_local.insert(path.clone(), (file.sha256.clone(), Vec::new()));
3536        }
3537    }
3538    for path in &deleted {
3539        expected_local.remove(path);
3540    }
3541    let local_dirty = installed_local.riding.iter().any(|(path, (hash, _))| {
3542        expected_local.get(path).map(|expected| &expected.0) != Some(hash)
3543    }) || expected_local.iter().any(|(path, (hash, _))| {
3544        installed_local.riding.get(path).map(|actual| &actual.0) != Some(hash)
3545    });
3546    let final_head = v2_verified_head(cfg, requested_brain)?
3547        .ok_or_else(|| invalid_feed("v2 head disappeared during pull"))?;
3548    if !same_v2_head(&head, &final_head) {
3549        return Err(LinkError::RemoteAdvancedDuringSync);
3550    }
3551    accept_v2_head(cfg, &final_head)?;
3552    save_v2_baseline(
3553        cfg,
3554        &head.brain_id,
3555        &dest,
3556        &v2_baseline_from_head(cfg, &head, remote.clone(), Some(&installed_local))?,
3557    )?;
3558    Ok(PullReport {
3559        brain: head.brain_id,
3560        slug: requested_brain.to_string(),
3561        head_seq: pointer.map_or(0, |value| value.seq),
3562        files: remote.len(),
3563        dest: dest.to_string_lossy().into_owned(),
3564        extra_local,
3565        sync_status: if local_dirty {
3566            "local_dirty_after_install".to_string()
3567        } else {
3568            "synced".to_string()
3569        },
3570    })
3571}
3572
3573fn v2_expected(remote: Option<&V2BaselineFile>) -> Value {
3574    match remote {
3575        Some(file) => json!({ "kind": "blob", "hash": file.sha256 }),
3576        None => json!({ "kind": "absent" }),
3577    }
3578}
3579
3580fn v2_riding_matches_remote(
3581    local: &std::collections::BTreeMap<String, (String, Vec<u8>)>,
3582    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
3583    keeps_home: impl Fn(&str) -> bool,
3584) -> bool {
3585    remote.iter().all(|(path, file)| {
3586        keeps_home(path)
3587            || local.get(path).map(|value| value.0.as_str()) == Some(file.sha256.as_str())
3588    }) && local.iter().all(|(path, (hash, _))| {
3589        remote.get(path).map(|file| file.sha256.as_str()) == Some(hash.as_str())
3590    })
3591}
3592
3593fn v2_sync_push(
3594    cfg: &HubConfig,
3595    requested_brain: &str,
3596    store: &Store,
3597    head: V2VerifiedHead,
3598    resume_local_policy: bool,
3599) -> LinkResult<Value> {
3600    let _operation_lock = lock_v2_sync_operation(cfg, &head.brain_id)?;
3601    let head = v2_verified_head(cfg, requested_brain)?
3602        .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
3603    let remote = files_for_v2_view(
3604        &head,
3605        v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
3606    );
3607    let baseline = load_v2_baseline(cfg, &head.brain_id, &store.root)?;
3608    ensure_v2_view_compatible(&head, baseline.as_ref())?;
3609    if head.view_kind == "scoped" && baseline.is_none() {
3610        return Err(LinkError::ScopedViewChanged);
3611    }
3612    let mut local_view = v2_local_files(store)?;
3613    remove_scoped_projection(&head, baseline.as_ref(), &mut local_view)?;
3614    let local = &local_view.riding;
3615    if let Some(previous) = baseline.as_ref() {
3616        if previous.local_policy_digest.as_deref() != Some(&local_view.policy.digest)
3617            && !resume_local_policy
3618        {
3619            let mut newly_eligible = previous
3620                .local_eligibility
3621                .iter()
3622                .filter(|(path, riding)| !**riding && !local_view.policy.keeps_home(path))
3623                .map(|(path, _)| path.clone())
3624                .collect::<Vec<_>>();
3625            if !newly_eligible.is_empty() {
3626                newly_eligible.truncate(100);
3627                return Err(LinkError::LocalPolicyTransition {
3628                    paths: newly_eligible,
3629                });
3630            }
3631        }
3632    }
3633    let base = match baseline {
3634        Some(ref state) => state.files.clone(),
3635        None if remote.is_empty() => std::collections::BTreeMap::new(),
3636        None => {
3637            let mut conflicts = remote
3638                .iter()
3639                .filter(|(path, file)| {
3640                    local.get(*path).map(|value| value.0.as_str()) != Some(file.sha256.as_str())
3641                })
3642                .map(|(path, _)| path.clone())
3643                .collect::<Vec<_>>();
3644            if !conflicts.is_empty() {
3645                conflicts.truncate(100);
3646                return Err(LinkError::Conflict { paths: conflicts });
3647            }
3648            remote.clone()
3649        }
3650    };
3651    let all_paths = base
3652        .keys()
3653        .chain(remote.keys())
3654        .chain(local.keys())
3655        .cloned()
3656        .collect::<std::collections::BTreeSet<_>>();
3657    let mut conflicts = Vec::new();
3658    let mut operations = Vec::new();
3659    let mut blobs = Vec::new();
3660    let mut upload_bytes = std::collections::BTreeMap::<String, Vec<u8>>::new();
3661    for path in all_paths {
3662        let base_hash = base.get(&path).map(|file| file.sha256.as_str());
3663        let remote_file = remote.get(&path);
3664        let remote_hash = remote_file.map(|file| file.sha256.as_str());
3665        let local_file = local.get(&path);
3666        let local_hash = local_file.map(|file| file.0.as_str());
3667        if local_hash == base_hash {
3668            continue;
3669        }
3670        if local_view.policy.keeps_home(&path) {
3671            // Kept-home is a local transfer exclusion, never an implicit
3672            // delete of the company's already-hosted coordinate.
3673            continue;
3674        }
3675        if remote_hash != base_hash && local_hash != remote_hash {
3676            conflicts.push(path);
3677            continue;
3678        }
3679        match local_file {
3680            Some((sha256, bytes)) => {
3681                operations.push(json!({
3682                    "op": "put",
3683                    "path": path,
3684                    "expected": v2_expected(remote_file),
3685                    "blob": sha256,
3686                    "bytes": bytes.len(),
3687                }));
3688                blobs.push(json!({
3689                    "sha256": sha256,
3690                    "bytes": bytes.len(),
3691                    "content_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
3692                }));
3693                upload_bytes.insert(sha256.clone(), bytes.clone());
3694            }
3695            None => {
3696                let Some(current) = remote_file else {
3697                    continue;
3698                };
3699                operations.push(json!({
3700                    "op": "delete",
3701                    "path": path,
3702                    "expected": { "kind": "blob", "hash": current.sha256 },
3703                }));
3704            }
3705        }
3706    }
3707    if !conflicts.is_empty() {
3708        conflicts.truncate(100);
3709        return Err(LinkError::Conflict { paths: conflicts });
3710    }
3711    if operations.is_empty() {
3712        let final_head = v2_verified_head(cfg, requested_brain)?
3713            .ok_or_else(|| invalid_feed("v2 head disappeared during sync"))?;
3714        if !same_v2_head(&head, &final_head) {
3715            return Err(LinkError::RemoteAdvancedDuringSync);
3716        }
3717        let mut final_local = v2_local_files(store)?;
3718        remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
3719        let local_changed = final_local.riding != local_view.riding;
3720        let remote_ahead = !v2_riding_matches_remote(&final_local.riding, &remote, |path| {
3721            final_local.policy.keeps_home(path)
3722        });
3723        let next = v2_baseline_from_head(cfg, &head, remote, Some(&final_local))?;
3724        let split_count = next.remote_copy_remains.len();
3725        accept_v2_head(cfg, &final_head)?;
3726        if !local_changed && !remote_ahead {
3727            refresh_scoped_view_marker(store, &head, next.files.len())?;
3728            save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
3729        }
3730        return Ok(json!({
3731            "v": 2,
3732            "outcome": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "no_change" },
3733            "sync_status": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "synced" },
3734            "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
3735            "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
3736            "local_policy": {
3737                "remote_copy_remains": split_count,
3738            },
3739        }));
3740    }
3741    let includes_contract = operations
3742        .iter()
3743        .any(|operation| operation.get("path").and_then(Value::as_str) == Some("DB.md"));
3744    let rebase = if head.pointer.is_none() || includes_contract {
3745        "strict"
3746    } else {
3747        "disjoint"
3748    };
3749    let base_value = head.pointer.as_ref().map(|pointer| {
3750        json!({
3751            "seq": pointer.seq,
3752            "commit_hash": pointer.commit_hash,
3753            "content_root": pointer.content_root,
3754        })
3755    });
3756    let entropy = format!(
3757        "{}\0{}\0{}\0{}\0{}",
3758        normalized_origin(&cfg.hub)?,
3759        head.brain_id,
3760        std::process::id(),
3761        std::time::SystemTime::now()
3762            .duration_since(std::time::UNIX_EPOCH)
3763            .unwrap_or_default()
3764            .as_nanos(),
3765        serde_json::to_string(&operations).unwrap_or_default()
3766    );
3767    let mutation_id = format!("dbmd-{}", content_sha256(entropy.as_bytes()));
3768    let mut body = json!({
3769        "mutation_id": mutation_id,
3770        "base": base_value,
3771        "rebase": rebase,
3772        "reason": "dbmd sync",
3773        "operations": operations,
3774        "blobs": blobs,
3775    });
3776    let changed_bytes = upload_bytes.values().try_fold(0_usize, |total, bytes| {
3777        total
3778            .checked_add(bytes.len())
3779            .ok_or_else(|| LinkError::PushTooLarge {
3780                detail: "v2 changed-byte total overflow".to_string(),
3781            })
3782    })?;
3783    if changed_bytes > 3 * 1024 * 1024 || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
3784        let declarations = upload_bytes
3785            .iter()
3786            .map(|(sha256, bytes)| json!({ "sha256": sha256, "bytes": bytes.len() }))
3787            .collect::<Vec<_>>();
3788        let reserved = ensure_ok(
3789            request(
3790                cfg,
3791                "POST",
3792                &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
3793                Some(&json!({ "blobs": declarations })),
3794                Auth::Required,
3795            )?,
3796            "prepare v2 changed-byte uploads",
3797        )?;
3798        let items = reserved
3799            .get("uploads")
3800            .and_then(Value::as_array)
3801            .ok_or_else(|| invalid_feed("v2 upload reservation response has no items"))?;
3802        if items.len() != upload_bytes.len() {
3803            return Err(invalid_feed(
3804                "v2 upload reservation response changed the requested set",
3805            ));
3806        }
3807        let mut references = Vec::with_capacity(items.len());
3808        let mut seen = std::collections::BTreeSet::new();
3809        for item in items {
3810            let sha256 = item
3811                .get("sha256")
3812                .and_then(Value::as_str)
3813                .ok_or_else(|| invalid_feed("v2 upload reservation has no hash"))?;
3814            let bytes = upload_bytes
3815                .get(sha256)
3816                .ok_or_else(|| invalid_feed("v2 upload reservation introduced a blob"))?;
3817            let declared_bytes = item
3818                .get("bytes")
3819                .and_then(Value::as_u64)
3820                .ok_or_else(|| invalid_feed("v2 upload reservation has no byte length"))?;
3821            let reservation_id = item
3822                .get("reservation_id")
3823                .and_then(Value::as_str)
3824                .ok_or_else(|| invalid_feed("v2 upload reservation has no opaque id"))?;
3825            if declared_bytes != bytes.len() as u64
3826                || !crate::ulid::is_ulid(reservation_id)
3827                || !seen.insert(sha256.to_string())
3828            {
3829                return Err(invalid_feed("v2 upload reservation item is inconsistent"));
3830            }
3831            match item.get("status").and_then(Value::as_str) {
3832                Some("upload") => {
3833                    let url = item
3834                        .get("url")
3835                        .and_then(Value::as_str)
3836                        .ok_or_else(|| invalid_feed("v2 upload reservation has no URL"))?;
3837                    put_presigned(cfg, url, item.get("headers").unwrap_or(&Value::Null), bytes)?;
3838                }
3839                Some("already_present") => {}
3840                _ => return Err(invalid_feed("v2 upload reservation has an unknown status")),
3841            }
3842            references.push(json!({
3843                "sha256": sha256,
3844                "bytes": bytes.len(),
3845                "reservation_id": reservation_id,
3846            }));
3847        }
3848        body["blobs"] = Value::Array(references);
3849    }
3850    if body.to_string().len() > MAX_PUSH_BYTES {
3851        return Err(LinkError::PushTooLarge {
3852            detail: "v2 operation metadata exceeds the bounded commit request".to_string(),
3853        });
3854    }
3855    let path = format!("/api/hub/brains/{requested_brain}/v2/commits");
3856    let mut result = ensure_ok(
3857        request(cfg, "POST", &path, Some(&body), Auth::Required)?,
3858        "v2 sync push",
3859    )?;
3860    let refreshed = v2_verified_head(cfg, requested_brain)?
3861        .ok_or_else(|| invalid_feed("v2 head disappeared after commit"))?;
3862    let accepted_hash = result.get("commit_hash").and_then(Value::as_str);
3863    if refreshed
3864        .pointer
3865        .as_ref()
3866        .map(|pointer| pointer.commit_hash.as_str())
3867        != accepted_hash
3868    {
3869        return Err(LinkError::RemoteAdvancedDuringSync);
3870    }
3871    ensure_v2_view_compatible(&refreshed, baseline.as_ref())?;
3872    let refreshed_files = files_for_v2_view(
3873        &refreshed,
3874        v2_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
3875    );
3876    let mut final_local = v2_local_files(store)?;
3877    remove_scoped_projection(&refreshed, baseline.as_ref(), &mut final_local)?;
3878    let local_dirty = final_local.riding != local_view.riding
3879        || !v2_riding_matches_remote(&final_local.riding, &refreshed_files, |path| {
3880            final_local.policy.keeps_home(path)
3881        });
3882    let next = v2_baseline_from_head(cfg, &refreshed, refreshed_files, Some(&final_local))?;
3883    let split_count = next.remote_copy_remains.len();
3884    accept_v2_head(cfg, &refreshed)?;
3885    if !local_dirty {
3886        refresh_scoped_view_marker(store, &refreshed, next.files.len())?;
3887        save_v2_baseline(cfg, &refreshed.brain_id, &store.root, &next)?;
3888    }
3889    if let Some(object) = result.as_object_mut() {
3890        object.insert(
3891            "local_policy".to_string(),
3892            json!({ "remote_copy_remains": split_count }),
3893        );
3894        object.insert(
3895            "sync_status".to_string(),
3896            Value::String(if local_dirty {
3897                "remote_committed_local_dirty".to_string()
3898            } else {
3899                "synced".to_string()
3900            }),
3901        );
3902    }
3903    Ok(result)
3904}
3905
3906/// Negotiate v2 and send only local changes. A v1 hub retains the existing
3907/// whole-snapshot behavior until its brain advertises the new profile.
3908pub fn sync_push_incremental(cfg: &HubConfig, brain: &str, store: &Store) -> LinkResult<Value> {
3909    sync_push_incremental_with_policy(cfg, brain, store, false)
3910}
3911
3912/// The explicit `.sevralocal` adoption form. Ordinary sync never uploads a
3913/// path that became eligible because local policy was weakened or removed.
3914pub fn sync_push_incremental_with_policy(
3915    cfg: &HubConfig,
3916    brain: &str,
3917    store: &Store,
3918    resume_local_policy: bool,
3919) -> LinkResult<Value> {
3920    require_safe_ref(brain)?;
3921    if let Some(head) = v2_verified_head(cfg, brain)? {
3922        return v2_sync_push(cfg, brain, store, head, resume_local_policy);
3923    }
3924    if resume_local_policy {
3925        return Err(LinkError::InvalidPack {
3926            message: "--resume-local-policy requires a link.md v2 brain".to_string(),
3927        });
3928    }
3929    let files = collect_push_files(store)?;
3930    sync_push(cfg, brain, &files)
3931}
3932
3933/// Pull the granted slice of `brain` to `out` (default: `./<slug>`). Every
3934/// exported path is safety-gated before it touches disk; files are written
3935/// atomically; nothing local is ever deleted (locals the export lacks are
3936/// *reported* in `extra_local` instead). Returns the report; rebuilding the
3937/// local index catalog afterwards is the caller's (cheap, optional) step.
3938pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
3939    require_hardened_filesystem("sync pull")?;
3940    require_safe_ref(brain)?;
3941    if let Some(head) = v2_verified_head(cfg, brain)? {
3942        return v2_sync_pull(cfg, brain, head, out);
3943    }
3944    let remote = verified_remote_head(cfg, brain, false)?;
3945    if !remote.head.verified {
3946        return Err(invalid_feed(
3947            "the grant exposes head movement but no signed snapshot; refusing an unverifiable pull",
3948        ));
3949    }
3950    let snapshot_hash = remote.head.feed_hash.as_deref().unwrap_or("none");
3951    let path = format!(
3952        "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={snapshot_hash}",
3953        remote.head.seq
3954    );
3955    let body = ensure_ok(
3956        request(cfg, "GET", &path, None, Auth::Required)?,
3957        "sync pull",
3958    )?;
3959    if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
3960        || body.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
3961    {
3962        return Err(invalid_feed(
3963            "export response is not bound to the verified snapshot token",
3964        ));
3965    }
3966
3967    let remote_slug = body
3968        .get("slug")
3969        .and_then(Value::as_str)
3970        .filter(|slug| is_safe_slug(slug));
3971    let slug = remote_slug
3972        .or_else(|| is_safe_slug(brain).then_some(brain))
3973        .unwrap_or("brain")
3974        .to_string();
3975    let brain_id = body
3976        .get("brain")
3977        .and_then(Value::as_str)
3978        .unwrap_or(&remote.head.brain)
3979        .to_string();
3980    if brain_id != remote.head.brain {
3981        return Err(invalid_feed(
3982            "export response names a different brain than the verified head",
3983        ));
3984    }
3985    let head_seq = remote.head.seq;
3986    let dest: PathBuf = match out {
3987        Some(p) => p.to_path_buf(),
3988        None => PathBuf::from(&slug),
3989    };
3990    let entries = if head_seq == 0 {
3991        let files = body
3992            .get("files")
3993            .and_then(Value::as_array)
3994            .ok_or_else(|| invalid_feed("empty snapshot export did not carry an empty manifest"))?;
3995        if !files.is_empty() || body.get("url").is_some() {
3996            return Err(invalid_feed(
3997                "empty signed feed cannot authorize non-empty exported content",
3998            ));
3999        }
4000        Vec::new()
4001    } else {
4002        let signed_head = remote
4003            .head_entry
4004            .as_ref()
4005            .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
4006        let expected = &signed_head.entry.pack_sha256;
4007        if !is_sha256(expected) {
4008            return Err(invalid_feed(
4009                "signed head carries an invalid snapshot pack digest",
4010            ));
4011        }
4012        if let Some(url) = body.get("url").and_then(Value::as_str) {
4013            if body.get("sha256").and_then(Value::as_str) != Some(expected.as_str()) {
4014                return Err(invalid_feed(
4015                    "export pack digest does not match the signed head entry",
4016                ));
4017            }
4018            let bytes = get_presigned(cfg, url)?;
4019            let actual = format!("{:x}", Sha256::digest(&bytes));
4020            if actual != *expected {
4021                return Err(LinkError::InvalidPack {
4022                    message: "downloaded pack does not match the signed snapshot digest"
4023                        .to_string(),
4024                });
4025            }
4026            let entries = parse_store_pack(bytes)?;
4027            if signed_head.entry.kind == "push" {
4028                verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
4029            }
4030            entries
4031        } else {
4032            if signed_head.entry.kind != "push" {
4033                return Err(invalid_feed(
4034                    "delta snapshots must export the exact signed pack",
4035                ));
4036            }
4037            let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
4038                invalid_feed("verified snapshot export carried neither a pack nor files")
4039            })?;
4040            let mut entries = Vec::with_capacity(files.len());
4041            for file in files {
4042                let path = file
4043                    .get("path")
4044                    .and_then(Value::as_str)
4045                    .ok_or_else(|| invalid_feed("exported file path is not a string"))?;
4046                let content = file
4047                    .get("content")
4048                    .and_then(Value::as_str)
4049                    .ok_or_else(|| invalid_feed("exported inline content is not UTF-8 text"))?;
4050                entries.push((path.to_string(), content.as_bytes().to_vec()));
4051            }
4052            verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
4053            entries
4054        }
4055    };
4056
4057    // Gate the complete manifest before the first filesystem mutation.
4058    let mut seen = std::collections::HashSet::new();
4059    for (path, _) in &entries {
4060        if !safe_store_rel_path(path) {
4061            return Err(LinkError::UnsafePath { path: path.clone() });
4062        }
4063        if !seen.insert(path) {
4064            return Err(LinkError::InvalidPack {
4065                message: format!("duplicate path `{path}`"),
4066            });
4067        }
4068    }
4069    // Compute divergence against the still-live tree. The staged clone keeps
4070    // these extra locals byte-for-byte while overlaying the signed snapshot.
4071    let pulled: std::collections::BTreeSet<&str> =
4072        entries.iter().map(|(p, _)| p.as_str()).collect();
4073    let mut extra_local = Vec::new();
4074    if let Ok(store) = Store::open(&dest) {
4075        if let Ok(walked) = store.walk() {
4076            for rel in walked {
4077                let rel_str = rel.to_string_lossy().replace('\\', "/");
4078                if !pulled.contains(rel_str.as_str()) {
4079                    extra_local.push(rel_str);
4080                }
4081            }
4082        }
4083    }
4084    #[cfg(unix)]
4085    install_pulled_snapshot(&dest, &entries)?;
4086
4087    Ok(PullReport {
4088        brain: brain_id,
4089        slug,
4090        head_seq,
4091        files: entries.len(),
4092        dest: dest.to_string_lossy().into_owned(),
4093        extra_local,
4094        sync_status: "synced".to_string(),
4095    })
4096}
4097
4098#[cfg(unix)]
4099fn c_name(bytes: &[u8], display: &str) -> LinkResult<std::ffi::CString> {
4100    std::ffi::CString::new(bytes).map_err(|_| LinkError::UnsafePath {
4101        path: display.to_string(),
4102    })
4103}
4104
4105#[cfg(unix)]
4106fn open_dir_at(
4107    parent: std::os::fd::RawFd,
4108    name: &std::ffi::CStr,
4109    display: &str,
4110) -> LinkResult<std::fs::File> {
4111    use std::os::fd::FromRawFd as _;
4112    let fd = unsafe {
4113        libc::openat(
4114            parent,
4115            name.as_ptr(),
4116            libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4117        )
4118    };
4119    if fd < 0 {
4120        return Err(LinkError::UnsafePath {
4121            path: display.to_string(),
4122        });
4123    }
4124    Ok(unsafe { std::fs::File::from_raw_fd(fd) })
4125}
4126
4127/// Open (and, where absent, create) a directory path without following a
4128/// symlink in any component. The returned directory capability remains bound
4129/// to the opened inode even if an attacker renames or replaces an ancestor.
4130#[cfg(unix)]
4131fn open_dir_path_nofollow(path: &Path, create: bool) -> LinkResult<std::fs::File> {
4132    use std::os::fd::AsRawFd as _;
4133
4134    // macOS exposes a few system-owned compatibility symlinks at the root.
4135    // Normalize only those fixed OS aliases; never canonicalize an arbitrary
4136    // caller-controlled ancestor.
4137    #[cfg(target_os = "macos")]
4138    let normalized = [("/var", "/private/var"), ("/tmp", "/private/tmp")]
4139        .into_iter()
4140        .find_map(|(alias, real)| {
4141            path.strip_prefix(alias)
4142                .ok()
4143                .map(|rest| Path::new(real).join(rest))
4144        })
4145        .unwrap_or_else(|| path.to_path_buf());
4146    #[cfg(not(target_os = "macos"))]
4147    let normalized = path.to_path_buf();
4148
4149    let start = if normalized.is_absolute() {
4150        std::fs::File::open("/")?
4151    } else {
4152        std::fs::File::open(".")?
4153    };
4154    let mut directory = start;
4155    for component in normalized.components() {
4156        use std::path::Component;
4157        let name = match component {
4158            Component::RootDir | Component::CurDir => continue,
4159            Component::Normal(name) => name,
4160            Component::ParentDir | Component::Prefix(_) => {
4161                return Err(LinkError::UnsafePath {
4162                    path: path.display().to_string(),
4163                });
4164            }
4165        };
4166        use std::os::unix::ffi::OsStrExt as _;
4167        let name = c_name(name.as_bytes(), &path.display().to_string())?;
4168        if create {
4169            let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
4170            if made != 0 {
4171                let error = std::io::Error::last_os_error();
4172                if error.raw_os_error() != Some(libc::EEXIST) {
4173                    return Err(error.into());
4174                }
4175            }
4176        }
4177        directory = open_dir_at(directory.as_raw_fd(), &name, &path.display().to_string())?;
4178    }
4179    Ok(directory)
4180}
4181
4182#[cfg(unix)]
4183fn open_or_create_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
4184    open_dir_path_nofollow(path, true)
4185}
4186
4187#[cfg(unix)]
4188fn open_existing_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
4189    open_dir_path_nofollow(path, false)
4190}
4191
4192#[cfg(unix)]
4193fn entry_is_dir_at(parent: std::os::fd::RawFd, name: &std::ffi::CStr) -> LinkResult<Option<bool>> {
4194    let mut stat: libc::stat = unsafe { std::mem::zeroed() };
4195    let result =
4196        unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) };
4197    if result == 0 {
4198        return Ok(Some((stat.st_mode & libc::S_IFMT) == libc::S_IFDIR));
4199    }
4200    let error = std::io::Error::last_os_error();
4201    if error.kind() == std::io::ErrorKind::NotFound {
4202        Ok(None)
4203    } else {
4204        Err(error.into())
4205    }
4206}
4207
4208#[cfg(unix)]
4209fn create_dir_exclusive_at(
4210    parent: std::os::fd::RawFd,
4211    name: &std::ffi::CStr,
4212    display: &str,
4213) -> LinkResult<std::fs::File> {
4214    let made = unsafe { libc::mkdirat(parent, name.as_ptr(), 0o700) };
4215    if made != 0 {
4216        return Err(LinkError::UnsafePath {
4217            path: display.to_string(),
4218        });
4219    }
4220    open_dir_at(parent, name, display)
4221}
4222
4223#[cfg(unix)]
4224fn directory_entry_names(directory: &std::fs::File) -> LinkResult<Vec<std::ffi::CString>> {
4225    use std::os::fd::AsRawFd as _;
4226
4227    let duplicate = unsafe { libc::dup(directory.as_raw_fd()) };
4228    if duplicate < 0 {
4229        return Err(std::io::Error::last_os_error().into());
4230    }
4231    let stream = unsafe { libc::fdopendir(duplicate) };
4232    if stream.is_null() {
4233        let error = std::io::Error::last_os_error();
4234        unsafe {
4235            libc::close(duplicate);
4236        }
4237        return Err(error.into());
4238    }
4239    let mut names = Vec::new();
4240    loop {
4241        let entry = unsafe { libc::readdir(stream) };
4242        if entry.is_null() {
4243            break;
4244        }
4245        let raw = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) };
4246        if raw.to_bytes() != b"." && raw.to_bytes() != b".." {
4247            names.push(raw.to_owned());
4248        }
4249    }
4250    if unsafe { libc::closedir(stream) } != 0 {
4251        return Err(std::io::Error::last_os_error().into());
4252    }
4253    Ok(names)
4254}
4255
4256/// Remove an entry tree relative to a held directory capability. Symlinks are
4257/// unlinked, never traversed, even when an old mirror contains hostile names.
4258#[cfg(unix)]
4259fn remove_tree_at(
4260    parent: std::os::fd::RawFd,
4261    name: &std::ffi::CStr,
4262    display: &str,
4263) -> LinkResult<()> {
4264    use std::os::fd::AsRawFd as _;
4265
4266    match entry_is_dir_at(parent, name)? {
4267        None => return Ok(()),
4268        Some(false) => {
4269            if unsafe { libc::unlinkat(parent, name.as_ptr(), 0) } != 0 {
4270                return Err(std::io::Error::last_os_error().into());
4271            }
4272        }
4273        Some(true) => {
4274            let directory = open_dir_at(parent, name, display)?;
4275            for child in directory_entry_names(&directory)? {
4276                let child_display =
4277                    format!("{display}/{}", String::from_utf8_lossy(child.to_bytes()));
4278                remove_tree_at(directory.as_raw_fd(), &child, &child_display)?;
4279            }
4280            drop(directory);
4281            if unsafe { libc::unlinkat(parent, name.as_ptr(), libc::AT_REMOVEDIR) } != 0 {
4282                return Err(std::io::Error::last_os_error().into());
4283            }
4284        }
4285    }
4286    Ok(())
4287}
4288
4289/// Clone a live destination into a private sibling stage without following
4290/// any symlink. Regular files are streamed between held descriptors; symlinks
4291/// are reproduced as links, never opened. Special files are refused.
4292#[cfg(unix)]
4293fn clone_tree_contents(
4294    source: &std::fs::File,
4295    destination: &std::fs::File,
4296    display: &str,
4297) -> LinkResult<()> {
4298    use std::os::fd::{AsRawFd as _, FromRawFd as _};
4299
4300    for name in directory_entry_names(source)? {
4301        let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
4302        let mut stat: libc::stat = unsafe { std::mem::zeroed() };
4303        if unsafe {
4304            libc::fstatat(
4305                source.as_raw_fd(),
4306                name.as_ptr(),
4307                &mut stat,
4308                libc::AT_SYMLINK_NOFOLLOW,
4309            )
4310        } != 0
4311        {
4312            return Err(std::io::Error::last_os_error().into());
4313        }
4314        match stat.st_mode & libc::S_IFMT {
4315            libc::S_IFDIR => {
4316                if unsafe {
4317                    libc::mkdirat(destination.as_raw_fd(), name.as_ptr(), stat.st_mode & 0o777)
4318                } != 0
4319                {
4320                    return Err(std::io::Error::last_os_error().into());
4321                }
4322                let source_child = open_dir_at(source.as_raw_fd(), &name, &child_display)?;
4323                let destination_child =
4324                    open_dir_at(destination.as_raw_fd(), &name, &child_display)?;
4325                clone_tree_contents(&source_child, &destination_child, &child_display)?;
4326                destination_child.sync_all()?;
4327            }
4328            libc::S_IFREG => {
4329                let source_fd = unsafe {
4330                    libc::openat(
4331                        source.as_raw_fd(),
4332                        name.as_ptr(),
4333                        libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4334                    )
4335                };
4336                if source_fd < 0 {
4337                    return Err(std::io::Error::last_os_error().into());
4338                }
4339                let destination_fd = unsafe {
4340                    libc::openat(
4341                        destination.as_raw_fd(),
4342                        name.as_ptr(),
4343                        libc::O_WRONLY
4344                            | libc::O_CREAT
4345                            | libc::O_EXCL
4346                            | libc::O_CLOEXEC
4347                            | libc::O_NOFOLLOW,
4348                        (stat.st_mode & 0o777) as libc::c_uint,
4349                    )
4350                };
4351                if destination_fd < 0 {
4352                    unsafe {
4353                        libc::close(source_fd);
4354                    }
4355                    return Err(std::io::Error::last_os_error().into());
4356                }
4357                let mut input = unsafe { std::fs::File::from_raw_fd(source_fd) };
4358                let mut output = unsafe { std::fs::File::from_raw_fd(destination_fd) };
4359                std::io::copy(&mut input, &mut output)?;
4360                output.sync_all()?;
4361            }
4362            libc::S_IFLNK => {
4363                let mut target = vec![0_u8; 4097];
4364                let length = unsafe {
4365                    libc::readlinkat(
4366                        source.as_raw_fd(),
4367                        name.as_ptr(),
4368                        target.as_mut_ptr().cast(),
4369                        target.len(),
4370                    )
4371                };
4372                if length < 0 || length as usize >= target.len() {
4373                    return Err(LinkError::UnsafePath {
4374                        path: child_display,
4375                    });
4376                }
4377                target.truncate(length as usize);
4378                let target = c_name(&target, &child_display)?;
4379                if unsafe {
4380                    libc::symlinkat(target.as_ptr(), destination.as_raw_fd(), name.as_ptr())
4381                } != 0
4382                {
4383                    return Err(std::io::Error::last_os_error().into());
4384                }
4385            }
4386            _ => {
4387                return Err(LinkError::UnsafePath {
4388                    path: child_display,
4389                });
4390            }
4391        }
4392    }
4393    destination.sync_all()?;
4394    Ok(())
4395}
4396
4397#[cfg(target_os = "linux")]
4398fn install_stage_at(
4399    parent: std::os::fd::RawFd,
4400    stage: &std::ffi::CStr,
4401    dest: &std::ffi::CStr,
4402    dest_exists: bool,
4403) -> LinkResult<()> {
4404    let flags = if dest_exists {
4405        libc::RENAME_EXCHANGE
4406    } else {
4407        libc::RENAME_NOREPLACE
4408    };
4409    // `libc::renameat2` is not exported for the musl targets we ship. Invoke
4410    // the kernel ABI directly, as `fsx::renameat_noreplace` does, so the same
4411    // atomic exchange/no-replace boundary compiles for glibc and musl.
4412    let result = unsafe {
4413        libc::syscall(
4414            libc::SYS_renameat2,
4415            parent,
4416            stage.as_ptr(),
4417            parent,
4418            dest.as_ptr(),
4419            flags,
4420        )
4421    };
4422    if result == 0 {
4423        Ok(())
4424    } else {
4425        Err(std::io::Error::last_os_error().into())
4426    }
4427}
4428
4429#[cfg(target_os = "macos")]
4430fn install_stage_at(
4431    parent: std::os::fd::RawFd,
4432    stage: &std::ffi::CStr,
4433    dest: &std::ffi::CStr,
4434    dest_exists: bool,
4435) -> LinkResult<()> {
4436    let flags = if dest_exists {
4437        libc::RENAME_SWAP
4438    } else {
4439        libc::RENAME_EXCL
4440    };
4441    let result =
4442        unsafe { libc::renameatx_np(parent, stage.as_ptr(), parent, dest.as_ptr(), flags) };
4443    if result == 0 {
4444        Ok(())
4445    } else {
4446        Err(std::io::Error::last_os_error().into())
4447    }
4448}
4449
4450#[cfg(unix)]
4451fn write_pull_entries_beneath_dir(
4452    root: &std::fs::File,
4453    entries: &[(String, Vec<u8>)],
4454) -> LinkResult<()> {
4455    use std::os::fd::{AsRawFd as _, FromRawFd as _};
4456
4457    for (path, content) in entries {
4458        let components: Vec<&str> = path.split('/').collect();
4459        let (leaf, parents) = components
4460            .split_last()
4461            .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
4462        let mut directory = root.try_clone()?;
4463        for component in parents {
4464            let name = c_name(component.as_bytes(), path)?;
4465            let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
4466            if made != 0 {
4467                let error = std::io::Error::last_os_error();
4468                if error.raw_os_error() != Some(libc::EEXIST) {
4469                    return Err(error.into());
4470                }
4471            }
4472            directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
4473        }
4474
4475        let leaf_name = c_name(leaf.as_bytes(), path)?;
4476        let mut existing: libc::stat = unsafe { std::mem::zeroed() };
4477        let inspected = unsafe {
4478            libc::fstatat(
4479                directory.as_raw_fd(),
4480                leaf_name.as_ptr(),
4481                &mut existing,
4482                libc::AT_SYMLINK_NOFOLLOW,
4483            )
4484        };
4485        if inspected == 0 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK {
4486            return Err(LinkError::UnsafePath { path: path.clone() });
4487        }
4488
4489        let nonce = std::time::SystemTime::now()
4490            .duration_since(std::time::UNIX_EPOCH)
4491            .unwrap_or_default()
4492            .as_nanos();
4493        let temp_name = format!(
4494            ".dbmd-pull-{}-{nonce}-{}",
4495            std::process::id(),
4496            content_sha256(format!("{path}\0{}", content.len()).as_bytes())
4497        );
4498        let temp = c_name(temp_name.as_bytes(), path)?;
4499        let fd = unsafe {
4500            libc::openat(
4501                directory.as_raw_fd(),
4502                temp.as_ptr(),
4503                libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4504                0o600,
4505            )
4506        };
4507        if fd < 0 {
4508            return Err(std::io::Error::last_os_error().into());
4509        }
4510        let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
4511        if let Err(error) = file.write_all(content).and_then(|_| file.sync_all()) {
4512            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4513            return Err(error.into());
4514        }
4515        drop(file);
4516        let renamed = unsafe {
4517            libc::renameat(
4518                directory.as_raw_fd(),
4519                temp.as_ptr(),
4520                directory.as_raw_fd(),
4521                leaf_name.as_ptr(),
4522            )
4523        };
4524        if renamed != 0 {
4525            let error = std::io::Error::last_os_error();
4526            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4527            return Err(error.into());
4528        }
4529        directory.sync_all()?;
4530    }
4531    root.sync_all()?;
4532    Ok(())
4533}
4534
4535#[cfg(unix)]
4536fn remove_pull_paths_beneath_dir(root: &std::fs::File, paths: &[String]) -> LinkResult<()> {
4537    use std::os::fd::AsRawFd as _;
4538    for path in paths {
4539        if !safe_store_rel_path(path) {
4540            return Err(LinkError::UnsafePath { path: path.clone() });
4541        }
4542        let components = path.split('/').collect::<Vec<_>>();
4543        let Some((leaf, parents)) = components.split_last() else {
4544            return Err(LinkError::UnsafePath { path: path.clone() });
4545        };
4546        let mut directory = root.try_clone()?;
4547        let mut missing = false;
4548        for component in parents {
4549            let name = c_name(component.as_bytes(), path)?;
4550            match entry_is_dir_at(directory.as_raw_fd(), &name)? {
4551                None => {
4552                    missing = true;
4553                    break;
4554                }
4555                Some(false) => return Err(LinkError::UnsafePath { path: path.clone() }),
4556                Some(true) => {
4557                    directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
4558                }
4559            }
4560        }
4561        if missing {
4562            continue;
4563        }
4564        let leaf = c_name(leaf.as_bytes(), path)?;
4565        match entry_is_dir_at(directory.as_raw_fd(), &leaf)? {
4566            None => {}
4567            Some(true) => return Err(LinkError::UnsafePath { path: path.clone() }),
4568            Some(false) => {
4569                if unsafe { libc::unlinkat(directory.as_raw_fd(), leaf.as_ptr(), 0) } != 0 {
4570                    return Err(std::io::Error::last_os_error().into());
4571                }
4572                directory.sync_all()?;
4573            }
4574        }
4575    }
4576    Ok(())
4577}
4578
4579#[cfg(unix)]
4580fn install_pulled_delta(
4581    dest: &Path,
4582    entries: &[(String, Vec<u8>)],
4583    deleted: &[String],
4584    rebuild_indexes: bool,
4585) -> LinkResult<()> {
4586    use ring::rand::SecureRandom as _;
4587    use std::os::fd::AsRawFd as _;
4588    use std::os::unix::ffi::OsStrExt as _;
4589
4590    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
4591    let name = dest
4592        .file_name()
4593        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
4594        .ok_or_else(|| LinkError::UnsafePath {
4595            path: dest.display().to_string(),
4596        })?;
4597    let parent_dir = open_or_create_dir_nofollow(parent)?;
4598    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
4599    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
4600        None => false,
4601        Some(true) => true,
4602        Some(false) => {
4603            return Err(LinkError::UnsafePath {
4604                path: dest.display().to_string(),
4605            });
4606        }
4607    };
4608
4609    let mut nonce = [0_u8; 16];
4610    ring::rand::SystemRandom::new()
4611        .fill(&mut nonce)
4612        .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
4613    let stage_label = format!(
4614        ".{}.dbmd-pull-stage-{}",
4615        name.to_string_lossy(),
4616        URL_SAFE_NO_PAD.encode(nonce)
4617    );
4618    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
4619    let stage_dir = create_dir_exclusive_at(
4620        parent_dir.as_raw_fd(),
4621        &stage_name,
4622        &dest.display().to_string(),
4623    )?;
4624
4625    let prepared = (|| -> LinkResult<()> {
4626        if dest_exists {
4627            let live = open_dir_at(
4628                parent_dir.as_raw_fd(),
4629                &dest_name,
4630                &dest.display().to_string(),
4631            )?;
4632            clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
4633        }
4634        remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
4635        write_pull_entries_beneath_dir(&stage_dir, entries)?;
4636        if rebuild_indexes {
4637            let stage_store =
4638                Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
4639                    .map_err(|error| LinkError::InvalidPack {
4640                        message: format!("v2 staging tree is not a valid db.md store: {error}"),
4641                    })?;
4642            crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
4643                LinkError::InvalidPack {
4644                    message: format!("could not materialize v2 local catalogs: {error}"),
4645                }
4646            })?;
4647        }
4648        stage_dir.sync_all()?;
4649        Ok(())
4650    })();
4651    if let Err(error) = prepared {
4652        let _ = remove_tree_at(
4653            parent_dir.as_raw_fd(),
4654            &stage_name,
4655            &dest.display().to_string(),
4656        );
4657        return Err(error);
4658    }
4659
4660    if let Err(error) =
4661        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
4662    {
4663        let _ = remove_tree_at(
4664            parent_dir.as_raw_fd(),
4665            &stage_name,
4666            &dest.display().to_string(),
4667        );
4668        return Err(error);
4669    }
4670    parent_dir.sync_all()?;
4671    if dest_exists {
4672        // Commit already happened atomically. Cleanup is best-effort so a
4673        // failure cannot be reported as a failed pull after the live tree
4674        // changed; a crash may leave only this private old-tree sibling.
4675        let _ = remove_tree_at(
4676            parent_dir.as_raw_fd(),
4677            &stage_name,
4678            &dest.display().to_string(),
4679        );
4680        let _ = parent_dir.sync_all();
4681    }
4682    Ok(())
4683}
4684
4685#[cfg(unix)]
4686fn install_pulled_snapshot(dest: &Path, entries: &[(String, Vec<u8>)]) -> LinkResult<()> {
4687    install_pulled_delta(dest, entries, &[], false)
4688}
4689
4690fn is_safe_slug(slug: &str) -> bool {
4691    !slug.is_empty()
4692        && slug.len() <= 63
4693        && !slug.starts_with('-')
4694        && !slug.ends_with('-')
4695        && slug
4696            .bytes()
4697            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
4698}
4699
4700fn le_u16(bytes: &[u8], at: usize) -> Option<u16> {
4701    Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
4702}
4703
4704fn le_u32(bytes: &[u8], at: usize) -> Option<u32> {
4705    Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
4706}
4707
4708fn le_u64(bytes: &[u8], at: usize) -> Option<u64> {
4709    Some(u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?))
4710}
4711
4712fn preflight_zip_central_directory(
4713    bytes: &[u8],
4714    offset: usize,
4715    size: usize,
4716    count: u64,
4717) -> LinkResult<()> {
4718    const CENTRAL_ENTRY_SIG: &[u8; 4] = b"PK\x01\x02";
4719    let end = offset
4720        .checked_add(size)
4721        .filter(|end| *end <= bytes.len())
4722        .ok_or_else(|| LinkError::InvalidPack {
4723            message: "ZIP central directory is out of bounds".to_string(),
4724        })?;
4725    let mut cursor = offset;
4726    for _ in 0..count {
4727        if bytes.get(cursor..cursor + 4) != Some(CENTRAL_ENTRY_SIG.as_slice()) {
4728            return Err(LinkError::InvalidPack {
4729                message: "ZIP central directory entry count is inconsistent".to_string(),
4730            });
4731        }
4732        if le_u16(bytes, cursor + 34) != Some(0) {
4733            return Err(LinkError::InvalidPack {
4734                message: "multi-disk ZIP archives are not supported".to_string(),
4735            });
4736        }
4737        let variable = [28, 30, 32].into_iter().try_fold(0usize, |total, at| {
4738            total.checked_add(le_u16(bytes, cursor + at)? as usize)
4739        });
4740        cursor = cursor
4741            .checked_add(46)
4742            .and_then(|fixed| fixed.checked_add(variable?))
4743            .filter(|cursor| *cursor <= end)
4744            .ok_or_else(|| LinkError::InvalidPack {
4745                message: "ZIP central directory entry is truncated".to_string(),
4746            })?;
4747    }
4748    if cursor != end {
4749        return Err(LinkError::InvalidPack {
4750            message: "ZIP central directory size is inconsistent".to_string(),
4751        });
4752    }
4753    Ok(())
4754}
4755
4756/// Read only the bounded ZIP trailer before `ZipArchive::new` allocates one
4757/// metadata object per central-directory entry. Supports ordinary EOCD and the
4758/// Zip64 locator/record emitted for >65,535-entry archives.
4759fn preflight_zip_entry_count(bytes: &[u8], max_entries: usize) -> LinkResult<()> {
4760    const EOCD_SIG: &[u8; 4] = b"PK\x05\x06";
4761    const ZIP64_LOCATOR_SIG: &[u8; 4] = b"PK\x06\x07";
4762    const ZIP64_EOCD_SIG: &[u8; 4] = b"PK\x06\x06";
4763    let search_start = bytes.len().saturating_sub(22 + u16::MAX as usize);
4764    let eocd = bytes[search_start..]
4765        .windows(4)
4766        .rposition(|window| window == EOCD_SIG)
4767        .map(|offset| search_start + offset)
4768        .ok_or_else(|| LinkError::InvalidPack {
4769            message: "ZIP has no end-of-central-directory record".to_string(),
4770        })?;
4771    let invalid_end = || LinkError::InvalidPack {
4772        message: "ZIP has an invalid end-of-central-directory structure".to_string(),
4773    };
4774    let comment_len = le_u16(bytes, eocd + 20).ok_or_else(invalid_end)? as usize;
4775    if eocd
4776        .checked_add(22)
4777        .and_then(|end| end.checked_add(comment_len))
4778        != Some(bytes.len())
4779    {
4780        // Do not fall back to an earlier signature. ZipArchive does, and a
4781        // fake low-count EOCD appended after a real Zip64 entry-count bomb
4782        // would otherwise bypass this allocation preflight.
4783        return Err(invalid_end());
4784    }
4785    let disk = le_u16(bytes, eocd + 4);
4786    let central_disk = le_u16(bytes, eocd + 6);
4787    if disk != Some(0) || central_disk != Some(0) {
4788        return Err(LinkError::InvalidPack {
4789            message: "multi-disk ZIP archives are not supported".to_string(),
4790        });
4791    }
4792    let entries_on_disk = le_u16(bytes, eocd + 8).ok_or_else(invalid_end)?;
4793    let ordinary = le_u16(bytes, eocd + 10).ok_or_else(invalid_end)?;
4794    if entries_on_disk != ordinary {
4795        return Err(LinkError::InvalidPack {
4796            message: "multi-disk ZIP archives are not supported".to_string(),
4797        });
4798    }
4799    let zip64_locator = eocd
4800        .checked_sub(20)
4801        .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_LOCATOR_SIG.as_slice()));
4802    let (count, central_offset, central_size) = if ordinary != u16::MAX || zip64_locator.is_none() {
4803        let central_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)? as usize;
4804        let central_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)? as usize;
4805        if central_offset
4806            .checked_add(central_size)
4807            .filter(|end| *end == eocd)
4808            .is_none()
4809        {
4810            return Err(invalid_end());
4811        }
4812        (ordinary as u64, central_offset, central_size)
4813    } else {
4814        let Some(locator) = zip64_locator else {
4815            return Err(invalid_end());
4816        };
4817        if le_u32(bytes, locator + 4) != Some(0) || le_u32(bytes, locator + 16) != Some(1) {
4818            return Err(LinkError::InvalidPack {
4819                message: "multi-disk ZIP64 archives are not supported".to_string(),
4820            });
4821        }
4822        let record = le_u64(bytes, locator + 8)
4823            .and_then(|offset| usize::try_from(offset).ok())
4824            .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_EOCD_SIG.as_slice()))
4825            .ok_or_else(|| LinkError::InvalidPack {
4826                message: "ZIP64 archive has an invalid end record".to_string(),
4827            })?;
4828        let record_size = le_u64(bytes, record + 4)
4829            .and_then(|size| usize::try_from(size).ok())
4830            .filter(|size| *size >= 44)
4831            .ok_or_else(invalid_end)?;
4832        if record
4833            .checked_add(12)
4834            .and_then(|end| end.checked_add(record_size))
4835            != Some(locator)
4836            || le_u32(bytes, record + 16) != Some(0)
4837            || le_u32(bytes, record + 20) != Some(0)
4838        {
4839            return Err(invalid_end());
4840        }
4841        let zip64_on_disk = le_u64(bytes, record + 24).ok_or_else(invalid_end)?;
4842        let zip64_total = le_u64(bytes, record + 32).ok_or_else(invalid_end)?;
4843        let central_size = le_u64(bytes, record + 40)
4844            .and_then(|size| usize::try_from(size).ok())
4845            .ok_or_else(invalid_end)?;
4846        let central_offset = le_u64(bytes, record + 48)
4847            .and_then(|offset| usize::try_from(offset).ok())
4848            .ok_or_else(invalid_end)?;
4849        if zip64_on_disk != zip64_total
4850            || central_offset
4851                .checked_add(central_size)
4852                .filter(|end| *end == record)
4853                .is_none()
4854        {
4855            return Err(invalid_end());
4856        }
4857        let legacy_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)?;
4858        let legacy_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)?;
4859        if (legacy_size != u32::MAX && legacy_size as usize != central_size)
4860            || (legacy_offset != u32::MAX && legacy_offset as usize != central_offset)
4861        {
4862            return Err(invalid_end());
4863        }
4864        (zip64_total, central_offset, central_size)
4865    };
4866    if count == 0 || count > max_entries as u64 {
4867        return Err(LinkError::InvalidPack {
4868            message: format!("invalid file count {count}"),
4869        });
4870    }
4871    preflight_zip_central_directory(bytes, central_offset, central_size, count)?;
4872    Ok(())
4873}
4874
4875fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
4876    preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)?;
4877    let mut archive =
4878        zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
4879            message: format!("ZIP parse failed: {err}"),
4880        })?;
4881    if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
4882        return Err(LinkError::InvalidPack {
4883            message: format!("invalid file count {}", archive.len()),
4884        });
4885    }
4886    let mut total = 0u64;
4887    let mut seen = std::collections::HashSet::new();
4888    let mut entries = Vec::with_capacity(archive.len());
4889    for index in 0..archive.len() {
4890        let mut file = archive
4891            .by_index(index)
4892            .map_err(|err| LinkError::InvalidPack {
4893                message: format!("ZIP entry failed: {err}"),
4894            })?;
4895        if file.is_dir() {
4896            continue;
4897        }
4898        let path = file.name().to_string();
4899        if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
4900            return Err(LinkError::UnsafePath { path });
4901        }
4902        if file
4903            .unix_mode()
4904            .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
4905        {
4906            return Err(LinkError::InvalidPack {
4907                message: format!("non-file entry `{path}`"),
4908            });
4909        }
4910        if !seen.insert(path.clone()) {
4911            return Err(LinkError::InvalidPack {
4912                message: format!("duplicate path `{path}`"),
4913            });
4914        }
4915        let remaining = MAX_STORE_BYTES.saturating_sub(total);
4916        if file.size() > remaining {
4917            return Err(LinkError::InvalidPack {
4918                message: "expanded content exceeds the 512 MB limit".to_string(),
4919            });
4920        }
4921        let mut content = Vec::new();
4922        (&mut file)
4923            .take(remaining + 1)
4924            .read_to_end(&mut content)
4925            .map_err(|err| LinkError::InvalidPack {
4926                message: format!("could not decompress `{path}`: {err}"),
4927            })?;
4928        if content.len() as u64 > remaining {
4929            return Err(LinkError::InvalidPack {
4930                message: "expanded content exceeds the 512 MB limit".to_string(),
4931            });
4932        }
4933        if content.len() as u64 != file.size() {
4934            return Err(LinkError::InvalidPack {
4935                message: format!("length mismatch for `{path}`"),
4936            });
4937        }
4938        total += content.len() as u64;
4939        entries.push((path, content));
4940    }
4941    if entries.is_empty() {
4942        return Err(LinkError::InvalidPack {
4943            message: "pack contains no files".to_string(),
4944        });
4945    }
4946    Ok(entries)
4947}
4948
4949fn verify_snapshot_manifest(entries: &[(String, Vec<u8>)], signed: &[FeedFile]) -> LinkResult<()> {
4950    let mut expected = std::collections::BTreeMap::new();
4951    for file in signed {
4952        if !safe_store_rel_path(&file.path) {
4953            return Err(LinkError::UnsafePath {
4954                path: file.path.clone(),
4955            });
4956        }
4957        if !is_sha256(&file.sha256)
4958            || expected
4959                .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
4960                .is_some()
4961        {
4962            return Err(invalid_feed(
4963                "signed snapshot manifest contains an invalid or duplicate file",
4964            ));
4965        }
4966    }
4967    if expected.len() != entries.len() {
4968        return Err(invalid_feed(
4969            "downloaded pack file set differs from the signed snapshot manifest",
4970        ));
4971    }
4972    for (path, bytes) in entries {
4973        let Some((sha256, declared_bytes)) = expected.get(path.as_str()) else {
4974            return Err(invalid_feed(format!(
4975                "downloaded pack contains unsigned path `{path}`"
4976            )));
4977        };
4978        if *declared_bytes != bytes.len() as u64
4979            || *sha256 != format!("{:x}", Sha256::digest(bytes))
4980        {
4981            return Err(invalid_feed(format!(
4982                "downloaded file `{path}` differs from its signed manifest"
4983            )));
4984        }
4985    }
4986    Ok(())
4987}
4988
4989/// Collect the files a push sends: the store's owned text — `DB.md`,
4990/// `assets.jsonl` when present, and every content `.md` under `records/` and
4991/// `sources/` (the store walk, which already excludes hidden dirs like
4992/// `.dbmd/`, the `log/` archive, and derived `index.*` catalogs; the hub
4993/// derives its own index, and local history stays local). Returns
4994/// `(store-relative path, content)` pairs, path-sorted.
4995pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
4996    require_hardened_filesystem("sync push")?;
4997    preflight_push_ownership(store)?;
4998    let mut out: Vec<(String, String)> = Vec::new();
4999    let mut total = 0u64;
5000
5001    let mut read_text = |rel: &str| -> LinkResult<String> {
5002        let bytes = store.read_bounded(Path::new(rel), MAX_STORE_BYTES.saturating_sub(total))?;
5003        total = total
5004            .checked_add(bytes.len() as u64)
5005            .ok_or_else(|| LinkError::PushTooLarge {
5006                detail: "uncompressed byte count overflow".to_string(),
5007            })?;
5008        if total > MAX_STORE_BYTES {
5009            return Err(LinkError::PushTooLarge {
5010                detail: format!("{total} uncompressed bytes"),
5011            });
5012        }
5013        String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
5014            path: rel.to_string(),
5015        })
5016    };
5017
5018    out.push(("DB.md".to_string(), read_text("DB.md")?));
5019    if store
5020        .regular_file_exists(Path::new("assets.jsonl"))
5021        .unwrap_or(false)
5022    {
5023        out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
5024    }
5025
5026    for rel in store.walk()? {
5027        let rel_str = rel.to_string_lossy().replace('\\', "/");
5028        if !safe_store_rel_path(&rel_str) {
5029            // A locally-legal name outside the hub's portable charset cannot
5030            // travel this wire; refusing beats silently dropping it.
5031            return Err(LinkError::UnsafePath { path: rel_str });
5032        }
5033        let content = read_text(&rel_str)?;
5034        out.push((rel_str, content));
5035    }
5036
5037    out.sort_by(|a, b| a.0.cmp(&b.0));
5038    Ok(out)
5039}
5040
5041/// Refuse to build a destructive whole-store snapshot from an ambiguous local
5042/// tree. Ordinary read-only walks safely prune foreign paths, but a push that
5043/// silently omitted them could delete the hosted copies of those paths.
5044fn preflight_push_ownership(store: &Store) -> LinkResult<()> {
5045    if let Some(nested) = store.nested_store_roots()?.into_iter().next() {
5046        return Err(LinkError::from(std::io::Error::new(
5047            std::io::ErrorKind::PermissionDenied,
5048            format!("cannot push: nested db.md store at {}", nested.display()),
5049        )));
5050    }
5051
5052    if let Some(symlink) = store.unowned_symlinks()?.into_iter().next() {
5053        return Err(LinkError::from(std::io::Error::new(
5054            std::io::ErrorKind::PermissionDenied,
5055            format!(
5056                "cannot push: {} is a symlink outside the store ownership model",
5057                symlink.display()
5058            ),
5059        )));
5060    }
5061    Ok(())
5062}
5063
5064/// Push `files` to `brain` as a whole-store snapshot — the hub's push
5065/// semantics: the hosted copy becomes exactly this set (pull first if the
5066/// hosted side may have records the local copy lacks). Client-side caps
5067/// mirror the hub's JSON-path limits so an oversized push fails before the
5068/// upload.
5069pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
5070    require_safe_ref(brain)?;
5071    let remote = verified_remote_head(cfg, brain, false)?;
5072    if files.len() > MAX_PUSH_FILES {
5073        return Err(LinkError::PushTooLarge {
5074            detail: format!("{} files", files.len()),
5075        });
5076    }
5077    let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
5078    if raw_total > MAX_STORE_BYTES {
5079        return Err(LinkError::PushTooLarge {
5080            detail: format!("{raw_total} uncompressed bytes"),
5081        });
5082    }
5083
5084    // Self-custody (a brain key is configured): the JSON fast path is
5085    // hub-signed by construction, so every push goes through the pack flow
5086    // with a locally signed entry — the hub verifies and can never sign.
5087    if cfg.brain_key.is_none() {
5088        let body = json!({
5089            "files": files
5090                .iter()
5091                .map(|(p, c)| json!({ "path": p, "content": c }))
5092                .collect::<Vec<_>>(),
5093        });
5094        if body.to_string().len() <= MAX_PUSH_BYTES {
5095            let path = format!("/api/hub/brains/{brain}/push");
5096            let pushed = ensure_ok(
5097                request(cfg, "POST", &path, Some(&body), Auth::Required)?,
5098                "sync push",
5099            )?;
5100            return Ok(pushed);
5101        }
5102    }
5103
5104    let pack = build_store_pack(files)?;
5105    if pack.len() as u64 > MAX_PACK_BYTES {
5106        return Err(LinkError::PushTooLarge {
5107            detail: format!("{} pack bytes", pack.len()),
5108        });
5109    }
5110    let sha256 = format!("{:x}", Sha256::digest(&pack));
5111    let mut meta = json!({ "sha256": sha256, "bytes": pack.len() });
5112    if let Some(key) = &cfg.brain_key {
5113        if !remote.head.verified {
5114            return Err(invalid_feed(
5115                "self-custody push requires a fully verified, unscoped feed head",
5116            ));
5117        }
5118        let identity = remote
5119            .identity
5120            .as_ref()
5121            .ok_or_else(|| invalid_feed("verified brain has no current identity"))?;
5122        let current_multikey = format!("ed25519:{}", identity.fingerprint);
5123        if key.multikey != current_multikey || key.public_key_spki != identity.public_key_spki {
5124            return Err(invalid_feed(
5125                "configured brain key is not the verified current brain identity",
5126            ));
5127        }
5128        // The verified state pins seq + prev; a concurrent writer surfaces as
5129        // the hub's 422 on commit (re-run to retry against the new head).
5130        let next_seq = remote
5131            .head
5132            .seq
5133            .checked_add(1)
5134            .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
5135        let mut manifest: Vec<WireFeedFile> = files
5136            .iter()
5137            .map(|(path, content)| WireFeedFile {
5138                path: path.clone(),
5139                sha256: format!("{:x}", Sha256::digest(content.as_bytes())),
5140                bytes: content.len() as u64,
5141            })
5142            .collect();
5143        manifest.sort_by(|a, b| a.path.cmp(&b.path));
5144        let ts = crate::now()
5145            .with_timezone(&chrono::Utc)
5146            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
5147            .to_string();
5148        let entry = self_custody_entry(
5149            key,
5150            next_seq,
5151            ts,
5152            &sha256,
5153            &manifest,
5154            remote.head.feed_hash.as_deref(),
5155        )?;
5156        meta["entry"] = Value::String(entry);
5157    }
5158    let presigned = ensure_ok(
5159        request(
5160            cfg,
5161            "POST",
5162            &format!("/api/hub/brains/{brain}/packs/presign"),
5163            Some(&meta),
5164            Auth::Required,
5165        )?,
5166        "prepare pack upload",
5167    )?;
5168    let url = presigned
5169        .get("url")
5170        .and_then(Value::as_str)
5171        .ok_or_else(|| LinkError::InvalidPack {
5172            message: "the hub returned no upload URL".to_string(),
5173        })?;
5174    put_presigned(
5175        cfg,
5176        url,
5177        presigned.get("headers").unwrap_or(&Value::Null),
5178        &pack,
5179    )?;
5180    let committed = ensure_ok(
5181        request(
5182            cfg,
5183            "POST",
5184            &format!("/api/hub/brains/{brain}/packs/commit"),
5185            Some(&meta),
5186            Auth::Required,
5187        )?,
5188        "commit pack",
5189    )?;
5190    Ok(committed)
5191}
5192
5193fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
5194    const LOCAL_HEADER: u32 = 0x0403_4b50;
5195    const CENTRAL_HEADER: u32 = 0x0201_4b50;
5196    const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
5197    const VERSION_20: u16 = 20;
5198    const MADE_BY_UNIX_20: u16 = (3 << 8) | VERSION_20;
5199    const UTF8_FLAG: u16 = 1 << 11;
5200    const STORED: u16 = 0;
5201    const DOS_TIME_MIDNIGHT: u16 = 0;
5202    const DOS_DATE_1980_01_01: u16 = (1 << 5) | 1;
5203    const UNIX_REGULAR_0600: u32 = 0o100600 << 16;
5204
5205    struct CentralEntry<'a> {
5206        name: &'a [u8],
5207        crc32: u32,
5208        size: u32,
5209        local_offset: u32,
5210    }
5211
5212    fn push_u16(out: &mut Vec<u8>, value: u16) {
5213        out.extend_from_slice(&value.to_le_bytes());
5214    }
5215
5216    fn push_u32(out: &mut Vec<u8>, value: u32) {
5217        out.extend_from_slice(&value.to_le_bytes());
5218    }
5219
5220    if files.is_empty() {
5221        return Err(LinkError::InvalidPack {
5222            message: "cannot create an empty snapshot pack".to_string(),
5223        });
5224    }
5225    if files.len() > u16::MAX as usize {
5226        return Err(LinkError::PushTooLarge {
5227            detail: format!(
5228                "{} files (canonical ZIP32 packs cap at {})",
5229                files.len(),
5230                u16::MAX
5231            ),
5232        });
5233    }
5234
5235    let mut sorted: Vec<_> = files.iter().collect();
5236    sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
5237    let mut previous: Option<&str> = None;
5238    for (path, content) in &sorted {
5239        if !safe_store_rel_path(path) {
5240            return Err(LinkError::UnsafePath {
5241                path: (*path).clone(),
5242            });
5243        }
5244        if previous == Some(path.as_str()) {
5245            return Err(LinkError::InvalidPack {
5246                message: format!("duplicate path `{path}`"),
5247            });
5248        }
5249        previous = Some(path.as_str());
5250        u32::try_from(content.len()).map_err(|_| LinkError::PushTooLarge {
5251            detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
5252        })?;
5253    }
5254
5255    let mut out = Vec::new();
5256    let mut central = Vec::with_capacity(sorted.len());
5257    for (path, content) in sorted {
5258        let name = path.as_bytes();
5259        let name_len = u16::try_from(name.len()).map_err(|_| LinkError::InvalidPack {
5260            message: format!("ZIP entry name is too long: `{path}`"),
5261        })?;
5262        let bytes = content.as_bytes();
5263        let size = u32::try_from(bytes.len()).map_err(|_| LinkError::PushTooLarge {
5264            detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
5265        })?;
5266        let local_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
5267            detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
5268        })?;
5269        let crc32 = crc32fast::hash(bytes);
5270
5271        // Canonical local header: sizes and CRC are known up front. Bit 3 is
5272        // deliberately clear, so there is no trailing data descriptor.
5273        push_u32(&mut out, LOCAL_HEADER);
5274        push_u16(&mut out, VERSION_20);
5275        push_u16(&mut out, UTF8_FLAG);
5276        push_u16(&mut out, STORED);
5277        push_u16(&mut out, DOS_TIME_MIDNIGHT);
5278        push_u16(&mut out, DOS_DATE_1980_01_01);
5279        push_u32(&mut out, crc32);
5280        push_u32(&mut out, size);
5281        push_u32(&mut out, size);
5282        push_u16(&mut out, name_len);
5283        push_u16(&mut out, 0); // no local extra data
5284        out.extend_from_slice(name);
5285        out.extend_from_slice(bytes);
5286
5287        central.push(CentralEntry {
5288            name,
5289            crc32,
5290            size,
5291            local_offset,
5292        });
5293    }
5294
5295    let central_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
5296        detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
5297    })?;
5298    for entry in &central {
5299        push_u32(&mut out, CENTRAL_HEADER);
5300        push_u16(&mut out, MADE_BY_UNIX_20);
5301        push_u16(&mut out, VERSION_20);
5302        push_u16(&mut out, UTF8_FLAG);
5303        push_u16(&mut out, STORED);
5304        push_u16(&mut out, DOS_TIME_MIDNIGHT);
5305        push_u16(&mut out, DOS_DATE_1980_01_01);
5306        push_u32(&mut out, entry.crc32);
5307        push_u32(&mut out, entry.size);
5308        push_u32(&mut out, entry.size);
5309        push_u16(&mut out, entry.name.len() as u16);
5310        push_u16(&mut out, 0); // no central extra data
5311        push_u16(&mut out, 0); // no file comment
5312        push_u16(&mut out, 0); // disk number
5313        push_u16(&mut out, 0); // internal attributes
5314        push_u32(&mut out, UNIX_REGULAR_0600);
5315        push_u32(&mut out, entry.local_offset);
5316        out.extend_from_slice(entry.name);
5317    }
5318    let central_size = u32::try_from(out.len())
5319        .ok()
5320        .and_then(|end| end.checked_sub(central_offset))
5321        .ok_or_else(|| LinkError::PushTooLarge {
5322            detail: "canonical ZIP32 central directory exceeds its limit".to_string(),
5323        })?;
5324    let entry_count = central.len() as u16;
5325
5326    push_u32(&mut out, END_OF_CENTRAL_DIRECTORY);
5327    push_u16(&mut out, 0); // this disk
5328    push_u16(&mut out, 0); // central directory disk
5329    push_u16(&mut out, entry_count);
5330    push_u16(&mut out, entry_count);
5331    push_u32(&mut out, central_size);
5332    push_u32(&mut out, central_offset);
5333    push_u16(&mut out, 0); // no archive comment
5334
5335    if out.len() > u32::MAX as usize {
5336        return Err(LinkError::PushTooLarge {
5337            detail: "canonical ZIP32 pack exceeds 4 GiB".to_string(),
5338        });
5339    }
5340    Ok(out)
5341}
5342
5343// ─────────────────────────────────────────────────────────────────────────────
5344// grant — issue / list / revoke capabilities (owner-side)
5345// ─────────────────────────────────────────────────────────────────────────────
5346
5347/// The two capabilities a v0 hub enforces.
5348#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5349pub enum Capability {
5350    /// Read the granted slice.
5351    Read,
5352    /// Read and push (whole-store; a path-scoped grant is read-only).
5353    Write,
5354}
5355
5356impl Capability {
5357    /// The wire form.
5358    pub fn as_str(self) -> &'static str {
5359        match self {
5360            Capability::Read => "read",
5361            Capability::Write => "write",
5362        }
5363    }
5364}
5365
5366/// Issue (or refresh) a grant on `brain` to `grantee` — a hub principal named
5367/// by email in v0 (the protocol's near-term simplification; key-named
5368/// grantees arrive with the signing layer). `scope` is a store-path prefix
5369/// (the hub's enforcement unit); `until` an ISO 8601 expiry, absent = until
5370/// revoked.
5371pub fn grant_issue(
5372    cfg: &HubConfig,
5373    brain: &str,
5374    grantee: &str,
5375    can: Capability,
5376    scope: Option<&str>,
5377    until: Option<&str>,
5378) -> LinkResult<Value> {
5379    require_safe_ref(brain)?;
5380    let _ = verified_remote_head(cfg, brain, false)?;
5381    // Grantee shape decides the axis: a base64url Ed25519 SPKI is a bare
5382    // multikey holder (link.md §6 cross-party keys — no hub account; the
5383    // printed `publicKeySpki` from `dbmd key generate`); anything else is a
5384    // hub principal named by email.
5385    let is_key_grantee = URL_SAFE_NO_PAD
5386        .decode(grantee)
5387        .map(|der| der.len() == 44 && der.starts_with(&ED25519_SPKI_PREFIX))
5388        .unwrap_or(false);
5389    let mut body = if is_key_grantee {
5390        json!({ "keySpki": grantee, "capability": can.as_str() })
5391    } else {
5392        json!({ "email": grantee, "capability": can.as_str() })
5393    };
5394    if let Some(s) = scope {
5395        body["scopePrefix"] = json!(s);
5396    }
5397    if let Some(u) = until {
5398        body["expiresAt"] = json!(u);
5399    }
5400    let path = format!("/api/hub/brains/{brain}/grants");
5401    ensure_ok(
5402        request(cfg, "POST", &path, Some(&body), Auth::Required)?,
5403        "grant issue",
5404    )
5405}
5406
5407/// List the active grants (and pending invites) on `brain`. Owner-side.
5408pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
5409    require_safe_ref(brain)?;
5410    let _ = verified_remote_head(cfg, brain, false)?;
5411    let path = format!("/api/hub/brains/{brain}/grants");
5412    ensure_ok(
5413        request(cfg, "GET", &path, None, Auth::Required)?,
5414        "grant list",
5415    )
5416}
5417
5418/// Revoke a grant (or cancel a pending invite) by id. Owner-side; revocation
5419/// is soft on the hub (the audit trail survives).
5420pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
5421    require_safe_ref(brain)?;
5422    require_safe_grant_id(grant_id)?;
5423    let _ = verified_remote_head(cfg, brain, false)?;
5424    let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
5425    ensure_ok(
5426        request(cfg, "DELETE", &path, None, Auth::Required)?,
5427        "grant revoke",
5428    )
5429}
5430
5431// ─────────────────────────────────────────────────────────────────────────────
5432// propose — write without trust: evidence into the owner's inbox
5433// ─────────────────────────────────────────────────────────────────────────────
5434
5435/// Submit `body` to the published site `handle`, addressed to its app page
5436/// `app` (a page that declares the `write-inbox` capability). Deliberately
5437/// unauthenticated — this is the cross-party door; the submission lands as
5438/// *evidence* in the owner's `sources/inbox/`, never as truth, and the
5439/// owner's curator accepts or rejects it. Returns the hub's `{id, path}`
5440/// receipt.
5441pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
5442    require_valid_handle(handle)?;
5443    if body.len() as u64 > MAX_PROPOSE_BYTES {
5444        return Err(LinkError::ProposeTooLarge {
5445            bytes: body.len() as u64,
5446        });
5447    }
5448    let payload = json!({ "app": app, "body": body });
5449    // A ULID-shaped target is a bare brain address (link.md §7.4's
5450    // generalization): the brain inbox door, open on public brains, where a
5451    // configured credential earns a bigger actor-class budget. Anything else
5452    // is a published-site handle: that door is unauthenticated by design.
5453    let (path, auth) = if crate::ulid::is_ulid(handle) {
5454        (format!("/api/hub/brains/{handle}/inbox"), Auth::Optional)
5455    } else {
5456        (format!("/api/hub/sites/{handle}/inbox"), Auth::None)
5457    };
5458    ensure_ok(
5459        request(cfg, "POST", &path, Some(&payload), auth)?,
5460        "propose",
5461    )
5462}
5463
5464// ─────────────────────────────────────────────────────────────────────────────
5465// subscribe — follow feed-head movement
5466// ─────────────────────────────────────────────────────────────────────────────
5467
5468/// One observation of a brain's feed head.
5469#[derive(Debug, serde::Serialize)]
5470pub struct Head {
5471    /// The brain id.
5472    pub brain: String,
5473    /// The hub's durable feed cursor — advances on every accepted write.
5474    pub seq: u64,
5475    /// The hub's `updatedAt` for the brain, when present.
5476    #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
5477    pub updated_at: Option<String>,
5478    /// SHA-256 of the exact signed head entry.
5479    #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
5480    pub feed_hash: Option<String>,
5481    /// Whether the head entry's content hash, identity, and Ed25519 signature
5482    /// were verified locally. Path-scoped grants get head movement only.
5483    pub verified: bool,
5484}
5485
5486struct BoundedVecVisitor<T, const MAX: usize> {
5487    label: &'static str,
5488    marker: std::marker::PhantomData<T>,
5489}
5490
5491impl<'de, T, const MAX: usize> serde::de::Visitor<'de> for BoundedVecVisitor<T, MAX>
5492where
5493    T: Deserialize<'de>,
5494{
5495    type Value = Vec<T>;
5496
5497    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5498        write!(formatter, "at most {MAX} {}", self.label)
5499    }
5500
5501    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
5502    where
5503        A: serde::de::SeqAccess<'de>,
5504    {
5505        if sequence.size_hint().is_some_and(|size| size > MAX) {
5506            return Err(serde::de::Error::custom(format!(
5507                "{} exceeds the {MAX}-item limit",
5508                self.label
5509            )));
5510        }
5511        let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX));
5512        while let Some(value) = sequence.next_element()? {
5513            if values.len() == MAX {
5514                return Err(serde::de::Error::custom(format!(
5515                    "{} exceeds the {MAX}-item limit",
5516                    self.label
5517                )));
5518            }
5519            values.push(value);
5520        }
5521        Ok(values)
5522    }
5523}
5524
5525fn deserialize_bounded_vec<'de, D, T, const MAX: usize>(
5526    deserializer: D,
5527    label: &'static str,
5528) -> Result<Vec<T>, D::Error>
5529where
5530    D: serde::Deserializer<'de>,
5531    T: Deserialize<'de>,
5532{
5533    deserializer.deserialize_seq(BoundedVecVisitor::<T, MAX> {
5534        label,
5535        marker: std::marker::PhantomData,
5536    })
5537}
5538
5539fn deserialize_feed_files<'de, D>(deserializer: D) -> Result<Vec<FeedFile>, D::Error>
5540where
5541    D: serde::Deserializer<'de>,
5542{
5543    deserialize_bounded_vec::<D, FeedFile, MAX_PUSH_FILES>(deserializer, "feed files")
5544}
5545
5546fn deserialize_removed_paths<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
5547where
5548    D: serde::Deserializer<'de>,
5549{
5550    deserialize_bounded_vec::<D, String, MAX_PUSH_FILES>(deserializer, "removed paths")
5551}
5552
5553fn deserialize_previous_identities<'de, D>(
5554    deserializer: D,
5555) -> Result<Vec<PreviousIdentity>, D::Error>
5556where
5557    D: serde::Deserializer<'de>,
5558{
5559    deserialize_bounded_vec::<D, PreviousIdentity, MAX_IDENTITY_ROTATIONS>(
5560        deserializer,
5561        "previous identities",
5562    )
5563}
5564
5565fn deserialize_rotations<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
5566where
5567    D: serde::Deserializer<'de>,
5568{
5569    deserialize_bounded_vec::<D, String, MAX_IDENTITY_ROTATIONS>(
5570        deserializer,
5571        "rotation statements",
5572    )
5573}
5574
5575fn deserialize_feed_items<'de, D>(deserializer: D) -> Result<Vec<FeedItem>, D::Error>
5576where
5577    D: serde::Deserializer<'de>,
5578{
5579    deserialize_bounded_vec::<D, FeedItem, FEED_PAGE_LIMIT>(deserializer, "feed entries")
5580}
5581
5582#[derive(Debug, Clone, Deserialize, Serialize)]
5583struct FeedFile {
5584    path: String,
5585    sha256: String,
5586    bytes: u64,
5587}
5588
5589#[derive(Debug, Clone, Deserialize, Serialize)]
5590struct FeedEntry {
5591    v: u8,
5592    seq: u64,
5593    ts: String,
5594    brain: String,
5595    public_key: String,
5596    kind: String,
5597    op: String,
5598    pack_sha256: String,
5599    #[serde(deserialize_with = "deserialize_feed_files")]
5600    files: Vec<FeedFile>,
5601    #[serde(deserialize_with = "deserialize_removed_paths")]
5602    removed: Vec<String>,
5603    prev_entry_hash: Option<String>,
5604    sig: String,
5605}
5606
5607#[derive(Serialize)]
5608struct UnsignedFeedEntry<'a> {
5609    v: u8,
5610    seq: u64,
5611    ts: &'a str,
5612    brain: &'a str,
5613    public_key: &'a str,
5614    kind: &'a str,
5615    op: &'a str,
5616    pack_sha256: &'a str,
5617    files: &'a [FeedFile],
5618    removed: &'a [String],
5619    prev_entry_hash: &'a Option<String>,
5620}
5621
5622#[derive(Debug, Clone, Deserialize, Serialize)]
5623struct FeedItem {
5624    hash: String,
5625    entry: FeedEntry,
5626}
5627
5628#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
5629struct FeedIdentity {
5630    fingerprint: String,
5631    #[serde(rename = "publicKeySpki")]
5632    public_key_spki: String,
5633    /// Rotation history (link.md §9.1): identities this brain previously
5634    /// signed as. Entries verify against current OR previous — rotation
5635    /// never invalidates history.
5636    #[serde(default, deserialize_with = "deserialize_previous_identities")]
5637    previous: Vec<PreviousIdentity>,
5638    /// Exact normative rotation statements, oldest first. A list of previous
5639    /// public keys without these old-key signatures is not a trust chain.
5640    #[serde(default, deserialize_with = "deserialize_rotations")]
5641    rotations: Vec<String>,
5642}
5643
5644#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
5645struct PreviousIdentity {
5646    fingerprint: String,
5647    #[serde(rename = "publicKeySpki")]
5648    public_key_spki: String,
5649}
5650
5651#[derive(Debug, Deserialize)]
5652struct FeedResponse {
5653    #[serde(rename = "headSeq")]
5654    head_seq: u64,
5655    #[serde(rename = "feedHash")]
5656    feed_hash: Option<String>,
5657    identity: Option<FeedIdentity>,
5658    #[serde(deserialize_with = "deserialize_feed_items")]
5659    entries: Vec<FeedItem>,
5660    #[serde(rename = "scopeLimited")]
5661    scope_limited: bool,
5662}
5663
5664#[derive(Debug, Deserialize, Serialize)]
5665#[serde(deny_unknown_fields)]
5666struct RotationStatement {
5667    v: u8,
5668    op: String,
5669    brain: String,
5670    public_key: String,
5671    new_brain: String,
5672    new_public_key: String,
5673    prior_head_seq: u64,
5674    prior_feed_hash: Option<String>,
5675    ts: String,
5676    sig: String,
5677}
5678
5679#[derive(Debug, Clone, Deserialize, Serialize)]
5680struct TrustState {
5681    v: u8,
5682    origin: String,
5683    /// The exact caller-visible ref whose resolution this checkpoint pins.
5684    /// Slugs/handles are mutable names at the hub; once observed, they may not
5685    /// silently resolve to a different canonical brain.
5686    #[serde(default)]
5687    requested: String,
5688    /// Canonical hub brain id returned for `requested`.
5689    brain: String,
5690    /// Federation home origin when this ref was learned from a registry.
5691    /// Once observed, the registry cannot silently relocate the same handle.
5692    #[serde(default, skip_serializing_if = "Option::is_none")]
5693    home: Option<String>,
5694    anchor: String,
5695    current: String,
5696    #[serde(rename = "headSeq")]
5697    head_seq: u64,
5698    #[serde(rename = "feedHash")]
5699    feed_hash: Option<String>,
5700    /// Exact accepted old-key-signed rotation statements, oldest first. v2
5701    /// checkpoints require this vector to be an immutable prefix of every
5702    /// subsequently served identity chain.
5703    #[serde(default)]
5704    rotations: Vec<String>,
5705    /// Hub pointer-signing identity pinned on first v2 observation. This is
5706    /// separate from the brain key, which signs commits.
5707    #[serde(default, skip_serializing_if = "Option::is_none")]
5708    hub_signer: Option<String>,
5709    /// Explicit transport profile. Older accepted non-empty v2 checkpoints
5710    /// are recognized by their pinned hub signer during one-way migration.
5711    #[serde(default, skip_serializing_if = "Option::is_none")]
5712    protocol_profile: Option<String>,
5713}
5714
5715fn accepted_as_v2(state: &TrustState) -> bool {
5716    state.protocol_profile.as_deref() == Some("link-v2") || state.hub_signer.is_some()
5717}
5718
5719fn has_accepted_v2_ref(cfg: &HubConfig, requested: &str) -> LinkResult<bool> {
5720    let directory = open_trust_dir(cfg)?;
5721    if load_trust_in(cfg, &directory, requested)?.is_some_and(|state| accepted_as_v2(&state)) {
5722        return Ok(true);
5723    }
5724    let Some(alias) = load_alias_in(cfg, &directory, requested)? else {
5725        return Ok(false);
5726    };
5727    Ok(load_trust_in(cfg, &directory, &alias.brain)?.is_some_and(|state| accepted_as_v2(&state)))
5728}
5729
5730#[derive(Debug, Clone, Deserialize, Serialize)]
5731struct AliasBinding {
5732    v: u8,
5733    origin: String,
5734    requested: String,
5735    brain: String,
5736    #[serde(default, skip_serializing_if = "Option::is_none")]
5737    home: Option<String>,
5738}
5739
5740struct VerifiedRemote {
5741    head: Head,
5742    identity: Option<FeedIdentity>,
5743    head_entry: Option<FeedItem>,
5744    /// Present when the caller requested and verified the complete chain.
5745    entries: Vec<FeedItem>,
5746    anchor: Option<String>,
5747}
5748
5749fn invalid_feed(message: impl Into<String>) -> LinkError {
5750    LinkError::InvalidFeed {
5751        message: message.into(),
5752    }
5753}
5754
5755fn is_sha256(value: &str) -> bool {
5756    value.len() == 64
5757        && value
5758            .bytes()
5759            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
5760}
5761
5762fn identity_fingerprint(public_key_spki: &str) -> LinkResult<String> {
5763    let der = URL_SAFE_NO_PAD
5764        .decode(public_key_spki)
5765        .map_err(|_| invalid_feed("identity public key is not base64url"))?;
5766    if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
5767        return Err(invalid_feed(
5768            "identity public key is not a valid Ed25519 SPKI",
5769        ));
5770    }
5771    Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(&der)))
5772}
5773
5774/// Verify the hub's current identity and every old-key-signed rotation that
5775/// leads from the original TOFU anchor to it. `previous` alone is metadata,
5776/// never authority.
5777fn verify_identity_chain(
5778    identity: &FeedIdentity,
5779    pinned: Option<&TrustState>,
5780) -> LinkResult<String> {
5781    if identity.previous.len() > MAX_IDENTITY_ROTATIONS
5782        || identity.rotations.len() > MAX_IDENTITY_ROTATIONS
5783    {
5784        return Err(invalid_feed(
5785            "identity rotation history exceeds the client cap",
5786        ));
5787    }
5788    if identity.fingerprint != identity_fingerprint(&identity.public_key_spki)? {
5789        return Err(invalid_feed(
5790            "current identity fingerprint does not match its public key",
5791        ));
5792    }
5793    for previous in &identity.previous {
5794        if previous.fingerprint != identity_fingerprint(&previous.public_key_spki)? {
5795            return Err(invalid_feed(
5796                "previous identity fingerprint does not match its public key",
5797            ));
5798        }
5799    }
5800    if identity.rotations.len() != identity.previous.len() {
5801        return Err(invalid_feed(
5802            "identity history is missing an old-key-signed rotation statement",
5803        ));
5804    }
5805
5806    // The HTTP identity lists prior identities newest first. Rotation
5807    // statements are chronological, so verification walks the reversed list
5808    // and ends at the current identity.
5809    let mut chain: Vec<(&str, &str)> = identity
5810        .previous
5811        .iter()
5812        .rev()
5813        .map(|p| (p.fingerprint.as_str(), p.public_key_spki.as_str()))
5814        .collect();
5815    chain.push((&identity.fingerprint, &identity.public_key_spki));
5816
5817    for (index, raw) in identity.rotations.iter().enumerate() {
5818        let statement: RotationStatement = serde_json::from_str(raw)
5819            .map_err(|_| invalid_feed("rotation statement did not parse exactly"))?;
5820        let (old_fingerprint, old_spki) = chain[index];
5821        let (new_fingerprint, new_spki) = chain[index + 1];
5822        if statement.v != 1
5823            || statement.op != "rotate"
5824            || statement.brain != format!("ed25519:{old_fingerprint}")
5825            || statement.public_key != old_spki
5826            || statement.new_brain != format!("ed25519:{new_fingerprint}")
5827            || statement.new_public_key != new_spki
5828            || (statement.prior_head_seq == 0 && statement.prior_feed_hash.is_some())
5829            || (statement.prior_head_seq > 0
5830                && statement
5831                    .prior_feed_hash
5832                    .as_deref()
5833                    .is_none_or(|hash| !is_sha256(hash)))
5834        {
5835            return Err(invalid_feed(
5836                "rotation statement does not connect adjacent identities",
5837            ));
5838        }
5839        let unsigned = serde_json::to_string(&UnsignedRotation {
5840            v: statement.v,
5841            op: &statement.op,
5842            brain: &statement.brain,
5843            public_key: &statement.public_key,
5844            new_brain: &statement.new_brain,
5845            new_public_key: &statement.new_public_key,
5846            prior_head_seq: statement.prior_head_seq,
5847            prior_feed_hash: statement.prior_feed_hash.as_deref(),
5848            ts: statement.ts.clone(),
5849        })
5850        .map_err(|_| invalid_feed("could not canonicalize rotation statement"))?;
5851        let exact = format!(
5852            "{},\"sig\":\"{}\"}}",
5853            &unsigned[..unsigned.len() - 1],
5854            statement.sig
5855        );
5856        if exact != *raw {
5857            return Err(invalid_feed(
5858                "rotation statement is not in normative serialization",
5859            ));
5860        }
5861        let der = URL_SAFE_NO_PAD
5862            .decode(old_spki)
5863            .map_err(|_| invalid_feed("rotation public key is not base64url"))?;
5864        let signature = URL_SAFE_NO_PAD
5865            .decode(&statement.sig)
5866            .map_err(|_| invalid_feed("rotation signature is not base64url"))?;
5867        UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
5868            .verify(unsigned.as_bytes(), &signature)
5869            .map_err(|_| invalid_feed("rotation signature verification failed"))?;
5870        if index > 0 {
5871            let prior: RotationStatement = serde_json::from_str(&identity.rotations[index - 1])
5872                .map_err(|_| invalid_feed("prior rotation statement did not parse"))?;
5873            if statement.prior_head_seq < prior.prior_head_seq {
5874                return Err(invalid_feed("rotation feed boundaries move backward"));
5875            }
5876        }
5877    }
5878
5879    let anchor = format!("ed25519:{}", chain[0].0);
5880    let current = format!("ed25519:{}", identity.fingerprint);
5881    if let Some(pin) = pinned {
5882        if pin.anchor != anchor {
5883            return Err(invalid_feed(
5884                "served identity chain does not descend from the pinned anchor",
5885            ));
5886        }
5887        if !chain
5888            .iter()
5889            .any(|(fingerprint, _)| pin.current == format!("ed25519:{fingerprint}"))
5890        {
5891            return Err(invalid_feed(
5892                "served identity chain forked away from the last pinned identity",
5893            ));
5894        }
5895        if pin.current == current && pin.anchor != current && identity.rotations.is_empty() {
5896            return Err(invalid_feed("served identity discarded its rotation chain"));
5897        }
5898        if pin.v >= 2
5899            && (identity.rotations.len() < pin.rotations.len()
5900                || identity.rotations[..pin.rotations.len()] != pin.rotations)
5901        {
5902            return Err(invalid_feed(
5903                "served identity rewrote the locally accepted rotation history",
5904            ));
5905        }
5906    }
5907    Ok(anchor)
5908}
5909
5910fn verify_rotation_feed_boundaries(
5911    identity: &FeedIdentity,
5912    pinned: Option<&TrustState>,
5913    observed: &[FeedItem],
5914    advertised_seq: u64,
5915) -> LinkResult<()> {
5916    let mut chain: Vec<String> = identity
5917        .previous
5918        .iter()
5919        .rev()
5920        .map(|previous| format!("ed25519:{}", previous.fingerprint))
5921        .collect();
5922    chain.push(format!("ed25519:{}", identity.fingerprint));
5923    let pinned_index = pinned.and_then(|pin| chain.iter().position(|key| key == &pin.current));
5924
5925    for (index, raw) in identity.rotations.iter().enumerate() {
5926        let rotation: RotationStatement = serde_json::from_str(raw)
5927            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
5928        if rotation.prior_head_seq > advertised_seq {
5929            return Err(invalid_feed(
5930                "rotation claims a feed boundary beyond the advertised head",
5931            ));
5932        }
5933        if let (Some(pin), Some(pin_index)) = (pinned, pinned_index) {
5934            if index >= pin_index && rotation.prior_head_seq < pin.head_seq {
5935                return Err(invalid_feed(
5936                    "newly disclosed rotation predates the local feed checkpoint",
5937                ));
5938            }
5939        }
5940        let actual = if rotation.prior_head_seq == 0 {
5941            None
5942        } else if pinned.is_some_and(|pin| pin.head_seq == rotation.prior_head_seq) {
5943            pinned.and_then(|pin| pin.feed_hash.as_deref())
5944        } else {
5945            observed
5946                .iter()
5947                .find(|item| item.entry.seq == rotation.prior_head_seq)
5948                .map(|item| item.hash.as_str())
5949        };
5950        if let Some(actual) = actual {
5951            if rotation.prior_feed_hash.as_deref() != Some(actual) {
5952                return Err(invalid_feed(
5953                    "rotation statement does not commit the verified feed boundary",
5954                ));
5955            }
5956        } else if rotation.prior_head_seq == 0 {
5957            // The empty-feed boundary is represented by (0, null); there is
5958            // no entry hash to find in `observed`.
5959        } else if pinned.is_some_and(|pin| {
5960            pinned_index.is_some_and(|pin_index| index >= pin_index)
5961                || rotation.prior_head_seq >= pin.head_seq
5962        }) {
5963            return Err(invalid_feed(
5964                "rotation feed boundary was not present in the verified chain",
5965            ));
5966        }
5967    }
5968    Ok(())
5969}
5970
5971/// Once a client has checkpointed identity K, identities retired before K may
5972/// verify old history but can never regain authority over a later sequence.
5973/// This is also the safe migration rule for legacy v1 checkpoints that did not
5974/// persist the exact accepted rotation statements.
5975fn reject_retired_signer_after_checkpoint(
5976    identity: &FeedIdentity,
5977    pinned: Option<&TrustState>,
5978    item: &FeedItem,
5979) -> LinkResult<()> {
5980    let Some(pin) = pinned else {
5981        return Ok(());
5982    };
5983    if item.entry.seq <= pin.head_seq {
5984        return Ok(());
5985    }
5986    let mut chain: Vec<String> = identity
5987        .previous
5988        .iter()
5989        .rev()
5990        .map(|previous| format!("ed25519:{}", previous.fingerprint))
5991        .collect();
5992    chain.push(format!("ed25519:{}", identity.fingerprint));
5993    let pinned_index = chain
5994        .iter()
5995        .position(|key| key == &pin.current)
5996        .ok_or_else(|| invalid_feed("pinned identity is absent from the served chain"))?;
5997    let signer_index = chain
5998        .iter()
5999        .position(|key| key == &item.entry.brain)
6000        .ok_or_else(|| invalid_feed("feed signer is absent from the served identity chain"))?;
6001    if signer_index < pinned_index {
6002        return Err(invalid_feed(
6003            "a retired identity attempted to sign after the local checkpoint",
6004        ));
6005    }
6006    Ok(())
6007}
6008
6009fn trust_file_name(cfg: &HubConfig, brain: &str) -> LinkResult<String> {
6010    let origin = normalized_origin(&cfg.hub)?;
6011    let key = format!(
6012        "{:x}",
6013        Sha256::digest(format!("{origin}\0{brain}").as_bytes())
6014    );
6015    Ok(format!("{key}.json"))
6016}
6017
6018fn alias_file_name(cfg: &HubConfig, alias: &str) -> LinkResult<String> {
6019    let origin = normalized_origin(&cfg.hub)?;
6020    let key = format!(
6021        "{:x}",
6022        Sha256::digest(format!("{origin}\0alias\0{alias}").as_bytes())
6023    );
6024    Ok(format!("alias-{key}.json"))
6025}
6026
6027#[cfg(unix)]
6028struct TrustLock {
6029    _file: std::fs::File,
6030}
6031
6032#[cfg(unix)]
6033fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
6034    use std::os::fd::{AsRawFd as _, FromRawFd as _};
6035
6036    let lock_string = format!(".{state_name}.lock");
6037    let lock_name = c_name(lock_string.as_bytes(), &lock_string)?;
6038    let fd = unsafe {
6039        libc::openat(
6040            directory.as_raw_fd(),
6041            lock_name.as_ptr(),
6042            libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
6043            0o600,
6044        )
6045    };
6046    if fd < 0 {
6047        return Err(std::io::Error::last_os_error().into());
6048    }
6049    let file = unsafe { std::fs::File::from_raw_fd(fd) };
6050    if !file.metadata()?.is_file() {
6051        return Err(LinkError::UnsafePath { path: lock_string });
6052    }
6053    if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
6054        return Err(std::io::Error::last_os_error().into());
6055    }
6056    Ok(TrustLock { _file: file })
6057}
6058
6059#[cfg(unix)]
6060fn lock_trust_many(
6061    cfg: &HubConfig,
6062    directory: &std::fs::File,
6063    refs: &[&str],
6064) -> LinkResult<Vec<TrustLock>> {
6065    let mut names = refs
6066        .iter()
6067        .map(|reference| trust_file_name(cfg, reference))
6068        .collect::<LinkResult<Vec<_>>>()?;
6069    names.sort();
6070    names.dedup();
6071    names
6072        .iter()
6073        .map(|name| lock_trust_name(directory, name))
6074        .collect()
6075}
6076
6077#[cfg(not(unix))]
6078fn lock_trust_many(
6079    _cfg: &HubConfig,
6080    _directory: &TrustDirectory,
6081    _refs: &[&str],
6082) -> LinkResult<Vec<()>> {
6083    Err(LinkError::UnsupportedPlatform {
6084        operation: "verified link.md state",
6085    })
6086}
6087
6088#[cfg(unix)]
6089type TrustDirectory = std::fs::File;
6090
6091#[cfg(not(unix))]
6092struct TrustDirectory;
6093
6094#[cfg(unix)]
6095fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
6096    use std::os::fd::AsRawFd as _;
6097
6098    let directory = open_or_create_dir_nofollow(&cfg.state_dir.join("trust"))?;
6099    if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
6100        return Err(std::io::Error::last_os_error().into());
6101    }
6102    directory.sync_all()?;
6103    Ok(directory)
6104}
6105
6106#[cfg(not(unix))]
6107fn open_trust_dir(_cfg: &HubConfig) -> LinkResult<TrustDirectory> {
6108    Err(LinkError::UnsupportedPlatform {
6109        operation: "verified link.md state",
6110    })
6111}
6112
6113#[cfg(unix)]
6114fn load_trust_in(
6115    cfg: &HubConfig,
6116    directory: &TrustDirectory,
6117    requested: &str,
6118) -> LinkResult<Option<TrustState>> {
6119    use std::os::fd::{AsRawFd as _, FromRawFd as _};
6120
6121    let name_string = trust_file_name(cfg, requested)?;
6122    let name = c_name(name_string.as_bytes(), &name_string)?;
6123    let fd = unsafe {
6124        libc::openat(
6125            directory.as_raw_fd(),
6126            name.as_ptr(),
6127            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
6128        )
6129    };
6130    if fd < 0 {
6131        let error = std::io::Error::last_os_error();
6132        if error.kind() == std::io::ErrorKind::NotFound {
6133            return Ok(None);
6134        }
6135        return Err(LinkError::UnsafePath { path: name_string });
6136    }
6137    let file = unsafe { std::fs::File::from_raw_fd(fd) };
6138    if !file.metadata()?.is_file() {
6139        return Err(LinkError::UnsafePath { path: name_string });
6140    }
6141    let mut bytes = Vec::new();
6142    file.take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
6143    if bytes.len() > 1024 * 1024 {
6144        return Err(invalid_feed("local identity/feed checkpoint is oversized"));
6145    }
6146    let mut state: TrustState = serde_json::from_slice(&bytes)
6147        .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
6148    if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
6149        return Err(invalid_feed(
6150            "local identity/feed checkpoint does not match this hub and brain",
6151        ));
6152    }
6153    if state.v == 1 {
6154        // Legacy files were keyed by canonical brain id. They can migrate only
6155        // when the caller used that exact id; old slug lookups had no durable
6156        // alias binding and therefore cannot be guessed safely.
6157        if state.brain != requested {
6158            return Err(invalid_feed(
6159                "legacy checkpoint is not bound to the requested brain id",
6160            ));
6161        }
6162        state.requested = requested.to_string();
6163    } else if state.requested != requested {
6164        return Err(invalid_feed(
6165            "local identity/feed checkpoint is bound to a different requested ref",
6166        ));
6167    }
6168    Ok(Some(state))
6169}
6170
6171#[cfg(not(unix))]
6172fn load_trust_in(
6173    _cfg: &HubConfig,
6174    _directory: &TrustDirectory,
6175    _brain: &str,
6176) -> LinkResult<Option<TrustState>> {
6177    Err(LinkError::UnsupportedPlatform {
6178        operation: "verified link.md state",
6179    })
6180}
6181
6182#[cfg(all(test, unix))]
6183fn load_trust(cfg: &HubConfig, requested: &str) -> LinkResult<Option<TrustState>> {
6184    let directory = open_trust_dir(cfg)?;
6185    load_trust_in(cfg, &directory, requested)
6186}
6187
6188#[cfg(unix)]
6189fn save_trust_in(
6190    cfg: &HubConfig,
6191    directory: &TrustDirectory,
6192    state: &TrustState,
6193) -> LinkResult<()> {
6194    use std::os::fd::{AsRawFd as _, FromRawFd as _};
6195
6196    let name_string = trust_file_name(cfg, &state.requested)?;
6197    let name = c_name(name_string.as_bytes(), &name_string)?;
6198    let mut bytes = serde_json::to_vec(state)
6199        .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
6200    bytes.push(b'\n');
6201
6202    let nonce = std::time::SystemTime::now()
6203        .duration_since(std::time::UNIX_EPOCH)
6204        .unwrap_or_default()
6205        .as_nanos();
6206    let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
6207    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
6208    let fd = unsafe {
6209        libc::openat(
6210            directory.as_raw_fd(),
6211            temp.as_ptr(),
6212            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
6213            0o600,
6214        )
6215    };
6216    if fd < 0 {
6217        return Err(std::io::Error::last_os_error().into());
6218    }
6219    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
6220    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
6221        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6222        return Err(error.into());
6223    }
6224    drop(file);
6225    if unsafe {
6226        libc::renameat(
6227            directory.as_raw_fd(),
6228            temp.as_ptr(),
6229            directory.as_raw_fd(),
6230            name.as_ptr(),
6231        )
6232    } != 0
6233    {
6234        let error = std::io::Error::last_os_error();
6235        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6236        return Err(error.into());
6237    }
6238    directory.sync_all()?;
6239    Ok(())
6240}
6241
6242#[cfg(not(unix))]
6243fn save_trust_in(
6244    _cfg: &HubConfig,
6245    _directory: &TrustDirectory,
6246    _state: &TrustState,
6247) -> LinkResult<()> {
6248    Err(LinkError::UnsupportedPlatform {
6249        operation: "verified link.md state",
6250    })
6251}
6252
6253#[cfg(unix)]
6254fn load_alias_in(
6255    cfg: &HubConfig,
6256    directory: &TrustDirectory,
6257    requested: &str,
6258) -> LinkResult<Option<AliasBinding>> {
6259    use std::os::fd::{AsRawFd as _, FromRawFd as _};
6260
6261    let name_string = alias_file_name(cfg, requested)?;
6262    let name = c_name(name_string.as_bytes(), &name_string)?;
6263    let fd = unsafe {
6264        libc::openat(
6265            directory.as_raw_fd(),
6266            name.as_ptr(),
6267            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
6268        )
6269    };
6270    if fd < 0 {
6271        let error = std::io::Error::last_os_error();
6272        if error.kind() == std::io::ErrorKind::NotFound {
6273            return Ok(None);
6274        }
6275        return Err(LinkError::UnsafePath { path: name_string });
6276    }
6277    let file = unsafe { std::fs::File::from_raw_fd(fd) };
6278    if !file.metadata()?.is_file() {
6279        return Err(LinkError::UnsafePath { path: name_string });
6280    }
6281    let mut bytes = Vec::new();
6282    file.take(64 * 1024 + 1).read_to_end(&mut bytes)?;
6283    if bytes.len() > 64 * 1024 {
6284        return Err(invalid_feed("local alias binding is oversized"));
6285    }
6286    let alias: AliasBinding = serde_json::from_slice(&bytes)
6287        .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
6288    if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
6289    {
6290        return Err(invalid_feed(
6291            "local alias binding does not match this hub and requested ref",
6292        ));
6293    }
6294    Ok(Some(alias))
6295}
6296
6297#[cfg(not(unix))]
6298fn load_alias_in(
6299    _cfg: &HubConfig,
6300    _directory: &TrustDirectory,
6301    _requested: &str,
6302) -> LinkResult<Option<AliasBinding>> {
6303    Err(LinkError::UnsupportedPlatform {
6304        operation: "verified link.md state",
6305    })
6306}
6307
6308#[cfg(unix)]
6309fn save_alias_in(
6310    cfg: &HubConfig,
6311    directory: &TrustDirectory,
6312    alias: &AliasBinding,
6313) -> LinkResult<()> {
6314    use std::os::fd::{AsRawFd as _, FromRawFd as _};
6315
6316    let name_string = alias_file_name(cfg, &alias.requested)?;
6317    let name = c_name(name_string.as_bytes(), &name_string)?;
6318    let mut bytes = serde_json::to_vec(alias)
6319        .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
6320    bytes.push(b'\n');
6321    let nonce = std::time::SystemTime::now()
6322        .duration_since(std::time::UNIX_EPOCH)
6323        .unwrap_or_default()
6324        .as_nanos();
6325    let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
6326    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
6327    let fd = unsafe {
6328        libc::openat(
6329            directory.as_raw_fd(),
6330            temp.as_ptr(),
6331            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
6332            0o600,
6333        )
6334    };
6335    if fd < 0 {
6336        return Err(std::io::Error::last_os_error().into());
6337    }
6338    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
6339    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
6340        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6341        return Err(error.into());
6342    }
6343    drop(file);
6344    if unsafe {
6345        libc::renameat(
6346            directory.as_raw_fd(),
6347            temp.as_ptr(),
6348            directory.as_raw_fd(),
6349            name.as_ptr(),
6350        )
6351    } != 0
6352    {
6353        let error = std::io::Error::last_os_error();
6354        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6355        return Err(error.into());
6356    }
6357    directory.sync_all()?;
6358    Ok(())
6359}
6360
6361#[cfg(not(unix))]
6362fn save_alias_in(
6363    _cfg: &HubConfig,
6364    _directory: &TrustDirectory,
6365    _alias: &AliasBinding,
6366) -> LinkResult<()> {
6367    Err(LinkError::UnsupportedPlatform {
6368        operation: "verified link.md state",
6369    })
6370}
6371
6372/// Load the canonical checkpoint shared by every spelling of one brain and
6373/// the separate alias binding. This also performs the one-way migration from
6374/// pre-v3 checkpoints that stored a full trust state under the alias itself.
6375/// Callers hold both alias and canonical locks before entering.
6376fn load_canonical_pin(
6377    cfg: &HubConfig,
6378    directory: &TrustDirectory,
6379    requested: &str,
6380    resolved_brain: &str,
6381) -> LinkResult<(Option<TrustState>, Option<AliasBinding>)> {
6382    let mut canonical = load_trust_in(cfg, directory, resolved_brain)?;
6383    if requested == resolved_brain {
6384        return Ok((canonical, None));
6385    }
6386
6387    let mut alias = load_alias_in(cfg, directory, requested)?;
6388    if let Some(binding) = &alias {
6389        if binding.brain != resolved_brain {
6390            return Err(invalid_feed(
6391                "requested brain alias now resolves to a different canonical brain",
6392            ));
6393        }
6394        return Ok((canonical, alias));
6395    }
6396
6397    // A v2 build stored the complete checkpoint under the requested slug.
6398    // Promote it to the canonical ULID key before creating the lightweight
6399    // alias binding. Never silently merge two independently advanced states.
6400    if let Some(legacy) = load_trust_in(cfg, directory, requested)? {
6401        if legacy.brain != resolved_brain {
6402            return Err(invalid_feed(
6403                "legacy alias checkpoint names a different canonical brain",
6404            ));
6405        }
6406        if let Some(existing) = &canonical {
6407            if existing.brain != legacy.brain
6408                || existing.anchor != legacy.anchor
6409                || existing.current != legacy.current
6410                || existing.head_seq != legacy.head_seq
6411                || existing.feed_hash != legacy.feed_hash
6412                || existing.rotations != legacy.rotations
6413            {
6414                return Err(invalid_feed(
6415                    "legacy alias checkpoint conflicts with the canonical checkpoint",
6416                ));
6417            }
6418        } else {
6419            let mut promoted = legacy.clone();
6420            promoted.requested = resolved_brain.to_string();
6421            promoted.home = None;
6422            save_trust_in(cfg, directory, &promoted)?;
6423            canonical = Some(promoted);
6424        }
6425        alias = Some(AliasBinding {
6426            v: 1,
6427            origin: normalized_origin(&cfg.hub)?,
6428            requested: requested.to_string(),
6429            brain: resolved_brain.to_string(),
6430            home: legacy.home,
6431        });
6432        save_alias_in(cfg, directory, alias.as_ref().expect("alias just created"))?;
6433    }
6434    Ok((canonical, alias))
6435}
6436
6437fn save_canonical_pin_and_alias(
6438    cfg: &HubConfig,
6439    directory: &TrustDirectory,
6440    requested: &str,
6441    resolved_brain: &str,
6442    mut state: TrustState,
6443    existing_alias: Option<&AliasBinding>,
6444) -> LinkResult<()> {
6445    state.requested = resolved_brain.to_string();
6446    state.brain = resolved_brain.to_string();
6447    state.home = None;
6448    save_trust_in(cfg, directory, &state)?;
6449    if requested != resolved_brain {
6450        save_alias_in(
6451            cfg,
6452            directory,
6453            &AliasBinding {
6454                v: 1,
6455                origin: normalized_origin(&cfg.hub)?,
6456                requested: requested.to_string(),
6457                brain: resolved_brain.to_string(),
6458                home: existing_alias.and_then(|alias| alias.home.clone()),
6459            },
6460        )?;
6461    }
6462    Ok(())
6463}
6464
6465fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
6466    const ED25519_SPKI_PREFIX: &[u8] = &[
6467        0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
6468    ];
6469    let entry = &item.entry;
6470    let public_der = URL_SAFE_NO_PAD
6471        .decode(&entry.public_key)
6472        .map_err(|_| invalid_feed("public key is not base64url"))?;
6473    if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
6474        || !public_der.starts_with(ED25519_SPKI_PREFIX)
6475    {
6476        return Err(invalid_feed("entry public key is not a valid Ed25519 SPKI"));
6477    }
6478    let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
6479    if entry.brain != format!("ed25519:{fingerprint}") {
6480        return Err(invalid_feed(
6481            "brain fingerprint does not match its public key",
6482        ));
6483    }
6484    // Verify the complete rotation authority before using any previous key.
6485    let _ = verify_identity_chain(identity, None)?;
6486    let mut chain: Vec<(&str, &str)> = identity
6487        .previous
6488        .iter()
6489        .rev()
6490        .map(|previous| {
6491            (
6492                previous.fingerprint.as_str(),
6493                previous.public_key_spki.as_str(),
6494            )
6495        })
6496        .collect();
6497    chain.push((&identity.fingerprint, &identity.public_key_spki));
6498    let signer_index = chain.iter().position(|(known_fingerprint, spki)| {
6499        *known_fingerprint == fingerprint && *spki == entry.public_key
6500    });
6501    let Some(signer_index) = signer_index else {
6502        return Err(invalid_feed(
6503            "entry signer is not this brain's identity (current or rotated-from)",
6504        ));
6505    };
6506    let lower_boundary = if signer_index == 0 {
6507        None
6508    } else {
6509        let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
6510            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
6511        Some(prior.prior_head_seq)
6512    };
6513    let upper_boundary = if signer_index == identity.rotations.len() {
6514        None
6515    } else {
6516        let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
6517            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
6518        Some(next.prior_head_seq)
6519    };
6520    if lower_boundary.is_some_and(|boundary| entry.seq <= boundary)
6521        || upper_boundary.is_some_and(|boundary| entry.seq > boundary)
6522    {
6523        return Err(invalid_feed(
6524            "entry signer is outside its authenticated rotation epoch",
6525        ));
6526    }
6527    let unsigned = UnsignedFeedEntry {
6528        v: entry.v,
6529        seq: entry.seq,
6530        ts: &entry.ts,
6531        brain: &entry.brain,
6532        public_key: &entry.public_key,
6533        kind: &entry.kind,
6534        op: &entry.op,
6535        pack_sha256: &entry.pack_sha256,
6536        files: &entry.files,
6537        removed: &entry.removed,
6538        prev_entry_hash: &entry.prev_entry_hash,
6539    };
6540    let message =
6541        serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
6542    let signature = URL_SAFE_NO_PAD
6543        .decode(&entry.sig)
6544        .map_err(|_| invalid_feed("signature is not base64url"))?;
6545    UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
6546        .verify(&message, &signature)
6547        .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
6548
6549    let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
6550    exact.push(b'\n');
6551    let actual_hash = format!("{:x}", Sha256::digest(&exact));
6552    if actual_hash != item.hash {
6553        return Err(invalid_feed("entry SHA-256 does not match"));
6554    }
6555    Ok(())
6556}
6557
6558// ─────────────────────────────────────────────────────────────────────────────
6559// key rotation — link.md §9.1: the new key, signed by the old one
6560// ─────────────────────────────────────────────────────────────────────────────
6561
6562/// The unsigned rotation statement in its normative field order.
6563#[derive(Serialize)]
6564struct UnsignedRotation<'a> {
6565    v: u8,
6566    op: &'a str,
6567    brain: &'a str,
6568    public_key: &'a str,
6569    new_brain: &'a str,
6570    new_public_key: &'a str,
6571    prior_head_seq: u64,
6572    prior_feed_hash: Option<&'a str>,
6573    ts: String,
6574}
6575
6576/// Durable intent for an in-flight key rotation. The hub's recovery contract
6577/// identifies an ambiguous retry by the exact statement bytes, so the
6578/// statement cannot be reconstructed from the key and feed boundary later:
6579/// its timestamp and signature would differ.
6580#[derive(Debug, Deserialize, Serialize)]
6581#[serde(deny_unknown_fields)]
6582struct RotationJournal {
6583    v: u8,
6584    origin: String,
6585    brain: String,
6586    old_brain: String,
6587    new_brain: String,
6588    prior_head_seq: u64,
6589    prior_feed_hash: Option<String>,
6590    statement: String,
6591}
6592
6593fn rotation_journal_path(key_path: &Path) -> PathBuf {
6594    let mut path = key_path.as_os_str().to_os_string();
6595    path.push(".rotation.json");
6596    PathBuf::from(path)
6597}
6598
6599fn read_rotation_journal(path: &Path) -> LinkResult<RotationJournal> {
6600    #[cfg(unix)]
6601    let file = {
6602        use std::os::fd::{AsRawFd as _, FromRawFd as _};
6603        use std::os::unix::ffi::OsStrExt as _;
6604        let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
6605            .map_err(|error| {
6606                bad_agent_key(&format!("cannot open the rotation journal parent: {error}"))
6607            })?;
6608        let leaf_name = path
6609            .file_name()
6610            .ok_or_else(|| bad_agent_key("the rotation journal path has no file name"))?;
6611        let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
6612        let fd = unsafe {
6613            libc::openat(
6614                parent.as_raw_fd(),
6615                leaf.as_ptr(),
6616                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
6617            )
6618        };
6619        if fd < 0 {
6620            return Err(bad_agent_key(
6621                "the rotation journal must be an existing regular file without symlink ancestors",
6622            ));
6623        }
6624        unsafe { std::fs::File::from_raw_fd(fd) }
6625    };
6626    #[cfg(not(unix))]
6627    let file = std::fs::File::open(path)
6628        .map_err(|error| bad_agent_key(&format!("cannot read the rotation journal: {error}")))?;
6629    let metadata = file
6630        .metadata()
6631        .map_err(|error| bad_agent_key(&format!("cannot inspect the rotation journal: {error}")))?;
6632    if !metadata.is_file() || metadata.len() > MAX_REGISTRY_CARD_BYTES {
6633        return Err(bad_agent_key(
6634            "the rotation journal must be a bounded regular file",
6635        ));
6636    }
6637    #[cfg(unix)]
6638    {
6639        use std::os::unix::fs::PermissionsExt as _;
6640        if metadata.permissions().mode() & 0o077 != 0 {
6641            return Err(bad_agent_key(
6642                "the rotation journal is accessible to group/other; set mode 0600",
6643            ));
6644        }
6645    }
6646    serde_json::from_reader(file)
6647        .map_err(|_| bad_agent_key("the rotation journal is not valid exact JSON"))
6648}
6649
6650fn remove_rotation_journal(path: &Path) {
6651    #[cfg(unix)]
6652    {
6653        use std::os::fd::AsRawFd as _;
6654        use std::os::unix::ffi::OsStrExt as _;
6655        let Ok(parent) =
6656            open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
6657        else {
6658            return;
6659        };
6660        let Some(leaf_name) = path.file_name() else {
6661            return;
6662        };
6663        let Ok(leaf) = c_name(leaf_name.as_bytes(), &path.display().to_string()) else {
6664            return;
6665        };
6666        if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), 0) } == 0 {
6667            let _ = parent.sync_all();
6668        }
6669    }
6670    #[cfg(not(unix))]
6671    {
6672        let _ = std::fs::remove_file(path);
6673    }
6674}
6675
6676fn validate_rotation_journal(
6677    journal: &RotationJournal,
6678    cfg: &HubConfig,
6679    canonical_brain: &str,
6680    old_key: &AgentSigningKey,
6681    new_key: &AgentSigningKey,
6682    head: &Head,
6683) -> LinkResult<()> {
6684    if journal.v != 1
6685        || journal.origin != normalized_origin(&cfg.hub)?
6686        || journal.brain != canonical_brain
6687        || journal.old_brain != old_key.multikey
6688        || journal.new_brain != new_key.multikey
6689        || journal.prior_head_seq != head.seq
6690        || journal.prior_feed_hash != head.feed_hash
6691    {
6692        return Err(invalid_feed(
6693            "rotation journal does not match the verified key and feed boundary",
6694        ));
6695    }
6696    let statement: RotationStatement = serde_json::from_str(&journal.statement)
6697        .map_err(|_| invalid_feed("rotation journal statement did not parse exactly"))?;
6698    if statement.prior_head_seq != journal.prior_head_seq
6699        || statement.prior_feed_hash != journal.prior_feed_hash
6700        || statement.brain != old_key.multikey
6701        || statement.public_key != old_key.public_key_spki
6702        || statement.new_brain != new_key.multikey
6703        || statement.new_public_key != new_key.public_key_spki
6704    {
6705        return Err(invalid_feed(
6706            "rotation journal statement does not match its durable intent",
6707        ));
6708    }
6709    let identity = FeedIdentity {
6710        fingerprint: new_key.multikey.trim_start_matches("ed25519:").to_string(),
6711        public_key_spki: new_key.public_key_spki.clone(),
6712        previous: vec![PreviousIdentity {
6713            fingerprint: old_key.multikey.trim_start_matches("ed25519:").to_string(),
6714            public_key_spki: old_key.public_key_spki.clone(),
6715        }],
6716        rotations: vec![journal.statement.clone()],
6717    };
6718    verify_identity_chain(&identity, None)?;
6719    Ok(())
6720}
6721
6722/// What `dbmd key rotate` returns.
6723#[derive(Debug, Serialize)]
6724pub struct RotationReport {
6725    /// The brain id rotated.
6726    pub brain: String,
6727    /// The NEW identity the hub now serves.
6728    pub multikey: String,
6729    /// Where the new PKCS#8 secret landed (0600).
6730    #[serde(rename = "keyFile")]
6731    pub key_file: String,
6732    /// Prior identities (newest first) the feed still verifies against.
6733    pub previous: Vec<String>,
6734}
6735
6736/// Rotate a self-custodied brain's key: mint a fresh keypair, build the
6737/// §9.1 statement — the new key plus exact prior feed boundary, signed by the
6738/// OLD key in normative serialization — and send it to the hub. The new secret
6739/// is durably created at 0600 before the POST; an existing output is reused for
6740/// idempotent retry/reconciliation. The old key is left untouched.
6741pub fn rotate_brain_key(
6742    cfg: &HubConfig,
6743    brain: &str,
6744    old_key: &AgentSigningKey,
6745    out: &Path,
6746) -> LinkResult<RotationReport> {
6747    require_hardened_filesystem("key rotation")?;
6748    require_safe_ref(brain)?;
6749    // The new private key must be durable *before* the hub can accept its
6750    // public half. An existing file is the retry/reconciliation path after an
6751    // ambiguous network failure: reuse it, never generate another identity.
6752    let new_key = if out.exists() {
6753        load_signing_key(out)?
6754    } else {
6755        let rng = ring::rand::SystemRandom::new();
6756        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
6757            .map_err(|_| bad_agent_key("key generation failed"))?;
6758        let pair = agent_keypair(pkcs8.as_ref())?;
6759        let (public_key_spki, multikey) = public_identity_for(&pair);
6760        write_secret_new(
6761            out,
6762            format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
6763        )?;
6764        AgentSigningKey {
6765            pkcs8: pkcs8.as_ref().to_vec(),
6766            multikey,
6767            public_key_spki,
6768        }
6769    };
6770    let new_spki = new_key.public_key_spki.clone();
6771    let new_multikey = new_key.multikey.clone();
6772    let journal_path = rotation_journal_path(out);
6773    let before = verified_remote_head(cfg, brain, false)?;
6774    let served_identity = before
6775        .identity
6776        .as_ref()
6777        .ok_or_else(|| invalid_feed("cannot rotate a brain with no signed identity"))?;
6778    let served_multikey = format!("ed25519:{}", served_identity.fingerprint);
6779    if served_multikey == new_multikey {
6780        remove_rotation_journal(&journal_path);
6781        return Ok(RotationReport {
6782            brain: brain.to_string(),
6783            multikey: new_multikey,
6784            key_file: out.display().to_string(),
6785            previous: served_identity
6786                .previous
6787                .iter()
6788                .map(|identity| format!("ed25519:{}", identity.fingerprint))
6789                .collect(),
6790        });
6791    }
6792    if served_multikey != old_key.multikey {
6793        return Err(invalid_feed(
6794            "the supplied old key is not the brain's verified current identity",
6795        ));
6796    }
6797
6798    let journal = if journal_path.exists() {
6799        read_rotation_journal(&journal_path)?
6800    } else {
6801        let ts = crate::now()
6802            .with_timezone(&chrono::Utc)
6803            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
6804            .to_string();
6805        let unsigned = serde_json::to_string(&UnsignedRotation {
6806            v: 1,
6807            op: "rotate",
6808            brain: &old_key.multikey,
6809            public_key: &old_key.public_key_spki,
6810            new_brain: &new_multikey,
6811            new_public_key: &new_spki,
6812            prior_head_seq: before.head.seq,
6813            prior_feed_hash: before.head.feed_hash.as_deref(),
6814            ts,
6815        })
6816        .expect("serialize rotation");
6817        let old_pair = agent_keypair(&old_key.pkcs8)?;
6818        let sig = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
6819        let statement = format!("{},\"sig\":\"{}\"}}", &unsigned[..unsigned.len() - 1], sig);
6820        let journal = RotationJournal {
6821            v: 1,
6822            origin: normalized_origin(&cfg.hub)?,
6823            brain: before.head.brain.clone(),
6824            old_brain: old_key.multikey.clone(),
6825            new_brain: new_multikey.clone(),
6826            prior_head_seq: before.head.seq,
6827            prior_feed_hash: before.head.feed_hash.clone(),
6828            statement,
6829        };
6830        let mut exact = serde_json::to_vec(&journal)
6831            .map_err(|_| invalid_feed("could not serialize rotation journal"))?;
6832        exact.push(b'\n');
6833        if write_secret_new(&journal_path, &exact).is_err() {
6834            // A concurrent retry may have won the O_EXCL race. Only an exact,
6835            // fully validated journal is allowed to recover that race.
6836            read_rotation_journal(&journal_path)?
6837        } else {
6838            journal
6839        }
6840    };
6841    validate_rotation_journal(
6842        &journal,
6843        cfg,
6844        &before.head.brain,
6845        old_key,
6846        &new_key,
6847        &before.head,
6848    )?;
6849
6850    let body = json!({ "statement": journal.statement });
6851    let path = format!("/api/hub/brains/{brain}/rotate");
6852    let attempted = request(cfg, "POST", &path, Some(&body), Auth::Required);
6853    let attempted_failure = match attempted {
6854        Ok(response) if (200..300).contains(&response.status) => None,
6855        Ok(response) => Some(ensure_ok(response, "key rotate").unwrap_err()),
6856        Err(error) => Some(error),
6857    };
6858
6859    // A 2xx body is not authority, and a failed response may have followed a
6860    // committed mutation. In both cases success means the normal verifier sees
6861    // an append-only rotation chain ending at the durable new key.
6862    let after = match verified_remote_head(cfg, brain, false) {
6863        Ok(after) => after,
6864        Err(error) => return Err(attempted_failure.unwrap_or(error)),
6865    };
6866    let identity = after
6867        .identity
6868        .as_ref()
6869        .ok_or_else(|| invalid_feed("rotated brain has no verified identity"))?;
6870    if format!("ed25519:{}", identity.fingerprint) != new_multikey
6871        || identity.public_key_spki != new_spki
6872    {
6873        return Err(attempted_failure.unwrap_or_else(|| {
6874            invalid_feed("hub acknowledged rotation without committing the verified new identity")
6875        }));
6876    }
6877    let previous = identity
6878        .previous
6879        .iter()
6880        .map(|prior| format!("ed25519:{}", prior.fingerprint))
6881        .collect();
6882    remove_rotation_journal(&journal_path);
6883
6884    Ok(RotationReport {
6885        brain: brain.to_string(),
6886        multikey: new_multikey,
6887        key_file: out.display().to_string(),
6888        previous,
6889    })
6890}
6891
6892// ─────────────────────────────────────────────────────────────────────────────
6893// mirror — verified replication: the whole feed + files, re-servable
6894// ─────────────────────────────────────────────────────────────────────────────
6895
6896/// What `dbmd mirror` materialized.
6897#[derive(Debug, Serialize)]
6898pub struct MirrorReport {
6899    /// The brain id.
6900    pub brain: String,
6901    /// The mirrored feed head.
6902    #[serde(rename = "headSeq")]
6903    pub head_seq: u64,
6904    /// The head entry hash (the feed's advertised converged state).
6905    #[serde(rename = "feedHash")]
6906    pub feed_hash: Option<String>,
6907    /// Signed feed entries verified and stored.
6908    pub entries: u64,
6909    /// The brain's multikey, pinned in `.dbmd/config` (TOFU).
6910    pub pinned: String,
6911    /// Store files materialized by the pull.
6912    pub files: usize,
6913}
6914
6915/// The mirror state directory, relative to the mirror root.
6916pub const MIRROR_REL_DIR: &str = ".dbmd/mirror";
6917
6918/// Fully re-verified mirror material suitable for a read-only re-server.
6919#[derive(Debug)]
6920pub struct VerifiedMirrorMaterial {
6921    pub brain: String,
6922    pub head_seq: u64,
6923    pub feed_hash: Option<String>,
6924    pub identity: serde_json::Value,
6925    /// Sequence, exact normative entry JSON without newline, and entry hash.
6926    pub entries: Vec<(u64, String, String)>,
6927    pub pack_sha256: Option<String>,
6928}
6929
6930#[derive(Deserialize)]
6931#[serde(deny_unknown_fields)]
6932struct StoredMirrorHead {
6933    brain: String,
6934    #[serde(rename = "headSeq")]
6935    head_seq: u64,
6936    #[serde(rename = "feedHash")]
6937    feed_hash: Option<String>,
6938}
6939
6940/// Re-verify mirror metadata, every signature/hash/rotation boundary, and the
6941/// exact snapshot pack before `dbmd serve` exposes any bytes.
6942pub fn verify_mirror_material(
6943    head_bytes: &[u8],
6944    identity_bytes: &[u8],
6945    feed_bytes: &[Vec<u8>],
6946    snapshot_pack: Option<&[u8]>,
6947    expected_anchor: &str,
6948) -> LinkResult<VerifiedMirrorMaterial> {
6949    let snapshot_hash = snapshot_pack
6950        .filter(|pack| !pack.is_empty())
6951        .map(content_sha256);
6952    verify_mirror_material_with_pack_hash(
6953        head_bytes,
6954        identity_bytes,
6955        feed_bytes,
6956        snapshot_hash.as_deref(),
6957        expected_anchor,
6958    )
6959}
6960
6961/// Re-verify mirror metadata against a snapshot digest computed from a held
6962/// no-follow file capability. This lets `dbmd serve` authenticate and retain a
6963/// large pack without ever buffering the pack in process memory.
6964pub fn verify_mirror_material_with_pack_hash(
6965    head_bytes: &[u8],
6966    identity_bytes: &[u8],
6967    feed_bytes: &[Vec<u8>],
6968    snapshot_pack_sha256: Option<&str>,
6969    expected_anchor: &str,
6970) -> LinkResult<VerifiedMirrorMaterial> {
6971    let head: StoredMirrorHead = serde_json::from_slice(head_bytes)
6972        .map_err(|_| invalid_feed("stored mirror head did not parse exactly"))?;
6973    require_safe_ref(&head.brain)?;
6974    if head.head_seq > MAX_FEED_REPLAY_ENTRIES || feed_bytes.len() as u64 != head.head_seq {
6975        return Err(invalid_feed(
6976            "stored mirror feed count does not match its bounded head sequence",
6977        ));
6978    }
6979    let aggregate = feed_bytes
6980        .iter()
6981        .try_fold(0u64, |total, bytes| total.checked_add(bytes.len() as u64))
6982        .ok_or_else(|| invalid_feed("stored mirror feed size overflow"))?;
6983    if aggregate > MAX_FEED_REPLAY_BYTES {
6984        return Err(invalid_feed(
6985            "stored mirror feed metadata exceeds the aggregate limit",
6986        ));
6987    }
6988    let identity: FeedIdentity = serde_json::from_slice(identity_bytes)
6989        .map_err(|_| invalid_feed("stored mirror identity did not parse exactly"))?;
6990    let anchor = verify_identity_chain(&identity, None)?;
6991    if anchor != expected_anchor {
6992        return Err(invalid_feed(
6993            "stored mirror identity does not descend from the explicitly trusted anchor",
6994        ));
6995    }
6996
6997    let mut entries = Vec::with_capacity(feed_bytes.len());
6998    let mut items = Vec::with_capacity(feed_bytes.len());
6999    let mut previous_hash = None;
7000    let mut pack_sha256 = None;
7001    for (index, bytes) in feed_bytes.iter().enumerate() {
7002        let exact = bytes
7003            .strip_suffix(b"\n")
7004            .ok_or_else(|| invalid_feed("stored feed entry lacks its exact trailing newline"))?;
7005        if exact.ends_with(b"\n") {
7006            return Err(invalid_feed("stored feed entry has extra trailing bytes"));
7007        }
7008        let entry: FeedEntry = serde_json::from_slice(exact)
7009            .map_err(|_| invalid_feed("stored feed entry did not parse exactly"))?;
7010        let expected_seq = index as u64 + 1;
7011        if entry.seq != expected_seq || entry.prev_entry_hash != previous_hash {
7012            return Err(invalid_feed(
7013                "stored mirror feed is not contiguous and hash-chained",
7014            ));
7015        }
7016        let canonical = serde_json::to_vec(&entry)
7017            .map_err(|_| invalid_feed("could not canonicalize stored feed entry"))?;
7018        if canonical != exact {
7019            return Err(invalid_feed(
7020                "stored feed entry is not in normative serialization",
7021            ));
7022        }
7023        let hash = content_sha256(bytes);
7024        let item = FeedItem {
7025            hash: hash.clone(),
7026            entry,
7027        };
7028        verify_feed_item(&item, &identity)?;
7029        previous_hash = Some(hash.clone());
7030        if expected_seq == head.head_seq {
7031            pack_sha256 = Some(item.entry.pack_sha256.clone());
7032        }
7033        entries.push((
7034            expected_seq,
7035            std::str::from_utf8(exact)
7036                .map_err(|_| invalid_feed("stored feed entry is not UTF-8"))?
7037                .to_string(),
7038            hash,
7039        ));
7040        items.push(item);
7041    }
7042    if previous_hash != head.feed_hash {
7043        return Err(invalid_feed(
7044            "stored mirror feed does not converge on its advertised head",
7045        ));
7046    }
7047    verify_rotation_feed_boundaries(&identity, None, &items, head.head_seq)?;
7048    match (head.head_seq, snapshot_pack_sha256, pack_sha256.as_deref()) {
7049        (0, None, None) => {}
7050        (_, Some(actual), Some(expected)) if actual == expected => {}
7051        _ => {
7052            return Err(LinkError::InvalidPack {
7053                message: "stored snapshot pack does not match the signed head digest".to_string(),
7054            });
7055        }
7056    }
7057    let identity_value = serde_json::to_value(&identity)
7058        .map_err(|_| invalid_feed("could not serialize verified mirror identity"))?;
7059    Ok(VerifiedMirrorMaterial {
7060        brain: head.brain,
7061        head_seq: head.head_seq,
7062        feed_hash: head.feed_hash,
7063        identity: identity_value,
7064        entries,
7065        pack_sha256,
7066    })
7067}
7068
7069/// SHA-256 hex of one feed entry's stored bytes (`exact JSON + "\n"`) — the
7070/// entry hash every consumer recomputes (SPEC §5.3).
7071pub fn feed_entry_hash(exact_sans_newline: &str) -> String {
7072    format!(
7073        "{:x}",
7074        Sha256::digest(format!("{exact_sans_newline}\n").as_bytes())
7075    )
7076}
7077
7078/// SHA-256 hex for content a signed manifest names. Exposed for the thin
7079/// `dbmd serve` adapter; cryptographic verification remains centralized here.
7080pub fn content_sha256(bytes: &[u8]) -> String {
7081    format!("{:x}", Sha256::digest(bytes))
7082}
7083
7084/// SHA-256 a stream with a fixed-size working buffer.
7085pub fn content_sha256_reader(mut reader: impl Read) -> std::io::Result<String> {
7086    let mut digest = Sha256::new();
7087    let mut buffer = [0u8; 64 * 1024];
7088    loop {
7089        let read = reader.read(&mut buffer)?;
7090        if read == 0 {
7091            break;
7092        }
7093        digest.update(&buffer[..read]);
7094    }
7095    Ok(format!("{:x}", digest.finalize()))
7096}
7097
7098/// Replicate a brain with full verification (link.md §5.4 over the WHOLE
7099/// chain, not just the head): every entry's signature, hash, sequence
7100/// contiguity, prev-hash linkage, rotation chain, and exact signed pack are
7101/// checked in a sibling staging directory. Only then is the old mirror swapped
7102/// out through an atomic directory exchange. Every stage, install, and cleanup
7103/// operation is relative to one held no-follow parent-directory capability, so
7104/// renaming an ancestor cannot redirect any write or deletion.
7105pub fn mirror(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<MirrorReport> {
7106    require_hardened_filesystem("mirror")?;
7107    require_safe_ref(brain)?;
7108    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
7109    let name = dest
7110        .file_name()
7111        .and_then(|name| name.to_str())
7112        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
7113        .ok_or_else(|| LinkError::UnsafePath {
7114            path: dest.display().to_string(),
7115        })?;
7116    #[cfg(unix)]
7117    let parent_dir = open_or_create_dir_nofollow(parent)?;
7118    #[cfg(unix)]
7119    use std::os::fd::AsRawFd as _;
7120    #[cfg(unix)]
7121    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
7122    #[cfg(unix)]
7123    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
7124        None => false,
7125        Some(true) => true,
7126        Some(false) => {
7127            return Err(LinkError::UnsafePath {
7128                path: dest.display().to_string(),
7129            });
7130        }
7131    };
7132
7133    // A fixed backup was used by pre-hardening builds. Never interpret or
7134    // delete an attacker-planted entry at that name; require manual recovery.
7135    #[cfg(unix)]
7136    let legacy_backup_name = c_name(
7137        format!(".{name}.dbmd-backup").as_bytes(),
7138        &dest.display().to_string(),
7139    )?;
7140    #[cfg(unix)]
7141    if entry_is_dir_at(parent_dir.as_raw_fd(), &legacy_backup_name)?.is_some() {
7142        return Err(LinkError::UnsafePath {
7143            path: parent
7144                .join(format!(".{name}.dbmd-backup"))
7145                .display()
7146                .to_string(),
7147        });
7148    }
7149
7150    let nonce = std::time::SystemTime::now()
7151        .duration_since(std::time::UNIX_EPOCH)
7152        .unwrap_or_default()
7153        .as_nanos();
7154    let stage_label = format!(".{name}.dbmd-stage-{}-{nonce}", std::process::id());
7155    #[cfg(unix)]
7156    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
7157    #[cfg(unix)]
7158    let stage_dir = create_dir_exclusive_at(
7159        parent_dir.as_raw_fd(),
7160        &stage_name,
7161        &dest.display().to_string(),
7162    )?;
7163
7164    let assembled = (|| -> LinkResult<MirrorReport> {
7165        let remote = verified_remote_head(cfg, brain, true)?;
7166        let brain_id = remote.head.brain.clone();
7167        let identity = remote
7168            .identity
7169            .as_ref()
7170            .ok_or_else(|| invalid_feed("brain has no signed identity"))?;
7171        let anchor = remote
7172            .anchor
7173            .clone()
7174            .ok_or_else(|| invalid_feed("brain has no verified identity anchor"))?;
7175        let pack = download_verified_snapshot_pack(cfg, &brain_id, &remote)?;
7176        let snapshot_entries = parse_store_pack(pack.clone())?;
7177        let snapshot_count = snapshot_entries.len();
7178        let mut staged_entries = snapshot_entries;
7179        staged_entries.push((format!("{MIRROR_REL_DIR}/snapshot.pack"), pack));
7180        for item in &remote.entries {
7181            let mut exact = serde_json::to_vec(&item.entry)
7182                .map_err(|_| invalid_feed("could not serialize signed feed entry"))?;
7183            exact.push(b'\n');
7184            if format!("{:x}", Sha256::digest(&exact)) != item.hash {
7185                return Err(invalid_feed(
7186                    "serialized mirror entry differs from its verified hash",
7187                ));
7188            }
7189            staged_entries.push((
7190                format!("{MIRROR_REL_DIR}/feed/{}.json", item.entry.seq),
7191                exact,
7192            ));
7193        }
7194        let mut identity_bytes = serde_json::to_vec(identity)
7195            .map_err(|_| invalid_feed("could not serialize verified identity chain"))?;
7196        identity_bytes.push(b'\n');
7197        staged_entries.push((format!("{MIRROR_REL_DIR}/identity.json"), identity_bytes));
7198        let mut head_bytes = serde_json::to_vec(&json!({
7199            "brain": brain_id,
7200            "headSeq": remote.head.seq,
7201            "feedHash": remote.head.feed_hash,
7202        }))
7203        .map_err(|_| invalid_feed("could not serialize verified mirror head"))?;
7204        head_bytes.push(b'\n');
7205        staged_entries.push((format!("{MIRROR_REL_DIR}/head.json"), head_bytes));
7206        staged_entries.push((
7207            CONFIG_REL_PATH.to_string(),
7208            format!("hub = {}\npin = {anchor}\n", cfg.hub).into_bytes(),
7209        ));
7210        #[cfg(unix)]
7211        write_pull_entries_beneath_dir(&stage_dir, &staged_entries)?;
7212
7213        Ok(MirrorReport {
7214            brain: brain_id,
7215            head_seq: remote.head.seq,
7216            feed_hash: remote.head.feed_hash,
7217            entries: remote.entries.len() as u64,
7218            pinned: anchor,
7219            files: snapshot_count,
7220        })
7221    })();
7222
7223    let report = match assembled {
7224        Ok(report) => report,
7225        Err(error) => {
7226            #[cfg(unix)]
7227            let _ = remove_tree_at(
7228                parent_dir.as_raw_fd(),
7229                &stage_name,
7230                &dest.display().to_string(),
7231            );
7232            return Err(error);
7233        }
7234    };
7235
7236    #[cfg(unix)]
7237    if let Err(error) =
7238        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
7239    {
7240        let _ = remove_tree_at(
7241            parent_dir.as_raw_fd(),
7242            &stage_name,
7243            &dest.display().to_string(),
7244        );
7245        return Err(error);
7246    }
7247    // An exchange leaves the old mirror at the unique stage name. Cleanup is
7248    // capability-relative and never follows symlinks in hostile old content.
7249    #[cfg(unix)]
7250    if dest_exists {
7251        remove_tree_at(
7252            parent_dir.as_raw_fd(),
7253            &stage_name,
7254            &dest.display().to_string(),
7255        )?;
7256    }
7257    #[cfg(unix)]
7258    parent_dir.sync_all()?;
7259    Ok(report)
7260}
7261
7262fn verified_remote_head(
7263    cfg: &HubConfig,
7264    brain: &str,
7265    require_full_chain: bool,
7266) -> LinkResult<VerifiedRemote> {
7267    require_hardened_filesystem("verified link.md state")?;
7268    require_safe_ref(brain)?;
7269    // Refuse an unsafe state root before sending credentials or consulting an
7270    // untrusted card, and retain this exact directory inode for the complete
7271    // checkpoint transaction.
7272    let trust_directory = open_trust_dir(cfg)?;
7273    let path = format!("/api/hub/brains/{brain}");
7274    let body = ensure_ok(
7275        request(cfg, "GET", &path, None, Auth::Required)?,
7276        "subscribe",
7277    )?;
7278    let resolved_brain = body
7279        .get("id")
7280        .and_then(Value::as_str)
7281        .filter(|id| crate::ulid::is_ulid(id))
7282        .ok_or_else(|| invalid_feed("brain card has no canonical ULID id"))?
7283        .to_string();
7284    if crate::ulid::is_ulid(brain) && resolved_brain != brain {
7285        return Err(invalid_feed(
7286            "brain card id differs from the explicitly requested brain id",
7287        ));
7288    }
7289    // The card supplies the canonical ULID. Lock alias + canonical keys in
7290    // deterministic filename order, then hold both through verify + save.
7291    // Concurrent aliases for the same brain therefore converge on one
7292    // checkpoint instead of establishing independent TOFU universes.
7293    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &resolved_brain])?;
7294    let (pinned, alias_binding) =
7295        load_canonical_pin(cfg, &trust_directory, brain, &resolved_brain)?;
7296    let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
7297    let advertised_hash = body
7298        .get("feedHash")
7299        .and_then(Value::as_str)
7300        .map(str::to_string);
7301    let updated_at = body
7302        .get("updatedAt")
7303        .and_then(Value::as_str)
7304        .map(str::to_string);
7305    if let Some(pin) = &pinned {
7306        if seq < pin.head_seq {
7307            return Err(invalid_feed(format!(
7308                "feed rollback: hub advertised sequence {seq}, local checkpoint is {}",
7309                pin.head_seq
7310            )));
7311        }
7312        if seq == pin.head_seq && advertised_hash != pin.feed_hash {
7313            return Err(invalid_feed(
7314                "feed equivocation: the checkpoint sequence now has a different hash",
7315            ));
7316        }
7317    }
7318    if seq == 0 {
7319        if advertised_hash.is_some() {
7320            return Err(invalid_feed("an empty feed advertised a head hash"));
7321        }
7322        let identity: FeedIdentity = serde_json::from_value(
7323            body.get("identity")
7324                .cloned()
7325                .ok_or_else(|| invalid_feed("empty brain card has no signed identity"))?,
7326        )
7327        .map_err(|_| invalid_feed("empty brain card has an invalid identity"))?;
7328        let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
7329        // A valid old-key-signed rotation is still inconsistent if it commits
7330        // to history that the same card now claims never existed. Run the
7331        // identical feed-boundary proof used by non-empty heads before this
7332        // identity can become a durable TOFU checkpoint.
7333        verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], seq)?;
7334        save_canonical_pin_and_alias(
7335            cfg,
7336            &trust_directory,
7337            brain,
7338            &resolved_brain,
7339            TrustState {
7340                v: 2,
7341                origin: normalized_origin(&cfg.hub)?,
7342                requested: resolved_brain.clone(),
7343                brain: resolved_brain.clone(),
7344                home: None,
7345                anchor: anchor.clone(),
7346                current: format!("ed25519:{}", identity.fingerprint),
7347                head_seq: 0,
7348                feed_hash: None,
7349                rotations: identity.rotations.clone(),
7350                hub_signer: None,
7351                protocol_profile: None,
7352            },
7353            alias_binding.as_ref(),
7354        )?;
7355        return Ok(VerifiedRemote {
7356            head: Head {
7357                brain: resolved_brain,
7358                seq,
7359                updated_at,
7360                feed_hash: None,
7361                verified: true,
7362            },
7363            identity: Some(identity),
7364            head_entry: None,
7365            entries: Vec::new(),
7366            anchor: Some(anchor),
7367        });
7368    }
7369    if advertised_hash.as_ref().is_none_or(|hash| !is_sha256(hash)) {
7370        return Err(invalid_feed(
7371            "non-empty feed did not advertise a valid SHA-256 head",
7372        ));
7373    }
7374
7375    // On first contact the signed head itself is the TOFU checkpoint. A full
7376    // history replay cannot add authority before an anchor exists; mirrors
7377    // still request the complete chain because they promise an archival copy.
7378    let replay_head_only = !require_full_chain
7379        && pinned
7380            .as_ref()
7381            .is_none_or(|checkpoint| checkpoint.head_seq == seq);
7382    let mut after = if replay_head_only {
7383        seq - 1
7384    } else if require_full_chain || pinned.is_none() {
7385        0
7386    } else {
7387        pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq)
7388    };
7389    let mut expected_seq = after + 1;
7390    let mut previous_hash = if require_full_chain || pinned.is_none() || replay_head_only {
7391        None
7392    } else {
7393        pinned
7394            .as_ref()
7395            .and_then(|checkpoint| checkpoint.feed_hash.clone())
7396    };
7397    let mut identity: Option<FeedIdentity> = None;
7398    let mut anchor: Option<String> = None;
7399    let mut head_entry: Option<FeedItem> = None;
7400    let mut all_entries = Vec::new();
7401    let mut observed_entries = Vec::new();
7402    let replay_count = seq
7403        .checked_sub(after)
7404        .ok_or_else(|| invalid_feed("feed replay range moved backward"))?;
7405    if replay_count > MAX_FEED_REPLAY_ENTRIES {
7406        return Err(invalid_feed(format!(
7407            "feed replay requires {replay_count} entries, over the client cap"
7408        )));
7409    }
7410    let mut replay_bytes = 0u64;
7411
7412    loop {
7413        let feed_bytes = ensure_raw_ok(
7414            request_raw(
7415                cfg,
7416                "GET",
7417                &format!("/api/hub/brains/{brain}/feed?after={after}&limit=100"),
7418                None,
7419                Auth::Required,
7420                MAX_FEED_RESPONSE_BYTES,
7421            )?,
7422            "subscribe feed",
7423        )?;
7424        let feed: FeedResponse = serde_json::from_slice(&feed_bytes)
7425            .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
7426        if feed.head_seq != seq || feed.feed_hash != advertised_hash {
7427            return Err(invalid_feed("brain card and feed head disagree"));
7428        }
7429        if feed.entries.len() > FEED_PAGE_LIMIT {
7430            return Err(invalid_feed("feed page exceeds the requested entry limit"));
7431        }
7432        if feed.scope_limited {
7433            if require_full_chain {
7434                return Err(invalid_feed(
7435                    "path-scoped grants cannot verify a full snapshot chain",
7436                ));
7437            }
7438            return Ok(VerifiedRemote {
7439                head: Head {
7440                    brain: resolved_brain,
7441                    seq,
7442                    updated_at,
7443                    feed_hash: advertised_hash,
7444                    verified: false,
7445                },
7446                identity: None,
7447                head_entry: None,
7448                entries: Vec::new(),
7449                anchor: None,
7450            });
7451        }
7452        let page_identity = feed
7453            .identity
7454            .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
7455        let page_anchor = verify_identity_chain(&page_identity, pinned.as_ref())?;
7456        if identity
7457            .as_ref()
7458            .is_some_and(|existing| existing != &page_identity)
7459        {
7460            return Err(invalid_feed("identity changed while reading the feed"));
7461        }
7462        if anchor
7463            .as_ref()
7464            .is_some_and(|existing| existing != &page_anchor)
7465        {
7466            return Err(invalid_feed(
7467                "identity anchor changed while reading the feed",
7468            ));
7469        }
7470        identity = Some(page_identity.clone());
7471        if anchor.is_none() {
7472            anchor = Some(page_anchor);
7473        }
7474        if feed.entries.is_empty() {
7475            return Err(invalid_feed("feed page was empty before the signed head"));
7476        }
7477
7478        for item in feed.entries {
7479            if item.entry.seq != expected_seq {
7480                return Err(invalid_feed(format!(
7481                    "expected entry {expected_seq}, feed served {}",
7482                    item.entry.seq
7483                )));
7484            }
7485            if item.entry.seq > seq {
7486                return Err(invalid_feed("feed advanced past the card snapshot"));
7487            }
7488            if !replay_head_only && item.entry.prev_entry_hash != previous_hash {
7489                return Err(invalid_feed(format!(
7490                    "entry {} does not chain to the local checkpoint",
7491                    item.entry.seq
7492                )));
7493            }
7494            verify_feed_item(&item, &page_identity)?;
7495            reject_retired_signer_after_checkpoint(&page_identity, pinned.as_ref(), &item)?;
7496            replay_bytes = replay_bytes.saturating_add(
7497                serde_json::to_vec(&item)
7498                    .map_err(|_| invalid_feed("could not size feed entry"))?
7499                    .len() as u64,
7500            );
7501            if replay_bytes > MAX_FEED_REPLAY_BYTES {
7502                return Err(invalid_feed("feed replay metadata exceeds the client cap"));
7503            }
7504            previous_hash = Some(item.hash.clone());
7505            after = item.entry.seq;
7506            expected_seq = expected_seq
7507                .checked_add(1)
7508                .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
7509            if require_full_chain {
7510                all_entries.push(item.clone());
7511            }
7512            observed_entries.push(item.clone());
7513            head_entry = Some(item);
7514        }
7515        if after == seq {
7516            break;
7517        }
7518    }
7519
7520    if head_entry.as_ref().map(|item| &item.hash) != advertised_hash.as_ref() {
7521        return Err(invalid_feed(
7522            "verified chain does not converge on the advertised head",
7523        ));
7524    }
7525    let identity = identity.ok_or_else(|| invalid_feed("feed has no brain identity"))?;
7526    let anchor = anchor.ok_or_else(|| invalid_feed("feed has no identity anchor"))?;
7527    verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &observed_entries, seq)?;
7528    save_canonical_pin_and_alias(
7529        cfg,
7530        &trust_directory,
7531        brain,
7532        &resolved_brain,
7533        TrustState {
7534            v: 2,
7535            origin: normalized_origin(&cfg.hub)?,
7536            requested: resolved_brain.clone(),
7537            brain: resolved_brain.clone(),
7538            home: None,
7539            anchor: anchor.clone(),
7540            current: format!("ed25519:{}", identity.fingerprint),
7541            head_seq: seq,
7542            feed_hash: advertised_hash.clone(),
7543            rotations: identity.rotations.clone(),
7544            hub_signer: pinned.as_ref().and_then(|state| state.hub_signer.clone()),
7545            protocol_profile: pinned
7546                .as_ref()
7547                .and_then(|state| state.protocol_profile.clone()),
7548        },
7549        alias_binding.as_ref(),
7550    )?;
7551    Ok(VerifiedRemote {
7552        head: Head {
7553            brain: resolved_brain,
7554            seq,
7555            updated_at,
7556            feed_hash: advertised_hash,
7557            verified: true,
7558        },
7559        identity: Some(identity),
7560        head_entry,
7561        entries: all_entries,
7562        anchor: Some(anchor),
7563    })
7564}
7565
7566/// Read and locally verify the brain's current signed feed head. Identity
7567/// rotation is accepted only through an old-key-signed chain rooted at the
7568/// local TOFU anchor; sequence and hash checkpoints reject rollback and
7569/// equivocation across invocations.
7570pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
7571    Ok(verified_remote_head(cfg, brain, false)?.head)
7572}
7573
7574#[cfg(test)]
7575mod tests {
7576    use super::*;
7577
7578    const TEST_BRAIN_ID: &str = "01j5qc3v9k4ym8rwbn2tqe6f7d";
7579
7580    #[cfg(target_os = "linux")]
7581    #[test]
7582    fn linux_snapshot_install_is_atomic_for_create_and_exchange() {
7583        use std::os::fd::AsRawFd as _;
7584
7585        let sandbox = tempfile::TempDir::new().unwrap();
7586        let parent = std::fs::File::open(sandbox.path()).unwrap();
7587        let stage = std::ffi::CString::new("stage").unwrap();
7588        let destination = std::ffi::CString::new("brain").unwrap();
7589
7590        std::fs::create_dir(sandbox.path().join("stage")).unwrap();
7591        std::fs::write(sandbox.path().join("stage/value"), b"created").unwrap();
7592        install_stage_at(
7593            parent.as_raw_fd(),
7594            stage.as_c_str(),
7595            destination.as_c_str(),
7596            false,
7597        )
7598        .unwrap();
7599        assert!(!sandbox.path().join("stage").exists());
7600        assert_eq!(
7601            std::fs::read(sandbox.path().join("brain/value")).unwrap(),
7602            b"created"
7603        );
7604
7605        std::fs::create_dir(sandbox.path().join("stage")).unwrap();
7606        std::fs::write(sandbox.path().join("stage/value"), b"replacement").unwrap();
7607        install_stage_at(
7608            parent.as_raw_fd(),
7609            stage.as_c_str(),
7610            destination.as_c_str(),
7611            true,
7612        )
7613        .unwrap();
7614        assert_eq!(
7615            std::fs::read(sandbox.path().join("brain/value")).unwrap(),
7616            b"replacement"
7617        );
7618        assert_eq!(
7619            std::fs::read(sandbox.path().join("stage/value")).unwrap(),
7620            b"created",
7621            "RENAME_EXCHANGE must leave the predecessor at the unique stage name"
7622        );
7623    }
7624
7625    struct SignedRemoteFixture {
7626        card: String,
7627        feed: String,
7628        key: AgentSigningKey,
7629        identity: FeedIdentity,
7630    }
7631
7632    fn signed_remote_fixture() -> SignedRemoteFixture {
7633        let rng = ring::rand::SystemRandom::new();
7634        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
7635        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
7636        let (public_key, multikey) = public_identity_for(&pair);
7637        let identity = FeedIdentity {
7638            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
7639            public_key_spki: public_key.clone(),
7640            previous: Vec::new(),
7641            rotations: Vec::new(),
7642        };
7643        let mut entry = FeedEntry {
7644            v: 1,
7645            seq: 1,
7646            ts: "2026-07-30T12:00:00.000Z".to_string(),
7647            brain: multikey.clone(),
7648            public_key: public_key.clone(),
7649            kind: "push".to_string(),
7650            op: "snapshot".to_string(),
7651            pack_sha256: "a".repeat(64),
7652            files: Vec::new(),
7653            removed: Vec::new(),
7654            prev_entry_hash: None,
7655            sig: String::new(),
7656        };
7657        let unsigned = UnsignedFeedEntry {
7658            v: entry.v,
7659            seq: entry.seq,
7660            ts: &entry.ts,
7661            brain: &entry.brain,
7662            public_key: &entry.public_key,
7663            kind: &entry.kind,
7664            op: &entry.op,
7665            pack_sha256: &entry.pack_sha256,
7666            files: &entry.files,
7667            removed: &entry.removed,
7668            prev_entry_hash: &entry.prev_entry_hash,
7669        };
7670        entry.sig =
7671            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
7672        let mut exact = serde_json::to_vec(&entry).unwrap();
7673        exact.push(b'\n');
7674        let hash = content_sha256(&exact);
7675        let card = json!({
7676            "id": TEST_BRAIN_ID,
7677            "headSeq": 1,
7678            "feedHash": hash,
7679            "identity": identity.clone(),
7680        })
7681        .to_string();
7682        let feed = json!({
7683            "headSeq": 1,
7684            "feedHash": hash,
7685            "identity": identity.clone(),
7686            "entries": [{"hash": hash, "entry": entry}],
7687            "scopeLimited": false,
7688        })
7689        .to_string();
7690        SignedRemoteFixture {
7691            card,
7692            feed,
7693            key: AgentSigningKey {
7694                pkcs8: pkcs8.as_ref().to_vec(),
7695                multikey,
7696                public_key_spki: public_key,
7697            },
7698            identity,
7699        }
7700    }
7701
7702    #[test]
7703    fn wire_sequences_are_rejected_at_their_endpoint_count_limits() {
7704        let fixture = signed_remote_fixture();
7705        let feed: Value = serde_json::from_str(&fixture.feed).unwrap();
7706        let item = feed["entries"][0].to_string();
7707        let oversized_page = format!(
7708            "{{\"headSeq\":1,\"feedHash\":null,\"identity\":null,\"entries\":[{}],\"scopeLimited\":false}}",
7709            std::iter::repeat_n(item.as_str(), FEED_PAGE_LIMIT + 1)
7710                .collect::<Vec<_>>()
7711                .join(",")
7712        );
7713        assert!(serde_json::from_str::<FeedResponse>(&oversized_page).is_err());
7714
7715        let oversized_identity = format!(
7716            "{{\"fingerprint\":\"fp\",\"publicKeySpki\":\"spki\",\"previous\":[],\"rotations\":[{}]}}",
7717            std::iter::repeat_n("\"rotation\"", MAX_IDENTITY_ROTATIONS + 1)
7718                .collect::<Vec<_>>()
7719                .join(",")
7720        );
7721        assert!(serde_json::from_str::<FeedIdentity>(&oversized_identity).is_err());
7722
7723        let file = r#"{"path":"a","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bytes":1}"#;
7724        let oversized_entry = format!(
7725            "{{\"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\"}}",
7726            "a".repeat(64),
7727            std::iter::repeat_n(file, MAX_PUSH_FILES + 1)
7728                .collect::<Vec<_>>()
7729                .join(",")
7730        );
7731        assert!(serde_json::from_str::<FeedEntry>(&oversized_entry).is_err());
7732    }
7733
7734    fn scripted_json_hub(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle<()>) {
7735        use std::io::{BufRead as _, BufReader, Read as _, Write as _};
7736        use std::net::TcpListener;
7737
7738        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
7739        let url = format!("http://{}", listener.local_addr().unwrap());
7740        let handle = std::thread::spawn(move || {
7741            for (status, body) in responses {
7742                let (stream, _) = listener.accept().unwrap();
7743                let mut reader = BufReader::new(stream);
7744                let mut line = String::new();
7745                reader.read_line(&mut line).unwrap();
7746                let mut content_length = 0usize;
7747                loop {
7748                    line.clear();
7749                    reader.read_line(&mut line).unwrap();
7750                    if line == "\r\n" || line == "\n" || line.is_empty() {
7751                        break;
7752                    }
7753                    if let Some((name, value)) = line.split_once(':') {
7754                        if name.eq_ignore_ascii_case("content-length") {
7755                            content_length = value.trim().parse().unwrap();
7756                        }
7757                    }
7758                }
7759                let mut request_body = vec![0_u8; content_length];
7760                reader.read_exact(&mut request_body).unwrap();
7761                let response = format!(
7762                    "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
7763                    body.len()
7764                );
7765                reader.get_mut().write_all(response.as_bytes()).unwrap();
7766            }
7767        });
7768        (url, handle)
7769    }
7770
7771    fn routed_json_hub(
7772        requests: usize,
7773        mut respond: impl FnMut(&str) -> (u16, String) + Send + 'static,
7774    ) -> (String, std::thread::JoinHandle<()>) {
7775        use std::io::{BufRead as _, BufReader, Read as _, Write as _};
7776        use std::net::TcpListener;
7777
7778        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
7779        let url = format!("http://{}", listener.local_addr().unwrap());
7780        let handle = std::thread::spawn(move || {
7781            for _ in 0..requests {
7782                let (stream, _) = listener.accept().unwrap();
7783                let mut reader = BufReader::new(stream);
7784                let mut line = String::new();
7785                reader.read_line(&mut line).unwrap();
7786                let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
7787                let mut content_length = 0usize;
7788                loop {
7789                    line.clear();
7790                    reader.read_line(&mut line).unwrap();
7791                    if line == "\r\n" || line == "\n" || line.is_empty() {
7792                        break;
7793                    }
7794                    if let Some((name, value)) = line.split_once(':') {
7795                        if name.eq_ignore_ascii_case("content-length") {
7796                            content_length = value.trim().parse().unwrap();
7797                        }
7798                    }
7799                }
7800                let mut request_body = vec![0_u8; content_length];
7801                reader.read_exact(&mut request_body).unwrap();
7802                let (status, body) = respond(&path);
7803                let response = format!(
7804                    "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
7805                    body.len()
7806                );
7807                reader.get_mut().write_all(response.as_bytes()).unwrap();
7808            }
7809        });
7810        (url, handle)
7811    }
7812
7813    fn test_hub_config(hub: String, state_dir: PathBuf) -> HubConfig {
7814        HubConfig {
7815            hub,
7816            key: Some("test-key".to_string()),
7817            agent_key: None,
7818            brain_key: None,
7819            state_dir,
7820            store_selected: false,
7821        }
7822    }
7823
7824    #[test]
7825    fn linkmd_sig_v2_is_bound_to_the_exact_hub_origin() {
7826        use ring::signature::KeyPair as _;
7827
7828        let rng = ring::rand::SystemRandom::new();
7829        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
7830        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
7831        let (spki, multikey) = public_identity_for(&pair);
7832        let key = AgentSigningKey {
7833            pkcs8: pkcs8.as_ref().to_vec(),
7834            multikey,
7835            public_key_spki: spki,
7836        };
7837        let header = linkmd_sig_header(
7838            &key,
7839            "https://hub-a.example",
7840            "post",
7841            "/api/hub/brains/brain/push?mode=exact",
7842            Some("{\"ok\":true}"),
7843        )
7844        .unwrap();
7845        assert!(header.starts_with("LinkMD-Sig v2,"));
7846        let ts = header
7847            .split(",ts=")
7848            .nth(1)
7849            .unwrap()
7850            .split(',')
7851            .next()
7852            .unwrap();
7853        let signature = URL_SAFE_NO_PAD
7854            .decode(header.rsplit(",sig=").next().unwrap())
7855            .unwrap();
7856        let body_hash = format!("{:x}", Sha256::digest(b"{\"ok\":true}"));
7857        let accepted = format!(
7858            "v2\nhttps://hub-a.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
7859        );
7860        let replayed = format!(
7861            "v2\nhttps://hub-b.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
7862        );
7863        let public = pair.public_key().as_ref();
7864        assert!(UnparsedPublicKey::new(&ED25519, public)
7865            .verify(accepted.as_bytes(), &signature)
7866            .is_ok());
7867        assert!(
7868            UnparsedPublicKey::new(&ED25519, public)
7869                .verify(replayed.as_bytes(), &signature)
7870                .is_err(),
7871            "a proof captured at hub A must not authenticate at hub B"
7872        );
7873    }
7874
7875    #[test]
7876    fn explicit_brain_id_rejects_a_substituted_card_before_feed_trust() {
7877        let other = "01j5qc3v9k4ym8rwbn2tqe6f7e";
7878        let card = json!({
7879            "id": other,
7880            "headSeq": 0,
7881            "identity": signed_remote_fixture().identity,
7882        })
7883        .to_string();
7884        let (hub, server) = scripted_json_hub(vec![(200, card)]);
7885        let state = tempfile::tempdir().unwrap();
7886        let cfg = test_hub_config(hub, state.path().to_path_buf());
7887        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
7888        assert!(
7889            error.contains("differs from the explicitly requested"),
7890            "{error}"
7891        );
7892        server.join().unwrap();
7893    }
7894
7895    #[test]
7896    fn empty_brain_identity_is_pinned_and_cannot_be_substituted_later() {
7897        let first = signed_remote_fixture().identity;
7898        let second = signed_remote_fixture().identity;
7899        let card = |identity: FeedIdentity| {
7900            json!({
7901                "id": TEST_BRAIN_ID,
7902                "headSeq": 0,
7903                "identity": identity,
7904            })
7905            .to_string()
7906        };
7907        let (hub, server) = scripted_json_hub(vec![(200, card(first)), (200, card(second))]);
7908        let state = tempfile::tempdir().unwrap();
7909        let cfg = test_hub_config(hub, state.path().to_path_buf());
7910        assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
7911        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
7912        assert!(
7913            error.contains("pinned anchor") || error.contains("forked away"),
7914            "{error}"
7915        );
7916        server.join().unwrap();
7917    }
7918
7919    #[test]
7920    fn empty_head_rejects_a_rotation_that_commits_to_hidden_history() {
7921        let old = signed_remote_fixture();
7922        let new = signed_remote_fixture();
7923        let old_pair = ring::signature::Ed25519KeyPair::from_pkcs8(&old.key.pkcs8).unwrap();
7924        let unsigned = serde_json::to_string(&UnsignedRotation {
7925            v: 1,
7926            op: "rotate",
7927            brain: &old.key.multikey,
7928            public_key: &old.key.public_key_spki,
7929            new_brain: &new.key.multikey,
7930            new_public_key: &new.key.public_key_spki,
7931            prior_head_seq: 1,
7932            prior_feed_hash: Some(&"a".repeat(64)),
7933            ts: "2026-07-30T12:00:00.000Z".to_string(),
7934        })
7935        .unwrap();
7936        let signature = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
7937        let rotation = format!(
7938            "{},\"sig\":\"{}\"}}",
7939            &unsigned[..unsigned.len() - 1],
7940            signature
7941        );
7942        let identity = FeedIdentity {
7943            fingerprint: new.key.multikey.trim_start_matches("ed25519:").to_string(),
7944            public_key_spki: new.key.public_key_spki,
7945            previous: vec![PreviousIdentity {
7946                fingerprint: old.key.multikey.trim_start_matches("ed25519:").to_string(),
7947                public_key_spki: old.key.public_key_spki,
7948            }],
7949            rotations: vec![rotation],
7950        };
7951        let card = json!({
7952            "id": TEST_BRAIN_ID,
7953            "headSeq": 0,
7954            "feedHash": null,
7955            "identity": identity,
7956        })
7957        .to_string();
7958        let (hub, server) = scripted_json_hub(vec![(200, card)]);
7959        let state = tempfile::tempdir().unwrap();
7960        let cfg = test_hub_config(hub, state.path().to_path_buf());
7961        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
7962        assert!(
7963            error.contains("rotation claims a feed boundary beyond the advertised head"),
7964            "{error}"
7965        );
7966        assert!(
7967            load_trust(&cfg, TEST_BRAIN_ID).unwrap().is_none(),
7968            "an inconsistent empty-head identity must not become the TOFU checkpoint"
7969        );
7970        server.join().unwrap();
7971    }
7972
7973    #[test]
7974    fn trust_checkpoint_rejects_a_later_fork() {
7975        let fixture = signed_remote_fixture();
7976        let mut fork: Value = serde_json::from_str(&fixture.card).unwrap();
7977        fork["feedHash"] = Value::String("b".repeat(64));
7978        let (hub, server) = scripted_json_hub(vec![
7979            (200, fixture.card),
7980            (200, fixture.feed),
7981            (200, fork.to_string()),
7982        ]);
7983        let state = tempfile::tempdir().unwrap();
7984        let cfg = test_hub_config(hub, state.path().to_path_buf());
7985        assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
7986        assert!(head(&cfg, TEST_BRAIN_ID).is_err());
7987        server.join().unwrap();
7988    }
7989
7990    #[test]
7991    fn alias_and_canonical_id_share_one_identity_checkpoint() {
7992        let trusted = signed_remote_fixture();
7993        let attacker = signed_remote_fixture();
7994        let (hub, server) = scripted_json_hub(vec![
7995            (200, trusted.card),
7996            (200, trusted.feed),
7997            (200, attacker.card),
7998        ]);
7999        let state = tempfile::tempdir().unwrap();
8000        let cfg = test_hub_config(hub, state.path().to_path_buf());
8001        assert!(head(&cfg, "trusted-slug").unwrap().verified);
8002        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
8003        assert!(
8004            error.contains("equivocation")
8005                || error.contains("pinned")
8006                || error.contains("identity"),
8007            "{error}"
8008        );
8009        server.join().unwrap();
8010    }
8011
8012    #[test]
8013    fn concurrent_aliases_cannot_establish_conflicting_tofu_pins() {
8014        let alpha = signed_remote_fixture();
8015        let beta = signed_remote_fixture();
8016        let alpha_card = alpha.card.clone();
8017        let alpha_feed = alpha.feed.clone();
8018        let beta_card = beta.card.clone();
8019        let beta_feed = beta.feed.clone();
8020        let (hub, server) = routed_json_hub(3, move |path| {
8021            if path.contains("/alpha/feed?") {
8022                (200, alpha_feed.clone())
8023            } else if path.contains("/beta/feed?") {
8024                (200, beta_feed.clone())
8025            } else if path.ends_with("/alpha") {
8026                (200, alpha_card.clone())
8027            } else if path.ends_with("/beta") {
8028                (200, beta_card.clone())
8029            } else {
8030                (500, r#"{"error":"unexpected path"}"#.to_string())
8031            }
8032        });
8033        let state = tempfile::tempdir().unwrap();
8034        let cfg = test_hub_config(hub, state.path().to_path_buf());
8035        let alpha_cfg = cfg.clone();
8036        let beta_cfg = cfg;
8037        let first = std::thread::spawn(move || head(&alpha_cfg, "alpha"));
8038        let second = std::thread::spawn(move || head(&beta_cfg, "beta"));
8039        let results = [first.join().unwrap(), second.join().unwrap()];
8040        assert_eq!(
8041            results.iter().filter(|result| result.is_ok()).count(),
8042            1,
8043            "only one alias identity may establish canonical TOFU: {results:?}"
8044        );
8045        assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
8046        server.join().unwrap();
8047    }
8048
8049    #[cfg(unix)]
8050    #[test]
8051    fn trust_transaction_survives_an_ancestor_swap_without_writing_outside() {
8052        use std::os::unix::fs::symlink;
8053
8054        let fixture = signed_remote_fixture();
8055        let card = json!({
8056            "id": TEST_BRAIN_ID,
8057            "headSeq": 0,
8058            "feedHash": Value::Null,
8059            "identity": fixture.identity,
8060        })
8061        .to_string();
8062        let work = tempfile::tempdir().unwrap();
8063        let outside = tempfile::tempdir().unwrap();
8064        let state = work.path().join("state");
8065        let moved = work.path().join("state-held");
8066        let swap_state = state.clone();
8067        let swap_moved = moved.clone();
8068        let outside_path = outside.path().to_path_buf();
8069        let (hub, server) = routed_json_hub(1, move |_| {
8070            // The client has already opened state/trust before this response.
8071            std::fs::rename(&swap_state, &swap_moved).unwrap();
8072            symlink(&outside_path, &swap_state).unwrap();
8073            (200, card.clone())
8074        });
8075        let cfg = test_hub_config(hub, state);
8076
8077        let verified = verified_remote_head(&cfg, TEST_BRAIN_ID, false).unwrap();
8078        assert_eq!(verified.head.seq, 0);
8079        assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
8080        assert!(std::fs::read_dir(moved.join("trust"))
8081            .unwrap()
8082            .flatten()
8083            .any(|entry| entry.path().extension().is_some_and(|ext| ext == "json")));
8084        server.join().unwrap();
8085    }
8086
8087    #[test]
8088    fn self_custody_push_refuses_an_unrelated_key_before_requesting_an_upload() {
8089        let remote = signed_remote_fixture();
8090        let unrelated = signed_remote_fixture().key;
8091        let (hub, server) = scripted_json_hub(vec![(200, remote.card), (200, remote.feed)]);
8092        let state = tempfile::tempdir().unwrap();
8093        let mut cfg = test_hub_config(hub, state.path().to_path_buf());
8094        cfg.brain_key = Some(unrelated);
8095        let error = sync_push(
8096            &cfg,
8097            TEST_BRAIN_ID,
8098            &[("DB.md".to_string(), "signed local content".to_string())],
8099        )
8100        .unwrap_err()
8101        .to_string();
8102        assert!(
8103            error.contains("not the verified current brain identity"),
8104            "{error}"
8105        );
8106        server.join().unwrap();
8107    }
8108
8109    #[test]
8110    fn rotation_ignores_a_forged_2xx_body_and_requires_verified_postcondition() {
8111        let remote = signed_remote_fixture();
8112        let new = signed_remote_fixture().key;
8113        let state = tempfile::tempdir().unwrap();
8114        let new_file = state.path().join("new.key");
8115        std::fs::write(
8116            &new_file,
8117            format!("{}\n", URL_SAFE_NO_PAD.encode(&new.pkcs8)),
8118        )
8119        .unwrap();
8120        #[cfg(unix)]
8121        {
8122            use std::os::unix::fs::PermissionsExt as _;
8123            std::fs::set_permissions(&new_file, std::fs::Permissions::from_mode(0o600)).unwrap();
8124        }
8125        let forged = json!({
8126            "brain": TEST_BRAIN_ID,
8127            "identity": {
8128                "fingerprint": new.multikey.trim_start_matches("ed25519:"),
8129                "publicKeySpki": new.public_key_spki,
8130            }
8131        })
8132        .to_string();
8133        let (hub, server) = scripted_json_hub(vec![
8134            (200, remote.card.clone()),
8135            (200, remote.feed.clone()),
8136            (200, forged),
8137            (200, remote.card),
8138            (200, remote.feed),
8139        ]);
8140        let cfg = test_hub_config(hub, state.path().to_path_buf());
8141        let error = rotate_brain_key(&cfg, TEST_BRAIN_ID, &remote.key, &new_file)
8142            .unwrap_err()
8143            .to_string();
8144        assert!(
8145            error.contains("without committing the verified new identity"),
8146            "{error}"
8147        );
8148        server.join().unwrap();
8149    }
8150
8151    #[test]
8152    fn resolve_record_is_derived_from_signed_pack_not_a_query_response() {
8153        let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
8154        let raw = format!(
8155            "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
8156        );
8157        let pack = build_store_pack(&[
8158            (
8159                "DB.md".to_string(),
8160                "---\ntype: db-md\nscope: company\nowner: Test\n---\n".to_string(),
8161            ),
8162            ("records/clients/truth.md".to_string(), raw.clone()),
8163        ])
8164        .unwrap();
8165        let by_id = resolve_from_verified_pack(
8166            "01j5qc3v9k4ym8rwbn2tqe6f7d",
8167            &AddressTarget::Id(record_id.to_string()),
8168            pack.clone(),
8169        )
8170        .unwrap();
8171        assert_eq!(by_id["document"]["summary"], "Signed truth");
8172        assert_eq!(by_id["document"]["body"], "# Signed truth\n");
8173        assert_eq!(
8174            by_id["document"]["contentSha"],
8175            content_sha256(raw.as_bytes())
8176        );
8177
8178        let by_path = resolve_from_verified_pack(
8179            "01j5qc3v9k4ym8rwbn2tqe6f7d",
8180            &AddressTarget::Path("records/clients/truth.md".to_string()),
8181            pack,
8182        )
8183        .unwrap();
8184        assert_eq!(by_path["document"]["id"], record_id);
8185        assert_eq!(by_path["document"]["path"], "records/clients/truth.md");
8186    }
8187
8188    #[test]
8189    fn canonical_store_pack_matches_the_cross_language_zip32_golden() {
8190        let unsorted = vec![
8191            ("records/a.md".to_string(), "alpha\n".to_string()),
8192            ("DB.md".to_string(), "# db\n".to_string()),
8193        ];
8194        let sorted = vec![
8195            ("DB.md".to_string(), "# db\n".to_string()),
8196            ("records/a.md".to_string(), "alpha\n".to_string()),
8197        ];
8198        let pack = build_store_pack(&unsorted).unwrap();
8199
8200        // This digest is shared with the hub implementation. It locks every
8201        // byte of the wire profile: raw UTF-8 order, STORED payloads, fixed DOS
8202        // epoch, explicit CRC/sizes, Unix regular-0600 attributes, and the
8203        // absence of descriptors/extras/comments/ZIP64.
8204        assert_eq!(pack.len(), 219);
8205        assert_eq!(
8206            content_sha256(&pack),
8207            "972fb2045becaa21588baaf4b349e62a430687fa2c21167b53f4ca0efa6c9408"
8208        );
8209        assert_eq!(pack, build_store_pack(&sorted).unwrap());
8210        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x07\x08"));
8211        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x06"));
8212        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x07"));
8213
8214        assert_eq!(
8215            parse_store_pack(pack).unwrap(),
8216            vec![
8217                ("DB.md".to_string(), b"# db\n".to_vec()),
8218                ("records/a.md".to_string(), b"alpha\n".to_vec()),
8219            ]
8220        );
8221    }
8222
8223    #[test]
8224    fn canonical_store_pack_validates_every_path_before_writing() {
8225        let duplicate = vec![
8226            ("DB.md".to_string(), "first".to_string()),
8227            ("DB.md".to_string(), "second".to_string()),
8228        ];
8229        assert!(build_store_pack(&duplicate)
8230            .unwrap_err()
8231            .to_string()
8232            .contains("duplicate path"));
8233        assert!(matches!(
8234            build_store_pack(&[("../escape.md".to_string(), "no".to_string())]),
8235            Err(LinkError::UnsafePath { .. })
8236        ));
8237    }
8238
8239    #[test]
8240    fn zip64_preflight_rejects_an_entry_count_bomb_before_zip_parsing() {
8241        const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
8242        // One byte stands in for the central directory. The trailer itself is
8243        // structurally valid; the count is the sole reason for refusal.
8244        let mut bytes = vec![0_u8];
8245        let zip64_offset = bytes.len() as u64;
8246        bytes.extend_from_slice(b"PK\x06\x06");
8247        bytes.extend_from_slice(&44_u64.to_le_bytes());
8248        bytes.extend_from_slice(&[0_u8; 12]);
8249        bytes.extend_from_slice(&COUNT.to_le_bytes());
8250        bytes.extend_from_slice(&COUNT.to_le_bytes());
8251        bytes.extend_from_slice(&1_u64.to_le_bytes());
8252        bytes.extend_from_slice(&0_u64.to_le_bytes());
8253        bytes.extend_from_slice(b"PK\x06\x07");
8254        bytes.extend_from_slice(&0_u32.to_le_bytes());
8255        bytes.extend_from_slice(&zip64_offset.to_le_bytes());
8256        bytes.extend_from_slice(&1_u32.to_le_bytes());
8257        bytes.extend_from_slice(b"PK\x05\x06");
8258        bytes.extend_from_slice(&0_u16.to_le_bytes());
8259        bytes.extend_from_slice(&0_u16.to_le_bytes());
8260        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
8261        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
8262        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
8263        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
8264        bytes.extend_from_slice(&0_u16.to_le_bytes());
8265
8266        let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
8267            .unwrap_err()
8268            .to_string();
8269        assert!(error.contains("invalid file count"), "{error}");
8270    }
8271
8272    #[test]
8273    fn zip_preflight_rejects_a_fake_trailing_eocd_before_zip_parsing() {
8274        const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
8275        let mut bytes = vec![0_u8];
8276        let zip64_offset = bytes.len() as u64;
8277        bytes.extend_from_slice(b"PK\x06\x06");
8278        bytes.extend_from_slice(&44_u64.to_le_bytes());
8279        bytes.extend_from_slice(&[0_u8; 12]);
8280        bytes.extend_from_slice(&COUNT.to_le_bytes());
8281        bytes.extend_from_slice(&COUNT.to_le_bytes());
8282        bytes.extend_from_slice(&1_u64.to_le_bytes());
8283        bytes.extend_from_slice(&0_u64.to_le_bytes());
8284        bytes.extend_from_slice(b"PK\x06\x07");
8285        bytes.extend_from_slice(&0_u32.to_le_bytes());
8286        bytes.extend_from_slice(&zip64_offset.to_le_bytes());
8287        bytes.extend_from_slice(&1_u32.to_le_bytes());
8288        bytes.extend_from_slice(b"PK\x05\x06");
8289        bytes.extend_from_slice(&0_u16.to_le_bytes());
8290        bytes.extend_from_slice(&0_u16.to_le_bytes());
8291        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
8292        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
8293        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
8294        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
8295        bytes.extend_from_slice(&0_u16.to_le_bytes());
8296        // The old parser trusted this last low-count signature, while
8297        // ZipArchive fell back to the real Zip64 directory and allocated for
8298        // COUNT entries. Its central-directory offsets are deliberately fake.
8299        let fake_eocd = bytes.len() as u32;
8300        bytes.extend_from_slice(b"PK\x05\x06");
8301        bytes.extend_from_slice(&0_u16.to_le_bytes());
8302        bytes.extend_from_slice(&0_u16.to_le_bytes());
8303        bytes.extend_from_slice(&1_u16.to_le_bytes());
8304        bytes.extend_from_slice(&1_u16.to_le_bytes());
8305        bytes.extend_from_slice(&0_u32.to_le_bytes());
8306        bytes.extend_from_slice(&fake_eocd.to_le_bytes());
8307        bytes.extend_from_slice(&0_u16.to_le_bytes());
8308
8309        let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
8310            .unwrap_err()
8311            .to_string();
8312        assert!(error.contains("central directory"), "{error}");
8313    }
8314
8315    #[test]
8316    fn strict_http_status_handling_rejects_redirects_without_panicking() {
8317        let error = ensure_ok(
8318            HubResponse {
8319                status: 302,
8320                body: Some(json!({"redirect": "/elsewhere"})),
8321            },
8322            "mutation",
8323        )
8324        .unwrap_err();
8325        assert!(matches!(error, LinkError::Http { status: 302, .. }));
8326
8327        let error = ensure_raw_ok(
8328            RawHubResponse {
8329                status: 302,
8330                body: br#"{"redirect":"/elsewhere"}"#.to_vec(),
8331            },
8332            "feed",
8333        )
8334        .unwrap_err();
8335        assert!(matches!(error, LinkError::Http { status: 302, .. }));
8336    }
8337
8338    #[cfg(unix)]
8339    #[test]
8340    fn collect_push_files_refuses_external_symlink_and_nested_store() {
8341        use std::os::unix::fs::symlink;
8342
8343        let root = tempfile::tempdir().unwrap();
8344        std::fs::write(
8345            root.path().join("DB.md"),
8346            "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
8347        )
8348        .unwrap();
8349        std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
8350
8351        let external = tempfile::tempdir().unwrap();
8352        let secret = external.path().join("secret.md");
8353        std::fs::write(&secret, "TOP SECRET").unwrap();
8354        symlink(&secret, root.path().join("records/notes/secret.md")).unwrap();
8355
8356        let store = Store::open_strict(root.path()).unwrap();
8357        let err = collect_push_files(&store).unwrap_err().to_string();
8358        assert!(err.contains("cannot push"), "{err}");
8359        assert!(
8360            !err.contains("TOP SECRET"),
8361            "external bytes must never leak"
8362        );
8363
8364        std::fs::remove_file(root.path().join("records/notes/secret.md")).unwrap();
8365        let nested = root.path().join("records/nested");
8366        std::fs::create_dir_all(&nested).unwrap();
8367        std::fs::write(
8368            nested.join("DB.md"),
8369            "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
8370        )
8371        .unwrap();
8372        let err = collect_push_files(&store).unwrap_err().to_string();
8373        assert!(err.contains("nested db.md store"), "{err}");
8374    }
8375
8376    #[cfg(unix)]
8377    #[test]
8378    fn remote_push_uses_opened_root_after_path_replacement() {
8379        use std::os::unix::fs::symlink;
8380
8381        let sandbox = tempfile::tempdir().unwrap();
8382        let root = sandbox.path().join("store");
8383        std::fs::create_dir_all(root.join("records/notes")).unwrap();
8384        std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
8385        std::fs::write(
8386            root.join("records/notes/owned.md"),
8387            "---\ntype: note\nsummary: owned\n---\nowned upload\n",
8388        )
8389        .unwrap();
8390        let store = Store::open_strict(&root).unwrap();
8391        let detached = sandbox.path().join("detached");
8392        std::fs::rename(&root, &detached).unwrap();
8393
8394        let replacement = sandbox.path().join("replacement");
8395        std::fs::create_dir_all(replacement.join("records/notes")).unwrap();
8396        std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
8397        std::fs::write(
8398            replacement.join("records/notes/secret.md"),
8399            "---\ntype: note\nsummary: secret\n---\nreplacement sentinel\n",
8400        )
8401        .unwrap();
8402        symlink(&replacement, &root).unwrap();
8403
8404        let files = collect_push_files(&store).unwrap();
8405        let wire_text = files
8406            .iter()
8407            .map(|(path, content)| format!("{path}\n{content}"))
8408            .collect::<Vec<_>>()
8409            .join("\n");
8410        assert!(wire_text.contains("owned upload"));
8411        assert!(!wire_text.contains("replacement sentinel"));
8412        assert!(!wire_text.contains("records/notes/secret.md"));
8413
8414        let remote = signed_remote_fixture();
8415        let (hub, server) = scripted_json_hub(vec![
8416            (200, remote.card),
8417            (200, remote.feed),
8418            (200, json!({"ok": true}).to_string()),
8419        ]);
8420        let state = tempfile::tempdir().unwrap();
8421        let cfg = test_hub_config(hub, state.path().to_path_buf());
8422        let pushed = sync_push(&cfg, TEST_BRAIN_ID, &files).unwrap();
8423        assert_eq!(pushed, json!({"ok": true}));
8424        server.join().unwrap();
8425    }
8426
8427    #[test]
8428    fn signed_feed_item_verifies_identity_hash_and_signature() {
8429        use ring::rand::SystemRandom;
8430        use ring::signature::{Ed25519KeyPair, KeyPair};
8431
8432        const PREFIX: &[u8] = &[
8433            0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
8434        ];
8435        let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
8436        let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
8437        let mut spki = PREFIX.to_vec();
8438        spki.extend_from_slice(pair.public_key().as_ref());
8439        let public_key = URL_SAFE_NO_PAD.encode(&spki);
8440        let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
8441        let mut entry = FeedEntry {
8442            v: 1,
8443            seq: 1,
8444            ts: "2026-07-14T00:00:00.000Z".to_string(),
8445            brain: format!("ed25519:{fingerprint}"),
8446            public_key: public_key.clone(),
8447            kind: "push".to_string(),
8448            op: "snapshot".to_string(),
8449            pack_sha256: "a".repeat(64),
8450            files: vec![FeedFile {
8451                path: "DB.md".to_string(),
8452                sha256: "b".repeat(64),
8453                bytes: 3,
8454            }],
8455            removed: vec![],
8456            prev_entry_hash: None,
8457            sig: String::new(),
8458        };
8459        let unsigned = UnsignedFeedEntry {
8460            v: entry.v,
8461            seq: entry.seq,
8462            ts: &entry.ts,
8463            brain: &entry.brain,
8464            public_key: &entry.public_key,
8465            kind: &entry.kind,
8466            op: &entry.op,
8467            pack_sha256: &entry.pack_sha256,
8468            files: &entry.files,
8469            removed: &entry.removed,
8470            prev_entry_hash: &entry.prev_entry_hash,
8471        };
8472        entry.sig =
8473            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
8474        let mut exact = serde_json::to_vec(&entry).unwrap();
8475        exact.push(b'\n');
8476        let item = FeedItem {
8477            hash: format!("{:x}", Sha256::digest(&exact)),
8478            entry,
8479        };
8480        let identity = FeedIdentity {
8481            fingerprint,
8482            public_key_spki: public_key,
8483            previous: Vec::new(),
8484            rotations: Vec::new(),
8485        };
8486        assert!(verify_feed_item(&item, &identity).is_ok());
8487        let mut tampered = item;
8488        tampered.entry.pack_sha256 = "c".repeat(64);
8489        assert!(verify_feed_item(&tampered, &identity).is_err());
8490    }
8491
8492    #[test]
8493    fn a_self_custody_entry_verifies_like_any_hub_entry() {
8494        let rng = ring::rand::SystemRandom::new();
8495        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
8496        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
8497        let (spki, multikey) = public_identity_for(&pair);
8498        let key = AgentSigningKey {
8499            pkcs8: pkcs8.as_ref().to_vec(),
8500            multikey: multikey.clone(),
8501            public_key_spki: spki.clone(),
8502        };
8503        let files = vec![WireFeedFile {
8504            path: "DB.md".to_string(),
8505            sha256: "a".repeat(64),
8506            bytes: 3,
8507        }];
8508        let raw = self_custody_entry(
8509            &key,
8510            1,
8511            "2026-07-23T12:00:00.000Z".to_string(),
8512            &"c".repeat(64),
8513            &files,
8514            None,
8515        )
8516        .unwrap();
8517        // The exact client serialization parses as a feed entry and passes the
8518        // SAME verifier every subscribe read runs — the self-custody path
8519        // produces first-class wire-profile-v1 entries.
8520        let entry: FeedEntry = serde_json::from_str(&raw).unwrap();
8521        let hash = format!("{:x}", Sha256::digest(format!("{raw}\n").as_bytes()));
8522        let item = FeedItem { hash, entry };
8523        let identity = FeedIdentity {
8524            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
8525            public_key_spki: spki,
8526            previous: Vec::new(),
8527            rotations: Vec::new(),
8528        };
8529        assert!(verify_feed_item(&item, &identity).is_ok());
8530    }
8531
8532    #[test]
8533    fn identity_rotation_requires_old_key_signature_and_preserves_the_pin() {
8534        let rng = ring::rand::SystemRandom::new();
8535        let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
8536        let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
8537        let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
8538        let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
8539        let (old_spki, old_multikey) = public_identity_for(&old);
8540        let (new_spki, new_multikey) = public_identity_for(&new);
8541        let unsigned = serde_json::to_string(&UnsignedRotation {
8542            v: 1,
8543            op: "rotate",
8544            brain: &old_multikey,
8545            public_key: &old_spki,
8546            new_brain: &new_multikey,
8547            new_public_key: &new_spki,
8548            prior_head_seq: 1,
8549            prior_feed_hash: Some(&"a".repeat(64)),
8550            ts: "2026-07-30T12:00:00.000Z".to_string(),
8551        })
8552        .unwrap();
8553        let signature = URL_SAFE_NO_PAD.encode(old.sign(unsigned.as_bytes()).as_ref());
8554        let rotation = format!(
8555            "{},\"sig\":\"{}\"}}",
8556            &unsigned[..unsigned.len() - 1],
8557            signature
8558        );
8559        let identity = FeedIdentity {
8560            fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
8561            public_key_spki: new_spki,
8562            previous: vec![PreviousIdentity {
8563                fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
8564                public_key_spki: old_spki,
8565            }],
8566            rotations: vec![rotation],
8567        };
8568        let pin = TrustState {
8569            v: 2,
8570            origin: "https://hub.example".to_string(),
8571            requested: "brain".to_string(),
8572            brain: "brain".to_string(),
8573            home: None,
8574            anchor: old_multikey.clone(),
8575            current: old_multikey.clone(),
8576            head_seq: 1,
8577            feed_hash: Some("a".repeat(64)),
8578            rotations: Vec::new(),
8579            hub_signer: None,
8580            protocol_profile: None,
8581        };
8582        assert_eq!(
8583            verify_identity_chain(&identity, Some(&pin)).unwrap(),
8584            old_multikey
8585        );
8586        let mut accepted = pin.clone();
8587        accepted.current = new_multikey.clone();
8588        accepted.rotations = identity.rotations.clone();
8589        let alternate_unsigned = serde_json::to_string(&UnsignedRotation {
8590            v: 1,
8591            op: "rotate",
8592            brain: &old_multikey,
8593            public_key: &identity.previous[0].public_key_spki,
8594            new_brain: &new_multikey,
8595            new_public_key: &identity.public_key_spki,
8596            prior_head_seq: 1,
8597            prior_feed_hash: Some(&"a".repeat(64)),
8598            ts: "2026-07-30T12:00:01.000Z".to_string(),
8599        })
8600        .unwrap();
8601        let alternate_signature =
8602            URL_SAFE_NO_PAD.encode(old.sign(alternate_unsigned.as_bytes()).as_ref());
8603        let mut rewritten = identity.clone();
8604        rewritten.rotations[0] = format!(
8605            "{},\"sig\":\"{}\"}}",
8606            &alternate_unsigned[..alternate_unsigned.len() - 1],
8607            alternate_signature
8608        );
8609        assert!(
8610            verify_identity_chain(&rewritten, Some(&accepted)).is_err(),
8611            "an alternate valid statement must not rewrite accepted history"
8612        );
8613
8614        let mut stale_entry = FeedEntry {
8615            v: 1,
8616            seq: 2,
8617            ts: "2026-07-30T12:01:00.000Z".to_string(),
8618            brain: pin.current.clone(),
8619            public_key: identity.previous[0].public_key_spki.clone(),
8620            kind: "push".to_string(),
8621            op: "snapshot".to_string(),
8622            pack_sha256: "b".repeat(64),
8623            files: Vec::new(),
8624            removed: Vec::new(),
8625            prev_entry_hash: pin.feed_hash.clone(),
8626            sig: String::new(),
8627        };
8628        let stale_unsigned = UnsignedFeedEntry {
8629            v: stale_entry.v,
8630            seq: stale_entry.seq,
8631            ts: &stale_entry.ts,
8632            brain: &stale_entry.brain,
8633            public_key: &stale_entry.public_key,
8634            kind: &stale_entry.kind,
8635            op: &stale_entry.op,
8636            pack_sha256: &stale_entry.pack_sha256,
8637            files: &stale_entry.files,
8638            removed: &stale_entry.removed,
8639            prev_entry_hash: &stale_entry.prev_entry_hash,
8640        };
8641        stale_entry.sig = URL_SAFE_NO_PAD.encode(
8642            old.sign(&serde_json::to_vec(&stale_unsigned).unwrap())
8643                .as_ref(),
8644        );
8645        let mut stale_exact = serde_json::to_vec(&stale_entry).unwrap();
8646        stale_exact.push(b'\n');
8647        let stale_item = FeedItem {
8648            hash: content_sha256(&stale_exact),
8649            entry: stale_entry,
8650        };
8651        assert!(
8652            reject_retired_signer_after_checkpoint(&identity, Some(&accepted), &stale_item)
8653                .is_err(),
8654            "a key retired before the checkpoint must never append after it"
8655        );
8656        assert!(
8657            verify_feed_item(&stale_item, &identity).is_err(),
8658            "an old key must never append after its signed rotation boundary"
8659        );
8660
8661        let mut missing = identity.clone();
8662        missing.rotations.clear();
8663        assert!(verify_identity_chain(&missing, Some(&pin)).is_err());
8664
8665        let mut tampered = identity;
8666        tampered.rotations[0] = tampered.rotations[0].replace("\"sig\":\"", "\"sig\":\"A");
8667        assert!(verify_identity_chain(&tampered, Some(&pin)).is_err());
8668    }
8669
8670    #[cfg(unix)]
8671    #[test]
8672    fn key_creation_refuses_a_planted_symlink_without_touching_its_target() {
8673        use std::os::unix::fs::symlink;
8674
8675        let dir = tempfile::tempdir().unwrap();
8676        let target = dir.path().join("valuable.txt");
8677        let planted = dir.path().join("agent.key");
8678        std::fs::write(&target, "do not overwrite").unwrap();
8679        symlink(&target, &planted).unwrap();
8680
8681        assert!(matches!(
8682            generate_agent_key(&planted),
8683            Err(LinkError::BadAgentKey { .. })
8684        ));
8685        assert_eq!(std::fs::read_to_string(target).unwrap(), "do not overwrite");
8686    }
8687
8688    #[cfg(unix)]
8689    #[test]
8690    fn key_creation_refuses_a_symlinked_parent_without_writing_through_it() {
8691        use std::os::unix::fs::symlink;
8692
8693        let root = tempfile::tempdir().unwrap();
8694        let outside = tempfile::tempdir().unwrap();
8695        symlink(outside.path(), root.path().join("redirect")).unwrap();
8696
8697        assert!(generate_agent_key(&root.path().join("redirect/agent.key")).is_err());
8698        assert!(!outside.path().join("agent.key").exists());
8699    }
8700
8701    // ── Address parsing ─────────────────────────────────────────────────────
8702
8703    #[test]
8704    fn address_bare_brain_with_and_without_sigil() {
8705        for raw in ["@acme-ops", "acme-ops"] {
8706            let a = Address::parse(raw).expect(raw);
8707            assert_eq!(a.brain, "acme-ops");
8708            assert_eq!(a.target, None);
8709        }
8710    }
8711
8712    #[test]
8713    fn address_ulid_target_parses_as_id() {
8714        let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
8715        assert_eq!(a.brain, "acme");
8716        assert_eq!(
8717            a.target,
8718            Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
8719        );
8720    }
8721
8722    #[test]
8723    fn address_md_path_target_parses_as_path() {
8724        let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
8725        assert_eq!(
8726            a.target,
8727            Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
8728        );
8729    }
8730
8731    #[test]
8732    fn address_rejects_malformed_forms() {
8733        for raw in [
8734            "",
8735            "@",
8736            "@/x",
8737            "@acme/",
8738            "@acme/../etc/passwd",
8739            "@acme/records/.hidden.md",
8740            "@ACME",             // uppercase is not a hub ref shape
8741            "@acme/notes/x.txt", // target is neither ULID nor .md path
8742            "@a b",              // whitespace
8743        ] {
8744            assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
8745        }
8746    }
8747
8748    // ── Path safety ─────────────────────────────────────────────────────────
8749
8750    #[test]
8751    fn safe_paths_accept_store_shapes_and_reject_escapes() {
8752        for ok in [
8753            "DB.md",
8754            "assets.jsonl",
8755            "records/clients/lumio.md",
8756            "sources/emails/2026/07/x.md",
8757        ] {
8758            assert!(safe_store_rel_path(ok), "should accept {ok:?}");
8759        }
8760        for bad in [
8761            "",
8762            "/etc/passwd",
8763            "../up.md",
8764            "records/../../up.md",
8765            "records//x.md",
8766            ".dbmd/config",
8767            "records/.hidden/x.md",
8768            "records/a b.md",
8769            "records\\win.md",
8770        ] {
8771            assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
8772        }
8773    }
8774
8775    #[cfg(unix)]
8776    #[test]
8777    fn opened_destination_capability_survives_an_ancestor_path_swap() {
8778        use std::os::unix::fs::symlink;
8779
8780        let work = tempfile::tempdir().unwrap();
8781        let outside = tempfile::tempdir().unwrap();
8782        let original = work.path().join("destination");
8783        let moved = work.path().join("destination-moved");
8784        let directory = open_or_create_dir_nofollow(&original).unwrap();
8785
8786        std::fs::rename(&original, &moved).unwrap();
8787        symlink(outside.path(), &original).unwrap();
8788        write_pull_entries_beneath_dir(
8789            &directory,
8790            &[("records/note.md".to_string(), b"held inode".to_vec())],
8791        )
8792        .unwrap();
8793
8794        assert_eq!(
8795            std::fs::read(moved.join("records/note.md")).unwrap(),
8796            b"held inode"
8797        );
8798        assert!(!outside.path().join("records/note.md").exists());
8799    }
8800
8801    // ── Config resolution (flag + file precedence; env is covered by the CLI
8802    //    integration tests, where a child process isolates it) ───────────────
8803
8804    #[test]
8805    fn hub_config_flag_beats_file_and_requires_some_source() {
8806        let dir = tempfile::tempdir().unwrap();
8807        std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
8808        std::fs::write(
8809            dir.path().join(CONFIG_REL_PATH),
8810            "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
8811        )
8812        .unwrap();
8813
8814        let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
8815        assert_eq!(from_flag.hub, "https://flag.example.com");
8816
8817        let from_file = hub_config(None, dir.path()).unwrap();
8818        assert_eq!(from_file.hub, "https://file.example.com");
8819
8820        let none = hub_config(None, tempfile::tempdir().unwrap().path());
8821        assert!(matches!(none, Err(LinkError::NoHub)));
8822    }
8823
8824    #[test]
8825    fn https_guard_allows_loopback_only_for_plain_http() {
8826        assert!(assert_safe_hub("https://hub.example.com").is_ok());
8827        assert!(assert_safe_hub("http://localhost:3000").is_ok());
8828        assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
8829        assert!(assert_safe_hub("http://[::1]:3000").is_ok());
8830        assert!(matches!(
8831            assert_safe_hub("http://hub.example.com"),
8832            Err(LinkError::UnsafeHub { .. })
8833        ));
8834        assert!(matches!(
8835            assert_safe_hub("hub.example.com"),
8836            Err(LinkError::UnsafeHub { .. })
8837        ));
8838        assert!(matches!(
8839            assert_safe_hub("http://localhost:80@127.0.0.1:1"),
8840            Err(LinkError::UnsafeHub { .. })
8841        ));
8842        assert!(matches!(
8843            assert_safe_hub("https://hub.example.com@attacker.example"),
8844            Err(LinkError::UnsafeHub { .. })
8845        ));
8846        assert!(matches!(
8847            assert_safe_hub("https://hub.example.com/base"),
8848            Err(LinkError::UnsafeHub { .. })
8849        ));
8850    }
8851
8852    #[test]
8853    fn registry_ssrf_classifier_rejects_local_private_and_documentation_ranges() {
8854        for blocked in [
8855            "127.0.0.1",
8856            "10.0.0.1",
8857            "100.64.0.1",
8858            "169.254.169.254",
8859            "172.16.0.1",
8860            "192.168.0.1",
8861            "192.88.99.1",
8862            "198.18.0.1",
8863            "203.0.113.1",
8864            "::1",
8865            "fe80::1",
8866            "fd00::1",
8867            "2001:db8::1",
8868            "2001:1::1",
8869            "2002:7f00:1::",
8870            "3fff::1",
8871        ] {
8872            assert!(
8873                !is_public_registry_ip(blocked.parse().unwrap()),
8874                "must block {blocked}"
8875            );
8876        }
8877        assert!(is_public_registry_ip("1.1.1.1".parse().unwrap()));
8878        assert!(is_public_registry_ip(
8879            "2606:4700:4700::1111".parse().unwrap()
8880        ));
8881        assert!(is_public_registry_ip("3fff:1000::1".parse().unwrap()));
8882    }
8883
8884    #[test]
8885    fn registry_dns_answer_is_pinned_and_cannot_rebind_or_change_authority() {
8886        use ureq::Resolver as _;
8887
8888        let pinned: std::net::SocketAddr = "1.1.1.1:443".parse().unwrap();
8889        let resolver = PinnedRegistryResolver {
8890            netloc: "home.example:443".to_string(),
8891            addresses: vec![pinned],
8892        };
8893        assert_eq!(resolver.resolve("home.example:443").unwrap(), vec![pinned]);
8894        assert!(resolver.resolve("127.0.0.1:443").is_err());
8895        assert_eq!(
8896            resolver.resolve("home.example:443").unwrap(),
8897            vec![pinned],
8898            "subsequent connects reuse the validated answer instead of DNS"
8899        );
8900    }
8901
8902    #[test]
8903    fn production_object_urls_and_store_selected_hubs_cannot_reach_private_ips() {
8904        let cfg = HubConfig {
8905            hub: "https://hub.example".to_string(),
8906            key: None,
8907            agent_key: None,
8908            brain_key: None,
8909            state_dir: tempfile::tempdir().unwrap().keep(),
8910            store_selected: false,
8911        };
8912        assert!(
8913            presigned_agent(&cfg, "https://127.0.0.1/private").is_err(),
8914            "a production hub must not turn its presigned URL into an SSRF primitive"
8915        );
8916
8917        let store_selected = HubConfig {
8918            hub: "https://127.0.0.1".to_string(),
8919            store_selected: true,
8920            ..cfg
8921        };
8922        assert!(
8923            hub_agent(&store_selected).is_err(),
8924            "bytes in a cloned store must not select a private-network hub"
8925        );
8926    }
8927
8928    #[test]
8929    fn presigned_download_ceiling_cannot_overflow_at_u64_max() {
8930        assert_eq!(
8931            one_past_bounded_limit(MAX_PACK_BYTES),
8932            Some(MAX_PACK_BYTES + 1),
8933            "the presigned reader consumes exactly one refusal byte beyond its fixed pack cap"
8934        );
8935        assert_eq!(
8936            presigned_download_read_limit(),
8937            MAX_PACK_BYTES + 1,
8938            "the presigned reader is capped by the client constant, not a hub response"
8939        );
8940        assert_eq!(
8941            one_past_bounded_limit(u64::MAX),
8942            None,
8943            "the former attacker-controlled u64::MAX + 1 shape must fail without overflow"
8944        );
8945    }
8946
8947    #[test]
8948    fn https_guard_matches_the_scheme_case_insensitively() {
8949        // RFC 3986 schemes are case-insensitive: an uppercase-scheme HTTPS
8950        // hub is still HTTPS, never a misleading non-HTTPS refusal.
8951        assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
8952        assert!(assert_safe_hub("Https://hub.example.com").is_ok());
8953        // And an uppercase plain-HTTP hub is still refused outside loopback.
8954        assert!(matches!(
8955            assert_safe_hub("HTTP://hub.example.com"),
8956            Err(LinkError::UnsafeHub { .. })
8957        ));
8958    }
8959
8960    #[test]
8961    fn clean_key_refuses_paste_artifacts_without_echoing() {
8962        assert_eq!(clean_key("  vc_account_abc  ").unwrap(), "vc_account_abc");
8963        for bad in ["vc account", "vc\naccount", "ключ", ""] {
8964            let err = clean_key(bad).unwrap_err();
8965            assert!(matches!(err, LinkError::BadKey));
8966            assert!(
8967                !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
8968                "error must not echo the key"
8969            );
8970        }
8971    }
8972
8973    // ── Verb entry gates: refs must never reshape the request path ──────────
8974
8975    /// A config whose hub passes the loopback guard but is never listened on:
8976    /// every refusal below must come from the entry gate BEFORE a request
8977    /// exists — a dial on this dead port would surface `Transport` instead.
8978    fn dead_hub() -> HubConfig {
8979        HubConfig {
8980            hub: "http://127.0.0.1:9".to_string(),
8981            key: Some("k".to_string()),
8982            agent_key: None,
8983            brain_key: None,
8984            state_dir: PathBuf::from("."),
8985            store_selected: false,
8986        }
8987    }
8988
8989    #[test]
8990    fn request_retries_a_connection_failure_before_sending() {
8991        use std::io::{Read as _, Write as _};
8992        use std::net::TcpListener;
8993        use std::thread;
8994        use std::time::Duration;
8995
8996        let probe = TcpListener::bind("127.0.0.1:0").unwrap();
8997        let address = probe.local_addr().unwrap();
8998        drop(probe);
8999        let server = thread::spawn(move || {
9000            thread::sleep(Duration::from_millis(40));
9001            let listener = TcpListener::bind(address).unwrap();
9002            let (mut stream, _) = listener.accept().unwrap();
9003            let mut request_bytes = [0_u8; 1024];
9004            let _ = stream.read(&mut request_bytes).unwrap();
9005            stream
9006                .write_all(
9007                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
9008                )
9009                .unwrap();
9010        });
9011        let cfg = HubConfig {
9012            hub: format!("http://{address}"),
9013            key: None,
9014            agent_key: None,
9015            brain_key: None,
9016            state_dir: tempfile::tempdir().unwrap().keep(),
9017            store_selected: false,
9018        };
9019
9020        let response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
9021        assert_eq!(response.status, 200);
9022        assert_eq!(response.body, Some(json!({ "ok": true })));
9023        server.join().unwrap();
9024    }
9025
9026    #[test]
9027    fn endpoint_cap_refuses_a_body_before_json_parsing() {
9028        let (hub, server) = scripted_json_hub(vec![(200, "x".repeat(2_048))]);
9029        let cfg = HubConfig {
9030            hub,
9031            key: None,
9032            agent_key: None,
9033            brain_key: None,
9034            state_dir: tempfile::tempdir().unwrap().keep(),
9035            store_selected: false,
9036        };
9037
9038        assert!(matches!(
9039            request_capped(&cfg, "GET", "/bounded", None, Auth::None, 1_024),
9040            Err(LinkError::ResponseTooLarge { .. })
9041        ));
9042        server.join().unwrap();
9043    }
9044
9045    #[test]
9046    fn overall_deadline_stops_a_dribbled_response_body() {
9047        use std::io::{Read as _, Write as _};
9048        use std::net::TcpListener;
9049        use std::time::{Duration, Instant};
9050
9051        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
9052        let url = format!("http://{}/dribble", listener.local_addr().unwrap());
9053        let server = std::thread::spawn(move || {
9054            let (mut stream, _) = listener.accept().unwrap();
9055            let mut request = [0_u8; 1024];
9056            let _ = stream.read(&mut request);
9057            stream
9058                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 32\r\nConnection: close\r\n\r\n")
9059                .unwrap();
9060            for byte in [b'x'; 32] {
9061                if stream.write_all(&[byte]).is_err() {
9062                    break;
9063                }
9064                std::thread::sleep(Duration::from_millis(40));
9065            }
9066        });
9067        let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
9068        let started = Instant::now();
9069        let response = http.get(&url).call().unwrap();
9070        let mut body = Vec::new();
9071        let error = response
9072            .into_reader()
9073            .read_to_end(&mut body)
9074            .expect_err("per-read progress must not reset the overall deadline");
9075        assert!(
9076            started.elapsed() < Duration::from_millis(700),
9077            "dribbled body exceeded the wall-clock budget: {error}"
9078        );
9079        server.join().unwrap();
9080    }
9081
9082    #[test]
9083    fn overall_deadline_stops_a_stalled_upload() {
9084        use std::net::TcpListener;
9085        use std::time::{Duration, Instant};
9086
9087        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
9088        let url = format!("http://{}/upload", listener.local_addr().unwrap());
9089        let server = std::thread::spawn(move || {
9090            let (_stream, _) = listener.accept().unwrap();
9091            // Never consume the request body. Once the kernel send buffer fills,
9092            // the client must leave on its absolute write deadline.
9093            std::thread::sleep(Duration::from_millis(600));
9094        });
9095        let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
9096        let body = vec![0x5a; 32 * 1024 * 1024];
9097        let started = Instant::now();
9098        let error = http
9099            .put(&url)
9100            .send_bytes(&body)
9101            .expect_err("stalled request-body writes must time out");
9102        assert!(
9103            started.elapsed() < Duration::from_millis(700),
9104            "stalled upload exceeded the wall-clock budget: {error}"
9105        );
9106        server.join().unwrap();
9107    }
9108
9109    #[test]
9110    fn verb_entry_gates_accept_the_hub_ref_shapes() {
9111        for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
9112            assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
9113            assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
9114            assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
9115        }
9116    }
9117
9118    #[test]
9119    fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
9120        let cfg = dead_hub();
9121        for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
9122            assert!(
9123                matches!(
9124                    sync_pull(&cfg, bad, None),
9125                    Err(LinkError::BadAddress { .. })
9126                ),
9127                "sync_pull must refuse {bad:?}"
9128            );
9129            assert!(
9130                matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
9131                "sync_push must refuse {bad:?}"
9132            );
9133            assert!(
9134                matches!(
9135                    grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
9136                    Err(LinkError::BadAddress { .. })
9137                ),
9138                "grant_issue must refuse {bad:?}"
9139            );
9140            assert!(
9141                matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
9142                "grant_list must refuse {bad:?}"
9143            );
9144            assert!(
9145                matches!(
9146                    grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
9147                    Err(LinkError::BadAddress { .. })
9148                ),
9149                "grant_revoke must refuse brain {bad:?}"
9150            );
9151            assert!(
9152                matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
9153                "head must refuse {bad:?}"
9154            );
9155        }
9156    }
9157
9158    #[test]
9159    fn grant_revoke_refuses_url_reshaping_grant_ids() {
9160        let cfg = dead_hub();
9161        for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
9162            assert!(
9163                matches!(
9164                    grant_revoke(&cfg, "acme", bad),
9165                    Err(LinkError::BadGrantId { .. })
9166                ),
9167                "grant_revoke must refuse grant id {bad:?}"
9168            );
9169        }
9170    }
9171
9172    #[test]
9173    fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
9174        let cfg = dead_hub();
9175        for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
9176            assert!(
9177                matches!(
9178                    propose(&cfg, bad, "intake", "hi"),
9179                    Err(LinkError::BadAddress { .. })
9180                ),
9181                "propose must refuse handle {bad:?}"
9182            );
9183        }
9184        let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
9185        assert!(matches!(
9186            propose(&cfg, "acme-site", "intake", &oversize),
9187            Err(LinkError::ProposeTooLarge { .. })
9188        ));
9189        // A clean handle + in-cap body passes both gates: the failure is now
9190        // the (dead) wire, proving the gates refuse shape, not the verb.
9191        assert!(matches!(
9192            propose(&cfg, "acme-site", "intake", "hi"),
9193            Err(LinkError::Transport { .. })
9194        ));
9195    }
9196
9197    #[test]
9198    fn resolve_refuses_a_hand_built_unsafe_address() {
9199        let cfg = dead_hub();
9200        for brain in ["../up", "a/b", "a?x", "a#f"] {
9201            let addr = Address {
9202                brain: brain.to_string(),
9203                target: None,
9204            };
9205            assert!(
9206                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
9207                "resolve must refuse brain {brain:?}"
9208            );
9209        }
9210        for target in [
9211            AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
9212            AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), // not the minted shape
9213            AddressTarget::Path("../up.md".to_string()),
9214            AddressTarget::Path("records/x.md#frag".to_string()),
9215        ] {
9216            let addr = Address {
9217                brain: "acme".to_string(),
9218                target: Some(target.clone()),
9219            };
9220            assert!(
9221                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
9222                "resolve must refuse target {target:?}"
9223            );
9224        }
9225    }
9226
9227    #[test]
9228    fn v2_final_barrier_refuses_to_advance_a_remote_ahead_checkout() {
9229        let mut local = std::collections::BTreeMap::new();
9230        local.insert("records/a.md".to_string(), ("a".repeat(64), Vec::new()));
9231        local.insert("records/b.md".to_string(), ("b".repeat(64), Vec::new()));
9232        let mut remote = std::collections::BTreeMap::new();
9233        remote.insert(
9234            "records/a.md".to_string(),
9235            V2BaselineFile {
9236                sha256: "c".repeat(64),
9237                bytes: 1,
9238                proof: None,
9239            },
9240        );
9241        remote.insert(
9242            "records/b.md".to_string(),
9243            V2BaselineFile {
9244                sha256: "b".repeat(64),
9245                bytes: 1,
9246                proof: None,
9247            },
9248        );
9249        assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
9250    }
9251
9252    #[test]
9253    fn v2_final_barrier_ignores_only_explicit_kept_home_paths() {
9254        let local = std::collections::BTreeMap::new();
9255        let mut remote = std::collections::BTreeMap::new();
9256        remote.insert(
9257            "private/local.md".to_string(),
9258            V2BaselineFile {
9259                sha256: "d".repeat(64),
9260                bytes: 1,
9261                proof: None,
9262            },
9263        );
9264        assert!(v2_riding_matches_remote(&local, &remote, |path| path == "private/local.md"));
9265        assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
9266    }
9267
9268    fn scoped_test_head(revision: &str) -> V2VerifiedHead {
9269        V2VerifiedHead {
9270            requested: TEST_BRAIN_ID.to_string(),
9271            brain_id: TEST_BRAIN_ID.to_string(),
9272            view_kind: "scoped".to_string(),
9273            view_revision: revision.to_string(),
9274            pointer: None,
9275            trust: TrustState {
9276                v: 2,
9277                origin: "https://hub.example".to_string(),
9278                requested: TEST_BRAIN_ID.to_string(),
9279                brain: TEST_BRAIN_ID.to_string(),
9280                home: None,
9281                anchor: "ed25519:test".to_string(),
9282                current: "ed25519:test".to_string(),
9283                head_seq: 0,
9284                feed_hash: None,
9285                rotations: Vec::new(),
9286                hub_signer: None,
9287                protocol_profile: Some("link-v2".to_string()),
9288            },
9289            alias: None,
9290        }
9291    }
9292
9293    #[test]
9294    fn accepted_v2_checkpoint_never_downgrades_after_profile_migration() {
9295        let mut trust = scoped_test_head(&"a".repeat(64)).trust;
9296        assert!(accepted_as_v2(&trust));
9297
9298        trust.protocol_profile = None;
9299        trust.hub_signer = Some("ed25519:hub".to_string());
9300        assert!(accepted_as_v2(&trust));
9301
9302        trust.hub_signer = None;
9303        assert!(!accepted_as_v2(&trust));
9304    }
9305
9306    fn scoped_test_baseline(revision: &str) -> V2SyncBaseline {
9307        V2SyncBaseline {
9308            v: 2,
9309            origin: "https://hub.example".to_string(),
9310            brain: TEST_BRAIN_ID.to_string(),
9311            commit_hash: None,
9312            content_root: None,
9313            view_kind: Some("scoped".to_string()),
9314            view_revision: Some(revision.to_string()),
9315            projection_sha256: Some(scoped_projection_sha256(TEST_BRAIN_ID)),
9316            files: std::collections::BTreeMap::new(),
9317            local_policy_digest: None,
9318            local_eligibility: std::collections::BTreeMap::new(),
9319            remote_copy_remains: std::collections::BTreeMap::new(),
9320        }
9321    }
9322
9323    #[test]
9324    fn scoped_projection_is_a_valid_local_store_marker_but_never_rides() {
9325        let directory = tempfile::tempdir().unwrap();
9326        std::fs::write(
9327            directory.path().join("DB.md"),
9328            scoped_projection_bytes(TEST_BRAIN_ID),
9329        )
9330        .unwrap();
9331        let store = Store::open_strict(directory.path()).unwrap();
9332        let head = scoped_test_head(&"a".repeat(64));
9333        let baseline = scoped_test_baseline(&"a".repeat(64));
9334        let mut view = v2_local_files(&store).unwrap();
9335        remove_scoped_projection(&head, Some(&baseline), &mut view).unwrap();
9336        assert!(!view.riding.contains_key("DB.md"));
9337        assert!(!view.eligibility.contains_key("DB.md"));
9338    }
9339
9340    #[test]
9341    fn scoped_projection_edit_and_scope_transition_fail_closed() {
9342        let directory = tempfile::tempdir().unwrap();
9343        std::fs::write(
9344            directory.path().join("DB.md"),
9345            b"---\ntype: db-md\nscope: company\nowner: someone\n---\n",
9346        )
9347        .unwrap();
9348        let store = Store::open_strict(directory.path()).unwrap();
9349        let head = scoped_test_head(&"a".repeat(64));
9350        let baseline = scoped_test_baseline(&"a".repeat(64));
9351        let mut view = v2_local_files(&store).unwrap();
9352        assert!(matches!(
9353            remove_scoped_projection(&head, Some(&baseline), &mut view),
9354            Err(LinkError::ScopedProjectionModified)
9355        ));
9356
9357        let changed = scoped_test_head(&"b".repeat(64));
9358        assert!(matches!(
9359            ensure_v2_view_compatible(&changed, Some(&baseline)),
9360            Err(LinkError::ScopedViewChanged)
9361        ));
9362    }
9363
9364    #[test]
9365    fn scoped_view_metadata_is_explicitly_non_authoritative() {
9366        let head = scoped_test_head(&"a".repeat(64));
9367        let value: Value =
9368            serde_json::from_slice(&scoped_view_metadata(&head, 7).unwrap()).unwrap();
9369        assert_eq!(value["kind"], "link.md-scoped-view");
9370        assert_eq!(value["authoritative"], false);
9371        assert_eq!(value["visible_files"], 7);
9372        assert_eq!(value["brain"], TEST_BRAIN_ID);
9373    }
9374
9375    #[test]
9376    fn local_scoped_marker_requires_the_exact_generated_projection() {
9377        let directory = tempfile::tempdir().unwrap();
9378        std::fs::create_dir(directory.path().join(".dbmd")).unwrap();
9379        std::fs::write(
9380            directory.path().join("DB.md"),
9381            scoped_projection_bytes(TEST_BRAIN_ID),
9382        )
9383        .unwrap();
9384        let head = scoped_test_head(&"a".repeat(64));
9385        std::fs::write(
9386            directory.path().join(".dbmd/view.json"),
9387            scoped_view_metadata(&head, 0).unwrap(),
9388        )
9389        .unwrap();
9390        let store = Store::open_strict(directory.path()).unwrap();
9391        assert!(has_verified_local_scoped_view(&store));
9392
9393        std::fs::write(
9394            directory.path().join("DB.md"),
9395            b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
9396        )
9397        .unwrap();
9398        let altered = Store::open_strict(directory.path()).unwrap();
9399        assert!(!has_verified_local_scoped_view(&altered));
9400    }
9401
9402    #[cfg(unix)]
9403    #[test]
9404    fn v2_atomic_install_materializes_only_local_catalogs_before_swap() {
9405        let sandbox = tempfile::tempdir().unwrap();
9406        let destination = sandbox.path().join("brain");
9407        let entries = vec![
9408            (
9409                "DB.md".to_string(),
9410                scoped_projection_bytes(TEST_BRAIN_ID),
9411            ),
9412            (
9413                "records/contacts/a.md".to_string(),
9414                b"---\ntype: contact\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Visible contact\n---\n\n# A\n"
9415                    .to_vec(),
9416            ),
9417        ];
9418        install_pulled_delta(&destination, &entries, &[], true).unwrap();
9419        assert!(destination.join("index.md").is_file());
9420        assert!(destination.join("records/index.md").is_file());
9421        assert!(destination.join("records/contacts/index.md").is_file());
9422        assert!(destination.join("records/contacts/index.jsonl").is_file());
9423    }
9424}