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