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) and `GET /api/hub/brains/<brain>/resolve?id=…` / `?path=…` (a record) |
22//! | `sync` (pull) | `GET /api/hub/brains/<brain>/export?format=pack` — an immutable pack, or the granted slice as plain files |
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, so the file carries only
42//! non-secret targets and the agent's environment carries the key.
43//!
44//! Non-HTTPS hubs are refused (the bearer key must never travel in cleartext)
45//! with a loopback exemption for local development.
46//!
47//! # v0 honesty
48//!
49//! This client binds to what a hub enforces **today**: grantees are hub
50//! principals (an email), grant scopes are store-path prefixes, pushes are
51//! whole-store snapshots, and `subscribe` reports feed-head movement. The hub
52//! signs each committed snapshot in a hash-chained feed with a per-brain
53//! Ed25519 identity; this client verifies the content-addressed pack before it
54//! touches disk and verifies the signed feed head on every subscription read.
55
56use std::io::{Cursor, Read, Write};
57use std::path::{Path, PathBuf};
58
59use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
60use ring::signature::{UnparsedPublicKey, ED25519};
61use serde::{Deserialize, Serialize};
62use serde_json::{json, Value};
63use sha2::{Digest, Sha256};
64
65use crate::fsx::write_atomic;
66use crate::store::Store;
67
68/// Environment variable naming the hub base URL (e.g. `https://hub.example.com`).
69pub const HUB_URL_ENV: &str = "DBMD_HUB_URL";
70
71/// Environment variable carrying the hub bearer credential. The bearer
72/// credential source — see the module docs for why it is never store-based.
73pub const HUB_KEY_ENV: &str = "DBMD_HUB_KEY";
74
75/// Environment variable naming the PATH of a self-custodied BRAIN key file
76/// (link.md §2.4). When set, `sync --push` signs each feed entry locally and
77/// ships it through the pack flow — the hub verifies and stores the exact
78/// client bytes and can never sign for the brain. Same file format as agent
79/// keys (`dbmd key generate`).
80pub const BRAIN_KEY_FILE_ENV: &str = "DBMD_BRAIN_KEY_FILE";
81
82/// Environment variable naming the PATH of an agent signing key file
83/// (link.md §8 `LinkMD-Sig` proof of possession). When set, authenticated
84/// requests are signed per-request with the agent's Ed25519 key instead of
85/// carrying a bearer: the signature binds method + path + body + a ±60s
86/// window, so nothing reusable ever crosses the wire or lands in a log or an
87/// agent transcript. The file holds the base64url PKCS#8 key minted by
88/// `dbmd key generate`; the path is not a secret, the file is (mode 0600).
89pub const AGENT_KEY_FILE_ENV: &str = "DBMD_AGENT_KEY_FILE";
90
91/// The store-local config file, relative to the store root. Holds non-secret
92/// toolkit state (`hub = <URL>`); hidden, so every store walk skips it.
93pub const CONFIG_REL_PATH: &str = ".dbmd/config";
94
95/// The most this client will buffer from one hub response. A full-store
96/// export is the biggest honest payload; anything past this is refused loudly
97/// rather than silently truncated.
98const MAX_RESPONSE_BYTES: u64 = 256 * 1024 * 1024;
99
100/// Direct JSON pushes stay below the serverless request-body cap. Larger
101/// snapshots switch to the bounded object-store pack lane.
102const MAX_PUSH_BYTES: usize = 4 * 1024 * 1024;
103
104/// The hub's per-push file-count cap, mirrored client-side.
105const MAX_PUSH_FILES: usize = 100_000;
106const MAX_STORE_BYTES: u64 = 512 * 1024 * 1024;
107const MAX_PACK_BYTES: u64 = 256 * 1024 * 1024;
108
109/// The hub's inbox cap on one `propose` submission body, mirrored client-side
110/// so an oversized body fails before the upload, not after (the same
111/// fail-before-upload contract as the push caps). Public so the CLI can
112/// pre-check a `--body-file` from file metadata without reading it.
113pub const MAX_PROPOSE_BYTES: u64 = 16 * 1024;
114
115/// Bounded connect so a dead hub fails fast; a generous read window so a
116/// large export on a slow link still completes.
117const CONNECT_TIMEOUT_SECS: u64 = 10;
118const READ_TIMEOUT_SECS: u64 = 120;
119const CONNECT_ATTEMPTS: usize = 3;
120const CONNECT_RETRY_BACKOFF_MS: [u64; CONNECT_ATTEMPTS - 1] = [100, 300];
121
122/// Everything that can go wrong on the wire or at its edges. Each variant maps
123/// onto one stable CLI error code; messages are single-line and never echo the
124/// credential.
125#[derive(Debug, thiserror::Error)]
126pub enum LinkError {
127    /// No hub URL was configured anywhere (flag, env, `.dbmd/config`).
128    #[error(
129        "no hub configured — pass --hub <URL>, set {HUB_URL_ENV}, or add `hub = <URL>` to {CONFIG_REL_PATH}"
130    )]
131    NoHub,
132
133    /// The verb needs a credential and none was present.
134    #[error("no hub credential — set {HUB_KEY_ENV} (credentials never live in {CONFIG_REL_PATH})")]
135    NoCredential,
136
137    /// The credential contains whitespace / non-ASCII (a paste artifact). The
138    /// key is deliberately not echoed.
139    #[error(
140        "the hub credential in {HUB_KEY_ENV} contains whitespace or non-ASCII characters — re-copy it (the key is not shown here on purpose)"
141    )]
142    BadKey,
143
144    /// The agent signing key file named by [`AGENT_KEY_FILE_ENV`] is missing,
145    /// unreadable, or not a valid Ed25519 PKCS#8 — key material is never
146    /// echoed.
147    #[error("invalid agent signing key ({message}) — mint one with `dbmd key generate`")]
148    BadAgentKey {
149        /// What failed, without any key material.
150        message: String,
151    },
152
153    /// A non-HTTPS hub outside loopback: the bearer key would travel in cleartext.
154    #[error("refusing non-HTTPS hub {hub} — the credential would travel in cleartext (localhost is exempt)")]
155    UnsafeHub {
156        /// The offending hub URL.
157        hub: String,
158    },
159
160    /// TCP/TLS-level failure: the hub never answered.
161    #[error("hub unreachable at {hub}: {message}")]
162    Transport {
163        /// The hub base URL.
164        hub: String,
165        /// The transport-layer error text.
166        message: String,
167    },
168
169    /// The hub answered with an HTTP error status.
170    #[error("{what} failed (HTTP {status}): {message}")]
171    Http {
172        /// What the client was doing (e.g. `"resolve"`, `"sync pull"`).
173        what: &'static str,
174        /// The HTTP status code.
175        status: u16,
176        /// The hub's own `error` string when it sent one, else a placeholder.
177        message: String,
178        /// The hub's machine `code` field when it sent one.
179        code: Option<String>,
180    },
181
182    /// A 2xx whose body is not JSON — a captive portal, a proxy, or a wrong
183    /// URL — refused here rather than deserializing into nothing downstream.
184    #[error("{what}: the hub answered HTTP {status} with a non-JSON body — check the hub URL")]
185    NotJson {
186        /// What the client was doing.
187        what: &'static str,
188        /// The (2xx) status that carried the non-JSON body.
189        status: u16,
190    },
191
192    /// The hub response exceeded [`MAX_RESPONSE_BYTES`].
193    #[error("hub response exceeded {} MB — refusing to buffer it", MAX_RESPONSE_BYTES / (1024 * 1024))]
194    ResponseTooLarge,
195
196    /// A malformed `@brain/id` address.
197    #[error("invalid address `{given}`: {reason}")]
198    BadAddress {
199        /// The raw address as typed.
200        given: String,
201        /// Why it did not parse.
202        reason: String,
203    },
204
205    /// A grant id whose shape cannot travel as a URL path segment.
206    #[error(
207        "invalid grant id `{given}` — grant ids come from `grant list` (lowercase letters, digits, hyphens)"
208    )]
209    BadGrantId {
210        /// The raw id as typed.
211        given: String,
212    },
213
214    /// An exported file path that would escape or pollute the destination
215    /// (absolute, `..`, a dot-leading segment, or an illegal character). The
216    /// hub is not trusted with local path layout.
217    #[error("refusing unsafe path from the hub: `{path}`")]
218    UnsafePath {
219        /// The offending path as received.
220        path: String,
221    },
222
223    /// The store exceeds the hub's bounded whole-snapshot caps.
224    #[error(
225        "push too large ({detail}) — one snapshot caps at {} MB uncompressed, {} MB compressed, and {MAX_PUSH_FILES} files",
226        MAX_STORE_BYTES / (1024 * 1024),
227        MAX_PACK_BYTES / (1024 * 1024)
228    )]
229    PushTooLarge {
230        /// Which cap was hit, human-readable.
231        detail: String,
232    },
233
234    /// The propose body exceeds the hub's inbox cap.
235    #[error(
236        "propose body too large ({bytes} bytes) — the hub's inbox caps one submission at {} KB",
237        MAX_PROPOSE_BYTES / 1024
238    )]
239    ProposeTooLarge {
240        /// The offending body size in bytes.
241        bytes: u64,
242    },
243
244    /// A store file that is not valid UTF-8 cannot travel the JSON push path.
245    #[error("store file `{path}` is not valid UTF-8 — the JSON push path carries text only")]
246    NotUtf8 {
247        /// The store-relative path of the offending file.
248        path: String,
249    },
250
251    /// A downloaded pack failed validation before any local write.
252    #[error("invalid store pack: {message}")]
253    InvalidPack {
254        /// Hash, ZIP, path, count, or expansion failure.
255        message: String,
256    },
257
258    /// A signed feed entry, hash chain, or advertised feed head did not verify.
259    #[error("invalid signed feed: {message}")]
260    InvalidFeed {
261        /// The failed integrity condition, without untrusted secret material.
262        message: String,
263    },
264
265    /// Local filesystem failure while materializing a pull or reading a push.
266    #[error(transparent)]
267    Io(#[from] std::io::Error),
268
269    /// A store-level failure (walking the local store for a push).
270    #[error(transparent)]
271    Store(#[from] crate::StoreError),
272}
273
274/// Result alias for link.md client operations.
275pub type LinkResult<T> = std::result::Result<T, LinkError>;
276
277// ─────────────────────────────────────────────────────────────────────────────
278// Addressing — `@brain[/id]`, the reserved shape (SPEC § Addressing)
279// ─────────────────────────────────────────────────────────────────────────────
280
281/// What the part after `@brain/` names.
282#[derive(Debug, Clone, PartialEq, Eq)]
283pub enum AddressTarget {
284    /// A record `id` — the db.md lowercase ULID (the reserved `@brain/id` shape).
285    Id(String),
286    /// A store-relative `.md` path — a client-side convenience the hub's
287    /// resolve endpoint also accepts (`?path=`). Not part of the reserved
288    /// shape; unambiguous because a ULID is never a path.
289    Path(String),
290}
291
292/// Why a brain reference failed [`is_safe_ref`] — shared by [`Address::parse`]
293/// and the per-verb entry gates so the two surfaces never drift.
294const BAD_BRAIN_REASON: &str =
295    "the brain reference must be a brain id (lowercase ULID) or a slug (lowercase letters, digits, hyphens)";
296
297/// Why an address target failed its shape check — shared by [`Address::parse`]
298/// and the [`resolve`] entry gate.
299const BAD_TARGET_REASON: &str =
300    "the part after `/` must be a record id (lowercase ULID) or a store-relative `.md` path";
301
302/// A parsed `@brain[/target]` address. `brain` is a hub brain reference — the
303/// brain's ULID id (works for any caller, including cross-party on a public
304/// brain) or a slug (which a hub resolves only against the caller's own
305/// brains; slugs are unique per owner, not globally).
306#[derive(Debug, Clone, PartialEq, Eq)]
307pub struct Address {
308    /// The brain reference (leading `@` stripped).
309    pub brain: String,
310    /// The record target, when the address names one.
311    pub target: Option<AddressTarget>,
312}
313
314impl Address {
315    /// Parse `@brain`, `@brain/<ulid>`, or `@brain/<store-path>.md`. The `@`
316    /// sigil is optional (an agent piping ids around should not have to quote
317    /// it back on). Whitespace and empty segments are malformed.
318    pub fn parse(raw: &str) -> LinkResult<Address> {
319        let bad = |reason: &str| LinkError::BadAddress {
320            given: raw.to_string(),
321            reason: reason.to_string(),
322        };
323
324        let trimmed = raw.trim();
325        let body = trimmed.strip_prefix('@').unwrap_or(trimmed);
326        if body.is_empty() {
327            return Err(bad("empty address"));
328        }
329
330        let (brain, rest) = match body.split_once('/') {
331            Some((b, r)) => (b, Some(r)),
332            None => (body, None),
333        };
334
335        if brain.is_empty() {
336            return Err(bad("missing brain reference before `/`"));
337        }
338        if !is_safe_ref(brain) {
339            return Err(bad(BAD_BRAIN_REASON));
340        }
341
342        let target = match rest {
343            None => None,
344            Some("") => return Err(bad("trailing `/` with no record id or path")),
345            Some(r) if crate::ulid::is_ulid(r) => Some(AddressTarget::Id(r.to_string())),
346            Some(r) => {
347                if !safe_store_rel_path(r) || !r.ends_with(".md") {
348                    return Err(bad(BAD_TARGET_REASON));
349                }
350                Some(AddressTarget::Path(r.to_string()))
351            }
352        };
353
354        Ok(Address {
355            brain: brain.to_string(),
356            target,
357        })
358    }
359}
360
361/// A brain reference safe to embed in a URL path segment: the shapes a hub
362/// accepts (ULID id or slug), which are also exactly URL-path-clean.
363fn is_safe_ref(s: &str) -> bool {
364    !s.is_empty()
365        && s.len() <= 64
366        && s.bytes()
367            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
368}
369
370/// A published-site handle (the `propose` target). Same lexical shape as a
371/// slug.
372pub fn is_valid_handle(s: &str) -> bool {
373    is_safe_ref(s)
374}
375
376/// True when `p` is a store-relative path this client will read from or write
377/// to disk: relative, no `..`, no empty or dot-leading segment (which shields
378/// `.dbmd/` and `.git/`), and only the hub-portable character set. Applied to
379/// every path an export hands us (the hub is not trusted with local layout)
380/// and to every path a push sends (mirroring the hub's own gate).
381pub fn safe_store_rel_path(p: &str) -> bool {
382    if p.is_empty() || p.len() > 512 || p.starts_with('/') {
383        return false;
384    }
385    if !p
386        .bytes()
387        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/'))
388    {
389        return false;
390    }
391    p.split('/')
392        .all(|seg| !seg.is_empty() && seg != "." && seg != ".." && !seg.starts_with('.'))
393}
394
395/// Entry gate for every verb that embeds a caller-supplied brain reference in
396/// a URL path segment. `resolve` reaches the same check through
397/// [`Address::parse`]; the raw-ref verbs (`sync`, `grant`, `subscribe`) call
398/// this directly, so a ref carrying `/`, `..`, `?`, `#`, or any other
399/// URL-reshaping byte is refused before a request exists (the `url` crate
400/// normalizes dot segments, so an unvalidated ref would redirect the
401/// authenticated request to a different hub path).
402fn require_safe_ref(brain: &str) -> LinkResult<()> {
403    if is_safe_ref(brain) {
404        Ok(())
405    } else {
406        Err(LinkError::BadAddress {
407            given: brain.to_string(),
408            reason: BAD_BRAIN_REASON.to_string(),
409        })
410    }
411}
412
413/// Entry gate for the published-site handle `propose` embeds in its URL path.
414fn require_valid_handle(handle: &str) -> LinkResult<()> {
415    if is_valid_handle(handle) {
416        Ok(())
417    } else {
418        Err(LinkError::BadAddress {
419            given: handle.to_string(),
420            reason: "the site handle must be lowercase letters, digits, hyphens".to_string(),
421        })
422    }
423}
424
425/// Entry gate for the grant id `grant revoke` embeds in its URL path. Hub
426/// grant ids are lowercase ULIDs; the gate accepts the same URL-path-clean
427/// shape as a brain ref rather than pinning one mint scheme.
428fn require_safe_grant_id(id: &str) -> LinkResult<()> {
429    if is_safe_ref(id) {
430        Ok(())
431    } else {
432        Err(LinkError::BadGrantId {
433            given: id.to_string(),
434        })
435    }
436}
437
438// ─────────────────────────────────────────────────────────────────────────────
439// Configuration — flag > env > .dbmd/config; credential from env only
440// ─────────────────────────────────────────────────────────────────────────────
441
442/// The resolved client configuration for one invocation.
443#[derive(Debug, Clone)]
444pub struct HubConfig {
445    /// The hub base URL, trailing slash stripped, HTTPS-or-loopback enforced.
446    pub hub: String,
447    /// The bearer credential, when the environment carries one.
448    pub key: Option<String>,
449    /// The agent signing key, when [`AGENT_KEY_FILE_ENV`] names one. Wins
450    /// over the bearer for authenticated requests (link.md §8).
451    pub agent_key: Option<AgentSigningKey>,
452    /// The self-custodied brain signing key, when [`BRAIN_KEY_FILE_ENV`]
453    /// names one — `sync --push` then signs feed entries locally (§2.4).
454    pub brain_key: Option<AgentSigningKey>,
455}
456
457/// A loaded agent signing key: the PKCS#8 secret plus its derived public
458/// multikey. Debug never prints key material.
459#[derive(Clone)]
460pub struct AgentSigningKey {
461    pkcs8: Vec<u8>,
462    /// The key's public identity, `ed25519:<base64url sha256(SPKI)>`.
463    pub multikey: String,
464    /// The full public key, `base64url(SPKI DER)` — what feed entries carry.
465    pub public_key_spki: String,
466}
467
468impl std::fmt::Debug for AgentSigningKey {
469    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
470        f.debug_struct("AgentSigningKey")
471            .field("multikey", &self.multikey)
472            .field("pkcs8", &"<redacted>")
473            .finish()
474    }
475}
476
477impl HubConfig {
478    /// The credential, or the canonical "not configured" error. Verbs that
479    /// authenticate call this; `propose` never does.
480    pub fn require_key(&self) -> LinkResult<&str> {
481        self.key.as_deref().ok_or(LinkError::NoCredential)
482    }
483}
484
485/// Resolve the client configuration: `flag_hub` beats [`HUB_URL_ENV`] beats
486/// the `hub =` line in `<dir>/.dbmd/config`; no fallback default exists. The
487/// credential comes from [`HUB_KEY_ENV`] alone and is validated as a clean
488/// header token (never echoed on failure).
489pub fn hub_config(flag_hub: Option<&str>, dir: &Path) -> LinkResult<HubConfig> {
490    let hub = flag_hub
491        .map(str::to_string)
492        .or_else(|| env_nonempty(HUB_URL_ENV))
493        .or_else(|| config_file_hub(&dir.join(CONFIG_REL_PATH)))
494        .ok_or(LinkError::NoHub)?;
495    let hub = hub.trim().trim_end_matches('/').to_string();
496    assert_safe_hub(&hub)?;
497
498    let key = match env_nonempty(HUB_KEY_ENV) {
499        Some(raw) => Some(clean_key(&raw)?),
500        None => None,
501    };
502
503    let agent_key = match env_nonempty(AGENT_KEY_FILE_ENV) {
504        Some(path) => Some(load_agent_key(Path::new(&path))?),
505        None => None,
506    };
507
508    let brain_key = match env_nonempty(BRAIN_KEY_FILE_ENV) {
509        Some(path) => Some(load_agent_key(Path::new(&path))?),
510        None => None,
511    };
512
513    Ok(HubConfig {
514        hub,
515        key,
516        agent_key,
517        brain_key,
518    })
519}
520
521// ─────────────────────────────────────────────────────────────────────────────
522// Agent signing keys — link.md §8 `LinkMD-Sig` proof of possession
523// ─────────────────────────────────────────────────────────────────────────────
524
525/// The DER prefix that wraps a raw Ed25519 public key into a
526/// SubjectPublicKeyInfo (RFC 8410).
527const ED25519_SPKI_PREFIX: [u8; 12] = [
528    0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
529];
530
531fn bad_agent_key(message: &str) -> LinkError {
532    LinkError::BadAgentKey {
533        message: message.to_string(),
534    }
535}
536
537fn agent_keypair(pkcs8: &[u8]) -> LinkResult<ring::signature::Ed25519KeyPair> {
538    // `from_pkcs8` wants ring's own v2 encoding (private + public); keys from
539    // other tools are often PKCS#8 v1, which `maybe_unchecked` accepts by
540    // deriving the public half itself.
541    ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8)
542        .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(pkcs8))
543        .map_err(|_| bad_agent_key("not an Ed25519 PKCS#8 key"))
544}
545
546/// Derive `(publicKeySpki b64u, multikey)` from a keypair.
547fn public_identity_for(pair: &ring::signature::Ed25519KeyPair) -> (String, String) {
548    use ring::signature::KeyPair as _;
549    let mut spki = Vec::with_capacity(44);
550    spki.extend_from_slice(&ED25519_SPKI_PREFIX);
551    spki.extend_from_slice(pair.public_key().as_ref());
552    (
553        URL_SAFE_NO_PAD.encode(&spki),
554        format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&spki))),
555    )
556}
557
558/// Load and validate a signing-key file (agent or brain — same format):
559/// one base64url line of PKCS#8. Public so `dbmd key rotate` can load the
560/// old key explicitly.
561pub fn load_signing_key(path: &Path) -> LinkResult<AgentSigningKey> {
562    load_agent_key(path)
563}
564
565/// Load and validate the agent key file: one base64url line of PKCS#8.
566fn load_agent_key(path: &Path) -> LinkResult<AgentSigningKey> {
567    let text = std::fs::read_to_string(path)
568        .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
569    let pkcs8 = URL_SAFE_NO_PAD
570        .decode(text.trim())
571        .map_err(|_| bad_agent_key("the key file is not one base64url line"))?;
572    let (public_key_spki, multikey) = public_identity_for(&agent_keypair(&pkcs8)?);
573    Ok(AgentSigningKey {
574        pkcs8,
575        multikey,
576        public_key_spki,
577    })
578}
579
580/// What `dbmd key generate` returns: the public identity to register plus
581/// where the secret landed.
582#[derive(Debug, Serialize)]
583pub struct GeneratedAgentKey {
584    /// `ed25519:<fingerprint>` — the grantable/registerable identity.
585    pub multikey: String,
586    /// base64url SPKI DER — what a hub's register endpoint takes.
587    #[serde(rename = "publicKeySpki")]
588    pub public_key_spki: String,
589    /// Where the PKCS#8 secret was written (mode 0600).
590    #[serde(rename = "keyFile")]
591    pub key_file: String,
592}
593
594/// Mint a fresh Ed25519 agent keypair. The secret is written to `out`
595/// (base64url PKCS#8, one line, 0600, refusing to overwrite); only public
596/// identity is returned. The private key never enters a store and never
597/// travels — requests carry per-request signatures instead (link.md §8).
598pub fn generate_agent_key(out: &Path) -> LinkResult<GeneratedAgentKey> {
599    if out.exists() {
600        return Err(bad_agent_key(
601            "the output file already exists — refusing to overwrite a key",
602        ));
603    }
604    let rng = ring::rand::SystemRandom::new();
605    let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
606        .map_err(|_| bad_agent_key("key generation failed"))?;
607    let pair = agent_keypair(pkcs8.as_ref())?;
608    let (spki_b64u, multikey) = public_identity_for(&pair);
609
610    if let Some(parent) = out.parent() {
611        if !parent.as_os_str().is_empty() {
612            std::fs::create_dir_all(parent)?;
613        }
614    }
615    std::fs::write(out, format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())))?;
616    #[cfg(unix)]
617    {
618        use std::os::unix::fs::PermissionsExt as _;
619        std::fs::set_permissions(out, std::fs::Permissions::from_mode(0o600))?;
620    }
621
622    Ok(GeneratedAgentKey {
623        multikey,
624        public_key_spki: spki_b64u,
625        key_file: out.display().to_string(),
626    })
627}
628
629/// Build the `LinkMD-Sig` v1 header for one request:
630/// `canonical = "v1" LF METHOD LF path+query LF ts LF (sha256hex(body) | "-")`.
631fn linkmd_sig_header(
632    key: &AgentSigningKey,
633    method: &str,
634    path: &str,
635    body: Option<&str>,
636) -> LinkResult<String> {
637    let ts = std::time::SystemTime::now()
638        .duration_since(std::time::UNIX_EPOCH)
639        .map_err(|_| bad_agent_key("system clock is before the epoch"))?
640        .as_secs();
641    let body_hash = match body {
642        Some(b) => format!("{:x}", Sha256::digest(b.as_bytes())),
643        None => "-".to_string(),
644    };
645    let canonical = format!(
646        "v1\n{}\n{}\n{}\n{}",
647        method.to_uppercase(),
648        path,
649        ts,
650        body_hash
651    );
652    let pair = agent_keypair(&key.pkcs8)?;
653    let sig = URL_SAFE_NO_PAD.encode(pair.sign(canonical.as_bytes()).as_ref());
654    let fingerprint = key.multikey.trim_start_matches("ed25519:");
655    Ok(format!(
656        "LinkMD-Sig v1,key=ed25519:{fingerprint},ts={ts},sig={sig}"
657    ))
658}
659
660// ─────────────────────────────────────────────────────────────────────────────
661// Self-custody feed entries — the client signs what the hub only verifies
662// ─────────────────────────────────────────────────────────────────────────────
663
664/// One `files` element of a wire-profile-v1 feed entry (SPEC §5.1: fields in
665/// exactly this order).
666#[derive(Serialize)]
667struct WireFeedFile {
668    path: String,
669    sha256: String,
670    bytes: u64,
671}
672
673/// The unsigned entry in the normative §5.1 field order — serde serializes
674/// struct fields in declaration order, which IS the wire contract.
675#[derive(Serialize)]
676struct UnsignedWireEntry<'a> {
677    v: u8,
678    seq: u64,
679    ts: String,
680    brain: &'a str,
681    public_key: &'a str,
682    kind: &'a str,
683    op: &'a str,
684    pack_sha256: &'a str,
685    files: &'a [WireFeedFile],
686    removed: &'a [String],
687    prev_entry_hash: Option<&'a str>,
688}
689
690/// Build and sign a wire-profile-v1 `push` feed entry with a self-custodied
691/// brain key: serialize the unsigned entry compactly in the normative order,
692/// Ed25519-sign those exact bytes, splice `sig` on as the final field. The
693/// returned string is the exact serialization the hub stores verbatim (plus
694/// one trailing newline) and every independent reader re-derives.
695fn self_custody_entry(
696    key: &AgentSigningKey,
697    seq: u64,
698    ts: String,
699    pack_sha256: &str,
700    files: &[WireFeedFile],
701    prev_entry_hash: Option<&str>,
702) -> LinkResult<String> {
703    let removed: [String; 0] = [];
704    let unsigned = serde_json::to_string(&UnsignedWireEntry {
705        v: 1,
706        seq,
707        ts,
708        brain: &key.multikey,
709        public_key: &key.public_key_spki,
710        kind: "push",
711        op: "snapshot",
712        pack_sha256,
713        files,
714        removed: &removed,
715        prev_entry_hash,
716    })
717    .expect("serialize feed entry");
718    let pair = agent_keypair(&key.pkcs8)?;
719    let sig = URL_SAFE_NO_PAD.encode(pair.sign(unsigned.as_bytes()).as_ref());
720    Ok(format!(
721        "{},\"sig\":\"{}\"}}",
722        &unsigned[..unsigned.len() - 1],
723        sig
724    ))
725}
726
727/// An env var, treated as absent when unset or empty (an empty
728/// `DBMD_HUB_KEY=` falls through rather than becoming an empty credential).
729fn env_nonempty(name: &str) -> Option<String> {
730    std::env::var(name).ok().filter(|v| !v.trim().is_empty())
731}
732
733/// Read the `hub = <URL>` line out of a `.dbmd/config` file. The format is
734/// deliberately minimal: `key = value` lines, `#` comments, unknown keys
735/// ignored (forward-compatible). A missing or unreadable file is simply "not
736/// configured here".
737fn config_file_hub(path: &Path) -> Option<String> {
738    let text = std::fs::read_to_string(path).ok()?;
739    for line in text.lines() {
740        let line = line.trim();
741        if line.is_empty() || line.starts_with('#') {
742            continue;
743        }
744        if let Some((k, v)) = line.split_once('=') {
745            if k.trim() == "hub" {
746                let v = v.trim();
747                if !v.is_empty() {
748                    return Some(v.to_string());
749                }
750            }
751        }
752    }
753    None
754}
755
756/// The bearer key must never travel in cleartext; only loopback hosts may
757/// skip TLS (local development against a hub on localhost).
758fn assert_safe_hub(hub: &str) -> LinkResult<()> {
759    let parsed = url::Url::parse(hub).map_err(|_| LinkError::UnsafeHub {
760        hub: hub.to_string(),
761    })?;
762    if !parsed.username().is_empty()
763        || parsed.password().is_some()
764        || parsed.query().is_some()
765        || parsed.fragment().is_some()
766    {
767        return Err(LinkError::UnsafeHub {
768            hub: hub.to_string(),
769        });
770    }
771    let loopback = match parsed.host() {
772        Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
773        Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
774        Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
775        None => false,
776    };
777    if parsed.scheme().eq_ignore_ascii_case("https") || loopback {
778        Ok(())
779    } else {
780        Err(LinkError::UnsafeHub {
781            hub: hub.to_string(),
782        })
783    }
784}
785
786/// Trim paste artifacts and refuse anything outside the printable-ASCII token
787/// range WITHOUT echoing the key — an HTTP library rejecting a bad header
788/// value tends to echo the whole header line, credential included, so the
789/// gate sits here instead.
790fn clean_key(raw: &str) -> LinkResult<String> {
791    let k = raw.trim();
792    if k.is_empty() || k.bytes().any(|b| !(0x21..=0x7e).contains(&b)) {
793        return Err(LinkError::BadKey);
794    }
795    Ok(k.to_string())
796}
797
798// ─────────────────────────────────────────────────────────────────────────────
799// Transport — one blocking agent, capped reads, the JSON-or-refuse contract
800// ─────────────────────────────────────────────────────────────────────────────
801
802/// One hub response: the status plus the parsed JSON body when there was one.
803#[derive(Debug)]
804pub struct HubResponse {
805    /// The HTTP status code.
806    pub status: u16,
807    /// The parsed JSON body, `None` when the body was empty or not JSON.
808    pub body: Option<Value>,
809}
810
811/// Whether a request carries the bearer credential.
812#[derive(Debug, Clone, Copy, PartialEq, Eq)]
813enum Auth {
814    /// Send `authorization: Bearer <key>`; error without a key.
815    Required,
816    /// Send no credential — the propose door is unauthenticated by design.
817    None,
818    /// Send the configured credential when one exists, otherwise nothing —
819    /// brain-addressed propose works anonymously on public brains, and an
820    /// authenticated caller earns a bigger actor-class budget.
821    Optional,
822}
823
824fn agent() -> ureq::Agent {
825    ureq::AgentBuilder::new()
826        .user_agent(concat!("dbmd/", env!("CARGO_PKG_VERSION")))
827        // Never follow a redirect while a bearer, a store pack, or a signed
828        // response is in flight. Callers see the 3xx as a non-success instead
829        // of letting an origin steer sensitive material elsewhere.
830        .redirects(0)
831        .timeout_connect(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
832        .timeout_read(std::time::Duration::from_secs(READ_TIMEOUT_SECS))
833        .build()
834}
835
836/// Perform one hub request. `path` is the binding path (starts with `/`);
837/// `body` posts JSON. Transport failures, oversized bodies, and non-UTF-8 are
838/// all surfaced as typed [`LinkError`]s; HTTP error statuses are returned in
839/// the [`HubResponse`] for [`ensure_ok`] to shape.
840fn request(
841    cfg: &HubConfig,
842    method: &str,
843    path: &str,
844    body: Option<&Value>,
845    auth: Auth,
846) -> LinkResult<HubResponse> {
847    let url = format!("{}{}", cfg.hub, path);
848    let encoded_body = body.map(Value::to_string);
849    // An agent signing key outranks the bearer: possession proofs put nothing
850    // reusable on the wire, so when both are configured the stronger one wins.
851    let credential = match auth {
852        Auth::Required => Some(match &cfg.agent_key {
853            Some(key) => linkmd_sig_header(key, method, path, encoded_body.as_deref())?,
854            None => format!("Bearer {}", cfg.require_key()?),
855        }),
856        Auth::Optional => match &cfg.agent_key {
857            Some(key) => Some(linkmd_sig_header(
858                key,
859                method,
860                path,
861                encoded_body.as_deref(),
862            )?),
863            None => cfg.key.as_deref().map(|k| format!("Bearer {k}")),
864        },
865        Auth::None => None,
866    };
867    let http = agent();
868    let result = with_connect_retries(|| {
869        let mut req = http.request(method, &url);
870        if let Some(value) = &credential {
871            req = req.set("authorization", value);
872        }
873        match &encoded_body {
874            Some(value) => req
875                .set("content-type", "application/json")
876                .send_string(value)
877                .map_err(Box::new),
878            None => req.call().map_err(Box::new),
879        }
880    });
881    let resp = match result {
882        Ok(resp) => resp,
883        Err(error) => match *error {
884            ureq::Error::Status(_, resp) => resp,
885            ureq::Error::Transport(error) => {
886                return Err(LinkError::Transport {
887                    hub: cfg.hub.clone(),
888                    message: error.to_string(),
889                });
890            }
891        },
892    };
893
894    let status = resp.status();
895    let mut buf = Vec::new();
896    resp.into_reader()
897        .take(MAX_RESPONSE_BYTES + 1)
898        .read_to_end(&mut buf)?;
899    if buf.len() as u64 > MAX_RESPONSE_BYTES {
900        return Err(LinkError::ResponseTooLarge);
901    }
902    let parsed: Option<Value> = serde_json::from_slice(&buf).ok();
903    Ok(HubResponse {
904        status,
905        body: parsed,
906    })
907}
908
909/// These failures happen before any HTTP request reaches the hub, so retrying
910/// cannot duplicate a mutation. Mid-stream I/O is deliberately excluded: once
911/// bytes may have crossed the wire, the caller must rely on the verb's own
912/// idempotency contract instead of guessing.
913fn is_pre_request_transport(kind: ureq::ErrorKind) -> bool {
914    matches!(
915        kind,
916        ureq::ErrorKind::Dns | ureq::ErrorKind::ConnectionFailed | ureq::ErrorKind::ProxyConnect
917    )
918}
919
920fn with_connect_retries(
921    mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
922) -> Result<ureq::Response, Box<ureq::Error>> {
923    let mut attempt = 0;
924    loop {
925        match send() {
926            Err(error)
927                if matches!(
928                    error.as_ref(),
929                    ureq::Error::Transport(transport)
930                        if is_pre_request_transport(transport.kind())
931                ) && attempt + 1 < CONNECT_ATTEMPTS =>
932            {
933                std::thread::sleep(std::time::Duration::from_millis(
934                    CONNECT_RETRY_BACKOFF_MS[attempt],
935                ));
936                attempt += 1;
937            }
938            result => return result,
939        }
940    }
941}
942
943fn assert_safe_presigned_url(raw: &str) -> LinkResult<()> {
944    let parsed = url::Url::parse(raw).map_err(|_| LinkError::InvalidPack {
945        message: "the hub returned an invalid object-store URL".to_string(),
946    })?;
947    if !parsed.scheme().eq_ignore_ascii_case("https")
948        || !parsed.username().is_empty()
949        || parsed.password().is_some()
950        || parsed.fragment().is_some()
951    {
952        return Err(LinkError::InvalidPack {
953            message: "the hub returned an unsafe object-store URL".to_string(),
954        });
955    }
956    Ok(())
957}
958
959fn put_presigned(raw: &str, headers: &Value, bytes: &[u8]) -> LinkResult<()> {
960    assert_safe_presigned_url(raw)?;
961    let http = agent();
962    let result = with_connect_retries(|| {
963        let mut req = http.put(raw);
964        if let Some(map) = headers.as_object() {
965            for (name, value) in map {
966                if let Some(value) = value.as_str() {
967                    req = req.set(name, value);
968                }
969            }
970        }
971        req.send_bytes(bytes).map_err(Box::new)
972    });
973    match result {
974        Ok(resp) if resp.status() < 300 => Ok(()),
975        Ok(resp) => Err(LinkError::Http {
976            what: "pack upload",
977            status: resp.status(),
978            message: "object store rejected the upload".to_string(),
979            code: None,
980        }),
981        Err(error) => match *error {
982            ureq::Error::Status(_, resp) => Err(LinkError::Http {
983                what: "pack upload",
984                status: resp.status(),
985                message: "object store rejected the upload".to_string(),
986                code: None,
987            }),
988            ureq::Error::Transport(err) => Err(LinkError::Transport {
989                hub: "the object store".to_string(),
990                message: err.to_string(),
991            }),
992        },
993    }
994}
995
996fn get_presigned(raw: &str) -> LinkResult<Vec<u8>> {
997    assert_safe_presigned_url(raw)?;
998    let http = agent();
999    let resp = match with_connect_retries(|| http.get(raw).call().map_err(Box::new)) {
1000        Ok(resp) => resp,
1001        Err(error) => match *error {
1002            ureq::Error::Status(_, resp) => {
1003                return Err(LinkError::Http {
1004                    what: "pack download",
1005                    status: resp.status(),
1006                    message: "object store rejected the download".to_string(),
1007                    code: None,
1008                });
1009            }
1010            ureq::Error::Transport(err) => {
1011                return Err(LinkError::Transport {
1012                    hub: "the object store".to_string(),
1013                    message: err.to_string(),
1014                });
1015            }
1016        },
1017    };
1018    let mut bytes = Vec::new();
1019    resp.into_reader()
1020        .take(MAX_PACK_BYTES + 1)
1021        .read_to_end(&mut bytes)?;
1022    if bytes.len() as u64 > MAX_PACK_BYTES {
1023        return Err(LinkError::InvalidPack {
1024            message: "download exceeds the compressed-size limit".to_string(),
1025        });
1026    }
1027    Ok(bytes)
1028}
1029
1030/// Unwrap a successful JSON body, or shape the failure: a >=400 surfaces the
1031/// hub's own `error` + `code`; a 2xx without JSON is refused as not a hub
1032/// answer.
1033fn ensure_ok(r: HubResponse, what: &'static str) -> LinkResult<Value> {
1034    if r.status >= 400 {
1035        let message = r
1036            .body
1037            .as_ref()
1038            .and_then(|b| b.get("error"))
1039            .and_then(Value::as_str)
1040            .unwrap_or("unknown error")
1041            .to_string();
1042        let code = r
1043            .body
1044            .as_ref()
1045            .and_then(|b| b.get("code"))
1046            .and_then(Value::as_str)
1047            .map(str::to_string);
1048        return Err(LinkError::Http {
1049            what,
1050            status: r.status,
1051            message,
1052            code,
1053        });
1054    }
1055    r.body.ok_or(LinkError::NotJson {
1056        what,
1057        status: r.status,
1058    })
1059}
1060
1061// ─────────────────────────────────────────────────────────────────────────────
1062// resolve — handle → brain card; @brain/id → the record
1063// ─────────────────────────────────────────────────────────────────────────────
1064
1065/// Resolve an address. A bare `@brain` returns the brain card (metadata +
1066/// index stats — the v0 form of the card; keys arrive with the protocol's
1067/// signing layer). `@brain/<id>` and `@brain/<path>.md` return the full
1068/// record, frontmatter + body.
1069/// GET an absolute URL as JSON with NO credential — used to fetch a brain card
1070/// from a FOREIGN home during registry resolution. The hub credential is never
1071/// sent to another origin (a deliberate security property); the home is
1072/// HTTPS-or-loopback guarded like any hub.
1073fn get_json_absolute(url: &str) -> LinkResult<Value> {
1074    assert_safe_hub(url)?;
1075    let http = agent();
1076    let resp = match with_connect_retries(|| http.get(url).call().map_err(Box::new)) {
1077        Ok(resp) => resp,
1078        Err(error) => match *error {
1079            ureq::Error::Status(status, resp) => {
1080                let _ = resp;
1081                return Err(LinkError::Http {
1082                    what: "registry home fetch",
1083                    status,
1084                    message: "the home node rejected the card request".to_string(),
1085                    code: None,
1086                });
1087            }
1088            ureq::Error::Transport(err) => {
1089                return Err(LinkError::Transport {
1090                    hub: url.to_string(),
1091                    message: err.to_string(),
1092                });
1093            }
1094        },
1095    };
1096    let mut buf = Vec::new();
1097    resp.into_reader()
1098        .take(MAX_RESPONSE_BYTES + 1)
1099        .read_to_end(&mut buf)?;
1100    if buf.len() as u64 > MAX_RESPONSE_BYTES {
1101        return Err(LinkError::ResponseTooLarge);
1102    }
1103    serde_json::from_slice(&buf).map_err(|_| LinkError::InvalidFeed {
1104        message: "the home node returned invalid JSON".to_string(),
1105    })
1106}
1107
1108/// Resolve a bare `@handle` through the federation registry (link.md §7.1,
1109/// E5): look the handle up in the hub's registry, fetch the brain card from
1110/// the returned HOME node, and PIN — the card's identity fingerprint must
1111/// equal the registry's, or resolution fails. Returns the card enriched with
1112/// the resolved `home`, or `Ok(None)` when the registry has no such handle
1113/// (so the caller can fall back to a direct lookup).
1114pub fn resolve_registry(cfg: &HubConfig, handle: &str) -> LinkResult<Option<Value>> {
1115    require_safe_ref(handle)?;
1116    let reg = request(
1117        cfg,
1118        "GET",
1119        &format!("/api/hub/registry/{handle}"),
1120        None,
1121        Auth::None,
1122    )?;
1123    if reg.status == 404 {
1124        return Ok(None);
1125    }
1126    let body = ensure_ok(reg, "registry resolve")?;
1127    let home = body
1128        .get("home")
1129        .and_then(Value::as_str)
1130        .ok_or_else(|| invalid_feed("registry entry has no home"))?;
1131    let brain = body
1132        .get("brain")
1133        .and_then(Value::as_str)
1134        .ok_or_else(|| invalid_feed("registry entry has no brain"))?;
1135    let want_fp = body
1136        .get("identity")
1137        .and_then(|i| i.get("fingerprint"))
1138        .and_then(Value::as_str)
1139        .ok_or_else(|| invalid_feed("registry entry has no identity fingerprint"))?;
1140
1141    let home = home.trim_end_matches('/');
1142    let card = get_json_absolute(&format!("{home}/api/hub/brains/{brain}"))?;
1143    let got_fp = card
1144        .get("identity")
1145        .and_then(|i| i.get("fingerprint"))
1146        .and_then(Value::as_str)
1147        .unwrap_or_default();
1148    if got_fp != want_fp {
1149        return Err(invalid_feed(
1150            "the home node served an identity that does not match the registry — refusing",
1151        ));
1152    }
1153    let mut out = card;
1154    if let Value::Object(map) = &mut out {
1155        map.insert("home".to_string(), Value::String(home.to_string()));
1156        map.insert(
1157            "resolvedVia".to_string(),
1158            Value::String("registry".to_string()),
1159        );
1160    }
1161    Ok(Some(out))
1162}
1163
1164pub fn resolve(cfg: &HubConfig, addr: &Address) -> LinkResult<Value> {
1165    // `Address::parse` refuses these shapes already, but `Address` has public
1166    // fields — re-assert at the wire so a hand-built address can never
1167    // reshape the request path.
1168    require_safe_ref(&addr.brain)?;
1169    if let Some(target) = &addr.target {
1170        let (given, ok) = match target {
1171            AddressTarget::Id(id) => (id, crate::ulid::is_ulid(id)),
1172            AddressTarget::Path(p) => (p, safe_store_rel_path(p) && p.ends_with(".md")),
1173        };
1174        if !ok {
1175            return Err(LinkError::BadAddress {
1176                given: given.clone(),
1177                reason: BAD_TARGET_REASON.to_string(),
1178            });
1179        }
1180    }
1181
1182    let path = match &addr.target {
1183        None => format!("/api/hub/brains/{}", addr.brain),
1184        Some(AddressTarget::Id(id)) => {
1185            format!("/api/hub/brains/{}/resolve?id={id}", addr.brain)
1186        }
1187        Some(AddressTarget::Path(p)) => {
1188            format!("/api/hub/brains/{}/resolve?path={p}", addr.brain)
1189        }
1190    };
1191    // Direct first: the caller's own slug and hub-hosted public handles resolve
1192    // here unchanged. Only a bare `@handle` the hub can't resolve directly
1193    // (404) falls through to the federation registry — how a handle reaches a
1194    // brain on ANOTHER node (link.md §7.1, E5).
1195    let direct = request(cfg, "GET", &path, None, Auth::Required)?;
1196    if direct.status == 404 && addr.target.is_none() && !crate::ulid::is_ulid(&addr.brain) {
1197        if let Some(card) = resolve_registry(cfg, &addr.brain)? {
1198            return Ok(card);
1199        }
1200    }
1201    ensure_ok(direct, "resolve")
1202}
1203
1204// ─────────────────────────────────────────────────────────────────────────────
1205// sync — pull the granted slice as files; push the local store as a snapshot
1206// ─────────────────────────────────────────────────────────────────────────────
1207
1208/// What a pull materialized.
1209#[derive(Debug, serde::Serialize)]
1210pub struct PullReport {
1211    /// The brain id the hub reported.
1212    pub brain: String,
1213    /// The brain's slug.
1214    pub slug: String,
1215    /// The hub's feed head at export time.
1216    #[serde(rename = "headSeq")]
1217    pub head_seq: u64,
1218    /// How many files were written.
1219    pub files: usize,
1220    /// Where they were written (as given or derived from the slug).
1221    pub dest: String,
1222    /// Local content files that the export did not carry — present so a
1223    /// caller sees divergence; nothing is ever deleted locally.
1224    #[serde(rename = "extraLocal")]
1225    pub extra_local: Vec<String>,
1226}
1227
1228/// Pull the granted slice of `brain` to `out` (default: `./<slug>`). Every
1229/// exported path is safety-gated before it touches disk; files are written
1230/// atomically; nothing local is ever deleted (locals the export lacks are
1231/// *reported* in `extra_local` instead). Returns the report; rebuilding the
1232/// local index catalog afterwards is the caller's (cheap, optional) step.
1233pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
1234    require_safe_ref(brain)?;
1235    let path = format!("/api/hub/brains/{brain}/export?format=pack");
1236    let body = ensure_ok(
1237        request(cfg, "GET", &path, None, Auth::Required)?,
1238        "sync pull",
1239    )?;
1240
1241    let remote_slug = body
1242        .get("slug")
1243        .and_then(Value::as_str)
1244        .filter(|slug| is_safe_slug(slug));
1245    let slug = remote_slug
1246        .or_else(|| is_safe_slug(brain).then_some(brain))
1247        .unwrap_or("brain")
1248        .to_string();
1249    let brain_id = body
1250        .get("brain")
1251        .and_then(Value::as_str)
1252        .unwrap_or(brain)
1253        .to_string();
1254    let head_seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
1255    let dest: PathBuf = match out {
1256        Some(p) => p.to_path_buf(),
1257        None => PathBuf::from(&slug),
1258    };
1259    let entries =
1260        if let Some(url) = body.get("url").and_then(Value::as_str) {
1261            let expected = body
1262                .get("sha256")
1263                .and_then(Value::as_str)
1264                .filter(|hash| {
1265                    hash.len() == 64
1266                        && hash
1267                            .bytes()
1268                            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
1269                })
1270                .ok_or_else(|| LinkError::InvalidPack {
1271                    message: "the hub returned an invalid SHA-256".to_string(),
1272                })?;
1273            let bytes = get_presigned(url)?;
1274            let actual = format!("{:x}", Sha256::digest(&bytes));
1275            if actual != expected {
1276                return Err(LinkError::InvalidPack {
1277                    message: "SHA-256 verification failed".to_string(),
1278                });
1279            }
1280            parse_store_pack(bytes)?
1281        } else {
1282            let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
1283                LinkError::InvalidPack {
1284                    message: "the hub returned neither a pack nor a file manifest".to_string(),
1285                }
1286            })?;
1287            let mut entries = Vec::with_capacity(files.len());
1288            for file in files {
1289                let path = file.get("path").and_then(Value::as_str).ok_or_else(|| {
1290                    LinkError::InvalidPack {
1291                        message: "a file entry has no string path".to_string(),
1292                    }
1293                })?;
1294                let content = file.get("content").and_then(Value::as_str).ok_or_else(|| {
1295                    LinkError::InvalidPack {
1296                        message: format!("file `{path}` has no string content"),
1297                    }
1298                })?;
1299                entries.push((path.to_string(), content.as_bytes().to_vec()));
1300            }
1301            entries
1302        };
1303
1304    // Gate the complete manifest before the first filesystem mutation.
1305    let mut seen = std::collections::HashSet::new();
1306    for (path, _) in &entries {
1307        if !safe_store_rel_path(path) {
1308            return Err(LinkError::UnsafePath { path: path.clone() });
1309        }
1310        if !seen.insert(path) {
1311            return Err(LinkError::InvalidPack {
1312                message: format!("duplicate path `{path}`"),
1313            });
1314        }
1315    }
1316    std::fs::create_dir_all(&dest)?;
1317    let real_dest = std::fs::canonicalize(&dest)?;
1318
1319    for (p, content) in &entries {
1320        let abs = dest.join(p);
1321        if let Some(parent) = abs.parent() {
1322            std::fs::create_dir_all(parent)?;
1323            let real_parent = std::fs::canonicalize(parent)?;
1324            if !real_parent.starts_with(&real_dest) {
1325                return Err(LinkError::UnsafePath { path: p.clone() });
1326            }
1327        }
1328        if std::fs::symlink_metadata(&abs).is_ok_and(|meta| meta.file_type().is_symlink()) {
1329            return Err(LinkError::UnsafePath { path: p.clone() });
1330        }
1331        write_atomic(&abs, content)?;
1332    }
1333
1334    // Divergence report: local content files the export did not carry. Only
1335    // meaningful when the destination is (now) an openable store; a scoped
1336    // pull may lack DB.md, in which case there is nothing to compare against.
1337    let pulled: std::collections::BTreeSet<&str> =
1338        entries.iter().map(|(p, _)| p.as_str()).collect();
1339    let mut extra_local = Vec::new();
1340    if let Ok(store) = Store::open(&dest) {
1341        if let Ok(walked) = store.walk() {
1342            for rel in walked {
1343                let rel_str = rel.to_string_lossy().replace('\\', "/");
1344                if !pulled.contains(rel_str.as_str()) {
1345                    extra_local.push(rel_str);
1346                }
1347            }
1348        }
1349    }
1350
1351    Ok(PullReport {
1352        brain: brain_id,
1353        slug,
1354        head_seq,
1355        files: entries.len(),
1356        dest: dest.to_string_lossy().into_owned(),
1357        extra_local,
1358    })
1359}
1360
1361fn is_safe_slug(slug: &str) -> bool {
1362    !slug.is_empty()
1363        && slug.len() <= 63
1364        && !slug.starts_with('-')
1365        && !slug.ends_with('-')
1366        && slug
1367            .bytes()
1368            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
1369}
1370
1371fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
1372    let mut archive =
1373        zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
1374            message: format!("ZIP parse failed: {err}"),
1375        })?;
1376    if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
1377        return Err(LinkError::InvalidPack {
1378            message: format!("invalid file count {}", archive.len()),
1379        });
1380    }
1381    let mut total = 0u64;
1382    let mut entries = Vec::with_capacity(archive.len());
1383    for index in 0..archive.len() {
1384        let mut file = archive
1385            .by_index(index)
1386            .map_err(|err| LinkError::InvalidPack {
1387                message: format!("ZIP entry failed: {err}"),
1388            })?;
1389        if file.is_dir() {
1390            continue;
1391        }
1392        let path = file.name().to_string();
1393        if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
1394            return Err(LinkError::UnsafePath { path });
1395        }
1396        if file
1397            .unix_mode()
1398            .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
1399        {
1400            return Err(LinkError::InvalidPack {
1401                message: format!("non-file entry `{path}`"),
1402            });
1403        }
1404        total = total.saturating_add(file.size());
1405        if total > MAX_STORE_BYTES {
1406            return Err(LinkError::InvalidPack {
1407                message: "expanded content exceeds the 512 MB limit".to_string(),
1408            });
1409        }
1410        let mut content = Vec::new();
1411        file.read_to_end(&mut content)
1412            .map_err(|err| LinkError::InvalidPack {
1413                message: format!("could not decompress `{path}`: {err}"),
1414            })?;
1415        if content.len() as u64 != file.size() {
1416            return Err(LinkError::InvalidPack {
1417                message: format!("length mismatch for `{path}`"),
1418            });
1419        }
1420        entries.push((path, content));
1421    }
1422    if entries.is_empty() {
1423        return Err(LinkError::InvalidPack {
1424            message: "pack contains no files".to_string(),
1425        });
1426    }
1427    Ok(entries)
1428}
1429
1430/// Collect the files a push sends: the store's owned text — `DB.md`,
1431/// `assets.jsonl` when present, and every content `.md` under `records/` and
1432/// `sources/` (the store walk, which already excludes hidden dirs like
1433/// `.dbmd/`, the `log/` archive, and derived `index.*` catalogs; the hub
1434/// derives its own index, and local history stays local). Returns
1435/// `(store-relative path, content)` pairs, path-sorted.
1436pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
1437    preflight_push_ownership(store)?;
1438    let mut out: Vec<(String, String)> = Vec::new();
1439
1440    let read_text = |rel: &str| -> LinkResult<String> {
1441        let abs = store.root.join(rel);
1442        // Resolve through the store's ownership gate before reading. This
1443        // refuses an external symlink (including a hostile DB.md/assets.jsonl)
1444        // and any path crossing a nested-store boundary.
1445        let owned =
1446            crate::store::ensure_path_within_store(&store.root, &abs).map_err(LinkError::from)?;
1447        std::fs::read(&owned)
1448            .map_err(LinkError::from)
1449            .and_then(|bytes| {
1450                String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
1451                    path: rel.to_string(),
1452                })
1453            })
1454    };
1455
1456    out.push(("DB.md".to_string(), read_text("DB.md")?));
1457    if store.root.join("assets.jsonl").is_file() {
1458        out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
1459    }
1460
1461    for rel in store.walk()? {
1462        let rel_str = rel.to_string_lossy().replace('\\', "/");
1463        if !safe_store_rel_path(&rel_str) {
1464            // A locally-legal name outside the hub's portable charset cannot
1465            // travel this wire; refusing beats silently dropping it.
1466            return Err(LinkError::UnsafePath { path: rel_str });
1467        }
1468        let content = read_text(&rel_str)?;
1469        out.push((rel_str, content));
1470    }
1471
1472    out.sort_by(|a, b| a.0.cmp(&b.0));
1473    Ok(out)
1474}
1475
1476/// Refuse to build a destructive whole-store snapshot from an ambiguous local
1477/// tree. Ordinary read-only walks safely prune foreign paths, but a push that
1478/// silently omitted them could delete the hosted copies of those paths.
1479fn preflight_push_ownership(store: &Store) -> LinkResult<()> {
1480    if let Some(nested) = store.nested_store_roots()?.into_iter().next() {
1481        return Err(LinkError::from(std::io::Error::new(
1482            std::io::ErrorKind::PermissionDenied,
1483            format!("cannot push: nested db.md store at {}", nested.display()),
1484        )));
1485    }
1486
1487    for layer in crate::store::Layer::all() {
1488        let root = store.root.join(layer.dir_name());
1489        if !root.is_dir() {
1490            continue;
1491        }
1492        for entry in walkdir::WalkDir::new(&root)
1493            .follow_links(false)
1494            .into_iter()
1495            .filter_entry(|entry| !entry.file_name().to_string_lossy().starts_with('.'))
1496        {
1497            let entry = entry.map_err(|err| {
1498                LinkError::from(std::io::Error::other(format!(
1499                    "cannot inspect push path under {}: {err}",
1500                    root.display()
1501                )))
1502            })?;
1503            if entry.file_type().is_symlink()
1504                && crate::store::ensure_path_within_store(&store.root, entry.path()).is_err()
1505            {
1506                return Err(LinkError::from(std::io::Error::new(
1507                    std::io::ErrorKind::PermissionDenied,
1508                    format!(
1509                        "cannot push: {} resolves outside this store or into a nested store",
1510                        entry.path().display()
1511                    ),
1512                )));
1513            }
1514        }
1515    }
1516    Ok(())
1517}
1518
1519/// Push `files` to `brain` as a whole-store snapshot — the hub's push
1520/// semantics: the hosted copy becomes exactly this set (pull first if the
1521/// hosted side may have records the local copy lacks). Client-side caps
1522/// mirror the hub's JSON-path limits so an oversized push fails before the
1523/// upload.
1524pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
1525    require_safe_ref(brain)?;
1526    if files.len() > MAX_PUSH_FILES {
1527        return Err(LinkError::PushTooLarge {
1528            detail: format!("{} files", files.len()),
1529        });
1530    }
1531    let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
1532    if raw_total > MAX_STORE_BYTES {
1533        return Err(LinkError::PushTooLarge {
1534            detail: format!("{raw_total} uncompressed bytes"),
1535        });
1536    }
1537
1538    // Self-custody (a brain key is configured): the JSON fast path is
1539    // hub-signed by construction, so every push goes through the pack flow
1540    // with a locally signed entry — the hub verifies and can never sign.
1541    if cfg.brain_key.is_none() {
1542        let body = json!({
1543            "files": files
1544                .iter()
1545                .map(|(p, c)| json!({ "path": p, "content": c }))
1546                .collect::<Vec<_>>(),
1547        });
1548        if body.to_string().len() <= MAX_PUSH_BYTES {
1549            let path = format!("/api/hub/brains/{brain}/push");
1550            return ensure_ok(
1551                request(cfg, "POST", &path, Some(&body), Auth::Required)?,
1552                "sync push",
1553            );
1554        }
1555    }
1556
1557    let pack = build_store_pack(files)?;
1558    if pack.len() as u64 > MAX_PACK_BYTES {
1559        return Err(LinkError::PushTooLarge {
1560            detail: format!("{} compressed bytes", pack.len()),
1561        });
1562    }
1563    let sha256 = format!("{:x}", Sha256::digest(&pack));
1564    let mut meta = json!({ "sha256": sha256, "bytes": pack.len() });
1565    if let Some(key) = &cfg.brain_key {
1566        // Head state pins seq + prev; a concurrent writer surfaces as the
1567        // hub's 422 on commit (re-run to retry against the new head).
1568        let current = head(cfg, brain)?;
1569        let mut manifest: Vec<WireFeedFile> = files
1570            .iter()
1571            .map(|(path, content)| WireFeedFile {
1572                path: path.clone(),
1573                sha256: format!("{:x}", Sha256::digest(content.as_bytes())),
1574                bytes: content.len() as u64,
1575            })
1576            .collect();
1577        manifest.sort_by(|a, b| a.path.cmp(&b.path));
1578        let ts = crate::now()
1579            .with_timezone(&chrono::Utc)
1580            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
1581            .to_string();
1582        let entry = self_custody_entry(
1583            key,
1584            current.seq + 1,
1585            ts,
1586            &sha256,
1587            &manifest,
1588            current.feed_hash.as_deref(),
1589        )?;
1590        meta["entry"] = Value::String(entry);
1591    }
1592    let presigned = ensure_ok(
1593        request(
1594            cfg,
1595            "POST",
1596            &format!("/api/hub/brains/{brain}/packs/presign"),
1597            Some(&meta),
1598            Auth::Required,
1599        )?,
1600        "prepare pack upload",
1601    )?;
1602    let url = presigned
1603        .get("url")
1604        .and_then(Value::as_str)
1605        .ok_or_else(|| LinkError::InvalidPack {
1606            message: "the hub returned no upload URL".to_string(),
1607        })?;
1608    put_presigned(url, presigned.get("headers").unwrap_or(&Value::Null), &pack)?;
1609    ensure_ok(
1610        request(
1611            cfg,
1612            "POST",
1613            &format!("/api/hub/brains/{brain}/packs/commit"),
1614            Some(&meta),
1615            Auth::Required,
1616        )?,
1617        "commit pack",
1618    )
1619}
1620
1621fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
1622    let mut sorted: Vec<_> = files.iter().collect();
1623    sorted.sort_by(|a, b| a.0.cmp(&b.0));
1624    let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
1625    let options = zip::write::SimpleFileOptions::default()
1626        .compression_method(zip::CompressionMethod::Deflated)
1627        .last_modified_time(zip::DateTime::default())
1628        .unix_permissions(0o600);
1629    for (path, content) in sorted {
1630        writer
1631            .start_file(path, options)
1632            .map_err(|err| LinkError::InvalidPack {
1633                message: format!("could not create ZIP entry `{path}`: {err}"),
1634            })?;
1635        writer.write_all(content.as_bytes())?;
1636    }
1637    writer
1638        .finish()
1639        .map(Cursor::into_inner)
1640        .map_err(|err| LinkError::InvalidPack {
1641            message: format!("could not finish ZIP: {err}"),
1642        })
1643}
1644
1645// ─────────────────────────────────────────────────────────────────────────────
1646// grant — issue / list / revoke capabilities (owner-side)
1647// ─────────────────────────────────────────────────────────────────────────────
1648
1649/// The two capabilities a v0 hub enforces.
1650#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1651pub enum Capability {
1652    /// Read the granted slice.
1653    Read,
1654    /// Read and push (whole-store; a path-scoped grant is read-only).
1655    Write,
1656}
1657
1658impl Capability {
1659    /// The wire form.
1660    pub fn as_str(self) -> &'static str {
1661        match self {
1662            Capability::Read => "read",
1663            Capability::Write => "write",
1664        }
1665    }
1666}
1667
1668/// Issue (or refresh) a grant on `brain` to `grantee` — a hub principal named
1669/// by email in v0 (the protocol's near-term simplification; key-named
1670/// grantees arrive with the signing layer). `scope` is a store-path prefix
1671/// (the hub's enforcement unit); `until` an ISO 8601 expiry, absent = until
1672/// revoked.
1673pub fn grant_issue(
1674    cfg: &HubConfig,
1675    brain: &str,
1676    grantee: &str,
1677    can: Capability,
1678    scope: Option<&str>,
1679    until: Option<&str>,
1680) -> LinkResult<Value> {
1681    require_safe_ref(brain)?;
1682    // Grantee shape decides the axis: a base64url Ed25519 SPKI is a bare
1683    // multikey holder (link.md §6 cross-party keys — no hub account; the
1684    // printed `publicKeySpki` from `dbmd key generate`); anything else is a
1685    // hub principal named by email.
1686    let is_key_grantee = URL_SAFE_NO_PAD
1687        .decode(grantee)
1688        .map(|der| der.len() == 44 && der.starts_with(&ED25519_SPKI_PREFIX))
1689        .unwrap_or(false);
1690    let mut body = if is_key_grantee {
1691        json!({ "keySpki": grantee, "capability": can.as_str() })
1692    } else {
1693        json!({ "email": grantee, "capability": can.as_str() })
1694    };
1695    if let Some(s) = scope {
1696        body["scopePrefix"] = json!(s);
1697    }
1698    if let Some(u) = until {
1699        body["expiresAt"] = json!(u);
1700    }
1701    let path = format!("/api/hub/brains/{brain}/grants");
1702    ensure_ok(
1703        request(cfg, "POST", &path, Some(&body), Auth::Required)?,
1704        "grant issue",
1705    )
1706}
1707
1708/// List the active grants (and pending invites) on `brain`. Owner-side.
1709pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
1710    require_safe_ref(brain)?;
1711    let path = format!("/api/hub/brains/{brain}/grants");
1712    ensure_ok(
1713        request(cfg, "GET", &path, None, Auth::Required)?,
1714        "grant list",
1715    )
1716}
1717
1718/// Revoke a grant (or cancel a pending invite) by id. Owner-side; revocation
1719/// is soft on the hub (the audit trail survives).
1720pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
1721    require_safe_ref(brain)?;
1722    require_safe_grant_id(grant_id)?;
1723    let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
1724    ensure_ok(
1725        request(cfg, "DELETE", &path, None, Auth::Required)?,
1726        "grant revoke",
1727    )
1728}
1729
1730// ─────────────────────────────────────────────────────────────────────────────
1731// propose — write without trust: evidence into the owner's inbox
1732// ─────────────────────────────────────────────────────────────────────────────
1733
1734/// Submit `body` to the published site `handle`, addressed to its app page
1735/// `app` (a page that declares the `write-inbox` capability). Deliberately
1736/// unauthenticated — this is the cross-party door; the submission lands as
1737/// *evidence* in the owner's `sources/inbox/`, never as truth, and the
1738/// owner's curator accepts or rejects it. Returns the hub's `{id, path}`
1739/// receipt.
1740pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
1741    require_valid_handle(handle)?;
1742    if body.len() as u64 > MAX_PROPOSE_BYTES {
1743        return Err(LinkError::ProposeTooLarge {
1744            bytes: body.len() as u64,
1745        });
1746    }
1747    let payload = json!({ "app": app, "body": body });
1748    // A ULID-shaped target is a bare brain address (link.md §7.4's
1749    // generalization): the brain inbox door, open on public brains, where a
1750    // configured credential earns a bigger actor-class budget. Anything else
1751    // is a published-site handle: that door is unauthenticated by design.
1752    let (path, auth) = if crate::ulid::is_ulid(handle) {
1753        (format!("/api/hub/brains/{handle}/inbox"), Auth::Optional)
1754    } else {
1755        (format!("/api/hub/sites/{handle}/inbox"), Auth::None)
1756    };
1757    ensure_ok(
1758        request(cfg, "POST", &path, Some(&payload), auth)?,
1759        "propose",
1760    )
1761}
1762
1763// ─────────────────────────────────────────────────────────────────────────────
1764// subscribe — follow feed-head movement
1765// ─────────────────────────────────────────────────────────────────────────────
1766
1767/// One observation of a brain's feed head.
1768#[derive(Debug, serde::Serialize)]
1769pub struct Head {
1770    /// The brain id.
1771    pub brain: String,
1772    /// The hub's durable feed cursor — advances on every accepted write.
1773    pub seq: u64,
1774    /// The hub's `updatedAt` for the brain, when present.
1775    #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
1776    pub updated_at: Option<String>,
1777    /// SHA-256 of the exact signed head entry.
1778    #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
1779    pub feed_hash: Option<String>,
1780    /// Whether the head entry's content hash, identity, and Ed25519 signature
1781    /// were verified locally. Path-scoped grants get head movement only.
1782    pub verified: bool,
1783}
1784
1785#[derive(Debug, Deserialize, Serialize)]
1786struct FeedFile {
1787    path: String,
1788    sha256: String,
1789    bytes: u64,
1790}
1791
1792#[derive(Debug, Deserialize, Serialize)]
1793struct FeedEntry {
1794    v: u8,
1795    seq: u64,
1796    ts: String,
1797    brain: String,
1798    public_key: String,
1799    kind: String,
1800    op: String,
1801    pack_sha256: String,
1802    files: Vec<FeedFile>,
1803    removed: Vec<String>,
1804    prev_entry_hash: Option<String>,
1805    sig: String,
1806}
1807
1808#[derive(Serialize)]
1809struct UnsignedFeedEntry<'a> {
1810    v: u8,
1811    seq: u64,
1812    ts: &'a str,
1813    brain: &'a str,
1814    public_key: &'a str,
1815    kind: &'a str,
1816    op: &'a str,
1817    pack_sha256: &'a str,
1818    files: &'a [FeedFile],
1819    removed: &'a [String],
1820    prev_entry_hash: &'a Option<String>,
1821}
1822
1823#[derive(Debug, Deserialize)]
1824struct FeedItem {
1825    hash: String,
1826    entry: FeedEntry,
1827}
1828
1829#[derive(Debug, Deserialize)]
1830struct FeedIdentity {
1831    fingerprint: String,
1832    #[serde(rename = "publicKeySpki")]
1833    public_key_spki: String,
1834    /// Rotation history (link.md §9.1): identities this brain previously
1835    /// signed as. Entries verify against current OR previous — rotation
1836    /// never invalidates history.
1837    #[serde(default)]
1838    previous: Vec<PreviousIdentity>,
1839}
1840
1841#[derive(Debug, Deserialize)]
1842struct PreviousIdentity {
1843    fingerprint: String,
1844    #[serde(rename = "publicKeySpki")]
1845    public_key_spki: String,
1846}
1847
1848#[derive(Debug, Deserialize)]
1849struct FeedResponse {
1850    #[serde(rename = "headSeq")]
1851    head_seq: u64,
1852    #[serde(rename = "feedHash")]
1853    feed_hash: Option<String>,
1854    identity: Option<FeedIdentity>,
1855    entries: Vec<FeedItem>,
1856    #[serde(rename = "scopeLimited")]
1857    scope_limited: bool,
1858}
1859
1860fn invalid_feed(message: impl Into<String>) -> LinkError {
1861    LinkError::InvalidFeed {
1862        message: message.into(),
1863    }
1864}
1865
1866fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
1867    const ED25519_SPKI_PREFIX: &[u8] = &[
1868        0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
1869    ];
1870    let entry = &item.entry;
1871    let public_der = URL_SAFE_NO_PAD
1872        .decode(&entry.public_key)
1873        .map_err(|_| invalid_feed("public key is not base64url"))?;
1874    if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
1875        || !public_der.starts_with(ED25519_SPKI_PREFIX)
1876    {
1877        return Err(invalid_feed("entry public key is not a valid Ed25519 SPKI"));
1878    }
1879    let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
1880    if entry.brain != format!("ed25519:{fingerprint}") {
1881        return Err(invalid_feed(
1882            "brain fingerprint does not match its public key",
1883        ));
1884    }
1885    // The signer must be the brain's CURRENT identity or a PREVIOUS one
1886    // (link.md §9.1 rotation) — never an arbitrary self-consistent key.
1887    let known = fingerprint == identity.fingerprint
1888        || identity
1889            .previous
1890            .iter()
1891            .any(|p| p.fingerprint == fingerprint && p.public_key_spki == entry.public_key);
1892    if !known {
1893        return Err(invalid_feed(
1894            "entry signer is not this brain's identity (current or rotated-from)",
1895        ));
1896    }
1897    let unsigned = UnsignedFeedEntry {
1898        v: entry.v,
1899        seq: entry.seq,
1900        ts: &entry.ts,
1901        brain: &entry.brain,
1902        public_key: &entry.public_key,
1903        kind: &entry.kind,
1904        op: &entry.op,
1905        pack_sha256: &entry.pack_sha256,
1906        files: &entry.files,
1907        removed: &entry.removed,
1908        prev_entry_hash: &entry.prev_entry_hash,
1909    };
1910    let message =
1911        serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
1912    let signature = URL_SAFE_NO_PAD
1913        .decode(&entry.sig)
1914        .map_err(|_| invalid_feed("signature is not base64url"))?;
1915    UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
1916        .verify(&message, &signature)
1917        .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
1918
1919    let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
1920    exact.push(b'\n');
1921    let actual_hash = format!("{:x}", Sha256::digest(&exact));
1922    if actual_hash != item.hash {
1923        return Err(invalid_feed("entry SHA-256 does not match"));
1924    }
1925    Ok(())
1926}
1927
1928// ─────────────────────────────────────────────────────────────────────────────
1929// key rotation — link.md §9.1: the new key, signed by the old one
1930// ─────────────────────────────────────────────────────────────────────────────
1931
1932/// The unsigned rotation statement in its normative field order.
1933#[derive(Serialize)]
1934struct UnsignedRotation<'a> {
1935    v: u8,
1936    op: &'a str,
1937    brain: &'a str,
1938    public_key: &'a str,
1939    new_brain: &'a str,
1940    new_public_key: &'a str,
1941    ts: String,
1942}
1943
1944/// What `dbmd key rotate` returns.
1945#[derive(Debug, Serialize)]
1946pub struct RotationReport {
1947    /// The brain id rotated.
1948    pub brain: String,
1949    /// The NEW identity the hub now serves.
1950    pub multikey: String,
1951    /// Where the new PKCS#8 secret landed (0600).
1952    #[serde(rename = "keyFile")]
1953    pub key_file: String,
1954    /// Prior identities (newest first) the feed still verifies against.
1955    pub previous: Vec<String>,
1956}
1957
1958/// Rotate a self-custodied brain's key: mint a fresh keypair, build the
1959/// §9.1 statement — the new key, signed by the OLD key, normative
1960/// serialization — send it to the hub, and only after the hub accepts write
1961/// the new secret to `out` (0600, refusing overwrite). The old key file is
1962/// left untouched for the owner to retire.
1963pub fn rotate_brain_key(
1964    cfg: &HubConfig,
1965    brain: &str,
1966    old_key: &AgentSigningKey,
1967    out: &Path,
1968) -> LinkResult<RotationReport> {
1969    require_safe_ref(brain)?;
1970    if out.exists() {
1971        return Err(bad_agent_key(
1972            "the output file already exists — refusing to overwrite a key",
1973        ));
1974    }
1975    let rng = ring::rand::SystemRandom::new();
1976    let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
1977        .map_err(|_| bad_agent_key("key generation failed"))?;
1978    let pair = agent_keypair(pkcs8.as_ref())?;
1979    let (new_spki, new_multikey) = public_identity_for(&pair);
1980
1981    let ts = crate::now()
1982        .with_timezone(&chrono::Utc)
1983        .format("%Y-%m-%dT%H:%M:%S%.3fZ")
1984        .to_string();
1985    let unsigned = serde_json::to_string(&UnsignedRotation {
1986        v: 1,
1987        op: "rotate",
1988        brain: &old_key.multikey,
1989        public_key: &old_key.public_key_spki,
1990        new_brain: &new_multikey,
1991        new_public_key: &new_spki,
1992        ts,
1993    })
1994    .expect("serialize rotation");
1995    let old_pair = agent_keypair(&old_key.pkcs8)?;
1996    let sig = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
1997    let statement = format!("{},\"sig\":\"{}\"}}", &unsigned[..unsigned.len() - 1], sig);
1998
1999    let body = json!({ "statement": statement });
2000    let path = format!("/api/hub/brains/{brain}/rotate");
2001    let response = ensure_ok(
2002        request(cfg, "POST", &path, Some(&body), Auth::Required)?,
2003        "key rotate",
2004    )?;
2005    let previous = response
2006        .get("identity")
2007        .and_then(|i| i.get("previous"))
2008        .and_then(Value::as_array)
2009        .map(|arr| {
2010            arr.iter()
2011                .filter_map(|p| p.get("fingerprint").and_then(Value::as_str))
2012                .map(|f| format!("ed25519:{f}"))
2013                .collect()
2014        })
2015        .unwrap_or_default();
2016
2017    std::fs::write(out, format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())))?;
2018    #[cfg(unix)]
2019    {
2020        use std::os::unix::fs::PermissionsExt as _;
2021        std::fs::set_permissions(out, std::fs::Permissions::from_mode(0o600))?;
2022    }
2023    Ok(RotationReport {
2024        brain: brain.to_string(),
2025        multikey: new_multikey,
2026        key_file: out.display().to_string(),
2027        previous,
2028    })
2029}
2030
2031// ─────────────────────────────────────────────────────────────────────────────
2032// mirror — verified replication: the whole feed + files, re-servable
2033// ─────────────────────────────────────────────────────────────────────────────
2034
2035/// What `dbmd mirror` materialized.
2036#[derive(Debug, Serialize)]
2037pub struct MirrorReport {
2038    /// The brain id.
2039    pub brain: String,
2040    /// The mirrored feed head.
2041    #[serde(rename = "headSeq")]
2042    pub head_seq: u64,
2043    /// The head entry hash (the feed's advertised converged state).
2044    #[serde(rename = "feedHash")]
2045    pub feed_hash: Option<String>,
2046    /// Signed feed entries verified and stored.
2047    pub entries: u64,
2048    /// The brain's multikey, pinned in `.dbmd/config` (TOFU).
2049    pub pinned: String,
2050    /// Store files materialized by the pull.
2051    pub files: usize,
2052}
2053
2054/// The mirror state directory, relative to the mirror root.
2055pub const MIRROR_REL_DIR: &str = ".dbmd/mirror";
2056
2057/// SHA-256 hex of one feed entry's stored bytes (`exact JSON + "\n"`) — the
2058/// entry hash every consumer recomputes (SPEC §5.3).
2059pub fn feed_entry_hash(exact_sans_newline: &str) -> String {
2060    format!(
2061        "{:x}",
2062        Sha256::digest(format!("{exact_sans_newline}\n").as_bytes())
2063    )
2064}
2065
2066fn config_pin(path: &Path) -> Option<String> {
2067    let text = std::fs::read_to_string(path).ok()?;
2068    for line in text.lines() {
2069        let line = line.trim();
2070        if let Some(rest) = line.strip_prefix("pin") {
2071            let rest = rest.trim_start();
2072            if let Some(value) = rest.strip_prefix('=') {
2073                let value = value.trim();
2074                if !value.is_empty() {
2075                    return Some(value.to_string());
2076                }
2077            }
2078        }
2079    }
2080    None
2081}
2082
2083/// Replicate a brain with full verification (link.md §5.4 over the WHOLE
2084/// chain, not just the head): every entry's signature, hash, sequence
2085/// contiguity, and prev-hash linkage are checked before its exact bytes are
2086/// stored under `.dbmd/mirror/feed/<seq>.json`; the identity is pinned in
2087/// `.dbmd/config` (trust-on-first-use — a later mirror against a different
2088/// identity refuses); the store files are pulled beside it. feed + files =
2089/// the provable full copy, re-servable by `dbmd serve` — signatures survive
2090/// re-hosting, which is what makes the export an export.
2091pub fn mirror(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<MirrorReport> {
2092    require_safe_ref(brain)?;
2093    let card = head(cfg, brain)?;
2094    let brain_id = card.brain.clone();
2095
2096    let mirror_dir = dest.join(MIRROR_REL_DIR);
2097    let feed_dir = mirror_dir.join("feed");
2098    std::fs::create_dir_all(&feed_dir)?;
2099
2100    let mut expected_seq: u64 = 1;
2101    let mut prev_hash: Option<String> = None;
2102    let mut identity: Option<FeedIdentity> = None;
2103    let mut stored: u64 = 0;
2104    let advertised: Option<String> = loop {
2105        let path = format!(
2106            "/api/hub/brains/{brain_id}/feed?after={}&limit=100",
2107            expected_seq - 1
2108        );
2109        let body = ensure_ok(request(cfg, "GET", &path, None, Auth::Required)?, "mirror")?;
2110        let page: FeedResponse = serde_json::from_value(body)
2111            .map_err(|_| invalid_feed("feed response did not parse"))?;
2112        if page.scope_limited {
2113            return Err(invalid_feed(
2114                "this grant is path-scoped — mirroring needs full-store read",
2115            ));
2116        }
2117        let page_identity = page
2118            .identity
2119            .ok_or_else(|| invalid_feed("feed response carried no identity"))?;
2120        if let Some(existing) = &identity {
2121            if existing.fingerprint != page_identity.fingerprint {
2122                return Err(invalid_feed("identity changed mid-mirror"));
2123            }
2124        }
2125        for item in &page.entries {
2126            if item.entry.seq != expected_seq {
2127                return Err(invalid_feed(format!(
2128                    "expected entry {expected_seq}, feed served {}",
2129                    item.entry.seq
2130                )));
2131            }
2132            if item.entry.prev_entry_hash != prev_hash {
2133                return Err(invalid_feed(format!(
2134                    "entry {} does not chain to its predecessor",
2135                    item.entry.seq
2136                )));
2137            }
2138            verify_feed_item(item, &page_identity)?;
2139            let mut exact = serde_json::to_vec(&item.entry)
2140                .map_err(|_| invalid_feed("could not serialize entry"))?;
2141            exact.push(b'\n');
2142            crate::fsx::write_atomic(&feed_dir.join(format!("{}.json", item.entry.seq)), &exact)?;
2143            prev_hash = Some(item.hash.clone());
2144            expected_seq += 1;
2145            stored += 1;
2146        }
2147        identity = Some(page_identity);
2148        if expected_seq > page.head_seq || page.entries.is_empty() {
2149            if expected_seq <= page.head_seq {
2150                return Err(invalid_feed("feed page was empty before the head"));
2151            }
2152            break page.feed_hash.clone();
2153        }
2154    };
2155    if prev_hash != advertised {
2156        return Err(invalid_feed(
2157            "the verified chain does not converge on the advertised head",
2158        ));
2159    }
2160    let identity = identity.ok_or_else(|| invalid_feed("brain has no identity"))?;
2161    let multikey = format!("ed25519:{}", identity.fingerprint);
2162
2163    // TOFU pin: first mirror writes it; every later mirror must match it.
2164    let config_path = dest.join(CONFIG_REL_PATH);
2165    match config_pin(&config_path) {
2166        Some(pinned) if pinned != multikey => {
2167            return Err(invalid_feed(format!(
2168                "pinned identity {pinned} does not match served identity {multikey} — refusing"
2169            )));
2170        }
2171        Some(_) => {}
2172        None => {
2173            let mut text = std::fs::read_to_string(&config_path).unwrap_or_default();
2174            if !text.is_empty() && !text.ends_with('\n') {
2175                text.push('\n');
2176            }
2177            text.push_str(&format!("pin = {multikey}\n"));
2178            if let Some(parent) = config_path.parent() {
2179                std::fs::create_dir_all(parent)?;
2180            }
2181            crate::fsx::write_atomic(&config_path, text.as_bytes())?;
2182        }
2183    }
2184
2185    let previous: Vec<serde_json::Value> = identity
2186        .previous
2187        .iter()
2188        .map(|p| {
2189            serde_json::json!({
2190                "fingerprint": p.fingerprint,
2191                "publicKeySpki": p.public_key_spki,
2192            })
2193        })
2194        .collect();
2195    crate::fsx::write_atomic(
2196        &mirror_dir.join("identity.json"),
2197        format!(
2198            "{}\n",
2199            serde_json::json!({
2200                "fingerprint": identity.fingerprint,
2201                "publicKeySpki": identity.public_key_spki,
2202                "previous": previous,
2203            })
2204        )
2205        .as_bytes(),
2206    )?;
2207    crate::fsx::write_atomic(
2208        &mirror_dir.join("head.json"),
2209        format!(
2210            "{}\n",
2211            serde_json::json!({
2212                "brain": brain_id,
2213                "headSeq": card.seq,
2214                "feedHash": prev_hash,
2215            })
2216        )
2217        .as_bytes(),
2218    )?;
2219
2220    let pulled = sync_pull(cfg, &brain_id, Some(dest))?;
2221    Ok(MirrorReport {
2222        brain: brain_id,
2223        head_seq: card.seq,
2224        feed_hash: prev_hash,
2225        entries: stored,
2226        pinned: multikey,
2227        files: pulled.files,
2228    })
2229}
2230
2231/// Read and locally verify the brain's current signed feed head. `subscribe`
2232/// polls this as movement detection; the caller re-pulls or re-queries after
2233/// an advance.
2234pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
2235    require_safe_ref(brain)?;
2236    let path = format!("/api/hub/brains/{brain}");
2237    let body = ensure_ok(
2238        request(cfg, "GET", &path, None, Auth::Required)?,
2239        "subscribe",
2240    )?;
2241    let resolved_brain = body
2242        .get("id")
2243        .and_then(Value::as_str)
2244        .unwrap_or(brain)
2245        .to_string();
2246    let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
2247    let advertised_hash = body
2248        .get("feedHash")
2249        .and_then(Value::as_str)
2250        .map(str::to_string);
2251    let updated_at = body
2252        .get("updatedAt")
2253        .and_then(Value::as_str)
2254        .map(str::to_string);
2255    if seq == 0 {
2256        return Ok(Head {
2257            brain: resolved_brain,
2258            seq,
2259            updated_at,
2260            feed_hash: None,
2261            verified: true,
2262        });
2263    }
2264
2265    let feed_value = ensure_ok(
2266        request(
2267            cfg,
2268            "GET",
2269            &format!("/api/hub/brains/{brain}/feed?after={}&limit=1", seq - 1),
2270            None,
2271            Auth::Required,
2272        )?,
2273        "subscribe feed",
2274    )?;
2275    let feed: FeedResponse = serde_json::from_value(feed_value)
2276        .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
2277    if feed.head_seq != seq || feed.feed_hash != advertised_hash {
2278        return Err(invalid_feed("brain card and feed head disagree"));
2279    }
2280    if feed.scope_limited {
2281        return Ok(Head {
2282            brain: resolved_brain,
2283            seq,
2284            updated_at,
2285            feed_hash: advertised_hash,
2286            verified: false,
2287        });
2288    }
2289    let identity = feed
2290        .identity
2291        .as_ref()
2292        .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
2293    let item = feed
2294        .entries
2295        .first()
2296        .ok_or_else(|| invalid_feed("feed head entry is missing"))?;
2297    if item.entry.seq != seq || Some(&item.hash) != advertised_hash.as_ref() {
2298        return Err(invalid_feed(
2299            "advertised feed hash does not address the head entry",
2300        ));
2301    }
2302    verify_feed_item(item, identity)?;
2303    Ok(Head {
2304        brain: resolved_brain,
2305        seq,
2306        updated_at,
2307        feed_hash: advertised_hash,
2308        verified: true,
2309    })
2310}
2311
2312#[cfg(test)]
2313mod tests {
2314    use super::*;
2315
2316    #[cfg(unix)]
2317    #[test]
2318    fn collect_push_files_refuses_external_symlink_and_nested_store() {
2319        use std::os::unix::fs::symlink;
2320
2321        let root = tempfile::tempdir().unwrap();
2322        std::fs::write(
2323            root.path().join("DB.md"),
2324            "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
2325        )
2326        .unwrap();
2327        std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
2328
2329        let external = tempfile::tempdir().unwrap();
2330        let secret = external.path().join("secret.md");
2331        std::fs::write(&secret, "TOP SECRET").unwrap();
2332        symlink(&secret, root.path().join("records/notes/secret.md")).unwrap();
2333
2334        let store = Store::open_strict(root.path()).unwrap();
2335        let err = collect_push_files(&store).unwrap_err().to_string();
2336        assert!(err.contains("cannot push"), "{err}");
2337        assert!(
2338            !err.contains("TOP SECRET"),
2339            "external bytes must never leak"
2340        );
2341
2342        std::fs::remove_file(root.path().join("records/notes/secret.md")).unwrap();
2343        let nested = root.path().join("records/nested");
2344        std::fs::create_dir_all(&nested).unwrap();
2345        std::fs::write(
2346            nested.join("DB.md"),
2347            "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
2348        )
2349        .unwrap();
2350        let err = collect_push_files(&store).unwrap_err().to_string();
2351        assert!(err.contains("nested db.md store"), "{err}");
2352    }
2353
2354    #[test]
2355    fn signed_feed_item_verifies_identity_hash_and_signature() {
2356        use ring::rand::SystemRandom;
2357        use ring::signature::{Ed25519KeyPair, KeyPair};
2358
2359        const PREFIX: &[u8] = &[
2360            0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
2361        ];
2362        let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
2363        let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
2364        let mut spki = PREFIX.to_vec();
2365        spki.extend_from_slice(pair.public_key().as_ref());
2366        let public_key = URL_SAFE_NO_PAD.encode(&spki);
2367        let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
2368        let mut entry = FeedEntry {
2369            v: 1,
2370            seq: 1,
2371            ts: "2026-07-14T00:00:00.000Z".to_string(),
2372            brain: format!("ed25519:{fingerprint}"),
2373            public_key: public_key.clone(),
2374            kind: "push".to_string(),
2375            op: "snapshot".to_string(),
2376            pack_sha256: "a".repeat(64),
2377            files: vec![FeedFile {
2378                path: "DB.md".to_string(),
2379                sha256: "b".repeat(64),
2380                bytes: 3,
2381            }],
2382            removed: vec![],
2383            prev_entry_hash: None,
2384            sig: String::new(),
2385        };
2386        let unsigned = UnsignedFeedEntry {
2387            v: entry.v,
2388            seq: entry.seq,
2389            ts: &entry.ts,
2390            brain: &entry.brain,
2391            public_key: &entry.public_key,
2392            kind: &entry.kind,
2393            op: &entry.op,
2394            pack_sha256: &entry.pack_sha256,
2395            files: &entry.files,
2396            removed: &entry.removed,
2397            prev_entry_hash: &entry.prev_entry_hash,
2398        };
2399        entry.sig =
2400            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
2401        let mut exact = serde_json::to_vec(&entry).unwrap();
2402        exact.push(b'\n');
2403        let item = FeedItem {
2404            hash: format!("{:x}", Sha256::digest(&exact)),
2405            entry,
2406        };
2407        let identity = FeedIdentity {
2408            fingerprint,
2409            public_key_spki: public_key,
2410            previous: Vec::new(),
2411        };
2412        assert!(verify_feed_item(&item, &identity).is_ok());
2413        let mut tampered = item;
2414        tampered.entry.pack_sha256 = "c".repeat(64);
2415        assert!(verify_feed_item(&tampered, &identity).is_err());
2416    }
2417
2418    #[test]
2419    fn a_self_custody_entry_verifies_like_any_hub_entry() {
2420        let rng = ring::rand::SystemRandom::new();
2421        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
2422        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
2423        let (spki, multikey) = public_identity_for(&pair);
2424        let key = AgentSigningKey {
2425            pkcs8: pkcs8.as_ref().to_vec(),
2426            multikey: multikey.clone(),
2427            public_key_spki: spki.clone(),
2428        };
2429        let files = vec![WireFeedFile {
2430            path: "DB.md".to_string(),
2431            sha256: "a".repeat(64),
2432            bytes: 3,
2433        }];
2434        let raw = self_custody_entry(
2435            &key,
2436            1,
2437            "2026-07-23T12:00:00.000Z".to_string(),
2438            &"c".repeat(64),
2439            &files,
2440            None,
2441        )
2442        .unwrap();
2443        // The exact client serialization parses as a feed entry and passes the
2444        // SAME verifier every subscribe read runs — the self-custody path
2445        // produces first-class wire-profile-v1 entries.
2446        let entry: FeedEntry = serde_json::from_str(&raw).unwrap();
2447        let hash = format!("{:x}", Sha256::digest(format!("{raw}\n").as_bytes()));
2448        let item = FeedItem { hash, entry };
2449        let identity = FeedIdentity {
2450            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
2451            public_key_spki: spki,
2452            previous: Vec::new(),
2453        };
2454        assert!(verify_feed_item(&item, &identity).is_ok());
2455    }
2456
2457    // ── Address parsing ─────────────────────────────────────────────────────
2458
2459    #[test]
2460    fn address_bare_brain_with_and_without_sigil() {
2461        for raw in ["@acme-ops", "acme-ops"] {
2462            let a = Address::parse(raw).expect(raw);
2463            assert_eq!(a.brain, "acme-ops");
2464            assert_eq!(a.target, None);
2465        }
2466    }
2467
2468    #[test]
2469    fn address_ulid_target_parses_as_id() {
2470        let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
2471        assert_eq!(a.brain, "acme");
2472        assert_eq!(
2473            a.target,
2474            Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
2475        );
2476    }
2477
2478    #[test]
2479    fn address_md_path_target_parses_as_path() {
2480        let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
2481        assert_eq!(
2482            a.target,
2483            Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
2484        );
2485    }
2486
2487    #[test]
2488    fn address_rejects_malformed_forms() {
2489        for raw in [
2490            "",
2491            "@",
2492            "@/x",
2493            "@acme/",
2494            "@acme/../etc/passwd",
2495            "@acme/records/.hidden.md",
2496            "@ACME",             // uppercase is not a hub ref shape
2497            "@acme/notes/x.txt", // target is neither ULID nor .md path
2498            "@a b",              // whitespace
2499        ] {
2500            assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
2501        }
2502    }
2503
2504    // ── Path safety ─────────────────────────────────────────────────────────
2505
2506    #[test]
2507    fn safe_paths_accept_store_shapes_and_reject_escapes() {
2508        for ok in [
2509            "DB.md",
2510            "assets.jsonl",
2511            "records/clients/lumio.md",
2512            "sources/emails/2026/07/x.md",
2513        ] {
2514            assert!(safe_store_rel_path(ok), "should accept {ok:?}");
2515        }
2516        for bad in [
2517            "",
2518            "/etc/passwd",
2519            "../up.md",
2520            "records/../../up.md",
2521            "records//x.md",
2522            ".dbmd/config",
2523            "records/.hidden/x.md",
2524            "records/a b.md",
2525            "records\\win.md",
2526        ] {
2527            assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
2528        }
2529    }
2530
2531    // ── Config resolution (flag + file precedence; env is covered by the CLI
2532    //    integration tests, where a child process isolates it) ───────────────
2533
2534    #[test]
2535    fn hub_config_flag_beats_file_and_requires_some_source() {
2536        let dir = tempfile::tempdir().unwrap();
2537        std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
2538        std::fs::write(
2539            dir.path().join(CONFIG_REL_PATH),
2540            "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
2541        )
2542        .unwrap();
2543
2544        let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
2545        assert_eq!(from_flag.hub, "https://flag.example.com");
2546
2547        let from_file = hub_config(None, dir.path()).unwrap();
2548        assert_eq!(from_file.hub, "https://file.example.com");
2549
2550        let none = hub_config(None, tempfile::tempdir().unwrap().path());
2551        assert!(matches!(none, Err(LinkError::NoHub)));
2552    }
2553
2554    #[test]
2555    fn https_guard_allows_loopback_only_for_plain_http() {
2556        assert!(assert_safe_hub("https://hub.example.com").is_ok());
2557        assert!(assert_safe_hub("http://localhost:3000").is_ok());
2558        assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
2559        assert!(assert_safe_hub("http://[::1]:3000").is_ok());
2560        assert!(matches!(
2561            assert_safe_hub("http://hub.example.com"),
2562            Err(LinkError::UnsafeHub { .. })
2563        ));
2564        assert!(matches!(
2565            assert_safe_hub("hub.example.com"),
2566            Err(LinkError::UnsafeHub { .. })
2567        ));
2568        assert!(matches!(
2569            assert_safe_hub("http://localhost:80@127.0.0.1:1"),
2570            Err(LinkError::UnsafeHub { .. })
2571        ));
2572        assert!(matches!(
2573            assert_safe_hub("https://hub.example.com@attacker.example"),
2574            Err(LinkError::UnsafeHub { .. })
2575        ));
2576    }
2577
2578    #[test]
2579    fn https_guard_matches_the_scheme_case_insensitively() {
2580        // RFC 3986 schemes are case-insensitive: an uppercase-scheme HTTPS
2581        // hub is still HTTPS, never a misleading non-HTTPS refusal.
2582        assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
2583        assert!(assert_safe_hub("Https://hub.example.com").is_ok());
2584        // And an uppercase plain-HTTP hub is still refused outside loopback.
2585        assert!(matches!(
2586            assert_safe_hub("HTTP://hub.example.com"),
2587            Err(LinkError::UnsafeHub { .. })
2588        ));
2589    }
2590
2591    #[test]
2592    fn clean_key_refuses_paste_artifacts_without_echoing() {
2593        assert_eq!(clean_key("  vc_account_abc  ").unwrap(), "vc_account_abc");
2594        for bad in ["vc account", "vc\naccount", "ключ", ""] {
2595            let err = clean_key(bad).unwrap_err();
2596            assert!(matches!(err, LinkError::BadKey));
2597            assert!(
2598                !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
2599                "error must not echo the key"
2600            );
2601        }
2602    }
2603
2604    // ── Verb entry gates: refs must never reshape the request path ──────────
2605
2606    /// A config whose hub passes the loopback guard but is never listened on:
2607    /// every refusal below must come from the entry gate BEFORE a request
2608    /// exists — a dial on this dead port would surface `Transport` instead.
2609    fn dead_hub() -> HubConfig {
2610        HubConfig {
2611            hub: "http://127.0.0.1:9".to_string(),
2612            key: Some("k".to_string()),
2613            agent_key: None,
2614            brain_key: None,
2615        }
2616    }
2617
2618    #[test]
2619    fn request_retries_a_connection_failure_before_sending() {
2620        use std::io::{Read as _, Write as _};
2621        use std::net::TcpListener;
2622        use std::thread;
2623        use std::time::Duration;
2624
2625        let probe = TcpListener::bind("127.0.0.1:0").unwrap();
2626        let address = probe.local_addr().unwrap();
2627        drop(probe);
2628        let server = thread::spawn(move || {
2629            thread::sleep(Duration::from_millis(40));
2630            let listener = TcpListener::bind(address).unwrap();
2631            let (mut stream, _) = listener.accept().unwrap();
2632            let mut request_bytes = [0_u8; 1024];
2633            let _ = stream.read(&mut request_bytes).unwrap();
2634            stream
2635                .write_all(
2636                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
2637                )
2638                .unwrap();
2639        });
2640        let cfg = HubConfig {
2641            hub: format!("http://{address}"),
2642            key: None,
2643            agent_key: None,
2644            brain_key: None,
2645        };
2646
2647        let response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
2648        assert_eq!(response.status, 200);
2649        assert_eq!(response.body, Some(json!({ "ok": true })));
2650        server.join().unwrap();
2651    }
2652
2653    #[test]
2654    fn verb_entry_gates_accept_the_hub_ref_shapes() {
2655        for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
2656            assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
2657            assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
2658            assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
2659        }
2660    }
2661
2662    #[test]
2663    fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
2664        let cfg = dead_hub();
2665        for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
2666            assert!(
2667                matches!(
2668                    sync_pull(&cfg, bad, None),
2669                    Err(LinkError::BadAddress { .. })
2670                ),
2671                "sync_pull must refuse {bad:?}"
2672            );
2673            assert!(
2674                matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
2675                "sync_push must refuse {bad:?}"
2676            );
2677            assert!(
2678                matches!(
2679                    grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
2680                    Err(LinkError::BadAddress { .. })
2681                ),
2682                "grant_issue must refuse {bad:?}"
2683            );
2684            assert!(
2685                matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
2686                "grant_list must refuse {bad:?}"
2687            );
2688            assert!(
2689                matches!(
2690                    grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
2691                    Err(LinkError::BadAddress { .. })
2692                ),
2693                "grant_revoke must refuse brain {bad:?}"
2694            );
2695            assert!(
2696                matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
2697                "head must refuse {bad:?}"
2698            );
2699        }
2700    }
2701
2702    #[test]
2703    fn grant_revoke_refuses_url_reshaping_grant_ids() {
2704        let cfg = dead_hub();
2705        for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
2706            assert!(
2707                matches!(
2708                    grant_revoke(&cfg, "acme", bad),
2709                    Err(LinkError::BadGrantId { .. })
2710                ),
2711                "grant_revoke must refuse grant id {bad:?}"
2712            );
2713        }
2714    }
2715
2716    #[test]
2717    fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
2718        let cfg = dead_hub();
2719        for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
2720            assert!(
2721                matches!(
2722                    propose(&cfg, bad, "intake", "hi"),
2723                    Err(LinkError::BadAddress { .. })
2724                ),
2725                "propose must refuse handle {bad:?}"
2726            );
2727        }
2728        let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
2729        assert!(matches!(
2730            propose(&cfg, "acme-site", "intake", &oversize),
2731            Err(LinkError::ProposeTooLarge { .. })
2732        ));
2733        // A clean handle + in-cap body passes both gates: the failure is now
2734        // the (dead) wire, proving the gates refuse shape, not the verb.
2735        assert!(matches!(
2736            propose(&cfg, "acme-site", "intake", "hi"),
2737            Err(LinkError::Transport { .. })
2738        ));
2739    }
2740
2741    #[test]
2742    fn resolve_refuses_a_hand_built_unsafe_address() {
2743        let cfg = dead_hub();
2744        for brain in ["../up", "a/b", "a?x", "a#f"] {
2745            let addr = Address {
2746                brain: brain.to_string(),
2747                target: None,
2748            };
2749            assert!(
2750                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
2751                "resolve must refuse brain {brain:?}"
2752            );
2753        }
2754        for target in [
2755            AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
2756            AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), // not the minted shape
2757            AddressTarget::Path("../up.md".to_string()),
2758            AddressTarget::Path("records/x.md#frag".to_string()),
2759        ] {
2760            let addr = Address {
2761                brain: "acme".to_string(),
2762                target: Some(target.clone()),
2763            };
2764            assert!(
2765                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
2766                "resolve must refuse target {target:?}"
2767            );
2768        }
2769    }
2770}