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 one and only
72/// credential source — see the module docs for why it is never file-based.
73pub const HUB_KEY_ENV: &str = "DBMD_HUB_KEY";
74
75/// The store-local config file, relative to the store root. Holds non-secret
76/// toolkit state (`hub = <URL>`); hidden, so every store walk skips it.
77pub const CONFIG_REL_PATH: &str = ".dbmd/config";
78
79/// The most this client will buffer from one hub response. A full-store
80/// export is the biggest honest payload; anything past this is refused loudly
81/// rather than silently truncated.
82const MAX_RESPONSE_BYTES: u64 = 256 * 1024 * 1024;
83
84/// Direct JSON pushes stay below the serverless request-body cap. Larger
85/// snapshots switch to the bounded object-store pack lane.
86const MAX_PUSH_BYTES: usize = 4 * 1024 * 1024;
87
88/// The hub's per-push file-count cap, mirrored client-side.
89const MAX_PUSH_FILES: usize = 100_000;
90const MAX_STORE_BYTES: u64 = 512 * 1024 * 1024;
91const MAX_PACK_BYTES: u64 = 256 * 1024 * 1024;
92
93/// The hub's inbox cap on one `propose` submission body, mirrored client-side
94/// so an oversized body fails before the upload, not after (the same
95/// fail-before-upload contract as the push caps). Public so the CLI can
96/// pre-check a `--body-file` from file metadata without reading it.
97pub const MAX_PROPOSE_BYTES: u64 = 16 * 1024;
98
99/// Bounded connect so a dead hub fails fast; a generous read window so a
100/// large export on a slow link still completes.
101const CONNECT_TIMEOUT_SECS: u64 = 10;
102const READ_TIMEOUT_SECS: u64 = 120;
103
104/// Everything that can go wrong on the wire or at its edges. Each variant maps
105/// onto one stable CLI error code; messages are single-line and never echo the
106/// credential.
107#[derive(Debug, thiserror::Error)]
108pub enum LinkError {
109    /// No hub URL was configured anywhere (flag, env, `.dbmd/config`).
110    #[error(
111        "no hub configured — pass --hub <URL>, set {HUB_URL_ENV}, or add `hub = <URL>` to {CONFIG_REL_PATH}"
112    )]
113    NoHub,
114
115    /// The verb needs a credential and none was present.
116    #[error("no hub credential — set {HUB_KEY_ENV} (credentials never live in {CONFIG_REL_PATH})")]
117    NoCredential,
118
119    /// The credential contains whitespace / non-ASCII (a paste artifact). The
120    /// key is deliberately not echoed.
121    #[error(
122        "the hub credential in {HUB_KEY_ENV} contains whitespace or non-ASCII characters — re-copy it (the key is not shown here on purpose)"
123    )]
124    BadKey,
125
126    /// A non-HTTPS hub outside loopback: the bearer key would travel in cleartext.
127    #[error("refusing non-HTTPS hub {hub} — the credential would travel in cleartext (localhost is exempt)")]
128    UnsafeHub {
129        /// The offending hub URL.
130        hub: String,
131    },
132
133    /// TCP/TLS-level failure: the hub never answered.
134    #[error("hub unreachable at {hub}: {message}")]
135    Transport {
136        /// The hub base URL.
137        hub: String,
138        /// The transport-layer error text.
139        message: String,
140    },
141
142    /// The hub answered with an HTTP error status.
143    #[error("{what} failed (HTTP {status}): {message}")]
144    Http {
145        /// What the client was doing (e.g. `"resolve"`, `"sync pull"`).
146        what: &'static str,
147        /// The HTTP status code.
148        status: u16,
149        /// The hub's own `error` string when it sent one, else a placeholder.
150        message: String,
151        /// The hub's machine `code` field when it sent one.
152        code: Option<String>,
153    },
154
155    /// A 2xx whose body is not JSON — a captive portal, a proxy, or a wrong
156    /// URL — refused here rather than deserializing into nothing downstream.
157    #[error("{what}: the hub answered HTTP {status} with a non-JSON body — check the hub URL")]
158    NotJson {
159        /// What the client was doing.
160        what: &'static str,
161        /// The (2xx) status that carried the non-JSON body.
162        status: u16,
163    },
164
165    /// The hub response exceeded [`MAX_RESPONSE_BYTES`].
166    #[error("hub response exceeded {} MB — refusing to buffer it", MAX_RESPONSE_BYTES / (1024 * 1024))]
167    ResponseTooLarge,
168
169    /// A malformed `@brain/id` address.
170    #[error("invalid address `{given}`: {reason}")]
171    BadAddress {
172        /// The raw address as typed.
173        given: String,
174        /// Why it did not parse.
175        reason: String,
176    },
177
178    /// A grant id whose shape cannot travel as a URL path segment.
179    #[error(
180        "invalid grant id `{given}` — grant ids come from `grant list` (lowercase letters, digits, hyphens)"
181    )]
182    BadGrantId {
183        /// The raw id as typed.
184        given: String,
185    },
186
187    /// An exported file path that would escape or pollute the destination
188    /// (absolute, `..`, a dot-leading segment, or an illegal character). The
189    /// hub is not trusted with local path layout.
190    #[error("refusing unsafe path from the hub: `{path}`")]
191    UnsafePath {
192        /// The offending path as received.
193        path: String,
194    },
195
196    /// The store exceeds the hub's bounded whole-snapshot caps.
197    #[error(
198        "push too large ({detail}) — one snapshot caps at {} MB uncompressed, {} MB compressed, and {MAX_PUSH_FILES} files",
199        MAX_STORE_BYTES / (1024 * 1024),
200        MAX_PACK_BYTES / (1024 * 1024)
201    )]
202    PushTooLarge {
203        /// Which cap was hit, human-readable.
204        detail: String,
205    },
206
207    /// The propose body exceeds the hub's inbox cap.
208    #[error(
209        "propose body too large ({bytes} bytes) — the hub's inbox caps one submission at {} KB",
210        MAX_PROPOSE_BYTES / 1024
211    )]
212    ProposeTooLarge {
213        /// The offending body size in bytes.
214        bytes: u64,
215    },
216
217    /// A store file that is not valid UTF-8 cannot travel the JSON push path.
218    #[error("store file `{path}` is not valid UTF-8 — the JSON push path carries text only")]
219    NotUtf8 {
220        /// The store-relative path of the offending file.
221        path: String,
222    },
223
224    /// A downloaded pack failed validation before any local write.
225    #[error("invalid store pack: {message}")]
226    InvalidPack {
227        /// Hash, ZIP, path, count, or expansion failure.
228        message: String,
229    },
230
231    /// A signed feed entry, hash chain, or advertised feed head did not verify.
232    #[error("invalid signed feed: {message}")]
233    InvalidFeed {
234        /// The failed integrity condition, without untrusted secret material.
235        message: String,
236    },
237
238    /// Local filesystem failure while materializing a pull or reading a push.
239    #[error(transparent)]
240    Io(#[from] std::io::Error),
241
242    /// A store-level failure (walking the local store for a push).
243    #[error(transparent)]
244    Store(#[from] crate::StoreError),
245}
246
247/// Result alias for link.md client operations.
248pub type LinkResult<T> = std::result::Result<T, LinkError>;
249
250// ─────────────────────────────────────────────────────────────────────────────
251// Addressing — `@brain[/id]`, the reserved shape (SPEC § Addressing)
252// ─────────────────────────────────────────────────────────────────────────────
253
254/// What the part after `@brain/` names.
255#[derive(Debug, Clone, PartialEq, Eq)]
256pub enum AddressTarget {
257    /// A record `id` — the db.md lowercase ULID (the reserved `@brain/id` shape).
258    Id(String),
259    /// A store-relative `.md` path — a client-side convenience the hub's
260    /// resolve endpoint also accepts (`?path=`). Not part of the reserved
261    /// shape; unambiguous because a ULID is never a path.
262    Path(String),
263}
264
265/// Why a brain reference failed [`is_safe_ref`] — shared by [`Address::parse`]
266/// and the per-verb entry gates so the two surfaces never drift.
267const BAD_BRAIN_REASON: &str =
268    "the brain reference must be a brain id (lowercase ULID) or a slug (lowercase letters, digits, hyphens)";
269
270/// Why an address target failed its shape check — shared by [`Address::parse`]
271/// and the [`resolve`] entry gate.
272const BAD_TARGET_REASON: &str =
273    "the part after `/` must be a record id (lowercase ULID) or a store-relative `.md` path";
274
275/// A parsed `@brain[/target]` address. `brain` is a hub brain reference — the
276/// brain's ULID id (works for any caller, including cross-party on a public
277/// brain) or a slug (which a hub resolves only against the caller's own
278/// brains; slugs are unique per owner, not globally).
279#[derive(Debug, Clone, PartialEq, Eq)]
280pub struct Address {
281    /// The brain reference (leading `@` stripped).
282    pub brain: String,
283    /// The record target, when the address names one.
284    pub target: Option<AddressTarget>,
285}
286
287impl Address {
288    /// Parse `@brain`, `@brain/<ulid>`, or `@brain/<store-path>.md`. The `@`
289    /// sigil is optional (an agent piping ids around should not have to quote
290    /// it back on). Whitespace and empty segments are malformed.
291    pub fn parse(raw: &str) -> LinkResult<Address> {
292        let bad = |reason: &str| LinkError::BadAddress {
293            given: raw.to_string(),
294            reason: reason.to_string(),
295        };
296
297        let trimmed = raw.trim();
298        let body = trimmed.strip_prefix('@').unwrap_or(trimmed);
299        if body.is_empty() {
300            return Err(bad("empty address"));
301        }
302
303        let (brain, rest) = match body.split_once('/') {
304            Some((b, r)) => (b, Some(r)),
305            None => (body, None),
306        };
307
308        if brain.is_empty() {
309            return Err(bad("missing brain reference before `/`"));
310        }
311        if !is_safe_ref(brain) {
312            return Err(bad(BAD_BRAIN_REASON));
313        }
314
315        let target = match rest {
316            None => None,
317            Some("") => return Err(bad("trailing `/` with no record id or path")),
318            Some(r) if crate::ulid::is_ulid(r) => Some(AddressTarget::Id(r.to_string())),
319            Some(r) => {
320                if !safe_store_rel_path(r) || !r.ends_with(".md") {
321                    return Err(bad(BAD_TARGET_REASON));
322                }
323                Some(AddressTarget::Path(r.to_string()))
324            }
325        };
326
327        Ok(Address {
328            brain: brain.to_string(),
329            target,
330        })
331    }
332}
333
334/// A brain reference safe to embed in a URL path segment: the shapes a hub
335/// accepts (ULID id or slug), which are also exactly URL-path-clean.
336fn is_safe_ref(s: &str) -> bool {
337    !s.is_empty()
338        && s.len() <= 64
339        && s.bytes()
340            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
341}
342
343/// A published-site handle (the `propose` target). Same lexical shape as a
344/// slug.
345pub fn is_valid_handle(s: &str) -> bool {
346    is_safe_ref(s)
347}
348
349/// True when `p` is a store-relative path this client will read from or write
350/// to disk: relative, no `..`, no empty or dot-leading segment (which shields
351/// `.dbmd/` and `.git/`), and only the hub-portable character set. Applied to
352/// every path an export hands us (the hub is not trusted with local layout)
353/// and to every path a push sends (mirroring the hub's own gate).
354pub fn safe_store_rel_path(p: &str) -> bool {
355    if p.is_empty() || p.len() > 512 || p.starts_with('/') {
356        return false;
357    }
358    if !p
359        .bytes()
360        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/'))
361    {
362        return false;
363    }
364    p.split('/')
365        .all(|seg| !seg.is_empty() && seg != "." && seg != ".." && !seg.starts_with('.'))
366}
367
368/// Entry gate for every verb that embeds a caller-supplied brain reference in
369/// a URL path segment. `resolve` reaches the same check through
370/// [`Address::parse`]; the raw-ref verbs (`sync`, `grant`, `subscribe`) call
371/// this directly, so a ref carrying `/`, `..`, `?`, `#`, or any other
372/// URL-reshaping byte is refused before a request exists (the `url` crate
373/// normalizes dot segments, so an unvalidated ref would redirect the
374/// authenticated request to a different hub path).
375fn require_safe_ref(brain: &str) -> LinkResult<()> {
376    if is_safe_ref(brain) {
377        Ok(())
378    } else {
379        Err(LinkError::BadAddress {
380            given: brain.to_string(),
381            reason: BAD_BRAIN_REASON.to_string(),
382        })
383    }
384}
385
386/// Entry gate for the published-site handle `propose` embeds in its URL path.
387fn require_valid_handle(handle: &str) -> LinkResult<()> {
388    if is_valid_handle(handle) {
389        Ok(())
390    } else {
391        Err(LinkError::BadAddress {
392            given: handle.to_string(),
393            reason: "the site handle must be lowercase letters, digits, hyphens".to_string(),
394        })
395    }
396}
397
398/// Entry gate for the grant id `grant revoke` embeds in its URL path. Hub
399/// grant ids are lowercase ULIDs; the gate accepts the same URL-path-clean
400/// shape as a brain ref rather than pinning one mint scheme.
401fn require_safe_grant_id(id: &str) -> LinkResult<()> {
402    if is_safe_ref(id) {
403        Ok(())
404    } else {
405        Err(LinkError::BadGrantId {
406            given: id.to_string(),
407        })
408    }
409}
410
411// ─────────────────────────────────────────────────────────────────────────────
412// Configuration — flag > env > .dbmd/config; credential from env only
413// ─────────────────────────────────────────────────────────────────────────────
414
415/// The resolved client configuration for one invocation.
416#[derive(Debug, Clone)]
417pub struct HubConfig {
418    /// The hub base URL, trailing slash stripped, HTTPS-or-loopback enforced.
419    pub hub: String,
420    /// The bearer credential, when the environment carries one.
421    pub key: Option<String>,
422}
423
424impl HubConfig {
425    /// The credential, or the canonical "not configured" error. Verbs that
426    /// authenticate call this; `propose` never does.
427    pub fn require_key(&self) -> LinkResult<&str> {
428        self.key.as_deref().ok_or(LinkError::NoCredential)
429    }
430}
431
432/// Resolve the client configuration: `flag_hub` beats [`HUB_URL_ENV`] beats
433/// the `hub =` line in `<dir>/.dbmd/config`; no fallback default exists. The
434/// credential comes from [`HUB_KEY_ENV`] alone and is validated as a clean
435/// header token (never echoed on failure).
436pub fn hub_config(flag_hub: Option<&str>, dir: &Path) -> LinkResult<HubConfig> {
437    let hub = flag_hub
438        .map(str::to_string)
439        .or_else(|| env_nonempty(HUB_URL_ENV))
440        .or_else(|| config_file_hub(&dir.join(CONFIG_REL_PATH)))
441        .ok_or(LinkError::NoHub)?;
442    let hub = hub.trim().trim_end_matches('/').to_string();
443    assert_safe_hub(&hub)?;
444
445    let key = match env_nonempty(HUB_KEY_ENV) {
446        Some(raw) => Some(clean_key(&raw)?),
447        None => None,
448    };
449
450    Ok(HubConfig { hub, key })
451}
452
453/// An env var, treated as absent when unset or empty (an empty
454/// `DBMD_HUB_KEY=` falls through rather than becoming an empty credential).
455fn env_nonempty(name: &str) -> Option<String> {
456    std::env::var(name).ok().filter(|v| !v.trim().is_empty())
457}
458
459/// Read the `hub = <URL>` line out of a `.dbmd/config` file. The format is
460/// deliberately minimal: `key = value` lines, `#` comments, unknown keys
461/// ignored (forward-compatible). A missing or unreadable file is simply "not
462/// configured here".
463fn config_file_hub(path: &Path) -> Option<String> {
464    let text = std::fs::read_to_string(path).ok()?;
465    for line in text.lines() {
466        let line = line.trim();
467        if line.is_empty() || line.starts_with('#') {
468            continue;
469        }
470        if let Some((k, v)) = line.split_once('=') {
471            if k.trim() == "hub" {
472                let v = v.trim();
473                if !v.is_empty() {
474                    return Some(v.to_string());
475                }
476            }
477        }
478    }
479    None
480}
481
482/// The bearer key must never travel in cleartext; only loopback hosts may
483/// skip TLS (local development against a hub on localhost).
484fn assert_safe_hub(hub: &str) -> LinkResult<()> {
485    let parsed = url::Url::parse(hub).map_err(|_| LinkError::UnsafeHub {
486        hub: hub.to_string(),
487    })?;
488    if !parsed.username().is_empty()
489        || parsed.password().is_some()
490        || parsed.query().is_some()
491        || parsed.fragment().is_some()
492    {
493        return Err(LinkError::UnsafeHub {
494            hub: hub.to_string(),
495        });
496    }
497    let loopback = match parsed.host() {
498        Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
499        Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
500        Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
501        None => false,
502    };
503    if parsed.scheme().eq_ignore_ascii_case("https") || loopback {
504        Ok(())
505    } else {
506        Err(LinkError::UnsafeHub {
507            hub: hub.to_string(),
508        })
509    }
510}
511
512/// Trim paste artifacts and refuse anything outside the printable-ASCII token
513/// range WITHOUT echoing the key — an HTTP library rejecting a bad header
514/// value tends to echo the whole header line, credential included, so the
515/// gate sits here instead.
516fn clean_key(raw: &str) -> LinkResult<String> {
517    let k = raw.trim();
518    if k.is_empty() || k.bytes().any(|b| !(0x21..=0x7e).contains(&b)) {
519        return Err(LinkError::BadKey);
520    }
521    Ok(k.to_string())
522}
523
524// ─────────────────────────────────────────────────────────────────────────────
525// Transport — one blocking agent, capped reads, the JSON-or-refuse contract
526// ─────────────────────────────────────────────────────────────────────────────
527
528/// One hub response: the status plus the parsed JSON body when there was one.
529#[derive(Debug)]
530pub struct HubResponse {
531    /// The HTTP status code.
532    pub status: u16,
533    /// The parsed JSON body, `None` when the body was empty or not JSON.
534    pub body: Option<Value>,
535}
536
537/// Whether a request carries the bearer credential.
538#[derive(Debug, Clone, Copy, PartialEq, Eq)]
539enum Auth {
540    /// Send `authorization: Bearer <key>`; error without a key.
541    Required,
542    /// Send no credential — the propose door is unauthenticated by design.
543    None,
544}
545
546fn agent() -> ureq::Agent {
547    ureq::AgentBuilder::new()
548        .user_agent(concat!("dbmd/", env!("CARGO_PKG_VERSION")))
549        // Never follow a redirect while a bearer, a store pack, or a signed
550        // response is in flight. Callers see the 3xx as a non-success instead
551        // of letting an origin steer sensitive material elsewhere.
552        .redirects(0)
553        .timeout_connect(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
554        .timeout_read(std::time::Duration::from_secs(READ_TIMEOUT_SECS))
555        .build()
556}
557
558/// Perform one hub request. `path` is the binding path (starts with `/`);
559/// `body` posts JSON. Transport failures, oversized bodies, and non-UTF-8 are
560/// all surfaced as typed [`LinkError`]s; HTTP error statuses are returned in
561/// the [`HubResponse`] for [`ensure_ok`] to shape.
562fn request(
563    cfg: &HubConfig,
564    method: &str,
565    path: &str,
566    body: Option<&Value>,
567    auth: Auth,
568) -> LinkResult<HubResponse> {
569    let url = format!("{}{}", cfg.hub, path);
570    let mut req = agent().request(method, &url);
571    if auth == Auth::Required {
572        req = req.set("authorization", &format!("Bearer {}", cfg.require_key()?));
573    }
574
575    let result = match body {
576        Some(v) => req
577            .set("content-type", "application/json")
578            .send_string(&v.to_string()),
579        None => req.call(),
580    };
581
582    let resp = match result {
583        Ok(resp) => resp,
584        Err(ureq::Error::Status(_, resp)) => resp,
585        Err(ureq::Error::Transport(t)) => {
586            return Err(LinkError::Transport {
587                hub: cfg.hub.clone(),
588                message: t.to_string(),
589            })
590        }
591    };
592
593    let status = resp.status();
594    let mut buf = Vec::new();
595    resp.into_reader()
596        .take(MAX_RESPONSE_BYTES + 1)
597        .read_to_end(&mut buf)?;
598    if buf.len() as u64 > MAX_RESPONSE_BYTES {
599        return Err(LinkError::ResponseTooLarge);
600    }
601    let parsed: Option<Value> = serde_json::from_slice(&buf).ok();
602    Ok(HubResponse {
603        status,
604        body: parsed,
605    })
606}
607
608fn assert_safe_presigned_url(raw: &str) -> LinkResult<()> {
609    let parsed = url::Url::parse(raw).map_err(|_| LinkError::InvalidPack {
610        message: "the hub returned an invalid object-store URL".to_string(),
611    })?;
612    if !parsed.scheme().eq_ignore_ascii_case("https")
613        || !parsed.username().is_empty()
614        || parsed.password().is_some()
615        || parsed.fragment().is_some()
616    {
617        return Err(LinkError::InvalidPack {
618            message: "the hub returned an unsafe object-store URL".to_string(),
619        });
620    }
621    Ok(())
622}
623
624fn put_presigned(raw: &str, headers: &Value, bytes: &[u8]) -> LinkResult<()> {
625    assert_safe_presigned_url(raw)?;
626    let mut req = agent().put(raw);
627    if let Some(map) = headers.as_object() {
628        for (name, value) in map {
629            if let Some(value) = value.as_str() {
630                req = req.set(name, value);
631            }
632        }
633    }
634    match req.send_bytes(bytes) {
635        Ok(resp) if resp.status() < 300 => Ok(()),
636        Ok(resp) | Err(ureq::Error::Status(_, resp)) => Err(LinkError::Http {
637            what: "pack upload",
638            status: resp.status(),
639            message: "object store rejected the upload".to_string(),
640            code: None,
641        }),
642        Err(ureq::Error::Transport(err)) => Err(LinkError::Transport {
643            hub: "the object store".to_string(),
644            message: err.to_string(),
645        }),
646    }
647}
648
649fn get_presigned(raw: &str) -> LinkResult<Vec<u8>> {
650    assert_safe_presigned_url(raw)?;
651    let resp = match agent().get(raw).call() {
652        Ok(resp) => resp,
653        Err(ureq::Error::Status(_, resp)) => {
654            return Err(LinkError::Http {
655                what: "pack download",
656                status: resp.status(),
657                message: "object store rejected the download".to_string(),
658                code: None,
659            })
660        }
661        Err(ureq::Error::Transport(err)) => {
662            return Err(LinkError::Transport {
663                hub: "the object store".to_string(),
664                message: err.to_string(),
665            })
666        }
667    };
668    let mut bytes = Vec::new();
669    resp.into_reader()
670        .take(MAX_PACK_BYTES + 1)
671        .read_to_end(&mut bytes)?;
672    if bytes.len() as u64 > MAX_PACK_BYTES {
673        return Err(LinkError::InvalidPack {
674            message: "download exceeds the compressed-size limit".to_string(),
675        });
676    }
677    Ok(bytes)
678}
679
680/// Unwrap a successful JSON body, or shape the failure: a >=400 surfaces the
681/// hub's own `error` + `code`; a 2xx without JSON is refused as not a hub
682/// answer.
683fn ensure_ok(r: HubResponse, what: &'static str) -> LinkResult<Value> {
684    if r.status >= 400 {
685        let message = r
686            .body
687            .as_ref()
688            .and_then(|b| b.get("error"))
689            .and_then(Value::as_str)
690            .unwrap_or("unknown error")
691            .to_string();
692        let code = r
693            .body
694            .as_ref()
695            .and_then(|b| b.get("code"))
696            .and_then(Value::as_str)
697            .map(str::to_string);
698        return Err(LinkError::Http {
699            what,
700            status: r.status,
701            message,
702            code,
703        });
704    }
705    r.body.ok_or(LinkError::NotJson {
706        what,
707        status: r.status,
708    })
709}
710
711// ─────────────────────────────────────────────────────────────────────────────
712// resolve — handle → brain card; @brain/id → the record
713// ─────────────────────────────────────────────────────────────────────────────
714
715/// Resolve an address. A bare `@brain` returns the brain card (metadata +
716/// index stats — the v0 form of the card; keys arrive with the protocol's
717/// signing layer). `@brain/<id>` and `@brain/<path>.md` return the full
718/// record, frontmatter + body.
719pub fn resolve(cfg: &HubConfig, addr: &Address) -> LinkResult<Value> {
720    // `Address::parse` refuses these shapes already, but `Address` has public
721    // fields — re-assert at the wire so a hand-built address can never
722    // reshape the request path.
723    require_safe_ref(&addr.brain)?;
724    if let Some(target) = &addr.target {
725        let (given, ok) = match target {
726            AddressTarget::Id(id) => (id, crate::ulid::is_ulid(id)),
727            AddressTarget::Path(p) => (p, safe_store_rel_path(p) && p.ends_with(".md")),
728        };
729        if !ok {
730            return Err(LinkError::BadAddress {
731                given: given.clone(),
732                reason: BAD_TARGET_REASON.to_string(),
733            });
734        }
735    }
736
737    let path = match &addr.target {
738        None => format!("/api/hub/brains/{}", addr.brain),
739        Some(AddressTarget::Id(id)) => {
740            format!("/api/hub/brains/{}/resolve?id={id}", addr.brain)
741        }
742        Some(AddressTarget::Path(p)) => {
743            format!("/api/hub/brains/{}/resolve?path={p}", addr.brain)
744        }
745    };
746    ensure_ok(request(cfg, "GET", &path, None, Auth::Required)?, "resolve")
747}
748
749// ─────────────────────────────────────────────────────────────────────────────
750// sync — pull the granted slice as files; push the local store as a snapshot
751// ─────────────────────────────────────────────────────────────────────────────
752
753/// What a pull materialized.
754#[derive(Debug, serde::Serialize)]
755pub struct PullReport {
756    /// The brain id the hub reported.
757    pub brain: String,
758    /// The brain's slug.
759    pub slug: String,
760    /// The hub's feed head at export time.
761    #[serde(rename = "headSeq")]
762    pub head_seq: u64,
763    /// How many files were written.
764    pub files: usize,
765    /// Where they were written (as given or derived from the slug).
766    pub dest: String,
767    /// Local content files that the export did not carry — present so a
768    /// caller sees divergence; nothing is ever deleted locally.
769    #[serde(rename = "extraLocal")]
770    pub extra_local: Vec<String>,
771}
772
773/// Pull the granted slice of `brain` to `out` (default: `./<slug>`). Every
774/// exported path is safety-gated before it touches disk; files are written
775/// atomically; nothing local is ever deleted (locals the export lacks are
776/// *reported* in `extra_local` instead). Returns the report; rebuilding the
777/// local index catalog afterwards is the caller's (cheap, optional) step.
778pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
779    require_safe_ref(brain)?;
780    let path = format!("/api/hub/brains/{brain}/export?format=pack");
781    let body = ensure_ok(
782        request(cfg, "GET", &path, None, Auth::Required)?,
783        "sync pull",
784    )?;
785
786    let remote_slug = body
787        .get("slug")
788        .and_then(Value::as_str)
789        .filter(|slug| is_safe_slug(slug));
790    let slug = remote_slug
791        .or_else(|| is_safe_slug(brain).then_some(brain))
792        .unwrap_or("brain")
793        .to_string();
794    let brain_id = body
795        .get("brain")
796        .and_then(Value::as_str)
797        .unwrap_or(brain)
798        .to_string();
799    let head_seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
800    let dest: PathBuf = match out {
801        Some(p) => p.to_path_buf(),
802        None => PathBuf::from(&slug),
803    };
804    let entries =
805        if let Some(url) = body.get("url").and_then(Value::as_str) {
806            let expected = body
807                .get("sha256")
808                .and_then(Value::as_str)
809                .filter(|hash| {
810                    hash.len() == 64
811                        && hash
812                            .bytes()
813                            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
814                })
815                .ok_or_else(|| LinkError::InvalidPack {
816                    message: "the hub returned an invalid SHA-256".to_string(),
817                })?;
818            let bytes = get_presigned(url)?;
819            let actual = format!("{:x}", Sha256::digest(&bytes));
820            if actual != expected {
821                return Err(LinkError::InvalidPack {
822                    message: "SHA-256 verification failed".to_string(),
823                });
824            }
825            parse_store_pack(bytes)?
826        } else {
827            let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
828                LinkError::InvalidPack {
829                    message: "the hub returned neither a pack nor a file manifest".to_string(),
830                }
831            })?;
832            let mut entries = Vec::with_capacity(files.len());
833            for file in files {
834                let path = file.get("path").and_then(Value::as_str).ok_or_else(|| {
835                    LinkError::InvalidPack {
836                        message: "a file entry has no string path".to_string(),
837                    }
838                })?;
839                let content = file.get("content").and_then(Value::as_str).ok_or_else(|| {
840                    LinkError::InvalidPack {
841                        message: format!("file `{path}` has no string content"),
842                    }
843                })?;
844                entries.push((path.to_string(), content.as_bytes().to_vec()));
845            }
846            entries
847        };
848
849    // Gate the complete manifest before the first filesystem mutation.
850    let mut seen = std::collections::HashSet::new();
851    for (path, _) in &entries {
852        if !safe_store_rel_path(path) {
853            return Err(LinkError::UnsafePath { path: path.clone() });
854        }
855        if !seen.insert(path) {
856            return Err(LinkError::InvalidPack {
857                message: format!("duplicate path `{path}`"),
858            });
859        }
860    }
861    std::fs::create_dir_all(&dest)?;
862    let real_dest = std::fs::canonicalize(&dest)?;
863
864    for (p, content) in &entries {
865        let abs = dest.join(p);
866        if let Some(parent) = abs.parent() {
867            std::fs::create_dir_all(parent)?;
868            let real_parent = std::fs::canonicalize(parent)?;
869            if !real_parent.starts_with(&real_dest) {
870                return Err(LinkError::UnsafePath { path: p.clone() });
871            }
872        }
873        if std::fs::symlink_metadata(&abs).is_ok_and(|meta| meta.file_type().is_symlink()) {
874            return Err(LinkError::UnsafePath { path: p.clone() });
875        }
876        write_atomic(&abs, content)?;
877    }
878
879    // Divergence report: local content files the export did not carry. Only
880    // meaningful when the destination is (now) an openable store; a scoped
881    // pull may lack DB.md, in which case there is nothing to compare against.
882    let pulled: std::collections::BTreeSet<&str> =
883        entries.iter().map(|(p, _)| p.as_str()).collect();
884    let mut extra_local = Vec::new();
885    if let Ok(store) = Store::open(&dest) {
886        if let Ok(walked) = store.walk() {
887            for rel in walked {
888                let rel_str = rel.to_string_lossy().replace('\\', "/");
889                if !pulled.contains(rel_str.as_str()) {
890                    extra_local.push(rel_str);
891                }
892            }
893        }
894    }
895
896    Ok(PullReport {
897        brain: brain_id,
898        slug,
899        head_seq,
900        files: entries.len(),
901        dest: dest.to_string_lossy().into_owned(),
902        extra_local,
903    })
904}
905
906fn is_safe_slug(slug: &str) -> bool {
907    !slug.is_empty()
908        && slug.len() <= 63
909        && !slug.starts_with('-')
910        && !slug.ends_with('-')
911        && slug
912            .bytes()
913            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
914}
915
916fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
917    let mut archive =
918        zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
919            message: format!("ZIP parse failed: {err}"),
920        })?;
921    if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
922        return Err(LinkError::InvalidPack {
923            message: format!("invalid file count {}", archive.len()),
924        });
925    }
926    let mut total = 0u64;
927    let mut entries = Vec::with_capacity(archive.len());
928    for index in 0..archive.len() {
929        let mut file = archive
930            .by_index(index)
931            .map_err(|err| LinkError::InvalidPack {
932                message: format!("ZIP entry failed: {err}"),
933            })?;
934        if file.is_dir() {
935            continue;
936        }
937        let path = file.name().to_string();
938        if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
939            return Err(LinkError::UnsafePath { path });
940        }
941        if file
942            .unix_mode()
943            .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
944        {
945            return Err(LinkError::InvalidPack {
946                message: format!("non-file entry `{path}`"),
947            });
948        }
949        total = total.saturating_add(file.size());
950        if total > MAX_STORE_BYTES {
951            return Err(LinkError::InvalidPack {
952                message: "expanded content exceeds the 512 MB limit".to_string(),
953            });
954        }
955        let mut content = Vec::new();
956        file.read_to_end(&mut content)
957            .map_err(|err| LinkError::InvalidPack {
958                message: format!("could not decompress `{path}`: {err}"),
959            })?;
960        if content.len() as u64 != file.size() {
961            return Err(LinkError::InvalidPack {
962                message: format!("length mismatch for `{path}`"),
963            });
964        }
965        entries.push((path, content));
966    }
967    if entries.is_empty() {
968        return Err(LinkError::InvalidPack {
969            message: "pack contains no files".to_string(),
970        });
971    }
972    Ok(entries)
973}
974
975/// Collect the files a push sends: the store's owned text — `DB.md`,
976/// `assets.jsonl` when present, and every content `.md` under `records/` and
977/// `sources/` (the store walk, which already excludes hidden dirs like
978/// `.dbmd/`, the `log/` archive, and derived `index.*` catalogs; the hub
979/// derives its own index, and local history stays local). Returns
980/// `(store-relative path, content)` pairs, path-sorted.
981pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
982    let mut out: Vec<(String, String)> = Vec::new();
983
984    let read_text = |rel: &str| -> LinkResult<String> {
985        let abs = store.root.join(rel);
986        std::fs::read(&abs)
987            .map_err(LinkError::from)
988            .and_then(|bytes| {
989                String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
990                    path: rel.to_string(),
991                })
992            })
993    };
994
995    out.push(("DB.md".to_string(), read_text("DB.md")?));
996    if store.root.join("assets.jsonl").is_file() {
997        out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
998    }
999
1000    for rel in store.walk()? {
1001        let rel_str = rel.to_string_lossy().replace('\\', "/");
1002        if !safe_store_rel_path(&rel_str) {
1003            // A locally-legal name outside the hub's portable charset cannot
1004            // travel this wire; refusing beats silently dropping it.
1005            return Err(LinkError::UnsafePath { path: rel_str });
1006        }
1007        let content = read_text(&rel_str)?;
1008        out.push((rel_str, content));
1009    }
1010
1011    out.sort_by(|a, b| a.0.cmp(&b.0));
1012    Ok(out)
1013}
1014
1015/// Push `files` to `brain` as a whole-store snapshot — the hub's push
1016/// semantics: the hosted copy becomes exactly this set (pull first if the
1017/// hosted side may have records the local copy lacks). Client-side caps
1018/// mirror the hub's JSON-path limits so an oversized push fails before the
1019/// upload.
1020pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
1021    require_safe_ref(brain)?;
1022    if files.len() > MAX_PUSH_FILES {
1023        return Err(LinkError::PushTooLarge {
1024            detail: format!("{} files", files.len()),
1025        });
1026    }
1027    let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
1028    if raw_total > MAX_STORE_BYTES {
1029        return Err(LinkError::PushTooLarge {
1030            detail: format!("{raw_total} uncompressed bytes"),
1031        });
1032    }
1033
1034    let body = json!({
1035        "files": files
1036            .iter()
1037            .map(|(p, c)| json!({ "path": p, "content": c }))
1038            .collect::<Vec<_>>(),
1039    });
1040    if body.to_string().len() <= MAX_PUSH_BYTES {
1041        let path = format!("/api/hub/brains/{brain}/push");
1042        return ensure_ok(
1043            request(cfg, "POST", &path, Some(&body), Auth::Required)?,
1044            "sync push",
1045        );
1046    }
1047
1048    let pack = build_store_pack(files)?;
1049    if pack.len() as u64 > MAX_PACK_BYTES {
1050        return Err(LinkError::PushTooLarge {
1051            detail: format!("{} compressed bytes", pack.len()),
1052        });
1053    }
1054    let sha256 = format!("{:x}", Sha256::digest(&pack));
1055    let meta = json!({ "sha256": sha256, "bytes": pack.len() });
1056    let presigned = ensure_ok(
1057        request(
1058            cfg,
1059            "POST",
1060            &format!("/api/hub/brains/{brain}/packs/presign"),
1061            Some(&meta),
1062            Auth::Required,
1063        )?,
1064        "prepare pack upload",
1065    )?;
1066    let url = presigned
1067        .get("url")
1068        .and_then(Value::as_str)
1069        .ok_or_else(|| LinkError::InvalidPack {
1070            message: "the hub returned no upload URL".to_string(),
1071        })?;
1072    put_presigned(url, presigned.get("headers").unwrap_or(&Value::Null), &pack)?;
1073    ensure_ok(
1074        request(
1075            cfg,
1076            "POST",
1077            &format!("/api/hub/brains/{brain}/packs/commit"),
1078            Some(&meta),
1079            Auth::Required,
1080        )?,
1081        "commit pack",
1082    )
1083}
1084
1085fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
1086    let mut sorted: Vec<_> = files.iter().collect();
1087    sorted.sort_by(|a, b| a.0.cmp(&b.0));
1088    let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
1089    let options = zip::write::SimpleFileOptions::default()
1090        .compression_method(zip::CompressionMethod::Deflated)
1091        .last_modified_time(zip::DateTime::default())
1092        .unix_permissions(0o600);
1093    for (path, content) in sorted {
1094        writer
1095            .start_file(path, options)
1096            .map_err(|err| LinkError::InvalidPack {
1097                message: format!("could not create ZIP entry `{path}`: {err}"),
1098            })?;
1099        writer.write_all(content.as_bytes())?;
1100    }
1101    writer
1102        .finish()
1103        .map(Cursor::into_inner)
1104        .map_err(|err| LinkError::InvalidPack {
1105            message: format!("could not finish ZIP: {err}"),
1106        })
1107}
1108
1109// ─────────────────────────────────────────────────────────────────────────────
1110// grant — issue / list / revoke capabilities (owner-side)
1111// ─────────────────────────────────────────────────────────────────────────────
1112
1113/// The two capabilities a v0 hub enforces.
1114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1115pub enum Capability {
1116    /// Read the granted slice.
1117    Read,
1118    /// Read and push (whole-store; a path-scoped grant is read-only).
1119    Write,
1120}
1121
1122impl Capability {
1123    /// The wire form.
1124    pub fn as_str(self) -> &'static str {
1125        match self {
1126            Capability::Read => "read",
1127            Capability::Write => "write",
1128        }
1129    }
1130}
1131
1132/// Issue (or refresh) a grant on `brain` to `grantee` — a hub principal named
1133/// by email in v0 (the protocol's near-term simplification; key-named
1134/// grantees arrive with the signing layer). `scope` is a store-path prefix
1135/// (the hub's enforcement unit); `until` an ISO 8601 expiry, absent = until
1136/// revoked.
1137pub fn grant_issue(
1138    cfg: &HubConfig,
1139    brain: &str,
1140    grantee: &str,
1141    can: Capability,
1142    scope: Option<&str>,
1143    until: Option<&str>,
1144) -> LinkResult<Value> {
1145    require_safe_ref(brain)?;
1146    let mut body = json!({
1147        "email": grantee,
1148        "capability": can.as_str(),
1149    });
1150    if let Some(s) = scope {
1151        body["scopePrefix"] = json!(s);
1152    }
1153    if let Some(u) = until {
1154        body["expiresAt"] = json!(u);
1155    }
1156    let path = format!("/api/hub/brains/{brain}/grants");
1157    ensure_ok(
1158        request(cfg, "POST", &path, Some(&body), Auth::Required)?,
1159        "grant issue",
1160    )
1161}
1162
1163/// List the active grants (and pending invites) on `brain`. Owner-side.
1164pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
1165    require_safe_ref(brain)?;
1166    let path = format!("/api/hub/brains/{brain}/grants");
1167    ensure_ok(
1168        request(cfg, "GET", &path, None, Auth::Required)?,
1169        "grant list",
1170    )
1171}
1172
1173/// Revoke a grant (or cancel a pending invite) by id. Owner-side; revocation
1174/// is soft on the hub (the audit trail survives).
1175pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
1176    require_safe_ref(brain)?;
1177    require_safe_grant_id(grant_id)?;
1178    let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
1179    ensure_ok(
1180        request(cfg, "DELETE", &path, None, Auth::Required)?,
1181        "grant revoke",
1182    )
1183}
1184
1185// ─────────────────────────────────────────────────────────────────────────────
1186// propose — write without trust: evidence into the owner's inbox
1187// ─────────────────────────────────────────────────────────────────────────────
1188
1189/// Submit `body` to the published site `handle`, addressed to its app page
1190/// `app` (a page that declares the `write-inbox` capability). Deliberately
1191/// unauthenticated — this is the cross-party door; the submission lands as
1192/// *evidence* in the owner's `sources/inbox/`, never as truth, and the
1193/// owner's curator accepts or rejects it. Returns the hub's `{id, path}`
1194/// receipt.
1195pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
1196    require_valid_handle(handle)?;
1197    if body.len() as u64 > MAX_PROPOSE_BYTES {
1198        return Err(LinkError::ProposeTooLarge {
1199            bytes: body.len() as u64,
1200        });
1201    }
1202    let payload = json!({ "app": app, "body": body });
1203    let path = format!("/api/hub/sites/{handle}/inbox");
1204    ensure_ok(
1205        request(cfg, "POST", &path, Some(&payload), Auth::None)?,
1206        "propose",
1207    )
1208}
1209
1210// ─────────────────────────────────────────────────────────────────────────────
1211// subscribe — follow feed-head movement
1212// ─────────────────────────────────────────────────────────────────────────────
1213
1214/// One observation of a brain's feed head.
1215#[derive(Debug, serde::Serialize)]
1216pub struct Head {
1217    /// The brain id.
1218    pub brain: String,
1219    /// The hub's durable feed cursor — advances on every accepted write.
1220    pub seq: u64,
1221    /// The hub's `updatedAt` for the brain, when present.
1222    #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
1223    pub updated_at: Option<String>,
1224    /// SHA-256 of the exact signed head entry.
1225    #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
1226    pub feed_hash: Option<String>,
1227    /// Whether the head entry's content hash, identity, and Ed25519 signature
1228    /// were verified locally. Path-scoped grants get head movement only.
1229    pub verified: bool,
1230}
1231
1232#[derive(Debug, Deserialize, Serialize)]
1233struct FeedFile {
1234    path: String,
1235    sha256: String,
1236    bytes: u64,
1237}
1238
1239#[derive(Debug, Deserialize, Serialize)]
1240struct FeedEntry {
1241    v: u8,
1242    seq: u64,
1243    ts: String,
1244    brain: String,
1245    public_key: String,
1246    kind: String,
1247    op: String,
1248    pack_sha256: String,
1249    files: Vec<FeedFile>,
1250    removed: Vec<String>,
1251    prev_entry_hash: Option<String>,
1252    sig: String,
1253}
1254
1255#[derive(Serialize)]
1256struct UnsignedFeedEntry<'a> {
1257    v: u8,
1258    seq: u64,
1259    ts: &'a str,
1260    brain: &'a str,
1261    public_key: &'a str,
1262    kind: &'a str,
1263    op: &'a str,
1264    pack_sha256: &'a str,
1265    files: &'a [FeedFile],
1266    removed: &'a [String],
1267    prev_entry_hash: &'a Option<String>,
1268}
1269
1270#[derive(Debug, Deserialize)]
1271struct FeedItem {
1272    hash: String,
1273    entry: FeedEntry,
1274}
1275
1276#[derive(Debug, Deserialize)]
1277struct FeedIdentity {
1278    fingerprint: String,
1279    #[serde(rename = "publicKeySpki")]
1280    public_key_spki: String,
1281}
1282
1283#[derive(Debug, Deserialize)]
1284struct FeedResponse {
1285    #[serde(rename = "headSeq")]
1286    head_seq: u64,
1287    #[serde(rename = "feedHash")]
1288    feed_hash: Option<String>,
1289    identity: Option<FeedIdentity>,
1290    entries: Vec<FeedItem>,
1291    #[serde(rename = "scopeLimited")]
1292    scope_limited: bool,
1293}
1294
1295fn invalid_feed(message: impl Into<String>) -> LinkError {
1296    LinkError::InvalidFeed {
1297        message: message.into(),
1298    }
1299}
1300
1301fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
1302    const ED25519_SPKI_PREFIX: &[u8] = &[
1303        0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
1304    ];
1305    let entry = &item.entry;
1306    let public_der = URL_SAFE_NO_PAD
1307        .decode(&entry.public_key)
1308        .map_err(|_| invalid_feed("public key is not base64url"))?;
1309    if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
1310        || !public_der.starts_with(ED25519_SPKI_PREFIX)
1311        || identity.public_key_spki != entry.public_key
1312    {
1313        return Err(invalid_feed("public key does not match the brain card"));
1314    }
1315    let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
1316    if fingerprint != identity.fingerprint || entry.brain != format!("ed25519:{fingerprint}") {
1317        return Err(invalid_feed(
1318            "brain fingerprint does not match its public key",
1319        ));
1320    }
1321    let unsigned = UnsignedFeedEntry {
1322        v: entry.v,
1323        seq: entry.seq,
1324        ts: &entry.ts,
1325        brain: &entry.brain,
1326        public_key: &entry.public_key,
1327        kind: &entry.kind,
1328        op: &entry.op,
1329        pack_sha256: &entry.pack_sha256,
1330        files: &entry.files,
1331        removed: &entry.removed,
1332        prev_entry_hash: &entry.prev_entry_hash,
1333    };
1334    let message =
1335        serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
1336    let signature = URL_SAFE_NO_PAD
1337        .decode(&entry.sig)
1338        .map_err(|_| invalid_feed("signature is not base64url"))?;
1339    UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
1340        .verify(&message, &signature)
1341        .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
1342
1343    let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
1344    exact.push(b'\n');
1345    let actual_hash = format!("{:x}", Sha256::digest(&exact));
1346    if actual_hash != item.hash {
1347        return Err(invalid_feed("entry SHA-256 does not match"));
1348    }
1349    Ok(())
1350}
1351
1352/// Read and locally verify the brain's current signed feed head. `subscribe`
1353/// polls this as movement detection; the caller re-pulls or re-queries after
1354/// an advance.
1355pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
1356    require_safe_ref(brain)?;
1357    let path = format!("/api/hub/brains/{brain}");
1358    let body = ensure_ok(
1359        request(cfg, "GET", &path, None, Auth::Required)?,
1360        "subscribe",
1361    )?;
1362    let resolved_brain = body
1363        .get("id")
1364        .and_then(Value::as_str)
1365        .unwrap_or(brain)
1366        .to_string();
1367    let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
1368    let advertised_hash = body
1369        .get("feedHash")
1370        .and_then(Value::as_str)
1371        .map(str::to_string);
1372    let updated_at = body
1373        .get("updatedAt")
1374        .and_then(Value::as_str)
1375        .map(str::to_string);
1376    if seq == 0 {
1377        return Ok(Head {
1378            brain: resolved_brain,
1379            seq,
1380            updated_at,
1381            feed_hash: None,
1382            verified: true,
1383        });
1384    }
1385
1386    let feed_value = ensure_ok(
1387        request(
1388            cfg,
1389            "GET",
1390            &format!("/api/hub/brains/{brain}/feed?after={}&limit=1", seq - 1),
1391            None,
1392            Auth::Required,
1393        )?,
1394        "subscribe feed",
1395    )?;
1396    let feed: FeedResponse = serde_json::from_value(feed_value)
1397        .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
1398    if feed.head_seq != seq || feed.feed_hash != advertised_hash {
1399        return Err(invalid_feed("brain card and feed head disagree"));
1400    }
1401    if feed.scope_limited {
1402        return Ok(Head {
1403            brain: resolved_brain,
1404            seq,
1405            updated_at,
1406            feed_hash: advertised_hash,
1407            verified: false,
1408        });
1409    }
1410    let identity = feed
1411        .identity
1412        .as_ref()
1413        .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
1414    let item = feed
1415        .entries
1416        .first()
1417        .ok_or_else(|| invalid_feed("feed head entry is missing"))?;
1418    if item.entry.seq != seq || Some(&item.hash) != advertised_hash.as_ref() {
1419        return Err(invalid_feed(
1420            "advertised feed hash does not address the head entry",
1421        ));
1422    }
1423    verify_feed_item(item, identity)?;
1424    Ok(Head {
1425        brain: resolved_brain,
1426        seq,
1427        updated_at,
1428        feed_hash: advertised_hash,
1429        verified: true,
1430    })
1431}
1432
1433#[cfg(test)]
1434mod tests {
1435    use super::*;
1436
1437    #[test]
1438    fn signed_feed_item_verifies_identity_hash_and_signature() {
1439        use ring::rand::SystemRandom;
1440        use ring::signature::{Ed25519KeyPair, KeyPair};
1441
1442        const PREFIX: &[u8] = &[
1443            0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
1444        ];
1445        let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
1446        let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
1447        let mut spki = PREFIX.to_vec();
1448        spki.extend_from_slice(pair.public_key().as_ref());
1449        let public_key = URL_SAFE_NO_PAD.encode(&spki);
1450        let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
1451        let mut entry = FeedEntry {
1452            v: 1,
1453            seq: 1,
1454            ts: "2026-07-14T00:00:00.000Z".to_string(),
1455            brain: format!("ed25519:{fingerprint}"),
1456            public_key: public_key.clone(),
1457            kind: "push".to_string(),
1458            op: "snapshot".to_string(),
1459            pack_sha256: "a".repeat(64),
1460            files: vec![FeedFile {
1461                path: "DB.md".to_string(),
1462                sha256: "b".repeat(64),
1463                bytes: 3,
1464            }],
1465            removed: vec![],
1466            prev_entry_hash: None,
1467            sig: String::new(),
1468        };
1469        let unsigned = UnsignedFeedEntry {
1470            v: entry.v,
1471            seq: entry.seq,
1472            ts: &entry.ts,
1473            brain: &entry.brain,
1474            public_key: &entry.public_key,
1475            kind: &entry.kind,
1476            op: &entry.op,
1477            pack_sha256: &entry.pack_sha256,
1478            files: &entry.files,
1479            removed: &entry.removed,
1480            prev_entry_hash: &entry.prev_entry_hash,
1481        };
1482        entry.sig =
1483            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
1484        let mut exact = serde_json::to_vec(&entry).unwrap();
1485        exact.push(b'\n');
1486        let item = FeedItem {
1487            hash: format!("{:x}", Sha256::digest(&exact)),
1488            entry,
1489        };
1490        let identity = FeedIdentity {
1491            fingerprint,
1492            public_key_spki: public_key,
1493        };
1494        assert!(verify_feed_item(&item, &identity).is_ok());
1495        let mut tampered = item;
1496        tampered.entry.pack_sha256 = "c".repeat(64);
1497        assert!(verify_feed_item(&tampered, &identity).is_err());
1498    }
1499
1500    // ── Address parsing ─────────────────────────────────────────────────────
1501
1502    #[test]
1503    fn address_bare_brain_with_and_without_sigil() {
1504        for raw in ["@acme-ops", "acme-ops"] {
1505            let a = Address::parse(raw).expect(raw);
1506            assert_eq!(a.brain, "acme-ops");
1507            assert_eq!(a.target, None);
1508        }
1509    }
1510
1511    #[test]
1512    fn address_ulid_target_parses_as_id() {
1513        let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
1514        assert_eq!(a.brain, "acme");
1515        assert_eq!(
1516            a.target,
1517            Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
1518        );
1519    }
1520
1521    #[test]
1522    fn address_md_path_target_parses_as_path() {
1523        let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
1524        assert_eq!(
1525            a.target,
1526            Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
1527        );
1528    }
1529
1530    #[test]
1531    fn address_rejects_malformed_forms() {
1532        for raw in [
1533            "",
1534            "@",
1535            "@/x",
1536            "@acme/",
1537            "@acme/../etc/passwd",
1538            "@acme/records/.hidden.md",
1539            "@ACME",             // uppercase is not a hub ref shape
1540            "@acme/notes/x.txt", // target is neither ULID nor .md path
1541            "@a b",              // whitespace
1542        ] {
1543            assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
1544        }
1545    }
1546
1547    // ── Path safety ─────────────────────────────────────────────────────────
1548
1549    #[test]
1550    fn safe_paths_accept_store_shapes_and_reject_escapes() {
1551        for ok in [
1552            "DB.md",
1553            "assets.jsonl",
1554            "records/clients/lumio.md",
1555            "sources/emails/2026/07/x.md",
1556        ] {
1557            assert!(safe_store_rel_path(ok), "should accept {ok:?}");
1558        }
1559        for bad in [
1560            "",
1561            "/etc/passwd",
1562            "../up.md",
1563            "records/../../up.md",
1564            "records//x.md",
1565            ".dbmd/config",
1566            "records/.hidden/x.md",
1567            "records/a b.md",
1568            "records\\win.md",
1569        ] {
1570            assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
1571        }
1572    }
1573
1574    // ── Config resolution (flag + file precedence; env is covered by the CLI
1575    //    integration tests, where a child process isolates it) ───────────────
1576
1577    #[test]
1578    fn hub_config_flag_beats_file_and_requires_some_source() {
1579        let dir = tempfile::tempdir().unwrap();
1580        std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
1581        std::fs::write(
1582            dir.path().join(CONFIG_REL_PATH),
1583            "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
1584        )
1585        .unwrap();
1586
1587        let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
1588        assert_eq!(from_flag.hub, "https://flag.example.com");
1589
1590        let from_file = hub_config(None, dir.path()).unwrap();
1591        assert_eq!(from_file.hub, "https://file.example.com");
1592
1593        let none = hub_config(None, tempfile::tempdir().unwrap().path());
1594        assert!(matches!(none, Err(LinkError::NoHub)));
1595    }
1596
1597    #[test]
1598    fn https_guard_allows_loopback_only_for_plain_http() {
1599        assert!(assert_safe_hub("https://hub.example.com").is_ok());
1600        assert!(assert_safe_hub("http://localhost:3000").is_ok());
1601        assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
1602        assert!(assert_safe_hub("http://[::1]:3000").is_ok());
1603        assert!(matches!(
1604            assert_safe_hub("http://hub.example.com"),
1605            Err(LinkError::UnsafeHub { .. })
1606        ));
1607        assert!(matches!(
1608            assert_safe_hub("hub.example.com"),
1609            Err(LinkError::UnsafeHub { .. })
1610        ));
1611        assert!(matches!(
1612            assert_safe_hub("http://localhost:80@127.0.0.1:1"),
1613            Err(LinkError::UnsafeHub { .. })
1614        ));
1615        assert!(matches!(
1616            assert_safe_hub("https://hub.example.com@attacker.example"),
1617            Err(LinkError::UnsafeHub { .. })
1618        ));
1619    }
1620
1621    #[test]
1622    fn https_guard_matches_the_scheme_case_insensitively() {
1623        // RFC 3986 schemes are case-insensitive: an uppercase-scheme HTTPS
1624        // hub is still HTTPS, never a misleading non-HTTPS refusal.
1625        assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
1626        assert!(assert_safe_hub("Https://hub.example.com").is_ok());
1627        // And an uppercase plain-HTTP hub is still refused outside loopback.
1628        assert!(matches!(
1629            assert_safe_hub("HTTP://hub.example.com"),
1630            Err(LinkError::UnsafeHub { .. })
1631        ));
1632    }
1633
1634    #[test]
1635    fn clean_key_refuses_paste_artifacts_without_echoing() {
1636        assert_eq!(clean_key("  vc_account_abc  ").unwrap(), "vc_account_abc");
1637        for bad in ["vc account", "vc\naccount", "ключ", ""] {
1638            let err = clean_key(bad).unwrap_err();
1639            assert!(matches!(err, LinkError::BadKey));
1640            assert!(
1641                !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
1642                "error must not echo the key"
1643            );
1644        }
1645    }
1646
1647    // ── Verb entry gates: refs must never reshape the request path ──────────
1648
1649    /// A config whose hub passes the loopback guard but is never listened on:
1650    /// every refusal below must come from the entry gate BEFORE a request
1651    /// exists — a dial on this dead port would surface `Transport` instead.
1652    fn dead_hub() -> HubConfig {
1653        HubConfig {
1654            hub: "http://127.0.0.1:9".to_string(),
1655            key: Some("k".to_string()),
1656        }
1657    }
1658
1659    #[test]
1660    fn verb_entry_gates_accept_the_hub_ref_shapes() {
1661        for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
1662            assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
1663            assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
1664            assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
1665        }
1666    }
1667
1668    #[test]
1669    fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
1670        let cfg = dead_hub();
1671        for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
1672            assert!(
1673                matches!(
1674                    sync_pull(&cfg, bad, None),
1675                    Err(LinkError::BadAddress { .. })
1676                ),
1677                "sync_pull must refuse {bad:?}"
1678            );
1679            assert!(
1680                matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
1681                "sync_push must refuse {bad:?}"
1682            );
1683            assert!(
1684                matches!(
1685                    grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
1686                    Err(LinkError::BadAddress { .. })
1687                ),
1688                "grant_issue must refuse {bad:?}"
1689            );
1690            assert!(
1691                matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
1692                "grant_list must refuse {bad:?}"
1693            );
1694            assert!(
1695                matches!(
1696                    grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
1697                    Err(LinkError::BadAddress { .. })
1698                ),
1699                "grant_revoke must refuse brain {bad:?}"
1700            );
1701            assert!(
1702                matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
1703                "head must refuse {bad:?}"
1704            );
1705        }
1706    }
1707
1708    #[test]
1709    fn grant_revoke_refuses_url_reshaping_grant_ids() {
1710        let cfg = dead_hub();
1711        for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
1712            assert!(
1713                matches!(
1714                    grant_revoke(&cfg, "acme", bad),
1715                    Err(LinkError::BadGrantId { .. })
1716                ),
1717                "grant_revoke must refuse grant id {bad:?}"
1718            );
1719        }
1720    }
1721
1722    #[test]
1723    fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
1724        let cfg = dead_hub();
1725        for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
1726            assert!(
1727                matches!(
1728                    propose(&cfg, bad, "intake", "hi"),
1729                    Err(LinkError::BadAddress { .. })
1730                ),
1731                "propose must refuse handle {bad:?}"
1732            );
1733        }
1734        let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
1735        assert!(matches!(
1736            propose(&cfg, "acme-site", "intake", &oversize),
1737            Err(LinkError::ProposeTooLarge { .. })
1738        ));
1739        // A clean handle + in-cap body passes both gates: the failure is now
1740        // the (dead) wire, proving the gates refuse shape, not the verb.
1741        assert!(matches!(
1742            propose(&cfg, "acme-site", "intake", "hi"),
1743            Err(LinkError::Transport { .. })
1744        ));
1745    }
1746
1747    #[test]
1748    fn resolve_refuses_a_hand_built_unsafe_address() {
1749        let cfg = dead_hub();
1750        for brain in ["../up", "a/b", "a?x", "a#f"] {
1751            let addr = Address {
1752                brain: brain.to_string(),
1753                target: None,
1754            };
1755            assert!(
1756                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
1757                "resolve must refuse brain {brain:?}"
1758            );
1759        }
1760        for target in [
1761            AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
1762            AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), // not the minted shape
1763            AddressTarget::Path("../up.md".to_string()),
1764            AddressTarget::Path("records/x.md#frag".to_string()),
1765        ] {
1766            let addr = Address {
1767                brain: "acme".to_string(),
1768                target: Some(target.clone()),
1769            };
1770            assert!(
1771                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
1772                "resolve must refuse target {target:?}"
1773            );
1774        }
1775    }
1776}