Skip to main content

dbmd_core/
linkmd.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! The **link.md client** — the five interconnect verbs `dbmd` speaks against
4//! a hub: `resolve`, `sync`, `grant`, `propose`, `subscribe`.
5//!
6//! One binary, two specs (the git precedent: one binary carries both the
7//! object format and the wire protocol). The db.md FORMAT is untouched by this
8//! module: a store never needs link.md to be valid db.md, record files stay
9//! plain markdown, and SPEC.md reserves only the `@brain/id` address *shape*.
10//! Everything with a wire or a trust boundary — addressing across stores,
11//! pulling/pushing a hosted copy, capability grants, the propose door, feed
12//! polling and signed-entry verification — lives here, as a *client
13//! capability*, never a format requirement.
14//!
15//! # What this client speaks
16//!
17//! The v0 HTTP binding a hub serves under its base URL:
18//!
19//! | verb | binding |
20//! | --- | --- |
21//! | `resolve` | `GET /api/hub/brains/<brain>` (the brain card); records are read from the exact signed snapshot pack named by that card's verified feed head |
22//! | `sync` (pull) | `GET /api/hub/brains/<brain>/export?format=pack&atSeq=<n>&feedHash=<hash>` — the exact verified snapshot |
23//! | `sync` (push) | `POST /api/hub/brains/<brain>/push` for small snapshots; presign/upload/commit for large snapshots |
24//! | `grant` | `GET` / `POST /api/hub/brains/<brain>/grants`, `DELETE /api/hub/brains/<brain>/grants/<id>` |
25//! | `propose` | `POST /api/hub/sites/<handle>/inbox` — evidence in, without trust (unauthenticated by design) |
26//! | `subscribe` | `GET /api/hub/brains/<brain>` + `/feed` for a locally verified signed head |
27//!
28//! # Configuration — no default hub, credential never in the store
29//!
30//! There is **no built-in hub endpoint**: the toolkit is neutral and a hub is
31//! whatever the user points it at. Resolution order for the hub URL:
32//!
33//! 1. the `--hub <URL>` flag,
34//! 2. the `DBMD_HUB_URL` environment variable,
35//! 3. the `hub = <URL>` line in the store-local `.dbmd/config` file
36//!    (toolkit state, not store content — the walkers already skip hidden
37//!    directories, so `.dbmd/` never syncs, indexes, or validates).
38//!
39//! The credential is the `DBMD_HUB_KEY` environment variable, full stop. It is
40//! deliberately **not** read from `.dbmd/config`: a secret inside the store
41//! tree is one commit or one push away from leaking. A store-selected hub may
42//! receive an ambient bearer or agent key only when
43//! `DBMD_HUB_CREDENTIAL_ORIGIN` binds it to that exact origin. Identity pins
44//! and monotonic feed checkpoints live in the user's global state directory,
45//! never in store-controlled `.dbmd/`.
46//!
47//! Non-HTTPS hubs are refused (the bearer key must never travel in cleartext)
48//! with a loopback exemption for local development.
49//!
50//! # v0 honesty
51//!
52//! This client binds to what a hub enforces **today**: grantees are hub
53//! principals (an email), grant scopes are store-path prefixes, pushes are
54//! whole-store snapshots, and `subscribe` reports feed-head movement. The hub
55//! signs each committed snapshot in a hash-chained feed with a per-brain
56//! Ed25519 identity. This client verifies the rotation chain, signer epochs,
57//! monotonic feed checkpoint, snapshot token, and content-addressed pack before
58//! untrusted bytes touch their destination.
59
60use std::collections::{BTreeMap, BTreeSet};
61use std::io::{Cursor, Read, Write};
62use std::path::{Path, PathBuf};
63use std::time::{SystemTime, UNIX_EPOCH};
64
65use base64::{
66    engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
67    Engine as _,
68};
69use ring::signature::{UnparsedPublicKey, ED25519};
70use serde::{Deserialize, Serialize};
71use serde_json::{json, Value};
72use sha2::{Digest, Sha256};
73
74use crate::store::Store;
75
76/// Environment variable naming the hub base URL (e.g. `https://hub.example.com`).
77pub const HUB_URL_ENV: &str = "DBMD_HUB_URL";
78
79/// Environment variable carrying the hub bearer credential. The bearer
80/// credential source — see the module docs for why it is never store-based.
81pub const HUB_KEY_ENV: &str = "DBMD_HUB_KEY";
82
83/// Explicit origin binding required before an ambient bearer or agent key may
84/// be sent to a hub selected by untrusted store-local configuration. The value
85/// is an origin (`https://host[:port]`), not an arbitrary URL path.
86pub const HUB_CREDENTIAL_ORIGIN_ENV: &str = "DBMD_HUB_CREDENTIAL_ORIGIN";
87
88/// Override for dbmd's user-owned trust/checkpoint root. Must be absolute.
89/// Intended for managed installations and hermetic tests; untrusted stores
90/// cannot select it.
91pub const STATE_DIR_ENV: &str = "DBMD_STATE_DIR";
92
93/// Explicit test/development escape hatch for registry homes on loopback or
94/// private networks. Production federation rejects every non-public resolved
95/// address and pins the validated DNS answer into the HTTP agent.
96pub const ALLOW_PRIVATE_REGISTRY_HOME_ENV: &str = "DBMD_ALLOW_PRIVATE_REGISTRY_HOME";
97
98/// Explicit development escape hatch for object-store URLs on loopback/private
99/// networks. Production hubs must return public HTTPS URLs. A local loopback hub
100/// is allowed to use local object URLs without this switch.
101pub const ALLOW_PRIVATE_OBJECT_URL_ENV: &str = "DBMD_ALLOW_PRIVATE_OBJECT_URL";
102
103/// Environment variable naming the PATH of a self-custodied BRAIN key file
104/// (link.md §2.4). When set, `sync --push` signs each feed entry locally and
105/// ships it through the pack flow — the hub verifies and stores the exact
106/// client bytes and can never sign for the brain. Same file format as agent
107/// keys (`dbmd key generate`).
108pub const BRAIN_KEY_FILE_ENV: &str = "DBMD_BRAIN_KEY_FILE";
109
110/// Environment variable naming the PATH of an agent signing key file
111/// (link.md §8 `LinkMD-Sig` proof of possession). When set, authenticated
112/// requests are signed per-request with the agent's Ed25519 key instead of
113/// carrying a bearer: the signature binds method + path + body + a ±60s
114/// window, so nothing reusable ever crosses the wire or lands in a log or an
115/// agent transcript. The file holds the base64url PKCS#8 key minted by
116/// `dbmd key generate`; the path is not a secret, the file is (mode 0600).
117pub const AGENT_KEY_FILE_ENV: &str = "DBMD_AGENT_KEY_FILE";
118
119/// The store-local config file, relative to the store root. Holds non-secret
120/// toolkit state (`hub = <URL>`); hidden, so every store walk skips it.
121pub const CONFIG_REL_PATH: &str = ".dbmd/config";
122
123/// Control-plane JSON is metadata, never the store itself. Exact snapshot
124/// bytes travel through the separately bounded pack lane.
125const MAX_RESPONSE_BYTES: u64 = 8 * 1024 * 1024;
126/// A feed page can legitimately carry a large snapshot manifest, but remains
127/// far below a full pack and is parsed with count-bounded sequence visitors.
128const MAX_FEED_RESPONSE_BYTES: u64 = 16 * 1024 * 1024;
129/// Foreign registry cards are identity metadata only.
130const MAX_REGISTRY_CARD_BYTES: u64 = 1024 * 1024;
131
132/// Direct JSON pushes stay below the serverless request-body cap. Larger
133/// snapshots switch to the bounded object-store pack lane.
134const MAX_PUSH_BYTES: usize = 4 * 1024 * 1024;
135
136/// Canonical raw ZIP32 uses a u16 entry count and stores each UTF-8 name twice.
137const MAX_PUSH_FILES: usize = u16::MAX as usize;
138const MAX_STORE_PATH_BYTES: usize = 1_024;
139const MAX_STORE_BYTES: u64 = 512 * 1024 * 1024;
140/// Exact worst case for the canonical STORED profile:
141/// payload + (local 30 + central 46 + name twice) per entry + EOCD 22.
142const MAX_PACK_BYTES: u64 =
143    MAX_STORE_BYTES + MAX_PUSH_FILES as u64 * (76 + 2 * MAX_STORE_PATH_BYTES) as u64 + 22;
144/// A legitimate brain should rotate rarely. Bound adversarial identity
145/// histories before allocating and repeatedly verifying an unbounded chain.
146const MAX_IDENTITY_ROTATIONS: usize = 1_024;
147/// A client never needs to replay an unbounded feed in one invocation. Large
148/// histories are mirrored incrementally; a first mirror beyond this cap needs a
149/// checkpoint/export rather than allocating attacker-controlled metadata.
150const MAX_FEED_REPLAY_ENTRIES: u64 = 100_000;
151const MAX_FEED_REPLAY_BYTES: u64 = 64 * 1024 * 1024;
152const FEED_PAGE_LIMIT: usize = 100;
153
154/// The hub's inbox cap on one `propose` submission body, mirrored client-side
155/// so an oversized body fails before the upload, not after (the same
156/// fail-before-upload contract as the push caps). Public so the CLI can
157/// pre-check a `--body-file` from file metadata without reading it.
158pub const MAX_PROPOSE_BYTES: u64 = 16 * 1024;
159
160/// Bounded connect so a dead hub fails fast; a generous read window so a
161/// large export on a slow link still completes.
162const CONNECT_TIMEOUT_SECS: u64 = 10;
163const READ_TIMEOUT_SECS: u64 = 120;
164/// Hard wall-clock budget for one HTTP attempt, spanning socket writes,
165/// response headers, and the complete response body. DNS performed for pinned
166/// agents has its own interruptible deadline below.
167const OVERALL_REQUEST_TIMEOUT_SECS: u64 = 120;
168const CONNECT_ATTEMPTS: usize = 3;
169const CONNECT_RETRY_BACKOFF_MS: [u64; CONNECT_ATTEMPTS - 1] = [100, 300];
170/// Cold checkouts may need every visible blob, but short-lived direct object
171/// capabilities must not create an unbounded connection storm. Keep the
172/// verified downloads inside this fixed worker count.
173const V2_BLOB_DOWNLOAD_WORKERS: usize = 16;
174/// One authenticated link.md v2 bulk frame remains small enough for bounded
175/// retries and serverless streaming while amortizing tens of thousands of
176/// immutable blobs over a few hundred requests instead of one request each.
177const V2_BULK_STREAM_FILES: usize = 256;
178const V2_BULK_STREAM_CONTENT_BYTES: u64 = 8 * 1024 * 1024;
179const V2_BULK_STREAM_RESPONSE_BYTES: u64 = 10 * 1024 * 1024;
180const V2_BULK_STREAM_MAGIC: &[u8; 8] = b"LMD2STRM";
181
182/// Everything that can go wrong on the wire or at its edges. Each variant maps
183/// onto one stable CLI error code; messages are single-line and never echo the
184/// credential.
185#[derive(Debug, thiserror::Error)]
186pub enum LinkError {
187    /// No hub URL was configured anywhere (flag, env, `.dbmd/config`).
188    #[error(
189        "no hub configured — pass --hub <URL>, set {HUB_URL_ENV}, or add `hub = <URL>` to {CONFIG_REL_PATH}"
190    )]
191    NoHub,
192
193    /// The verb needs a credential and none was present.
194    #[error("no hub credential — set {HUB_KEY_ENV} (credentials never live in {CONFIG_REL_PATH})")]
195    NoCredential,
196
197    /// The credential contains whitespace / non-ASCII (a paste artifact). The
198    /// key is deliberately not echoed.
199    #[error(
200        "the hub credential in {HUB_KEY_ENV} contains whitespace or non-ASCII characters — re-copy it (the key is not shown here on purpose)"
201    )]
202    BadKey,
203
204    /// A store selected the destination while an ambient credential was
205    /// present, but the operator did not bind that credential to the same
206    /// origin. This is a hard refusal, not an anonymous fallback: silently
207    /// dropping a credential can turn an intended private operation into a
208    /// confusing public one.
209    #[error(
210        "refusing to send an ambient credential to the hub selected by {CONFIG_REL_PATH} — set {HUB_CREDENTIAL_ORIGIN_ENV} to that exact origin, or choose the hub explicitly with --hub/{HUB_URL_ENV}"
211    )]
212    UnboundCredential,
213
214    /// The agent signing key file named by [`AGENT_KEY_FILE_ENV`] is missing,
215    /// unreadable, or not a valid Ed25519 PKCS#8 — key material is never
216    /// echoed.
217    #[error("invalid agent signing key ({message}) — mint one with `dbmd key generate`")]
218    BadAgentKey {
219        /// What failed, without any key material.
220        message: String,
221    },
222
223    /// A non-HTTPS hub outside loopback: the bearer key would travel in cleartext.
224    #[error("refusing non-HTTPS hub {hub} — the credential would travel in cleartext (localhost is exempt)")]
225    UnsafeHub {
226        /// The offending hub URL.
227        hub: String,
228    },
229
230    /// TCP/TLS-level failure: the hub never answered.
231    #[error("hub unreachable at {hub}: {message}")]
232    Transport {
233        /// The hub base URL.
234        hub: String,
235        /// The transport-layer error text.
236        message: String,
237    },
238
239    /// The hub answered with an HTTP error status.
240    #[error("{what} failed (HTTP {status}): {message}")]
241    Http {
242        /// What the client was doing (e.g. `"resolve"`, `"sync pull"`).
243        what: &'static str,
244        /// The HTTP status code.
245        status: u16,
246        /// The hub's own `error` string when it sent one, else a placeholder.
247        message: String,
248        /// The hub's machine `code` field when it sent one.
249        code: Option<String>,
250        /// Permission-filtered structured refusal details, when supplied.
251        details: Option<Value>,
252    },
253
254    /// A 2xx whose body is not JSON — a captive portal, a proxy, or a wrong
255    /// URL — refused here rather than deserializing into nothing downstream.
256    #[error("{what}: the hub answered HTTP {status} with a non-JSON body — check the hub URL")]
257    NotJson {
258        /// What the client was doing.
259        what: &'static str,
260        /// The (2xx) status that carried the non-JSON body.
261        status: u16,
262    },
263
264    /// The hub response exceeded the selected endpoint's byte cap.
265    #[error("hub response exceeded the {limit_bytes}-byte endpoint cap — refusing to buffer it")]
266    ResponseTooLarge {
267        /// The endpoint-specific cap applied before JSON parsing.
268        limit_bytes: u64,
269    },
270
271    /// A malformed `@brain/id` address.
272    #[error("invalid address `{given}`: {reason}")]
273    BadAddress {
274        /// The raw address as typed.
275        given: String,
276        /// Why it did not parse.
277        reason: String,
278    },
279
280    /// A grant id whose shape cannot travel as a URL path segment.
281    #[error(
282        "invalid grant id `{given}` — grant ids come from `grant list` (lowercase letters, digits, hyphens)"
283    )]
284    BadGrantId {
285        /// The raw id as typed.
286        given: String,
287    },
288
289    /// An exported file path that would escape or pollute the destination
290    /// (absolute, `..`, a dot-leading segment, or an illegal character). The
291    /// hub is not trusted with local path layout.
292    #[error("refusing unsafe path from the hub: `{path}`")]
293    UnsafePath {
294        /// The offending path as received.
295        path: String,
296    },
297
298    /// The store exceeds the hub's bounded whole-snapshot caps.
299    #[error(
300        "push too large ({detail}) — one snapshot caps at {} MB uncompressed, {} MB as a pack, and {MAX_PUSH_FILES} files",
301        MAX_STORE_BYTES / (1024 * 1024),
302        MAX_PACK_BYTES / (1024 * 1024)
303    )]
304    PushTooLarge {
305        /// Which cap was hit, human-readable.
306        detail: String,
307    },
308
309    /// The propose body exceeds the hub's inbox cap.
310    #[error(
311        "propose body too large ({bytes} bytes) — the hub's inbox caps one submission at {} KB",
312        MAX_PROPOSE_BYTES / 1024
313    )]
314    ProposeTooLarge {
315        /// The offending body size in bytes.
316        bytes: u64,
317    },
318
319    /// A store file that is not valid UTF-8 cannot travel the JSON push path.
320    #[error("store file `{path}` is not valid UTF-8 — the JSON push path carries text only")]
321    NotUtf8 {
322        /// The store-relative path of the offending file.
323        path: String,
324    },
325
326    /// A downloaded pack failed validation before any local write.
327    #[error("invalid store pack: {message}")]
328    InvalidPack {
329        /// Hash, ZIP, path, count, or expansion failure.
330        message: String,
331    },
332
333    /// A signed feed entry, hash chain, or advertised feed head did not verify.
334    #[error("invalid signed feed: {message}")]
335    InvalidFeed {
336        /// The failed integrity condition, without untrusted secret material.
337        message: String,
338    },
339
340    /// Local and remote both changed one or more coordinates since the last
341    /// verified sync baseline. No side was overwritten.
342    #[error("sync conflict on {paths:?} — resolve the named files and retry")]
343    Conflict {
344        /// Bounded, portable paths safe to show to the agent/operator.
345        paths: Vec<String>,
346    },
347
348    /// A readable v2 conflict was preserved as a private, exact-head bundle.
349    /// The bundle is local control state; none of its bytes ride with the
350    /// brain, and resolving it always creates a fresh explicit mutation.
351    #[error(
352        "sync conflict preserved in private bundle `{bundle}` for {paths:?} — run `dbmd sync resolve {bundle} --keep-local`, `--take-remote`, or `--from <safe-file>`"
353    )]
354    ConflictBundle {
355        /// Local ULID naming `.dbmd/conflicts/<bundle>/plan.json`.
356        bundle: String,
357        /// Bounded, readable conflict paths.
358        paths: Vec<String>,
359    },
360
361    /// `.sevralocal` newly made paths eligible to ride. Uploading them is an
362    /// explicit adoption boundary, never an accidental side effect of editing
363    /// or removing a local policy file.
364    #[error(
365        "local sync policy newly exposes {paths:?} — review and retry with --resume-local-policy"
366    )]
367    LocalPolicyTransition {
368        /// Bounded local-only paths. They are never sent in telemetry.
369        paths: Vec<String>,
370    },
371
372    /// The exact mutation crosses a permissioned bulk-impact boundary. The
373    /// hub has not committed it; `preview` is the bounded, permission-filtered
374    /// receipt an agent must inspect before explicitly confirming the same
375    /// request.
376    #[error(
377        "bulk change requires explicit confirmation — review the preview and retry the same sync with --confirm-bulk <id>:<digest>"
378    )]
379    BulkPreviewRequired {
380        /// Stable structured preview returned by the hub.
381        preview: Value,
382    },
383
384    /// A scoped checkout's generated store marker was edited or removed.
385    /// It is local projection metadata and is never accepted as brain data.
386    #[error(
387        "the generated DB.md for this scoped view was modified — clone a fresh scoped checkout"
388    )]
389    ScopedProjectionModified,
390
391    /// The effective permission slice changed after this checkout was pinned.
392    /// Reusing the directory could conceal removals or accidentally adopt files
393    /// revealed by a wider grant, so a new checkout is required.
394    #[error(
395        "the checkout's permission scope changed — clone into a new directory to accept the new view"
396    )]
397    ScopedViewChanged,
398
399    /// A v2 identity/head was previously accepted for this ref, but the hub
400    /// now hides it. Never reinterpret that as a v1 downgrade.
401    #[error("the previously verified brain is unavailable — access may have been revoked or the brain removed")]
402    BrainUnavailable,
403
404    /// The verified remote head advanced while a pull or post-commit barrier
405    /// was in flight. The old baseline is deliberately retained.
406    #[error(
407        "the remote brain advanced during sync — retry to converge from the new verified head"
408    )]
409    RemoteAdvancedDuringSync,
410
411    /// This build cannot provide the no-follow, directory-handle-relative
412    /// filesystem semantics required for trust/key/snapshot state.
413    #[error(
414        "{operation} is unavailable on this platform — use the official macOS/Linux build or WSL"
415    )]
416    UnsupportedPlatform {
417        /// The operation that requires hardened local filesystem primitives.
418        operation: &'static str,
419    },
420
421    /// Local filesystem failure while materializing a pull or reading a push.
422    #[error(transparent)]
423    Io(#[from] std::io::Error),
424
425    /// A store-level failure (walking the local store for a push).
426    #[error(transparent)]
427    Store(#[from] crate::StoreError),
428}
429
430/// Result alias for link.md client operations.
431pub type LinkResult<T> = std::result::Result<T, LinkError>;
432
433/// Exact approval token returned by a permissioned v2 bulk preview.
434#[derive(Debug, Clone, PartialEq, Eq)]
435pub struct V2BulkConfirmation {
436    /// Opaque preview receipt id.
437    pub id: String,
438    /// Digest binding the receipt to principal, head, control revision, and
439    /// the exact strict mutation.
440    pub digest: String,
441}
442
443impl V2BulkConfirmation {
444    /// Parse the CLI/wire handoff form `<id>:<digest>` and reject anything
445    /// outside the protocol's exact lowercase shapes before network I/O.
446    pub fn parse(value: &str) -> LinkResult<Self> {
447        let (id, digest) = value
448            .split_once(':')
449            .ok_or_else(|| LinkError::InvalidPack {
450                message: "bulk confirmation must be <id>:<digest>".to_string(),
451            })?;
452        if !crate::ulid::is_ulid(id) || !is_sha256(digest) {
453            return Err(LinkError::InvalidPack {
454                message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
455                    .to_string(),
456            });
457        }
458        Ok(Self {
459            id: id.to_string(),
460            digest: digest.to_string(),
461        })
462    }
463}
464
465/// Security-sensitive link.md state and destination writes require a hardened
466/// descriptor/handle-relative filesystem implementation. Unix uses
467/// `openat`/`O_NOFOLLOW`; Windows uses held directory handles, reparse-point
468/// refusal, exclusive share modes, and write-through replacement.
469fn require_hardened_filesystem(operation: &'static str) -> LinkResult<()> {
470    #[cfg(any(target_os = "linux", target_os = "macos", windows))]
471    {
472        let _ = operation;
473        Ok(())
474    }
475    #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
476    {
477        Err(LinkError::UnsupportedPlatform { operation })
478    }
479}
480
481// ─────────────────────────────────────────────────────────────────────────────
482// Addressing — `@brain[/id]`, the reserved shape (SPEC § Addressing)
483// ─────────────────────────────────────────────────────────────────────────────
484
485/// What the part after `@brain/` names.
486#[derive(Debug, Clone, PartialEq, Eq)]
487pub enum AddressTarget {
488    /// A record `id` — the db.md lowercase ULID (the reserved `@brain/id` shape).
489    Id(String),
490    /// A store-relative `.md` path — a client-side convenience the hub's
491    /// resolve endpoint also accepts (`?path=`). Not part of the reserved
492    /// shape; unambiguous because a ULID is never a path.
493    Path(String),
494}
495
496/// Why a brain reference failed [`is_safe_ref`] — shared by [`Address::parse`]
497/// and the per-verb entry gates so the two surfaces never drift.
498const BAD_BRAIN_REASON: &str =
499    "the brain reference must be a brain id (lowercase ULID) or a slug (lowercase letters, digits, hyphens)";
500
501/// Why an address target failed its shape check — shared by [`Address::parse`]
502/// and the [`resolve`] entry gate.
503const BAD_TARGET_REASON: &str =
504    "the part after `/` must be a record id (lowercase ULID) or a store-relative `.md` path";
505
506/// A parsed `@brain[/target]` address. `brain` is a hub brain reference — the
507/// brain's ULID id (works for any caller, including cross-party on a public
508/// brain) or a slug (which a hub resolves only against the caller's own
509/// brains; slugs are unique per owner, not globally).
510#[derive(Debug, Clone, PartialEq, Eq)]
511pub struct Address {
512    /// The brain reference (leading `@` stripped).
513    pub brain: String,
514    /// The record target, when the address names one.
515    pub target: Option<AddressTarget>,
516}
517
518impl Address {
519    /// Parse `@brain`, `@brain/<ulid>`, or `@brain/<store-path>.md`. The `@`
520    /// sigil is optional (an agent piping ids around should not have to quote
521    /// it back on). Whitespace and empty segments are malformed.
522    pub fn parse(raw: &str) -> LinkResult<Address> {
523        let bad = |reason: &str| LinkError::BadAddress {
524            given: raw.to_string(),
525            reason: reason.to_string(),
526        };
527
528        let trimmed = raw.trim();
529        let body = trimmed.strip_prefix('@').unwrap_or(trimmed);
530        if body.is_empty() {
531            return Err(bad("empty address"));
532        }
533
534        let (brain, rest) = match body.split_once('/') {
535            Some((b, r)) => (b, Some(r)),
536            None => (body, None),
537        };
538
539        if brain.is_empty() {
540            return Err(bad("missing brain reference before `/`"));
541        }
542        if !is_safe_ref(brain) {
543            return Err(bad(BAD_BRAIN_REASON));
544        }
545
546        let target = match rest {
547            None => None,
548            Some("") => return Err(bad("trailing `/` with no record id or path")),
549            Some(r) if crate::ulid::is_ulid(r) => Some(AddressTarget::Id(r.to_string())),
550            Some(r) => {
551                if !safe_store_rel_path(r) || !r.ends_with(".md") {
552                    return Err(bad(BAD_TARGET_REASON));
553                }
554                Some(AddressTarget::Path(r.to_string()))
555            }
556        };
557
558        Ok(Address {
559            brain: brain.to_string(),
560            target,
561        })
562    }
563}
564
565/// A brain reference safe to embed in a URL path segment: the shapes a hub
566/// accepts (ULID id or slug), which are also exactly URL-path-clean.
567fn is_safe_ref(s: &str) -> bool {
568    !s.is_empty()
569        && s.len() <= 64
570        && s.bytes()
571            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
572}
573
574/// A published-site handle (the `propose` target). Same lexical shape as a
575/// slug.
576pub fn is_valid_handle(s: &str) -> bool {
577    is_safe_ref(s)
578}
579
580/// True when `p` is a store-relative path this client will read from or write
581/// to disk: relative, no `..`, no empty or dot-leading segment (which shields
582/// `.dbmd/` and `.git/`), and only the hub-portable character set. Applied to
583/// every path an export hands us (the hub is not trusted with local layout)
584/// and to every path a push sends (mirroring the hub's own gate).
585pub fn safe_store_rel_path(p: &str) -> bool {
586    if p.is_empty() || p.len() > MAX_STORE_PATH_BYTES || p.starts_with('/') {
587        return false;
588    }
589    if !p
590        .bytes()
591        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/'))
592    {
593        return false;
594    }
595    p.split('/')
596        .all(|seg| !seg.is_empty() && seg != "." && seg != ".." && !seg.starts_with('.'))
597}
598
599/// Entry gate for every verb that embeds a caller-supplied brain reference in
600/// a URL path segment. `resolve` reaches the same check through
601/// [`Address::parse`]; the raw-ref verbs (`sync`, `grant`, `subscribe`) call
602/// this directly, so a ref carrying `/`, `..`, `?`, `#`, or any other
603/// URL-reshaping byte is refused before a request exists (the `url` crate
604/// normalizes dot segments, so an unvalidated ref would redirect the
605/// authenticated request to a different hub path).
606fn require_safe_ref(brain: &str) -> LinkResult<()> {
607    if is_safe_ref(brain) {
608        Ok(())
609    } else {
610        Err(LinkError::BadAddress {
611            given: brain.to_string(),
612            reason: BAD_BRAIN_REASON.to_string(),
613        })
614    }
615}
616
617/// Entry gate for the published-site handle `propose` embeds in its URL path.
618fn require_valid_handle(handle: &str) -> LinkResult<()> {
619    if is_valid_handle(handle) {
620        Ok(())
621    } else {
622        Err(LinkError::BadAddress {
623            given: handle.to_string(),
624            reason: "the site handle must be lowercase letters, digits, hyphens".to_string(),
625        })
626    }
627}
628
629/// Entry gate for the grant id `grant revoke` embeds in its URL path. Hub
630/// grant ids are lowercase ULIDs; the gate accepts the same URL-path-clean
631/// shape as a brain ref rather than pinning one mint scheme.
632fn require_safe_grant_id(id: &str) -> LinkResult<()> {
633    if is_safe_ref(id) {
634        Ok(())
635    } else {
636        Err(LinkError::BadGrantId {
637            given: id.to_string(),
638        })
639    }
640}
641
642// ─────────────────────────────────────────────────────────────────────────────
643// Configuration — flag > env > .dbmd/config; credential from env only
644// ─────────────────────────────────────────────────────────────────────────────
645
646/// The resolved client configuration for one invocation.
647#[derive(Debug, Clone)]
648pub struct HubConfig {
649    /// The hub base URL, trailing slash stripped, HTTPS-or-loopback enforced.
650    pub hub: String,
651    /// The bearer credential, when the environment carries one.
652    pub key: Option<String>,
653    /// The agent signing key, when [`AGENT_KEY_FILE_ENV`] names one. Wins
654    /// over the bearer for authenticated requests (link.md §8).
655    pub agent_key: Option<AgentSigningKey>,
656    /// The self-custodied brain signing key, when [`BRAIN_KEY_FILE_ENV`]
657    /// names one — `sync --push` then signs feed entries locally (§2.4).
658    pub brain_key: Option<AgentSigningKey>,
659    /// User-owned global toolkit state root. Identity pins and monotonic feed
660    /// checkpoints live below `<state_dir>/trust/`, never under a store.
661    pub state_dir: PathBuf,
662    /// True only when the origin came from untrusted store-local configuration.
663    /// Such origins are resolved and public-IP-pinned for every request.
664    store_selected: bool,
665}
666
667/// A loaded agent signing key: the PKCS#8 secret plus its derived public
668/// multikey. Debug never prints key material.
669#[derive(Clone)]
670pub struct AgentSigningKey {
671    pkcs8: Vec<u8>,
672    /// The key's public identity, `ed25519:<base64url sha256(SPKI)>`.
673    pub multikey: String,
674    /// The full public key, `base64url(SPKI DER)` — what feed entries carry.
675    pub public_key_spki: String,
676}
677
678impl std::fmt::Debug for AgentSigningKey {
679    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
680        f.debug_struct("AgentSigningKey")
681            .field("multikey", &self.multikey)
682            .field("pkcs8", &"<redacted>")
683            .finish()
684    }
685}
686
687impl HubConfig {
688    /// The credential, or the canonical "not configured" error. Verbs that
689    /// authenticate call this; `propose` never does.
690    pub fn require_key(&self) -> LinkResult<&str> {
691        self.key.as_deref().ok_or(LinkError::NoCredential)
692    }
693}
694
695/// Resolve the client configuration: `flag_hub` beats [`HUB_URL_ENV`] beats
696/// the `hub =` line in `<dir>/.dbmd/config`; no fallback default exists. The
697/// credential comes from [`HUB_KEY_ENV`] alone and is validated as a clean
698/// header token (never echoed on failure).
699pub fn hub_config(flag_hub: Option<&str>, dir: &Path) -> LinkResult<HubConfig> {
700    let explicit_hub = flag_hub
701        .map(str::to_string)
702        .or_else(|| env_nonempty(HUB_URL_ENV));
703    let selected_by_store = explicit_hub.is_none();
704    let hub = explicit_hub
705        .or_else(|| config_file_hub(&dir.join(CONFIG_REL_PATH)))
706        .ok_or(LinkError::NoHub)?;
707    let hub = hub.trim().trim_end_matches('/').to_string();
708    assert_safe_hub(&hub)?;
709    if selected_by_store {
710        let parsed =
711            url::Url::parse(&hub).map_err(|_| LinkError::UnsafeHub { hub: hub.clone() })?;
712        // A cloned store is not an operator opt-in to local-network access.
713        // Local/private development hubs must be selected explicitly by flag or
714        // environment, never by bytes inside the store.
715        if !parsed.scheme().eq_ignore_ascii_case("https")
716            || (parsed.path() != "/" && !parsed.path().is_empty())
717        {
718            return Err(LinkError::UnsafeHub { hub });
719        }
720    }
721
722    let key = match env_nonempty(HUB_KEY_ENV) {
723        Some(raw) => Some(clean_key(&raw)?),
724        None => None,
725    };
726
727    let agent_key = match env_nonempty(AGENT_KEY_FILE_ENV) {
728        Some(path) => Some(load_agent_key(Path::new(&path))?),
729        None => None,
730    };
731
732    let brain_key = match env_nonempty(BRAIN_KEY_FILE_ENV) {
733        Some(path) => Some(load_agent_key(Path::new(&path))?),
734        None => None,
735    };
736
737    // A cloned store controls `.dbmd/config`. It must never be able to point
738    // the process at an attacker origin and harvest the user's ambient account
739    // bearer, signed agent identity, or self-custodied brain signatures/content.
740    // Explicit --hub/DBMD_HUB_URL selection already pairs target + credential
741    // in the invocation environment; a store-selected target additionally
742    // needs an exact origin binding.
743    if selected_by_store && (key.is_some() || agent_key.is_some() || brain_key.is_some()) {
744        let bound = env_nonempty(HUB_CREDENTIAL_ORIGIN_ENV)
745            .and_then(|value| normalized_origin(&value).ok());
746        let selected_origin = normalized_origin(&hub)?;
747        if bound.as_deref() != Some(selected_origin.as_str()) {
748            return Err(LinkError::UnboundCredential);
749        }
750    }
751
752    Ok(HubConfig {
753        hub,
754        key,
755        agent_key,
756        brain_key,
757        state_dir: toolkit_state_dir()?,
758        store_selected: selected_by_store,
759    })
760}
761
762fn toolkit_state_dir() -> LinkResult<PathBuf> {
763    if let Some(path) = env_nonempty(STATE_DIR_ENV) {
764        let path = PathBuf::from(path);
765        if !path.is_absolute() {
766            return Err(LinkError::UnsafePath {
767                path: path.display().to_string(),
768            });
769        }
770        return Ok(path);
771    }
772    #[cfg(windows)]
773    if let Some(base) = env_nonempty("LOCALAPPDATA") {
774        let base = PathBuf::from(base);
775        if base.is_absolute() {
776            return Ok(base.join("dbmd").join("state"));
777        }
778    }
779    #[cfg(windows)]
780    {
781        Err(LinkError::Io(std::io::Error::new(
782            std::io::ErrorKind::NotFound,
783            format!("cannot locate user state; set {STATE_DIR_ENV} or LOCALAPPDATA"),
784        )))
785    }
786    #[cfg(not(windows))]
787    if let Some(base) = env_nonempty("XDG_STATE_HOME") {
788        let base = PathBuf::from(base);
789        if base.is_absolute() {
790            return Ok(base.join("dbmd"));
791        }
792    }
793    #[cfg(not(windows))]
794    let home = PathBuf::from(env_nonempty("HOME").ok_or_else(|| {
795        LinkError::Io(std::io::Error::new(
796            std::io::ErrorKind::NotFound,
797            format!("cannot locate user state; set {STATE_DIR_ENV}"),
798        ))
799    })?);
800    #[cfg(not(windows))]
801    if !home.is_absolute() {
802        return Err(LinkError::UnsafePath {
803            path: home.display().to_string(),
804        });
805    }
806    #[cfg(target_os = "macos")]
807    {
808        Ok(home
809            .join("Library")
810            .join("Application Support")
811            .join("dbmd")
812            .join("state"))
813    }
814    #[cfg(all(not(target_os = "macos"), not(windows)))]
815    {
816        Ok(home.join(".local").join("state").join("dbmd"))
817    }
818}
819
820fn normalized_origin(value: &str) -> LinkResult<String> {
821    let parsed = url::Url::parse(value).map_err(|_| LinkError::UnsafeHub {
822        hub: value.to_string(),
823    })?;
824    if !(parsed.scheme().eq_ignore_ascii_case("https")
825        || parsed.scheme().eq_ignore_ascii_case("http"))
826        || !parsed.username().is_empty()
827        || parsed.password().is_some()
828        || (parsed.path() != "/" && !parsed.path().is_empty())
829        || parsed.query().is_some()
830        || parsed.fragment().is_some()
831    {
832        return Err(LinkError::UnsafeHub {
833            hub: value.to_string(),
834        });
835    }
836    let host = parsed.host_str().ok_or_else(|| LinkError::UnsafeHub {
837        hub: value.to_string(),
838    })?;
839    let host = if host.contains(':') {
840        format!("[{host}]")
841    } else {
842        host.to_ascii_lowercase()
843    };
844    let port = parsed
845        .port_or_known_default()
846        .ok_or_else(|| LinkError::UnsafeHub {
847            hub: value.to_string(),
848        })?;
849    let default = (parsed.scheme().eq_ignore_ascii_case("https") && port == 443)
850        || (parsed.scheme().eq_ignore_ascii_case("http") && port == 80);
851    Ok(format!(
852        "{}://{}{}",
853        parsed.scheme().to_ascii_lowercase(),
854        host,
855        if default {
856            String::new()
857        } else {
858            format!(":{port}")
859        }
860    ))
861}
862
863// ─────────────────────────────────────────────────────────────────────────────
864// Agent signing keys — link.md §8 `LinkMD-Sig` proof of possession
865// ─────────────────────────────────────────────────────────────────────────────
866
867/// The DER prefix that wraps a raw Ed25519 public key into a
868/// SubjectPublicKeyInfo (RFC 8410).
869const ED25519_SPKI_PREFIX: [u8; 12] = [
870    0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
871];
872
873fn bad_agent_key(message: &str) -> LinkError {
874    LinkError::BadAgentKey {
875        message: message.to_string(),
876    }
877}
878
879fn agent_keypair(pkcs8: &[u8]) -> LinkResult<ring::signature::Ed25519KeyPair> {
880    // `from_pkcs8` wants ring's own v2 encoding (private + public); keys from
881    // other tools are often PKCS#8 v1, which `maybe_unchecked` accepts by
882    // deriving the public half itself.
883    ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8)
884        .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(pkcs8))
885        .map_err(|_| bad_agent_key("not an Ed25519 PKCS#8 key"))
886}
887
888/// Derive `(publicKeySpki b64u, multikey)` from a keypair.
889fn public_identity_for(pair: &ring::signature::Ed25519KeyPair) -> (String, String) {
890    use ring::signature::KeyPair as _;
891    let mut spki = Vec::with_capacity(44);
892    spki.extend_from_slice(&ED25519_SPKI_PREFIX);
893    spki.extend_from_slice(pair.public_key().as_ref());
894    (
895        URL_SAFE_NO_PAD.encode(&spki),
896        format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&spki))),
897    )
898}
899
900/// Load and validate a signing-key file (agent or brain — same format):
901/// one base64url line of PKCS#8. Public so `dbmd key rotate` can load the
902/// old key explicitly.
903pub fn load_signing_key(path: &Path) -> LinkResult<AgentSigningKey> {
904    load_agent_key(path)
905}
906
907/// Load and validate the agent key file: one base64url line of PKCS#8.
908fn load_agent_key(path: &Path) -> LinkResult<AgentSigningKey> {
909    #[cfg(unix)]
910    let file = {
911        use std::os::fd::{AsRawFd as _, FromRawFd as _};
912        use std::os::unix::ffi::OsStrExt as _;
913        let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
914            .map_err(|e| bad_agent_key(&format!("cannot open the key parent: {e}")))?;
915        let leaf = path
916            .file_name()
917            .ok_or_else(|| bad_agent_key("the key path has no file name"))?;
918        let leaf = c_name(leaf.as_bytes(), &path.display().to_string())?;
919        let fd = unsafe {
920            libc::openat(
921                parent.as_raw_fd(),
922                leaf.as_ptr(),
923                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
924            )
925        };
926        if fd < 0 {
927            return Err(bad_agent_key(
928                "the key path must be an existing regular file without symlink ancestors",
929            ));
930        }
931        unsafe { std::fs::File::from_raw_fd(fd) }
932    };
933    #[cfg(not(unix))]
934    let file = std::fs::File::open(path)
935        .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
936    let metadata = file
937        .metadata()
938        .map_err(|e| bad_agent_key(&format!("cannot inspect the key file: {e}")))?;
939    if !metadata.is_file() {
940        return Err(bad_agent_key("the key path must be a regular file"));
941    }
942    #[cfg(unix)]
943    {
944        use std::os::unix::fs::PermissionsExt as _;
945        if metadata.permissions().mode() & 0o077 != 0 {
946            return Err(bad_agent_key(
947                "the key file is accessible to group/other; set mode 0600",
948            ));
949        }
950    }
951    let mut text = String::new();
952    file.take(1024 * 1024 + 1)
953        .read_to_string(&mut text)
954        .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
955    if text.len() > 1024 * 1024 {
956        return Err(bad_agent_key("the key file exceeds the size limit"));
957    }
958    let pkcs8 = URL_SAFE_NO_PAD
959        .decode(text.trim())
960        .map_err(|_| bad_agent_key("the key file is not one base64url line"))?;
961    let (public_key_spki, multikey) = public_identity_for(&agent_keypair(&pkcs8)?);
962    Ok(AgentSigningKey {
963        pkcs8,
964        multikey,
965        public_key_spki,
966    })
967}
968
969/// Durably create a new secret file without an exists→write race and without
970/// ever exposing a default-mode (commonly 0644) key between write and chmod.
971/// `create_new` also refuses a planted symlink. The file and its parent
972/// directory are synced before the caller can publish the corresponding
973/// public identity remotely.
974fn write_secret_new(path: &Path, bytes: &[u8]) -> LinkResult<()> {
975    #[cfg(unix)]
976    let (mut file, parent, leaf) = {
977        use std::os::fd::{AsRawFd as _, FromRawFd as _};
978        use std::os::unix::ffi::OsStrExt as _;
979        let parent = open_or_create_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))?;
980        let leaf_name = path
981            .file_name()
982            .ok_or_else(|| bad_agent_key("the output key path has no file name"))?;
983        let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
984        let fd = unsafe {
985            libc::openat(
986                parent.as_raw_fd(),
987                leaf.as_ptr(),
988                libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
989                0o600,
990            )
991        };
992        if fd < 0 {
993            let error = std::io::Error::last_os_error();
994            if error.kind() == std::io::ErrorKind::AlreadyExists {
995                return Err(bad_agent_key(
996                    "the output file already exists — refusing to overwrite a key",
997                ));
998            }
999            return Err(error.into());
1000        }
1001        (unsafe { std::fs::File::from_raw_fd(fd) }, parent, leaf)
1002    };
1003    #[cfg(not(unix))]
1004    let mut file = std::fs::OpenOptions::new()
1005        .write(true)
1006        .create_new(true)
1007        .open(path)
1008        .map_err(|error| {
1009            if error.kind() == std::io::ErrorKind::AlreadyExists {
1010                bad_agent_key("the output file already exists — refusing to overwrite a key")
1011            } else {
1012                LinkError::Io(error)
1013            }
1014        })?;
1015    if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_all()) {
1016        drop(file);
1017        #[cfg(unix)]
1018        let _ =
1019            unsafe { libc::unlinkat(std::os::fd::AsRawFd::as_raw_fd(&parent), leaf.as_ptr(), 0) };
1020        #[cfg(not(unix))]
1021        let _ = std::fs::remove_file(path);
1022        return Err(LinkError::Io(error));
1023    }
1024    drop(file);
1025    #[cfg(unix)]
1026    parent.sync_all()?;
1027    Ok(())
1028}
1029
1030/// What `dbmd key generate` returns: the public identity to register plus
1031/// where the secret landed.
1032#[derive(Debug, Serialize)]
1033pub struct GeneratedAgentKey {
1034    /// `ed25519:<fingerprint>` — the grantable/registerable identity.
1035    pub multikey: String,
1036    /// base64url SPKI DER — what a hub's register endpoint takes.
1037    #[serde(rename = "publicKeySpki")]
1038    pub public_key_spki: String,
1039    /// Where the PKCS#8 secret was written (mode 0600).
1040    #[serde(rename = "keyFile")]
1041    pub key_file: String,
1042}
1043
1044/// Mint a fresh Ed25519 agent keypair. The secret is written to `out`
1045/// (base64url PKCS#8, one line, 0600, refusing to overwrite); only public
1046/// identity is returned. The private key never enters a store and never
1047/// travels — requests carry per-request signatures instead (link.md §8).
1048pub fn generate_agent_key(out: &Path) -> LinkResult<GeneratedAgentKey> {
1049    require_hardened_filesystem("key generation")?;
1050    let rng = ring::rand::SystemRandom::new();
1051    let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
1052        .map_err(|_| bad_agent_key("key generation failed"))?;
1053    let pair = agent_keypair(pkcs8.as_ref())?;
1054    let (spki_b64u, multikey) = public_identity_for(&pair);
1055
1056    write_secret_new(
1057        out,
1058        format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
1059    )?;
1060
1061    Ok(GeneratedAgentKey {
1062        multikey,
1063        public_key_spki: spki_b64u,
1064        key_file: out.display().to_string(),
1065    })
1066}
1067
1068/// Build the origin-bound `LinkMD-Sig` v2 header for one request:
1069/// `canonical = "v2" LF origin LF METHOD LF path+query LF ts LF
1070/// (sha256hex(body) | "-")`.
1071///
1072/// The origin is derived from the already-validated hub URL, never from
1073/// attacker-controlled request metadata. Binding it closes the v1 replay
1074/// class where a proof captured by one hub could be replayed to another hub
1075/// serving the same path inside the timestamp window.
1076fn linkmd_sig_header(
1077    key: &AgentSigningKey,
1078    origin: &str,
1079    method: &str,
1080    path: &str,
1081    body: Option<&str>,
1082) -> LinkResult<String> {
1083    let ts = std::time::SystemTime::now()
1084        .duration_since(std::time::UNIX_EPOCH)
1085        .map_err(|_| bad_agent_key("system clock is before the epoch"))?
1086        .as_secs();
1087    let body_hash = match body {
1088        Some(b) => format!("{:x}", Sha256::digest(b.as_bytes())),
1089        None => "-".to_string(),
1090    };
1091    let canonical = format!(
1092        "v2\n{}\n{}\n{}\n{}\n{}",
1093        origin,
1094        method.to_uppercase(),
1095        path,
1096        ts,
1097        body_hash
1098    );
1099    let pair = agent_keypair(&key.pkcs8)?;
1100    let sig = URL_SAFE_NO_PAD.encode(pair.sign(canonical.as_bytes()).as_ref());
1101    let fingerprint = key.multikey.trim_start_matches("ed25519:");
1102    Ok(format!(
1103        "LinkMD-Sig v2,key=ed25519:{fingerprint},ts={ts},sig={sig}"
1104    ))
1105}
1106
1107// ─────────────────────────────────────────────────────────────────────────────
1108// Self-custody feed entries — the client signs what the hub only verifies
1109// ─────────────────────────────────────────────────────────────────────────────
1110
1111/// One `files` element of a wire-profile-v1 feed entry (SPEC §5.1: fields in
1112/// exactly this order).
1113#[derive(Serialize)]
1114struct WireFeedFile {
1115    path: String,
1116    sha256: String,
1117    bytes: u64,
1118}
1119
1120/// The unsigned entry in the normative §5.1 field order — serde serializes
1121/// struct fields in declaration order, which IS the wire contract.
1122#[derive(Serialize)]
1123struct UnsignedWireEntry<'a> {
1124    v: u8,
1125    seq: u64,
1126    ts: String,
1127    brain: &'a str,
1128    public_key: &'a str,
1129    kind: &'a str,
1130    op: &'a str,
1131    pack_sha256: &'a str,
1132    files: &'a [WireFeedFile],
1133    removed: &'a [String],
1134    prev_entry_hash: Option<&'a str>,
1135}
1136
1137/// Build and sign a wire-profile-v1 `push` feed entry with a self-custodied
1138/// brain key: serialize the unsigned entry compactly in the normative order,
1139/// Ed25519-sign those exact bytes, splice `sig` on as the final field. The
1140/// returned string is the exact serialization the hub stores verbatim (plus
1141/// one trailing newline) and every independent reader re-derives.
1142fn self_custody_entry(
1143    key: &AgentSigningKey,
1144    seq: u64,
1145    ts: String,
1146    pack_sha256: &str,
1147    files: &[WireFeedFile],
1148    prev_entry_hash: Option<&str>,
1149) -> LinkResult<String> {
1150    let removed: [String; 0] = [];
1151    let unsigned = serde_json::to_string(&UnsignedWireEntry {
1152        v: 1,
1153        seq,
1154        ts,
1155        brain: &key.multikey,
1156        public_key: &key.public_key_spki,
1157        kind: "push",
1158        op: "snapshot",
1159        pack_sha256,
1160        files,
1161        removed: &removed,
1162        prev_entry_hash,
1163    })
1164    .expect("serialize feed entry");
1165    let pair = agent_keypair(&key.pkcs8)?;
1166    let sig = URL_SAFE_NO_PAD.encode(pair.sign(unsigned.as_bytes()).as_ref());
1167    Ok(format!(
1168        "{},\"sig\":\"{}\"}}",
1169        &unsigned[..unsigned.len() - 1],
1170        sig
1171    ))
1172}
1173
1174/// An env var, treated as absent when unset or empty (an empty
1175/// `DBMD_HUB_KEY=` falls through rather than becoming an empty credential).
1176fn env_nonempty(name: &str) -> Option<String> {
1177    std::env::var(name).ok().filter(|v| !v.trim().is_empty())
1178}
1179
1180/// Read the `hub = <URL>` line out of a `.dbmd/config` file. The format is
1181/// deliberately minimal: `key = value` lines, `#` comments, unknown keys
1182/// ignored (forward-compatible). A missing or unreadable file is simply "not
1183/// configured here".
1184fn config_file_hub(path: &Path) -> Option<String> {
1185    const MAX_CONFIG_BYTES: u64 = 64 * 1024;
1186    #[cfg(unix)]
1187    let file = {
1188        use std::os::fd::{AsRawFd as _, FromRawFd as _};
1189        use std::os::unix::ffi::OsStrExt as _;
1190        let parent =
1191            open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new("."))).ok()?;
1192        let leaf = c_name(path.file_name()?.as_bytes(), &path.display().to_string()).ok()?;
1193        let fd = unsafe {
1194            libc::openat(
1195                parent.as_raw_fd(),
1196                leaf.as_ptr(),
1197                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1198            )
1199        };
1200        if fd < 0 {
1201            return None;
1202        }
1203        unsafe { std::fs::File::from_raw_fd(fd) }
1204    };
1205    #[cfg(not(unix))]
1206    let file = std::fs::File::open(path).ok()?;
1207    let metadata = file.metadata().ok()?;
1208    if !metadata.is_file() || metadata.len() > MAX_CONFIG_BYTES {
1209        return None;
1210    }
1211    let mut bytes = Vec::with_capacity(metadata.len() as usize);
1212    file.take(MAX_CONFIG_BYTES + 1)
1213        .read_to_end(&mut bytes)
1214        .ok()?;
1215    if bytes.len() as u64 > MAX_CONFIG_BYTES {
1216        return None;
1217    }
1218    let text = String::from_utf8(bytes).ok()?;
1219    for line in text.lines() {
1220        let line = line.trim();
1221        if line.is_empty() || line.starts_with('#') {
1222            continue;
1223        }
1224        if let Some((k, v)) = line.split_once('=') {
1225            if k.trim() == "hub" {
1226                let v = v.trim();
1227                if !v.is_empty() {
1228                    return Some(v.to_string());
1229                }
1230            }
1231        }
1232    }
1233    None
1234}
1235
1236/// The bearer key must never travel in cleartext; only loopback hosts may
1237/// skip TLS (local development against a hub on localhost).
1238fn assert_safe_hub(hub: &str) -> LinkResult<()> {
1239    let parsed = url::Url::parse(hub).map_err(|_| LinkError::UnsafeHub {
1240        hub: hub.to_string(),
1241    })?;
1242    if !(parsed.scheme().eq_ignore_ascii_case("https")
1243        || parsed.scheme().eq_ignore_ascii_case("http"))
1244        || !parsed.username().is_empty()
1245        || parsed.password().is_some()
1246        || (parsed.path() != "/" && !parsed.path().is_empty())
1247        || parsed.query().is_some()
1248        || parsed.fragment().is_some()
1249    {
1250        return Err(LinkError::UnsafeHub {
1251            hub: hub.to_string(),
1252        });
1253    }
1254    let loopback = match parsed.host() {
1255        Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
1256        Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
1257        Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
1258        None => false,
1259    };
1260    if parsed.scheme().eq_ignore_ascii_case("https") || loopback {
1261        Ok(())
1262    } else {
1263        Err(LinkError::UnsafeHub {
1264            hub: hub.to_string(),
1265        })
1266    }
1267}
1268
1269/// Trim paste artifacts and refuse anything outside the printable-ASCII token
1270/// range WITHOUT echoing the key — an HTTP library rejecting a bad header
1271/// value tends to echo the whole header line, credential included, so the
1272/// gate sits here instead.
1273fn clean_key(raw: &str) -> LinkResult<String> {
1274    let k = raw.trim();
1275    if k.is_empty() || k.bytes().any(|b| !(0x21..=0x7e).contains(&b)) {
1276        return Err(LinkError::BadKey);
1277    }
1278    Ok(k.to_string())
1279}
1280
1281// ─────────────────────────────────────────────────────────────────────────────
1282// Transport — one blocking agent, capped reads, the JSON-or-refuse contract
1283// ─────────────────────────────────────────────────────────────────────────────
1284
1285/// One hub response: the status plus the parsed JSON body when there was one.
1286#[derive(Debug)]
1287pub struct HubResponse {
1288    /// The HTTP status code.
1289    pub status: u16,
1290    /// The parsed JSON body, `None` when the body was empty or not JSON.
1291    pub body: Option<Value>,
1292}
1293
1294struct RawHubResponse {
1295    status: u16,
1296    body: Vec<u8>,
1297}
1298
1299/// Whether a request carries the bearer credential.
1300#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1301enum Auth {
1302    /// Send `authorization: Bearer <key>`; error without a key.
1303    Required,
1304    /// Send no credential — the propose door is unauthenticated by design.
1305    None,
1306    /// Send the configured credential when one exists, otherwise nothing —
1307    /// brain-addressed propose works anonymously on public brains, and an
1308    /// authenticated caller earns a bigger actor-class budget.
1309    Optional,
1310}
1311
1312fn agent_builder_with_timeout(overall: std::time::Duration) -> ureq::AgentBuilder {
1313    ureq::AgentBuilder::new()
1314        .user_agent(concat!("dbmd/", env!("CARGO_PKG_VERSION")))
1315        // Never follow a redirect while a bearer, a store pack, or a signed
1316        // response is in flight. Callers see the 3xx as a non-success instead
1317        // of letting an origin steer sensitive material elsewhere.
1318        .redirects(0)
1319        .timeout_connect(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
1320        .timeout_read(std::time::Duration::from_secs(READ_TIMEOUT_SECS))
1321        .timeout_write(overall)
1322        .timeout(overall)
1323}
1324
1325fn agent_builder() -> ureq::AgentBuilder {
1326    agent_builder_with_timeout(std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS))
1327}
1328
1329fn agent() -> ureq::Agent {
1330    agent_builder().build()
1331}
1332
1333fn hub_agent(cfg: &HubConfig) -> LinkResult<ureq::Agent> {
1334    if !cfg.store_selected {
1335        return Ok(agent());
1336    }
1337    let parsed = url::Url::parse(&cfg.hub).map_err(|_| LinkError::UnsafeHub {
1338        hub: cfg.hub.clone(),
1339    })?;
1340    pinned_public_agent(&parsed, false, "store-selected hub")
1341}
1342
1343/// Perform one hub request. `path` is the binding path (starts with `/`);
1344/// `body` posts JSON. Transport failures, oversized bodies, and non-UTF-8 are
1345/// all surfaced as typed [`LinkError`]s; HTTP error statuses are returned in
1346/// the [`HubResponse`] for [`ensure_ok`] to shape.
1347fn request_raw(
1348    cfg: &HubConfig,
1349    method: &str,
1350    path: &str,
1351    body: Option<&Value>,
1352    auth: Auth,
1353    max_response_bytes: u64,
1354) -> LinkResult<RawHubResponse> {
1355    let http = hub_agent(cfg)?;
1356    request_raw_with_agent(cfg, &http, method, path, body, auth, max_response_bytes)
1357}
1358
1359fn request_raw_with_agent(
1360    cfg: &HubConfig,
1361    http: &ureq::Agent,
1362    method: &str,
1363    path: &str,
1364    body: Option<&Value>,
1365    auth: Auth,
1366    max_response_bytes: u64,
1367) -> LinkResult<RawHubResponse> {
1368    let url = format!("{}{}", cfg.hub, path);
1369    let encoded_body = body.map(Value::to_string);
1370    let origin = normalized_origin(&cfg.hub)?;
1371    // An agent signing key outranks the bearer: possession proofs put nothing
1372    // reusable on the wire, so when both are configured the stronger one wins.
1373    let credential = match auth {
1374        Auth::Required => Some(match &cfg.agent_key {
1375            Some(key) => linkmd_sig_header(key, &origin, method, path, encoded_body.as_deref())?,
1376            None => format!("Bearer {}", cfg.require_key()?),
1377        }),
1378        Auth::Optional => match &cfg.agent_key {
1379            Some(key) => Some(linkmd_sig_header(
1380                key,
1381                &origin,
1382                method,
1383                path,
1384                encoded_body.as_deref(),
1385            )?),
1386            None => cfg.key.as_deref().map(|k| format!("Bearer {k}")),
1387        },
1388        Auth::None => None,
1389    };
1390    let result = with_connect_retries(|| {
1391        let mut req = http.request(method, &url);
1392        if let Some(value) = &credential {
1393            req = req.set("authorization", value);
1394        }
1395        match &encoded_body {
1396            Some(value) => req
1397                .set("content-type", "application/json")
1398                .send_string(value)
1399                .map_err(Box::new),
1400            None => req.call().map_err(Box::new),
1401        }
1402    });
1403    let resp = match result {
1404        Ok(resp) => resp,
1405        Err(error) => match *error {
1406            ureq::Error::Status(_, resp) => resp,
1407            ureq::Error::Transport(error) => {
1408                return Err(LinkError::Transport {
1409                    hub: cfg.hub.clone(),
1410                    message: error.to_string(),
1411                });
1412            }
1413        },
1414    };
1415
1416    let status = resp.status();
1417    let mut buf = Vec::new();
1418    resp.into_reader()
1419        .take(max_response_bytes + 1)
1420        .read_to_end(&mut buf)?;
1421    if buf.len() as u64 > max_response_bytes {
1422        return Err(LinkError::ResponseTooLarge {
1423            limit_bytes: max_response_bytes,
1424        });
1425    }
1426    Ok(RawHubResponse { status, body: buf })
1427}
1428
1429fn request_capped(
1430    cfg: &HubConfig,
1431    method: &str,
1432    path: &str,
1433    body: Option<&Value>,
1434    auth: Auth,
1435    max_response_bytes: u64,
1436) -> LinkResult<HubResponse> {
1437    let raw = request_raw(cfg, method, path, body, auth, max_response_bytes)?;
1438    let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
1439    Ok(HubResponse {
1440        status: raw.status,
1441        body: parsed,
1442    })
1443}
1444
1445fn request(
1446    cfg: &HubConfig,
1447    method: &str,
1448    path: &str,
1449    body: Option<&Value>,
1450    auth: Auth,
1451) -> LinkResult<HubResponse> {
1452    request_capped(cfg, method, path, body, auth, MAX_RESPONSE_BYTES)
1453}
1454
1455fn ensure_raw_ok(r: RawHubResponse, what: &'static str) -> LinkResult<Vec<u8>> {
1456    if (200..300).contains(&r.status) {
1457        return Ok(r.body);
1458    }
1459    ensure_ok(
1460        HubResponse {
1461            status: r.status,
1462            body: serde_json::from_slice(&r.body).ok(),
1463        },
1464        what,
1465    )
1466    .and_then(|_| Err(invalid_feed("a non-2xx response was accepted unexpectedly")))
1467}
1468
1469/// These failures happen before any HTTP request reaches the hub, so retrying
1470/// cannot duplicate a mutation. Mid-stream I/O is deliberately excluded: once
1471/// bytes may have crossed the wire, the caller must rely on the verb's own
1472/// idempotency contract instead of guessing.
1473fn is_pre_request_transport(kind: ureq::ErrorKind) -> bool {
1474    matches!(
1475        kind,
1476        ureq::ErrorKind::Dns | ureq::ErrorKind::ConnectionFailed | ureq::ErrorKind::ProxyConnect
1477    )
1478}
1479
1480fn with_connect_retries(
1481    mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
1482) -> Result<ureq::Response, Box<ureq::Error>> {
1483    let mut attempt = 0;
1484    loop {
1485        match send() {
1486            Err(error)
1487                if matches!(
1488                    error.as_ref(),
1489                    ureq::Error::Transport(transport)
1490                        if is_pre_request_transport(transport.kind())
1491                ) && attempt + 1 < CONNECT_ATTEMPTS =>
1492            {
1493                std::thread::sleep(std::time::Duration::from_millis(
1494                    CONNECT_RETRY_BACKOFF_MS[attempt],
1495                ));
1496                attempt += 1;
1497            }
1498            result => return result,
1499        }
1500    }
1501}
1502
1503fn hub_is_loopback(hub: &str) -> bool {
1504    url::Url::parse(hub).ok().is_some_and(|parsed| {
1505        parsed.host().is_some_and(|host| match host {
1506            url::Host::Domain(host) => host.eq_ignore_ascii_case("localhost"),
1507            url::Host::Ipv4(ip) => ip.is_loopback(),
1508            url::Host::Ipv6(ip) => ip.is_loopback(),
1509        })
1510    })
1511}
1512
1513fn presigned_agent(cfg: &HubConfig, raw: &str) -> LinkResult<ureq::Agent> {
1514    let parsed = url::Url::parse(raw).map_err(|_| LinkError::InvalidPack {
1515        message: "the hub returned an invalid object-store URL".to_string(),
1516    })?;
1517    let allow_private = hub_is_loopback(&cfg.hub)
1518        || env_nonempty(ALLOW_PRIVATE_OBJECT_URL_ENV).as_deref() == Some("1");
1519    if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1520        || !parsed.username().is_empty()
1521        || parsed.password().is_some()
1522        || parsed.fragment().is_some()
1523    {
1524        return Err(LinkError::InvalidPack {
1525            message: "the hub returned an unsafe object-store URL".to_string(),
1526        });
1527    }
1528    pinned_public_agent(&parsed, allow_private, "object-store URL").map_err(|_| {
1529        LinkError::InvalidPack {
1530            message: "the hub returned an object-store URL with an unsafe network target"
1531                .to_string(),
1532        }
1533    })
1534}
1535
1536fn put_presigned(cfg: &HubConfig, raw: &str, headers: &Value, bytes: &[u8]) -> LinkResult<()> {
1537    let http = presigned_agent(cfg, raw)?;
1538    let result = with_connect_retries(|| {
1539        let mut req = http.put(raw);
1540        if let Some(map) = headers.as_object() {
1541            for (name, value) in map {
1542                if let Some(value) = value.as_str() {
1543                    req = req.set(name, value);
1544                }
1545            }
1546        }
1547        req.send_bytes(bytes).map_err(Box::new)
1548    });
1549    match result {
1550        Ok(resp) if (200..300).contains(&resp.status()) => Ok(()),
1551        Ok(resp) => Err(LinkError::Http {
1552            what: "pack upload",
1553            status: resp.status(),
1554            message: "object store rejected the upload".to_string(),
1555            code: None,
1556            details: None,
1557        }),
1558        Err(error) => match *error {
1559            // Immutable uploads use If-None-Match. A concurrent writer may
1560            // win the same content address; the commit/confirm endpoint reads
1561            // and hashes that object before accepting it, so 412 safely means
1562            // "continue to verification", never "trust the upload".
1563            ureq::Error::Status(412, _) => Ok(()),
1564            ureq::Error::Status(_, resp) => Err(LinkError::Http {
1565                what: "pack upload",
1566                status: resp.status(),
1567                message: "object store rejected the upload".to_string(),
1568                code: None,
1569                details: None,
1570            }),
1571            ureq::Error::Transport(err) => Err(LinkError::Transport {
1572                hub: "the object store".to_string(),
1573                message: err.to_string(),
1574            }),
1575        },
1576    }
1577}
1578
1579fn one_past_bounded_limit(max_bytes: u64) -> Option<u64> {
1580    max_bytes.checked_add(1)
1581}
1582
1583fn presigned_download_read_limit() -> u64 {
1584    one_past_bounded_limit(MAX_PACK_BYTES)
1585        .expect("the fixed presigned-download ceiling must leave room for the refusal byte")
1586}
1587
1588fn get_presigned(cfg: &HubConfig, raw: &str) -> LinkResult<Vec<u8>> {
1589    let http = presigned_agent(cfg, raw)?;
1590    let resp = match with_connect_retries(|| http.get(raw).call().map_err(Box::new)) {
1591        Ok(resp) => resp,
1592        Err(error) => match *error {
1593            ureq::Error::Status(_, resp) => {
1594                return Err(LinkError::Http {
1595                    what: "pack download",
1596                    status: resp.status(),
1597                    message: "object store rejected the download".to_string(),
1598                    code: None,
1599                    details: None,
1600                });
1601            }
1602            ureq::Error::Transport(err) => {
1603                return Err(LinkError::Transport {
1604                    hub: "the object store".to_string(),
1605                    message: err.to_string(),
1606                });
1607            }
1608        },
1609    };
1610    if !(200..300).contains(&resp.status()) {
1611        return Err(LinkError::Http {
1612            what: "pack download",
1613            status: resp.status(),
1614            message: "object store rejected the download".to_string(),
1615            code: None,
1616            details: None,
1617        });
1618    }
1619    let mut bytes = Vec::new();
1620    resp.into_reader()
1621        .take(presigned_download_read_limit())
1622        .read_to_end(&mut bytes)?;
1623    if bytes.len() as u64 > MAX_PACK_BYTES {
1624        return Err(LinkError::InvalidPack {
1625            message: "download exceeds the compressed-size limit".to_string(),
1626        });
1627    }
1628    Ok(bytes)
1629}
1630
1631/// Unwrap a successful JSON body, or shape the failure: a >=400 surfaces the
1632/// hub's own `error` + `code`; a 2xx without JSON is refused as not a hub
1633/// answer.
1634fn ensure_ok(r: HubResponse, what: &'static str) -> LinkResult<Value> {
1635    if !(200..300).contains(&r.status) {
1636        let message = r
1637            .body
1638            .as_ref()
1639            .and_then(|b| b.get("error"))
1640            .and_then(Value::as_str)
1641            .unwrap_or("unknown error")
1642            .to_string();
1643        let code = r
1644            .body
1645            .as_ref()
1646            .and_then(|b| b.get("code"))
1647            .and_then(Value::as_str)
1648            .map(str::to_string);
1649        let details = r.body.as_ref().and_then(|b| b.get("details")).cloned();
1650        return Err(LinkError::Http {
1651            what,
1652            status: r.status,
1653            message,
1654            code,
1655            details,
1656        });
1657    }
1658    r.body.ok_or(LinkError::NotJson {
1659        what,
1660        status: r.status,
1661    })
1662}
1663
1664// ─────────────────────────────────────────────────────────────────────────────
1665// resolve — handle → brain card; @brain/id → the record
1666// ─────────────────────────────────────────────────────────────────────────────
1667
1668/// Resolve an address. A bare `@brain` returns the brain card (metadata +
1669/// index stats — the v0 form of the card; keys arrive with the protocol's
1670/// signing layer). `@brain/<id>` and `@brain/<path>.md` return the full
1671/// record, frontmatter + body.
1672fn is_public_registry_ip(ip: std::net::IpAddr) -> bool {
1673    match ip {
1674        std::net::IpAddr::V4(ip) => {
1675            let [a, b, c, _] = ip.octets();
1676            !(a == 0
1677                || a == 10
1678                || a == 127
1679                || (a == 100 && (64..=127).contains(&b))
1680                || (a == 169 && b == 254)
1681                || (a == 172 && (16..=31).contains(&b))
1682                || (a == 192 && b == 0 && c == 0)
1683                || (a == 192 && b == 0 && c == 2)
1684                || (a == 192 && b == 88 && c == 99)
1685                || (a == 192 && b == 168)
1686                || (a == 198 && (b == 18 || b == 19))
1687                || (a == 198 && b == 51 && c == 100)
1688                || (a == 203 && b == 0 && c == 113)
1689                || a >= 224)
1690        }
1691        std::net::IpAddr::V6(ip) => {
1692            let segments = ip.segments();
1693            // Conservatively accept only global unicast 2000::/3, excluding
1694            // special-purpose/transition blocks. In particular, 6to4 embeds
1695            // an IPv4 destination and must not tunnel a validated public
1696            // connect to 127/8 or RFC1918.
1697            (segments[0] & 0xe000) == 0x2000
1698                && !(segments[0] == 0x2001 && (segments[1] & 0xfe00) == 0)
1699                && !(segments[0] == 0x2001 && segments[1] == 0x0db8)
1700                && segments[0] != 0x2002
1701                && !(segments[0] == 0x3fff && (segments[1] & 0xf000) == 0)
1702        }
1703    }
1704}
1705
1706#[derive(Clone)]
1707struct PinnedRegistryResolver {
1708    netloc: String,
1709    addresses: Vec<std::net::SocketAddr>,
1710}
1711
1712impl ureq::Resolver for PinnedRegistryResolver {
1713    fn resolve(&self, requested: &str) -> std::io::Result<Vec<std::net::SocketAddr>> {
1714        if requested == self.netloc {
1715            Ok(self.addresses.clone())
1716        } else {
1717            Err(std::io::Error::new(
1718                std::io::ErrorKind::PermissionDenied,
1719                "registry request attempted to resolve an unvalidated authority",
1720            ))
1721        }
1722    }
1723}
1724
1725fn pinned_public_agent(
1726    url: &url::Url,
1727    allow_private: bool,
1728    label: &str,
1729) -> LinkResult<ureq::Agent> {
1730    let host = url
1731        .host_str()
1732        .ok_or_else(|| invalid_feed(format!("{label} has no host")))?;
1733    let port = url
1734        .port_or_known_default()
1735        .ok_or_else(|| invalid_feed(format!("{label} has no port")))?;
1736    let addresses = resolve_addresses_with_deadline(
1737        host,
1738        port,
1739        std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS),
1740    )
1741    .map_err(|error| invalid_feed(format!("{label} DNS resolution failed: {error}")))?;
1742    if addresses.is_empty() {
1743        return Err(invalid_feed(format!("{label} DNS returned no addresses")));
1744    }
1745    if !allow_private
1746        && addresses
1747            .iter()
1748            .any(|address| !is_public_registry_ip(address.ip()))
1749    {
1750        return Err(invalid_feed(format!(
1751            "{label} resolves to a non-public address"
1752        )));
1753    }
1754    let netloc = if host.contains(':') {
1755        format!("[{host}]:{port}")
1756    } else {
1757        format!("{host}:{port}")
1758    };
1759    Ok(agent_builder()
1760        .resolver(PinnedRegistryResolver { netloc, addresses })
1761        .build())
1762}
1763
1764/// Resolve one authority without allowing libc DNS to hold the caller forever.
1765/// `ToSocketAddrs` itself has no timeout API, so resolution runs in a detached
1766/// worker and only its result channel is awaited. A late resolver result has no
1767/// side effects and is discarded after the deadline.
1768fn resolve_addresses_with_deadline(
1769    host: &str,
1770    port: u16,
1771    timeout: std::time::Duration,
1772) -> std::io::Result<Vec<std::net::SocketAddr>> {
1773    use std::net::ToSocketAddrs as _;
1774
1775    let host = host.to_string();
1776    let (send, receive) = std::sync::mpsc::sync_channel(1);
1777    std::thread::Builder::new()
1778        .name("dbmd-dns".to_string())
1779        .spawn(move || {
1780            let result = (host.as_str(), port)
1781                .to_socket_addrs()
1782                .map(|addresses| addresses.collect());
1783            let _ = send.send(result);
1784        })
1785        .map_err(|error| std::io::Error::other(format!("cannot start resolver: {error}")))?;
1786    match receive.recv_timeout(timeout) {
1787        Ok(result) => result,
1788        Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(std::io::Error::new(
1789            std::io::ErrorKind::TimedOut,
1790            "resolution exceeded its deadline",
1791        )),
1792        Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(std::io::Error::other(
1793            "resolver stopped without returning a result",
1794        )),
1795    }
1796}
1797
1798fn registry_agent(url: &url::Url) -> LinkResult<ureq::Agent> {
1799    let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
1800    pinned_public_agent(url, allow_private, "registry home")
1801}
1802
1803/// GET an absolute URL as JSON with NO credential — used to fetch a brain card
1804/// from a FOREIGN home during registry resolution. DNS is resolved once,
1805/// every answer is required to be public, and that exact answer set is pinned
1806/// into a no-redirect HTTP agent to close private-network SSRF and rebinding.
1807fn get_json_absolute(url: &str) -> LinkResult<Value> {
1808    let parsed = url::Url::parse(url).map_err(|_| invalid_feed("invalid registry home URL"))?;
1809    let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
1810    if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1811        || !parsed.username().is_empty()
1812        || parsed.password().is_some()
1813        || parsed.query().is_some()
1814        || parsed.fragment().is_some()
1815    {
1816        return Err(invalid_feed("unsafe registry home URL"));
1817    }
1818    let http = registry_agent(&parsed)?;
1819    let resp = match with_connect_retries(|| http.get(url).call().map_err(Box::new)) {
1820        Ok(resp) => resp,
1821        Err(error) => match *error {
1822            ureq::Error::Status(status, resp) => {
1823                let _ = resp;
1824                return Err(LinkError::Http {
1825                    what: "registry home fetch",
1826                    status,
1827                    message: "the home node rejected the card request".to_string(),
1828                    code: None,
1829                    details: None,
1830                });
1831            }
1832            ureq::Error::Transport(err) => {
1833                return Err(LinkError::Transport {
1834                    hub: url.to_string(),
1835                    message: err.to_string(),
1836                });
1837            }
1838        },
1839    };
1840    if !(200..300).contains(&resp.status()) {
1841        return Err(LinkError::Http {
1842            what: "registry home fetch",
1843            status: resp.status(),
1844            message: "the home node returned a redirect or error".to_string(),
1845            code: None,
1846            details: None,
1847        });
1848    }
1849    let mut buf = Vec::new();
1850    resp.into_reader()
1851        .take(MAX_REGISTRY_CARD_BYTES + 1)
1852        .read_to_end(&mut buf)?;
1853    if buf.len() as u64 > MAX_REGISTRY_CARD_BYTES {
1854        return Err(LinkError::ResponseTooLarge {
1855            limit_bytes: MAX_REGISTRY_CARD_BYTES,
1856        });
1857    }
1858    serde_json::from_slice(&buf).map_err(|_| LinkError::InvalidFeed {
1859        message: "the home node returned invalid JSON".to_string(),
1860    })
1861}
1862
1863/// Resolve a bare `@handle` through the federation registry (link.md §7.1,
1864/// E5): look the handle up in the hub's registry, fetch the brain card from
1865/// the returned HOME node, and PIN — the card's identity fingerprint must
1866/// equal the registry's, or resolution fails. Returns the card enriched with
1867/// the resolved `home`, or `Ok(None)` when the registry has no such handle
1868/// (so the caller can fall back to a direct lookup).
1869pub fn resolve_registry(cfg: &HubConfig, handle: &str) -> LinkResult<Option<Value>> {
1870    require_safe_ref(handle)?;
1871    // Hold the validated trust-directory capability before any network I/O.
1872    // Every lock/load/save below remains relative to this exact inode even if
1873    // an attacker swaps an ancestor while the registry or home is answering.
1874    let trust_directory = open_trust_dir(cfg)?;
1875    let reg = request_capped(
1876        cfg,
1877        "GET",
1878        &format!("/api/hub/registry/{handle}"),
1879        None,
1880        Auth::None,
1881        MAX_REGISTRY_CARD_BYTES,
1882    )?;
1883    if reg.status == 404 {
1884        return Ok(None);
1885    }
1886    let body = ensure_ok(reg, "registry resolve")?;
1887    let home = body
1888        .get("home")
1889        .and_then(Value::as_str)
1890        .ok_or_else(|| invalid_feed("registry entry has no home"))?;
1891    let brain = body
1892        .get("brain")
1893        .and_then(Value::as_str)
1894        .ok_or_else(|| invalid_feed("registry entry has no brain"))?;
1895    if !crate::ulid::is_ulid(brain) {
1896        return Err(invalid_feed(
1897            "registry entry brain is not a canonical lowercase ULID",
1898        ));
1899    }
1900    let want_fp = body
1901        .get("identity")
1902        .and_then(|i| i.get("fingerprint"))
1903        .and_then(Value::as_str)
1904        .ok_or_else(|| invalid_feed("registry entry has no identity fingerprint"))?;
1905
1906    let home = home.trim_end_matches('/');
1907    let origin = normalized_origin(home)?;
1908    if origin != home {
1909        return Err(invalid_feed(
1910            "registry home must be an origin without a path, query, or fragment",
1911        ));
1912    }
1913    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[handle, brain])?;
1914    let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, handle, brain)?;
1915    if let Some(binding) = &alias_binding {
1916        if binding
1917            .home
1918            .as_deref()
1919            .is_some_and(|pinned_home| pinned_home != home)
1920        {
1921            return Err(invalid_feed(
1922                "registry relocated a pinned handle to a different home",
1923            ));
1924        }
1925    }
1926    let card = get_json_absolute(&format!("{home}/api/hub/brains/{brain}"))?;
1927    if card.get("id").and_then(Value::as_str) != Some(brain) {
1928        return Err(invalid_feed(
1929            "the home node served a card for a different brain",
1930        ));
1931    }
1932    let identity: FeedIdentity = serde_json::from_value(
1933        card.get("identity")
1934            .cloned()
1935            .ok_or_else(|| invalid_feed("the home node served no identity"))?,
1936    )
1937    .map_err(|_| invalid_feed("the home node served an invalid identity"))?;
1938    let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
1939    let got_fp = card
1940        .get("identity")
1941        .and_then(|i| i.get("fingerprint"))
1942        .and_then(Value::as_str)
1943        .unwrap_or_default();
1944    if got_fp != want_fp {
1945        return Err(invalid_feed(
1946            "the home node served an identity that does not match the registry — refusing",
1947        ));
1948    }
1949    let current = format!("ed25519:{}", identity.fingerprint);
1950    let advertised_seq = card
1951        .get("headSeq")
1952        .and_then(Value::as_u64)
1953        .ok_or_else(|| invalid_feed("the home node served an invalid head sequence"))?;
1954    let advertised_hash = card.get("feedHash").and_then(Value::as_str);
1955    if (advertised_seq == 0 && !card.get("feedHash").is_some_and(Value::is_null))
1956        || (advertised_seq > 0 && advertised_hash.is_none_or(|hash| !is_sha256(hash)))
1957    {
1958        return Err(invalid_feed(
1959            "the home node served an invalid feed head boundary",
1960        ));
1961    }
1962    // This is required even on first contact. Otherwise a syntactically valid
1963    // identity may claim that a rotation happened after a head the home has
1964    // never reached, then become the permanent TOFU anchor.
1965    verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], advertised_seq)?;
1966    let registry_alias = AliasBinding {
1967        v: 1,
1968        origin: normalized_origin(&cfg.hub)?,
1969        requested: handle.to_string(),
1970        brain: brain.to_string(),
1971        home: Some(home.to_string()),
1972    };
1973    save_canonical_pin_and_alias(
1974        cfg,
1975        &trust_directory,
1976        handle,
1977        brain,
1978        TrustState {
1979            v: 2,
1980            origin: normalized_origin(&cfg.hub)?,
1981            requested: brain.to_string(),
1982            brain: brain.to_string(),
1983            home: None,
1984            anchor,
1985            current,
1986            head_seq: pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq),
1987            feed_hash: pinned
1988                .as_ref()
1989                .and_then(|checkpoint| checkpoint.feed_hash.clone()),
1990            rotations: identity.rotations.clone(),
1991            hub_signer: None,
1992            protocol_profile: None,
1993        },
1994        Some(&registry_alias),
1995    )?;
1996    let mut out = card;
1997    if let Value::Object(map) = &mut out {
1998        map.insert("home".to_string(), Value::String(home.to_string()));
1999        map.insert(
2000            "resolvedVia".to_string(),
2001            Value::String("registry".to_string()),
2002        );
2003    }
2004    Ok(Some(out))
2005}
2006
2007pub fn resolve(cfg: &HubConfig, addr: &Address) -> LinkResult<Value> {
2008    // `Address::parse` refuses these shapes already, but `Address` has public
2009    // fields — re-assert at the wire so a hand-built address can never
2010    // reshape the request path.
2011    require_safe_ref(&addr.brain)?;
2012    if let Some(target) = &addr.target {
2013        let (given, ok) = match target {
2014            AddressTarget::Id(id) => (id, crate::ulid::is_ulid(id)),
2015            AddressTarget::Path(p) => (p, safe_store_rel_path(p) && p.ends_with(".md")),
2016        };
2017        if !ok {
2018            return Err(LinkError::BadAddress {
2019                given: given.clone(),
2020                reason: BAD_TARGET_REASON.to_string(),
2021            });
2022        }
2023    }
2024
2025    // A record is never accepted from the hub's mutable query/index response:
2026    // that response was not covered by the brain's feed signature and could
2027    // return arbitrary frontmatter/body while an unrelated signed head still
2028    // verified. Materialize the record from the exact content-addressed pack
2029    // named by the verified signed head instead.
2030    if let Some(target) = &addr.target {
2031        let remote = verified_remote_head(cfg, &addr.brain, false)?;
2032        if !remote.head.verified {
2033            return Err(invalid_feed(
2034                "a path-scoped feed cannot prove a record against the full signed snapshot",
2035            ));
2036        }
2037        if remote.head.seq == 0 {
2038            return Err(LinkError::Http {
2039                what: "resolve",
2040                status: 404,
2041                message: "record not found".to_string(),
2042                code: Some("NOT_FOUND".to_string()),
2043                details: None,
2044            });
2045        }
2046        let brain = remote.head.brain.clone();
2047        let pack = download_verified_snapshot_pack(cfg, &brain, &remote)?;
2048        return resolve_from_verified_pack(&brain, target, pack);
2049    }
2050
2051    let path = format!("/api/hub/brains/{}", addr.brain);
2052    // Direct first: the caller's own slug and hub-hosted public handles resolve
2053    // here unchanged. Only a bare `@handle` the hub can't resolve directly
2054    // (404) falls through to the federation registry — how a handle reaches a
2055    // brain on ANOTHER node (link.md §7.1, E5).
2056    let direct = request(cfg, "GET", &path, None, Auth::Required)?;
2057    if direct.status == 404 && addr.target.is_none() && !crate::ulid::is_ulid(&addr.brain) {
2058        if let Some(card) = resolve_registry(cfg, &addr.brain)? {
2059            return Ok(card);
2060        }
2061    }
2062    let resolved = ensure_ok(direct, "resolve")?;
2063    // A successful direct response is accepted only after the same centralized
2064    // identity/rotation/feed checkpoint verification used by sync and
2065    // subscribe. No verb gets a weaker ad-hoc pinning path.
2066    let remote = verified_remote_head(cfg, &addr.brain, false)?;
2067    if resolved.get("id").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2068        || resolved.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2069        || resolved.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
2070    {
2071        return Err(invalid_feed(
2072            "resolve card is not bound to the exact verified feed checkpoint",
2073        ));
2074    }
2075    let card_identity: FeedIdentity = serde_json::from_value(
2076        resolved
2077            .get("identity")
2078            .cloned()
2079            .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2080    )
2081    .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2082    if remote.identity.as_ref() != Some(&card_identity) {
2083        return Err(invalid_feed(
2084            "resolve card identity differs from the verified feed identity",
2085        ));
2086    }
2087    Ok(resolved)
2088}
2089
2090/// Resolve one record strictly from the exact signed snapshot pack. The hub's
2091/// mutable query/index result is intentionally not consulted: a signed pack
2092/// digest is the only cryptographic binding between a feed checkpoint and
2093/// record bytes in wire profile v1.
2094fn resolve_from_verified_pack(
2095    brain: &str,
2096    target: &AddressTarget,
2097    pack: Vec<u8>,
2098) -> LinkResult<Value> {
2099    let entries = parse_store_pack(pack)?;
2100    let mut matched: Option<(String, Vec<u8>)> = None;
2101
2102    for (path, bytes) in entries {
2103        let is_candidate = match target {
2104            AddressTarget::Path(want) => &path == want,
2105            AddressTarget::Id(_) => {
2106                path.ends_with(".md")
2107                    && (path.starts_with("records/") || path.starts_with("sources/"))
2108            }
2109        };
2110        if !is_candidate {
2111            continue;
2112        }
2113        let text = std::str::from_utf8(&bytes)
2114            .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2115        let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2116            .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2117        if let AddressTarget::Id(want) = target {
2118            let frontmatter =
2119                crate::parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(&path))
2120                    .map_err(|_| {
2121                        invalid_feed(format!("signed snapshot record `{path}` is malformed"))
2122                    })?;
2123            if frontmatter.id.as_deref() != Some(want) {
2124                continue;
2125            }
2126        }
2127        if matched.is_some() {
2128            return Err(invalid_feed(
2129                "signed snapshot contains more than one record for the requested target",
2130            ));
2131        }
2132        matched = Some((path, bytes));
2133    }
2134
2135    let (path, bytes) = matched.ok_or_else(|| LinkError::Http {
2136        what: "resolve",
2137        status: 404,
2138        message: "record not found".to_string(),
2139        code: Some("NOT_FOUND".to_string()),
2140        details: None,
2141    })?;
2142    let text = std::str::from_utf8(&bytes)
2143        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2144    let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2145        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2146    let frontmatter: Value = serde_norway::from_str(&parsed.frontmatter_yaml)
2147        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2148    let Value::Object(fields) = frontmatter else {
2149        return Err(invalid_feed(format!(
2150            "signed snapshot record `{path}` frontmatter is not a mapping"
2151        )));
2152    };
2153    let mut document = serde_json::Map::new();
2154    document.insert("path".to_string(), Value::String(path));
2155    for (key, value) in fields {
2156        document.insert(key, value);
2157    }
2158    document.insert("body".to_string(), Value::String(parsed.body));
2159    document.insert(
2160        "contentSha".to_string(),
2161        Value::String(content_sha256(&bytes)),
2162    );
2163    Ok(json!({
2164        "brain": brain,
2165        "document": Value::Object(document),
2166    }))
2167}
2168
2169// ─────────────────────────────────────────────────────────────────────────────
2170// sync — pull the granted slice as files; push the local store as a snapshot
2171// ─────────────────────────────────────────────────────────────────────────────
2172
2173/// What a pull materialized.
2174#[derive(Debug, serde::Serialize)]
2175pub struct PullReport {
2176    /// The brain id the hub reported.
2177    pub brain: String,
2178    /// The brain's slug.
2179    pub slug: String,
2180    /// The hub's feed head at export time.
2181    #[serde(rename = "headSeq")]
2182    pub head_seq: u64,
2183    /// How many files were written.
2184    pub files: usize,
2185    /// Where they were written (as given or derived from the slug).
2186    pub dest: String,
2187    /// Local content files that the export did not carry — present so a
2188    /// caller sees divergence; nothing is ever deleted locally.
2189    #[serde(rename = "extraLocal")]
2190    pub extra_local: Vec<String>,
2191    /// Exact convergence result from the post-install barrier.
2192    #[serde(rename = "syncStatus")]
2193    pub sync_status: String,
2194}
2195
2196fn download_verified_snapshot_pack(
2197    cfg: &HubConfig,
2198    brain: &str,
2199    remote: &VerifiedRemote,
2200) -> LinkResult<Vec<u8>> {
2201    let feed_hash = remote
2202        .head
2203        .feed_hash
2204        .as_deref()
2205        .ok_or_else(|| invalid_feed("non-empty snapshot has no verified feed hash"))?;
2206    let signed_head = remote
2207        .head_entry
2208        .as_ref()
2209        .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
2210    let expected = &signed_head.entry.pack_sha256;
2211    if !is_sha256(expected) {
2212        return Err(invalid_feed(
2213            "signed head carries an invalid snapshot pack digest",
2214        ));
2215    }
2216    let path = format!(
2217        "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={feed_hash}",
2218        remote.head.seq
2219    );
2220    let body = ensure_ok(
2221        request(cfg, "GET", &path, None, Auth::Required)?,
2222        "sync pull",
2223    )?;
2224    if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2225        || body.get("feedHash").and_then(Value::as_str) != Some(feed_hash)
2226        || body.get("brain").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2227        || body.get("sha256").and_then(Value::as_str) != Some(expected.as_str())
2228    {
2229        return Err(invalid_feed(
2230            "export response is not bound to the exact verified snapshot",
2231        ));
2232    }
2233    let url = body
2234        .get("url")
2235        .and_then(Value::as_str)
2236        .ok_or_else(|| invalid_feed("verified snapshot export carried no exact pack URL"))?;
2237    let bytes = get_presigned(cfg, url)?;
2238    if content_sha256(&bytes) != *expected {
2239        return Err(LinkError::InvalidPack {
2240            message: "downloaded pack does not match the signed snapshot digest".to_string(),
2241        });
2242    }
2243    let entries = parse_store_pack(bytes.clone())?;
2244    if signed_head.entry.kind == "push" {
2245        verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
2246    }
2247    Ok(bytes)
2248}
2249
2250#[derive(Debug, Clone, Deserialize, Serialize)]
2251struct V2PointerBody {
2252    v: u8,
2253    brain: String,
2254    seq: u64,
2255    commit_hash: String,
2256    feed_hash: String,
2257    content_root: Option<String>,
2258    asset_root: Option<String>,
2259    materializer: String,
2260    signer_epoch: u64,
2261    control_revision: String,
2262    backup_preparation: String,
2263    prior_pointer_hash: Option<String>,
2264    signed_at: String,
2265}
2266
2267#[derive(Debug, Clone, Deserialize)]
2268struct V2SignedPointer {
2269    pointer: V2PointerBody,
2270    hub_public_key: String,
2271    hub_fingerprint: String,
2272    sig: String,
2273}
2274
2275#[derive(Debug, Clone, Deserialize)]
2276struct V2HeadIdentity {
2277    #[serde(default)]
2278    custody: String,
2279    fingerprint: String,
2280    public_key_spki: String,
2281    #[serde(default)]
2282    previous: Vec<V2PreviousIdentity>,
2283    #[serde(default)]
2284    rotations: Vec<String>,
2285}
2286
2287#[derive(Debug, Clone, Deserialize)]
2288struct V2PreviousIdentity {
2289    fingerprint: String,
2290    public_key_spki: String,
2291}
2292
2293#[derive(Debug, Deserialize)]
2294struct V2HeadResponse {
2295    v: u8,
2296    brain_id: String,
2297    profile: String,
2298    view: Option<V2HeadView>,
2299    pointer: Option<V2SignedPointer>,
2300    identity: Option<V2HeadIdentity>,
2301}
2302
2303#[derive(Debug, Clone, Deserialize)]
2304struct V2HeadView {
2305    kind: String,
2306    control_revision: String,
2307}
2308
2309#[derive(Debug, Clone)]
2310struct V2VerifiedHead {
2311    requested: String,
2312    brain_id: String,
2313    view_kind: String,
2314    view_revision: String,
2315    identity: V2HeadIdentity,
2316    pointer: Option<V2PointerBody>,
2317    trust: TrustState,
2318    alias: Option<AliasBinding>,
2319}
2320
2321fn verify_v2_spki_signature(
2322    public_key: &str,
2323    message: &[u8],
2324    signature: &str,
2325) -> LinkResult<Vec<u8>> {
2326    let der = URL_SAFE_NO_PAD
2327        .decode(public_key)
2328        .map_err(|_| invalid_feed("v2 signer public key is not base64url"))?;
2329    if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
2330        return Err(invalid_feed("v2 signer public key is not Ed25519 SPKI"));
2331    }
2332    let sig = URL_SAFE_NO_PAD
2333        .decode(signature)
2334        .map_err(|_| invalid_feed("v2 signature is not base64url"))?;
2335    UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
2336        .verify(message, &sig)
2337        .map_err(|_| invalid_feed("v2 Ed25519 signature failed"))?;
2338    Ok(der)
2339}
2340
2341fn verify_v2_pointer(pointer: &V2SignedPointer, expected_brain: &str) -> LinkResult<String> {
2342    if pointer.pointer.v != 2
2343        || pointer.pointer.brain != expected_brain
2344        || pointer.pointer.seq == 0
2345        || !is_sha256(&pointer.pointer.commit_hash)
2346        || !is_sha256(&pointer.pointer.feed_hash)
2347        || pointer
2348            .pointer
2349            .content_root
2350            .as_deref()
2351            .is_some_and(|hash| !is_sha256(hash))
2352        || !is_sha256(&pointer.pointer.backup_preparation)
2353    {
2354        return Err(invalid_feed("v2 pointer fields are invalid"));
2355    }
2356    let value = serde_json::to_value(&pointer.pointer)
2357        .map_err(|_| invalid_feed("v2 pointer could not be canonicalized"))?;
2358    let message = crate::linkmd_v2::canonical_bytes(&value)
2359        .map_err(|error| invalid_feed(error.to_string()))?;
2360    let der = verify_v2_spki_signature(&pointer.hub_public_key, &message, &pointer.sig)?;
2361    let fingerprint = format!("{:x}", Sha256::digest(&der));
2362    if fingerprint != pointer.hub_fingerprint {
2363        return Err(invalid_feed("v2 hub signer fingerprint mismatch"));
2364    }
2365    Ok(format!(
2366        "{}:{}",
2367        pointer.hub_fingerprint, pointer.hub_public_key
2368    ))
2369}
2370
2371fn v2_identity(identity: &V2HeadIdentity) -> FeedIdentity {
2372    FeedIdentity {
2373        fingerprint: identity.fingerprint.clone(),
2374        public_key_spki: identity.public_key_spki.clone(),
2375        previous: identity
2376            .previous
2377            .iter()
2378            .map(|previous| PreviousIdentity {
2379                fingerprint: previous.fingerprint.clone(),
2380                public_key_spki: previous.public_key_spki.clone(),
2381            })
2382            .collect(),
2383        rotations: identity.rotations.clone(),
2384    }
2385}
2386
2387fn verified_v2_commit_object(
2388    raw: &[u8],
2389    identity: &V2HeadIdentity,
2390) -> LinkResult<serde_json::Map<String, Value>> {
2391    let mut value: Value =
2392        serde_json::from_slice(raw).map_err(|_| invalid_feed("v2 commit is not JSON"))?;
2393    let canonical = crate::linkmd_v2::canonical_bytes(&value)
2394        .map_err(|error| invalid_feed(error.to_string()))?;
2395    if canonical != raw {
2396        return Err(invalid_feed("v2 commit is not canonical JSON"));
2397    }
2398    let object = value
2399        .as_object_mut()
2400        .ok_or_else(|| invalid_feed("v2 commit is not an object"))?;
2401    let sig = object
2402        .remove("sig")
2403        .and_then(|value| value.as_str().map(str::to_string))
2404        .ok_or_else(|| invalid_feed("v2 commit has no signature"))?;
2405    const FIELDS: [&str; 18] = [
2406        "actor_ref",
2407        "asset_root",
2408        "brain",
2409        "changes_sha256",
2410        "control_revision",
2411        "materializer",
2412        "op",
2413        "parent_asset_root",
2414        "parent_commit",
2415        "parent_root",
2416        "prev_entry_hash",
2417        "public_key",
2418        "seq",
2419        "signer_epoch",
2420        "state_root",
2421        "ts",
2422        "v",
2423        "v1_bridge",
2424    ];
2425    if object.len() != FIELDS.len() || FIELDS.iter().any(|field| !object.contains_key(*field)) {
2426        return Err(invalid_feed("v2 commit has a non-normative field set"));
2427    }
2428    let seq = object
2429        .get("seq")
2430        .and_then(Value::as_u64)
2431        .filter(|seq| *seq > 0)
2432        .ok_or_else(|| invalid_feed("v2 commit has an invalid sequence"))?;
2433    let signer_epoch = object
2434        .get("signer_epoch")
2435        .and_then(Value::as_u64)
2436        .filter(|epoch| *epoch > 0)
2437        .ok_or_else(|| invalid_feed("v2 commit has an invalid signer epoch"))?;
2438    let hash_or_null = |field: &str| {
2439        object
2440            .get(field)
2441            .is_some_and(|value| value.is_null() || value.as_str().is_some_and(is_sha256))
2442    };
2443    if object.get("v").and_then(Value::as_u64) != Some(2)
2444        || object.get("op").and_then(Value::as_str) != Some("changeset")
2445        || !object
2446            .get("changes_sha256")
2447            .and_then(Value::as_str)
2448            .is_some_and(is_sha256)
2449        || !object
2450            .get("actor_ref")
2451            .and_then(Value::as_str)
2452            .is_some_and(is_sha256)
2453        || !object
2454            .get("control_revision")
2455            .and_then(Value::as_str)
2456            .is_some_and(is_sha256)
2457        || !object
2458            .get("state_root")
2459            .and_then(Value::as_str)
2460            .is_some_and(is_sha256)
2461        || !hash_or_null("parent_commit")
2462        || !hash_or_null("parent_root")
2463        || !hash_or_null("parent_asset_root")
2464        || !hash_or_null("asset_root")
2465        || !hash_or_null("prev_entry_hash")
2466        || !object
2467            .get("materializer")
2468            .and_then(Value::as_str)
2469            .is_some_and(|value| !value.is_empty() && value.len() <= 128)
2470        || !object
2471            .get("ts")
2472            .and_then(Value::as_str)
2473            .is_some_and(|value| value.len() == 24 && value.ends_with('Z'))
2474    {
2475        return Err(invalid_feed("v2 commit fields are invalid"));
2476    }
2477    if (seq == 1
2478        && [
2479            "parent_commit",
2480            "parent_root",
2481            "parent_asset_root",
2482            "prev_entry_hash",
2483        ]
2484        .iter()
2485        .any(|field| !object.get(*field).is_some_and(Value::is_null)))
2486        || (seq > 1
2487            && ["parent_commit", "parent_root", "prev_entry_hash"]
2488                .iter()
2489                .any(|field| object.get(*field).and_then(Value::as_str).is_none()))
2490    {
2491        return Err(invalid_feed("v2 commit parent shape is invalid"));
2492    }
2493    match object.get("v1_bridge") {
2494        Some(Value::Null) => {}
2495        Some(Value::Object(bridge))
2496            if seq == 1
2497                && bridge.len() == 3
2498                && bridge
2499                    .get("head_seq")
2500                    .and_then(Value::as_u64)
2501                    .is_some_and(|v| v > 0)
2502                && bridge
2503                    .get("feed_hash")
2504                    .and_then(Value::as_str)
2505                    .is_some_and(is_sha256)
2506                && bridge
2507                    .get("pack_sha256")
2508                    .and_then(Value::as_str)
2509                    .is_some_and(is_sha256) => {}
2510        _ => return Err(invalid_feed("v2 commit has an invalid v1 bridge")),
2511    }
2512    let public_key = object
2513        .get("public_key")
2514        .and_then(Value::as_str)
2515        .ok_or_else(|| invalid_feed("v2 commit has no public key"))?;
2516    let der = URL_SAFE_NO_PAD
2517        .decode(public_key)
2518        .map_err(|_| invalid_feed("v2 brain public key is not base64url"))?;
2519    let expected_multikey = format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&der)));
2520    if object.get("brain").and_then(Value::as_str) != Some(expected_multikey.as_str()) {
2521        return Err(invalid_feed("v2 commit brain identity mismatch"));
2522    }
2523    // Verify the old-key-signed chain before trusting any prior public key.
2524    verify_identity_chain(&v2_identity(identity), None)?;
2525    // Old commits remain verifiable after brain-key rotation. `previous` is
2526    // newest-first, while signer epochs and rotation statements are oldest-first.
2527    let mut chain: Vec<(&str, &str)> = identity
2528        .previous
2529        .iter()
2530        .rev()
2531        .map(|previous| {
2532            (
2533                previous.fingerprint.as_str(),
2534                previous.public_key_spki.as_str(),
2535            )
2536        })
2537        .collect();
2538    chain.push((&identity.fingerprint, &identity.public_key_spki));
2539    let signer_index = chain.iter().position(|(fingerprint, spki)| {
2540        *fingerprint == expected_multikey.trim_start_matches("ed25519:") && *spki == public_key
2541    });
2542    let Some(signer_index) = signer_index else {
2543        return Err(invalid_feed("v2 commit uses an unrecognized brain key"));
2544    };
2545    if signer_epoch != signer_index as u64 + 1 {
2546        return Err(invalid_feed("v2 commit signer epoch differs from its key"));
2547    }
2548    let lower_boundary = if signer_index == 0 {
2549        None
2550    } else {
2551        let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
2552            .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
2553        Some(prior.prior_head_seq)
2554    };
2555    let upper_boundary = if signer_index == identity.rotations.len() {
2556        None
2557    } else {
2558        let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
2559            .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
2560        Some(next.prior_head_seq)
2561    };
2562    if lower_boundary.is_some_and(|boundary| seq <= boundary)
2563        || upper_boundary.is_some_and(|boundary| seq > boundary)
2564    {
2565        return Err(invalid_feed(
2566            "v2 commit signer is outside its authenticated rotation epoch",
2567        ));
2568    }
2569    let unsigned = crate::linkmd_v2::canonical_bytes(&Value::Object(object.clone()))
2570        .map_err(|error| invalid_feed(error.to_string()))?;
2571    verify_v2_spki_signature(public_key, &unsigned, &sig)?;
2572    Ok(object.clone())
2573}
2574
2575#[derive(Debug, Deserialize)]
2576struct V2FeedWireEntry {
2577    seq: u64,
2578    commit_hash: String,
2579    feed_hash: String,
2580    bytes_base64: String,
2581}
2582
2583#[derive(Debug, Deserialize)]
2584struct V2FeedPage {
2585    v: u8,
2586    head_seq: u64,
2587    head_commit_hash: String,
2588    head_feed_hash: String,
2589    entries: Vec<V2FeedWireEntry>,
2590    next_after: u64,
2591    complete: bool,
2592}
2593
2594fn replay_v2_feed(
2595    cfg: &HubConfig,
2596    brain: &str,
2597    pointer: &V2PointerBody,
2598    identity: &V2HeadIdentity,
2599    start_after: u64,
2600    start_feed: Option<String>,
2601) -> LinkResult<()> {
2602    let mut after = start_after;
2603    let mut prior_feed = start_feed;
2604    let mut final_object = None;
2605    let mut replayed_entries = 0_u64;
2606    let mut replayed_bytes = 0_u64;
2607    while after < pointer.seq {
2608        let path = format!("/api/hub/brains/{brain}/v2/feed?after={after}&limit=100");
2609        let value = ensure_ok(
2610            request_capped(
2611                cfg,
2612                "GET",
2613                &path,
2614                None,
2615                Auth::Required,
2616                MAX_FEED_REPLAY_BYTES,
2617            )?,
2618            "v2 feed replay",
2619        )?;
2620        let page: V2FeedPage = serde_json::from_value(value)
2621            .map_err(|_| invalid_feed("v2 feed page has an invalid shape"))?;
2622        if page.v != 2
2623            || page.head_seq != pointer.seq
2624            || page.head_commit_hash != pointer.commit_hash
2625            || page.head_feed_hash != pointer.feed_hash
2626            || page.entries.is_empty()
2627            || page.entries.len() > FEED_PAGE_LIMIT
2628        {
2629            return Err(invalid_feed("v2 feed page differs from the signed head"));
2630        }
2631        for entry in page.entries {
2632            if entry.seq != after + 1
2633                || !is_sha256(&entry.commit_hash)
2634                || !is_sha256(&entry.feed_hash)
2635            {
2636                return Err(invalid_feed("v2 feed sequence is not contiguous"));
2637            }
2638            let raw = base64::engine::general_purpose::STANDARD
2639                .decode(&entry.bytes_base64)
2640                .map_err(|_| invalid_feed("v2 feed bytes are not canonical base64"))?;
2641            replayed_entries = replayed_entries
2642                .checked_add(1)
2643                .ok_or_else(|| invalid_feed("v2 feed replay count overflow"))?;
2644            replayed_bytes = replayed_bytes
2645                .checked_add(raw.len() as u64)
2646                .ok_or_else(|| invalid_feed("v2 feed replay byte count overflow"))?;
2647            if replayed_entries > MAX_FEED_REPLAY_ENTRIES || replayed_bytes > MAX_FEED_REPLAY_BYTES
2648            {
2649                return Err(invalid_feed("v2 feed replay exceeds its safety bound"));
2650            }
2651            if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
2652                .map_err(|error| invalid_feed(error.to_string()))?
2653                != entry.commit_hash
2654                || content_sha256(&raw) != entry.feed_hash
2655            {
2656                return Err(invalid_feed("v2 feed entry address mismatch"));
2657            }
2658            let object = verified_v2_commit_object(&raw, identity)?;
2659            if object.get("seq").and_then(Value::as_u64) != Some(entry.seq)
2660                || object.get("prev_entry_hash").and_then(Value::as_str) != prior_feed.as_deref()
2661            {
2662                return Err(invalid_feed(
2663                    "v2 feed entry does not extend its predecessor",
2664                ));
2665            }
2666            after = entry.seq;
2667            prior_feed = Some(entry.feed_hash);
2668            final_object = Some((entry.commit_hash, object));
2669        }
2670        if page.next_after != after || (page.complete != (after == pointer.seq)) {
2671            return Err(invalid_feed("v2 feed page cursor is inconsistent"));
2672        }
2673    }
2674    let (final_hash, object) =
2675        final_object.ok_or_else(|| invalid_feed("v2 feed replay made no progress"))?;
2676    if final_hash != pointer.commit_hash
2677        || prior_feed.as_deref() != Some(pointer.feed_hash.as_str())
2678        || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
2679        || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
2680        || object.get("control_revision").and_then(Value::as_str)
2681            != Some(pointer.control_revision.as_str())
2682        || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
2683    {
2684        return Err(invalid_feed(
2685            "v2 replay did not converge on the signed pointer",
2686        ));
2687    }
2688    Ok(())
2689}
2690
2691fn verify_v1_to_v2_bridge(
2692    cfg: &HubConfig,
2693    brain: &str,
2694    pointer: &V2PointerBody,
2695    identity: &V2HeadIdentity,
2696    checkpoint: &TrustState,
2697) -> LinkResult<()> {
2698    let value = ensure_ok(
2699        request_capped(
2700            cfg,
2701            "GET",
2702            &format!("/api/hub/brains/{brain}/v2/feed?after=0&limit=1"),
2703            None,
2704            Auth::Required,
2705            MAX_FEED_RESPONSE_BYTES,
2706        )?,
2707        "v2 genesis bridge",
2708    )?;
2709    let page: V2FeedPage = serde_json::from_value(value)
2710        .map_err(|_| invalid_feed("v2 genesis bridge page has an invalid shape"))?;
2711    if page.v != 2
2712        || page.head_seq != pointer.seq
2713        || page.head_commit_hash != pointer.commit_hash
2714        || page.head_feed_hash != pointer.feed_hash
2715        || page.entries.len() != 1
2716        || page.entries[0].seq != 1
2717        || !is_sha256(&page.entries[0].commit_hash)
2718        || !is_sha256(&page.entries[0].feed_hash)
2719    {
2720        return Err(invalid_feed(
2721            "v2 genesis bridge page differs from the signed head",
2722        ));
2723    }
2724    let first = &page.entries[0];
2725    let raw = STANDARD
2726        .decode(&first.bytes_base64)
2727        .map_err(|_| invalid_feed("v2 genesis bridge bytes are not canonical base64"))?;
2728    if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
2729        .map_err(|error| invalid_feed(error.to_string()))?
2730        != first.commit_hash
2731        || content_sha256(&raw) != first.feed_hash
2732    {
2733        return Err(invalid_feed("v2 genesis bridge address mismatch"));
2734    }
2735    let object = verified_v2_commit_object(&raw, identity)?;
2736    if checkpoint.head_seq == 0 {
2737        if checkpoint.feed_hash.is_some() || object.get("v1_bridge") != Some(&Value::Null) {
2738            return Err(invalid_feed(
2739                "empty v1 checkpoint did not transition through an empty v2 genesis",
2740            ));
2741        }
2742        return Ok(());
2743    }
2744    let bridge = object
2745        .get("v1_bridge")
2746        .and_then(Value::as_object)
2747        .ok_or_else(|| invalid_feed("v2 genesis omitted the pinned v1 boundary"))?;
2748    let checkpoint_feed = checkpoint
2749        .feed_hash
2750        .as_deref()
2751        .ok_or_else(|| invalid_feed("non-empty v1 checkpoint has no feed hash"))?;
2752    if bridge.get("head_seq").and_then(Value::as_u64) != Some(checkpoint.head_seq)
2753        || bridge.get("feed_hash").and_then(Value::as_str) != Some(checkpoint_feed)
2754    {
2755        return Err(invalid_feed(
2756            "v2 genesis bridge differs from the pinned v1 checkpoint",
2757        ));
2758    }
2759    let legacy_raw = ensure_raw_ok(
2760        request_raw(
2761            cfg,
2762            "GET",
2763            &format!(
2764                "/api/hub/brains/{brain}/feed?after={}&limit=1",
2765                checkpoint.head_seq - 1
2766            ),
2767            None,
2768            Auth::Required,
2769            MAX_FEED_RESPONSE_BYTES,
2770        )?,
2771        "v1 bridge boundary",
2772    )?;
2773    let legacy: FeedResponse = serde_json::from_slice(&legacy_raw)
2774        .map_err(|_| invalid_feed("v1 bridge boundary has an invalid feed shape"))?;
2775    let legacy_identity = legacy
2776        .identity
2777        .ok_or_else(|| invalid_feed("v1 bridge boundary has no identity"))?;
2778    let item = legacy
2779        .entries
2780        .first()
2781        .filter(|_| legacy.entries.len() == 1)
2782        .ok_or_else(|| invalid_feed("v1 bridge boundary did not return one exact entry"))?;
2783    if legacy.scope_limited
2784        || legacy.head_seq != checkpoint.head_seq
2785        || legacy.feed_hash.as_deref() != Some(checkpoint_feed)
2786        || item.entry.seq != checkpoint.head_seq
2787        || item.hash != checkpoint_feed
2788        || legacy_identity != v2_identity(identity)
2789        || bridge.get("pack_sha256").and_then(Value::as_str)
2790            != Some(item.entry.pack_sha256.as_str())
2791    {
2792        return Err(invalid_feed(
2793            "v1 bridge boundary differs from its signed legacy head",
2794        ));
2795    }
2796    let anchor = verify_identity_chain(&legacy_identity, Some(checkpoint))?;
2797    if anchor != checkpoint.anchor {
2798        return Err(invalid_feed("v1 bridge changed the pinned identity anchor"));
2799    }
2800    verify_feed_item(item, &legacy_identity)?;
2801    verify_rotation_feed_boundaries(
2802        &legacy_identity,
2803        Some(checkpoint),
2804        std::slice::from_ref(item),
2805        checkpoint.head_seq,
2806    )?;
2807    Ok(())
2808}
2809
2810fn verify_v2_commit(
2811    cfg: &HubConfig,
2812    brain: &str,
2813    pointer: &V2PointerBody,
2814    identity: &V2HeadIdentity,
2815    pinned: Option<&TrustState>,
2816) -> LinkResult<()> {
2817    let path = format!(
2818        "/api/hub/brains/{brain}/v2/commit?commit={}",
2819        pointer.commit_hash
2820    );
2821    let raw = ensure_raw_ok(
2822        request_raw(cfg, "GET", &path, None, Auth::Required, MAX_RESPONSE_BYTES)?,
2823        "v2 commit",
2824    )?;
2825    if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
2826        .map_err(|error| invalid_feed(error.to_string()))?
2827        != pointer.commit_hash
2828        || content_sha256(&raw) != pointer.feed_hash
2829    {
2830        return Err(invalid_feed("v2 commit address differs from the pointer"));
2831    }
2832    let object = verified_v2_commit_object(&raw, identity)?;
2833    if object.get("seq").and_then(Value::as_u64) != Some(pointer.seq)
2834        || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
2835        || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
2836        || object.get("control_revision").and_then(Value::as_str)
2837            != Some(pointer.control_revision.as_str())
2838        || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
2839    {
2840        return Err(invalid_feed("v2 commit fields differ from the pointer"));
2841    }
2842    if let Some(checkpoint) = pinned.filter(|checkpoint| accepted_as_v2(checkpoint)) {
2843        if pointer.seq == checkpoint.head_seq + 1
2844            && object.get("prev_entry_hash").and_then(Value::as_str)
2845                != checkpoint.feed_hash.as_deref()
2846        {
2847            return Err(invalid_feed(
2848                "v2 commit does not extend the pinned feed hash",
2849            ));
2850        }
2851        if pointer.seq > checkpoint.head_seq + 1 {
2852            return replay_v2_feed(
2853                cfg,
2854                brain,
2855                pointer,
2856                identity,
2857                checkpoint.head_seq,
2858                checkpoint.feed_hash.clone(),
2859            );
2860        }
2861    } else {
2862        if let Some(checkpoint) = pinned {
2863            verify_v1_to_v2_bridge(cfg, brain, pointer, identity, checkpoint)?;
2864        }
2865        if pointer.seq > 1 {
2866            return replay_v2_feed(cfg, brain, pointer, identity, 0, None);
2867        }
2868    }
2869    Ok(())
2870}
2871
2872fn v2_verified_head(cfg: &HubConfig, brain: &str) -> LinkResult<Option<V2VerifiedHead>> {
2873    require_hardened_filesystem("verified link.md v2 state")?;
2874    require_safe_ref(brain)?;
2875    let path = format!("/api/hub/brains/{brain}/v2/head");
2876    let response = request(cfg, "GET", &path, None, Auth::Required)?;
2877    if response.status == 404 {
2878        if has_accepted_v2_ref(cfg, brain)? {
2879            return Err(LinkError::BrainUnavailable);
2880        }
2881        return Ok(None);
2882    }
2883    let body = ensure_ok(response, "v2 head")?;
2884    let head: V2HeadResponse = serde_json::from_value(body)
2885        .map_err(|_| invalid_feed("v2 head response has an invalid shape"))?;
2886    if head.v != 2 || !crate::ulid::is_ulid(&head.brain_id) {
2887        return Err(invalid_feed("v2 head has no canonical brain id"));
2888    }
2889    if crate::ulid::is_ulid(brain) && head.brain_id != brain {
2890        return Err(invalid_feed("v2 head resolved a different brain id"));
2891    }
2892    if head.profile == "v1" {
2893        return Ok(None);
2894    }
2895    if head.profile != "v2" && head.profile != "v2-empty" {
2896        return Err(invalid_feed("v2 head advertised an unknown profile"));
2897    }
2898    let view = head
2899        .view
2900        .as_ref()
2901        .ok_or_else(|| invalid_feed("v2 head has no permission view"))?;
2902    if !matches!(view.kind.as_str(), "full" | "scoped") || !is_sha256(&view.control_revision) {
2903        return Err(invalid_feed("v2 head has an invalid permission view"));
2904    }
2905    let view_kind = view.kind.clone();
2906    let view_revision = view.control_revision.clone();
2907    let identity = head
2908        .identity
2909        .as_ref()
2910        .ok_or_else(|| invalid_feed("v2 head has no brain identity"))?;
2911    let trust_directory = open_trust_dir(cfg)?;
2912    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &head.brain_id])?;
2913    let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, brain, &head.brain_id)?;
2914    let feed_identity = v2_identity(identity);
2915    let anchor = verify_identity_chain(&feed_identity, pinned.as_ref())?;
2916    let (seq, feed_hash, hub_signer) = match &head.pointer {
2917        None => {
2918            if head.profile != "v2-empty" {
2919                return Err(invalid_feed("initialized v2 head has no pointer"));
2920            }
2921            (
2922                0,
2923                None,
2924                pinned.as_ref().and_then(|state| state.hub_signer.clone()),
2925            )
2926        }
2927        Some(signed) => {
2928            let signer = verify_v2_pointer(signed, &head.brain_id)?;
2929            if pinned
2930                .as_ref()
2931                .and_then(|state| state.hub_signer.as_ref())
2932                .is_some_and(|known| known != &signer)
2933            {
2934                return Err(invalid_feed(
2935                    "v2 hub pointer signer changed without a trust transition",
2936                ));
2937            }
2938            if let Some(checkpoint) = pinned.as_ref().filter(|state| accepted_as_v2(state)) {
2939                if signed.pointer.seq < checkpoint.head_seq
2940                    || (signed.pointer.seq == checkpoint.head_seq
2941                        && checkpoint.feed_hash.as_deref()
2942                            != Some(signed.pointer.feed_hash.as_str()))
2943                {
2944                    return Err(invalid_feed("v2 pointer rolled back or equivocated"));
2945                }
2946            }
2947            verify_v2_commit(
2948                cfg,
2949                &head.brain_id,
2950                &signed.pointer,
2951                identity,
2952                pinned.as_ref(),
2953            )?;
2954            (
2955                signed.pointer.seq,
2956                Some(signed.pointer.feed_hash.clone()),
2957                Some(signer),
2958            )
2959        }
2960    };
2961    let trust = TrustState {
2962        v: 2,
2963        origin: normalized_origin(&cfg.hub)?,
2964        requested: head.brain_id.clone(),
2965        brain: head.brain_id.clone(),
2966        home: None,
2967        anchor,
2968        current: format!("ed25519:{}", identity.fingerprint),
2969        head_seq: seq,
2970        feed_hash,
2971        rotations: identity.rotations.clone(),
2972        hub_signer,
2973        protocol_profile: Some("link-v2".to_string()),
2974    };
2975    Ok(Some(V2VerifiedHead {
2976        requested: brain.to_string(),
2977        brain_id: head.brain_id,
2978        view_kind,
2979        view_revision,
2980        identity: identity.clone(),
2981        pointer: head.pointer.map(|signed| signed.pointer),
2982        trust,
2983        alias: alias_binding,
2984    }))
2985}
2986
2987fn accept_v2_head(cfg: &HubConfig, head: &V2VerifiedHead) -> LinkResult<()> {
2988    let directory = open_trust_dir(cfg)?;
2989    let _locks = lock_trust_many(cfg, &directory, &[&head.requested, &head.brain_id])?;
2990    let (current, alias) = load_canonical_pin(cfg, &directory, &head.requested, &head.brain_id)?;
2991    if let Some(current) = current {
2992        let common_invalid = head.trust.anchor != current.anchor
2993            || !head.trust.rotations.starts_with(&current.rotations);
2994        let profile_invalid = if accepted_as_v2(&current) {
2995            head.trust.head_seq < current.head_seq
2996                || (head.trust.head_seq == current.head_seq
2997                    && head.trust.feed_hash != current.feed_hash)
2998                || current
2999                    .hub_signer
3000                    .as_ref()
3001                    .is_some_and(|known| head.trust.hub_signer.as_ref() != Some(known))
3002        } else {
3003            head.trust.protocol_profile.as_deref() != Some("link-v2")
3004                || head.trust.hub_signer.is_none()
3005        };
3006        if common_invalid || profile_invalid {
3007            return Err(invalid_feed(
3008                "v2 head cannot advance the currently accepted trust checkpoint",
3009            ));
3010        }
3011    }
3012    save_canonical_pin_and_alias(
3013        cfg,
3014        &directory,
3015        &head.requested,
3016        &head.brain_id,
3017        head.trust.clone(),
3018        alias.as_ref().or(head.alias.as_ref()),
3019    )
3020}
3021
3022#[derive(Debug, Clone, Deserialize, Serialize)]
3023struct V2BaselineFile {
3024    sha256: String,
3025    bytes: u64,
3026    #[serde(skip)]
3027    proof: Option<Vec<V2ProofStep>>,
3028}
3029
3030#[derive(Debug, Clone, Deserialize, Serialize)]
3031struct V2SyncBaseline {
3032    v: u8,
3033    origin: String,
3034    brain: String,
3035    #[serde(default)]
3036    head_seq: Option<u64>,
3037    commit_hash: Option<String>,
3038    content_root: Option<String>,
3039    #[serde(default)]
3040    asset_root: Option<String>,
3041    #[serde(default)]
3042    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
3043    #[serde(default)]
3044    view_kind: Option<String>,
3045    #[serde(default)]
3046    view_revision: Option<String>,
3047    #[serde(default)]
3048    projection_sha256: Option<String>,
3049    files: std::collections::BTreeMap<String, V2BaselineFile>,
3050    #[serde(default)]
3051    local_policy_digest: Option<String>,
3052    #[serde(default)]
3053    local_eligibility: std::collections::BTreeMap<String, bool>,
3054    #[serde(default)]
3055    remote_copy_remains: std::collections::BTreeMap<String, String>,
3056}
3057
3058struct V2LocalView {
3059    riding: std::collections::BTreeMap<String, (String, u64)>,
3060    eligibility: std::collections::BTreeMap<String, bool>,
3061    policy: crate::linkmd_sync_policy::SyncPolicy,
3062}
3063
3064#[derive(Debug, Clone, Deserialize, Serialize)]
3065struct V2ProofStep {
3066    directory_root: String,
3067    component: String,
3068    proof: crate::linkmd_v2::HamtProof,
3069}
3070
3071#[derive(Debug, Deserialize)]
3072struct V2ManifestFile {
3073    path: String,
3074    sha256: String,
3075    bytes: u64,
3076    proof: Vec<V2ProofStep>,
3077}
3078
3079#[derive(Debug, Deserialize)]
3080struct V2ManifestPage {
3081    v: u8,
3082    commit: String,
3083    content_root: Option<String>,
3084    files: Vec<V2ManifestFile>,
3085    next_cursor: Option<String>,
3086}
3087
3088#[derive(Debug, Clone, Deserialize, Serialize)]
3089struct V2BaselineAsset {
3090    blob_sha256: String,
3091    bytes: u64,
3092    media_type: String,
3093    wrappers: Vec<String>,
3094    required: bool,
3095    disposition: String,
3096    leaf_hash: String,
3097}
3098
3099#[derive(Debug, Deserialize)]
3100struct V2AssetManifestItem {
3101    path: String,
3102    blob_sha256: String,
3103    bytes: u64,
3104    media_type: String,
3105    wrappers: Vec<String>,
3106    required: bool,
3107    disposition: String,
3108    leaf_hash: String,
3109    proof: crate::linkmd_v2::HamtProof,
3110}
3111
3112#[derive(Debug, Deserialize)]
3113struct V2AssetManifestPage {
3114    v: u8,
3115    commit: String,
3116    asset_root: Option<String>,
3117    assets: Vec<V2AssetManifestItem>,
3118    next_cursor: Option<String>,
3119}
3120
3121#[derive(Debug, Deserialize)]
3122struct V2SigningCandidate {
3123    seq: u64,
3124    content_root: Option<String>,
3125    asset_root: Option<String>,
3126    signing_bytes_base64: String,
3127    changes_base64: String,
3128    actor_claim_base64: String,
3129}
3130
3131#[derive(Debug, Deserialize)]
3132struct V2SigningCandidatePage {
3133    v: u8,
3134    challenge_id: String,
3135    mutation_id: String,
3136    request_hash: String,
3137    parent: V2SigningParent,
3138    candidate: V2SigningCandidate,
3139    files: Vec<V2ManifestFile>,
3140    #[serde(default)]
3141    assets: Vec<V2AssetManifestItem>,
3142    next_cursor: Option<String>,
3143    expires_at: String,
3144}
3145
3146#[derive(Debug, Deserialize)]
3147struct V2SigningParent {
3148    seq: u64,
3149    commit_hash: Option<String>,
3150}
3151
3152fn verify_v2_file_proof(root: &str, file: &V2ManifestFile) -> LinkResult<()> {
3153    let normalized = crate::linkmd_v2::normalize_path(&file.path)
3154        .map_err(|error| invalid_feed(error.to_string()))?;
3155    let components = normalized.split('/').collect::<Vec<_>>();
3156    if components.len() != file.proof.len() || !is_sha256(&file.sha256) {
3157        return Err(invalid_feed("v2 file proof has the wrong shape"));
3158    }
3159    let mut directory_root = root.to_string();
3160    for (index, step) in file.proof.iter().enumerate() {
3161        if step.directory_root != directory_root || step.component != components[index] {
3162            return Err(invalid_feed(
3163                "v2 file proof path chain differs from its manifest",
3164            ));
3165        }
3166        if !crate::linkmd_v2::verify_proof(&directory_root, &step.component, &step.proof)
3167            .map_err(|error| invalid_feed(error.to_string()))?
3168        {
3169            return Err(invalid_feed("v2 file proof failed verification"));
3170        }
3171        let entry = match &step.proof {
3172            crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry,
3173            crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
3174                return Err(invalid_feed("v2 manifest carried a non-inclusion proof"));
3175            }
3176        };
3177        if index + 1 == components.len() {
3178            if entry.kind != crate::linkmd_v2::EntryKind::Blob
3179                || entry.child_hash != file.sha256
3180                || entry.bytes != Some(file.bytes)
3181            {
3182                return Err(invalid_feed("v2 file proof leaf differs from its manifest"));
3183            }
3184        } else if entry.kind != crate::linkmd_v2::EntryKind::Tree {
3185            return Err(invalid_feed("v2 file proof traversed a non-directory"));
3186        } else {
3187            directory_root = entry.child_hash.clone();
3188        }
3189    }
3190    Ok(())
3191}
3192
3193fn v2_manifest(
3194    cfg: &HubConfig,
3195    brain: &str,
3196    pointer: Option<&V2PointerBody>,
3197) -> LinkResult<std::collections::BTreeMap<String, V2BaselineFile>> {
3198    let Some(pointer) = pointer else {
3199        return Ok(std::collections::BTreeMap::new());
3200    };
3201    let Some(root) = pointer.content_root.as_deref() else {
3202        return Ok(std::collections::BTreeMap::new());
3203    };
3204    let mut files = std::collections::BTreeMap::new();
3205    let mut after = String::new();
3206    loop {
3207        let encoded_after: String =
3208            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3209        let path = format!(
3210            "/api/hub/brains/{brain}/v2/files?commit={}&limit=500&after={encoded_after}",
3211            pointer.commit_hash
3212        );
3213        let value = ensure_ok(
3214            request_capped(
3215                cfg,
3216                "GET",
3217                &path,
3218                None,
3219                Auth::Required,
3220                MAX_FEED_RESPONSE_BYTES,
3221            )?,
3222            "v2 file manifest",
3223        )?;
3224        let page: V2ManifestPage = serde_json::from_value(value)
3225            .map_err(|_| invalid_feed("v2 file manifest has an invalid shape"))?;
3226        if page.v != 2
3227            || page.commit != pointer.commit_hash
3228            || page.content_root.as_deref() != Some(root)
3229            || page.files.len() > 500
3230        {
3231            return Err(invalid_feed(
3232                "v2 file manifest is not bound to the verified head",
3233            ));
3234        }
3235        for file in page.files {
3236            verify_v2_file_proof(root, &file)?;
3237            if files
3238                .insert(
3239                    file.path.clone(),
3240                    V2BaselineFile {
3241                        sha256: file.sha256,
3242                        bytes: file.bytes,
3243                        proof: Some(file.proof),
3244                    },
3245                )
3246                .is_some()
3247            {
3248                return Err(invalid_feed("v2 file manifest repeats a path"));
3249            }
3250            if files.len() > MAX_PUSH_FILES {
3251                return Err(invalid_feed(
3252                    "v2 file manifest exceeds the file-count bound",
3253                ));
3254            }
3255        }
3256        match page.next_cursor {
3257            None => break,
3258            Some(next) if next > after => after = next,
3259            Some(_) => return Err(invalid_feed("v2 file manifest cursor did not advance")),
3260        }
3261    }
3262    Ok(files)
3263}
3264
3265fn verify_v2_asset_proof(root: &str, item: &V2AssetManifestItem) -> LinkResult<()> {
3266    crate::linkmd_v2::normalize_path(&item.path)
3267        .map_err(|error| invalid_feed(error.to_string()))?;
3268    if !is_sha256(&item.blob_sha256)
3269        || !is_sha256(&item.leaf_hash)
3270        || item.wrappers.is_empty()
3271        || !matches!(item.disposition.as_str(), "hosted" | "withheld")
3272        || item
3273            .wrappers
3274            .iter()
3275            .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
3276    {
3277        return Err(invalid_feed("v2 asset manifest item is invalid"));
3278    }
3279    let leaf = json!({
3280        "blob_sha256": item.blob_sha256,
3281        "bytes": item.bytes,
3282        "disposition": item.disposition,
3283        "media_type": item.media_type,
3284        "path": item.path,
3285        "required": item.required,
3286        "v": 2,
3287        "wrappers": item.wrappers,
3288    });
3289    if crate::linkmd_v2::domain_hash("v2/asset-leaf", &leaf)
3290        .map_err(|error| invalid_feed(error.to_string()))?
3291        != item.leaf_hash
3292        || !crate::linkmd_v2::verify_proof_with_domain(
3293            root,
3294            &item.path,
3295            &item.proof,
3296            crate::linkmd_v2::ASSET_TREE_HASH_DOMAIN,
3297        )
3298        .map_err(|error| invalid_feed(error.to_string()))?
3299    {
3300        return Err(invalid_feed("v2 asset inclusion proof failed"));
3301    }
3302    match &item.proof {
3303        crate::linkmd_v2::HamtProof::Inclusion { entry, .. }
3304            if entry.name == item.path
3305                && entry.kind == crate::linkmd_v2::EntryKind::Blob
3306                && entry.child_hash == item.leaf_hash
3307                && entry.bytes == Some(item.bytes) =>
3308        {
3309            Ok(())
3310        }
3311        _ => Err(invalid_feed(
3312            "v2 asset proof leaf differs from its manifest",
3313        )),
3314    }
3315}
3316
3317fn v2_asset_manifest(
3318    cfg: &HubConfig,
3319    brain: &str,
3320    pointer: Option<&V2PointerBody>,
3321) -> LinkResult<std::collections::BTreeMap<String, V2BaselineAsset>> {
3322    let Some(pointer) = pointer else {
3323        return Ok(std::collections::BTreeMap::new());
3324    };
3325    let Some(root) = pointer.asset_root.as_deref() else {
3326        return Ok(std::collections::BTreeMap::new());
3327    };
3328    let mut assets = std::collections::BTreeMap::new();
3329    let mut after = String::new();
3330    loop {
3331        let encoded_after: String =
3332            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3333        let path = format!(
3334            "/api/hub/brains/{brain}/v2/assets?commit={}&limit=500&after={encoded_after}",
3335            pointer.commit_hash
3336        );
3337        let value = ensure_ok(
3338            request_capped(
3339                cfg,
3340                "GET",
3341                &path,
3342                None,
3343                Auth::Required,
3344                MAX_FEED_RESPONSE_BYTES,
3345            )?,
3346            "v2 asset manifest",
3347        )?;
3348        let page: V2AssetManifestPage = serde_json::from_value(value)
3349            .map_err(|_| invalid_feed("v2 asset manifest has an invalid shape"))?;
3350        if page.v != 2
3351            || page.commit != pointer.commit_hash
3352            || page.asset_root.as_deref() != Some(root)
3353            || page.assets.len() > 500
3354        {
3355            return Err(invalid_feed(
3356                "v2 asset manifest is not bound to the verified head",
3357            ));
3358        }
3359        for item in page.assets {
3360            verify_v2_asset_proof(root, &item)?;
3361            let path = item.path.clone();
3362            if assets
3363                .insert(
3364                    path,
3365                    V2BaselineAsset {
3366                        blob_sha256: item.blob_sha256,
3367                        bytes: item.bytes,
3368                        media_type: item.media_type,
3369                        wrappers: item.wrappers,
3370                        required: item.required,
3371                        disposition: item.disposition,
3372                        leaf_hash: item.leaf_hash,
3373                    },
3374                )
3375                .is_some()
3376            {
3377                return Err(invalid_feed("v2 asset manifest repeats a path"));
3378            }
3379            if assets.len() > MAX_PUSH_FILES {
3380                return Err(invalid_feed(
3381                    "v2 asset manifest exceeds the item-count bound",
3382                ));
3383            }
3384        }
3385        match page.next_cursor {
3386            None => break,
3387            Some(next) if next > after => after = next,
3388            Some(_) => return Err(invalid_feed("v2 asset manifest cursor did not advance")),
3389        }
3390    }
3391    Ok(assets)
3392}
3393
3394fn v2_asset_record(asset: &V2BaselineAsset, path: &str) -> crate::AssetRecord {
3395    crate::AssetRecord {
3396        path: path.to_string(),
3397        sha256: asset.blob_sha256.clone(),
3398        bytes: asset.bytes,
3399        media_type: asset.media_type.clone(),
3400        wrappers: asset.wrappers.clone(),
3401        required: asset.required,
3402    }
3403}
3404
3405fn v2_asset_manifest_bytes(
3406    assets: &std::collections::BTreeMap<String, V2BaselineAsset>,
3407) -> LinkResult<Vec<u8>> {
3408    let mut bytes = Vec::new();
3409    for (path, asset) in assets {
3410        serde_json::to_writer(&mut bytes, &v2_asset_record(asset, path))
3411            .map_err(|_| invalid_feed("could not materialize v2 assets.jsonl"))?;
3412        bytes.push(b'\n');
3413    }
3414    Ok(bytes)
3415}
3416
3417fn sign_verified_v2_candidate(
3418    cfg: &HubConfig,
3419    head: &V2VerifiedHead,
3420    expected: &std::collections::BTreeMap<String, V2BaselineFile>,
3421    expected_assets: &std::collections::BTreeMap<String, V2BaselineAsset>,
3422    mutation_id: &str,
3423    request_body: &Value,
3424    challenge_value: &Value,
3425) -> LinkResult<(String, String, String)> {
3426    if head.view_kind != "full" {
3427        return Err(invalid_feed(
3428            "a scoped self-custody writer must use the proposal workflow",
3429        ));
3430    }
3431    if head.identity.custody != "self" {
3432        return Err(invalid_feed(
3433            "a hub-custodied brain unexpectedly requested an external signature",
3434        ));
3435    }
3436    let key = cfg
3437        .brain_key
3438        .as_ref()
3439        .ok_or_else(|| bad_agent_key("this self-custodied brain requires DBMD_BRAIN_KEY_FILE"))?;
3440    if key.multikey != format!("ed25519:{}", head.identity.fingerprint)
3441        || key.public_key_spki != head.identity.public_key_spki
3442    {
3443        return Err(bad_agent_key(
3444            "DBMD_BRAIN_KEY_FILE does not match the verified brain identity",
3445        ));
3446    }
3447    let challenge_id = challenge_value
3448        .get("id")
3449        .and_then(Value::as_str)
3450        .filter(|id| crate::ulid::is_ulid(id))
3451        .ok_or_else(|| invalid_feed("self-custody challenge has no canonical id"))?;
3452    let expected_endpoint = format!(
3453        "/api/hub/brains/{}/v2/signing-challenges/{challenge_id}",
3454        head.brain_id
3455    );
3456    if challenge_value
3457        .get("candidate_endpoint")
3458        .and_then(Value::as_str)
3459        != Some(expected_endpoint.as_str())
3460    {
3461        return Err(invalid_feed(
3462            "self-custody challenge candidate endpoint is not origin-bound",
3463        ));
3464    }
3465
3466    let mut files = std::collections::BTreeMap::new();
3467    let mut after = String::new();
3468    type CandidateCoordinate = (
3469        String,
3470        String,
3471        String,
3472        String,
3473        Option<String>,
3474        Option<String>,
3475        u64,
3476        Option<String>,
3477    );
3478    let mut pinned: Option<CandidateCoordinate> = None;
3479    loop {
3480        let encoded_after: String =
3481            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3482        let path = format!("{expected_endpoint}?limit=500&after={encoded_after}");
3483        let value = ensure_ok(
3484            request_capped(
3485                cfg,
3486                "GET",
3487                &path,
3488                None,
3489                Auth::Required,
3490                MAX_FEED_RESPONSE_BYTES,
3491            )?,
3492            "v2 self-custody candidate",
3493        )?;
3494        let page: V2SigningCandidatePage = serde_json::from_value(value)
3495            .map_err(|_| invalid_feed("self-custody candidate has an invalid shape"))?;
3496        if page.v != 2
3497            || page.challenge_id != challenge_id
3498            || page.mutation_id != mutation_id
3499            || page.candidate.seq != page.parent.seq + 1
3500            || page.files.len() > 500
3501            || page.expires_at.is_empty()
3502        {
3503            return Err(invalid_feed(
3504                "self-custody candidate is not bound to this mutation",
3505            ));
3506        }
3507        let coordinate = (
3508            page.request_hash.clone(),
3509            page.candidate.signing_bytes_base64.clone(),
3510            page.candidate.changes_base64.clone(),
3511            page.candidate.actor_claim_base64.clone(),
3512            page.candidate.content_root.clone(),
3513            page.candidate.asset_root.clone(),
3514            page.parent.seq,
3515            page.parent.commit_hash.clone(),
3516        );
3517        if pinned.as_ref().is_some_and(|prior| prior != &coordinate) {
3518            return Err(invalid_feed(
3519                "self-custody candidate changed between manifest pages",
3520            ));
3521        }
3522        pinned = Some(coordinate);
3523        let root = page
3524            .candidate
3525            .content_root
3526            .as_deref()
3527            .ok_or_else(|| invalid_feed("self-custody candidate has no content root"))?;
3528        for file in page.files {
3529            verify_v2_file_proof(root, &file)?;
3530            if files
3531                .insert(
3532                    file.path.clone(),
3533                    V2BaselineFile {
3534                        sha256: file.sha256,
3535                        bytes: file.bytes,
3536                        proof: Some(file.proof),
3537                    },
3538                )
3539                .is_some()
3540            {
3541                return Err(invalid_feed(
3542                    "self-custody candidate repeats a manifest path",
3543                ));
3544            }
3545            if files.len() > MAX_PUSH_FILES {
3546                return Err(invalid_feed(
3547                    "self-custody candidate exceeds the file-count bound",
3548                ));
3549            }
3550        }
3551        match page.next_cursor {
3552            None => break,
3553            Some(next) if next > after => after = next,
3554            Some(_) => {
3555                return Err(invalid_feed(
3556                    "self-custody candidate cursor did not advance",
3557                ))
3558            }
3559        }
3560    }
3561    if files.len() != expected.len()
3562        || files.iter().any(|(path, file)| {
3563            expected.get(path).is_none_or(|expected| {
3564                expected.sha256 != file.sha256 || expected.bytes != file.bytes
3565            })
3566        })
3567    {
3568        return Err(invalid_feed(
3569            "self-custody candidate contains an unexpected file mutation",
3570        ));
3571    }
3572    let mut assets = std::collections::BTreeMap::new();
3573    after.clear();
3574    loop {
3575        let encoded_after: String =
3576            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3577        let path = format!("{expected_endpoint}?kind=assets&limit=500&after={encoded_after}");
3578        let value = ensure_ok(
3579            request_capped(
3580                cfg,
3581                "GET",
3582                &path,
3583                None,
3584                Auth::Required,
3585                MAX_FEED_RESPONSE_BYTES,
3586            )?,
3587            "v2 self-custody asset candidate",
3588        )?;
3589        let page: V2SigningCandidatePage = serde_json::from_value(value)
3590            .map_err(|_| invalid_feed("self-custody asset candidate has an invalid shape"))?;
3591        let coordinate = (
3592            page.request_hash.clone(),
3593            page.candidate.signing_bytes_base64.clone(),
3594            page.candidate.changes_base64.clone(),
3595            page.candidate.actor_claim_base64.clone(),
3596            page.candidate.content_root.clone(),
3597            page.candidate.asset_root.clone(),
3598            page.parent.seq,
3599            page.parent.commit_hash.clone(),
3600        );
3601        if page.v != 2
3602            || page.challenge_id != challenge_id
3603            || page.mutation_id != mutation_id
3604            || page.assets.len() > 500
3605            || pinned.as_ref() != Some(&coordinate)
3606        {
3607            return Err(invalid_feed(
3608                "self-custody asset candidate changed or is not bound",
3609            ));
3610        }
3611        let root = page.candidate.asset_root.as_deref();
3612        if !page.assets.is_empty() && root.is_none() {
3613            return Err(invalid_feed("asset candidate has no asset root"));
3614        }
3615        for item in page.assets {
3616            verify_v2_asset_proof(root.expect("non-empty assets checked"), &item)?;
3617            if assets
3618                .insert(
3619                    item.path.clone(),
3620                    V2BaselineAsset {
3621                        blob_sha256: item.blob_sha256,
3622                        bytes: item.bytes,
3623                        media_type: item.media_type,
3624                        wrappers: item.wrappers,
3625                        required: item.required,
3626                        disposition: item.disposition,
3627                        leaf_hash: item.leaf_hash,
3628                    },
3629                )
3630                .is_some()
3631            {
3632                return Err(invalid_feed("self-custody candidate repeats an asset"));
3633            }
3634        }
3635        match page.next_cursor {
3636            None => break,
3637            Some(next) if next > after => after = next,
3638            Some(_) => {
3639                return Err(invalid_feed(
3640                    "self-custody asset candidate cursor did not advance",
3641                ))
3642            }
3643        }
3644    }
3645    if assets.len() != expected_assets.len()
3646        || assets.iter().any(|(path, asset)| {
3647            expected_assets.get(path).is_none_or(|expected| {
3648                asset.blob_sha256 != expected.blob_sha256
3649                    || asset.bytes != expected.bytes
3650                    || asset.media_type != expected.media_type
3651                    || asset.wrappers != expected.wrappers
3652                    || asset.required != expected.required
3653                    || asset.disposition != expected.disposition
3654            })
3655        })
3656    {
3657        return Err(invalid_feed(
3658            "self-custody candidate contains an unexpected asset mutation",
3659        ));
3660    }
3661    let Some((
3662        request_hash,
3663        signing_b64,
3664        changes_b64,
3665        actor_b64,
3666        root,
3667        asset_root,
3668        parent_seq,
3669        parent,
3670    )) = pinned
3671    else {
3672        return Err(invalid_feed("self-custody candidate has no manifest"));
3673    };
3674    let current_seq = head.pointer.as_ref().map_or(0, |pointer| pointer.seq);
3675    let current_commit = head
3676        .pointer
3677        .as_ref()
3678        .map(|pointer| pointer.commit_hash.clone());
3679    if parent_seq != current_seq || parent != current_commit {
3680        return Err(LinkError::RemoteAdvancedDuringSync);
3681    }
3682    let changes = STANDARD
3683        .decode(changes_b64)
3684        .map_err(|_| invalid_feed("self-custody changeset is not base64"))?;
3685    let expected_changes = json!({
3686        "mutation_id": mutation_id,
3687        "operations": request_body.get("operations").cloned().unwrap_or(Value::Null),
3688        "reason": request_body.get("reason").cloned().unwrap_or(Value::Null),
3689        "v": 2,
3690    });
3691    let expected_changes_bytes = crate::linkmd_v2::canonical_bytes(&expected_changes)
3692        .map_err(|error| invalid_feed(error.to_string()))?;
3693    if changes != expected_changes_bytes {
3694        return Err(invalid_feed(
3695            "self-custody changeset differs from the requested mutation",
3696        ));
3697    }
3698    let changes_hash = crate::linkmd_v2::domain_hash_bytes("v2/changeset", &changes)
3699        .map_err(|error| invalid_feed(error.to_string()))?;
3700    let request_value = json!({
3701        "base": request_body.get("base").cloned().unwrap_or(Value::Null),
3702        "brain": head.brain_id,
3703        "changes_sha256": changes_hash,
3704        "rebase": request_body.get("rebase").cloned().unwrap_or(Value::Null),
3705        "v": 2,
3706        "v1_bridge": Value::Null,
3707    });
3708    let expected_request_hash = crate::linkmd_v2::domain_hash("v2/request", &request_value)
3709        .map_err(|error| invalid_feed(error.to_string()))?;
3710    if request_hash != expected_request_hash {
3711        return Err(invalid_feed(
3712            "self-custody request hash differs from the requested mutation",
3713        ));
3714    }
3715    let actor = STANDARD
3716        .decode(actor_b64)
3717        .map_err(|_| invalid_feed("self-custody actor claim is not base64"))?;
3718    let actor_value: Value = serde_json::from_slice(&actor)
3719        .map_err(|_| invalid_feed("self-custody actor claim is not JSON"))?;
3720    if crate::linkmd_v2::canonical_bytes(&actor_value)
3721        .map_err(|error| invalid_feed(error.to_string()))?
3722        != actor
3723    {
3724        return Err(invalid_feed("self-custody actor claim is not canonical"));
3725    }
3726    let actor_object = actor_value
3727        .as_object()
3728        .ok_or_else(|| invalid_feed("self-custody actor claim is not an object"))?;
3729    let actor_claim = actor_object
3730        .get("claim")
3731        .ok_or_else(|| invalid_feed("self-custody actor claim body is missing"))?;
3732    let actor_public_key = actor_object
3733        .get("public_key")
3734        .and_then(Value::as_str)
3735        .ok_or_else(|| invalid_feed("self-custody actor signer is missing"))?;
3736    let actor_fingerprint = actor_object
3737        .get("fingerprint")
3738        .and_then(Value::as_str)
3739        .ok_or_else(|| invalid_feed("self-custody actor fingerprint is missing"))?;
3740    let actor_signature = actor_object
3741        .get("sig")
3742        .and_then(Value::as_str)
3743        .ok_or_else(|| invalid_feed("self-custody actor signature is missing"))?;
3744    let actor_message = crate::linkmd_v2::canonical_bytes(actor_claim)
3745        .map_err(|error| invalid_feed(error.to_string()))?;
3746    let actor_der = verify_v2_spki_signature(actor_public_key, &actor_message, actor_signature)?;
3747    let expected_actor_signer = format!("{actor_fingerprint}:{actor_public_key}");
3748    let expected_actor_root = root.clone().map(Value::String).unwrap_or(Value::Null);
3749    let expected_actor_asset_root = asset_root.clone().map(Value::String).unwrap_or(Value::Null);
3750    let impact = actor_claim
3751        .get("result")
3752        .and_then(|result| result.get("impact"))
3753        .and_then(Value::as_object);
3754    let impact_fields = [
3755        "creates",
3756        "updates",
3757        "deletes",
3758        "withdrawals",
3759        "renames",
3760        "restores",
3761        "asset_changes",
3762        "public_expansions",
3763        "executable_activations",
3764    ];
3765    let impact_is_valid = impact.is_some_and(|impact| {
3766        impact.len() == impact_fields.len() + 1
3767            && impact.get("v").and_then(Value::as_u64) == Some(1)
3768            && impact_fields
3769                .iter()
3770                .all(|field| impact.get(*field).and_then(Value::as_u64).is_some())
3771    });
3772    if format!("{:x}", Sha256::digest(&actor_der)) != actor_fingerprint
3773        || head
3774            .trust
3775            .hub_signer
3776            .as_ref()
3777            .is_some_and(|known| known != &expected_actor_signer)
3778        || actor_claim.get("mutation_id").and_then(Value::as_str) != Some(mutation_id)
3779        || actor_claim.get("request_hash").and_then(Value::as_str) != Some(request_hash.as_str())
3780        || actor_claim
3781            .get("candidate")
3782            .and_then(|candidate| candidate.get("changes_sha256"))
3783            .and_then(Value::as_str)
3784            != Some(changes_hash.as_str())
3785        || actor_claim
3786            .get("candidate")
3787            .and_then(|candidate| candidate.get("state_root"))
3788            != Some(&expected_actor_root)
3789        || actor_claim
3790            .get("candidate")
3791            .and_then(|candidate| candidate.get("asset_root"))
3792            != Some(&expected_actor_asset_root)
3793        || actor_claim
3794            .get("candidate")
3795            .and_then(|candidate| candidate.get("control_revision"))
3796            .and_then(Value::as_str)
3797            != Some(head.view_revision.as_str())
3798        || !impact_is_valid
3799    {
3800        return Err(invalid_feed(
3801            "self-custody actor claim does not bind the verified authority",
3802        ));
3803    }
3804    let actor_hash = crate::linkmd_v2::domain_hash_bytes("v2/actor-claim", &actor)
3805        .map_err(|error| invalid_feed(error.to_string()))?;
3806    let signing = STANDARD
3807        .decode(signing_b64)
3808        .map_err(|_| invalid_feed("self-custody signing bytes are not base64"))?;
3809    let signing_value: Value = serde_json::from_slice(&signing)
3810        .map_err(|_| invalid_feed("self-custody signing bytes are not JSON"))?;
3811    if crate::linkmd_v2::canonical_bytes(&signing_value)
3812        .map_err(|error| invalid_feed(error.to_string()))?
3813        != signing
3814    {
3815        return Err(invalid_feed("self-custody signing bytes are not canonical"));
3816    }
3817    let pointer = head.pointer.as_ref();
3818    let expected_materializer = pointer
3819        .map(|value| value.materializer.as_str())
3820        .unwrap_or("dbmd-projection-v1");
3821    let expected_parent_commit = request_body
3822        .get("base")
3823        .and_then(|base| base.get("commit_hash"))
3824        .cloned()
3825        .unwrap_or(Value::Null);
3826    let expected_parent_root = request_body
3827        .get("base")
3828        .and_then(|base| base.get("content_root"))
3829        .cloned()
3830        .unwrap_or(Value::Null);
3831    let expected_state_root = root.clone().map(Value::String).unwrap_or(Value::Null);
3832    let expected_parent_asset_root = request_body
3833        .get("base")
3834        .and_then(|base| base.get("asset_root"))
3835        .cloned()
3836        .unwrap_or(Value::Null);
3837    let expected_asset_root = asset_root.map(Value::String).unwrap_or(Value::Null);
3838    let expected_prev_entry = pointer
3839        .map(|value| Value::String(value.feed_hash.clone()))
3840        .unwrap_or(Value::Null);
3841    let expected_signer_epoch = u64::try_from(head.identity.previous.len())
3842        .map_err(|_| invalid_feed("brain identity history is too large"))?
3843        + 1;
3844    if signing_value.get("v").and_then(Value::as_u64) != Some(2)
3845        || signing_value.get("seq").and_then(Value::as_u64) != Some(current_seq + 1)
3846        || signing_value.get("signer_epoch").and_then(Value::as_u64) != Some(expected_signer_epoch)
3847        || signing_value.get("brain").and_then(Value::as_str) != Some(key.multikey.as_str())
3848        || signing_value.get("public_key").and_then(Value::as_str)
3849            != Some(key.public_key_spki.as_str())
3850        || signing_value.get("parent_commit") != Some(&expected_parent_commit)
3851        || signing_value.get("parent_root") != Some(&expected_parent_root)
3852        || signing_value.get("state_root") != Some(&expected_state_root)
3853        || signing_value.get("parent_asset_root") != Some(&expected_parent_asset_root)
3854        || signing_value.get("asset_root") != Some(&expected_asset_root)
3855        || signing_value.get("materializer").and_then(Value::as_str) != Some(expected_materializer)
3856        || signing_value.get("changes_sha256").and_then(Value::as_str)
3857            != Some(changes_hash.as_str())
3858        || signing_value.get("actor_ref").and_then(Value::as_str) != Some(actor_hash.as_str())
3859        || signing_value
3860            .get("control_revision")
3861            .and_then(Value::as_str)
3862            != Some(head.view_revision.as_str())
3863        || signing_value.get("prev_entry_hash") != Some(&expected_prev_entry)
3864        || signing_value.get("v1_bridge") != Some(&Value::Null)
3865        || signing_value.get("op").and_then(Value::as_str) != Some("changeset")
3866    {
3867        return Err(invalid_feed(
3868            "self-custody signing bytes do not bind the verified candidate",
3869        ));
3870    }
3871    let pair = agent_keypair(&key.pkcs8)?;
3872    let signature = URL_SAFE_NO_PAD.encode(pair.sign(&signing).as_ref());
3873    Ok((challenge_id.to_string(), signature, expected_actor_signer))
3874}
3875
3876fn v2_baseline_name(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<String> {
3877    let origin = normalized_origin(&cfg.hub)?;
3878    let absolute = if checkout.is_absolute() {
3879        checkout.to_path_buf()
3880    } else {
3881        std::env::current_dir()?.join(checkout)
3882    };
3883    Ok(format!(
3884        "sync-{}.json",
3885        content_sha256(format!("{origin}\0{brain}\0{}", absolute.display()).as_bytes())
3886    ))
3887}
3888
3889#[cfg(any(unix, windows))]
3890fn lock_v2_sync_operation(cfg: &HubConfig, brain: &str) -> LinkResult<TrustLock> {
3891    let directory = open_trust_dir(cfg)?;
3892    let origin = normalized_origin(&cfg.hub)?;
3893    let name = format!(
3894        "operation-{}.lock",
3895        content_sha256(format!("{origin}\0{brain}").as_bytes())
3896    );
3897    lock_trust_name(&directory, &name)
3898}
3899
3900#[cfg(not(any(unix, windows)))]
3901fn lock_v2_sync_operation(_cfg: &HubConfig, _brain: &str) -> LinkResult<()> {
3902    Err(LinkError::UnsupportedPlatform {
3903        operation: "serialized link.md v2 sync",
3904    })
3905}
3906
3907fn same_v2_head(left: &V2VerifiedHead, right: &V2VerifiedHead) -> bool {
3908    left.brain_id == right.brain_id
3909        && left.view_kind == right.view_kind
3910        && left.view_revision == right.view_revision
3911        && match (&left.pointer, &right.pointer) {
3912            (None, None) => true,
3913            (Some(left), Some(right)) => {
3914                left.seq == right.seq
3915                    && left.commit_hash == right.commit_hash
3916                    && left.content_root == right.content_root
3917                    && left.asset_root == right.asset_root
3918                    && left.feed_hash == right.feed_hash
3919            }
3920            _ => false,
3921        }
3922}
3923
3924fn scoped_projection_bytes(brain: &str) -> Vec<u8> {
3925    format!(
3926        "---\ntype: db-md\nscope: company\nowner: link.md scoped view\n---\n\n# Scoped brain view\n\nThis DB.md is generated locally by dbmd. It is not the brain's canonical contract and is never uploaded.\n\nCanonical brain: @{brain}\n"
3927    )
3928    .into_bytes()
3929}
3930
3931fn scoped_projection_sha256(brain: &str) -> String {
3932    content_sha256(&scoped_projection_bytes(brain))
3933}
3934
3935#[derive(Deserialize)]
3936struct LocalScopedViewMarker {
3937    v: u8,
3938    kind: String,
3939    authoritative: bool,
3940    brain: String,
3941    projection_sha256: String,
3942}
3943
3944/// True only when this store carries the exact generated marker for a local
3945/// link.md scoped view. This is presentation context, never authorization;
3946/// the hub remains the only authority for reads and writes.
3947pub fn has_verified_local_scoped_view(store: &Store) -> bool {
3948    let marker = store
3949        .read_bounded(Path::new(".dbmd/view.json"), 64 * 1024)
3950        .ok()
3951        .and_then(|bytes| serde_json::from_slice::<LocalScopedViewMarker>(&bytes).ok());
3952    let Some(marker) = marker else {
3953        return false;
3954    };
3955    if marker.v != 1
3956        || marker.kind != "link.md-scoped-view"
3957        || marker.authoritative
3958        || !crate::ulid::is_ulid(&marker.brain)
3959        || marker.projection_sha256 != scoped_projection_sha256(&marker.brain)
3960    {
3961        return false;
3962    }
3963    store
3964        .read_bounded(Path::new("DB.md"), crate::parser::MAX_DBMD_FILE_BYTES)
3965        .is_ok_and(|bytes| content_sha256(&bytes) == marker.projection_sha256)
3966}
3967
3968fn scoped_view_metadata(head: &V2VerifiedHead, files: usize) -> LinkResult<Vec<u8>> {
3969    let mut bytes = serde_json::to_vec_pretty(&json!({
3970        "v": 1,
3971        "kind": "link.md-scoped-view",
3972        "authoritative": false,
3973        "brain": head.brain_id,
3974        "view_revision": head.view_revision,
3975        "head_seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
3976        "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
3977        "content_root": head.pointer.as_ref().and_then(|pointer| pointer.content_root.as_ref()),
3978        "visible_files": files,
3979        "projection_sha256": scoped_projection_sha256(&head.brain_id),
3980    }))
3981    .map_err(|_| invalid_feed("could not serialize scoped view metadata"))?;
3982    bytes.push(b'\n');
3983    Ok(bytes)
3984}
3985
3986fn refresh_scoped_view_marker(
3987    store: &Store,
3988    head: &V2VerifiedHead,
3989    files: usize,
3990) -> LinkResult<()> {
3991    if head.view_kind == "scoped" {
3992        store.write_atomic(
3993            Path::new(".dbmd/view.json"),
3994            &scoped_view_metadata(head, files)?,
3995        )?;
3996    }
3997    Ok(())
3998}
3999
4000fn ensure_v2_view_compatible(
4001    head: &V2VerifiedHead,
4002    baseline: Option<&V2SyncBaseline>,
4003) -> LinkResult<()> {
4004    let Some(baseline) = baseline else {
4005        return Ok(());
4006    };
4007    match (
4008        baseline.view_kind.as_deref(),
4009        baseline.view_revision.as_deref(),
4010    ) {
4011        (None, None) if head.view_kind == "full" => Ok(()),
4012        (Some(kind), Some(revision))
4013            if kind == head.view_kind && revision == head.view_revision =>
4014        {
4015            Ok(())
4016        }
4017        _ => Err(LinkError::ScopedViewChanged),
4018    }
4019}
4020
4021fn remove_scoped_projection(
4022    head: &V2VerifiedHead,
4023    baseline: Option<&V2SyncBaseline>,
4024    view: &mut V2LocalView,
4025) -> LinkResult<()> {
4026    if head.view_kind != "scoped" {
4027        return Ok(());
4028    }
4029    let expected = scoped_projection_sha256(&head.brain_id);
4030    if baseline
4031        .and_then(|state| state.projection_sha256.as_deref())
4032        .is_some_and(|pinned| pinned != expected)
4033    {
4034        return Err(LinkError::ScopedViewChanged);
4035    }
4036    if view.riding.get("DB.md").map(|(sha256, _)| sha256.as_str()) != Some(expected.as_str()) {
4037        return Err(LinkError::ScopedProjectionModified);
4038    }
4039    view.riding.remove("DB.md");
4040    view.eligibility.remove("DB.md");
4041    Ok(())
4042}
4043
4044fn files_for_v2_view(
4045    head: &V2VerifiedHead,
4046    mut files: std::collections::BTreeMap<String, V2BaselineFile>,
4047) -> std::collections::BTreeMap<String, V2BaselineFile> {
4048    if head.view_kind == "scoped" {
4049        // Even when a grant explicitly includes canonical DB.md, a partial
4050        // checkout uses a generated marker. Canonical validation remains a
4051        // server-side operation over the complete brain.
4052        files.remove("DB.md");
4053    }
4054    files
4055}
4056
4057fn parse_v2_baseline(cfg: &HubConfig, brain: &str, bytes: &[u8]) -> LinkResult<V2SyncBaseline> {
4058    let baseline: V2SyncBaseline =
4059        serde_json::from_slice(bytes).map_err(|_| invalid_feed("v2 sync baseline is corrupt"))?;
4060    if baseline.v != 2
4061        || baseline.origin != normalized_origin(&cfg.hub)?
4062        || baseline.brain != brain
4063        || baseline
4064            .commit_hash
4065            .as_deref()
4066            .is_some_and(|hash| !is_sha256(hash))
4067        || baseline
4068            .content_root
4069            .as_deref()
4070            .is_some_and(|hash| !is_sha256(hash))
4071        || baseline
4072            .asset_root
4073            .as_deref()
4074            .is_some_and(|hash| !is_sha256(hash))
4075        || baseline
4076            .local_policy_digest
4077            .as_deref()
4078            .is_some_and(|hash| !is_sha256(hash))
4079        || baseline
4080            .view_kind
4081            .as_deref()
4082            .is_some_and(|kind| !matches!(kind, "full" | "scoped"))
4083        || baseline
4084            .view_revision
4085            .as_deref()
4086            .is_some_and(|hash| !is_sha256(hash))
4087        || baseline
4088            .projection_sha256
4089            .as_deref()
4090            .is_some_and(|hash| !is_sha256(hash))
4091        || (baseline.view_kind.as_deref() == Some("scoped")
4092            && (baseline.view_revision.is_none() || baseline.projection_sha256.is_none()))
4093        || baseline.files.len() > MAX_PUSH_FILES
4094        || baseline.assets.len() > MAX_PUSH_FILES
4095        || baseline.local_eligibility.len() > MAX_PUSH_FILES
4096        || baseline.remote_copy_remains.len() > MAX_PUSH_FILES
4097        || baseline.files.iter().any(|(path, file)| {
4098            crate::linkmd_v2::normalize_path(path).is_err()
4099                || !is_sha256(&file.sha256)
4100                || file.bytes > MAX_STORE_BYTES
4101        })
4102        || baseline.assets.iter().any(|(path, asset)| {
4103            crate::linkmd_v2::normalize_path(path).is_err()
4104                || !is_sha256(&asset.blob_sha256)
4105                || !is_sha256(&asset.leaf_hash)
4106                || asset.bytes > MAX_STORE_BYTES
4107                || !matches!(asset.disposition.as_str(), "hosted" | "withheld")
4108                || asset.wrappers.is_empty()
4109                || asset
4110                    .wrappers
4111                    .iter()
4112                    .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
4113        })
4114        || baseline
4115            .local_eligibility
4116            .keys()
4117            .chain(baseline.remote_copy_remains.keys())
4118            .any(|path| crate::linkmd_v2::normalize_path(path).is_err())
4119        || baseline
4120            .remote_copy_remains
4121            .values()
4122            .any(|hash| !is_sha256(hash))
4123    {
4124        return Err(invalid_feed("v2 sync baseline failed validation"));
4125    }
4126    Ok(baseline)
4127}
4128
4129#[cfg(unix)]
4130fn load_v2_baseline(
4131    cfg: &HubConfig,
4132    brain: &str,
4133    checkout: &Path,
4134) -> LinkResult<Option<V2SyncBaseline>> {
4135    use std::os::fd::{AsRawFd as _, FromRawFd as _};
4136    let directory = open_trust_dir(cfg)?;
4137    let name_string = v2_baseline_name(cfg, brain, checkout)?;
4138    let _lock = lock_trust_name(&directory, &name_string)?;
4139    let name = c_name(name_string.as_bytes(), &name_string)?;
4140    let fd = unsafe {
4141        libc::openat(
4142            directory.as_raw_fd(),
4143            name.as_ptr(),
4144            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4145        )
4146    };
4147    if fd < 0 {
4148        let error = std::io::Error::last_os_error();
4149        return if error.kind() == std::io::ErrorKind::NotFound {
4150            Ok(None)
4151        } else {
4152            Err(LinkError::UnsafePath { path: name_string })
4153        };
4154    }
4155    let file = unsafe { std::fs::File::from_raw_fd(fd) };
4156    let mut bytes = Vec::new();
4157    file.take(MAX_FEED_RESPONSE_BYTES + 1)
4158        .read_to_end(&mut bytes)?;
4159    if bytes.len() as u64 > MAX_FEED_RESPONSE_BYTES {
4160        return Err(invalid_feed("v2 sync baseline is oversized"));
4161    }
4162    Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?))
4163}
4164
4165#[cfg(windows)]
4166fn load_v2_baseline(
4167    cfg: &HubConfig,
4168    brain: &str,
4169    checkout: &Path,
4170) -> LinkResult<Option<V2SyncBaseline>> {
4171    let directory = open_trust_dir(cfg)?;
4172    let name = v2_baseline_name(cfg, brain, checkout)?;
4173    let _lock = lock_trust_name(&directory, &name)?;
4174    let mut reader = crate::fsx::BoundedDirReader::from_root(&directory)?;
4175    match reader.read(Path::new(&name), MAX_FEED_RESPONSE_BYTES) {
4176        Ok(bytes) => Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?)),
4177        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
4178        Err(_) => Err(LinkError::UnsafePath { path: name }),
4179    }
4180}
4181
4182#[cfg(not(any(unix, windows)))]
4183fn load_v2_baseline(
4184    _cfg: &HubConfig,
4185    _brain: &str,
4186    _checkout: &Path,
4187) -> LinkResult<Option<V2SyncBaseline>> {
4188    Err(LinkError::UnsupportedPlatform {
4189        operation: "verified link.md v2 baseline",
4190    })
4191}
4192
4193#[cfg(unix)]
4194fn save_v2_baseline(
4195    cfg: &HubConfig,
4196    brain: &str,
4197    checkout: &Path,
4198    baseline: &V2SyncBaseline,
4199) -> LinkResult<()> {
4200    use std::os::fd::{AsRawFd as _, FromRawFd as _};
4201    let directory = open_trust_dir(cfg)?;
4202    let name_string = v2_baseline_name(cfg, brain, checkout)?;
4203    let _lock = lock_trust_name(&directory, &name_string)?;
4204    let name = c_name(name_string.as_bytes(), &name_string)?;
4205    let mut bytes = serde_json::to_vec(baseline)
4206        .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
4207    bytes.push(b'\n');
4208    let temp_string = format!(
4209        ".{name_string}.tmp.{}-{}",
4210        std::process::id(),
4211        std::time::SystemTime::now()
4212            .duration_since(std::time::UNIX_EPOCH)
4213            .unwrap_or_default()
4214            .as_nanos()
4215    );
4216    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
4217    let fd = unsafe {
4218        libc::openat(
4219            directory.as_raw_fd(),
4220            temp.as_ptr(),
4221            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4222            0o600,
4223        )
4224    };
4225    if fd < 0 {
4226        return Err(std::io::Error::last_os_error().into());
4227    }
4228    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
4229    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
4230        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4231        return Err(error.into());
4232    }
4233    drop(file);
4234    if unsafe {
4235        libc::renameat(
4236            directory.as_raw_fd(),
4237            temp.as_ptr(),
4238            directory.as_raw_fd(),
4239            name.as_ptr(),
4240        )
4241    } != 0
4242    {
4243        let error = std::io::Error::last_os_error();
4244        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4245        return Err(error.into());
4246    }
4247    directory.sync_all()?;
4248    Ok(())
4249}
4250
4251#[cfg(windows)]
4252fn save_v2_baseline(
4253    cfg: &HubConfig,
4254    brain: &str,
4255    checkout: &Path,
4256    baseline: &V2SyncBaseline,
4257) -> LinkResult<()> {
4258    let directory = open_trust_dir(cfg)?;
4259    let name = v2_baseline_name(cfg, brain, checkout)?;
4260    let _lock = lock_trust_name(&directory, &name)?;
4261    let mut bytes = serde_json::to_vec(baseline)
4262        .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
4263    bytes.push(b'\n');
4264    crate::fsx::write_atomic_beneath(&directory, Path::new(&name), &bytes, false, true)?;
4265    Ok(())
4266}
4267
4268#[cfg(not(any(unix, windows)))]
4269fn save_v2_baseline(
4270    _cfg: &HubConfig,
4271    _brain: &str,
4272    _checkout: &Path,
4273    _baseline: &V2SyncBaseline,
4274) -> LinkResult<()> {
4275    Err(LinkError::UnsupportedPlatform {
4276        operation: "verified link.md v2 baseline",
4277    })
4278}
4279
4280fn v2_baseline_from_head(
4281    cfg: &HubConfig,
4282    head: &V2VerifiedHead,
4283    files: std::collections::BTreeMap<String, V2BaselineFile>,
4284    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
4285    local: Option<&V2LocalView>,
4286) -> LinkResult<V2SyncBaseline> {
4287    let mut local_eligibility = local
4288        .map(|view| view.eligibility.clone())
4289        .unwrap_or_default();
4290    if let Some(view) = local {
4291        for path in files.keys() {
4292            local_eligibility
4293                .entry(path.clone())
4294                .or_insert_with(|| !view.policy.keeps_home(path));
4295        }
4296    }
4297    let remote_copy_remains = local_eligibility
4298        .iter()
4299        .filter(|(_, riding)| !**riding)
4300        .filter_map(|(path, _)| {
4301            files
4302                .get(path)
4303                .map(|file| (path.clone(), file.sha256.clone()))
4304        })
4305        .collect();
4306    Ok(V2SyncBaseline {
4307        v: 2,
4308        origin: normalized_origin(&cfg.hub)?,
4309        brain: head.brain_id.clone(),
4310        head_seq: Some(head.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
4311        commit_hash: head
4312            .pointer
4313            .as_ref()
4314            .map(|pointer| pointer.commit_hash.clone()),
4315        content_root: head
4316            .pointer
4317            .as_ref()
4318            .and_then(|pointer| pointer.content_root.clone()),
4319        asset_root: head
4320            .pointer
4321            .as_ref()
4322            .and_then(|pointer| pointer.asset_root.clone()),
4323        assets,
4324        view_kind: Some(head.view_kind.clone()),
4325        view_revision: Some(head.view_revision.clone()),
4326        projection_sha256: (head.view_kind == "scoped")
4327            .then(|| scoped_projection_sha256(&head.brain_id)),
4328        files,
4329        local_policy_digest: local.map(|view| view.policy.digest.clone()),
4330        local_eligibility,
4331        remote_copy_remains,
4332    })
4333}
4334
4335fn v2_local_files(store: &Store) -> LinkResult<V2LocalView> {
4336    let policy = crate::linkmd_sync_policy::load(store)
4337        .map_err(|message| LinkError::InvalidPack { message })?;
4338    let asset_paths = crate::assets::read_manifest(store)
4339        .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
4340        .into_iter()
4341        .map(|asset| asset.path)
4342        .collect::<std::collections::BTreeSet<_>>();
4343    let mut result = std::collections::BTreeMap::new();
4344    let mut eligibility = std::collections::BTreeMap::new();
4345    let mut total = 0_u64;
4346    let mut paths = vec![PathBuf::from("DB.md")];
4347    paths.extend(store.walk()?);
4348    for relative in paths {
4349        let path = relative.to_string_lossy().replace('\\', "/");
4350        // v2 catalogs and asset inventory are materialized/signed separately.
4351        if matches!(path.as_str(), "assets.jsonl" | "index.md" | "index.jsonl") {
4352            continue;
4353        }
4354        if asset_paths.contains(&path) {
4355            continue;
4356        }
4357        crate::linkmd_v2::normalize_path(&path).map_err(|error| LinkError::UnsafePath {
4358            path: error.to_string(),
4359        })?;
4360        let riding = !policy.keeps_home(&path);
4361        eligibility.insert(path.clone(), riding);
4362        if !riding {
4363            continue;
4364        }
4365        let remaining = MAX_STORE_BYTES.saturating_sub(total);
4366        let bytes = store.read_bounded(&relative, remaining)?;
4367        total = total
4368            .checked_add(bytes.len() as u64)
4369            .ok_or_else(|| LinkError::PushTooLarge {
4370                detail: "v2 local byte count overflow".to_string(),
4371            })?;
4372        if total > MAX_STORE_BYTES {
4373            return Err(LinkError::PushTooLarge {
4374                detail: format!("{total} uncompressed bytes"),
4375            });
4376        }
4377        if std::str::from_utf8(&bytes).is_err() {
4378            return Err(LinkError::NotUtf8 { path });
4379        }
4380        result.insert(path, (content_sha256(&bytes), bytes.len() as u64));
4381    }
4382    Ok(V2LocalView {
4383        riding: result,
4384        eligibility,
4385        policy,
4386    })
4387}
4388
4389#[derive(Debug, Deserialize)]
4390struct V2DownloadItem {
4391    path: String,
4392    sha256: String,
4393    bytes: u64,
4394    url: String,
4395    method: String,
4396}
4397
4398#[derive(Debug, Deserialize)]
4399struct V2DownloadWindow {
4400    v: u8,
4401    commit: String,
4402    downloads: Vec<V2DownloadItem>,
4403}
4404
4405#[derive(Debug, Deserialize)]
4406struct V2BulkStreamHeader {
4407    v: u8,
4408    path: String,
4409    sha256: String,
4410    bytes: u64,
4411}
4412
4413fn parse_v2_bulk_stream(
4414    bytes: &[u8],
4415    expected: &[(&String, &V2BaselineFile)],
4416) -> LinkResult<Vec<(String, Vec<u8>)>> {
4417    if !bytes.starts_with(V2_BULK_STREAM_MAGIC) {
4418        return Err(invalid_feed("v2 bulk stream has an invalid magic"));
4419    }
4420    let mut cursor = V2_BULK_STREAM_MAGIC.len();
4421    let mut result = Vec::with_capacity(expected.len());
4422    for (expected_path, expected_file) in expected {
4423        let length_bytes = bytes
4424            .get(cursor..cursor + 4)
4425            .ok_or_else(|| invalid_feed("v2 bulk stream ended before a frame header"))?;
4426        cursor += 4;
4427        let header_len = u32::from_be_bytes(length_bytes.try_into().expect("four bytes")) as usize;
4428        if header_len == 0 || header_len > 4 * 1024 {
4429            return Err(invalid_feed("v2 bulk stream has an invalid frame length"));
4430        }
4431        let header_bytes = bytes
4432            .get(cursor..cursor + header_len)
4433            .ok_or_else(|| invalid_feed("v2 bulk stream ended inside a frame header"))?;
4434        cursor += header_len;
4435        let header: V2BulkStreamHeader = serde_json::from_slice(header_bytes)
4436            .map_err(|_| invalid_feed("v2 bulk stream has an invalid frame header"))?;
4437        if header.v != 2
4438            || &header.path != *expected_path
4439            || header.sha256 != expected_file.sha256
4440            || header.bytes != expected_file.bytes
4441            || header.bytes > V2_BULK_STREAM_CONTENT_BYTES
4442        {
4443            return Err(invalid_feed(
4444                "v2 bulk stream frame differs from its proven manifest entry",
4445            ));
4446        }
4447        let body_len = usize::try_from(header.bytes)
4448            .map_err(|_| invalid_feed("v2 bulk stream frame length overflows this platform"))?;
4449        let body = bytes
4450            .get(cursor..cursor + body_len)
4451            .ok_or_else(|| invalid_feed("v2 bulk stream ended inside file bytes"))?;
4452        cursor += body_len;
4453        if content_sha256(body) != header.sha256 {
4454            return Err(invalid_feed(
4455                "v2 bulk stream file differs from its proven manifest entry",
4456            ));
4457        }
4458        result.push((header.path, body.to_vec()));
4459    }
4460    if bytes.get(cursor..cursor + 4) != Some(&[0, 0, 0, 0]) {
4461        return Err(invalid_feed("v2 bulk stream has no exact end marker"));
4462    }
4463    cursor += 4;
4464    if cursor != bytes.len() {
4465        return Err(invalid_feed("v2 bulk stream carries trailing data"));
4466    }
4467    Ok(result)
4468}
4469
4470fn download_v2_bulk_stream(
4471    cfg: &HubConfig,
4472    brain: &str,
4473    pointer: &V2PointerBody,
4474    pending: &[(&String, &V2BaselineFile)],
4475) -> LinkResult<Vec<(String, Vec<u8>)>> {
4476    let claims = pending
4477        .iter()
4478        .map(|(path, file)| {
4479            Ok(json!({
4480                "path": path,
4481                "sha256": file.sha256,
4482                "bytes": file.bytes,
4483                "proof": file.proof.as_ref().ok_or_else(|| {
4484                    invalid_feed("v2 manifest omitted a bulk-stream proof")
4485                })?,
4486            }))
4487        })
4488        .collect::<LinkResult<Vec<_>>>()?;
4489    let raw = request_raw(
4490        cfg,
4491        "POST",
4492        &format!("/api/hub/brains/{brain}/v2/stream"),
4493        Some(&json!({
4494            "commit": pointer.commit_hash,
4495            "files": claims,
4496        })),
4497        Auth::Required,
4498        V2_BULK_STREAM_RESPONSE_BYTES,
4499    )?;
4500    let body = ensure_raw_ok(raw, "download v2 bulk stream")?;
4501    parse_v2_bulk_stream(&body, pending)
4502}
4503
4504fn prepare_v2_downloads(
4505    cfg: &HubConfig,
4506    brain: &str,
4507    pointer: &V2PointerBody,
4508    pending: &[(&String, &V2BaselineFile)],
4509) -> LinkResult<Vec<V2DownloadItem>> {
4510    let mut result = Vec::with_capacity(pending.len());
4511    for chunk in pending.chunks(128) {
4512        let claims = chunk
4513            .iter()
4514            .map(|(path, file)| {
4515                Ok(json!({
4516                    "path": path,
4517                    "sha256": file.sha256,
4518                    "bytes": file.bytes,
4519                    "proof": file.proof.as_ref().ok_or_else(|| {
4520                        invalid_feed("v2 manifest omitted a download proof")
4521                    })?,
4522                }))
4523            })
4524            .collect::<LinkResult<Vec<_>>>()?;
4525        let value = ensure_ok(
4526            request_capped(
4527                cfg,
4528                "POST",
4529                &format!("/api/hub/brains/{brain}/v2/downloads"),
4530                Some(&json!({
4531                    "commit": pointer.commit_hash,
4532                    "files": claims,
4533                })),
4534                Auth::Required,
4535                MAX_FEED_RESPONSE_BYTES,
4536            )?,
4537            "prepare v2 blob downloads",
4538        )?;
4539        let window: V2DownloadWindow = serde_json::from_value(value)
4540            .map_err(|_| invalid_feed("v2 download window has an invalid shape"))?;
4541        if window.v != 2
4542            || window.commit != pointer.commit_hash
4543            || window.downloads.len() != chunk.len()
4544        {
4545            return Err(invalid_feed(
4546                "v2 download window is not bound to the requested files",
4547            ));
4548        }
4549        let mut by_path = window
4550            .downloads
4551            .into_iter()
4552            .map(|item| (item.path.clone(), item))
4553            .collect::<std::collections::BTreeMap<_, _>>();
4554        if by_path.len() != chunk.len() {
4555            return Err(invalid_feed("v2 download window repeats a path"));
4556        }
4557        for (path, file) in chunk {
4558            let item = by_path
4559                .remove(*path)
4560                .ok_or_else(|| invalid_feed("v2 download window omitted a path"))?;
4561            if item.method != "GET"
4562                || item.sha256 != file.sha256
4563                || item.bytes != file.bytes
4564                || item.url.is_empty()
4565            {
4566                return Err(invalid_feed(
4567                    "v2 download capability differs from its proven file",
4568                ));
4569            }
4570            result.push(item);
4571        }
4572    }
4573    Ok(result)
4574}
4575
4576fn prepare_v2_asset_downloads(
4577    cfg: &HubConfig,
4578    brain: &str,
4579    pointer: &V2PointerBody,
4580    pending: &[(&String, &V2BaselineAsset)],
4581) -> LinkResult<Vec<V2DownloadItem>> {
4582    let mut result = Vec::with_capacity(pending.len());
4583    for chunk in pending.chunks(128) {
4584        let claims = chunk
4585            .iter()
4586            .map(|(path, asset)| {
4587                json!({
4588                    "path": path,
4589                    "sha256": asset.blob_sha256,
4590                    "bytes": asset.bytes,
4591                    "leaf_hash": asset.leaf_hash,
4592                })
4593            })
4594            .collect::<Vec<_>>();
4595        let value = ensure_ok(
4596            request_capped(
4597                cfg,
4598                "POST",
4599                &format!("/api/hub/brains/{brain}/v2/assets/downloads"),
4600                Some(&json!({
4601                    "commit": pointer.commit_hash,
4602                    "assets": claims,
4603                })),
4604                Auth::Required,
4605                MAX_FEED_RESPONSE_BYTES,
4606            )?,
4607            "prepare v2 asset downloads",
4608        )?;
4609        let window: V2DownloadWindow = serde_json::from_value(value)
4610            .map_err(|_| invalid_feed("v2 asset download window has an invalid shape"))?;
4611        if window.v != 2
4612            || window.commit != pointer.commit_hash
4613            || window.downloads.len() != chunk.len()
4614        {
4615            return Err(invalid_feed(
4616                "v2 asset download window is not bound to the requested assets",
4617            ));
4618        }
4619        let mut by_path = window
4620            .downloads
4621            .into_iter()
4622            .map(|item| (item.path.clone(), item))
4623            .collect::<std::collections::BTreeMap<_, _>>();
4624        if by_path.len() != chunk.len() {
4625            return Err(invalid_feed("v2 asset download window repeats a path"));
4626        }
4627        for (path, asset) in chunk {
4628            let item = by_path
4629                .remove(*path)
4630                .ok_or_else(|| invalid_feed("v2 asset download window omitted a path"))?;
4631            if item.method != "GET"
4632                || item.sha256 != asset.blob_sha256
4633                || item.bytes != asset.bytes
4634                || item.url.is_empty()
4635            {
4636                return Err(invalid_feed(
4637                    "v2 asset download capability differs from its signed leaf",
4638                ));
4639            }
4640            result.push(item);
4641        }
4642    }
4643    Ok(result)
4644}
4645
4646fn download_v2_blob(cfg: &HubConfig, item: &V2DownloadItem) -> LinkResult<Vec<u8>> {
4647    let bytes = get_presigned(cfg, &item.url)?;
4648    if bytes.len() as u64 != item.bytes || content_sha256(&bytes) != item.sha256 {
4649        return Err(invalid_feed("v2 blob differs from its proven path entry"));
4650    }
4651    Ok(bytes)
4652}
4653
4654#[derive(Debug, Clone)]
4655struct V2StagedFile {
4656    path: String,
4657    source: PathBuf,
4658    sha256: String,
4659    bytes: u64,
4660}
4661
4662#[cfg(unix)]
4663fn v2_download_cache_dir(
4664    cfg: &HubConfig,
4665    brain: &str,
4666    pointer: &V2PointerBody,
4667) -> LinkResult<PathBuf> {
4668    v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
4669}
4670
4671#[cfg(unix)]
4672fn v2_download_cache_dir_for(
4673    cfg: &HubConfig,
4674    brain: &str,
4675    transaction: &str,
4676) -> LinkResult<PathBuf> {
4677    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
4678        return Err(invalid_feed("v2 download cache address is invalid"));
4679    }
4680    let path = cfg
4681        .state_dir
4682        .join("downloads")
4683        .join(brain)
4684        .join(transaction);
4685    let directory = open_or_create_dir_nofollow(&path)?;
4686    use std::os::fd::AsRawFd as _;
4687    if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
4688        return Err(std::io::Error::last_os_error().into());
4689    }
4690    directory.sync_all()?;
4691    Ok(path)
4692}
4693
4694#[cfg(windows)]
4695fn v2_download_cache_dir(
4696    cfg: &HubConfig,
4697    brain: &str,
4698    pointer: &V2PointerBody,
4699) -> LinkResult<PathBuf> {
4700    v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
4701}
4702
4703#[cfg(windows)]
4704fn v2_download_cache_dir_for(
4705    cfg: &HubConfig,
4706    brain: &str,
4707    transaction: &str,
4708) -> LinkResult<PathBuf> {
4709    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
4710        return Err(invalid_feed("v2 download cache address is invalid"));
4711    }
4712    let path = cfg
4713        .state_dir
4714        .join("downloads")
4715        .join(brain)
4716        .join(transaction);
4717    crate::fsx::write_atomic(&path.join(".directory"), b"v2 download cache\n")?;
4718    crate::fsx::open_directory_nofollow(&path)?;
4719    Ok(path)
4720}
4721
4722#[cfg(unix)]
4723fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
4724    use std::os::fd::AsRawFd as _;
4725    let parent = cfg.state_dir.join("downloads").join(brain);
4726    let Ok(directory) = open_existing_dir_nofollow(&parent) else {
4727        return;
4728    };
4729    let Ok(name) = c_name(transaction.as_bytes(), transaction) else {
4730        return;
4731    };
4732    let _ = remove_tree_at(directory.as_raw_fd(), &name, &parent.display().to_string());
4733    let _ = directory.sync_all();
4734}
4735
4736#[cfg(windows)]
4737fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
4738    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
4739        return;
4740    }
4741    let parent = cfg.state_dir.join("downloads").join(brain);
4742    let Ok(root) = crate::fsx::open_directory_nofollow(&parent) else {
4743        return;
4744    };
4745    let _ = crate::fsx::remove_tree_beneath(&root, Path::new(transaction));
4746}
4747
4748#[cfg(not(any(unix, windows)))]
4749fn cleanup_v2_download_cache(_cfg: &HubConfig, _brain: &str, _transaction: &str) {}
4750
4751#[cfg(not(any(unix, windows)))]
4752fn v2_download_cache_dir_for(
4753    _cfg: &HubConfig,
4754    _brain: &str,
4755    _transaction: &str,
4756) -> LinkResult<PathBuf> {
4757    Err(LinkError::UnsupportedPlatform {
4758        operation: "resumable v2 download staging",
4759    })
4760}
4761
4762#[cfg(any(unix, windows))]
4763fn cached_blob_is_exact(path: &Path, sha256: &str, bytes: u64) -> LinkResult<bool> {
4764    let file = match crate::fsx::open_regular_nofollow(path) {
4765        Ok(file) => file,
4766        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
4767        Err(error) => return Err(error.into()),
4768    };
4769    if file.metadata()?.len() != bytes {
4770        return Ok(false);
4771    }
4772    Ok(content_sha256_reader(file)? == sha256)
4773}
4774
4775#[cfg(any(unix, windows))]
4776fn cache_v2_blob_bytes(
4777    cache_dir: &Path,
4778    sha256: &str,
4779    expected_bytes: u64,
4780    bytes: &[u8],
4781) -> LinkResult<PathBuf> {
4782    if bytes.len() as u64 != expected_bytes || content_sha256(bytes) != sha256 {
4783        return Err(invalid_feed("v2 cached blob differs from its declaration"));
4784    }
4785    let path = cache_dir.join(sha256);
4786    if !cached_blob_is_exact(&path, sha256, expected_bytes)? {
4787        crate::fsx::write_atomic(&path, bytes)?;
4788    }
4789    Ok(path)
4790}
4791
4792#[cfg(not(any(unix, windows)))]
4793fn cache_v2_blob_bytes(
4794    _cache_dir: &Path,
4795    _sha256: &str,
4796    _expected_bytes: u64,
4797    _bytes: &[u8],
4798) -> LinkResult<PathBuf> {
4799    Err(LinkError::UnsupportedPlatform {
4800        operation: "resumable v2 download staging",
4801    })
4802}
4803
4804#[cfg(unix)]
4805fn download_presigned_to_cache(
4806    cfg: &HubConfig,
4807    url: &str,
4808    cache_dir: &Path,
4809    sha256: &str,
4810    expected_bytes: u64,
4811) -> LinkResult<PathBuf> {
4812    use std::os::fd::{AsRawFd as _, FromRawFd as _};
4813
4814    let target = cache_dir.join(sha256);
4815    if cached_blob_is_exact(&target, sha256, expected_bytes)? {
4816        return Ok(target);
4817    }
4818    let directory = open_existing_dir_nofollow(cache_dir)?;
4819    let mut nonce = [0_u8; 16];
4820    ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut nonce)
4821        .map_err(|_| invalid_feed("could not mint a download cache name"))?;
4822    let temp_string = format!(".download-{}", URL_SAFE_NO_PAD.encode(nonce));
4823    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
4824    let fd = unsafe {
4825        libc::openat(
4826            directory.as_raw_fd(),
4827            temp.as_ptr(),
4828            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4829            0o600,
4830        )
4831    };
4832    if fd < 0 {
4833        return Err(std::io::Error::last_os_error().into());
4834    }
4835    let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
4836    let response = match presigned_agent(cfg, url)?.get(url).call() {
4837        Ok(response) => response,
4838        Err(ureq::Error::Status(_, response)) => {
4839            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4840            return Err(LinkError::Http {
4841                what: "v2 direct download",
4842                status: response.status(),
4843                message: "object store rejected the download".to_string(),
4844                code: None,
4845                details: None,
4846            });
4847        }
4848        Err(ureq::Error::Transport(error)) => {
4849            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4850            return Err(LinkError::Transport {
4851                hub: cfg.hub.clone(),
4852                message: error.to_string(),
4853            });
4854        }
4855    };
4856    let mut reader = response
4857        .into_reader()
4858        .take(expected_bytes.saturating_add(1));
4859    let mut digest = Sha256::new();
4860    let mut total = 0_u64;
4861    let mut buffer = [0_u8; 64 * 1024];
4862    let write_result = (|| -> std::io::Result<()> {
4863        loop {
4864            let read = reader.read(&mut buffer)?;
4865            if read == 0 {
4866                break;
4867            }
4868            total = total.saturating_add(read as u64);
4869            digest.update(&buffer[..read]);
4870            output.write_all(&buffer[..read])?;
4871        }
4872        output.sync_all()
4873    })();
4874    if let Err(error) = write_result {
4875        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4876        return Err(error.into());
4877    }
4878    drop(output);
4879    if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
4880        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4881        return Err(invalid_feed(
4882            "v2 direct download failed integrity verification",
4883        ));
4884    }
4885    let target_name = c_name(sha256.as_bytes(), sha256)?;
4886    // Replace only a previously inspected cache leaf. Cache bytes are private
4887    // and content-addressed; final install re-verifies them again.
4888    if unsafe {
4889        libc::renameat(
4890            directory.as_raw_fd(),
4891            temp.as_ptr(),
4892            directory.as_raw_fd(),
4893            target_name.as_ptr(),
4894        )
4895    } != 0
4896    {
4897        let error = std::io::Error::last_os_error();
4898        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4899        return Err(error.into());
4900    }
4901    directory.sync_all()?;
4902    Ok(target)
4903}
4904
4905#[cfg(windows)]
4906fn download_presigned_to_cache(
4907    cfg: &HubConfig,
4908    url: &str,
4909    cache_dir: &Path,
4910    sha256: &str,
4911    expected_bytes: u64,
4912) -> LinkResult<PathBuf> {
4913    use std::fs::OpenOptions;
4914
4915    let target = cache_dir.join(sha256);
4916    if cached_blob_is_exact(&target, sha256, expected_bytes)? {
4917        return Ok(target);
4918    }
4919    // Keep the checked directory handle alive while the private cache path is
4920    // used. Windows opens omit FILE_SHARE_DELETE, so neither this directory nor
4921    // an ancestor can be swapped after validation.
4922    let _directory = crate::fsx::open_directory_nofollow(cache_dir)?;
4923    let temp = cache_dir.join(format!(".download-{}", crate::ulid::mint()));
4924    let mut output = OpenOptions::new()
4925        .write(true)
4926        .create_new(true)
4927        .open(&temp)?;
4928    let response = match presigned_agent(cfg, url)?.get(url).call() {
4929        Ok(response) => response,
4930        Err(ureq::Error::Status(_, response)) => {
4931            let _ = std::fs::remove_file(&temp);
4932            return Err(LinkError::Http {
4933                what: "v2 direct download",
4934                status: response.status(),
4935                message: "object store rejected the download".to_string(),
4936                code: None,
4937                details: None,
4938            });
4939        }
4940        Err(ureq::Error::Transport(error)) => {
4941            let _ = std::fs::remove_file(&temp);
4942            return Err(LinkError::Transport {
4943                hub: cfg.hub.clone(),
4944                message: error.to_string(),
4945            });
4946        }
4947    };
4948    let mut reader = response
4949        .into_reader()
4950        .take(expected_bytes.saturating_add(1));
4951    let mut digest = Sha256::new();
4952    let mut total = 0_u64;
4953    let mut buffer = [0_u8; 64 * 1024];
4954    let copied = (|| -> std::io::Result<()> {
4955        loop {
4956            let read = reader.read(&mut buffer)?;
4957            if read == 0 {
4958                break;
4959            }
4960            total = total.saturating_add(read as u64);
4961            digest.update(&buffer[..read]);
4962            output.write_all(&buffer[..read])?;
4963        }
4964        output.sync_all()
4965    })();
4966    if let Err(error) = copied {
4967        let _ = std::fs::remove_file(&temp);
4968        return Err(error.into());
4969    }
4970    drop(output);
4971    if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
4972        let _ = std::fs::remove_file(&temp);
4973        return Err(invalid_feed(
4974            "v2 direct download failed integrity verification",
4975        ));
4976    }
4977    if target.exists() {
4978        std::fs::remove_file(&target)?;
4979    }
4980    if let Err(error) = std::fs::rename(&temp, &target) {
4981        let _ = std::fs::remove_file(&temp);
4982        return Err(error.into());
4983    }
4984    Ok(target)
4985}
4986
4987#[cfg(not(any(unix, windows)))]
4988fn download_presigned_to_cache(
4989    _cfg: &HubConfig,
4990    _url: &str,
4991    _cache_dir: &Path,
4992    _sha256: &str,
4993    _expected_bytes: u64,
4994) -> LinkResult<PathBuf> {
4995    Err(LinkError::UnsupportedPlatform {
4996        operation: "resumable v2 download staging",
4997    })
4998}
4999
5000fn download_v2_blobs(
5001    cfg: &HubConfig,
5002    brain: &str,
5003    pointer: &V2PointerBody,
5004    pending: Vec<(&String, &V2BaselineFile)>,
5005) -> LinkResult<Vec<(String, Vec<u8>)>> {
5006    if pending.is_empty() {
5007        return Ok(Vec::new());
5008    }
5009    let expected_order = pending
5010        .iter()
5011        .map(|(path, _)| (*path).clone())
5012        .collect::<Vec<_>>();
5013    let mut streamed = std::collections::BTreeMap::new();
5014    let mut direct = Vec::new();
5015    let mut window = Vec::new();
5016    let mut window_bytes = 0_u64;
5017    let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
5018                 window_bytes: &mut u64,
5019                 streamed: &mut std::collections::BTreeMap<String, Vec<u8>>|
5020     -> LinkResult<()> {
5021        if window.is_empty() {
5022            return Ok(());
5023        }
5024        for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, window)? {
5025            if streamed.insert(path, bytes).is_some() {
5026                return Err(invalid_feed("v2 bulk streams repeated a path"));
5027            }
5028        }
5029        window.clear();
5030        *window_bytes = 0;
5031        Ok(())
5032    };
5033    for &(path, file) in &pending {
5034        if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
5035            flush(&mut window, &mut window_bytes, &mut streamed)?;
5036            direct.push((path, file));
5037            continue;
5038        }
5039        if window.len() == V2_BULK_STREAM_FILES
5040            || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
5041        {
5042            flush(&mut window, &mut window_bytes, &mut streamed)?;
5043        }
5044        window.push((path, file));
5045        window_bytes += file.bytes;
5046    }
5047    flush(&mut window, &mut window_bytes, &mut streamed)?;
5048
5049    let downloads = prepare_v2_downloads(cfg, brain, pointer, &direct)?;
5050    let next = std::sync::atomic::AtomicUsize::new(0);
5051    let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
5052    let mut results = std::iter::repeat_with(|| None)
5053        .take(downloads.len())
5054        .collect::<Vec<Option<LinkResult<(String, Vec<u8>)>>>>();
5055    std::thread::scope(|scope| {
5056        let (sender, receiver) = std::sync::mpsc::channel();
5057        for _ in 0..worker_count {
5058            let sender = sender.clone();
5059            let downloads = &downloads;
5060            let next = &next;
5061            scope.spawn(move || loop {
5062                let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5063                let Some(item) = downloads.get(index) else {
5064                    break;
5065                };
5066                let result = download_v2_blob(cfg, item).map(|bytes| (item.path.clone(), bytes));
5067                if sender.send((index, result)).is_err() {
5068                    break;
5069                }
5070            });
5071        }
5072        drop(sender);
5073        for (index, result) in receiver {
5074            results[index] = Some(result);
5075        }
5076    });
5077    for result in results.into_iter().map(|result| {
5078        result.ok_or_else(|| LinkError::Transport {
5079            hub: cfg.hub.clone(),
5080            message: "a bounded v2 blob worker stopped before reporting its result".to_string(),
5081        })?
5082    }) {
5083        let (path, bytes) = result?;
5084        if streamed.insert(path, bytes).is_some() {
5085            return Err(invalid_feed("v2 download lanes repeated a path"));
5086        }
5087    }
5088    expected_order
5089        .into_iter()
5090        .map(|path| {
5091            streamed
5092                .remove(&path)
5093                .map(|bytes| (path, bytes))
5094                .ok_or_else(|| invalid_feed("v2 download lanes omitted a proven path"))
5095        })
5096        .collect()
5097}
5098
5099/// Resume an exact-head materialization into the private global cache. Each
5100/// verified frame is durable before the next network window begins, so a
5101/// retry skips it and memory never grows with total changed bytes.
5102#[cfg(any(unix, windows))]
5103fn stage_v2_blobs(
5104    cfg: &HubConfig,
5105    brain: &str,
5106    pointer: &V2PointerBody,
5107    pending: Vec<(&String, &V2BaselineFile)>,
5108) -> LinkResult<Vec<V2StagedFile>> {
5109    let cache_dir = v2_download_cache_dir(cfg, brain, pointer)?;
5110    let mut staged = std::collections::BTreeMap::<String, V2StagedFile>::new();
5111    let mut direct = Vec::new();
5112    let mut window = Vec::new();
5113    let mut window_bytes = 0_u64;
5114    let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
5115                 window_bytes: &mut u64,
5116                 staged: &mut std::collections::BTreeMap<String, V2StagedFile>|
5117     -> LinkResult<()> {
5118        if window.is_empty() {
5119            return Ok(());
5120        }
5121        let missing = window
5122            .iter()
5123            .filter_map(|(path, file)| {
5124                let target = cache_dir.join(&file.sha256);
5125                match cached_blob_is_exact(&target, &file.sha256, file.bytes) {
5126                    Ok(true) => {
5127                        staged.insert(
5128                            (*path).clone(),
5129                            V2StagedFile {
5130                                path: (*path).clone(),
5131                                source: target,
5132                                sha256: file.sha256.clone(),
5133                                bytes: file.bytes,
5134                            },
5135                        );
5136                        None
5137                    }
5138                    Ok(false) => Some(Ok((*path, *file))),
5139                    Err(error) => Some(Err(error)),
5140                }
5141            })
5142            .collect::<LinkResult<Vec<_>>>()?;
5143        if !missing.is_empty() {
5144            for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, &missing)? {
5145                let file = missing
5146                    .iter()
5147                    .find_map(|(expected_path, file)| (*expected_path == &path).then_some(*file))
5148                    .ok_or_else(|| invalid_feed("v2 stream returned an unrequested cache path"))?;
5149                let source = cache_v2_blob_bytes(&cache_dir, &file.sha256, file.bytes, &bytes)?;
5150                staged.insert(
5151                    path.clone(),
5152                    V2StagedFile {
5153                        path,
5154                        source,
5155                        sha256: file.sha256.clone(),
5156                        bytes: file.bytes,
5157                    },
5158                );
5159            }
5160        }
5161        window.clear();
5162        *window_bytes = 0;
5163        Ok(())
5164    };
5165    for &(path, file) in &pending {
5166        if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
5167            flush(&mut window, &mut window_bytes, &mut staged)?;
5168            direct.push((path, file));
5169            continue;
5170        }
5171        if window.len() == V2_BULK_STREAM_FILES
5172            || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
5173        {
5174            flush(&mut window, &mut window_bytes, &mut staged)?;
5175        }
5176        window.push((path, file));
5177        window_bytes += file.bytes;
5178    }
5179    flush(&mut window, &mut window_bytes, &mut staged)?;
5180    for item in prepare_v2_downloads(cfg, brain, pointer, &direct)? {
5181        let source =
5182            download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
5183        staged.insert(
5184            item.path.clone(),
5185            V2StagedFile {
5186                path: item.path,
5187                source,
5188                sha256: item.sha256,
5189                bytes: item.bytes,
5190            },
5191        );
5192    }
5193    pending
5194        .into_iter()
5195        .map(|(path, _)| {
5196            staged
5197                .remove(path)
5198                .ok_or_else(|| invalid_feed("v2 download cache omitted a proven path"))
5199        })
5200        .collect()
5201}
5202
5203#[cfg(not(any(unix, windows)))]
5204fn stage_v2_blobs(
5205    _cfg: &HubConfig,
5206    _brain: &str,
5207    _pointer: &V2PointerBody,
5208    _pending: Vec<(&String, &V2BaselineFile)>,
5209) -> LinkResult<Vec<V2StagedFile>> {
5210    Err(LinkError::UnsupportedPlatform {
5211        operation: "resumable v2 download staging",
5212    })
5213}
5214
5215const V2_CONFLICT_BUNDLE_MAX: usize = 32;
5216const V2_CONFLICT_BUNDLE_TTL_SECS: u64 = 7 * 24 * 60 * 60;
5217const V2_CONFLICT_REMOTE_BYTES_MAX: u64 = 64 * 1024 * 1024;
5218
5219#[derive(Debug, Clone, Deserialize, Serialize)]
5220struct V2ConflictCoordinate {
5221    sha256: Option<String>,
5222    bytes: Option<u64>,
5223    file: Option<String>,
5224}
5225
5226#[derive(Debug, Clone, Deserialize, Serialize)]
5227struct V2ConflictFile {
5228    path: String,
5229    base: V2ConflictCoordinate,
5230    local: V2ConflictCoordinate,
5231    remote: V2ConflictCoordinate,
5232}
5233
5234#[derive(Debug, Clone, Deserialize, Serialize)]
5235struct V2ConflictPlan {
5236    v: u8,
5237    class: String,
5238    bundle: String,
5239    brain: String,
5240    origin: String,
5241    created_unix: u64,
5242    expires_unix: u64,
5243    base_seq: Option<u64>,
5244    base_commit: Option<String>,
5245    remote_seq: u64,
5246    remote_commit: Option<String>,
5247    remote_content_root: Option<String>,
5248    view_kind: String,
5249    view_revision: String,
5250    files: Vec<V2ConflictFile>,
5251}
5252
5253fn v2_conflict_relative(bundle: &str, suffix: &str) -> PathBuf {
5254    PathBuf::from(".dbmd")
5255        .join("conflicts")
5256        .join(bundle)
5257        .join(suffix)
5258}
5259
5260fn read_historical_conflict_blob(
5261    cfg: &HubConfig,
5262    brain: &str,
5263    baseline: &V2SyncBaseline,
5264    path: &str,
5265    file: &V2BaselineFile,
5266) -> LinkResult<Option<Vec<u8>>> {
5267    let (Some(seq), Some(commit)) = (baseline.head_seq, baseline.commit_hash.as_deref()) else {
5268        return Ok(None);
5269    };
5270    if seq == 0 {
5271        return Ok(None);
5272    }
5273    let encoded_path: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
5274    let endpoint = format!(
5275        "/api/hub/brains/{brain}/v2/history/blob?seq={seq}&commit={commit}&path={encoded_path}&sha256={}",
5276        file.sha256
5277    );
5278    let raw = request_raw(cfg, "GET", &endpoint, None, Auth::Required, file.bytes)?;
5279    if raw.status == 404 || raw.status == 403 {
5280        return Ok(None);
5281    }
5282    let bytes = ensure_raw_ok(raw, "v2 conflict base")?;
5283    if bytes.len() as u64 != file.bytes || content_sha256(&bytes) != file.sha256 {
5284        return Err(invalid_feed(
5285            "v2 conflict base failed integrity verification",
5286        ));
5287    }
5288    Ok(Some(bytes))
5289}
5290
5291/// Persist a readable content conflict without modifying either side. The
5292/// plan is written last, so an interrupted bundle build is never actionable.
5293fn create_v2_conflict_bundle(
5294    cfg: &HubConfig,
5295    store: &Store,
5296    head: &V2VerifiedHead,
5297    baseline: Option<&V2SyncBaseline>,
5298    local: &std::collections::BTreeMap<String, (String, u64)>,
5299    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
5300    paths: &[String],
5301) -> LinkResult<(String, Vec<String>)> {
5302    let conflicts_root = Path::new(".dbmd/conflicts");
5303    store.create_dir_all(conflicts_root)?;
5304    let completed = store
5305        .directory_names(conflicts_root)?
5306        .into_iter()
5307        .filter(|name| name.to_str().is_some_and(crate::ulid::is_ulid))
5308        .count();
5309    if completed >= V2_CONFLICT_BUNDLE_MAX {
5310        return Err(LinkError::InvalidPack {
5311            message: format!(
5312                "private conflict cache has {completed} bundles; resolve or prune one before syncing"
5313            ),
5314        });
5315    }
5316
5317    // A conflict bundle is diagnostic state, not a second whole-brain cache.
5318    // Materialize a deterministic prefix whose remote artifacts fit one
5319    // bounded window. Resolving it exposes the next prefix on the next sync.
5320    let mut selected_paths = Vec::new();
5321    let mut selected_remote_bytes = 0_u64;
5322    for path in paths {
5323        let bytes = remote.get(path).map_or(0, |file| file.bytes);
5324        if !selected_paths.is_empty()
5325            && selected_remote_bytes.saturating_add(bytes) > V2_CONFLICT_REMOTE_BYTES_MAX
5326        {
5327            break;
5328        }
5329        selected_remote_bytes = selected_remote_bytes.saturating_add(bytes);
5330        selected_paths.push(path.clone());
5331        if selected_remote_bytes >= V2_CONFLICT_REMOTE_BYTES_MAX {
5332            break;
5333        }
5334    }
5335    if selected_paths.is_empty() {
5336        return Err(invalid_feed("content conflict set is empty"));
5337    }
5338    let bundle = crate::ulid::mint();
5339    let bundle_root = v2_conflict_relative(&bundle, "");
5340    store.create_dir_all(&bundle_root.join("files"))?;
5341    let pointer = head.pointer.as_ref();
5342    let remote_bytes = match pointer {
5343        Some(pointer) => download_v2_blobs(
5344            cfg,
5345            &head.brain_id,
5346            pointer,
5347            selected_paths
5348                .iter()
5349                .filter_map(|path| {
5350                    remote
5351                        .get(path)
5352                        .filter(|file| file.bytes <= V2_CONFLICT_REMOTE_BYTES_MAX)
5353                        .map(|file| (path, file))
5354                })
5355                .collect(),
5356        )?
5357        .into_iter()
5358        .collect::<std::collections::BTreeMap<_, _>>(),
5359        None => std::collections::BTreeMap::new(),
5360    };
5361
5362    let mut files = Vec::with_capacity(selected_paths.len());
5363    for (index, path) in selected_paths.iter().enumerate() {
5364        let base_file = baseline.and_then(|state| state.files.get(path));
5365        let base_bytes = match (baseline, base_file) {
5366            (Some(state), Some(file)) => {
5367                read_historical_conflict_blob(cfg, &head.brain_id, state, path, file)?
5368            }
5369            _ => None,
5370        };
5371        let local_file = local.get(path);
5372        let remote_file = remote.get(path);
5373        let remote_content = remote_bytes.get(path);
5374        let prefix = format!("files/{index:04}");
5375        let base_name = base_bytes.as_ref().map(|_| format!("{prefix}.base"));
5376        let local_name = local_file.as_ref().map(|_| format!("{prefix}.local"));
5377        let remote_name = remote_content.as_ref().map(|_| format!("{prefix}.remote"));
5378        if let (Some(name), Some(bytes)) = (&base_name, &base_bytes) {
5379            store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
5380        }
5381        if let (Some(name), Some((expected_hash, expected_bytes))) = (&local_name, local_file) {
5382            let bytes = store.read_bounded(Path::new(path), *expected_bytes)?;
5383            if bytes.len() as u64 != *expected_bytes || content_sha256(&bytes) != *expected_hash {
5384                return Err(LinkError::InvalidPack {
5385                    message: format!("local conflict path `{path}` changed while bundling"),
5386                });
5387            }
5388            store.write_atomic_new(&v2_conflict_relative(&bundle, name), &bytes)?;
5389        }
5390        if let (Some(name), Some(bytes)) = (&remote_name, remote_content) {
5391            store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
5392        }
5393        files.push(V2ConflictFile {
5394            path: path.clone(),
5395            base: V2ConflictCoordinate {
5396                sha256: base_file.map(|file| file.sha256.clone()),
5397                bytes: base_file.map(|file| file.bytes),
5398                file: base_name,
5399            },
5400            local: V2ConflictCoordinate {
5401                sha256: local_file.map(|(sha256, _)| sha256.clone()),
5402                bytes: local_file.map(|(_, bytes)| *bytes),
5403                file: local_name,
5404            },
5405            remote: V2ConflictCoordinate {
5406                sha256: remote_file.map(|file| file.sha256.clone()),
5407                bytes: remote_file.map(|file| file.bytes),
5408                file: remote_name,
5409            },
5410        });
5411    }
5412    let now = SystemTime::now()
5413        .duration_since(UNIX_EPOCH)
5414        .unwrap_or_default()
5415        .as_secs();
5416    let plan = V2ConflictPlan {
5417        v: 2,
5418        class: "content_resolution_required".to_string(),
5419        bundle: bundle.clone(),
5420        brain: head.brain_id.clone(),
5421        origin: normalized_origin(&cfg.hub)?,
5422        created_unix: now,
5423        expires_unix: now.saturating_add(V2_CONFLICT_BUNDLE_TTL_SECS),
5424        base_seq: baseline.and_then(|state| state.head_seq),
5425        base_commit: baseline.and_then(|state| state.commit_hash.clone()),
5426        remote_seq: pointer.map_or(0, |value| value.seq),
5427        remote_commit: pointer.map(|value| value.commit_hash.clone()),
5428        remote_content_root: pointer.and_then(|value| value.content_root.clone()),
5429        view_kind: head.view_kind.clone(),
5430        view_revision: head.view_revision.clone(),
5431        files,
5432    };
5433    let mut bytes = serde_json::to_vec_pretty(&plan)
5434        .map_err(|_| invalid_feed("could not serialize v2 conflict plan"))?;
5435    bytes.push(b'\n');
5436    store.write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), &bytes)?;
5437    Ok((bundle, selected_paths))
5438}
5439
5440fn v2_sync_pull(
5441    cfg: &HubConfig,
5442    requested_brain: &str,
5443    head: V2VerifiedHead,
5444    out: Option<&Path>,
5445) -> LinkResult<PullReport> {
5446    let dest = out
5447        .map(Path::to_path_buf)
5448        .unwrap_or_else(|| PathBuf::from(requested_brain));
5449    let _operation_lock = lock_v2_sync_operation(cfg, &head.brain_id)?;
5450    recover_windows_v2_pull(cfg, &head.brain_id, &dest)?;
5451    let head = v2_verified_head(cfg, requested_brain)?
5452        .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
5453    let remote = files_for_v2_view(
5454        &head,
5455        v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
5456    );
5457    let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
5458    let baseline = load_v2_baseline(cfg, &head.brain_id, &dest)?;
5459    ensure_v2_view_compatible(&head, baseline.as_ref())?;
5460    let local_store = Store::open_strict(&dest).ok();
5461    let mut local_view = local_store.as_ref().map(v2_local_files).transpose()?;
5462    if head.view_kind == "scoped" && baseline.is_none() && local_view.is_some() {
5463        return Err(LinkError::ScopedViewChanged);
5464    }
5465    if let Some(view) = local_view.as_mut() {
5466        remove_scoped_projection(&head, baseline.as_ref(), view)?;
5467    }
5468    let local = local_view
5469        .as_ref()
5470        .map(|view| &view.riding)
5471        .cloned()
5472        .unwrap_or_default();
5473    let kept_home = |path: &str| {
5474        local_view
5475            .as_ref()
5476            .is_some_and(|view| view.policy.keeps_home(path))
5477    };
5478    let base = baseline
5479        .as_ref()
5480        .map(|state| &state.files)
5481        .cloned()
5482        .unwrap_or_default();
5483    let base_assets = baseline
5484        .as_ref()
5485        .map(|state| state.assets.clone())
5486        .unwrap_or_default();
5487    let local_assets = local_store
5488        .as_ref()
5489        .map(|store| {
5490            crate::assets::read_manifest(store)
5491                .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))
5492        })
5493        .transpose()?
5494        .unwrap_or_default()
5495        .into_iter()
5496        .map(|asset| (asset.path.clone(), asset))
5497        .collect::<std::collections::BTreeMap<_, _>>();
5498    let all_paths = base
5499        .keys()
5500        .chain(remote.keys())
5501        .chain(local.keys())
5502        .cloned()
5503        .collect::<std::collections::BTreeSet<_>>();
5504    let mut conflicts = Vec::new();
5505    for path in &all_paths {
5506        if kept_home(path) {
5507            continue;
5508        }
5509        let base_hash = base.get(path).map(|file| file.sha256.as_str());
5510        let remote_hash = remote.get(path).map(|file| file.sha256.as_str());
5511        let local_hash = local.get(path).map(|file| file.0.as_str());
5512        if local_hash != base_hash && remote_hash != base_hash && local_hash != remote_hash {
5513            conflicts.push(path.clone());
5514        }
5515    }
5516    if !conflicts.is_empty() {
5517        conflicts.truncate(100);
5518        if let Some(store) = local_store.as_ref() {
5519            let (bundle, paths) = create_v2_conflict_bundle(
5520                cfg,
5521                store,
5522                &head,
5523                baseline.as_ref(),
5524                &local,
5525                &remote,
5526                &conflicts,
5527            )?;
5528            return Err(LinkError::ConflictBundle { bundle, paths });
5529        }
5530        return Err(LinkError::Conflict { paths: conflicts });
5531    }
5532    let asset_paths = base_assets
5533        .keys()
5534        .chain(remote_assets.keys())
5535        .chain(local_assets.keys())
5536        .cloned()
5537        .collect::<std::collections::BTreeSet<_>>();
5538    for path in &asset_paths {
5539        let base_record = base_assets
5540            .get(path)
5541            .map(|asset| v2_asset_record(asset, path));
5542        let remote_record = remote_assets
5543            .get(path)
5544            .map(|asset| v2_asset_record(asset, path));
5545        let local_record = local_assets.get(path).cloned();
5546        if local_record != base_record
5547            && remote_record != base_record
5548            && local_record != remote_record
5549        {
5550            conflicts.push(path.clone());
5551        }
5552    }
5553    if !conflicts.is_empty() {
5554        conflicts.truncate(100);
5555        return Err(LinkError::Conflict { paths: conflicts });
5556    }
5557    let pointer = head.pointer.as_ref();
5558    let cache_transaction = pointer.map_or_else(
5559        || content_sha256(format!("empty\0{}", head.view_revision).as_bytes()),
5560        |value| value.commit_hash.clone(),
5561    );
5562    let cache_dir = v2_download_cache_dir_for(cfg, &head.brain_id, &cache_transaction)?;
5563    let mut changed = match pointer {
5564        Some(pointer) => stage_v2_blobs(
5565            cfg,
5566            &head.brain_id,
5567            pointer,
5568            remote
5569                .iter()
5570                .filter(|(path, file)| {
5571                    !kept_home(path)
5572                        && local.get(*path).map(|value| value.0.as_str())
5573                            != Some(file.sha256.as_str())
5574                })
5575                .collect(),
5576        )?,
5577        None => Vec::new(),
5578    };
5579    let mut deleted = base
5580        .iter()
5581        .filter(|(path, file)| {
5582            !remote.contains_key(*path)
5583                && !kept_home(path)
5584                && local.get(*path).map(|value| value.0.as_str()) == Some(file.sha256.as_str())
5585        })
5586        .map(|(path, _)| path.clone())
5587        .collect::<Vec<_>>();
5588    if local_assets
5589        != remote_assets
5590            .iter()
5591            .map(|(path, asset)| (path.clone(), v2_asset_record(asset, path)))
5592            .collect()
5593    {
5594        if remote_assets.is_empty() {
5595            deleted.push("assets.jsonl".to_string());
5596        } else {
5597            let bytes = v2_asset_manifest_bytes(&remote_assets)?;
5598            let sha256 = content_sha256(&bytes);
5599            let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
5600            changed.push(V2StagedFile {
5601                path: "assets.jsonl".to_string(),
5602                source,
5603                sha256,
5604                bytes: bytes.len() as u64,
5605            });
5606        }
5607    }
5608    if let Some(pointer) = pointer {
5609        let mut pending_assets = Vec::new();
5610        for (path, asset) in &remote_assets {
5611            if asset.disposition != "hosted" || kept_home(path) {
5612                continue;
5613            }
5614            let already_current = local_store.as_ref().is_some_and(|store| {
5615                matches!(store.regular_file_exists(Path::new(path)), Ok(true))
5616                    && store
5617                        .read_bounded(Path::new(path), asset.bytes)
5618                        .ok()
5619                        .is_some_and(|bytes| {
5620                            bytes.len() as u64 == asset.bytes
5621                                && content_sha256(&bytes) == asset.blob_sha256
5622                        })
5623            });
5624            if !already_current {
5625                pending_assets.push((path, asset));
5626            }
5627        }
5628        for item in prepare_v2_asset_downloads(cfg, &head.brain_id, pointer, &pending_assets)? {
5629            let source =
5630                download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
5631            changed.push(V2StagedFile {
5632                path: item.path,
5633                source,
5634                sha256: item.sha256,
5635                bytes: item.bytes,
5636            });
5637        }
5638    }
5639    for (path, prior) in &base_assets {
5640        if remote_assets.contains_key(path) || kept_home(path) {
5641            continue;
5642        }
5643        let unchanged = local_store.as_ref().is_some_and(|store| {
5644            matches!(store.regular_file_exists(Path::new(path)), Ok(true))
5645                && store
5646                    .read_bounded(Path::new(path), prior.bytes)
5647                    .ok()
5648                    .is_some_and(|bytes| content_sha256(&bytes) == prior.blob_sha256)
5649        });
5650        if unchanged {
5651            deleted.push(path.clone());
5652        }
5653    }
5654    let extra_local = local
5655        .keys()
5656        .filter(|path| !remote.contains_key(*path) && !deleted.contains(path))
5657        .cloned()
5658        .collect::<Vec<_>>();
5659    if head.view_kind == "scoped" {
5660        for (path, bytes) in [
5661            ("DB.md".to_string(), scoped_projection_bytes(&head.brain_id)),
5662            (
5663                ".dbmd/view.json".to_string(),
5664                scoped_view_metadata(&head, remote.len())?,
5665            ),
5666        ] {
5667            let sha256 = content_sha256(&bytes);
5668            let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
5669            changed.push(V2StagedFile {
5670                path,
5671                source,
5672                sha256,
5673                bytes: bytes.len() as u64,
5674            });
5675        }
5676    }
5677    install_pulled_delta_sources(&dest, &changed, &deleted, true, baseline.as_ref(), &head)?;
5678    let finalized = (|| -> LinkResult<bool> {
5679        let installed_store =
5680            Store::open_strict(&dest).map_err(|error| LinkError::InvalidPack {
5681                message: format!("installed v2 checkout is not a valid db.md store: {error}"),
5682            })?;
5683        let mut installed_local = v2_local_files(&installed_store)?;
5684        remove_scoped_projection(&head, baseline.as_ref(), &mut installed_local)?;
5685        let mut expected_local = local.clone();
5686        for (path, file) in &remote {
5687            if !kept_home(path) {
5688                expected_local.insert(path.clone(), (file.sha256.clone(), file.bytes));
5689            }
5690        }
5691        for path in &deleted {
5692            expected_local.remove(path);
5693        }
5694        let local_dirty = installed_local.riding.iter().any(|(path, (hash, _))| {
5695            expected_local.get(path).map(|expected| &expected.0) != Some(hash)
5696        }) || expected_local.iter().any(|(path, (hash, _))| {
5697            installed_local.riding.get(path).map(|actual| &actual.0) != Some(hash)
5698        });
5699        let final_head = v2_verified_head(cfg, requested_brain)?
5700            .ok_or_else(|| invalid_feed("v2 head disappeared during pull"))?;
5701        if !same_v2_head(&head, &final_head) {
5702            return Err(LinkError::RemoteAdvancedDuringSync);
5703        }
5704        accept_v2_head(cfg, &final_head)?;
5705        save_v2_baseline(
5706            cfg,
5707            &head.brain_id,
5708            &dest,
5709            &v2_baseline_from_head(
5710                cfg,
5711                &head,
5712                remote.clone(),
5713                remote_assets.clone(),
5714                Some(&installed_local),
5715            )?,
5716        )?;
5717        complete_windows_v2_pull(&dest)?;
5718        Ok(local_dirty)
5719    })();
5720    let local_dirty = match finalized {
5721        Ok(value) => value,
5722        Err(error) => {
5723            if let Err(recovery) = recover_windows_v2_pull(cfg, &head.brain_id, &dest) {
5724                return Err(LinkError::InvalidPack {
5725                    message: format!("{error}; durable pull recovery also failed: {recovery}"),
5726                });
5727            }
5728            return Err(error);
5729        }
5730    };
5731    cleanup_v2_download_cache(cfg, &head.brain_id, &cache_transaction);
5732    Ok(PullReport {
5733        brain: head.brain_id,
5734        slug: requested_brain.to_string(),
5735        head_seq: pointer.map_or(0, |value| value.seq),
5736        files: remote.len() + remote_assets.len(),
5737        dest: dest.to_string_lossy().into_owned(),
5738        extra_local,
5739        sync_status: if local_dirty {
5740            "local_dirty_after_install".to_string()
5741        } else {
5742            "synced".to_string()
5743        },
5744    })
5745}
5746
5747fn v2_expected(remote: Option<&V2BaselineFile>) -> Value {
5748    match remote {
5749        Some(file) => json!({ "kind": "blob", "hash": file.sha256 }),
5750        None => json!({ "kind": "absent" }),
5751    }
5752}
5753
5754fn v2_asset_expected(remote: Option<&V2BaselineAsset>) -> Value {
5755    match remote {
5756        Some(asset) => json!({ "kind": "asset", "hash": asset.leaf_hash }),
5757        None => json!({ "kind": "absent" }),
5758    }
5759}
5760
5761fn v2_asset_value(record: &crate::AssetRecord, disposition: &str) -> Value {
5762    json!({
5763        "blob_sha256": record.sha256,
5764        "bytes": record.bytes,
5765        "media_type": record.media_type,
5766        "wrappers": record.wrappers,
5767        "required": record.required,
5768        "disposition": disposition,
5769    })
5770}
5771
5772fn v2_riding_matches_remote(
5773    local: &std::collections::BTreeMap<String, (String, u64)>,
5774    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
5775    keeps_home: impl Fn(&str) -> bool,
5776) -> bool {
5777    remote.iter().all(|(path, file)| {
5778        keeps_home(path)
5779            || local.get(path).map(|value| value.0.as_str()) == Some(file.sha256.as_str())
5780    }) && local.iter().all(|(path, (hash, _))| {
5781        remote.get(path).map(|file| file.sha256.as_str()) == Some(hash.as_str())
5782    })
5783}
5784
5785#[derive(Debug, Clone)]
5786struct V2ResolutionOverride {
5787    expected_remote: Option<String>,
5788    selected_local: Option<String>,
5789}
5790
5791#[derive(Debug, Clone)]
5792struct V2UploadSource {
5793    path: String,
5794    bytes: u64,
5795}
5796
5797fn verify_v2_upload_source(
5798    store: &Store,
5799    path: &str,
5800    sha256: &str,
5801    expected_bytes: u64,
5802) -> LinkResult<()> {
5803    let file = store.open_regular(Path::new(path))?;
5804    if file.metadata()?.len() != expected_bytes || content_sha256_reader(file)? != sha256 {
5805        return Err(LinkError::InvalidPack {
5806            message: format!("local path `{path}` changed during sync planning"),
5807        });
5808    }
5809    Ok(())
5810}
5811
5812fn put_presigned_source(
5813    cfg: &HubConfig,
5814    raw: &str,
5815    headers: &Value,
5816    store: &Store,
5817    source: &V2UploadSource,
5818) -> LinkResult<()> {
5819    let http = presigned_agent(cfg, raw)?;
5820    let mut attempt = 0;
5821    let result = loop {
5822        let file = store.open_regular(Path::new(&source.path))?;
5823        if file.metadata()?.len() != source.bytes {
5824            return Err(LinkError::InvalidPack {
5825                message: format!("local path `{}` changed before upload", source.path),
5826            });
5827        }
5828        let mut req = http
5829            .put(raw)
5830            .set("Content-Length", &source.bytes.to_string());
5831        if let Some(map) = headers.as_object() {
5832            for (name, value) in map {
5833                if let Some(value) = value.as_str() {
5834                    req = req.set(name, value);
5835                }
5836            }
5837        }
5838        match req.send(file) {
5839            Err(ureq::Error::Transport(error))
5840                if is_pre_request_transport(error.kind()) && attempt + 1 < CONNECT_ATTEMPTS =>
5841            {
5842                std::thread::sleep(std::time::Duration::from_millis(
5843                    CONNECT_RETRY_BACKOFF_MS[attempt],
5844                ));
5845                attempt += 1;
5846            }
5847            result => break result,
5848        }
5849    };
5850    match result {
5851        Ok(response) if (200..300).contains(&response.status()) => Ok(()),
5852        Ok(response) => Err(LinkError::Http {
5853            what: "v2 changed-byte upload",
5854            status: response.status(),
5855            message: "object store rejected the upload".to_string(),
5856            code: None,
5857            details: None,
5858        }),
5859        Err(error) => match error {
5860            ureq::Error::Status(412, _) => Ok(()),
5861            ureq::Error::Status(_, response) => Err(LinkError::Http {
5862                what: "v2 changed-byte upload",
5863                status: response.status(),
5864                message: "object store rejected the upload".to_string(),
5865                code: None,
5866                details: None,
5867            }),
5868            ureq::Error::Transport(error) => Err(LinkError::Transport {
5869                hub: "the object store".to_string(),
5870                message: error.to_string(),
5871            }),
5872        },
5873    }
5874}
5875
5876fn v2_sync_push(
5877    cfg: &HubConfig,
5878    requested_brain: &str,
5879    store: &Store,
5880    head: V2VerifiedHead,
5881    resume_local_policy: bool,
5882    bulk_confirmation: Option<&V2BulkConfirmation>,
5883    resolution: Option<&std::collections::BTreeMap<String, V2ResolutionOverride>>,
5884) -> LinkResult<Value> {
5885    let _operation_lock = lock_v2_sync_operation(cfg, &head.brain_id)?;
5886    let head = v2_verified_head(cfg, requested_brain)?
5887        .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
5888    let remote = files_for_v2_view(
5889        &head,
5890        v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
5891    );
5892    let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
5893    let baseline = load_v2_baseline(cfg, &head.brain_id, &store.root)?;
5894    ensure_v2_view_compatible(&head, baseline.as_ref())?;
5895    if head.view_kind == "scoped" && baseline.is_none() {
5896        return Err(LinkError::ScopedViewChanged);
5897    }
5898    let mut local_view = v2_local_files(store)?;
5899    remove_scoped_projection(&head, baseline.as_ref(), &mut local_view)?;
5900    let local = &local_view.riding;
5901    let local_assets = crate::assets::read_manifest(store)
5902        .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
5903        .into_iter()
5904        .map(|asset| (asset.path.clone(), asset))
5905        .collect::<std::collections::BTreeMap<_, _>>();
5906    if let Some(previous) = baseline.as_ref() {
5907        if previous.local_policy_digest.as_deref() != Some(&local_view.policy.digest)
5908            && !resume_local_policy
5909        {
5910            let mut newly_eligible = previous
5911                .local_eligibility
5912                .iter()
5913                .filter(|(path, riding)| !**riding && !local_view.policy.keeps_home(path))
5914                .map(|(path, _)| path.clone())
5915                .collect::<Vec<_>>();
5916            if !newly_eligible.is_empty() {
5917                newly_eligible.truncate(100);
5918                return Err(LinkError::LocalPolicyTransition {
5919                    paths: newly_eligible,
5920                });
5921            }
5922        }
5923    }
5924    let base = match baseline {
5925        Some(ref state) => state.files.clone(),
5926        None if remote.is_empty() => std::collections::BTreeMap::new(),
5927        None => {
5928            let mut conflicts = remote
5929                .iter()
5930                .filter(|(path, file)| {
5931                    local.get(*path).map(|value| value.0.as_str()) != Some(file.sha256.as_str())
5932                })
5933                .map(|(path, _)| path.clone())
5934                .collect::<Vec<_>>();
5935            if !conflicts.is_empty() {
5936                conflicts.truncate(100);
5937                let (bundle, paths) =
5938                    create_v2_conflict_bundle(cfg, store, &head, None, local, &remote, &conflicts)?;
5939                return Err(LinkError::ConflictBundle { bundle, paths });
5940            }
5941            remote.clone()
5942        }
5943    };
5944    let all_paths = base
5945        .keys()
5946        .chain(remote.keys())
5947        .chain(local.keys())
5948        .cloned()
5949        .collect::<std::collections::BTreeSet<_>>();
5950    let mut conflicts = Vec::new();
5951    let mut operations = Vec::new();
5952    let mut upload_sources = std::collections::BTreeMap::<String, V2UploadSource>::new();
5953    for path in all_paths {
5954        let base_hash = base.get(&path).map(|file| file.sha256.as_str());
5955        let remote_file = remote.get(&path);
5956        let remote_hash = remote_file.map(|file| file.sha256.as_str());
5957        let local_file = local.get(&path);
5958        let local_hash = local_file.map(|file| file.0.as_str());
5959        if local_hash == base_hash {
5960            continue;
5961        }
5962        if resolution.is_some_and(|allowed| !allowed.contains_key(&path)) {
5963            continue;
5964        }
5965        if local_view.policy.keeps_home(&path) {
5966            // Kept-home is a local transfer exclusion, never an implicit
5967            // delete of the company's already-hosted coordinate.
5968            continue;
5969        }
5970        if remote_hash != base_hash && local_hash != remote_hash {
5971            let explicitly_resolved = resolution
5972                .and_then(|allowed| allowed.get(&path))
5973                .is_some_and(|selected| {
5974                    selected.expected_remote.as_deref() == remote_hash
5975                        && selected.selected_local.as_deref() == local_hash
5976                });
5977            if !explicitly_resolved {
5978                conflicts.push(path);
5979                continue;
5980            }
5981        }
5982        match local_file {
5983            Some((sha256, byte_count)) => {
5984                verify_v2_upload_source(store, &path, sha256, *byte_count)?;
5985                operations.push(json!({
5986                    "op": "put",
5987                    "path": path,
5988                    "expected": v2_expected(remote_file),
5989                    "blob": sha256,
5990                    "bytes": byte_count,
5991                }));
5992                upload_sources
5993                    .entry(sha256.clone())
5994                    .or_insert_with(|| V2UploadSource {
5995                        path: path.clone(),
5996                        bytes: *byte_count,
5997                    });
5998            }
5999            None => {
6000                let Some(current) = remote_file else {
6001                    continue;
6002                };
6003                operations.push(json!({
6004                    "op": "delete",
6005                    "path": path,
6006                    "expected": { "kind": "blob", "hash": current.sha256 },
6007                }));
6008            }
6009        }
6010    }
6011    if !conflicts.is_empty() {
6012        conflicts.truncate(100);
6013        let (bundle, paths) = create_v2_conflict_bundle(
6014            cfg,
6015            store,
6016            &head,
6017            baseline.as_ref(),
6018            local,
6019            &remote,
6020            &conflicts,
6021        )?;
6022        return Err(LinkError::ConflictBundle { bundle, paths });
6023    }
6024    let base_assets = match baseline.as_ref() {
6025        Some(state) => state.assets.clone(),
6026        None if remote_assets.is_empty() => std::collections::BTreeMap::new(),
6027        None => {
6028            let mismatched = remote_assets.iter().any(|(path, remote)| {
6029                local_assets.get(path) != Some(&v2_asset_record(remote, path))
6030            }) || local_assets.len() != remote_assets.len();
6031            if mismatched {
6032                return Err(LinkError::Conflict {
6033                    paths: vec!["assets.jsonl".to_string()],
6034                });
6035            }
6036            remote_assets.clone()
6037        }
6038    };
6039    let asset_paths = base_assets
6040        .keys()
6041        .chain(remote_assets.keys())
6042        .chain(local_assets.keys())
6043        .cloned()
6044        .collect::<std::collections::BTreeSet<_>>();
6045    for path in asset_paths {
6046        let base_record = base_assets
6047            .get(&path)
6048            .map(|asset| v2_asset_record(asset, &path));
6049        let remote = remote_assets.get(&path);
6050        let remote_record = remote.map(|asset| v2_asset_record(asset, &path));
6051        let local_record = local_assets.get(&path);
6052        if local_record == base_record.as_ref() {
6053            continue;
6054        }
6055        if remote_record != base_record && local_record != remote_record.as_ref() {
6056            conflicts.push(path);
6057            continue;
6058        }
6059        let Some(record) = local_record else {
6060            if let Some(remote) = remote {
6061                operations.push(json!({
6062                    "op": "asset_delete",
6063                    "path": path,
6064                    "expected": v2_asset_expected(Some(remote)),
6065                }));
6066            }
6067            continue;
6068        };
6069        crate::linkmd_v2::normalize_path(&record.path)
6070            .map_err(|error| invalid_feed(error.to_string()))?;
6071        let kept_home = local_view.policy.keeps_home(&path);
6072        let raw = if matches!(store.regular_file_exists(Path::new(&path)), Ok(true)) {
6073            verify_v2_upload_source(store, &path, &record.sha256, record.bytes)?;
6074            Some(())
6075        } else {
6076            None
6077        };
6078        let disposition = if kept_home || raw.is_none() {
6079            "withheld"
6080        } else {
6081            "hosted"
6082        };
6083        if raw.is_none() && record.required && !kept_home {
6084            return Err(LinkError::InvalidPack {
6085                message: format!("required asset {path} is missing"),
6086            });
6087        }
6088        let op = if remote.is_some_and(|asset| {
6089            asset.disposition == "withheld"
6090                && disposition == "hosted"
6091                && v2_asset_record(asset, &path) == *record
6092        }) {
6093            if !resume_local_policy {
6094                continue;
6095            }
6096            "asset_resume"
6097        } else {
6098            "asset_put"
6099        };
6100        operations.push(json!({
6101            "op": op,
6102            "path": path,
6103            "expected": v2_asset_expected(remote),
6104            "asset": v2_asset_value(record, disposition),
6105        }));
6106        if disposition == "hosted" {
6107            raw.expect("hosted asset was checked present");
6108            upload_sources
6109                .entry(record.sha256.clone())
6110                .or_insert_with(|| V2UploadSource {
6111                    path: path.clone(),
6112                    bytes: record.bytes,
6113                });
6114        }
6115    }
6116    if !conflicts.is_empty() {
6117        conflicts.truncate(100);
6118        return Err(LinkError::Conflict { paths: conflicts });
6119    }
6120    if operations.is_empty() {
6121        let final_head = v2_verified_head(cfg, requested_brain)?
6122            .ok_or_else(|| invalid_feed("v2 head disappeared during sync"))?;
6123        if !same_v2_head(&head, &final_head) {
6124            return Err(LinkError::RemoteAdvancedDuringSync);
6125        }
6126        let mut final_local = v2_local_files(store)?;
6127        remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
6128        let local_changed = final_local.riding != local_view.riding;
6129        let remote_ahead = !v2_riding_matches_remote(&final_local.riding, &remote, |path| {
6130            final_local.policy.keeps_home(path)
6131        });
6132        let next = v2_baseline_from_head(cfg, &head, remote, remote_assets, Some(&final_local))?;
6133        let split_count = next.remote_copy_remains.len();
6134        accept_v2_head(cfg, &final_head)?;
6135        if !local_changed && !remote_ahead {
6136            refresh_scoped_view_marker(store, &head, next.files.len())?;
6137            save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
6138        }
6139        return Ok(json!({
6140            "v": 2,
6141            "outcome": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "no_change" },
6142            "sync_status": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "synced" },
6143            "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
6144            "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
6145            "local_policy": {
6146                "remote_copy_remains": split_count,
6147            },
6148        }));
6149    }
6150    let includes_contract = operations
6151        .iter()
6152        .any(|operation| operation.get("path").and_then(Value::as_str) == Some("DB.md"));
6153    let rebase = if head.pointer.is_none() || includes_contract {
6154        "strict"
6155    } else {
6156        "disjoint"
6157    };
6158    let base_value = head.pointer.as_ref().map(|pointer| {
6159        json!({
6160            "seq": pointer.seq,
6161            "commit_hash": pointer.commit_hash,
6162            "content_root": pointer.content_root,
6163            "asset_root": pointer.asset_root,
6164        })
6165    });
6166    // The mutation coordinate is stable for the exact observed base and local
6167    // operation set. A lost response can therefore be retried without a
6168    // duplicate commit/proposal even across process restarts.
6169    let entropy = format!(
6170        "{}\0{}\0{}\0{}",
6171        normalized_origin(&cfg.hub)?,
6172        head.brain_id,
6173        serde_json::to_string(&base_value).unwrap_or_default(),
6174        serde_json::to_string(&operations).unwrap_or_default()
6175    );
6176    let mutation_id = format!("dbmd-{}", content_sha256(entropy.as_bytes()));
6177    let mut expected_candidate = remote.clone();
6178    let mut expected_candidate_assets = remote_assets.clone();
6179    for operation in &operations {
6180        match operation.get("op").and_then(Value::as_str) {
6181            Some("put") => {
6182                let path = operation
6183                    .get("path")
6184                    .and_then(Value::as_str)
6185                    .ok_or_else(|| invalid_feed("v2 put has no path"))?;
6186                let sha256 = operation
6187                    .get("blob")
6188                    .and_then(Value::as_str)
6189                    .ok_or_else(|| invalid_feed("v2 put has no blob"))?;
6190                let bytes = operation
6191                    .get("bytes")
6192                    .and_then(Value::as_u64)
6193                    .ok_or_else(|| invalid_feed("v2 put has no byte count"))?;
6194                expected_candidate.insert(
6195                    path.to_string(),
6196                    V2BaselineFile {
6197                        sha256: sha256.to_string(),
6198                        bytes,
6199                        proof: None,
6200                    },
6201                );
6202            }
6203            Some("delete") => {
6204                let path = operation
6205                    .get("path")
6206                    .and_then(Value::as_str)
6207                    .ok_or_else(|| invalid_feed("v2 delete has no path"))?;
6208                expected_candidate.remove(path);
6209            }
6210            Some("asset_delete") => {
6211                let path = operation
6212                    .get("path")
6213                    .and_then(Value::as_str)
6214                    .ok_or_else(|| invalid_feed("v2 asset delete has no path"))?;
6215                expected_candidate_assets.remove(path);
6216            }
6217            Some("asset_put" | "asset_resume") => {
6218                let path = operation
6219                    .get("path")
6220                    .and_then(Value::as_str)
6221                    .ok_or_else(|| invalid_feed("v2 asset write has no path"))?;
6222                let record = local_assets
6223                    .get(path)
6224                    .ok_or_else(|| invalid_feed("v2 asset write has no local record"))?;
6225                let disposition = operation
6226                    .get("asset")
6227                    .and_then(|asset| asset.get("disposition"))
6228                    .and_then(Value::as_str)
6229                    .ok_or_else(|| invalid_feed("v2 asset write has no disposition"))?;
6230                expected_candidate_assets.insert(
6231                    path.to_string(),
6232                    V2BaselineAsset {
6233                        blob_sha256: record.sha256.clone(),
6234                        bytes: record.bytes,
6235                        media_type: record.media_type.clone(),
6236                        wrappers: record.wrappers.clone(),
6237                        required: record.required,
6238                        disposition: disposition.to_string(),
6239                        leaf_hash: String::new(),
6240                    },
6241                );
6242            }
6243            _ => return Err(invalid_feed("dbmd generated an unsupported v2 operation")),
6244        }
6245    }
6246    let changed_bytes = upload_sources.values().try_fold(0_u64, |total, source| {
6247        total
6248            .checked_add(source.bytes)
6249            .ok_or_else(|| LinkError::PushTooLarge {
6250                detail: "v2 changed-byte total overflow".to_string(),
6251            })
6252    })?;
6253    let inline = changed_bytes <= 3 * 1024 * 1024;
6254    let inline_blobs = if inline {
6255        upload_sources
6256            .iter()
6257            .map(|(sha256, source)| {
6258                let bytes = store.read_bounded(Path::new(&source.path), source.bytes)?;
6259                if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != *sha256 {
6260                    return Err(LinkError::InvalidPack {
6261                        message: format!("local path `{}` changed before upload", source.path),
6262                    });
6263                }
6264                Ok(json!({
6265                    "sha256": sha256,
6266                    "bytes": source.bytes,
6267                    "content_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
6268                }))
6269            })
6270            .collect::<LinkResult<Vec<_>>>()?
6271    } else {
6272        Vec::new()
6273    };
6274    let mut body = json!({
6275        "mutation_id": mutation_id,
6276        "base": base_value,
6277        "rebase": rebase,
6278        "reason": "dbmd sync",
6279        "operations": operations,
6280        "blobs": inline_blobs,
6281    });
6282    if let Some(confirmation) = bulk_confirmation {
6283        if !crate::ulid::is_ulid(&confirmation.id) || !is_sha256(&confirmation.digest) {
6284            return Err(LinkError::InvalidPack {
6285                message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
6286                    .to_string(),
6287            });
6288        }
6289        // A preview is bound to the exact observed head. Confirmation can
6290        // therefore never use disjoint rebase semantics, even when the normal
6291        // small-mutation path could.
6292        body["rebase"] = Value::String("strict".to_string());
6293        body["bulk_preview_id"] = Value::String(confirmation.id.clone());
6294        body["bulk_preview_digest"] = Value::String(confirmation.digest.clone());
6295    }
6296    if !inline || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
6297        let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
6298        for operation in &operations {
6299            let Some(kind) = operation.get("op").and_then(Value::as_str) else {
6300                return Err(invalid_feed("v2 upload operation has no kind"));
6301            };
6302            let hash = match kind {
6303                "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
6304                "asset_put" | "asset_resume" => operation
6305                    .get("asset")
6306                    .and_then(|asset| asset.get("blob_sha256"))
6307                    .and_then(Value::as_str),
6308                _ => None,
6309            };
6310            let Some(hash) = hash else { continue };
6311            let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
6312            if kind == "rename" {
6313                for field in ["from", "to"] {
6314                    coordinates.insert(
6315                        operation
6316                            .get(field)
6317                            .and_then(Value::as_str)
6318                            .ok_or_else(|| invalid_feed("v2 rename upload has no coordinate"))?
6319                            .to_string(),
6320                    );
6321                }
6322            } else {
6323                let path = operation
6324                    .get("path")
6325                    .and_then(Value::as_str)
6326                    .ok_or_else(|| invalid_feed("v2 upload has no coordinate"))?;
6327                coordinates.insert(if kind.starts_with("asset_") {
6328                    format!("assets/{path}")
6329                } else {
6330                    path.to_string()
6331                });
6332            }
6333        }
6334        let declarations = upload_sources
6335            .iter()
6336            .map(|(sha256, source)| {
6337                json!({
6338                    "sha256": sha256,
6339                    "bytes": source.bytes,
6340                    "coordinates": coordinates_by_hash
6341                        .get(sha256)
6342                        .into_iter()
6343                        .flatten()
6344                        .collect::<Vec<_>>(),
6345                })
6346            })
6347            .collect::<Vec<_>>();
6348        let reserved = ensure_ok(
6349            request(
6350                cfg,
6351                "POST",
6352                &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
6353                Some(&json!({ "blobs": declarations })),
6354                Auth::Required,
6355            )?,
6356            "prepare v2 changed-byte uploads",
6357        )?;
6358        let items = reserved
6359            .get("uploads")
6360            .and_then(Value::as_array)
6361            .ok_or_else(|| invalid_feed("v2 upload reservation response has no items"))?;
6362        if items.len() != upload_sources.len() {
6363            return Err(invalid_feed(
6364                "v2 upload reservation response changed the requested set",
6365            ));
6366        }
6367        let mut references = Vec::with_capacity(items.len());
6368        let mut seen = std::collections::BTreeSet::new();
6369        for item in items {
6370            let sha256 = item
6371                .get("sha256")
6372                .and_then(Value::as_str)
6373                .ok_or_else(|| invalid_feed("v2 upload reservation has no hash"))?;
6374            let source = upload_sources
6375                .get(sha256)
6376                .ok_or_else(|| invalid_feed("v2 upload reservation introduced a blob"))?;
6377            let declared_bytes = item
6378                .get("bytes")
6379                .and_then(Value::as_u64)
6380                .ok_or_else(|| invalid_feed("v2 upload reservation has no byte length"))?;
6381            let reservation_id = item
6382                .get("reservation_id")
6383                .and_then(Value::as_str)
6384                .ok_or_else(|| invalid_feed("v2 upload reservation has no opaque id"))?;
6385            let expected_coordinates = coordinates_by_hash
6386                .get(sha256)
6387                .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinate binding"))?;
6388            let returned_coordinates = item
6389                .get("coordinates")
6390                .and_then(Value::as_array)
6391                .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinates"))?;
6392            if declared_bytes != source.bytes
6393                || !crate::ulid::is_ulid(reservation_id)
6394                || !seen.insert(sha256.to_string())
6395                || returned_coordinates.len() != expected_coordinates.len()
6396                || returned_coordinates
6397                    .iter()
6398                    .zip(expected_coordinates)
6399                    .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
6400            {
6401                return Err(invalid_feed("v2 upload reservation item is inconsistent"));
6402            }
6403            match item.get("status").and_then(Value::as_str) {
6404                Some("upload") => {
6405                    let url = item
6406                        .get("url")
6407                        .and_then(Value::as_str)
6408                        .ok_or_else(|| invalid_feed("v2 upload reservation has no URL"))?;
6409                    put_presigned_source(
6410                        cfg,
6411                        url,
6412                        item.get("headers").unwrap_or(&Value::Null),
6413                        store,
6414                        source,
6415                    )?;
6416                    verify_v2_upload_source(store, &source.path, sha256, source.bytes)?;
6417                }
6418                Some("already_present") => {}
6419                _ => return Err(invalid_feed("v2 upload reservation has an unknown status")),
6420            }
6421            references.push(json!({
6422                "sha256": sha256,
6423                "bytes": source.bytes,
6424                "reservation_id": reservation_id,
6425            }));
6426        }
6427        body["blobs"] = Value::Array(references);
6428    }
6429    if body.to_string().len() > MAX_PUSH_BYTES {
6430        return Err(LinkError::PushTooLarge {
6431            detail: "v2 operation metadata exceeds the bounded commit request".to_string(),
6432        });
6433    }
6434    let path = format!("/api/hub/brains/{requested_brain}/v2/commits");
6435    let mut candidate_hub_signer: Option<String> = None;
6436    let mut response = request(cfg, "POST", &path, Some(&body), Auth::Required)?;
6437    let bulk_preview_required = !(200..300).contains(&response.status)
6438        && response.body.as_ref().is_some_and(|value| {
6439            value.get("code").and_then(Value::as_str) == Some("bulk_preview_required")
6440                || value
6441                    .get("details")
6442                    .and_then(|details| details.get("code"))
6443                    .and_then(Value::as_str)
6444                    == Some("bulk_preview_required")
6445        });
6446    if bulk_preview_required && bulk_confirmation.is_none() {
6447        body["rebase"] = Value::String("strict".to_string());
6448        body["preview_only"] = Value::Bool(true);
6449        let preview = ensure_ok(
6450            request(cfg, "POST", &path, Some(&body), Auth::Required)?,
6451            "v2 bulk preview",
6452        )?;
6453        let preview_code = preview.get("code").and_then(Value::as_str);
6454        let required = preview.get("required").and_then(Value::as_bool);
6455        if preview.get("v").and_then(Value::as_u64) != Some(2)
6456            || preview.get("mutation_id").and_then(Value::as_str) != Some(mutation_id.as_str())
6457            || !matches!(
6458                preview_code,
6459                Some("bulk_preview_created" | "bulk_preview_not_required")
6460            )
6461            || required.is_none()
6462        {
6463            return Err(invalid_feed(
6464                "bulk preview response is not bound to the requested mutation",
6465            ));
6466        }
6467        if required == Some(true) {
6468            let preview_id = preview.get("bulk_preview_id").and_then(Value::as_str);
6469            let preview_digest = preview.get("bulk_preview_digest").and_then(Value::as_str);
6470            if preview_code != Some("bulk_preview_created")
6471                || preview_id.is_none_or(|value| !crate::ulid::is_ulid(value))
6472                || preview_digest.is_none_or(|value| !is_sha256(value))
6473                || preview.get("expires_at").and_then(Value::as_str).is_none()
6474                || !preview.get("impact").is_some_and(Value::is_object)
6475            {
6476                return Err(invalid_feed("bulk preview receipt is malformed"));
6477            }
6478            return Err(LinkError::BulkPreviewRequired { preview });
6479        }
6480        if preview_code != Some("bulk_preview_not_required") {
6481            return Err(invalid_feed("bulk preview requirement is inconsistent"));
6482        }
6483        // The rolling activity window can expire between the refusal and
6484        // preview. Retry once, still pinned strictly to the observed head.
6485        body.as_object_mut()
6486            .expect("v2 commit request is an object")
6487            .remove("preview_only");
6488        response = request(cfg, "POST", &path, Some(&body), Auth::Required)?;
6489    }
6490    let mut result = ensure_ok(response, "v2 sync push")?;
6491    if result.get("code").and_then(Value::as_str) == Some("proposal_queued") {
6492        if let Some(object) = result.as_object_mut() {
6493            object.insert(
6494                "sync_status".to_string(),
6495                Value::String("proposal_pending".to_string()),
6496            );
6497        }
6498        return Ok(result);
6499    }
6500    if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
6501        let challenge = result
6502            .get("signing_challenge")
6503            .ok_or_else(|| invalid_feed("self-custody response has no signing challenge"))?;
6504        let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
6505            cfg,
6506            &head,
6507            &expected_candidate,
6508            &expected_candidate_assets,
6509            &mutation_id,
6510            &body,
6511            challenge,
6512        )?;
6513        body["signing_challenge_id"] = Value::String(challenge_id);
6514        body["signature_base64url"] = Value::String(signature);
6515        candidate_hub_signer = Some(actor_signer);
6516        result = ensure_ok(
6517            request(cfg, "POST", &path, Some(&body), Auth::Required)?,
6518            "v2 self-custody commit",
6519        )?;
6520    }
6521    let refreshed = v2_verified_head(cfg, requested_brain)?
6522        .ok_or_else(|| invalid_feed("v2 head disappeared after commit"))?;
6523    if candidate_hub_signer
6524        .as_ref()
6525        .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
6526    {
6527        return Err(invalid_feed(
6528            "self-custody actor signer differs from the committed hub pointer signer",
6529        ));
6530    }
6531    let accepted_hash = result.get("commit_hash").and_then(Value::as_str);
6532    if refreshed
6533        .pointer
6534        .as_ref()
6535        .map(|pointer| pointer.commit_hash.as_str())
6536        != accepted_hash
6537    {
6538        return Err(LinkError::RemoteAdvancedDuringSync);
6539    }
6540    ensure_v2_view_compatible(&refreshed, baseline.as_ref())?;
6541    let refreshed_files = files_for_v2_view(
6542        &refreshed,
6543        v2_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
6544    );
6545    let refreshed_assets = v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?;
6546    let mut final_local = v2_local_files(store)?;
6547    remove_scoped_projection(&refreshed, baseline.as_ref(), &mut final_local)?;
6548    let local_dirty = final_local.riding != local_view.riding
6549        || !v2_riding_matches_remote(&final_local.riding, &refreshed_files, |path| {
6550            final_local.policy.keeps_home(path)
6551        });
6552    let next = v2_baseline_from_head(
6553        cfg,
6554        &refreshed,
6555        refreshed_files,
6556        refreshed_assets,
6557        Some(&final_local),
6558    )?;
6559    let split_count = next.remote_copy_remains.len();
6560    accept_v2_head(cfg, &refreshed)?;
6561    if !local_dirty {
6562        refresh_scoped_view_marker(store, &refreshed, next.files.len())?;
6563        save_v2_baseline(cfg, &refreshed.brain_id, &store.root, &next)?;
6564    }
6565    if let Some(object) = result.as_object_mut() {
6566        object.insert(
6567            "local_policy".to_string(),
6568            json!({ "remote_copy_remains": split_count }),
6569        );
6570        object.insert(
6571            "sync_status".to_string(),
6572            Value::String(if local_dirty {
6573                "remote_committed_local_dirty".to_string()
6574            } else {
6575                "synced".to_string()
6576            }),
6577        );
6578    }
6579    Ok(result)
6580}
6581
6582/// Negotiate v2 and send only local changes. A v1 hub retains the existing
6583/// whole-snapshot behavior until its brain advertises the new profile.
6584pub fn sync_push_incremental(cfg: &HubConfig, brain: &str, store: &Store) -> LinkResult<Value> {
6585    sync_push_incremental_with_policy(cfg, brain, store, false)
6586}
6587
6588/// The explicit `.sevralocal` adoption form. Ordinary sync never uploads a
6589/// path that became eligible because local policy was weakened or removed.
6590pub fn sync_push_incremental_with_policy(
6591    cfg: &HubConfig,
6592    brain: &str,
6593    store: &Store,
6594    resume_local_policy: bool,
6595) -> LinkResult<Value> {
6596    sync_push_incremental_with_options(cfg, brain, store, resume_local_policy, None)
6597}
6598
6599/// Incremental push with both local-policy adoption and an optional exact
6600/// permissioned bulk-preview confirmation.
6601pub fn sync_push_incremental_with_options(
6602    cfg: &HubConfig,
6603    brain: &str,
6604    store: &Store,
6605    resume_local_policy: bool,
6606    bulk_confirmation: Option<&V2BulkConfirmation>,
6607) -> LinkResult<Value> {
6608    require_safe_ref(brain)?;
6609    if let Some(head) = v2_verified_head(cfg, brain)? {
6610        return v2_sync_push(
6611            cfg,
6612            brain,
6613            store,
6614            head,
6615            resume_local_policy,
6616            bulk_confirmation,
6617            None,
6618        );
6619    }
6620    legacy_sync_push_incremental(cfg, brain, store, resume_local_policy, bulk_confirmation)
6621}
6622
6623#[cfg(windows)]
6624fn legacy_sync_push_incremental(
6625    _cfg: &HubConfig,
6626    _brain: &str,
6627    _store: &Store,
6628    _resume_local_policy: bool,
6629    _bulk_confirmation: Option<&V2BulkConfirmation>,
6630) -> LinkResult<Value> {
6631    Err(LinkError::UnsupportedPlatform {
6632        operation: "legacy v1 whole-snapshot push on Windows; upgrade the brain to link.md v2",
6633    })
6634}
6635
6636#[cfg(not(windows))]
6637fn legacy_sync_push_incremental(
6638    cfg: &HubConfig,
6639    brain: &str,
6640    store: &Store,
6641    resume_local_policy: bool,
6642    bulk_confirmation: Option<&V2BulkConfirmation>,
6643) -> LinkResult<Value> {
6644    if resume_local_policy || bulk_confirmation.is_some() {
6645        return Err(LinkError::InvalidPack {
6646            message: "v2 sync options require a link.md v2 brain".to_string(),
6647        });
6648    }
6649    let files = collect_push_files(store)?;
6650    sync_push(cfg, brain, &files)
6651}
6652
6653/// One explicit resolution for a previously preserved private conflict bundle.
6654#[derive(Debug, Clone)]
6655pub enum V2ConflictChoice {
6656    KeepLocal,
6657    TakeRemote,
6658    From(PathBuf),
6659}
6660
6661fn load_v2_conflict_plan(store: &Store, bundle: &str) -> LinkResult<V2ConflictPlan> {
6662    if !crate::ulid::is_ulid(bundle) {
6663        return Err(LinkError::InvalidPack {
6664            message: "conflict bundle must be a lowercase ULID".to_string(),
6665        });
6666    }
6667    let bytes = store.read_bounded(&v2_conflict_relative(bundle, "plan.json"), 1024 * 1024)?;
6668    let plan: V2ConflictPlan = serde_json::from_slice(&bytes)
6669        .map_err(|_| invalid_feed("private conflict plan is corrupt"))?;
6670    if plan.v != 2
6671        || plan.class != "content_resolution_required"
6672        || plan.bundle != bundle
6673        || !crate::ulid::is_ulid(&plan.brain)
6674        || plan.files.is_empty()
6675        || plan.files.len() > 100
6676        || plan.files.iter().any(|file| {
6677            crate::linkmd_v2::normalize_path(&file.path).is_err()
6678                || [&file.base, &file.local, &file.remote]
6679                    .into_iter()
6680                    .any(|coordinate| {
6681                        coordinate
6682                            .sha256
6683                            .as_deref()
6684                            .is_some_and(|hash| !is_sha256(hash))
6685                            || coordinate.file.as_deref().is_some_and(|name| {
6686                                name.starts_with('/')
6687                                    || name
6688                                        .split('/')
6689                                        .any(|part| part.is_empty() || part == "." || part == "..")
6690                            })
6691                    })
6692        })
6693    {
6694        return Err(invalid_feed("private conflict plan failed validation"));
6695    }
6696    Ok(plan)
6697}
6698
6699/// Inspect or prune private conflict-control state. Ordinary pruning removes
6700/// only expired completed bundles and interrupted bundles with no `plan.json`;
6701/// corrupt completed plans fail closed. `all` is an explicit local discard of
6702/// every well-addressed bundle and never changes hosted brain data.
6703pub fn sync_conflicts(checkout: &Path, prune: bool, all: bool) -> LinkResult<Value> {
6704    require_hardened_filesystem("private conflict maintenance")?;
6705    if all && !prune {
6706        return Err(LinkError::InvalidPack {
6707            message: "discarding all conflict bundles requires prune=true".to_string(),
6708        });
6709    }
6710    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
6711        message: format!("conflict checkout is not a valid db.md store: {error}"),
6712    })?;
6713    let _transaction = store.transaction()?;
6714    let root = Path::new(".dbmd/conflicts");
6715    let names = match store.directory_names(root) {
6716        Ok(names) => names,
6717        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
6718        Err(error) => return Err(error.into()),
6719    };
6720    let now = SystemTime::now()
6721        .duration_since(UNIX_EPOCH)
6722        .unwrap_or_default()
6723        .as_secs();
6724    let mut bundles = Vec::new();
6725    let mut pruned = 0_u64;
6726    for name in names {
6727        let Some(bundle) = name.to_str().filter(|value| crate::ulid::is_ulid(value)) else {
6728            continue;
6729        };
6730        let plan_path = v2_conflict_relative(bundle, "plan.json");
6731        let plan_exists = store.regular_file_exists(&plan_path)?;
6732        let expired = if plan_exists {
6733            match load_v2_conflict_plan(&store, bundle) {
6734                Ok(plan) => plan.expires_unix < now,
6735                Err(error) if all => {
6736                    let _ = error;
6737                    true
6738                }
6739                Err(error) => return Err(error),
6740            }
6741        } else {
6742            true
6743        };
6744        if prune && (all || expired) {
6745            store.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
6746            pruned += 1;
6747            continue;
6748        }
6749        bundles.push(json!({
6750            "bundle": bundle,
6751            "complete": plan_exists,
6752            "expired": expired,
6753        }));
6754    }
6755    Ok(json!({
6756        "v": 2,
6757        "class": "private_conflict_state",
6758        "bundles": bundles.len(),
6759        "pruned": pruned,
6760        "items": bundles,
6761    }))
6762}
6763
6764/// Resolve one exact private conflict plan. Drift never lowers a precondition:
6765/// the remote head/view and every original local coordinate are rechecked
6766/// before either an explicit local install or a fresh normal commit.
6767pub fn sync_resolve_conflict(
6768    cfg: &HubConfig,
6769    checkout: &Path,
6770    bundle: &str,
6771    choice: V2ConflictChoice,
6772    bulk_confirmation: Option<&V2BulkConfirmation>,
6773) -> LinkResult<Value> {
6774    require_hardened_filesystem("conflict resolution")?;
6775    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
6776        message: format!("conflict checkout is not a valid db.md store: {error}"),
6777    })?;
6778    let plan = load_v2_conflict_plan(&store, bundle)?;
6779    if plan.origin != normalized_origin(&cfg.hub)? {
6780        return Err(invalid_feed(
6781            "conflict bundle belongs to another hub origin",
6782        ));
6783    }
6784    let now = SystemTime::now()
6785        .duration_since(UNIX_EPOCH)
6786        .unwrap_or_default()
6787        .as_secs();
6788    if now > plan.expires_unix {
6789        return Err(LinkError::InvalidPack {
6790            message: "conflict bundle expired; rerun sync to obtain current coordinates"
6791                .to_string(),
6792        });
6793    }
6794    let head = v2_verified_head(cfg, &plan.brain)?
6795        .ok_or_else(|| invalid_feed("conflict brain no longer advertises v2"))?;
6796    let pointer = head.pointer.as_ref();
6797    if pointer.map_or(0, |value| value.seq) != plan.remote_seq
6798        || pointer.map(|value| value.commit_hash.as_str()) != plan.remote_commit.as_deref()
6799        || pointer.and_then(|value| value.content_root.as_deref())
6800            != plan.remote_content_root.as_deref()
6801        || head.view_kind != plan.view_kind
6802        || head.view_revision != plan.view_revision
6803    {
6804        return Err(LinkError::RemoteAdvancedDuringSync);
6805    }
6806
6807    // A resolution never overwrites a post-bundle local edit.
6808    for file in &plan.files {
6809        let actual = match store.regular_file_exists(Path::new(&file.path))? {
6810            true => Some(content_sha256(&store.read_bounded(
6811                Path::new(&file.path),
6812                file.local.bytes.unwrap_or(MAX_STORE_BYTES),
6813            )?)),
6814            false => None,
6815        };
6816        if actual.as_deref() != file.local.sha256.as_deref() {
6817            return Err(LinkError::InvalidPack {
6818                message: format!(
6819                    "local conflict path `{}` changed after the bundle was created",
6820                    file.path
6821                ),
6822            });
6823        }
6824    }
6825
6826    let from_source = match &choice {
6827        V2ConflictChoice::From(source) => Some(source.clone()),
6828        _ => None,
6829    };
6830    let result = match choice {
6831        V2ConflictChoice::TakeRemote => {
6832            if bulk_confirmation.is_some() {
6833                return Err(LinkError::InvalidPack {
6834                    message: "bulk confirmation applies to keep-local/from commits, not a local take-remote install".to_string(),
6835                });
6836            }
6837            let mut remote_files = std::collections::BTreeMap::new();
6838            let mut deleted = Vec::new();
6839            for file in &plan.files {
6840                match (&file.remote.sha256, file.remote.bytes) {
6841                    (Some(sha256), Some(bytes)) => {
6842                        remote_files.insert(
6843                            file.path.clone(),
6844                            V2BaselineFile {
6845                                sha256: sha256.clone(),
6846                                bytes,
6847                                proof: None,
6848                            },
6849                        );
6850                    }
6851                    (None, None) => deleted.push(file.path.clone()),
6852                    _ => return Err(invalid_feed("conflict remote coordinate is incomplete")),
6853                }
6854            }
6855            let staged = match head.pointer.as_ref() {
6856                Some(pointer) => {
6857                    stage_v2_blobs(cfg, &plan.brain, pointer, remote_files.iter().collect())?
6858                }
6859                None if remote_files.is_empty() => Vec::new(),
6860                None => return Err(invalid_feed("conflict head has no content pointer")),
6861            };
6862            let baseline = load_v2_baseline(cfg, &plan.brain, checkout)?;
6863            install_pulled_delta_sources(
6864                checkout,
6865                &staged,
6866                &deleted,
6867                true,
6868                baseline.as_ref(),
6869                &head,
6870            )?;
6871            complete_windows_v2_pull(checkout)?;
6872            let refreshed = v2_verified_head(cfg, &plan.brain)?
6873                .ok_or_else(|| invalid_feed("conflict brain disappeared during resolution"))?;
6874            serde_json::to_value(v2_sync_pull(cfg, &plan.brain, refreshed, Some(checkout))?)
6875                .map_err(|_| invalid_feed("could not serialize conflict pull receipt"))?
6876        }
6877        V2ConflictChoice::KeepLocal | V2ConflictChoice::From(_) => {
6878            if let Some(source) = from_source.as_ref() {
6879                if plan.files.len() != 1 {
6880                    return Err(LinkError::InvalidPack {
6881                        message: "--from requires a bundle with exactly one conflict".to_string(),
6882                    });
6883                }
6884                let candidate = crate::fsx::read_bounded_nofollow(source, MAX_STORE_BYTES)?;
6885                if std::str::from_utf8(&candidate).is_err() {
6886                    return Err(LinkError::NotUtf8 {
6887                        path: source.display().to_string(),
6888                    });
6889                }
6890                store.write_atomic(Path::new(&plan.files[0].path), &candidate)?;
6891            }
6892            let refreshed_store =
6893                Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
6894                    message: format!("resolved checkout is not a valid db.md store: {error}"),
6895                })?;
6896            let mut overrides = std::collections::BTreeMap::new();
6897            for file in &plan.files {
6898                let selected_local = match refreshed_store
6899                    .regular_file_exists(Path::new(&file.path))?
6900                {
6901                    true => Some(content_sha256(
6902                        &refreshed_store.read_bounded(Path::new(&file.path), MAX_STORE_BYTES)?,
6903                    )),
6904                    false => None,
6905                };
6906                overrides.insert(
6907                    file.path.clone(),
6908                    V2ResolutionOverride {
6909                        expected_remote: file.remote.sha256.clone(),
6910                        selected_local,
6911                    },
6912                );
6913            }
6914            v2_sync_push(
6915                cfg,
6916                &plan.brain,
6917                &refreshed_store,
6918                head,
6919                true,
6920                bulk_confirmation,
6921                Some(&overrides),
6922            )?
6923        }
6924    };
6925
6926    if result.get("code").and_then(Value::as_str) != Some("proposal_queued") {
6927        let installed = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
6928            message: format!("resolved checkout is not a valid db.md store: {error}"),
6929        })?;
6930        installed.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
6931    }
6932    Ok(json!({
6933        "v": 2,
6934        "class": "auto_converged",
6935        "bundle": bundle,
6936        "receipt": result,
6937    }))
6938}
6939
6940/// Converge one established permissioned-v2 checkout in both directions.
6941///
6942/// The pull half first performs the ordinary three-way conflict check and
6943/// crash-safe install against the accepted baseline. The push half then opens
6944/// that exact installed checkout, takes its local transaction guard, computes
6945/// a fresh three-way delta, and submits it with mandatory preconditions. A
6946/// remote movement between halves is therefore a normal rebase/conflict input,
6947/// never a blind overwrite. Legacy v1 remains explicit pull-only/push-only so
6948/// its whole-pack replacement semantics are never presented as granular
6949/// convergence.
6950pub fn sync_converge(
6951    cfg: &HubConfig,
6952    brain: &str,
6953    checkout: &Path,
6954    resume_local_policy: bool,
6955) -> LinkResult<Value> {
6956    sync_converge_with_options(cfg, brain, checkout, resume_local_policy, None)
6957}
6958
6959/// Bidirectional convergence with optional exact bulk-preview confirmation.
6960pub fn sync_converge_with_options(
6961    cfg: &HubConfig,
6962    brain: &str,
6963    checkout: &Path,
6964    resume_local_policy: bool,
6965    bulk_confirmation: Option<&V2BulkConfirmation>,
6966) -> LinkResult<Value> {
6967    require_hardened_filesystem("bidirectional sync")?;
6968    require_safe_ref(brain)?;
6969    let head = v2_verified_head(cfg, brain)?.ok_or_else(|| LinkError::InvalidPack {
6970        message:
6971            "bidirectional sync requires link.md v2; use --pull-only or --push-only for a legacy brain"
6972                .to_string(),
6973    })?;
6974    let pulled = v2_sync_pull(cfg, brain, head, Some(checkout))?;
6975    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
6976        message: format!("installed v2 checkout is not a valid db.md store: {error}"),
6977    })?;
6978    let _transaction = store.transaction()?;
6979    let fresh = v2_verified_head(cfg, brain)?
6980        .ok_or_else(|| invalid_feed("v2 head disappeared between sync phases"))?;
6981    let mut result = v2_sync_push(
6982        cfg,
6983        brain,
6984        &store,
6985        fresh,
6986        resume_local_policy,
6987        bulk_confirmation,
6988        None,
6989    )?;
6990    if let Some(object) = result.as_object_mut() {
6991        object.insert("pulled_files".to_string(), json!(pulled.files));
6992        object.insert("checkout".to_string(), Value::String(pulled.dest));
6993        object.insert(
6994            "mode".to_string(),
6995            Value::String("bidirectional".to_string()),
6996        );
6997    }
6998    Ok(result)
6999}
7000
7001/// Pull the granted slice of `brain` to `out` (default: `./<slug>`). Every
7002/// exported path is safety-gated before it touches disk; files are written
7003/// atomically; nothing local is ever deleted (locals the export lacks are
7004/// *reported* in `extra_local` instead). Returns the report; rebuilding the
7005/// local index catalog afterwards is the caller's (cheap, optional) step.
7006pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
7007    require_hardened_filesystem("sync pull")?;
7008    require_safe_ref(brain)?;
7009    if let Some(head) = v2_verified_head(cfg, brain)? {
7010        return v2_sync_pull(cfg, brain, head, out);
7011    }
7012    legacy_sync_pull(cfg, brain, out)
7013}
7014
7015#[cfg(windows)]
7016fn legacy_sync_pull(_cfg: &HubConfig, _brain: &str, _out: Option<&Path>) -> LinkResult<PullReport> {
7017    Err(LinkError::UnsupportedPlatform {
7018        operation: "legacy v1 whole-snapshot pull on Windows; upgrade the brain to link.md v2",
7019    })
7020}
7021
7022#[cfg(not(windows))]
7023fn legacy_sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
7024    let remote = verified_remote_head(cfg, brain, false)?;
7025    if !remote.head.verified {
7026        return Err(invalid_feed(
7027            "the grant exposes head movement but no signed snapshot; refusing an unverifiable pull",
7028        ));
7029    }
7030    let snapshot_hash = remote.head.feed_hash.as_deref().unwrap_or("none");
7031    let path = format!(
7032        "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={snapshot_hash}",
7033        remote.head.seq
7034    );
7035    let body = ensure_ok(
7036        request(cfg, "GET", &path, None, Auth::Required)?,
7037        "sync pull",
7038    )?;
7039    if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
7040        || body.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
7041    {
7042        return Err(invalid_feed(
7043            "export response is not bound to the verified snapshot token",
7044        ));
7045    }
7046
7047    let remote_slug = body
7048        .get("slug")
7049        .and_then(Value::as_str)
7050        .filter(|slug| is_safe_slug(slug));
7051    let slug = remote_slug
7052        .or_else(|| is_safe_slug(brain).then_some(brain))
7053        .unwrap_or("brain")
7054        .to_string();
7055    let brain_id = body
7056        .get("brain")
7057        .and_then(Value::as_str)
7058        .unwrap_or(&remote.head.brain)
7059        .to_string();
7060    if brain_id != remote.head.brain {
7061        return Err(invalid_feed(
7062            "export response names a different brain than the verified head",
7063        ));
7064    }
7065    let head_seq = remote.head.seq;
7066    let dest: PathBuf = match out {
7067        Some(p) => p.to_path_buf(),
7068        None => PathBuf::from(&slug),
7069    };
7070    let entries = if head_seq == 0 {
7071        let files = body
7072            .get("files")
7073            .and_then(Value::as_array)
7074            .ok_or_else(|| invalid_feed("empty snapshot export did not carry an empty manifest"))?;
7075        if !files.is_empty() || body.get("url").is_some() {
7076            return Err(invalid_feed(
7077                "empty signed feed cannot authorize non-empty exported content",
7078            ));
7079        }
7080        Vec::new()
7081    } else {
7082        let signed_head = remote
7083            .head_entry
7084            .as_ref()
7085            .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
7086        let expected = &signed_head.entry.pack_sha256;
7087        if !is_sha256(expected) {
7088            return Err(invalid_feed(
7089                "signed head carries an invalid snapshot pack digest",
7090            ));
7091        }
7092        if let Some(url) = body.get("url").and_then(Value::as_str) {
7093            if body.get("sha256").and_then(Value::as_str) != Some(expected.as_str()) {
7094                return Err(invalid_feed(
7095                    "export pack digest does not match the signed head entry",
7096                ));
7097            }
7098            let bytes = get_presigned(cfg, url)?;
7099            let actual = format!("{:x}", Sha256::digest(&bytes));
7100            if actual != *expected {
7101                return Err(LinkError::InvalidPack {
7102                    message: "downloaded pack does not match the signed snapshot digest"
7103                        .to_string(),
7104                });
7105            }
7106            let entries = parse_store_pack(bytes)?;
7107            if signed_head.entry.kind == "push" {
7108                verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
7109            }
7110            entries
7111        } else {
7112            if signed_head.entry.kind != "push" {
7113                return Err(invalid_feed(
7114                    "delta snapshots must export the exact signed pack",
7115                ));
7116            }
7117            let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
7118                invalid_feed("verified snapshot export carried neither a pack nor files")
7119            })?;
7120            let mut entries = Vec::with_capacity(files.len());
7121            for file in files {
7122                let path = file
7123                    .get("path")
7124                    .and_then(Value::as_str)
7125                    .ok_or_else(|| invalid_feed("exported file path is not a string"))?;
7126                let content = file
7127                    .get("content")
7128                    .and_then(Value::as_str)
7129                    .ok_or_else(|| invalid_feed("exported inline content is not UTF-8 text"))?;
7130                entries.push((path.to_string(), content.as_bytes().to_vec()));
7131            }
7132            verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
7133            entries
7134        }
7135    };
7136
7137    // Gate the complete manifest before the first filesystem mutation.
7138    let mut seen = std::collections::HashSet::new();
7139    for (path, _) in &entries {
7140        if !safe_store_rel_path(path) {
7141            return Err(LinkError::UnsafePath { path: path.clone() });
7142        }
7143        if !seen.insert(path) {
7144            return Err(LinkError::InvalidPack {
7145                message: format!("duplicate path `{path}`"),
7146            });
7147        }
7148    }
7149    // Compute divergence against the still-live tree. The staged clone keeps
7150    // these extra locals byte-for-byte while overlaying the signed snapshot.
7151    let pulled: std::collections::BTreeSet<&str> =
7152        entries.iter().map(|(p, _)| p.as_str()).collect();
7153    let mut extra_local = Vec::new();
7154    if let Ok(store) = Store::open(&dest) {
7155        if let Ok(walked) = store.walk() {
7156            for rel in walked {
7157                let rel_str = rel.to_string_lossy().replace('\\', "/");
7158                if !pulled.contains(rel_str.as_str()) {
7159                    extra_local.push(rel_str);
7160                }
7161            }
7162        }
7163    }
7164    #[cfg(unix)]
7165    install_pulled_snapshot(&dest, &entries)?;
7166
7167    Ok(PullReport {
7168        brain: brain_id,
7169        slug,
7170        head_seq,
7171        files: entries.len(),
7172        dest: dest.to_string_lossy().into_owned(),
7173        extra_local,
7174        sync_status: "synced".to_string(),
7175    })
7176}
7177
7178#[cfg(unix)]
7179fn c_name(bytes: &[u8], display: &str) -> LinkResult<std::ffi::CString> {
7180    std::ffi::CString::new(bytes).map_err(|_| LinkError::UnsafePath {
7181        path: display.to_string(),
7182    })
7183}
7184
7185#[cfg(unix)]
7186fn open_dir_at(
7187    parent: std::os::fd::RawFd,
7188    name: &std::ffi::CStr,
7189    display: &str,
7190) -> LinkResult<std::fs::File> {
7191    use std::os::fd::FromRawFd as _;
7192    let fd = unsafe {
7193        libc::openat(
7194            parent,
7195            name.as_ptr(),
7196            libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
7197        )
7198    };
7199    if fd < 0 {
7200        return Err(LinkError::UnsafePath {
7201            path: display.to_string(),
7202        });
7203    }
7204    Ok(unsafe { std::fs::File::from_raw_fd(fd) })
7205}
7206
7207/// Open (and, where absent, create) a directory path without following a
7208/// symlink in any component. The returned directory capability remains bound
7209/// to the opened inode even if an attacker renames or replaces an ancestor.
7210#[cfg(unix)]
7211fn open_dir_path_nofollow(path: &Path, create: bool) -> LinkResult<std::fs::File> {
7212    use std::os::fd::AsRawFd as _;
7213
7214    // macOS exposes a few system-owned compatibility symlinks at the root.
7215    // Normalize only those fixed OS aliases; never canonicalize an arbitrary
7216    // caller-controlled ancestor.
7217    #[cfg(target_os = "macos")]
7218    let normalized = [("/var", "/private/var"), ("/tmp", "/private/tmp")]
7219        .into_iter()
7220        .find_map(|(alias, real)| {
7221            path.strip_prefix(alias)
7222                .ok()
7223                .map(|rest| Path::new(real).join(rest))
7224        })
7225        .unwrap_or_else(|| path.to_path_buf());
7226    #[cfg(not(target_os = "macos"))]
7227    let normalized = path.to_path_buf();
7228
7229    let start = if normalized.is_absolute() {
7230        std::fs::File::open("/")?
7231    } else {
7232        std::fs::File::open(".")?
7233    };
7234    let mut directory = start;
7235    for component in normalized.components() {
7236        use std::path::Component;
7237        let name = match component {
7238            Component::RootDir | Component::CurDir => continue,
7239            Component::Normal(name) => name,
7240            Component::ParentDir | Component::Prefix(_) => {
7241                return Err(LinkError::UnsafePath {
7242                    path: path.display().to_string(),
7243                });
7244            }
7245        };
7246        use std::os::unix::ffi::OsStrExt as _;
7247        let name = c_name(name.as_bytes(), &path.display().to_string())?;
7248        if create {
7249            let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
7250            if made != 0 {
7251                let error = std::io::Error::last_os_error();
7252                if error.raw_os_error() != Some(libc::EEXIST) {
7253                    return Err(error.into());
7254                }
7255            }
7256        }
7257        directory = open_dir_at(directory.as_raw_fd(), &name, &path.display().to_string())?;
7258    }
7259    Ok(directory)
7260}
7261
7262#[cfg(unix)]
7263fn open_or_create_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
7264    open_dir_path_nofollow(path, true)
7265}
7266
7267#[cfg(unix)]
7268fn open_existing_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
7269    open_dir_path_nofollow(path, false)
7270}
7271
7272#[cfg(unix)]
7273fn entry_is_dir_at(parent: std::os::fd::RawFd, name: &std::ffi::CStr) -> LinkResult<Option<bool>> {
7274    let mut stat: libc::stat = unsafe { std::mem::zeroed() };
7275    let result =
7276        unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) };
7277    if result == 0 {
7278        return Ok(Some((stat.st_mode & libc::S_IFMT) == libc::S_IFDIR));
7279    }
7280    let error = std::io::Error::last_os_error();
7281    if error.kind() == std::io::ErrorKind::NotFound {
7282        Ok(None)
7283    } else {
7284        Err(error.into())
7285    }
7286}
7287
7288#[cfg(unix)]
7289fn create_dir_exclusive_at(
7290    parent: std::os::fd::RawFd,
7291    name: &std::ffi::CStr,
7292    display: &str,
7293) -> LinkResult<std::fs::File> {
7294    let made = unsafe { libc::mkdirat(parent, name.as_ptr(), 0o700) };
7295    if made != 0 {
7296        return Err(LinkError::UnsafePath {
7297            path: display.to_string(),
7298        });
7299    }
7300    open_dir_at(parent, name, display)
7301}
7302
7303#[cfg(unix)]
7304fn directory_entry_names(directory: &std::fs::File) -> LinkResult<Vec<std::ffi::CString>> {
7305    use std::os::fd::AsRawFd as _;
7306
7307    let duplicate = unsafe { libc::dup(directory.as_raw_fd()) };
7308    if duplicate < 0 {
7309        return Err(std::io::Error::last_os_error().into());
7310    }
7311    let stream = unsafe { libc::fdopendir(duplicate) };
7312    if stream.is_null() {
7313        let error = std::io::Error::last_os_error();
7314        unsafe {
7315            libc::close(duplicate);
7316        }
7317        return Err(error.into());
7318    }
7319    let mut names = Vec::new();
7320    loop {
7321        let entry = unsafe { libc::readdir(stream) };
7322        if entry.is_null() {
7323            break;
7324        }
7325        let raw = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) };
7326        if raw.to_bytes() != b"." && raw.to_bytes() != b".." {
7327            names.push(raw.to_owned());
7328        }
7329    }
7330    if unsafe { libc::closedir(stream) } != 0 {
7331        return Err(std::io::Error::last_os_error().into());
7332    }
7333    Ok(names)
7334}
7335
7336/// Remove an entry tree relative to a held directory capability. Symlinks are
7337/// unlinked, never traversed, even when an old mirror contains hostile names.
7338#[cfg(unix)]
7339fn remove_tree_at(
7340    parent: std::os::fd::RawFd,
7341    name: &std::ffi::CStr,
7342    display: &str,
7343) -> LinkResult<()> {
7344    use std::os::fd::AsRawFd as _;
7345
7346    match entry_is_dir_at(parent, name)? {
7347        None => return Ok(()),
7348        Some(false) => {
7349            if unsafe { libc::unlinkat(parent, name.as_ptr(), 0) } != 0 {
7350                return Err(std::io::Error::last_os_error().into());
7351            }
7352        }
7353        Some(true) => {
7354            let directory = open_dir_at(parent, name, display)?;
7355            for child in directory_entry_names(&directory)? {
7356                let child_display =
7357                    format!("{display}/{}", String::from_utf8_lossy(child.to_bytes()));
7358                remove_tree_at(directory.as_raw_fd(), &child, &child_display)?;
7359            }
7360            drop(directory);
7361            if unsafe { libc::unlinkat(parent, name.as_ptr(), libc::AT_REMOVEDIR) } != 0 {
7362                return Err(std::io::Error::last_os_error().into());
7363            }
7364        }
7365    }
7366    Ok(())
7367}
7368
7369/// Clone a live destination into a private sibling stage without following
7370/// any symlink. Regular files are streamed between held descriptors; symlinks
7371/// are reproduced as links, never opened. Special files are refused.
7372#[cfg(unix)]
7373fn clone_tree_contents(
7374    source: &std::fs::File,
7375    destination: &std::fs::File,
7376    display: &str,
7377) -> LinkResult<()> {
7378    use std::os::fd::{AsRawFd as _, FromRawFd as _};
7379
7380    for name in directory_entry_names(source)? {
7381        let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
7382        let mut stat: libc::stat = unsafe { std::mem::zeroed() };
7383        if unsafe {
7384            libc::fstatat(
7385                source.as_raw_fd(),
7386                name.as_ptr(),
7387                &mut stat,
7388                libc::AT_SYMLINK_NOFOLLOW,
7389            )
7390        } != 0
7391        {
7392            return Err(std::io::Error::last_os_error().into());
7393        }
7394        match stat.st_mode & libc::S_IFMT {
7395            libc::S_IFDIR => {
7396                if unsafe {
7397                    libc::mkdirat(destination.as_raw_fd(), name.as_ptr(), stat.st_mode & 0o777)
7398                } != 0
7399                {
7400                    return Err(std::io::Error::last_os_error().into());
7401                }
7402                let source_child = open_dir_at(source.as_raw_fd(), &name, &child_display)?;
7403                let destination_child =
7404                    open_dir_at(destination.as_raw_fd(), &name, &child_display)?;
7405                clone_tree_contents(&source_child, &destination_child, &child_display)?;
7406                destination_child.sync_all()?;
7407            }
7408            libc::S_IFREG => {
7409                let source_fd = unsafe {
7410                    libc::openat(
7411                        source.as_raw_fd(),
7412                        name.as_ptr(),
7413                        libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
7414                    )
7415                };
7416                if source_fd < 0 {
7417                    return Err(std::io::Error::last_os_error().into());
7418                }
7419                let destination_fd = unsafe {
7420                    libc::openat(
7421                        destination.as_raw_fd(),
7422                        name.as_ptr(),
7423                        libc::O_WRONLY
7424                            | libc::O_CREAT
7425                            | libc::O_EXCL
7426                            | libc::O_CLOEXEC
7427                            | libc::O_NOFOLLOW,
7428                        (stat.st_mode & 0o777) as libc::c_uint,
7429                    )
7430                };
7431                if destination_fd < 0 {
7432                    unsafe {
7433                        libc::close(source_fd);
7434                    }
7435                    return Err(std::io::Error::last_os_error().into());
7436                }
7437                let mut input = unsafe { std::fs::File::from_raw_fd(source_fd) };
7438                let mut output = unsafe { std::fs::File::from_raw_fd(destination_fd) };
7439                std::io::copy(&mut input, &mut output)?;
7440                output.sync_all()?;
7441            }
7442            libc::S_IFLNK => {
7443                let mut target = vec![0_u8; 4097];
7444                let length = unsafe {
7445                    libc::readlinkat(
7446                        source.as_raw_fd(),
7447                        name.as_ptr(),
7448                        target.as_mut_ptr().cast(),
7449                        target.len(),
7450                    )
7451                };
7452                if length < 0 || length as usize >= target.len() {
7453                    return Err(LinkError::UnsafePath {
7454                        path: child_display,
7455                    });
7456                }
7457                target.truncate(length as usize);
7458                let target = c_name(&target, &child_display)?;
7459                if unsafe {
7460                    libc::symlinkat(target.as_ptr(), destination.as_raw_fd(), name.as_ptr())
7461                } != 0
7462                {
7463                    return Err(std::io::Error::last_os_error().into());
7464                }
7465            }
7466            _ => {
7467                return Err(LinkError::UnsafePath {
7468                    path: child_display,
7469                });
7470            }
7471        }
7472    }
7473    destination.sync_all()?;
7474    Ok(())
7475}
7476
7477#[cfg(target_os = "linux")]
7478fn install_stage_at(
7479    parent: std::os::fd::RawFd,
7480    stage: &std::ffi::CStr,
7481    dest: &std::ffi::CStr,
7482    dest_exists: bool,
7483) -> LinkResult<()> {
7484    let flags = if dest_exists {
7485        libc::RENAME_EXCHANGE
7486    } else {
7487        libc::RENAME_NOREPLACE
7488    };
7489    // `libc::renameat2` is not exported for the musl targets we ship. Invoke
7490    // the kernel ABI directly, as `fsx::renameat_noreplace` does, so the same
7491    // atomic exchange/no-replace boundary compiles for glibc and musl.
7492    let result = unsafe {
7493        libc::syscall(
7494            libc::SYS_renameat2,
7495            parent,
7496            stage.as_ptr(),
7497            parent,
7498            dest.as_ptr(),
7499            flags,
7500        )
7501    };
7502    if result == 0 {
7503        Ok(())
7504    } else {
7505        Err(std::io::Error::last_os_error().into())
7506    }
7507}
7508
7509#[cfg(target_os = "macos")]
7510fn install_stage_at(
7511    parent: std::os::fd::RawFd,
7512    stage: &std::ffi::CStr,
7513    dest: &std::ffi::CStr,
7514    dest_exists: bool,
7515) -> LinkResult<()> {
7516    let flags = if dest_exists {
7517        libc::RENAME_SWAP
7518    } else {
7519        libc::RENAME_EXCL
7520    };
7521    let result =
7522        unsafe { libc::renameatx_np(parent, stage.as_ptr(), parent, dest.as_ptr(), flags) };
7523    if result == 0 {
7524        Ok(())
7525    } else {
7526        Err(std::io::Error::last_os_error().into())
7527    }
7528}
7529
7530#[cfg(unix)]
7531fn write_pull_entries_beneath_dir(
7532    root: &std::fs::File,
7533    entries: &[(String, Vec<u8>)],
7534) -> LinkResult<()> {
7535    use std::os::fd::{AsRawFd as _, FromRawFd as _};
7536
7537    for (path, content) in entries {
7538        let components: Vec<&str> = path.split('/').collect();
7539        let (leaf, parents) = components
7540            .split_last()
7541            .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
7542        let mut directory = root.try_clone()?;
7543        for component in parents {
7544            let name = c_name(component.as_bytes(), path)?;
7545            let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
7546            if made != 0 {
7547                let error = std::io::Error::last_os_error();
7548                if error.raw_os_error() != Some(libc::EEXIST) {
7549                    return Err(error.into());
7550                }
7551            }
7552            directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
7553        }
7554
7555        let leaf_name = c_name(leaf.as_bytes(), path)?;
7556        let mut existing: libc::stat = unsafe { std::mem::zeroed() };
7557        let inspected = unsafe {
7558            libc::fstatat(
7559                directory.as_raw_fd(),
7560                leaf_name.as_ptr(),
7561                &mut existing,
7562                libc::AT_SYMLINK_NOFOLLOW,
7563            )
7564        };
7565        if inspected == 0 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK {
7566            return Err(LinkError::UnsafePath { path: path.clone() });
7567        }
7568
7569        let nonce = std::time::SystemTime::now()
7570            .duration_since(std::time::UNIX_EPOCH)
7571            .unwrap_or_default()
7572            .as_nanos();
7573        let temp_name = format!(
7574            ".dbmd-pull-{}-{nonce}-{}",
7575            std::process::id(),
7576            content_sha256(format!("{path}\0{}", content.len()).as_bytes())
7577        );
7578        let temp = c_name(temp_name.as_bytes(), path)?;
7579        let fd = unsafe {
7580            libc::openat(
7581                directory.as_raw_fd(),
7582                temp.as_ptr(),
7583                libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
7584                0o600,
7585            )
7586        };
7587        if fd < 0 {
7588            return Err(std::io::Error::last_os_error().into());
7589        }
7590        let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
7591        if let Err(error) = file.write_all(content).and_then(|_| file.sync_all()) {
7592            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
7593            return Err(error.into());
7594        }
7595        drop(file);
7596        let renamed = unsafe {
7597            libc::renameat(
7598                directory.as_raw_fd(),
7599                temp.as_ptr(),
7600                directory.as_raw_fd(),
7601                leaf_name.as_ptr(),
7602            )
7603        };
7604        if renamed != 0 {
7605            let error = std::io::Error::last_os_error();
7606            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
7607            return Err(error.into());
7608        }
7609        directory.sync_all()?;
7610    }
7611    root.sync_all()?;
7612    Ok(())
7613}
7614
7615#[cfg(unix)]
7616fn write_pull_sources_beneath_dir(
7617    root: &std::fs::File,
7618    entries: &[V2StagedFile],
7619) -> LinkResult<()> {
7620    use std::os::fd::{AsRawFd as _, FromRawFd as _};
7621
7622    for entry in entries {
7623        let path = &entry.path;
7624        let components: Vec<&str> = path.split('/').collect();
7625        let (leaf, parents) = components
7626            .split_last()
7627            .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
7628        let mut directory = root.try_clone()?;
7629        for component in parents {
7630            let name = c_name(component.as_bytes(), path)?;
7631            let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
7632            if made != 0 {
7633                let error = std::io::Error::last_os_error();
7634                if error.raw_os_error() != Some(libc::EEXIST) {
7635                    return Err(error.into());
7636                }
7637            }
7638            directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
7639        }
7640        let leaf_name = c_name(leaf.as_bytes(), path)?;
7641        let mut existing: libc::stat = unsafe { std::mem::zeroed() };
7642        if unsafe {
7643            libc::fstatat(
7644                directory.as_raw_fd(),
7645                leaf_name.as_ptr(),
7646                &mut existing,
7647                libc::AT_SYMLINK_NOFOLLOW,
7648            )
7649        } == 0
7650            && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK
7651        {
7652            return Err(LinkError::UnsafePath { path: path.clone() });
7653        }
7654        let nonce = SystemTime::now()
7655            .duration_since(UNIX_EPOCH)
7656            .unwrap_or_default()
7657            .as_nanos();
7658        let temp_name = format!(".dbmd-pull-{}-{nonce}", std::process::id());
7659        let temp = c_name(temp_name.as_bytes(), path)?;
7660        let fd = unsafe {
7661            libc::openat(
7662                directory.as_raw_fd(),
7663                temp.as_ptr(),
7664                libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
7665                0o600,
7666            )
7667        };
7668        if fd < 0 {
7669            return Err(std::io::Error::last_os_error().into());
7670        }
7671        let mut input = crate::fsx::open_regular_nofollow(&entry.source)?;
7672        let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
7673        let mut digest = Sha256::new();
7674        let mut total = 0_u64;
7675        let mut buffer = [0_u8; 64 * 1024];
7676        let copied = (|| -> std::io::Result<()> {
7677            loop {
7678                let read = input.read(&mut buffer)?;
7679                if read == 0 {
7680                    break;
7681                }
7682                total = total.saturating_add(read as u64);
7683                if total > entry.bytes {
7684                    return Err(std::io::Error::new(
7685                        std::io::ErrorKind::InvalidData,
7686                        "staged sync source grew beyond its verified length",
7687                    ));
7688                }
7689                digest.update(&buffer[..read]);
7690                output.write_all(&buffer[..read])?;
7691            }
7692            output.sync_all()
7693        })();
7694        if let Err(error) = copied {
7695            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
7696            return Err(error.into());
7697        }
7698        drop(output);
7699        if total != entry.bytes || format!("{:x}", digest.finalize()) != entry.sha256 {
7700            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
7701            return Err(invalid_feed(
7702                "private staged sync source failed final integrity verification",
7703            ));
7704        }
7705        if unsafe {
7706            libc::renameat(
7707                directory.as_raw_fd(),
7708                temp.as_ptr(),
7709                directory.as_raw_fd(),
7710                leaf_name.as_ptr(),
7711            )
7712        } != 0
7713        {
7714            let error = std::io::Error::last_os_error();
7715            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
7716            return Err(error.into());
7717        }
7718        directory.sync_all()?;
7719    }
7720    root.sync_all()?;
7721    Ok(())
7722}
7723
7724#[cfg(unix)]
7725fn remove_pull_paths_beneath_dir(root: &std::fs::File, paths: &[String]) -> LinkResult<()> {
7726    use std::os::fd::AsRawFd as _;
7727    for path in paths {
7728        if !safe_store_rel_path(path) {
7729            return Err(LinkError::UnsafePath { path: path.clone() });
7730        }
7731        let components = path.split('/').collect::<Vec<_>>();
7732        let Some((leaf, parents)) = components.split_last() else {
7733            return Err(LinkError::UnsafePath { path: path.clone() });
7734        };
7735        let mut directory = root.try_clone()?;
7736        let mut missing = false;
7737        for component in parents {
7738            let name = c_name(component.as_bytes(), path)?;
7739            match entry_is_dir_at(directory.as_raw_fd(), &name)? {
7740                None => {
7741                    missing = true;
7742                    break;
7743                }
7744                Some(false) => return Err(LinkError::UnsafePath { path: path.clone() }),
7745                Some(true) => {
7746                    directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
7747                }
7748            }
7749        }
7750        if missing {
7751            continue;
7752        }
7753        let leaf = c_name(leaf.as_bytes(), path)?;
7754        match entry_is_dir_at(directory.as_raw_fd(), &leaf)? {
7755            None => {}
7756            Some(true) => return Err(LinkError::UnsafePath { path: path.clone() }),
7757            Some(false) => {
7758                if unsafe { libc::unlinkat(directory.as_raw_fd(), leaf.as_ptr(), 0) } != 0 {
7759                    return Err(std::io::Error::last_os_error().into());
7760                }
7761                directory.sync_all()?;
7762            }
7763        }
7764    }
7765    Ok(())
7766}
7767
7768#[cfg(unix)]
7769fn install_pulled_delta(
7770    dest: &Path,
7771    entries: &[(String, Vec<u8>)],
7772    deleted: &[String],
7773    rebuild_indexes: bool,
7774) -> LinkResult<()> {
7775    use ring::rand::SecureRandom as _;
7776    use std::os::fd::AsRawFd as _;
7777    use std::os::unix::ffi::OsStrExt as _;
7778
7779    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
7780    let name = dest
7781        .file_name()
7782        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
7783        .ok_or_else(|| LinkError::UnsafePath {
7784            path: dest.display().to_string(),
7785        })?;
7786    let parent_dir = open_or_create_dir_nofollow(parent)?;
7787    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
7788    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
7789        None => false,
7790        Some(true) => true,
7791        Some(false) => {
7792            return Err(LinkError::UnsafePath {
7793                path: dest.display().to_string(),
7794            });
7795        }
7796    };
7797
7798    let mut nonce = [0_u8; 16];
7799    ring::rand::SystemRandom::new()
7800        .fill(&mut nonce)
7801        .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
7802    let stage_label = format!(
7803        ".{}.dbmd-pull-stage-{}",
7804        name.to_string_lossy(),
7805        URL_SAFE_NO_PAD.encode(nonce)
7806    );
7807    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
7808    let stage_dir = create_dir_exclusive_at(
7809        parent_dir.as_raw_fd(),
7810        &stage_name,
7811        &dest.display().to_string(),
7812    )?;
7813
7814    let prepared = (|| -> LinkResult<()> {
7815        if dest_exists {
7816            let live = open_dir_at(
7817                parent_dir.as_raw_fd(),
7818                &dest_name,
7819                &dest.display().to_string(),
7820            )?;
7821            clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
7822        }
7823        remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
7824        write_pull_entries_beneath_dir(&stage_dir, entries)?;
7825        if rebuild_indexes {
7826            let stage_store =
7827                Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
7828                    .map_err(|error| LinkError::InvalidPack {
7829                        message: format!("v2 staging tree is not a valid db.md store: {error}"),
7830                    })?;
7831            crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
7832                LinkError::InvalidPack {
7833                    message: format!("could not materialize v2 local catalogs: {error}"),
7834                }
7835            })?;
7836        }
7837        stage_dir.sync_all()?;
7838        Ok(())
7839    })();
7840    if let Err(error) = prepared {
7841        let _ = remove_tree_at(
7842            parent_dir.as_raw_fd(),
7843            &stage_name,
7844            &dest.display().to_string(),
7845        );
7846        return Err(error);
7847    }
7848
7849    if let Err(error) =
7850        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
7851    {
7852        let _ = remove_tree_at(
7853            parent_dir.as_raw_fd(),
7854            &stage_name,
7855            &dest.display().to_string(),
7856        );
7857        return Err(error);
7858    }
7859    parent_dir.sync_all()?;
7860    if dest_exists {
7861        // Commit already happened atomically. Cleanup is best-effort so a
7862        // failure cannot be reported as a failed pull after the live tree
7863        // changed; a crash may leave only this private old-tree sibling.
7864        let _ = remove_tree_at(
7865            parent_dir.as_raw_fd(),
7866            &stage_name,
7867            &dest.display().to_string(),
7868        );
7869        let _ = parent_dir.sync_all();
7870    }
7871    Ok(())
7872}
7873
7874#[cfg(unix)]
7875fn install_pulled_delta_sources(
7876    dest: &Path,
7877    entries: &[V2StagedFile],
7878    deleted: &[String],
7879    rebuild_indexes: bool,
7880    _previous: Option<&V2SyncBaseline>,
7881    _next: &V2VerifiedHead,
7882) -> LinkResult<()> {
7883    use ring::rand::SecureRandom as _;
7884    use std::os::fd::AsRawFd as _;
7885    use std::os::unix::ffi::OsStrExt as _;
7886
7887    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
7888    let name = dest
7889        .file_name()
7890        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
7891        .ok_or_else(|| LinkError::UnsafePath {
7892            path: dest.display().to_string(),
7893        })?;
7894    let parent_dir = open_or_create_dir_nofollow(parent)?;
7895    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
7896    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
7897        None => false,
7898        Some(true) => true,
7899        Some(false) => {
7900            return Err(LinkError::UnsafePath {
7901                path: dest.display().to_string(),
7902            })
7903        }
7904    };
7905    let mut nonce = [0_u8; 16];
7906    ring::rand::SystemRandom::new()
7907        .fill(&mut nonce)
7908        .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
7909    let stage_label = format!(
7910        ".{}.dbmd-pull-stage-{}",
7911        name.to_string_lossy(),
7912        URL_SAFE_NO_PAD.encode(nonce)
7913    );
7914    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
7915    let stage_dir = create_dir_exclusive_at(
7916        parent_dir.as_raw_fd(),
7917        &stage_name,
7918        &dest.display().to_string(),
7919    )?;
7920    let prepared = (|| -> LinkResult<()> {
7921        if dest_exists {
7922            let live = open_dir_at(
7923                parent_dir.as_raw_fd(),
7924                &dest_name,
7925                &dest.display().to_string(),
7926            )?;
7927            clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
7928        }
7929        remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
7930        write_pull_sources_beneath_dir(&stage_dir, entries)?;
7931        if rebuild_indexes {
7932            let stage_store =
7933                Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
7934                    .map_err(|error| LinkError::InvalidPack {
7935                        message: format!("v2 staging tree is not a valid db.md store: {error}"),
7936                    })?;
7937            crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
7938                LinkError::InvalidPack {
7939                    message: format!("could not materialize v2 local catalogs: {error}"),
7940                }
7941            })?;
7942        }
7943        stage_dir.sync_all()?;
7944        Ok(())
7945    })();
7946    if let Err(error) = prepared {
7947        let _ = remove_tree_at(
7948            parent_dir.as_raw_fd(),
7949            &stage_name,
7950            &dest.display().to_string(),
7951        );
7952        return Err(error);
7953    }
7954    if let Err(error) =
7955        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
7956    {
7957        let _ = remove_tree_at(
7958            parent_dir.as_raw_fd(),
7959            &stage_name,
7960            &dest.display().to_string(),
7961        );
7962        return Err(error);
7963    }
7964    parent_dir.sync_all()?;
7965    if dest_exists {
7966        let _ = remove_tree_at(
7967            parent_dir.as_raw_fd(),
7968            &stage_name,
7969            &dest.display().to_string(),
7970        );
7971        let _ = parent_dir.sync_all();
7972    }
7973    Ok(())
7974}
7975
7976#[cfg(windows)]
7977#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
7978struct WindowsPullCoordinate {
7979    head_seq: Option<u64>,
7980    commit_hash: Option<String>,
7981    view_kind: Option<String>,
7982    view_revision: Option<String>,
7983}
7984
7985#[cfg(windows)]
7986#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
7987struct WindowsPullFileCoordinate {
7988    sha256: String,
7989    bytes: u64,
7990}
7991
7992#[cfg(windows)]
7993#[derive(Debug, Clone, Deserialize, Serialize)]
7994struct WindowsPullJournalEntry {
7995    path: String,
7996    old: Option<WindowsPullFileCoordinate>,
7997    new: Option<WindowsPullFileCoordinate>,
7998    backup: Option<String>,
7999}
8000
8001#[cfg(windows)]
8002#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
8003#[serde(rename_all = "snake_case")]
8004enum WindowsPullPhase {
8005    Preparing,
8006    Ready,
8007}
8008
8009#[cfg(windows)]
8010#[derive(Debug, Clone, Deserialize, Serialize)]
8011struct WindowsPullJournal {
8012    v: u8,
8013    phase: WindowsPullPhase,
8014    brain: String,
8015    previous: WindowsPullCoordinate,
8016    next: WindowsPullCoordinate,
8017    backup_dir: String,
8018    entries: Vec<WindowsPullJournalEntry>,
8019}
8020
8021#[cfg(windows)]
8022const WINDOWS_PULL_JOURNAL: &str = ".dbmd/pull-journal.json";
8023
8024#[cfg(windows)]
8025fn windows_baseline_coordinate(baseline: Option<&V2SyncBaseline>) -> WindowsPullCoordinate {
8026    WindowsPullCoordinate {
8027        head_seq: baseline.and_then(|value| value.head_seq),
8028        commit_hash: baseline.and_then(|value| value.commit_hash.clone()),
8029        view_kind: baseline.and_then(|value| value.view_kind.clone()),
8030        view_revision: baseline.and_then(|value| value.view_revision.clone()),
8031    }
8032}
8033
8034#[cfg(windows)]
8035fn windows_head_coordinate(head: &V2VerifiedHead) -> WindowsPullCoordinate {
8036    WindowsPullCoordinate {
8037        head_seq: head.pointer.as_ref().map(|value| value.seq),
8038        commit_hash: head.pointer.as_ref().map(|value| value.commit_hash.clone()),
8039        view_kind: Some(head.view_kind.clone()),
8040        view_revision: Some(head.view_revision.clone()),
8041    }
8042}
8043
8044#[cfg(windows)]
8045fn windows_pull_state(
8046    store: &Store,
8047    path: &str,
8048    limit: u64,
8049) -> LinkResult<Option<WindowsPullFileCoordinate>> {
8050    let file = match store.open_regular(Path::new(path)) {
8051        Ok(file) => file,
8052        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
8053        Err(error) => return Err(error.into()),
8054    };
8055    let bytes = file.metadata()?.len();
8056    if bytes > limit || bytes > MAX_STORE_BYTES {
8057        return Err(invalid_feed(
8058            "pull transaction file exceeds its declared bound",
8059        ));
8060    }
8061    Ok(Some(WindowsPullFileCoordinate {
8062        sha256: content_sha256_reader(file)?,
8063        bytes,
8064    }))
8065}
8066
8067#[cfg(windows)]
8068fn windows_pull_journal_bytes(journal: &WindowsPullJournal) -> LinkResult<Vec<u8>> {
8069    let mut bytes = serde_json::to_vec_pretty(journal)
8070        .map_err(|_| invalid_feed("could not serialize Windows pull journal"))?;
8071    bytes.push(b'\n');
8072    Ok(bytes)
8073}
8074
8075#[cfg(windows)]
8076fn validate_windows_pull_journal(journal: &WindowsPullJournal) -> LinkResult<()> {
8077    let backup_prefix = ".dbmd/pull-backup-";
8078    let suffix = journal
8079        .backup_dir
8080        .strip_prefix(backup_prefix)
8081        .ok_or_else(|| invalid_feed("Windows pull journal backup address is invalid"))?;
8082    let mut paths = std::collections::BTreeSet::new();
8083    if journal.v != 1
8084        || !crate::ulid::is_ulid(&journal.brain)
8085        || !crate::ulid::is_ulid(suffix)
8086        || journal.entries.is_empty()
8087        || journal.entries.len() > MAX_PUSH_FILES + 4
8088        || journal.previous == journal.next
8089    {
8090        return Err(invalid_feed("Windows pull journal failed validation"));
8091    }
8092    for (index, entry) in journal.entries.iter().enumerate() {
8093        if !safe_store_rel_path(&entry.path)
8094            || entry.path == WINDOWS_PULL_JOURNAL
8095            || entry.path.starts_with(backup_prefix)
8096            || !paths.insert(entry.path.clone())
8097            || (entry.old.is_none() && entry.new.is_none())
8098            || entry
8099                .old
8100                .iter()
8101                .chain(entry.new.iter())
8102                .any(|value| !is_sha256(&value.sha256) || value.bytes > MAX_STORE_BYTES)
8103            || entry.backup.as_deref()
8104                != entry
8105                    .old
8106                    .as_ref()
8107                    .map(|_| format!("{index:08x}"))
8108                    .as_deref()
8109        {
8110            return Err(invalid_feed("Windows pull journal entry failed validation"));
8111        }
8112    }
8113    Ok(())
8114}
8115
8116#[cfg(windows)]
8117fn load_windows_pull_journal(store: &Store) -> LinkResult<Option<WindowsPullJournal>> {
8118    let bytes = match store.read_bounded(Path::new(WINDOWS_PULL_JOURNAL), 64 * 1024 * 1024) {
8119        Ok(bytes) => bytes,
8120        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
8121        Err(error) => return Err(error.into()),
8122    };
8123    let journal: WindowsPullJournal = serde_json::from_slice(&bytes)
8124        .map_err(|_| invalid_feed("Windows pull journal is corrupt"))?;
8125    validate_windows_pull_journal(&journal)?;
8126    Ok(Some(journal))
8127}
8128
8129#[cfg(windows)]
8130fn cleanup_windows_pull_journal(store: &Store, journal: &WindowsPullJournal) -> LinkResult<()> {
8131    // The journal is the only recovery authority. Remove it first only after
8132    // rollback or baseline commit is durable; an orphan private backup is safe
8133    // and can be pruned, while a journal without its backups is not recoverable.
8134    match store.remove_file(Path::new(WINDOWS_PULL_JOURNAL)) {
8135        Ok(()) => {}
8136        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
8137        Err(error) => return Err(error.into()),
8138    }
8139    match store.remove_private_tree(Path::new(&journal.backup_dir)) {
8140        Ok(()) => Ok(()),
8141        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
8142        Err(error) => Err(error.into()),
8143    }
8144}
8145
8146#[cfg(windows)]
8147fn prune_orphan_windows_pull_backups(store: &Store) -> LinkResult<()> {
8148    let names = match store.directory_names(Path::new(".dbmd")) {
8149        Ok(names) => names,
8150        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
8151        Err(error) => return Err(error.into()),
8152    };
8153    for name in names {
8154        let Some(name) = name.to_str() else {
8155            continue;
8156        };
8157        let Some(suffix) = name.strip_prefix("pull-backup-") else {
8158            continue;
8159        };
8160        if crate::ulid::is_ulid(suffix) {
8161            store.remove_private_tree(&Path::new(".dbmd").join(name))?;
8162        }
8163    }
8164    Ok(())
8165}
8166
8167#[cfg(windows)]
8168fn rollback_windows_pull(store: &Store, journal: &WindowsPullJournal) -> LinkResult<()> {
8169    // Validate every live coordinate and every backup before the first restore.
8170    for entry in &journal.entries {
8171        let limit = entry
8172            .old
8173            .as_ref()
8174            .into_iter()
8175            .chain(entry.new.iter())
8176            .map(|value| value.bytes)
8177            .max()
8178            .unwrap_or(0);
8179        let current = windows_pull_state(store, &entry.path, limit)?;
8180        if current != entry.old && current != entry.new {
8181            return Err(LinkError::InvalidPack {
8182                message: format!(
8183                    "cannot recover interrupted pull because `{}` changed afterward",
8184                    entry.path
8185                ),
8186            });
8187        }
8188        if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
8189            let path = Path::new(&journal.backup_dir).join(backup);
8190            let file = store.open_regular(&path)?;
8191            if file.metadata()?.len() != old.bytes || content_sha256_reader(file)? != old.sha256 {
8192                return Err(invalid_feed(
8193                    "Windows pull recovery backup failed verification",
8194                ));
8195            }
8196        }
8197    }
8198    for entry in journal.entries.iter().rev() {
8199        match (&entry.old, &entry.backup) {
8200            (Some(old), Some(backup)) => {
8201                let bytes =
8202                    store.read_bounded(&Path::new(&journal.backup_dir).join(backup), old.bytes)?;
8203                store.write_atomic(Path::new(&entry.path), &bytes)?;
8204            }
8205            (None, None) if store.regular_file_exists(Path::new(&entry.path))? => {
8206                store.remove_file(Path::new(&entry.path))?;
8207            }
8208            (None, None) => {}
8209            _ => return Err(invalid_feed("Windows pull recovery entry is inconsistent")),
8210        }
8211    }
8212    crate::index::Index::rebuild_all(store).map_err(|error| LinkError::InvalidPack {
8213        message: format!("could not rebuild catalogs after pull recovery: {error}"),
8214    })?;
8215    cleanup_windows_pull_journal(store, journal)
8216}
8217
8218#[cfg(windows)]
8219fn recover_windows_v2_pull(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<()> {
8220    let Ok(store) = Store::open_strict(dest) else {
8221        return Ok(());
8222    };
8223    if let Some(journal) = load_windows_pull_journal(&store)? {
8224        if journal.brain != brain {
8225            return Err(invalid_feed(
8226                "Windows pull journal belongs to another brain",
8227            ));
8228        }
8229        if journal.phase == WindowsPullPhase::Preparing {
8230            cleanup_windows_pull_journal(&store, &journal)?;
8231        } else {
8232            let baseline = load_v2_baseline(cfg, brain, dest)?;
8233            let current = windows_baseline_coordinate(baseline.as_ref());
8234            if current == journal.next {
8235                cleanup_windows_pull_journal(&store, &journal)?;
8236            } else {
8237                if current != journal.previous {
8238                    return Err(invalid_feed(
8239                        "cannot recover interrupted pull because its baseline changed afterward",
8240                    ));
8241                }
8242                rollback_windows_pull(&store, &journal)?;
8243            }
8244        }
8245    }
8246    // Cleanup removes the journal first after the state is durably old or new.
8247    // A hard kill in the tiny interval before deleting its backup directory can
8248    // therefore leave only an inert, private orphan. The per-brain operation
8249    // lock is held by the caller, so no live pull can own one here.
8250    prune_orphan_windows_pull_backups(&store)
8251}
8252
8253#[cfg(windows)]
8254fn complete_windows_v2_pull(dest: &Path) -> LinkResult<()> {
8255    let store = Store::open_strict(dest).map_err(|error| LinkError::InvalidPack {
8256        message: format!("installed Windows checkout is not a valid db.md store: {error}"),
8257    })?;
8258    if let Some(journal) = load_windows_pull_journal(&store)? {
8259        cleanup_windows_pull_journal(&store, &journal)?;
8260    }
8261    Ok(())
8262}
8263
8264#[cfg(not(windows))]
8265fn recover_windows_v2_pull(_cfg: &HubConfig, _brain: &str, _dest: &Path) -> LinkResult<()> {
8266    Ok(())
8267}
8268
8269#[cfg(not(windows))]
8270fn complete_windows_v2_pull(_dest: &Path) -> LinkResult<()> {
8271    Ok(())
8272}
8273
8274#[cfg(windows)]
8275fn install_windows_initial_sources(
8276    dest: &Path,
8277    entries: &[V2StagedFile],
8278    rebuild_indexes: bool,
8279) -> LinkResult<()> {
8280    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
8281    let name = dest.file_name().ok_or_else(|| LinkError::UnsafePath {
8282        path: dest.display().to_string(),
8283    })?;
8284    let parent_capability = crate::fsx::open_or_create_directory_nofollow(parent)?;
8285    if crate::fsx::directory_exists_beneath(&parent_capability, Path::new(name))? {
8286        return Err(LinkError::UnsafePath {
8287            path: dest.display().to_string(),
8288        });
8289    }
8290    let stage_name = format!(
8291        ".{}.dbmd-pull-stage-{}",
8292        name.to_string_lossy(),
8293        crate::ulid::mint()
8294    );
8295    let stage_path = parent.join(&stage_name);
8296    let stage_capability =
8297        crate::fsx::open_directory_beneath(&parent_capability, Path::new(&stage_name), true)?;
8298    let stage = Store::from_root_and_config(&stage_path, crate::Config::default())?;
8299    let prepared = (|| -> LinkResult<()> {
8300        for entry in entries {
8301            let bytes = crate::fsx::read_bounded_nofollow(&entry.source, entry.bytes)?;
8302            if bytes.len() as u64 != entry.bytes || content_sha256(&bytes) != entry.sha256 {
8303                return Err(invalid_feed(
8304                    "private staged sync source failed final integrity verification",
8305                ));
8306            }
8307            stage.write_atomic(Path::new(&entry.path), &bytes)?;
8308        }
8309        let strict = Store::from_held_root_strict(&stage_path, stage_capability.try_clone()?)
8310            .map_err(|error| LinkError::InvalidPack {
8311                message: format!("v2 staging tree is not a valid db.md store: {error}"),
8312            })?;
8313        if rebuild_indexes {
8314            crate::index::Index::rebuild_all(&strict).map_err(|error| LinkError::InvalidPack {
8315                message: format!("could not materialize v2 local catalogs: {error}"),
8316            })?;
8317        }
8318        Ok(())
8319    })();
8320    if let Err(error) = prepared {
8321        let _ = crate::fsx::remove_tree_beneath(&parent_capability, Path::new(&stage_name));
8322        return Err(error);
8323    }
8324    crate::fsx::rename_directory_beneath(
8325        &parent_capability,
8326        Path::new(&stage_name),
8327        Path::new(name),
8328    )?;
8329    Ok(())
8330}
8331
8332#[cfg(windows)]
8333fn install_pulled_delta_sources(
8334    dest: &Path,
8335    entries: &[V2StagedFile],
8336    deleted: &[String],
8337    rebuild_indexes: bool,
8338    previous: Option<&V2SyncBaseline>,
8339    next: &V2VerifiedHead,
8340) -> LinkResult<()> {
8341    let store = match Store::open_strict(dest) {
8342        Ok(store) => store,
8343        Err(_) => return install_windows_initial_sources(dest, entries, rebuild_indexes),
8344    };
8345    if load_windows_pull_journal(&store)?.is_some() {
8346        return Err(invalid_feed(
8347            "an interrupted Windows pull must be recovered before installing",
8348        ));
8349    }
8350    let mut sources = std::collections::BTreeMap::new();
8351    for entry in entries {
8352        if sources.insert(entry.path.clone(), entry).is_some() || deleted.contains(&entry.path) {
8353            return Err(invalid_feed("Windows pull mutation repeats a path"));
8354        }
8355    }
8356    let mut paths = sources.keys().cloned().collect::<Vec<_>>();
8357    paths.extend(deleted.iter().cloned());
8358    paths.sort();
8359    paths.dedup();
8360    if paths.is_empty() {
8361        return Ok(());
8362    }
8363    let backup_dir = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
8364    let mut journal = WindowsPullJournal {
8365        v: 1,
8366        phase: WindowsPullPhase::Preparing,
8367        brain: next.brain_id.clone(),
8368        previous: windows_baseline_coordinate(previous),
8369        next: windows_head_coordinate(next),
8370        backup_dir: backup_dir.clone(),
8371        entries: Vec::with_capacity(paths.len()),
8372    };
8373    for (index, path) in paths.iter().enumerate() {
8374        let old = windows_pull_state(&store, path, MAX_STORE_BYTES)?;
8375        let new = sources.get(path).map(|entry| WindowsPullFileCoordinate {
8376            sha256: entry.sha256.clone(),
8377            bytes: entry.bytes,
8378        });
8379        journal.entries.push(WindowsPullJournalEntry {
8380            path: path.clone(),
8381            backup: old.as_ref().map(|_| format!("{index:08x}")),
8382            old,
8383            new,
8384        });
8385    }
8386    validate_windows_pull_journal(&journal)?;
8387    store.write_atomic_new(
8388        Path::new(WINDOWS_PULL_JOURNAL),
8389        &windows_pull_journal_bytes(&journal)?,
8390    )?;
8391    let prepared = (|| -> LinkResult<()> {
8392        store.create_dir_all(Path::new(&backup_dir))?;
8393        for entry in &journal.entries {
8394            if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
8395                let bytes = store.read_bounded(Path::new(&entry.path), old.bytes)?;
8396                if content_sha256(&bytes) != old.sha256 {
8397                    return Err(invalid_feed("live pull source changed during backup"));
8398                }
8399                store.write_atomic_new(&Path::new(&backup_dir).join(backup), &bytes)?;
8400            }
8401        }
8402        journal.phase = WindowsPullPhase::Ready;
8403        store.write_atomic(
8404            Path::new(WINDOWS_PULL_JOURNAL),
8405            &windows_pull_journal_bytes(&journal)?,
8406        )?;
8407        Ok(())
8408    })();
8409    if let Err(error) = prepared {
8410        let cleanup = cleanup_windows_pull_journal(&store, &journal);
8411        return match cleanup {
8412            Ok(()) => Err(error),
8413            Err(cleanup) => Err(LinkError::InvalidPack {
8414                message: format!("{error}; recovery metadata cleanup also failed: {cleanup}"),
8415            }),
8416        };
8417    }
8418    let installed = (|| -> LinkResult<()> {
8419        for entry in &journal.entries {
8420            if windows_pull_state(&store, &entry.path, MAX_STORE_BYTES)? != entry.old {
8421                return Err(LinkError::InvalidPack {
8422                    message: format!("local path `{}` changed during pull", entry.path),
8423                });
8424            }
8425            if let Some(source) = sources.get(&entry.path) {
8426                let bytes = crate::fsx::read_bounded_nofollow(&source.source, source.bytes)?;
8427                if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != source.sha256 {
8428                    return Err(invalid_feed(
8429                        "private staged sync source failed final integrity verification",
8430                    ));
8431                }
8432                store.write_atomic(Path::new(&entry.path), &bytes)?;
8433            } else if entry.old.is_some() {
8434                store.remove_file(Path::new(&entry.path))?;
8435            }
8436        }
8437        if rebuild_indexes {
8438            crate::index::Index::rebuild_all(&store).map_err(|error| LinkError::InvalidPack {
8439                message: format!("could not materialize v2 local catalogs: {error}"),
8440            })?;
8441        }
8442        Ok(())
8443    })();
8444    if let Err(error) = installed {
8445        return match rollback_windows_pull(&store, &journal) {
8446            Ok(()) => Err(error),
8447            Err(rollback) => Err(LinkError::InvalidPack {
8448                message: format!("{error}; durable pull rollback also failed: {rollback}"),
8449            }),
8450        };
8451    }
8452    Ok(())
8453}
8454
8455#[cfg(not(any(unix, windows)))]
8456fn install_pulled_delta_sources(
8457    _dest: &Path,
8458    _entries: &[V2StagedFile],
8459    _deleted: &[String],
8460    _rebuild_indexes: bool,
8461    _previous: Option<&V2SyncBaseline>,
8462    _next: &V2VerifiedHead,
8463) -> LinkResult<()> {
8464    Err(LinkError::UnsupportedPlatform {
8465        operation: "atomic v2 pull install",
8466    })
8467}
8468
8469#[cfg(unix)]
8470fn install_pulled_snapshot(dest: &Path, entries: &[(String, Vec<u8>)]) -> LinkResult<()> {
8471    install_pulled_delta(dest, entries, &[], false)
8472}
8473
8474#[cfg(not(windows))]
8475fn is_safe_slug(slug: &str) -> bool {
8476    !slug.is_empty()
8477        && slug.len() <= 63
8478        && !slug.starts_with('-')
8479        && !slug.ends_with('-')
8480        && slug
8481            .bytes()
8482            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
8483}
8484
8485fn le_u16(bytes: &[u8], at: usize) -> Option<u16> {
8486    Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
8487}
8488
8489fn le_u32(bytes: &[u8], at: usize) -> Option<u32> {
8490    Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
8491}
8492
8493fn le_u64(bytes: &[u8], at: usize) -> Option<u64> {
8494    Some(u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?))
8495}
8496
8497fn preflight_zip_central_directory(
8498    bytes: &[u8],
8499    offset: usize,
8500    size: usize,
8501    count: u64,
8502) -> LinkResult<()> {
8503    const CENTRAL_ENTRY_SIG: &[u8; 4] = b"PK\x01\x02";
8504    let end = offset
8505        .checked_add(size)
8506        .filter(|end| *end <= bytes.len())
8507        .ok_or_else(|| LinkError::InvalidPack {
8508            message: "ZIP central directory is out of bounds".to_string(),
8509        })?;
8510    let mut cursor = offset;
8511    for _ in 0..count {
8512        if bytes.get(cursor..cursor + 4) != Some(CENTRAL_ENTRY_SIG.as_slice()) {
8513            return Err(LinkError::InvalidPack {
8514                message: "ZIP central directory entry count is inconsistent".to_string(),
8515            });
8516        }
8517        if le_u16(bytes, cursor + 34) != Some(0) {
8518            return Err(LinkError::InvalidPack {
8519                message: "multi-disk ZIP archives are not supported".to_string(),
8520            });
8521        }
8522        let variable = [28, 30, 32].into_iter().try_fold(0usize, |total, at| {
8523            total.checked_add(le_u16(bytes, cursor + at)? as usize)
8524        });
8525        cursor = cursor
8526            .checked_add(46)
8527            .and_then(|fixed| fixed.checked_add(variable?))
8528            .filter(|cursor| *cursor <= end)
8529            .ok_or_else(|| LinkError::InvalidPack {
8530                message: "ZIP central directory entry is truncated".to_string(),
8531            })?;
8532    }
8533    if cursor != end {
8534        return Err(LinkError::InvalidPack {
8535            message: "ZIP central directory size is inconsistent".to_string(),
8536        });
8537    }
8538    Ok(())
8539}
8540
8541/// Read only the bounded ZIP trailer before `ZipArchive::new` allocates one
8542/// metadata object per central-directory entry. Supports ordinary EOCD and the
8543/// Zip64 locator/record emitted for >65,535-entry archives.
8544fn preflight_zip_entry_count(bytes: &[u8], max_entries: usize) -> LinkResult<()> {
8545    const EOCD_SIG: &[u8; 4] = b"PK\x05\x06";
8546    const ZIP64_LOCATOR_SIG: &[u8; 4] = b"PK\x06\x07";
8547    const ZIP64_EOCD_SIG: &[u8; 4] = b"PK\x06\x06";
8548    let search_start = bytes.len().saturating_sub(22 + u16::MAX as usize);
8549    let eocd = bytes[search_start..]
8550        .windows(4)
8551        .rposition(|window| window == EOCD_SIG)
8552        .map(|offset| search_start + offset)
8553        .ok_or_else(|| LinkError::InvalidPack {
8554            message: "ZIP has no end-of-central-directory record".to_string(),
8555        })?;
8556    let invalid_end = || LinkError::InvalidPack {
8557        message: "ZIP has an invalid end-of-central-directory structure".to_string(),
8558    };
8559    let comment_len = le_u16(bytes, eocd + 20).ok_or_else(invalid_end)? as usize;
8560    if eocd
8561        .checked_add(22)
8562        .and_then(|end| end.checked_add(comment_len))
8563        != Some(bytes.len())
8564    {
8565        // Do not fall back to an earlier signature. ZipArchive does, and a
8566        // fake low-count EOCD appended after a real Zip64 entry-count bomb
8567        // would otherwise bypass this allocation preflight.
8568        return Err(invalid_end());
8569    }
8570    let disk = le_u16(bytes, eocd + 4);
8571    let central_disk = le_u16(bytes, eocd + 6);
8572    if disk != Some(0) || central_disk != Some(0) {
8573        return Err(LinkError::InvalidPack {
8574            message: "multi-disk ZIP archives are not supported".to_string(),
8575        });
8576    }
8577    let entries_on_disk = le_u16(bytes, eocd + 8).ok_or_else(invalid_end)?;
8578    let ordinary = le_u16(bytes, eocd + 10).ok_or_else(invalid_end)?;
8579    if entries_on_disk != ordinary {
8580        return Err(LinkError::InvalidPack {
8581            message: "multi-disk ZIP archives are not supported".to_string(),
8582        });
8583    }
8584    let zip64_locator = eocd
8585        .checked_sub(20)
8586        .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_LOCATOR_SIG.as_slice()));
8587    let (count, central_offset, central_size) = if ordinary != u16::MAX || zip64_locator.is_none() {
8588        let central_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)? as usize;
8589        let central_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)? as usize;
8590        if central_offset
8591            .checked_add(central_size)
8592            .filter(|end| *end == eocd)
8593            .is_none()
8594        {
8595            return Err(invalid_end());
8596        }
8597        (ordinary as u64, central_offset, central_size)
8598    } else {
8599        let Some(locator) = zip64_locator else {
8600            return Err(invalid_end());
8601        };
8602        if le_u32(bytes, locator + 4) != Some(0) || le_u32(bytes, locator + 16) != Some(1) {
8603            return Err(LinkError::InvalidPack {
8604                message: "multi-disk ZIP64 archives are not supported".to_string(),
8605            });
8606        }
8607        let record = le_u64(bytes, locator + 8)
8608            .and_then(|offset| usize::try_from(offset).ok())
8609            .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_EOCD_SIG.as_slice()))
8610            .ok_or_else(|| LinkError::InvalidPack {
8611                message: "ZIP64 archive has an invalid end record".to_string(),
8612            })?;
8613        let record_size = le_u64(bytes, record + 4)
8614            .and_then(|size| usize::try_from(size).ok())
8615            .filter(|size| *size >= 44)
8616            .ok_or_else(invalid_end)?;
8617        if record
8618            .checked_add(12)
8619            .and_then(|end| end.checked_add(record_size))
8620            != Some(locator)
8621            || le_u32(bytes, record + 16) != Some(0)
8622            || le_u32(bytes, record + 20) != Some(0)
8623        {
8624            return Err(invalid_end());
8625        }
8626        let zip64_on_disk = le_u64(bytes, record + 24).ok_or_else(invalid_end)?;
8627        let zip64_total = le_u64(bytes, record + 32).ok_or_else(invalid_end)?;
8628        let central_size = le_u64(bytes, record + 40)
8629            .and_then(|size| usize::try_from(size).ok())
8630            .ok_or_else(invalid_end)?;
8631        let central_offset = le_u64(bytes, record + 48)
8632            .and_then(|offset| usize::try_from(offset).ok())
8633            .ok_or_else(invalid_end)?;
8634        if zip64_on_disk != zip64_total
8635            || central_offset
8636                .checked_add(central_size)
8637                .filter(|end| *end == record)
8638                .is_none()
8639        {
8640            return Err(invalid_end());
8641        }
8642        let legacy_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)?;
8643        let legacy_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)?;
8644        if (legacy_size != u32::MAX && legacy_size as usize != central_size)
8645            || (legacy_offset != u32::MAX && legacy_offset as usize != central_offset)
8646        {
8647            return Err(invalid_end());
8648        }
8649        (zip64_total, central_offset, central_size)
8650    };
8651    if count == 0 || count > max_entries as u64 {
8652        return Err(LinkError::InvalidPack {
8653            message: format!("invalid file count {count}"),
8654        });
8655    }
8656    preflight_zip_central_directory(bytes, central_offset, central_size, count)?;
8657    Ok(())
8658}
8659
8660fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
8661    preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)?;
8662    let mut archive =
8663        zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
8664            message: format!("ZIP parse failed: {err}"),
8665        })?;
8666    if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
8667        return Err(LinkError::InvalidPack {
8668            message: format!("invalid file count {}", archive.len()),
8669        });
8670    }
8671    let mut total = 0u64;
8672    let mut seen = std::collections::HashSet::new();
8673    let mut entries = Vec::with_capacity(archive.len());
8674    for index in 0..archive.len() {
8675        let mut file = archive
8676            .by_index(index)
8677            .map_err(|err| LinkError::InvalidPack {
8678                message: format!("ZIP entry failed: {err}"),
8679            })?;
8680        if file.is_dir() {
8681            continue;
8682        }
8683        let path = file.name().to_string();
8684        if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
8685            return Err(LinkError::UnsafePath { path });
8686        }
8687        if file
8688            .unix_mode()
8689            .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
8690        {
8691            return Err(LinkError::InvalidPack {
8692                message: format!("non-file entry `{path}`"),
8693            });
8694        }
8695        if !seen.insert(path.clone()) {
8696            return Err(LinkError::InvalidPack {
8697                message: format!("duplicate path `{path}`"),
8698            });
8699        }
8700        let remaining = MAX_STORE_BYTES.saturating_sub(total);
8701        if file.size() > remaining {
8702            return Err(LinkError::InvalidPack {
8703                message: "expanded content exceeds the 512 MB limit".to_string(),
8704            });
8705        }
8706        let mut content = Vec::new();
8707        (&mut file)
8708            .take(remaining + 1)
8709            .read_to_end(&mut content)
8710            .map_err(|err| LinkError::InvalidPack {
8711                message: format!("could not decompress `{path}`: {err}"),
8712            })?;
8713        if content.len() as u64 > remaining {
8714            return Err(LinkError::InvalidPack {
8715                message: "expanded content exceeds the 512 MB limit".to_string(),
8716            });
8717        }
8718        if content.len() as u64 != file.size() {
8719            return Err(LinkError::InvalidPack {
8720                message: format!("length mismatch for `{path}`"),
8721            });
8722        }
8723        total += content.len() as u64;
8724        entries.push((path, content));
8725    }
8726    if entries.is_empty() {
8727        return Err(LinkError::InvalidPack {
8728            message: "pack contains no files".to_string(),
8729        });
8730    }
8731    Ok(entries)
8732}
8733
8734fn verify_snapshot_manifest(entries: &[(String, Vec<u8>)], signed: &[FeedFile]) -> LinkResult<()> {
8735    let mut expected = std::collections::BTreeMap::new();
8736    for file in signed {
8737        if !safe_store_rel_path(&file.path) {
8738            return Err(LinkError::UnsafePath {
8739                path: file.path.clone(),
8740            });
8741        }
8742        if !is_sha256(&file.sha256)
8743            || expected
8744                .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
8745                .is_some()
8746        {
8747            return Err(invalid_feed(
8748                "signed snapshot manifest contains an invalid or duplicate file",
8749            ));
8750        }
8751    }
8752    if expected.len() != entries.len() {
8753        return Err(invalid_feed(
8754            "downloaded pack file set differs from the signed snapshot manifest",
8755        ));
8756    }
8757    for (path, bytes) in entries {
8758        let Some((sha256, declared_bytes)) = expected.get(path.as_str()) else {
8759            return Err(invalid_feed(format!(
8760                "downloaded pack contains unsigned path `{path}`"
8761            )));
8762        };
8763        if *declared_bytes != bytes.len() as u64
8764            || *sha256 != format!("{:x}", Sha256::digest(bytes))
8765        {
8766            return Err(invalid_feed(format!(
8767                "downloaded file `{path}` differs from its signed manifest"
8768            )));
8769        }
8770    }
8771    Ok(())
8772}
8773
8774/// Collect the files a push sends: the store's owned text — `DB.md`,
8775/// `assets.jsonl` when present, and every content `.md` under `records/` and
8776/// `sources/` (the store walk, which already excludes hidden dirs like
8777/// `.dbmd/`, the `log/` archive, and derived `index.*` catalogs; the hub
8778/// derives its own index, and local history stays local). Returns
8779/// `(store-relative path, content)` pairs, path-sorted.
8780pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
8781    require_hardened_filesystem("sync push")?;
8782    preflight_push_ownership(store)?;
8783    let mut out: Vec<(String, String)> = Vec::new();
8784    let mut total = 0u64;
8785
8786    let mut read_text = |rel: &str| -> LinkResult<String> {
8787        let bytes = store.read_bounded(Path::new(rel), MAX_STORE_BYTES.saturating_sub(total))?;
8788        total = total
8789            .checked_add(bytes.len() as u64)
8790            .ok_or_else(|| LinkError::PushTooLarge {
8791                detail: "uncompressed byte count overflow".to_string(),
8792            })?;
8793        if total > MAX_STORE_BYTES {
8794            return Err(LinkError::PushTooLarge {
8795                detail: format!("{total} uncompressed bytes"),
8796            });
8797        }
8798        String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
8799            path: rel.to_string(),
8800        })
8801    };
8802
8803    out.push(("DB.md".to_string(), read_text("DB.md")?));
8804    if store
8805        .regular_file_exists(Path::new("assets.jsonl"))
8806        .unwrap_or(false)
8807    {
8808        out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
8809    }
8810
8811    for rel in store.walk()? {
8812        let rel_str = rel.to_string_lossy().replace('\\', "/");
8813        if !safe_store_rel_path(&rel_str) {
8814            // A locally-legal name outside the hub's portable charset cannot
8815            // travel this wire; refusing beats silently dropping it.
8816            return Err(LinkError::UnsafePath { path: rel_str });
8817        }
8818        let content = read_text(&rel_str)?;
8819        out.push((rel_str, content));
8820    }
8821
8822    out.sort_by(|a, b| a.0.cmp(&b.0));
8823    Ok(out)
8824}
8825
8826/// Refuse to build a destructive whole-store snapshot from an ambiguous local
8827/// tree. Ordinary read-only walks safely prune foreign paths, but a push that
8828/// silently omitted them could delete the hosted copies of those paths.
8829fn preflight_push_ownership(store: &Store) -> LinkResult<()> {
8830    if let Some(nested) = store.nested_store_roots()?.into_iter().next() {
8831        return Err(LinkError::from(std::io::Error::new(
8832            std::io::ErrorKind::PermissionDenied,
8833            format!("cannot push: nested db.md store at {}", nested.display()),
8834        )));
8835    }
8836
8837    if let Some(symlink) = store.unowned_symlinks()?.into_iter().next() {
8838        return Err(LinkError::from(std::io::Error::new(
8839            std::io::ErrorKind::PermissionDenied,
8840            format!(
8841                "cannot push: {} is a symlink outside the store ownership model",
8842                symlink.display()
8843            ),
8844        )));
8845    }
8846    Ok(())
8847}
8848
8849/// Push `files` to `brain` as a whole-store snapshot — the hub's push
8850/// semantics: the hosted copy becomes exactly this set (pull first if the
8851/// hosted side may have records the local copy lacks). Client-side caps
8852/// mirror the hub's JSON-path limits so an oversized push fails before the
8853/// upload.
8854pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
8855    require_safe_ref(brain)?;
8856    let remote = verified_remote_head(cfg, brain, false)?;
8857    if files.len() > MAX_PUSH_FILES {
8858        return Err(LinkError::PushTooLarge {
8859            detail: format!("{} files", files.len()),
8860        });
8861    }
8862    let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
8863    if raw_total > MAX_STORE_BYTES {
8864        return Err(LinkError::PushTooLarge {
8865            detail: format!("{raw_total} uncompressed bytes"),
8866        });
8867    }
8868
8869    // Self-custody (a brain key is configured): the JSON fast path is
8870    // hub-signed by construction, so every push goes through the pack flow
8871    // with a locally signed entry — the hub verifies and can never sign.
8872    if cfg.brain_key.is_none() {
8873        let body = json!({
8874            "files": files
8875                .iter()
8876                .map(|(p, c)| json!({ "path": p, "content": c }))
8877                .collect::<Vec<_>>(),
8878        });
8879        if body.to_string().len() <= MAX_PUSH_BYTES {
8880            let path = format!("/api/hub/brains/{brain}/push");
8881            let pushed = ensure_ok(
8882                request(cfg, "POST", &path, Some(&body), Auth::Required)?,
8883                "sync push",
8884            )?;
8885            return Ok(pushed);
8886        }
8887    }
8888
8889    let pack = build_store_pack(files)?;
8890    if pack.len() as u64 > MAX_PACK_BYTES {
8891        return Err(LinkError::PushTooLarge {
8892            detail: format!("{} pack bytes", pack.len()),
8893        });
8894    }
8895    let sha256 = format!("{:x}", Sha256::digest(&pack));
8896    let mut meta = json!({ "sha256": sha256, "bytes": pack.len() });
8897    if let Some(key) = &cfg.brain_key {
8898        if !remote.head.verified {
8899            return Err(invalid_feed(
8900                "self-custody push requires a fully verified, unscoped feed head",
8901            ));
8902        }
8903        let identity = remote
8904            .identity
8905            .as_ref()
8906            .ok_or_else(|| invalid_feed("verified brain has no current identity"))?;
8907        let current_multikey = format!("ed25519:{}", identity.fingerprint);
8908        if key.multikey != current_multikey || key.public_key_spki != identity.public_key_spki {
8909            return Err(invalid_feed(
8910                "configured brain key is not the verified current brain identity",
8911            ));
8912        }
8913        // The verified state pins seq + prev; a concurrent writer surfaces as
8914        // the hub's 422 on commit (re-run to retry against the new head).
8915        let next_seq = remote
8916            .head
8917            .seq
8918            .checked_add(1)
8919            .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
8920        let mut manifest: Vec<WireFeedFile> = files
8921            .iter()
8922            .map(|(path, content)| WireFeedFile {
8923                path: path.clone(),
8924                sha256: format!("{:x}", Sha256::digest(content.as_bytes())),
8925                bytes: content.len() as u64,
8926            })
8927            .collect();
8928        manifest.sort_by(|a, b| a.path.cmp(&b.path));
8929        let ts = crate::now()
8930            .with_timezone(&chrono::Utc)
8931            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
8932            .to_string();
8933        let entry = self_custody_entry(
8934            key,
8935            next_seq,
8936            ts,
8937            &sha256,
8938            &manifest,
8939            remote.head.feed_hash.as_deref(),
8940        )?;
8941        meta["entry"] = Value::String(entry);
8942    }
8943    let presigned = ensure_ok(
8944        request(
8945            cfg,
8946            "POST",
8947            &format!("/api/hub/brains/{brain}/packs/presign"),
8948            Some(&meta),
8949            Auth::Required,
8950        )?,
8951        "prepare pack upload",
8952    )?;
8953    let url = presigned
8954        .get("url")
8955        .and_then(Value::as_str)
8956        .ok_or_else(|| LinkError::InvalidPack {
8957            message: "the hub returned no upload URL".to_string(),
8958        })?;
8959    put_presigned(
8960        cfg,
8961        url,
8962        presigned.get("headers").unwrap_or(&Value::Null),
8963        &pack,
8964    )?;
8965    let committed = ensure_ok(
8966        request(
8967            cfg,
8968            "POST",
8969            &format!("/api/hub/brains/{brain}/packs/commit"),
8970            Some(&meta),
8971            Auth::Required,
8972        )?,
8973        "commit pack",
8974    )?;
8975    Ok(committed)
8976}
8977
8978fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
8979    const LOCAL_HEADER: u32 = 0x0403_4b50;
8980    const CENTRAL_HEADER: u32 = 0x0201_4b50;
8981    const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
8982    const VERSION_20: u16 = 20;
8983    const MADE_BY_UNIX_20: u16 = (3 << 8) | VERSION_20;
8984    const UTF8_FLAG: u16 = 1 << 11;
8985    const STORED: u16 = 0;
8986    const DOS_TIME_MIDNIGHT: u16 = 0;
8987    const DOS_DATE_1980_01_01: u16 = (1 << 5) | 1;
8988    const UNIX_REGULAR_0600: u32 = 0o100600 << 16;
8989
8990    struct CentralEntry<'a> {
8991        name: &'a [u8],
8992        crc32: u32,
8993        size: u32,
8994        local_offset: u32,
8995    }
8996
8997    fn push_u16(out: &mut Vec<u8>, value: u16) {
8998        out.extend_from_slice(&value.to_le_bytes());
8999    }
9000
9001    fn push_u32(out: &mut Vec<u8>, value: u32) {
9002        out.extend_from_slice(&value.to_le_bytes());
9003    }
9004
9005    if files.is_empty() {
9006        return Err(LinkError::InvalidPack {
9007            message: "cannot create an empty snapshot pack".to_string(),
9008        });
9009    }
9010    if files.len() > u16::MAX as usize {
9011        return Err(LinkError::PushTooLarge {
9012            detail: format!(
9013                "{} files (canonical ZIP32 packs cap at {})",
9014                files.len(),
9015                u16::MAX
9016            ),
9017        });
9018    }
9019
9020    let mut sorted: Vec<_> = files.iter().collect();
9021    sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
9022    let mut previous: Option<&str> = None;
9023    for (path, content) in &sorted {
9024        if !safe_store_rel_path(path) {
9025            return Err(LinkError::UnsafePath {
9026                path: (*path).clone(),
9027            });
9028        }
9029        if previous == Some(path.as_str()) {
9030            return Err(LinkError::InvalidPack {
9031                message: format!("duplicate path `{path}`"),
9032            });
9033        }
9034        previous = Some(path.as_str());
9035        u32::try_from(content.len()).map_err(|_| LinkError::PushTooLarge {
9036            detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
9037        })?;
9038    }
9039
9040    let mut out = Vec::new();
9041    let mut central = Vec::with_capacity(sorted.len());
9042    for (path, content) in sorted {
9043        let name = path.as_bytes();
9044        let name_len = u16::try_from(name.len()).map_err(|_| LinkError::InvalidPack {
9045            message: format!("ZIP entry name is too long: `{path}`"),
9046        })?;
9047        let bytes = content.as_bytes();
9048        let size = u32::try_from(bytes.len()).map_err(|_| LinkError::PushTooLarge {
9049            detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
9050        })?;
9051        let local_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
9052            detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
9053        })?;
9054        let crc32 = crc32fast::hash(bytes);
9055
9056        // Canonical local header: sizes and CRC are known up front. Bit 3 is
9057        // deliberately clear, so there is no trailing data descriptor.
9058        push_u32(&mut out, LOCAL_HEADER);
9059        push_u16(&mut out, VERSION_20);
9060        push_u16(&mut out, UTF8_FLAG);
9061        push_u16(&mut out, STORED);
9062        push_u16(&mut out, DOS_TIME_MIDNIGHT);
9063        push_u16(&mut out, DOS_DATE_1980_01_01);
9064        push_u32(&mut out, crc32);
9065        push_u32(&mut out, size);
9066        push_u32(&mut out, size);
9067        push_u16(&mut out, name_len);
9068        push_u16(&mut out, 0); // no local extra data
9069        out.extend_from_slice(name);
9070        out.extend_from_slice(bytes);
9071
9072        central.push(CentralEntry {
9073            name,
9074            crc32,
9075            size,
9076            local_offset,
9077        });
9078    }
9079
9080    let central_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
9081        detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
9082    })?;
9083    for entry in &central {
9084        push_u32(&mut out, CENTRAL_HEADER);
9085        push_u16(&mut out, MADE_BY_UNIX_20);
9086        push_u16(&mut out, VERSION_20);
9087        push_u16(&mut out, UTF8_FLAG);
9088        push_u16(&mut out, STORED);
9089        push_u16(&mut out, DOS_TIME_MIDNIGHT);
9090        push_u16(&mut out, DOS_DATE_1980_01_01);
9091        push_u32(&mut out, entry.crc32);
9092        push_u32(&mut out, entry.size);
9093        push_u32(&mut out, entry.size);
9094        push_u16(&mut out, entry.name.len() as u16);
9095        push_u16(&mut out, 0); // no central extra data
9096        push_u16(&mut out, 0); // no file comment
9097        push_u16(&mut out, 0); // disk number
9098        push_u16(&mut out, 0); // internal attributes
9099        push_u32(&mut out, UNIX_REGULAR_0600);
9100        push_u32(&mut out, entry.local_offset);
9101        out.extend_from_slice(entry.name);
9102    }
9103    let central_size = u32::try_from(out.len())
9104        .ok()
9105        .and_then(|end| end.checked_sub(central_offset))
9106        .ok_or_else(|| LinkError::PushTooLarge {
9107            detail: "canonical ZIP32 central directory exceeds its limit".to_string(),
9108        })?;
9109    let entry_count = central.len() as u16;
9110
9111    push_u32(&mut out, END_OF_CENTRAL_DIRECTORY);
9112    push_u16(&mut out, 0); // this disk
9113    push_u16(&mut out, 0); // central directory disk
9114    push_u16(&mut out, entry_count);
9115    push_u16(&mut out, entry_count);
9116    push_u32(&mut out, central_size);
9117    push_u32(&mut out, central_offset);
9118    push_u16(&mut out, 0); // no archive comment
9119
9120    if out.len() > u32::MAX as usize {
9121        return Err(LinkError::PushTooLarge {
9122            detail: "canonical ZIP32 pack exceeds 4 GiB".to_string(),
9123        });
9124    }
9125    Ok(out)
9126}
9127
9128// ─────────────────────────────────────────────────────────────────────────────
9129// grant — issue / list / revoke capabilities (owner-side)
9130// ─────────────────────────────────────────────────────────────────────────────
9131
9132/// The two capabilities a v0 hub enforces.
9133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9134pub enum Capability {
9135    /// Read the granted slice.
9136    Read,
9137    /// Read and push (whole-store; a path-scoped grant is read-only).
9138    Write,
9139}
9140
9141impl Capability {
9142    /// The wire form.
9143    pub fn as_str(self) -> &'static str {
9144        match self {
9145            Capability::Read => "read",
9146            Capability::Write => "write",
9147        }
9148    }
9149}
9150
9151/// Issue (or refresh) a grant on `brain` to `grantee` — a hub principal named
9152/// by email in v0 (the protocol's near-term simplification; key-named
9153/// grantees arrive with the signing layer). `scope` is a store-path prefix
9154/// (the hub's enforcement unit); `until` an ISO 8601 expiry, absent = until
9155/// revoked.
9156pub fn grant_issue(
9157    cfg: &HubConfig,
9158    brain: &str,
9159    grantee: &str,
9160    can: Capability,
9161    scope: Option<&str>,
9162    until: Option<&str>,
9163) -> LinkResult<Value> {
9164    require_safe_ref(brain)?;
9165    let _ = verified_remote_head(cfg, brain, false)?;
9166    // Grantee shape decides the axis: a base64url Ed25519 SPKI is a bare
9167    // multikey holder (link.md §6 cross-party keys — no hub account; the
9168    // printed `publicKeySpki` from `dbmd key generate`); anything else is a
9169    // hub principal named by email.
9170    let is_key_grantee = URL_SAFE_NO_PAD
9171        .decode(grantee)
9172        .map(|der| der.len() == 44 && der.starts_with(&ED25519_SPKI_PREFIX))
9173        .unwrap_or(false);
9174    let mut body = if is_key_grantee {
9175        json!({ "keySpki": grantee, "capability": can.as_str() })
9176    } else {
9177        json!({ "email": grantee, "capability": can.as_str() })
9178    };
9179    if let Some(s) = scope {
9180        body["scopePrefix"] = json!(s);
9181    }
9182    if let Some(u) = until {
9183        body["expiresAt"] = json!(u);
9184    }
9185    let path = format!("/api/hub/brains/{brain}/grants");
9186    ensure_ok(
9187        request(cfg, "POST", &path, Some(&body), Auth::Required)?,
9188        "grant issue",
9189    )
9190}
9191
9192/// List the active grants (and pending invites) on `brain`. Owner-side.
9193pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
9194    require_safe_ref(brain)?;
9195    let _ = verified_remote_head(cfg, brain, false)?;
9196    let path = format!("/api/hub/brains/{brain}/grants");
9197    ensure_ok(
9198        request(cfg, "GET", &path, None, Auth::Required)?,
9199        "grant list",
9200    )
9201}
9202
9203/// Revoke a grant (or cancel a pending invite) by id. Owner-side; revocation
9204/// is soft on the hub (the audit trail survives).
9205pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
9206    require_safe_ref(brain)?;
9207    require_safe_grant_id(grant_id)?;
9208    let _ = verified_remote_head(cfg, brain, false)?;
9209    let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
9210    ensure_ok(
9211        request(cfg, "DELETE", &path, None, Auth::Required)?,
9212        "grant revoke",
9213    )
9214}
9215
9216// ─────────────────────────────────────────────────────────────────────────────
9217// permissioned v2 proposals — inspect, accept, reject
9218// ─────────────────────────────────────────────────────────────────────────────
9219
9220#[derive(Debug)]
9221struct VerifiedV2Proposal {
9222    value: Value,
9223    changes: Value,
9224    blobs: Vec<(String, u64, String)>,
9225}
9226
9227fn require_proposal_id(id: &str) -> LinkResult<()> {
9228    if crate::ulid::is_ulid(id) {
9229        Ok(())
9230    } else {
9231        Err(invalid_feed("proposal id is not a lowercase ULID"))
9232    }
9233}
9234
9235fn verified_v2_proposal(
9236    cfg: &HubConfig,
9237    head: &V2VerifiedHead,
9238    proposal_id: &str,
9239) -> LinkResult<VerifiedV2Proposal> {
9240    require_proposal_id(proposal_id)?;
9241    if head.view_kind != "full" {
9242        return Err(invalid_feed(
9243            "proposal review requires a full readable view",
9244        ));
9245    }
9246    let path = format!(
9247        "/api/hub/brains/{}/v2/proposals/{proposal_id}",
9248        head.brain_id
9249    );
9250    let value = ensure_ok(
9251        request_capped(
9252            cfg,
9253            "GET",
9254            &path,
9255            None,
9256            Auth::Required,
9257            MAX_FEED_RESPONSE_BYTES,
9258        )?,
9259        "v2 proposal",
9260    )?;
9261    verify_v2_proposal_value(head, proposal_id, value)
9262}
9263
9264fn verify_v2_proposal_value(
9265    head: &V2VerifiedHead,
9266    proposal_id: &str,
9267    value: Value,
9268) -> LinkResult<VerifiedV2Proposal> {
9269    if value.get("v").and_then(Value::as_u64) != Some(2) {
9270        return Err(invalid_feed("proposal response has an invalid version"));
9271    }
9272    let proposal = value
9273        .get("proposal")
9274        .and_then(Value::as_object)
9275        .ok_or_else(|| invalid_feed("proposal response has no proposal"))?;
9276    if proposal.get("id").and_then(Value::as_str) != Some(proposal_id) {
9277        return Err(invalid_feed("proposal response changed its id"));
9278    }
9279    let payload_hash = proposal
9280        .get("payload_sha256")
9281        .and_then(Value::as_str)
9282        .filter(|hash| is_sha256(hash))
9283        .ok_or_else(|| invalid_feed("proposal has no payload address"))?;
9284    let clear_hash = proposal
9285        .get("clear_sha256")
9286        .and_then(Value::as_str)
9287        .filter(|hash| is_sha256(hash))
9288        .ok_or_else(|| invalid_feed("proposal has no clear payload digest"))?;
9289    let submission_hash = proposal
9290        .get("submission_claim_sha256")
9291        .and_then(Value::as_str)
9292        .filter(|hash| is_sha256(hash))
9293        .ok_or_else(|| invalid_feed("proposal has no submission claim address"))?;
9294    let submission = STANDARD
9295        .decode(
9296            proposal
9297                .get("submission_claim_base64")
9298                .and_then(Value::as_str)
9299                .ok_or_else(|| invalid_feed("proposal has no submission claim"))?,
9300        )
9301        .map_err(|_| invalid_feed("proposal submission claim is not base64"))?;
9302    let submission_value: Value = serde_json::from_slice(&submission)
9303        .map_err(|_| invalid_feed("proposal submission claim is not JSON"))?;
9304    if crate::linkmd_v2::canonical_bytes(&submission_value)
9305        .map_err(|error| invalid_feed(error.to_string()))?
9306        != submission
9307        || crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &submission)
9308            .map_err(|error| invalid_feed(error.to_string()))?
9309            != submission_hash
9310    {
9311        return Err(invalid_feed(
9312            "proposal submission claim is not canonical or addressed",
9313        ));
9314    }
9315    let envelope = submission_value
9316        .as_object()
9317        .ok_or_else(|| invalid_feed("proposal submission claim is not an object"))?;
9318    let claim = envelope
9319        .get("claim")
9320        .ok_or_else(|| invalid_feed("proposal submission claim body is missing"))?;
9321    let claim_object = claim
9322        .as_object()
9323        .ok_or_else(|| invalid_feed("proposal submission claim body is not an object"))?;
9324    let actor_root = claim_object
9325        .get("actor_root")
9326        .and_then(Value::as_object)
9327        .ok_or_else(|| invalid_feed("proposal submission actor root is missing"))?;
9328    let public_key = envelope
9329        .get("public_key")
9330        .and_then(Value::as_str)
9331        .ok_or_else(|| invalid_feed("proposal submission signer is missing"))?;
9332    let fingerprint = envelope
9333        .get("fingerprint")
9334        .and_then(Value::as_str)
9335        .ok_or_else(|| invalid_feed("proposal submission fingerprint is missing"))?;
9336    let signature = envelope
9337        .get("sig")
9338        .and_then(Value::as_str)
9339        .ok_or_else(|| invalid_feed("proposal submission signature is missing"))?;
9340    let claim_bytes = crate::linkmd_v2::canonical_bytes(claim)
9341        .map_err(|error| invalid_feed(error.to_string()))?;
9342    let der = verify_v2_spki_signature(public_key, &claim_bytes, signature)?;
9343    let signer = format!("{fingerprint}:{public_key}");
9344    let actor_class = actor_root.get("actor_class").and_then(Value::as_str);
9345    let grants = actor_root.get("grants").and_then(Value::as_array);
9346    let grants_are_canonical = grants.is_some_and(|items| {
9347        let mut prior: Option<&str> = None;
9348        items.iter().all(|item| {
9349            let Some(grant) = item.as_str() else {
9350                return false;
9351            };
9352            if !crate::ulid::is_ulid(grant) || prior.is_some_and(|value| value >= grant) {
9353                return false;
9354            }
9355            prior = Some(grant);
9356            true
9357        })
9358    });
9359    let optional_actor_field = |name: &str| {
9360        actor_root.get(name).is_some_and(|value| {
9361            value.is_null() || value.as_str().is_some_and(|text| !text.is_empty())
9362        })
9363    };
9364    let submitted_at = claim_object.get("submitted_at").and_then(Value::as_str);
9365    if claim_object.get("v").and_then(Value::as_u64) != Some(2)
9366        || format!("{:x}", Sha256::digest(&der)) != fingerprint
9367        || head
9368            .trust
9369            .hub_signer
9370            .as_ref()
9371            .is_some_and(|known| known != &signer)
9372        || !matches!(
9373            actor_class,
9374            Some(
9375                "user"
9376                    | "owned_agent"
9377                    | "foreign_key"
9378                    | "curation"
9379                    | "inbox"
9380                    | "restore"
9381                    | "migration"
9382                    | "operator_recovery"
9383            )
9384        )
9385        || actor_root
9386            .get("principal")
9387            .and_then(Value::as_str)
9388            .is_none_or(|value| value.is_empty())
9389        || actor_root
9390            .get("credential")
9391            .and_then(Value::as_str)
9392            .is_none_or(|value| value.is_empty())
9393        || !optional_actor_field("organization")
9394        || !optional_actor_field("role")
9395        || !grants_are_canonical
9396        || claim.get("brain").and_then(Value::as_str) != Some(head.brain_id.as_str())
9397        || claim.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
9398        || !claim_object
9399            .get("mutation_id")
9400            .and_then(Value::as_str)
9401            .is_some_and(|value| {
9402                !value.is_empty()
9403                    && value.len() <= 128
9404                    && value.chars().enumerate().all(|(index, char)| {
9405                        char.is_ascii_alphanumeric()
9406                            || (index > 0 && matches!(char, '.' | '_' | ':' | '-'))
9407                    })
9408            })
9409        || claim.get("payload_sha256").and_then(Value::as_str) != Some(payload_hash)
9410        || claim.get("clear_sha256").and_then(Value::as_str) != Some(clear_hash)
9411        || !claim_object
9412            .get("control_revision")
9413            .and_then(Value::as_str)
9414            .is_some_and(is_sha256)
9415        || submitted_at.is_none_or(|value| {
9416            chrono::DateTime::parse_from_rfc3339(value).is_err()
9417                || proposal.get("submitted_at").and_then(Value::as_str) != Some(value)
9418        })
9419        || !proposal
9420            .get("state")
9421            .and_then(Value::as_str)
9422            .is_some_and(|value| matches!(value, "pending" | "accepted" | "rejected" | "expired"))
9423        || proposal
9424            .get("expires_at")
9425            .and_then(Value::as_str)
9426            .is_none_or(|value| chrono::DateTime::parse_from_rfc3339(value).is_err())
9427        || proposal
9428            .get("proposer")
9429            .and_then(Value::as_object)
9430            .and_then(|value| value.get("class"))
9431            .and_then(Value::as_str)
9432            != actor_class
9433    {
9434        return Err(invalid_feed(
9435            "proposal submission claim does not bind the verified proposal",
9436        ));
9437    }
9438    let changes_b64 = proposal
9439        .get("changes_base64")
9440        .and_then(Value::as_str)
9441        .ok_or_else(|| invalid_feed("proposal has no changeset"))?;
9442    let changes_bytes = STANDARD
9443        .decode(changes_b64)
9444        .map_err(|_| invalid_feed("proposal changeset is not base64"))?;
9445    let changes: Value = serde_json::from_slice(&changes_bytes)
9446        .map_err(|_| invalid_feed("proposal changeset is not JSON"))?;
9447    if crate::linkmd_v2::canonical_bytes(&changes)
9448        .map_err(|error| invalid_feed(error.to_string()))?
9449        != changes_bytes
9450        || changes.get("v").and_then(Value::as_u64) != Some(2)
9451        || !changes.get("operations").is_some_and(Value::is_array)
9452    {
9453        return Err(invalid_feed("proposal changeset is not canonical v2"));
9454    }
9455    let blob_values = proposal
9456        .get("blobs")
9457        .and_then(Value::as_array)
9458        .ok_or_else(|| invalid_feed("proposal has no blob declarations"))?;
9459    let mut blobs = Vec::with_capacity(blob_values.len());
9460    let mut descriptor_blobs = Vec::with_capacity(blob_values.len());
9461    let mut prior_hash: Option<String> = None;
9462    for item in blob_values {
9463        let hash = item
9464            .get("sha256")
9465            .and_then(Value::as_str)
9466            .filter(|hash| is_sha256(hash))
9467            .ok_or_else(|| invalid_feed("proposal blob has no address"))?;
9468        let bytes = item
9469            .get("bytes")
9470            .and_then(Value::as_u64)
9471            .filter(|bytes| *bytes <= MAX_STORE_BYTES)
9472            .ok_or_else(|| invalid_feed("proposal blob has an invalid size"))?;
9473        if prior_hash.as_deref().is_some_and(|prior| prior >= hash) {
9474            return Err(invalid_feed(
9475                "proposal blob declarations are not unique and sorted",
9476            ));
9477        }
9478        prior_hash = Some(hash.to_string());
9479        let endpoint = item
9480            .get("endpoint")
9481            .and_then(Value::as_str)
9482            .ok_or_else(|| invalid_feed("proposal blob has no endpoint"))?;
9483        let expected_endpoint = format!(
9484            "/api/hub/brains/{}/v2/proposals/{proposal_id}/blob?sha256={hash}",
9485            head.brain_id
9486        );
9487        if endpoint != expected_endpoint {
9488            return Err(invalid_feed("proposal blob endpoint is not origin-bound"));
9489        }
9490        descriptor_blobs.push(json!({ "bytes": bytes, "sha256": hash }));
9491        blobs.push((hash.to_string(), bytes, endpoint.to_string()));
9492    }
9493    let descriptor = json!({
9494        "base": proposal.get("base").cloned().unwrap_or(Value::Null),
9495        "blobs": descriptor_blobs,
9496        "changes_base64": changes_b64,
9497        "rebase": proposal.get("rebase").cloned().unwrap_or(Value::Null),
9498        "v": 2,
9499    });
9500    let descriptor_bytes = crate::linkmd_v2::canonical_bytes(&descriptor)
9501        .map_err(|error| invalid_feed(error.to_string()))?;
9502    if content_sha256(&descriptor_bytes) != clear_hash {
9503        return Err(invalid_feed(
9504            "proposal clear payload differs from its signed submission claim",
9505        ));
9506    }
9507    Ok(VerifiedV2Proposal {
9508        value,
9509        changes,
9510        blobs,
9511    })
9512}
9513
9514pub fn proposal_list(
9515    cfg: &HubConfig,
9516    brain: &str,
9517    state: &str,
9518    after: Option<&str>,
9519    limit: usize,
9520) -> LinkResult<Value> {
9521    require_safe_ref(brain)?;
9522    if !matches!(state, "pending" | "accepted" | "rejected" | "expired") {
9523        return Err(invalid_feed("proposal state is invalid"));
9524    }
9525    if after.is_some_and(|value| !crate::ulid::is_ulid(value)) {
9526        return Err(invalid_feed("proposal cursor is invalid"));
9527    }
9528    let head = v2_verified_head(cfg, brain)?
9529        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
9530    let path = format!(
9531        "/api/hub/brains/{}/v2/proposals?state={state}&limit={}{}",
9532        head.brain_id,
9533        limit.clamp(1, 100),
9534        after.map_or_else(String::new, |value| format!("&after={value}"))
9535    );
9536    ensure_ok(
9537        request_capped(
9538            cfg,
9539            "GET",
9540            &path,
9541            None,
9542            Auth::Required,
9543            MAX_FEED_RESPONSE_BYTES,
9544        )?,
9545        "v2 proposal list",
9546    )
9547}
9548
9549pub fn proposal_show(cfg: &HubConfig, brain: &str, proposal_id: &str) -> LinkResult<Value> {
9550    require_safe_ref(brain)?;
9551    let head = v2_verified_head(cfg, brain)?
9552        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
9553    Ok(verified_v2_proposal(cfg, &head, proposal_id)?.value)
9554}
9555
9556pub fn proposal_reject(
9557    cfg: &HubConfig,
9558    brain: &str,
9559    proposal_id: &str,
9560    mutation_id: &str,
9561    reason: &str,
9562) -> LinkResult<Value> {
9563    require_safe_ref(brain)?;
9564    require_proposal_id(proposal_id)?;
9565    let head = v2_verified_head(cfg, brain)?
9566        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
9567    let _ = verified_v2_proposal(cfg, &head, proposal_id)?;
9568    let body = json!({
9569        "mutation_id": mutation_id,
9570        "control_revision": head.view_revision,
9571        "reason": reason,
9572    });
9573    let path = format!(
9574        "/api/hub/brains/{}/v2/proposals/{proposal_id}",
9575        head.brain_id
9576    );
9577    ensure_ok(
9578        request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
9579        "v2 proposal rejection",
9580    )
9581}
9582
9583pub fn proposal_accept_exact(
9584    cfg: &HubConfig,
9585    brain: &str,
9586    proposal_id: &str,
9587    mutation_id: &str,
9588    reason: &str,
9589) -> LinkResult<Value> {
9590    require_safe_ref(brain)?;
9591    require_proposal_id(proposal_id)?;
9592    let head = v2_verified_head(cfg, brain)?
9593        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
9594    let proposal = verified_v2_proposal(cfg, &head, proposal_id)?;
9595    let operations = proposal
9596        .changes
9597        .get("operations")
9598        .and_then(Value::as_array)
9599        .cloned()
9600        .ok_or_else(|| invalid_feed("proposal changeset has no operations"))?;
9601    if operations.is_empty() || operations.len() > MAX_PUSH_FILES {
9602        return Err(invalid_feed("proposal operation count is invalid"));
9603    }
9604    let mut downloaded = std::collections::BTreeMap::new();
9605    for (hash, bytes, endpoint) in &proposal.blobs {
9606        let body = ensure_raw_ok(
9607            request_raw(cfg, "GET", endpoint, None, Auth::Required, *bytes)?,
9608            "v2 proposal blob",
9609        )?;
9610        if body.len() as u64 != *bytes || content_sha256(&body) != *hash {
9611            return Err(invalid_feed("proposal blob does not match its declaration"));
9612        }
9613        downloaded.insert(hash.clone(), body);
9614    }
9615    let remote = files_for_v2_view(
9616        &head,
9617        v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
9618    );
9619    let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
9620    let mut expected_candidate = remote.clone();
9621    let mut expected_candidate_assets = remote_assets;
9622    for operation in &operations {
9623        let op = operation
9624            .get("op")
9625            .and_then(Value::as_str)
9626            .ok_or_else(|| invalid_feed("proposal operation has no kind"))?;
9627        match op {
9628            "put" | "restore" => {
9629                let path = operation
9630                    .get("path")
9631                    .and_then(Value::as_str)
9632                    .ok_or_else(|| invalid_feed("proposal write has no path"))?;
9633                crate::linkmd_v2::normalize_path(path)
9634                    .map_err(|error| invalid_feed(error.to_string()))?;
9635                let hash = operation
9636                    .get("blob")
9637                    .and_then(Value::as_str)
9638                    .filter(|hash| is_sha256(hash))
9639                    .ok_or_else(|| invalid_feed("proposal write has no blob"))?;
9640                let bytes = operation
9641                    .get("bytes")
9642                    .and_then(Value::as_u64)
9643                    .ok_or_else(|| invalid_feed("proposal write has no size"))?;
9644                expected_candidate.insert(
9645                    path.to_string(),
9646                    V2BaselineFile {
9647                        sha256: hash.to_string(),
9648                        bytes,
9649                        proof: None,
9650                    },
9651                );
9652            }
9653            "delete" | "withdraw_from_hosting" => {
9654                let path = operation
9655                    .get("path")
9656                    .and_then(Value::as_str)
9657                    .ok_or_else(|| invalid_feed("proposal removal has no path"))?;
9658                crate::linkmd_v2::normalize_path(path)
9659                    .map_err(|error| invalid_feed(error.to_string()))?;
9660                expected_candidate.remove(path);
9661            }
9662            "rename" => {
9663                let from = operation
9664                    .get("from")
9665                    .and_then(Value::as_str)
9666                    .ok_or_else(|| invalid_feed("proposal rename has no source"))?;
9667                let to = operation
9668                    .get("to")
9669                    .and_then(Value::as_str)
9670                    .ok_or_else(|| invalid_feed("proposal rename has no destination"))?;
9671                crate::linkmd_v2::normalize_path(from)
9672                    .and_then(|_| crate::linkmd_v2::normalize_path(to))
9673                    .map_err(|error| invalid_feed(error.to_string()))?;
9674                let hash = operation
9675                    .get("blob")
9676                    .and_then(Value::as_str)
9677                    .filter(|hash| is_sha256(hash))
9678                    .ok_or_else(|| invalid_feed("proposal rename has no blob"))?;
9679                let bytes = operation
9680                    .get("bytes")
9681                    .and_then(Value::as_u64)
9682                    .ok_or_else(|| invalid_feed("proposal rename has no size"))?;
9683                expected_candidate.remove(from);
9684                expected_candidate.insert(
9685                    to.to_string(),
9686                    V2BaselineFile {
9687                        sha256: hash.to_string(),
9688                        bytes,
9689                        proof: None,
9690                    },
9691                );
9692            }
9693            "asset_delete" => {
9694                let path = operation
9695                    .get("path")
9696                    .and_then(Value::as_str)
9697                    .ok_or_else(|| invalid_feed("proposal asset delete has no path"))?;
9698                expected_candidate_assets.remove(path);
9699            }
9700            "asset_withdraw" => {
9701                let path = operation
9702                    .get("path")
9703                    .and_then(Value::as_str)
9704                    .ok_or_else(|| invalid_feed("proposal asset withdrawal has no path"))?;
9705                let asset = expected_candidate_assets
9706                    .get_mut(path)
9707                    .ok_or_else(|| invalid_feed("proposal withdraws an unknown asset"))?;
9708                asset.disposition = "withheld".to_string();
9709                asset.leaf_hash.clear();
9710            }
9711            "asset_put" | "asset_resume" => {
9712                let path = operation
9713                    .get("path")
9714                    .and_then(Value::as_str)
9715                    .ok_or_else(|| invalid_feed("proposal asset write has no path"))?;
9716                let asset = operation
9717                    .get("asset")
9718                    .and_then(Value::as_object)
9719                    .ok_or_else(|| invalid_feed("proposal asset write has no value"))?;
9720                let blob_sha256 = asset
9721                    .get("blob_sha256")
9722                    .and_then(Value::as_str)
9723                    .filter(|hash| is_sha256(hash))
9724                    .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
9725                let bytes = asset
9726                    .get("bytes")
9727                    .and_then(Value::as_u64)
9728                    .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
9729                let media_type = asset
9730                    .get("media_type")
9731                    .and_then(Value::as_str)
9732                    .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
9733                let wrappers = asset
9734                    .get("wrappers")
9735                    .and_then(Value::as_array)
9736                    .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
9737                    .iter()
9738                    .map(|wrapper| {
9739                        wrapper
9740                            .as_str()
9741                            .map(str::to_string)
9742                            .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
9743                    })
9744                    .collect::<LinkResult<Vec<_>>>()?;
9745                let required = asset
9746                    .get("required")
9747                    .and_then(Value::as_bool)
9748                    .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
9749                let disposition = asset
9750                    .get("disposition")
9751                    .and_then(Value::as_str)
9752                    .filter(|value| matches!(*value, "hosted" | "withheld"))
9753                    .ok_or_else(|| invalid_feed("proposal asset disposition is invalid"))?;
9754                expected_candidate_assets.insert(
9755                    path.to_string(),
9756                    V2BaselineAsset {
9757                        blob_sha256: blob_sha256.to_string(),
9758                        bytes,
9759                        media_type: media_type.to_string(),
9760                        wrappers,
9761                        required,
9762                        disposition: disposition.to_string(),
9763                        leaf_hash: String::new(),
9764                    },
9765                );
9766            }
9767            _ => return Err(invalid_feed("proposal operation kind is unsupported")),
9768        }
9769    }
9770    let base = head.pointer.as_ref().map(|pointer| {
9771        json!({
9772            "seq": pointer.seq,
9773            "commit_hash": pointer.commit_hash,
9774            "content_root": pointer.content_root,
9775            "asset_root": pointer.asset_root,
9776        })
9777    });
9778    let mut body = json!({
9779        "mutation_id": mutation_id,
9780        "base": base,
9781        "rebase": "strict",
9782        "reason": reason,
9783        "operations": operations,
9784        "blobs": downloaded
9785            .iter()
9786            .map(|(sha256, bytes)| json!({
9787                "sha256": sha256,
9788                "bytes": bytes.len(),
9789                "content_base64": STANDARD.encode(bytes),
9790            }))
9791            .collect::<Vec<_>>(),
9792        "proposal_id": proposal_id,
9793        "proposal_mode": "exact",
9794    });
9795    let changed_bytes = downloaded.values().try_fold(0_usize, |total, bytes| {
9796        total
9797            .checked_add(bytes.len())
9798            .ok_or_else(|| LinkError::PushTooLarge {
9799                detail: "proposal changed-byte total overflow".to_string(),
9800            })
9801    })?;
9802    if changed_bytes > 3 * 1024 * 1024 || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
9803        let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
9804        for operation in &operations {
9805            let Some(kind) = operation.get("op").and_then(Value::as_str) else {
9806                return Err(invalid_feed("proposal upload operation has no kind"));
9807            };
9808            let hash = match kind {
9809                "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
9810                "asset_put" | "asset_resume" => operation
9811                    .get("asset")
9812                    .and_then(|asset| asset.get("blob_sha256"))
9813                    .and_then(Value::as_str),
9814                _ => None,
9815            };
9816            let Some(hash) = hash else { continue };
9817            let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
9818            if kind == "rename" {
9819                for field in ["from", "to"] {
9820                    coordinates.insert(
9821                        operation
9822                            .get(field)
9823                            .and_then(Value::as_str)
9824                            .ok_or_else(|| invalid_feed("proposal rename has no coordinate"))?
9825                            .to_string(),
9826                    );
9827                }
9828            } else {
9829                let path = operation
9830                    .get("path")
9831                    .and_then(Value::as_str)
9832                    .ok_or_else(|| invalid_feed("proposal upload has no coordinate"))?;
9833                coordinates.insert(if kind.starts_with("asset_") {
9834                    format!("assets/{path}")
9835                } else {
9836                    path.to_string()
9837                });
9838            }
9839        }
9840        let declarations = downloaded
9841            .iter()
9842            .map(|(sha256, bytes)| {
9843                json!({
9844                    "sha256": sha256,
9845                    "bytes": bytes.len(),
9846                    "coordinates": coordinates_by_hash
9847                        .get(sha256)
9848                        .into_iter()
9849                        .flatten()
9850                        .collect::<Vec<_>>(),
9851                })
9852            })
9853            .collect::<Vec<_>>();
9854        let reserved = ensure_ok(
9855            request(
9856                cfg,
9857                "POST",
9858                &format!("/api/hub/brains/{}/v2/uploads", head.brain_id),
9859                Some(&json!({ "blobs": declarations })),
9860                Auth::Required,
9861            )?,
9862            "prepare proposal blob transport",
9863        )?;
9864        let items = reserved
9865            .get("uploads")
9866            .and_then(Value::as_array)
9867            .ok_or_else(|| invalid_feed("proposal upload reservation has no items"))?;
9868        if items.len() != downloaded.len() {
9869            return Err(invalid_feed("proposal upload reservation changed the set"));
9870        }
9871        let mut references = Vec::with_capacity(items.len());
9872        for item in items {
9873            let hash = item
9874                .get("sha256")
9875                .and_then(Value::as_str)
9876                .ok_or_else(|| invalid_feed("proposal upload reservation has no hash"))?;
9877            let bytes = downloaded
9878                .get(hash)
9879                .ok_or_else(|| invalid_feed("proposal upload reservation introduced a blob"))?;
9880            let reservation_id = item
9881                .get("reservation_id")
9882                .and_then(Value::as_str)
9883                .filter(|id| crate::ulid::is_ulid(id))
9884                .ok_or_else(|| invalid_feed("proposal upload reservation has no id"))?;
9885            let expected_coordinates = coordinates_by_hash
9886                .get(hash)
9887                .ok_or_else(|| invalid_feed("proposal upload has no coordinate binding"))?;
9888            let returned_coordinates = item
9889                .get("coordinates")
9890                .and_then(Value::as_array)
9891                .ok_or_else(|| invalid_feed("proposal upload reservation has no coordinates"))?;
9892            if returned_coordinates.len() != expected_coordinates.len()
9893                || returned_coordinates
9894                    .iter()
9895                    .zip(expected_coordinates)
9896                    .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
9897            {
9898                return Err(invalid_feed(
9899                    "proposal upload reservation changed its coordinates",
9900                ));
9901            }
9902            match item.get("status").and_then(Value::as_str) {
9903                Some("upload") => put_presigned(
9904                    cfg,
9905                    item.get("url")
9906                        .and_then(Value::as_str)
9907                        .ok_or_else(|| invalid_feed("proposal upload has no URL"))?,
9908                    item.get("headers").unwrap_or(&Value::Null),
9909                    bytes,
9910                )?,
9911                Some("already_present") => {}
9912                _ => return Err(invalid_feed("proposal upload status is invalid")),
9913            }
9914            references.push(json!({
9915                "sha256": hash,
9916                "bytes": bytes.len(),
9917                "reservation_id": reservation_id,
9918            }));
9919        }
9920        body["blobs"] = Value::Array(references);
9921    }
9922    if body.to_string().len() > MAX_PUSH_BYTES {
9923        return Err(LinkError::PushTooLarge {
9924            detail: "proposal operation metadata exceeds the commit request cap".to_string(),
9925        });
9926    }
9927    let path = format!("/api/hub/brains/{}/v2/commits", head.brain_id);
9928    let mut result = ensure_ok(
9929        request(cfg, "POST", &path, Some(&body), Auth::Required)?,
9930        "exact proposal acceptance",
9931    )?;
9932    let mut candidate_hub_signer = None;
9933    if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
9934        let challenge = result
9935            .get("signing_challenge")
9936            .ok_or_else(|| invalid_feed("proposal acceptance has no signing challenge"))?;
9937        let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
9938            cfg,
9939            &head,
9940            &expected_candidate,
9941            &expected_candidate_assets,
9942            mutation_id,
9943            &body,
9944            challenge,
9945        )?;
9946        body["signing_challenge_id"] = Value::String(challenge_id);
9947        body["signature_base64url"] = Value::String(signature);
9948        candidate_hub_signer = Some(actor_signer);
9949        result = ensure_ok(
9950            request(cfg, "POST", &path, Some(&body), Auth::Required)?,
9951            "signed exact proposal acceptance",
9952        )?;
9953    }
9954    let refreshed = v2_verified_head(cfg, brain)?
9955        .ok_or_else(|| invalid_feed("v2 head disappeared after proposal acceptance"))?;
9956    if candidate_hub_signer
9957        .as_ref()
9958        .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
9959        || refreshed
9960            .pointer
9961            .as_ref()
9962            .map(|pointer| pointer.commit_hash.as_str())
9963            != result.get("commit_hash").and_then(Value::as_str)
9964        || result.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
9965        || result.get("proposal_state").and_then(Value::as_str) != Some("accepted")
9966    {
9967        return Err(LinkError::RemoteAdvancedDuringSync);
9968    }
9969    accept_v2_head(cfg, &refreshed)?;
9970    Ok(result)
9971}
9972
9973// ─────────────────────────────────────────────────────────────────────────────
9974// propose — write without trust: evidence into the owner's inbox
9975// ─────────────────────────────────────────────────────────────────────────────
9976
9977/// Submit `body` to the published site `handle`, addressed to its app page
9978/// `app` (a page that declares the `write-inbox` capability). Deliberately
9979/// unauthenticated — this is the cross-party door; the submission lands as
9980/// *evidence* in the owner's `sources/inbox/`, never as truth, and the
9981/// owner's curator accepts or rejects it. Returns the hub's `{id, path}`
9982/// receipt.
9983pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
9984    require_valid_handle(handle)?;
9985    if body.len() as u64 > MAX_PROPOSE_BYTES {
9986        return Err(LinkError::ProposeTooLarge {
9987            bytes: body.len() as u64,
9988        });
9989    }
9990    let payload = json!({ "app": app, "body": body });
9991    // A ULID-shaped target is a bare brain address (link.md §7.4's
9992    // generalization): the brain inbox door, open on public brains, where a
9993    // configured credential earns a bigger actor-class budget. Anything else
9994    // is a published-site handle: that door is unauthenticated by design.
9995    let (path, auth) = if crate::ulid::is_ulid(handle) {
9996        (format!("/api/hub/brains/{handle}/inbox"), Auth::Optional)
9997    } else {
9998        (format!("/api/hub/sites/{handle}/inbox"), Auth::None)
9999    };
10000    ensure_ok(
10001        request(cfg, "POST", &path, Some(&payload), auth)?,
10002        "propose",
10003    )
10004}
10005
10006// ─────────────────────────────────────────────────────────────────────────────
10007// subscribe — follow feed-head movement
10008// ─────────────────────────────────────────────────────────────────────────────
10009
10010/// One observation of a brain's feed head.
10011#[derive(Debug, serde::Serialize)]
10012pub struct Head {
10013    /// The brain id.
10014    pub brain: String,
10015    /// The hub's durable feed cursor — advances on every accepted write.
10016    pub seq: u64,
10017    /// The hub's `updatedAt` for the brain, when present.
10018    #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
10019    pub updated_at: Option<String>,
10020    /// SHA-256 of the exact signed head entry.
10021    #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
10022    pub feed_hash: Option<String>,
10023    /// Whether the head entry's content hash, identity, and Ed25519 signature
10024    /// were verified locally. Path-scoped grants get head movement only.
10025    pub verified: bool,
10026}
10027
10028struct BoundedVecVisitor<T, const MAX: usize> {
10029    label: &'static str,
10030    marker: std::marker::PhantomData<T>,
10031}
10032
10033impl<'de, T, const MAX: usize> serde::de::Visitor<'de> for BoundedVecVisitor<T, MAX>
10034where
10035    T: Deserialize<'de>,
10036{
10037    type Value = Vec<T>;
10038
10039    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
10040        write!(formatter, "at most {MAX} {}", self.label)
10041    }
10042
10043    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
10044    where
10045        A: serde::de::SeqAccess<'de>,
10046    {
10047        if sequence.size_hint().is_some_and(|size| size > MAX) {
10048            return Err(serde::de::Error::custom(format!(
10049                "{} exceeds the {MAX}-item limit",
10050                self.label
10051            )));
10052        }
10053        let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX));
10054        while let Some(value) = sequence.next_element()? {
10055            if values.len() == MAX {
10056                return Err(serde::de::Error::custom(format!(
10057                    "{} exceeds the {MAX}-item limit",
10058                    self.label
10059                )));
10060            }
10061            values.push(value);
10062        }
10063        Ok(values)
10064    }
10065}
10066
10067fn deserialize_bounded_vec<'de, D, T, const MAX: usize>(
10068    deserializer: D,
10069    label: &'static str,
10070) -> Result<Vec<T>, D::Error>
10071where
10072    D: serde::Deserializer<'de>,
10073    T: Deserialize<'de>,
10074{
10075    deserializer.deserialize_seq(BoundedVecVisitor::<T, MAX> {
10076        label,
10077        marker: std::marker::PhantomData,
10078    })
10079}
10080
10081fn deserialize_feed_files<'de, D>(deserializer: D) -> Result<Vec<FeedFile>, D::Error>
10082where
10083    D: serde::Deserializer<'de>,
10084{
10085    deserialize_bounded_vec::<D, FeedFile, MAX_PUSH_FILES>(deserializer, "feed files")
10086}
10087
10088fn deserialize_removed_paths<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
10089where
10090    D: serde::Deserializer<'de>,
10091{
10092    deserialize_bounded_vec::<D, String, MAX_PUSH_FILES>(deserializer, "removed paths")
10093}
10094
10095fn deserialize_previous_identities<'de, D>(
10096    deserializer: D,
10097) -> Result<Vec<PreviousIdentity>, D::Error>
10098where
10099    D: serde::Deserializer<'de>,
10100{
10101    deserialize_bounded_vec::<D, PreviousIdentity, MAX_IDENTITY_ROTATIONS>(
10102        deserializer,
10103        "previous identities",
10104    )
10105}
10106
10107fn deserialize_rotations<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
10108where
10109    D: serde::Deserializer<'de>,
10110{
10111    deserialize_bounded_vec::<D, String, MAX_IDENTITY_ROTATIONS>(
10112        deserializer,
10113        "rotation statements",
10114    )
10115}
10116
10117fn deserialize_feed_items<'de, D>(deserializer: D) -> Result<Vec<FeedItem>, D::Error>
10118where
10119    D: serde::Deserializer<'de>,
10120{
10121    deserialize_bounded_vec::<D, FeedItem, FEED_PAGE_LIMIT>(deserializer, "feed entries")
10122}
10123
10124#[derive(Debug, Clone, Deserialize, Serialize)]
10125struct FeedFile {
10126    path: String,
10127    sha256: String,
10128    bytes: u64,
10129}
10130
10131#[cfg(test)]
10132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10133enum V1DisclosureError {
10134    DuplicateFile,
10135    DuplicateRemoved,
10136    PushManifestMismatch,
10137    EditMissingChange,
10138    EditFalseFile,
10139    RemovedMismatch,
10140}
10141
10142/// Validate wire-profile-v1 `files`/`removed` disclosure semantics against
10143/// the exact previous and resulting pack manifests. `edit` accepts the
10144/// historical superset form while still requiring every actual changed path.
10145#[cfg(test)]
10146fn verify_v1_manifest_disclosure(
10147    kind: &str,
10148    previous: &[FeedFile],
10149    resulting: &[FeedFile],
10150    files: &[FeedFile],
10151    removed: &[String],
10152) -> Result<(), V1DisclosureError> {
10153    fn as_map(
10154        files: &[FeedFile],
10155    ) -> Result<std::collections::BTreeMap<&str, (&str, u64)>, V1DisclosureError> {
10156        let mut result = std::collections::BTreeMap::new();
10157        for file in files {
10158            if result
10159                .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
10160                .is_some()
10161            {
10162                return Err(V1DisclosureError::DuplicateFile);
10163            }
10164        }
10165        Ok(result)
10166    }
10167    let previous = as_map(previous)?;
10168    let resulting = as_map(resulting)?;
10169    let disclosed = as_map(files)?;
10170    let removed_set: std::collections::BTreeSet<&str> =
10171        removed.iter().map(String::as_str).collect();
10172    if removed_set.len() != removed.len() {
10173        return Err(V1DisclosureError::DuplicateRemoved);
10174    }
10175    let expected_removed: std::collections::BTreeSet<&str> = previous
10176        .keys()
10177        .copied()
10178        .filter(|path| !resulting.contains_key(path))
10179        .collect();
10180    if removed_set != expected_removed {
10181        return Err(V1DisclosureError::RemovedMismatch);
10182    }
10183    if kind == "push" {
10184        return if disclosed == resulting {
10185            Ok(())
10186        } else {
10187            Err(V1DisclosureError::PushManifestMismatch)
10188        };
10189    }
10190    if kind != "edit" {
10191        return Err(V1DisclosureError::EditFalseFile);
10192    }
10193    if disclosed
10194        .iter()
10195        .any(|(path, value)| resulting.get(path) != Some(value))
10196    {
10197        return Err(V1DisclosureError::EditFalseFile);
10198    }
10199    for (path, value) in &resulting {
10200        if previous.get(path) != Some(value) && !disclosed.contains_key(path) {
10201            return Err(V1DisclosureError::EditMissingChange);
10202        }
10203    }
10204    Ok(())
10205}
10206
10207#[derive(Debug, Clone, Deserialize, Serialize)]
10208struct FeedEntry {
10209    v: u8,
10210    seq: u64,
10211    ts: String,
10212    brain: String,
10213    public_key: String,
10214    kind: String,
10215    op: String,
10216    pack_sha256: String,
10217    #[serde(deserialize_with = "deserialize_feed_files")]
10218    files: Vec<FeedFile>,
10219    #[serde(deserialize_with = "deserialize_removed_paths")]
10220    removed: Vec<String>,
10221    prev_entry_hash: Option<String>,
10222    sig: String,
10223}
10224
10225#[derive(Serialize)]
10226struct UnsignedFeedEntry<'a> {
10227    v: u8,
10228    seq: u64,
10229    ts: &'a str,
10230    brain: &'a str,
10231    public_key: &'a str,
10232    kind: &'a str,
10233    op: &'a str,
10234    pack_sha256: &'a str,
10235    files: &'a [FeedFile],
10236    removed: &'a [String],
10237    prev_entry_hash: &'a Option<String>,
10238}
10239
10240#[derive(Debug, Clone, Deserialize, Serialize)]
10241struct FeedItem {
10242    hash: String,
10243    entry: FeedEntry,
10244}
10245
10246#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
10247struct FeedIdentity {
10248    fingerprint: String,
10249    #[serde(rename = "publicKeySpki")]
10250    public_key_spki: String,
10251    /// Rotation history (link.md §9.1): identities this brain previously
10252    /// signed as. Entries verify against current OR previous — rotation
10253    /// never invalidates history.
10254    #[serde(default, deserialize_with = "deserialize_previous_identities")]
10255    previous: Vec<PreviousIdentity>,
10256    /// Exact normative rotation statements, oldest first. A list of previous
10257    /// public keys without these old-key signatures is not a trust chain.
10258    #[serde(default, deserialize_with = "deserialize_rotations")]
10259    rotations: Vec<String>,
10260}
10261
10262#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
10263struct PreviousIdentity {
10264    fingerprint: String,
10265    #[serde(rename = "publicKeySpki")]
10266    public_key_spki: String,
10267}
10268
10269#[derive(Debug, Deserialize)]
10270struct FeedResponse {
10271    #[serde(rename = "headSeq")]
10272    head_seq: u64,
10273    #[serde(rename = "feedHash")]
10274    feed_hash: Option<String>,
10275    identity: Option<FeedIdentity>,
10276    #[serde(deserialize_with = "deserialize_feed_items")]
10277    entries: Vec<FeedItem>,
10278    #[serde(rename = "scopeLimited")]
10279    scope_limited: bool,
10280}
10281
10282#[derive(Debug, Deserialize, Serialize)]
10283#[serde(deny_unknown_fields)]
10284struct RotationStatement {
10285    v: u8,
10286    op: String,
10287    brain: String,
10288    public_key: String,
10289    new_brain: String,
10290    new_public_key: String,
10291    prior_head_seq: u64,
10292    prior_feed_hash: Option<String>,
10293    ts: String,
10294    sig: String,
10295}
10296
10297#[derive(Debug, Clone, Deserialize, Serialize)]
10298struct TrustState {
10299    v: u8,
10300    origin: String,
10301    /// The exact caller-visible ref whose resolution this checkpoint pins.
10302    /// Slugs/handles are mutable names at the hub; once observed, they may not
10303    /// silently resolve to a different canonical brain.
10304    #[serde(default)]
10305    requested: String,
10306    /// Canonical hub brain id returned for `requested`.
10307    brain: String,
10308    /// Federation home origin when this ref was learned from a registry.
10309    /// Once observed, the registry cannot silently relocate the same handle.
10310    #[serde(default, skip_serializing_if = "Option::is_none")]
10311    home: Option<String>,
10312    anchor: String,
10313    current: String,
10314    #[serde(rename = "headSeq")]
10315    head_seq: u64,
10316    #[serde(rename = "feedHash")]
10317    feed_hash: Option<String>,
10318    /// Exact accepted old-key-signed rotation statements, oldest first. v2
10319    /// checkpoints require this vector to be an immutable prefix of every
10320    /// subsequently served identity chain.
10321    #[serde(default)]
10322    rotations: Vec<String>,
10323    /// Hub pointer-signing identity pinned on first v2 observation. This is
10324    /// separate from the brain key, which signs commits.
10325    #[serde(default, skip_serializing_if = "Option::is_none")]
10326    hub_signer: Option<String>,
10327    /// Explicit transport profile. Older accepted non-empty v2 checkpoints
10328    /// are recognized by their pinned hub signer during one-way migration.
10329    #[serde(default, skip_serializing_if = "Option::is_none")]
10330    protocol_profile: Option<String>,
10331}
10332
10333fn accepted_as_v2(state: &TrustState) -> bool {
10334    state.protocol_profile.as_deref() == Some("link-v2") || state.hub_signer.is_some()
10335}
10336
10337fn has_accepted_v2_ref(cfg: &HubConfig, requested: &str) -> LinkResult<bool> {
10338    let directory = open_trust_dir(cfg)?;
10339    if load_trust_in(cfg, &directory, requested)?.is_some_and(|state| accepted_as_v2(&state)) {
10340        return Ok(true);
10341    }
10342    let Some(alias) = load_alias_in(cfg, &directory, requested)? else {
10343        return Ok(false);
10344    };
10345    Ok(load_trust_in(cfg, &directory, &alias.brain)?.is_some_and(|state| accepted_as_v2(&state)))
10346}
10347
10348#[derive(Debug, Clone, Deserialize, Serialize)]
10349struct AliasBinding {
10350    v: u8,
10351    origin: String,
10352    requested: String,
10353    brain: String,
10354    #[serde(default, skip_serializing_if = "Option::is_none")]
10355    home: Option<String>,
10356}
10357
10358struct VerifiedRemote {
10359    head: Head,
10360    identity: Option<FeedIdentity>,
10361    head_entry: Option<FeedItem>,
10362    /// Present when the caller requested and verified the complete chain.
10363    entries: Vec<FeedItem>,
10364    anchor: Option<String>,
10365}
10366
10367fn invalid_feed(message: impl Into<String>) -> LinkError {
10368    LinkError::InvalidFeed {
10369        message: message.into(),
10370    }
10371}
10372
10373fn is_sha256(value: &str) -> bool {
10374    value.len() == 64
10375        && value
10376            .bytes()
10377            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
10378}
10379
10380fn identity_fingerprint(public_key_spki: &str) -> LinkResult<String> {
10381    let der = URL_SAFE_NO_PAD
10382        .decode(public_key_spki)
10383        .map_err(|_| invalid_feed("identity public key is not base64url"))?;
10384    if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
10385        return Err(invalid_feed(
10386            "identity public key is not a valid Ed25519 SPKI",
10387        ));
10388    }
10389    Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(&der)))
10390}
10391
10392/// Verify the hub's current identity and every old-key-signed rotation that
10393/// leads from the original TOFU anchor to it. `previous` alone is metadata,
10394/// never authority.
10395fn verify_identity_chain(
10396    identity: &FeedIdentity,
10397    pinned: Option<&TrustState>,
10398) -> LinkResult<String> {
10399    if identity.previous.len() > MAX_IDENTITY_ROTATIONS
10400        || identity.rotations.len() > MAX_IDENTITY_ROTATIONS
10401    {
10402        return Err(invalid_feed(
10403            "identity rotation history exceeds the client cap",
10404        ));
10405    }
10406    if identity.fingerprint != identity_fingerprint(&identity.public_key_spki)? {
10407        return Err(invalid_feed(
10408            "current identity fingerprint does not match its public key",
10409        ));
10410    }
10411    for previous in &identity.previous {
10412        if previous.fingerprint != identity_fingerprint(&previous.public_key_spki)? {
10413            return Err(invalid_feed(
10414                "previous identity fingerprint does not match its public key",
10415            ));
10416        }
10417    }
10418    if identity.rotations.len() != identity.previous.len() {
10419        return Err(invalid_feed(
10420            "identity history is missing an old-key-signed rotation statement",
10421        ));
10422    }
10423
10424    // The HTTP identity lists prior identities newest first. Rotation
10425    // statements are chronological, so verification walks the reversed list
10426    // and ends at the current identity.
10427    let mut chain: Vec<(&str, &str)> = identity
10428        .previous
10429        .iter()
10430        .rev()
10431        .map(|p| (p.fingerprint.as_str(), p.public_key_spki.as_str()))
10432        .collect();
10433    chain.push((&identity.fingerprint, &identity.public_key_spki));
10434
10435    for (index, raw) in identity.rotations.iter().enumerate() {
10436        let statement: RotationStatement = serde_json::from_str(raw)
10437            .map_err(|_| invalid_feed("rotation statement did not parse exactly"))?;
10438        let (old_fingerprint, old_spki) = chain[index];
10439        let (new_fingerprint, new_spki) = chain[index + 1];
10440        if statement.v != 1
10441            || statement.op != "rotate"
10442            || statement.brain != format!("ed25519:{old_fingerprint}")
10443            || statement.public_key != old_spki
10444            || statement.new_brain != format!("ed25519:{new_fingerprint}")
10445            || statement.new_public_key != new_spki
10446            || (statement.prior_head_seq == 0 && statement.prior_feed_hash.is_some())
10447            || (statement.prior_head_seq > 0
10448                && statement
10449                    .prior_feed_hash
10450                    .as_deref()
10451                    .is_none_or(|hash| !is_sha256(hash)))
10452        {
10453            return Err(invalid_feed(
10454                "rotation statement does not connect adjacent identities",
10455            ));
10456        }
10457        let unsigned = serde_json::to_string(&UnsignedRotation {
10458            v: statement.v,
10459            op: &statement.op,
10460            brain: &statement.brain,
10461            public_key: &statement.public_key,
10462            new_brain: &statement.new_brain,
10463            new_public_key: &statement.new_public_key,
10464            prior_head_seq: statement.prior_head_seq,
10465            prior_feed_hash: statement.prior_feed_hash.as_deref(),
10466            ts: statement.ts.clone(),
10467        })
10468        .map_err(|_| invalid_feed("could not canonicalize rotation statement"))?;
10469        let exact = format!(
10470            "{},\"sig\":\"{}\"}}",
10471            &unsigned[..unsigned.len() - 1],
10472            statement.sig
10473        );
10474        if exact != *raw {
10475            return Err(invalid_feed(
10476                "rotation statement is not in normative serialization",
10477            ));
10478        }
10479        let der = URL_SAFE_NO_PAD
10480            .decode(old_spki)
10481            .map_err(|_| invalid_feed("rotation public key is not base64url"))?;
10482        let signature = URL_SAFE_NO_PAD
10483            .decode(&statement.sig)
10484            .map_err(|_| invalid_feed("rotation signature is not base64url"))?;
10485        UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
10486            .verify(unsigned.as_bytes(), &signature)
10487            .map_err(|_| invalid_feed("rotation signature verification failed"))?;
10488        if index > 0 {
10489            let prior: RotationStatement = serde_json::from_str(&identity.rotations[index - 1])
10490                .map_err(|_| invalid_feed("prior rotation statement did not parse"))?;
10491            if statement.prior_head_seq < prior.prior_head_seq {
10492                return Err(invalid_feed("rotation feed boundaries move backward"));
10493            }
10494        }
10495    }
10496
10497    let anchor = format!("ed25519:{}", chain[0].0);
10498    let current = format!("ed25519:{}", identity.fingerprint);
10499    if let Some(pin) = pinned {
10500        if pin.anchor != anchor {
10501            return Err(invalid_feed(
10502                "served identity chain does not descend from the pinned anchor",
10503            ));
10504        }
10505        if !chain
10506            .iter()
10507            .any(|(fingerprint, _)| pin.current == format!("ed25519:{fingerprint}"))
10508        {
10509            return Err(invalid_feed(
10510                "served identity chain forked away from the last pinned identity",
10511            ));
10512        }
10513        if pin.current == current && pin.anchor != current && identity.rotations.is_empty() {
10514            return Err(invalid_feed("served identity discarded its rotation chain"));
10515        }
10516        if pin.v >= 2
10517            && (identity.rotations.len() < pin.rotations.len()
10518                || identity.rotations[..pin.rotations.len()] != pin.rotations)
10519        {
10520            return Err(invalid_feed(
10521                "served identity rewrote the locally accepted rotation history",
10522            ));
10523        }
10524    }
10525    Ok(anchor)
10526}
10527
10528fn verify_rotation_feed_boundaries(
10529    identity: &FeedIdentity,
10530    pinned: Option<&TrustState>,
10531    observed: &[FeedItem],
10532    advertised_seq: u64,
10533) -> LinkResult<()> {
10534    let mut chain: Vec<String> = identity
10535        .previous
10536        .iter()
10537        .rev()
10538        .map(|previous| format!("ed25519:{}", previous.fingerprint))
10539        .collect();
10540    chain.push(format!("ed25519:{}", identity.fingerprint));
10541    let pinned_index = pinned.and_then(|pin| chain.iter().position(|key| key == &pin.current));
10542
10543    for (index, raw) in identity.rotations.iter().enumerate() {
10544        let rotation: RotationStatement = serde_json::from_str(raw)
10545            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
10546        if rotation.prior_head_seq > advertised_seq {
10547            return Err(invalid_feed(
10548                "rotation claims a feed boundary beyond the advertised head",
10549            ));
10550        }
10551        if let (Some(pin), Some(pin_index)) = (pinned, pinned_index) {
10552            if index >= pin_index && rotation.prior_head_seq < pin.head_seq {
10553                return Err(invalid_feed(
10554                    "newly disclosed rotation predates the local feed checkpoint",
10555                ));
10556            }
10557        }
10558        let actual = if rotation.prior_head_seq == 0 {
10559            None
10560        } else if pinned.is_some_and(|pin| pin.head_seq == rotation.prior_head_seq) {
10561            pinned.and_then(|pin| pin.feed_hash.as_deref())
10562        } else {
10563            observed
10564                .iter()
10565                .find(|item| item.entry.seq == rotation.prior_head_seq)
10566                .map(|item| item.hash.as_str())
10567        };
10568        if let Some(actual) = actual {
10569            if rotation.prior_feed_hash.as_deref() != Some(actual) {
10570                return Err(invalid_feed(
10571                    "rotation statement does not commit the verified feed boundary",
10572                ));
10573            }
10574        } else if rotation.prior_head_seq == 0 {
10575            // The empty-feed boundary is represented by (0, null); there is
10576            // no entry hash to find in `observed`.
10577        } else if pinned.is_some_and(|pin| {
10578            pinned_index.is_some_and(|pin_index| index >= pin_index)
10579                || rotation.prior_head_seq >= pin.head_seq
10580        }) {
10581            return Err(invalid_feed(
10582                "rotation feed boundary was not present in the verified chain",
10583            ));
10584        }
10585    }
10586    Ok(())
10587}
10588
10589/// Once a client has checkpointed identity K, identities retired before K may
10590/// verify old history but can never regain authority over a later sequence.
10591/// This is also the safe migration rule for legacy v1 checkpoints that did not
10592/// persist the exact accepted rotation statements.
10593fn reject_retired_signer_after_checkpoint(
10594    identity: &FeedIdentity,
10595    pinned: Option<&TrustState>,
10596    item: &FeedItem,
10597) -> LinkResult<()> {
10598    let Some(pin) = pinned else {
10599        return Ok(());
10600    };
10601    if item.entry.seq <= pin.head_seq {
10602        return Ok(());
10603    }
10604    let mut chain: Vec<String> = identity
10605        .previous
10606        .iter()
10607        .rev()
10608        .map(|previous| format!("ed25519:{}", previous.fingerprint))
10609        .collect();
10610    chain.push(format!("ed25519:{}", identity.fingerprint));
10611    let pinned_index = chain
10612        .iter()
10613        .position(|key| key == &pin.current)
10614        .ok_or_else(|| invalid_feed("pinned identity is absent from the served chain"))?;
10615    let signer_index = chain
10616        .iter()
10617        .position(|key| key == &item.entry.brain)
10618        .ok_or_else(|| invalid_feed("feed signer is absent from the served identity chain"))?;
10619    if signer_index < pinned_index {
10620        return Err(invalid_feed(
10621            "a retired identity attempted to sign after the local checkpoint",
10622        ));
10623    }
10624    Ok(())
10625}
10626
10627fn trust_file_name(cfg: &HubConfig, brain: &str) -> LinkResult<String> {
10628    let origin = normalized_origin(&cfg.hub)?;
10629    let key = format!(
10630        "{:x}",
10631        Sha256::digest(format!("{origin}\0{brain}").as_bytes())
10632    );
10633    Ok(format!("{key}.json"))
10634}
10635
10636fn alias_file_name(cfg: &HubConfig, alias: &str) -> LinkResult<String> {
10637    let origin = normalized_origin(&cfg.hub)?;
10638    let key = format!(
10639        "{:x}",
10640        Sha256::digest(format!("{origin}\0alias\0{alias}").as_bytes())
10641    );
10642    Ok(format!("alias-{key}.json"))
10643}
10644
10645#[cfg(any(unix, windows))]
10646struct TrustLock {
10647    _file: std::fs::File,
10648}
10649
10650#[cfg(unix)]
10651fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
10652    use std::os::fd::{AsRawFd as _, FromRawFd as _};
10653
10654    let lock_string = format!(".{state_name}.lock");
10655    let lock_name = c_name(lock_string.as_bytes(), &lock_string)?;
10656    let fd = unsafe {
10657        libc::openat(
10658            directory.as_raw_fd(),
10659            lock_name.as_ptr(),
10660            libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
10661            0o600,
10662        )
10663    };
10664    if fd < 0 {
10665        return Err(std::io::Error::last_os_error().into());
10666    }
10667    let file = unsafe { std::fs::File::from_raw_fd(fd) };
10668    if !file.metadata()?.is_file() {
10669        return Err(LinkError::UnsafePath { path: lock_string });
10670    }
10671    if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
10672        return Err(std::io::Error::last_os_error().into());
10673    }
10674    Ok(TrustLock { _file: file })
10675}
10676
10677#[cfg(windows)]
10678fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
10679    let lock_name = format!(".{state_name}.lock");
10680    let file = crate::fsx::lock_exclusive_beneath(directory, Path::new(&lock_name))?;
10681    Ok(TrustLock { _file: file })
10682}
10683
10684#[cfg(any(unix, windows))]
10685fn lock_trust_many(
10686    cfg: &HubConfig,
10687    directory: &std::fs::File,
10688    refs: &[&str],
10689) -> LinkResult<Vec<TrustLock>> {
10690    let mut names = refs
10691        .iter()
10692        .map(|reference| trust_file_name(cfg, reference))
10693        .collect::<LinkResult<Vec<_>>>()?;
10694    names.sort();
10695    names.dedup();
10696    names
10697        .iter()
10698        .map(|name| lock_trust_name(directory, name))
10699        .collect()
10700}
10701
10702#[cfg(not(any(unix, windows)))]
10703fn lock_trust_many(
10704    _cfg: &HubConfig,
10705    _directory: &TrustDirectory,
10706    _refs: &[&str],
10707) -> LinkResult<Vec<()>> {
10708    Err(LinkError::UnsupportedPlatform {
10709        operation: "verified link.md state",
10710    })
10711}
10712
10713#[cfg(any(unix, windows))]
10714type TrustDirectory = std::fs::File;
10715
10716#[cfg(not(any(unix, windows)))]
10717struct TrustDirectory;
10718
10719#[cfg(unix)]
10720fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
10721    use std::os::fd::AsRawFd as _;
10722
10723    let directory = open_or_create_dir_nofollow(&cfg.state_dir.join("trust"))?;
10724    if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
10725        return Err(std::io::Error::last_os_error().into());
10726    }
10727    directory.sync_all()?;
10728    Ok(directory)
10729}
10730
10731#[cfg(windows)]
10732fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
10733    let marker = cfg.state_dir.join("trust").join(".directory");
10734    crate::fsx::write_atomic(&marker, b"link.md trust directory\n")?;
10735    Ok(crate::fsx::open_directory_nofollow(
10736        marker.parent().expect("trust marker has a parent"),
10737    )?)
10738}
10739
10740#[cfg(not(any(unix, windows)))]
10741fn open_trust_dir(_cfg: &HubConfig) -> LinkResult<TrustDirectory> {
10742    Err(LinkError::UnsupportedPlatform {
10743        operation: "verified link.md state",
10744    })
10745}
10746
10747#[cfg(unix)]
10748fn load_trust_in(
10749    cfg: &HubConfig,
10750    directory: &TrustDirectory,
10751    requested: &str,
10752) -> LinkResult<Option<TrustState>> {
10753    use std::os::fd::{AsRawFd as _, FromRawFd as _};
10754
10755    let name_string = trust_file_name(cfg, requested)?;
10756    let name = c_name(name_string.as_bytes(), &name_string)?;
10757    let fd = unsafe {
10758        libc::openat(
10759            directory.as_raw_fd(),
10760            name.as_ptr(),
10761            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
10762        )
10763    };
10764    if fd < 0 {
10765        let error = std::io::Error::last_os_error();
10766        if error.kind() == std::io::ErrorKind::NotFound {
10767            return Ok(None);
10768        }
10769        return Err(LinkError::UnsafePath { path: name_string });
10770    }
10771    let file = unsafe { std::fs::File::from_raw_fd(fd) };
10772    if !file.metadata()?.is_file() {
10773        return Err(LinkError::UnsafePath { path: name_string });
10774    }
10775    let mut bytes = Vec::new();
10776    file.take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
10777    if bytes.len() > 1024 * 1024 {
10778        return Err(invalid_feed("local identity/feed checkpoint is oversized"));
10779    }
10780    let mut state: TrustState = serde_json::from_slice(&bytes)
10781        .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
10782    if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
10783        return Err(invalid_feed(
10784            "local identity/feed checkpoint does not match this hub and brain",
10785        ));
10786    }
10787    if state.v == 1 {
10788        // Legacy files were keyed by canonical brain id. They can migrate only
10789        // when the caller used that exact id; old slug lookups had no durable
10790        // alias binding and therefore cannot be guessed safely.
10791        if state.brain != requested {
10792            return Err(invalid_feed(
10793                "legacy checkpoint is not bound to the requested brain id",
10794            ));
10795        }
10796        state.requested = requested.to_string();
10797    } else if state.requested != requested {
10798        return Err(invalid_feed(
10799            "local identity/feed checkpoint is bound to a different requested ref",
10800        ));
10801    }
10802    Ok(Some(state))
10803}
10804
10805#[cfg(windows)]
10806fn load_trust_in(
10807    cfg: &HubConfig,
10808    directory: &TrustDirectory,
10809    requested: &str,
10810) -> LinkResult<Option<TrustState>> {
10811    let name = trust_file_name(cfg, requested)?;
10812    let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
10813    let bytes = match reader.read(Path::new(&name), 1024 * 1024) {
10814        Ok(bytes) => bytes,
10815        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10816        Err(_) => return Err(LinkError::UnsafePath { path: name }),
10817    };
10818    let mut state: TrustState = serde_json::from_slice(&bytes)
10819        .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
10820    if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
10821        return Err(invalid_feed(
10822            "local identity/feed checkpoint does not match this hub and brain",
10823        ));
10824    }
10825    if state.v == 1 {
10826        if state.brain != requested {
10827            return Err(invalid_feed(
10828                "legacy checkpoint is not bound to the requested brain id",
10829            ));
10830        }
10831        state.requested = requested.to_string();
10832    } else if state.requested != requested {
10833        return Err(invalid_feed(
10834            "local identity/feed checkpoint is bound to a different requested ref",
10835        ));
10836    }
10837    Ok(Some(state))
10838}
10839
10840#[cfg(not(any(unix, windows)))]
10841fn load_trust_in(
10842    _cfg: &HubConfig,
10843    _directory: &TrustDirectory,
10844    _brain: &str,
10845) -> LinkResult<Option<TrustState>> {
10846    Err(LinkError::UnsupportedPlatform {
10847        operation: "verified link.md state",
10848    })
10849}
10850
10851#[cfg(all(test, any(unix, windows)))]
10852fn load_trust(cfg: &HubConfig, requested: &str) -> LinkResult<Option<TrustState>> {
10853    let directory = open_trust_dir(cfg)?;
10854    load_trust_in(cfg, &directory, requested)
10855}
10856
10857#[cfg(unix)]
10858fn save_trust_in(
10859    cfg: &HubConfig,
10860    directory: &TrustDirectory,
10861    state: &TrustState,
10862) -> LinkResult<()> {
10863    use std::os::fd::{AsRawFd as _, FromRawFd as _};
10864
10865    let name_string = trust_file_name(cfg, &state.requested)?;
10866    let name = c_name(name_string.as_bytes(), &name_string)?;
10867    let mut bytes = serde_json::to_vec(state)
10868        .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
10869    bytes.push(b'\n');
10870
10871    let nonce = std::time::SystemTime::now()
10872        .duration_since(std::time::UNIX_EPOCH)
10873        .unwrap_or_default()
10874        .as_nanos();
10875    let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
10876    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
10877    let fd = unsafe {
10878        libc::openat(
10879            directory.as_raw_fd(),
10880            temp.as_ptr(),
10881            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
10882            0o600,
10883        )
10884    };
10885    if fd < 0 {
10886        return Err(std::io::Error::last_os_error().into());
10887    }
10888    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
10889    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
10890        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
10891        return Err(error.into());
10892    }
10893    drop(file);
10894    if unsafe {
10895        libc::renameat(
10896            directory.as_raw_fd(),
10897            temp.as_ptr(),
10898            directory.as_raw_fd(),
10899            name.as_ptr(),
10900        )
10901    } != 0
10902    {
10903        let error = std::io::Error::last_os_error();
10904        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
10905        return Err(error.into());
10906    }
10907    directory.sync_all()?;
10908    Ok(())
10909}
10910
10911#[cfg(windows)]
10912fn save_trust_in(
10913    cfg: &HubConfig,
10914    directory: &TrustDirectory,
10915    state: &TrustState,
10916) -> LinkResult<()> {
10917    let name = trust_file_name(cfg, &state.requested)?;
10918    let mut bytes = serde_json::to_vec(state)
10919        .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
10920    bytes.push(b'\n');
10921    crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
10922    Ok(())
10923}
10924
10925#[cfg(not(any(unix, windows)))]
10926fn save_trust_in(
10927    _cfg: &HubConfig,
10928    _directory: &TrustDirectory,
10929    _state: &TrustState,
10930) -> LinkResult<()> {
10931    Err(LinkError::UnsupportedPlatform {
10932        operation: "verified link.md state",
10933    })
10934}
10935
10936#[cfg(unix)]
10937fn load_alias_in(
10938    cfg: &HubConfig,
10939    directory: &TrustDirectory,
10940    requested: &str,
10941) -> LinkResult<Option<AliasBinding>> {
10942    use std::os::fd::{AsRawFd as _, FromRawFd as _};
10943
10944    let name_string = alias_file_name(cfg, requested)?;
10945    let name = c_name(name_string.as_bytes(), &name_string)?;
10946    let fd = unsafe {
10947        libc::openat(
10948            directory.as_raw_fd(),
10949            name.as_ptr(),
10950            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
10951        )
10952    };
10953    if fd < 0 {
10954        let error = std::io::Error::last_os_error();
10955        if error.kind() == std::io::ErrorKind::NotFound {
10956            return Ok(None);
10957        }
10958        return Err(LinkError::UnsafePath { path: name_string });
10959    }
10960    let file = unsafe { std::fs::File::from_raw_fd(fd) };
10961    if !file.metadata()?.is_file() {
10962        return Err(LinkError::UnsafePath { path: name_string });
10963    }
10964    let mut bytes = Vec::new();
10965    file.take(64 * 1024 + 1).read_to_end(&mut bytes)?;
10966    if bytes.len() > 64 * 1024 {
10967        return Err(invalid_feed("local alias binding is oversized"));
10968    }
10969    let alias: AliasBinding = serde_json::from_slice(&bytes)
10970        .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
10971    if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
10972    {
10973        return Err(invalid_feed(
10974            "local alias binding does not match this hub and requested ref",
10975        ));
10976    }
10977    Ok(Some(alias))
10978}
10979
10980#[cfg(windows)]
10981fn load_alias_in(
10982    cfg: &HubConfig,
10983    directory: &TrustDirectory,
10984    requested: &str,
10985) -> LinkResult<Option<AliasBinding>> {
10986    let name = alias_file_name(cfg, requested)?;
10987    let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
10988    let bytes = match reader.read(Path::new(&name), 64 * 1024) {
10989        Ok(bytes) => bytes,
10990        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10991        Err(_) => return Err(LinkError::UnsafePath { path: name }),
10992    };
10993    let alias: AliasBinding = serde_json::from_slice(&bytes)
10994        .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
10995    if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
10996    {
10997        return Err(invalid_feed(
10998            "local alias binding does not match this hub and requested ref",
10999        ));
11000    }
11001    Ok(Some(alias))
11002}
11003
11004#[cfg(not(any(unix, windows)))]
11005fn load_alias_in(
11006    _cfg: &HubConfig,
11007    _directory: &TrustDirectory,
11008    _requested: &str,
11009) -> LinkResult<Option<AliasBinding>> {
11010    Err(LinkError::UnsupportedPlatform {
11011        operation: "verified link.md state",
11012    })
11013}
11014
11015#[cfg(unix)]
11016fn save_alias_in(
11017    cfg: &HubConfig,
11018    directory: &TrustDirectory,
11019    alias: &AliasBinding,
11020) -> LinkResult<()> {
11021    use std::os::fd::{AsRawFd as _, FromRawFd as _};
11022
11023    let name_string = alias_file_name(cfg, &alias.requested)?;
11024    let name = c_name(name_string.as_bytes(), &name_string)?;
11025    let mut bytes = serde_json::to_vec(alias)
11026        .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
11027    bytes.push(b'\n');
11028    let nonce = std::time::SystemTime::now()
11029        .duration_since(std::time::UNIX_EPOCH)
11030        .unwrap_or_default()
11031        .as_nanos();
11032    let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
11033    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
11034    let fd = unsafe {
11035        libc::openat(
11036            directory.as_raw_fd(),
11037            temp.as_ptr(),
11038            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11039            0o600,
11040        )
11041    };
11042    if fd < 0 {
11043        return Err(std::io::Error::last_os_error().into());
11044    }
11045    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
11046    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
11047        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
11048        return Err(error.into());
11049    }
11050    drop(file);
11051    if unsafe {
11052        libc::renameat(
11053            directory.as_raw_fd(),
11054            temp.as_ptr(),
11055            directory.as_raw_fd(),
11056            name.as_ptr(),
11057        )
11058    } != 0
11059    {
11060        let error = std::io::Error::last_os_error();
11061        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
11062        return Err(error.into());
11063    }
11064    directory.sync_all()?;
11065    Ok(())
11066}
11067
11068#[cfg(windows)]
11069fn save_alias_in(
11070    cfg: &HubConfig,
11071    directory: &TrustDirectory,
11072    alias: &AliasBinding,
11073) -> LinkResult<()> {
11074    let name = alias_file_name(cfg, &alias.requested)?;
11075    let mut bytes = serde_json::to_vec(alias)
11076        .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
11077    bytes.push(b'\n');
11078    crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
11079    Ok(())
11080}
11081
11082#[cfg(not(any(unix, windows)))]
11083fn save_alias_in(
11084    _cfg: &HubConfig,
11085    _directory: &TrustDirectory,
11086    _alias: &AliasBinding,
11087) -> LinkResult<()> {
11088    Err(LinkError::UnsupportedPlatform {
11089        operation: "verified link.md state",
11090    })
11091}
11092
11093/// Load the canonical checkpoint shared by every spelling of one brain and
11094/// the separate alias binding. This also performs the one-way migration from
11095/// pre-v3 checkpoints that stored a full trust state under the alias itself.
11096/// Callers hold both alias and canonical locks before entering.
11097fn load_canonical_pin(
11098    cfg: &HubConfig,
11099    directory: &TrustDirectory,
11100    requested: &str,
11101    resolved_brain: &str,
11102) -> LinkResult<(Option<TrustState>, Option<AliasBinding>)> {
11103    let mut canonical = load_trust_in(cfg, directory, resolved_brain)?;
11104    if requested == resolved_brain {
11105        return Ok((canonical, None));
11106    }
11107
11108    let mut alias = load_alias_in(cfg, directory, requested)?;
11109    if let Some(binding) = &alias {
11110        if binding.brain != resolved_brain {
11111            return Err(invalid_feed(
11112                "requested brain alias now resolves to a different canonical brain",
11113            ));
11114        }
11115        return Ok((canonical, alias));
11116    }
11117
11118    // A v2 build stored the complete checkpoint under the requested slug.
11119    // Promote it to the canonical ULID key before creating the lightweight
11120    // alias binding. Never silently merge two independently advanced states.
11121    if let Some(legacy) = load_trust_in(cfg, directory, requested)? {
11122        if legacy.brain != resolved_brain {
11123            return Err(invalid_feed(
11124                "legacy alias checkpoint names a different canonical brain",
11125            ));
11126        }
11127        if let Some(existing) = &canonical {
11128            if existing.brain != legacy.brain
11129                || existing.anchor != legacy.anchor
11130                || existing.current != legacy.current
11131                || existing.head_seq != legacy.head_seq
11132                || existing.feed_hash != legacy.feed_hash
11133                || existing.rotations != legacy.rotations
11134            {
11135                return Err(invalid_feed(
11136                    "legacy alias checkpoint conflicts with the canonical checkpoint",
11137                ));
11138            }
11139        } else {
11140            let mut promoted = legacy.clone();
11141            promoted.requested = resolved_brain.to_string();
11142            promoted.home = None;
11143            save_trust_in(cfg, directory, &promoted)?;
11144            canonical = Some(promoted);
11145        }
11146        alias = Some(AliasBinding {
11147            v: 1,
11148            origin: normalized_origin(&cfg.hub)?,
11149            requested: requested.to_string(),
11150            brain: resolved_brain.to_string(),
11151            home: legacy.home,
11152        });
11153        save_alias_in(cfg, directory, alias.as_ref().expect("alias just created"))?;
11154    }
11155    Ok((canonical, alias))
11156}
11157
11158fn save_canonical_pin_and_alias(
11159    cfg: &HubConfig,
11160    directory: &TrustDirectory,
11161    requested: &str,
11162    resolved_brain: &str,
11163    mut state: TrustState,
11164    existing_alias: Option<&AliasBinding>,
11165) -> LinkResult<()> {
11166    state.requested = resolved_brain.to_string();
11167    state.brain = resolved_brain.to_string();
11168    state.home = None;
11169    save_trust_in(cfg, directory, &state)?;
11170    if requested != resolved_brain {
11171        save_alias_in(
11172            cfg,
11173            directory,
11174            &AliasBinding {
11175                v: 1,
11176                origin: normalized_origin(&cfg.hub)?,
11177                requested: requested.to_string(),
11178                brain: resolved_brain.to_string(),
11179                home: existing_alias.and_then(|alias| alias.home.clone()),
11180            },
11181        )?;
11182    }
11183    Ok(())
11184}
11185
11186fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
11187    const ED25519_SPKI_PREFIX: &[u8] = &[
11188        0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
11189    ];
11190    let entry = &item.entry;
11191    let public_der = URL_SAFE_NO_PAD
11192        .decode(&entry.public_key)
11193        .map_err(|_| invalid_feed("public key is not base64url"))?;
11194    if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
11195        || !public_der.starts_with(ED25519_SPKI_PREFIX)
11196    {
11197        return Err(invalid_feed("entry public key is not a valid Ed25519 SPKI"));
11198    }
11199    let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
11200    if entry.brain != format!("ed25519:{fingerprint}") {
11201        return Err(invalid_feed(
11202            "brain fingerprint does not match its public key",
11203        ));
11204    }
11205    // Verify the complete rotation authority before using any previous key.
11206    let _ = verify_identity_chain(identity, None)?;
11207    let mut chain: Vec<(&str, &str)> = identity
11208        .previous
11209        .iter()
11210        .rev()
11211        .map(|previous| {
11212            (
11213                previous.fingerprint.as_str(),
11214                previous.public_key_spki.as_str(),
11215            )
11216        })
11217        .collect();
11218    chain.push((&identity.fingerprint, &identity.public_key_spki));
11219    let signer_index = chain.iter().position(|(known_fingerprint, spki)| {
11220        *known_fingerprint == fingerprint && *spki == entry.public_key
11221    });
11222    let Some(signer_index) = signer_index else {
11223        return Err(invalid_feed(
11224            "entry signer is not this brain's identity (current or rotated-from)",
11225        ));
11226    };
11227    let lower_boundary = if signer_index == 0 {
11228        None
11229    } else {
11230        let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
11231            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
11232        Some(prior.prior_head_seq)
11233    };
11234    let upper_boundary = if signer_index == identity.rotations.len() {
11235        None
11236    } else {
11237        let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
11238            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
11239        Some(next.prior_head_seq)
11240    };
11241    if lower_boundary.is_some_and(|boundary| entry.seq <= boundary)
11242        || upper_boundary.is_some_and(|boundary| entry.seq > boundary)
11243    {
11244        return Err(invalid_feed(
11245            "entry signer is outside its authenticated rotation epoch",
11246        ));
11247    }
11248    let unsigned = UnsignedFeedEntry {
11249        v: entry.v,
11250        seq: entry.seq,
11251        ts: &entry.ts,
11252        brain: &entry.brain,
11253        public_key: &entry.public_key,
11254        kind: &entry.kind,
11255        op: &entry.op,
11256        pack_sha256: &entry.pack_sha256,
11257        files: &entry.files,
11258        removed: &entry.removed,
11259        prev_entry_hash: &entry.prev_entry_hash,
11260    };
11261    let message =
11262        serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
11263    let signature = URL_SAFE_NO_PAD
11264        .decode(&entry.sig)
11265        .map_err(|_| invalid_feed("signature is not base64url"))?;
11266    UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
11267        .verify(&message, &signature)
11268        .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
11269
11270    let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
11271    exact.push(b'\n');
11272    let actual_hash = format!("{:x}", Sha256::digest(&exact));
11273    if actual_hash != item.hash {
11274        return Err(invalid_feed("entry SHA-256 does not match"));
11275    }
11276    Ok(())
11277}
11278
11279// ─────────────────────────────────────────────────────────────────────────────
11280// key rotation — link.md §9.1: the new key, signed by the old one
11281// ─────────────────────────────────────────────────────────────────────────────
11282
11283/// The unsigned rotation statement in its normative field order.
11284#[derive(Serialize)]
11285struct UnsignedRotation<'a> {
11286    v: u8,
11287    op: &'a str,
11288    brain: &'a str,
11289    public_key: &'a str,
11290    new_brain: &'a str,
11291    new_public_key: &'a str,
11292    prior_head_seq: u64,
11293    prior_feed_hash: Option<&'a str>,
11294    ts: String,
11295}
11296
11297/// Durable intent for an in-flight key rotation. The hub's recovery contract
11298/// identifies an ambiguous retry by the exact statement bytes, so the
11299/// statement cannot be reconstructed from the key and feed boundary later:
11300/// its timestamp and signature would differ.
11301#[derive(Debug, Deserialize, Serialize)]
11302#[serde(deny_unknown_fields)]
11303struct RotationJournal {
11304    v: u8,
11305    origin: String,
11306    brain: String,
11307    old_brain: String,
11308    new_brain: String,
11309    prior_head_seq: u64,
11310    prior_feed_hash: Option<String>,
11311    statement: String,
11312}
11313
11314fn rotation_journal_path(key_path: &Path) -> PathBuf {
11315    let mut path = key_path.as_os_str().to_os_string();
11316    path.push(".rotation.json");
11317    PathBuf::from(path)
11318}
11319
11320fn read_rotation_journal(path: &Path) -> LinkResult<RotationJournal> {
11321    #[cfg(unix)]
11322    let file = {
11323        use std::os::fd::{AsRawFd as _, FromRawFd as _};
11324        use std::os::unix::ffi::OsStrExt as _;
11325        let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
11326            .map_err(|error| {
11327                bad_agent_key(&format!("cannot open the rotation journal parent: {error}"))
11328            })?;
11329        let leaf_name = path
11330            .file_name()
11331            .ok_or_else(|| bad_agent_key("the rotation journal path has no file name"))?;
11332        let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
11333        let fd = unsafe {
11334            libc::openat(
11335                parent.as_raw_fd(),
11336                leaf.as_ptr(),
11337                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11338            )
11339        };
11340        if fd < 0 {
11341            return Err(bad_agent_key(
11342                "the rotation journal must be an existing regular file without symlink ancestors",
11343            ));
11344        }
11345        unsafe { std::fs::File::from_raw_fd(fd) }
11346    };
11347    #[cfg(not(unix))]
11348    let file = std::fs::File::open(path)
11349        .map_err(|error| bad_agent_key(&format!("cannot read the rotation journal: {error}")))?;
11350    let metadata = file
11351        .metadata()
11352        .map_err(|error| bad_agent_key(&format!("cannot inspect the rotation journal: {error}")))?;
11353    if !metadata.is_file() || metadata.len() > MAX_REGISTRY_CARD_BYTES {
11354        return Err(bad_agent_key(
11355            "the rotation journal must be a bounded regular file",
11356        ));
11357    }
11358    #[cfg(unix)]
11359    {
11360        use std::os::unix::fs::PermissionsExt as _;
11361        if metadata.permissions().mode() & 0o077 != 0 {
11362            return Err(bad_agent_key(
11363                "the rotation journal is accessible to group/other; set mode 0600",
11364            ));
11365        }
11366    }
11367    serde_json::from_reader(file)
11368        .map_err(|_| bad_agent_key("the rotation journal is not valid exact JSON"))
11369}
11370
11371fn remove_rotation_journal(path: &Path) {
11372    #[cfg(unix)]
11373    {
11374        use std::os::fd::AsRawFd as _;
11375        use std::os::unix::ffi::OsStrExt as _;
11376        let Ok(parent) =
11377            open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
11378        else {
11379            return;
11380        };
11381        let Some(leaf_name) = path.file_name() else {
11382            return;
11383        };
11384        let Ok(leaf) = c_name(leaf_name.as_bytes(), &path.display().to_string()) else {
11385            return;
11386        };
11387        if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), 0) } == 0 {
11388            let _ = parent.sync_all();
11389        }
11390    }
11391    #[cfg(not(unix))]
11392    {
11393        let _ = std::fs::remove_file(path);
11394    }
11395}
11396
11397fn validate_rotation_journal(
11398    journal: &RotationJournal,
11399    cfg: &HubConfig,
11400    canonical_brain: &str,
11401    old_key: &AgentSigningKey,
11402    new_key: &AgentSigningKey,
11403    head: &Head,
11404) -> LinkResult<()> {
11405    if journal.v != 1
11406        || journal.origin != normalized_origin(&cfg.hub)?
11407        || journal.brain != canonical_brain
11408        || journal.old_brain != old_key.multikey
11409        || journal.new_brain != new_key.multikey
11410        || journal.prior_head_seq != head.seq
11411        || journal.prior_feed_hash != head.feed_hash
11412    {
11413        return Err(invalid_feed(
11414            "rotation journal does not match the verified key and feed boundary",
11415        ));
11416    }
11417    let statement: RotationStatement = serde_json::from_str(&journal.statement)
11418        .map_err(|_| invalid_feed("rotation journal statement did not parse exactly"))?;
11419    if statement.prior_head_seq != journal.prior_head_seq
11420        || statement.prior_feed_hash != journal.prior_feed_hash
11421        || statement.brain != old_key.multikey
11422        || statement.public_key != old_key.public_key_spki
11423        || statement.new_brain != new_key.multikey
11424        || statement.new_public_key != new_key.public_key_spki
11425    {
11426        return Err(invalid_feed(
11427            "rotation journal statement does not match its durable intent",
11428        ));
11429    }
11430    let identity = FeedIdentity {
11431        fingerprint: new_key.multikey.trim_start_matches("ed25519:").to_string(),
11432        public_key_spki: new_key.public_key_spki.clone(),
11433        previous: vec![PreviousIdentity {
11434            fingerprint: old_key.multikey.trim_start_matches("ed25519:").to_string(),
11435            public_key_spki: old_key.public_key_spki.clone(),
11436        }],
11437        rotations: vec![journal.statement.clone()],
11438    };
11439    verify_identity_chain(&identity, None)?;
11440    Ok(())
11441}
11442
11443/// What `dbmd key rotate` returns.
11444#[derive(Debug, Serialize)]
11445pub struct RotationReport {
11446    /// The brain id rotated.
11447    pub brain: String,
11448    /// The NEW identity the hub now serves.
11449    pub multikey: String,
11450    /// Where the new PKCS#8 secret landed (0600).
11451    #[serde(rename = "keyFile")]
11452    pub key_file: String,
11453    /// Prior identities (newest first) the feed still verifies against.
11454    pub previous: Vec<String>,
11455}
11456
11457/// Rotate a self-custodied brain's key: mint a fresh keypair, build the
11458/// §9.1 statement — the new key plus exact prior feed boundary, signed by the
11459/// OLD key in normative serialization — and send it to the hub. The new secret
11460/// is durably created at 0600 before the POST; an existing output is reused for
11461/// idempotent retry/reconciliation. The old key is left untouched.
11462pub fn rotate_brain_key(
11463    cfg: &HubConfig,
11464    brain: &str,
11465    old_key: &AgentSigningKey,
11466    out: &Path,
11467) -> LinkResult<RotationReport> {
11468    require_hardened_filesystem("key rotation")?;
11469    require_safe_ref(brain)?;
11470    // The new private key must be durable *before* the hub can accept its
11471    // public half. An existing file is the retry/reconciliation path after an
11472    // ambiguous network failure: reuse it, never generate another identity.
11473    let new_key = if out.exists() {
11474        load_signing_key(out)?
11475    } else {
11476        let rng = ring::rand::SystemRandom::new();
11477        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
11478            .map_err(|_| bad_agent_key("key generation failed"))?;
11479        let pair = agent_keypair(pkcs8.as_ref())?;
11480        let (public_key_spki, multikey) = public_identity_for(&pair);
11481        write_secret_new(
11482            out,
11483            format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
11484        )?;
11485        AgentSigningKey {
11486            pkcs8: pkcs8.as_ref().to_vec(),
11487            multikey,
11488            public_key_spki,
11489        }
11490    };
11491    let new_spki = new_key.public_key_spki.clone();
11492    let new_multikey = new_key.multikey.clone();
11493    let journal_path = rotation_journal_path(out);
11494    let before = verified_remote_head(cfg, brain, false)?;
11495    let served_identity = before
11496        .identity
11497        .as_ref()
11498        .ok_or_else(|| invalid_feed("cannot rotate a brain with no signed identity"))?;
11499    let served_multikey = format!("ed25519:{}", served_identity.fingerprint);
11500    if served_multikey == new_multikey {
11501        remove_rotation_journal(&journal_path);
11502        return Ok(RotationReport {
11503            brain: brain.to_string(),
11504            multikey: new_multikey,
11505            key_file: out.display().to_string(),
11506            previous: served_identity
11507                .previous
11508                .iter()
11509                .map(|identity| format!("ed25519:{}", identity.fingerprint))
11510                .collect(),
11511        });
11512    }
11513    if served_multikey != old_key.multikey {
11514        return Err(invalid_feed(
11515            "the supplied old key is not the brain's verified current identity",
11516        ));
11517    }
11518
11519    let journal = if journal_path.exists() {
11520        read_rotation_journal(&journal_path)?
11521    } else {
11522        let ts = crate::now()
11523            .with_timezone(&chrono::Utc)
11524            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
11525            .to_string();
11526        let unsigned = serde_json::to_string(&UnsignedRotation {
11527            v: 1,
11528            op: "rotate",
11529            brain: &old_key.multikey,
11530            public_key: &old_key.public_key_spki,
11531            new_brain: &new_multikey,
11532            new_public_key: &new_spki,
11533            prior_head_seq: before.head.seq,
11534            prior_feed_hash: before.head.feed_hash.as_deref(),
11535            ts,
11536        })
11537        .expect("serialize rotation");
11538        let old_pair = agent_keypair(&old_key.pkcs8)?;
11539        let sig = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
11540        let statement = format!("{},\"sig\":\"{}\"}}", &unsigned[..unsigned.len() - 1], sig);
11541        let journal = RotationJournal {
11542            v: 1,
11543            origin: normalized_origin(&cfg.hub)?,
11544            brain: before.head.brain.clone(),
11545            old_brain: old_key.multikey.clone(),
11546            new_brain: new_multikey.clone(),
11547            prior_head_seq: before.head.seq,
11548            prior_feed_hash: before.head.feed_hash.clone(),
11549            statement,
11550        };
11551        let mut exact = serde_json::to_vec(&journal)
11552            .map_err(|_| invalid_feed("could not serialize rotation journal"))?;
11553        exact.push(b'\n');
11554        if write_secret_new(&journal_path, &exact).is_err() {
11555            // A concurrent retry may have won the O_EXCL race. Only an exact,
11556            // fully validated journal is allowed to recover that race.
11557            read_rotation_journal(&journal_path)?
11558        } else {
11559            journal
11560        }
11561    };
11562    validate_rotation_journal(
11563        &journal,
11564        cfg,
11565        &before.head.brain,
11566        old_key,
11567        &new_key,
11568        &before.head,
11569    )?;
11570
11571    let body = json!({ "statement": journal.statement });
11572    let path = format!("/api/hub/brains/{brain}/rotate");
11573    let attempted = request(cfg, "POST", &path, Some(&body), Auth::Required);
11574    let attempted_failure = match attempted {
11575        Ok(response) if (200..300).contains(&response.status) => None,
11576        Ok(response) => Some(ensure_ok(response, "key rotate").unwrap_err()),
11577        Err(error) => Some(error),
11578    };
11579
11580    // A 2xx body is not authority, and a failed response may have followed a
11581    // committed mutation. In both cases success means the normal verifier sees
11582    // an append-only rotation chain ending at the durable new key.
11583    let after = match verified_remote_head(cfg, brain, false) {
11584        Ok(after) => after,
11585        Err(error) => return Err(attempted_failure.unwrap_or(error)),
11586    };
11587    let identity = after
11588        .identity
11589        .as_ref()
11590        .ok_or_else(|| invalid_feed("rotated brain has no verified identity"))?;
11591    if format!("ed25519:{}", identity.fingerprint) != new_multikey
11592        || identity.public_key_spki != new_spki
11593    {
11594        return Err(attempted_failure.unwrap_or_else(|| {
11595            invalid_feed("hub acknowledged rotation without committing the verified new identity")
11596        }));
11597    }
11598    let previous = identity
11599        .previous
11600        .iter()
11601        .map(|prior| format!("ed25519:{}", prior.fingerprint))
11602        .collect();
11603    remove_rotation_journal(&journal_path);
11604
11605    Ok(RotationReport {
11606        brain: brain.to_string(),
11607        multikey: new_multikey,
11608        key_file: out.display().to_string(),
11609        previous,
11610    })
11611}
11612
11613// ─────────────────────────────────────────────────────────────────────────────
11614// mirror — verified replication: the whole feed + files, re-servable
11615// ─────────────────────────────────────────────────────────────────────────────
11616
11617/// What `dbmd mirror` materialized.
11618#[derive(Debug, Serialize)]
11619pub struct MirrorReport {
11620    /// The brain id.
11621    pub brain: String,
11622    /// The mirrored feed head.
11623    #[serde(rename = "headSeq")]
11624    pub head_seq: u64,
11625    /// The head entry hash (the feed's advertised converged state).
11626    #[serde(rename = "feedHash")]
11627    pub feed_hash: Option<String>,
11628    /// Signed feed entries verified and stored.
11629    pub entries: u64,
11630    /// The brain's multikey, pinned in `.dbmd/config` (TOFU).
11631    pub pinned: String,
11632    /// Store files materialized by the pull.
11633    pub files: usize,
11634}
11635
11636/// The mirror state directory, relative to the mirror root.
11637pub const MIRROR_REL_DIR: &str = ".dbmd/mirror";
11638
11639/// Fully re-verified mirror material suitable for a read-only re-server.
11640#[derive(Debug)]
11641pub struct VerifiedMirrorMaterial {
11642    pub brain: String,
11643    pub head_seq: u64,
11644    pub feed_hash: Option<String>,
11645    pub identity: serde_json::Value,
11646    /// Sequence, exact normative entry JSON without newline, and entry hash.
11647    pub entries: Vec<(u64, String, String)>,
11648    pub pack_sha256: Option<String>,
11649}
11650
11651#[derive(Deserialize)]
11652#[serde(deny_unknown_fields)]
11653struct StoredMirrorHead {
11654    brain: String,
11655    #[serde(rename = "headSeq")]
11656    head_seq: u64,
11657    #[serde(rename = "feedHash")]
11658    feed_hash: Option<String>,
11659}
11660
11661/// Re-verify mirror metadata, every signature/hash/rotation boundary, and the
11662/// exact snapshot pack before `dbmd serve` exposes any bytes.
11663pub fn verify_mirror_material(
11664    head_bytes: &[u8],
11665    identity_bytes: &[u8],
11666    feed_bytes: &[Vec<u8>],
11667    snapshot_pack: Option<&[u8]>,
11668    expected_anchor: &str,
11669) -> LinkResult<VerifiedMirrorMaterial> {
11670    let snapshot_hash = snapshot_pack
11671        .filter(|pack| !pack.is_empty())
11672        .map(content_sha256);
11673    verify_mirror_material_with_pack_hash(
11674        head_bytes,
11675        identity_bytes,
11676        feed_bytes,
11677        snapshot_hash.as_deref(),
11678        expected_anchor,
11679    )
11680}
11681
11682/// Re-verify mirror metadata against a snapshot digest computed from a held
11683/// no-follow file capability. This lets `dbmd serve` authenticate and retain a
11684/// large pack without ever buffering the pack in process memory.
11685pub fn verify_mirror_material_with_pack_hash(
11686    head_bytes: &[u8],
11687    identity_bytes: &[u8],
11688    feed_bytes: &[Vec<u8>],
11689    snapshot_pack_sha256: Option<&str>,
11690    expected_anchor: &str,
11691) -> LinkResult<VerifiedMirrorMaterial> {
11692    let head: StoredMirrorHead = serde_json::from_slice(head_bytes)
11693        .map_err(|_| invalid_feed("stored mirror head did not parse exactly"))?;
11694    require_safe_ref(&head.brain)?;
11695    if head.head_seq > MAX_FEED_REPLAY_ENTRIES || feed_bytes.len() as u64 != head.head_seq {
11696        return Err(invalid_feed(
11697            "stored mirror feed count does not match its bounded head sequence",
11698        ));
11699    }
11700    let aggregate = feed_bytes
11701        .iter()
11702        .try_fold(0u64, |total, bytes| total.checked_add(bytes.len() as u64))
11703        .ok_or_else(|| invalid_feed("stored mirror feed size overflow"))?;
11704    if aggregate > MAX_FEED_REPLAY_BYTES {
11705        return Err(invalid_feed(
11706            "stored mirror feed metadata exceeds the aggregate limit",
11707        ));
11708    }
11709    let identity: FeedIdentity = serde_json::from_slice(identity_bytes)
11710        .map_err(|_| invalid_feed("stored mirror identity did not parse exactly"))?;
11711    let anchor = verify_identity_chain(&identity, None)?;
11712    if anchor != expected_anchor {
11713        return Err(invalid_feed(
11714            "stored mirror identity does not descend from the explicitly trusted anchor",
11715        ));
11716    }
11717
11718    let mut entries = Vec::with_capacity(feed_bytes.len());
11719    let mut items = Vec::with_capacity(feed_bytes.len());
11720    let mut previous_hash = None;
11721    let mut pack_sha256 = None;
11722    for (index, bytes) in feed_bytes.iter().enumerate() {
11723        let exact = bytes
11724            .strip_suffix(b"\n")
11725            .ok_or_else(|| invalid_feed("stored feed entry lacks its exact trailing newline"))?;
11726        if exact.ends_with(b"\n") {
11727            return Err(invalid_feed("stored feed entry has extra trailing bytes"));
11728        }
11729        let entry: FeedEntry = serde_json::from_slice(exact)
11730            .map_err(|_| invalid_feed("stored feed entry did not parse exactly"))?;
11731        let expected_seq = index as u64 + 1;
11732        if entry.seq != expected_seq || entry.prev_entry_hash != previous_hash {
11733            return Err(invalid_feed(
11734                "stored mirror feed is not contiguous and hash-chained",
11735            ));
11736        }
11737        let canonical = serde_json::to_vec(&entry)
11738            .map_err(|_| invalid_feed("could not canonicalize stored feed entry"))?;
11739        if canonical != exact {
11740            return Err(invalid_feed(
11741                "stored feed entry is not in normative serialization",
11742            ));
11743        }
11744        let hash = content_sha256(bytes);
11745        let item = FeedItem {
11746            hash: hash.clone(),
11747            entry,
11748        };
11749        verify_feed_item(&item, &identity)?;
11750        previous_hash = Some(hash.clone());
11751        if expected_seq == head.head_seq {
11752            pack_sha256 = Some(item.entry.pack_sha256.clone());
11753        }
11754        entries.push((
11755            expected_seq,
11756            std::str::from_utf8(exact)
11757                .map_err(|_| invalid_feed("stored feed entry is not UTF-8"))?
11758                .to_string(),
11759            hash,
11760        ));
11761        items.push(item);
11762    }
11763    if previous_hash != head.feed_hash {
11764        return Err(invalid_feed(
11765            "stored mirror feed does not converge on its advertised head",
11766        ));
11767    }
11768    verify_rotation_feed_boundaries(&identity, None, &items, head.head_seq)?;
11769    match (head.head_seq, snapshot_pack_sha256, pack_sha256.as_deref()) {
11770        (0, None, None) => {}
11771        (_, Some(actual), Some(expected)) if actual == expected => {}
11772        _ => {
11773            return Err(LinkError::InvalidPack {
11774                message: "stored snapshot pack does not match the signed head digest".to_string(),
11775            });
11776        }
11777    }
11778    let identity_value = serde_json::to_value(&identity)
11779        .map_err(|_| invalid_feed("could not serialize verified mirror identity"))?;
11780    Ok(VerifiedMirrorMaterial {
11781        brain: head.brain,
11782        head_seq: head.head_seq,
11783        feed_hash: head.feed_hash,
11784        identity: identity_value,
11785        entries,
11786        pack_sha256,
11787    })
11788}
11789
11790/// SHA-256 hex of one feed entry's stored bytes (`exact JSON + "\n"`) — the
11791/// entry hash every consumer recomputes (SPEC §5.3).
11792pub fn feed_entry_hash(exact_sans_newline: &str) -> String {
11793    format!(
11794        "{:x}",
11795        Sha256::digest(format!("{exact_sans_newline}\n").as_bytes())
11796    )
11797}
11798
11799/// SHA-256 hex for content a signed manifest names. Exposed for the thin
11800/// `dbmd serve` adapter; cryptographic verification remains centralized here.
11801pub fn content_sha256(bytes: &[u8]) -> String {
11802    format!("{:x}", Sha256::digest(bytes))
11803}
11804
11805/// SHA-256 a stream with a fixed-size working buffer.
11806pub fn content_sha256_reader(mut reader: impl Read) -> std::io::Result<String> {
11807    let mut digest = Sha256::new();
11808    let mut buffer = [0u8; 64 * 1024];
11809    loop {
11810        let read = reader.read(&mut buffer)?;
11811        if read == 0 {
11812            break;
11813        }
11814        digest.update(&buffer[..read]);
11815    }
11816    Ok(format!("{:x}", digest.finalize()))
11817}
11818
11819/// Replicate a brain with full verification (link.md §5.4 over the WHOLE
11820/// chain, not just the head): every entry's signature, hash, sequence
11821/// contiguity, prev-hash linkage, rotation chain, and exact signed pack are
11822/// checked in a sibling staging directory. Only then is the old mirror swapped
11823/// out through an atomic directory exchange. Every stage, install, and cleanup
11824/// operation is relative to one held no-follow parent-directory capability, so
11825/// renaming an ancestor cannot redirect any write or deletion.
11826#[cfg_attr(windows, allow(unreachable_code, unused_variables))]
11827pub fn mirror(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<MirrorReport> {
11828    require_hardened_filesystem("mirror")?;
11829    require_safe_ref(brain)?;
11830    #[cfg(windows)]
11831    {
11832        let _ = (cfg, dest);
11833        return Err(LinkError::UnsupportedPlatform {
11834            operation: "atomic whole-mirror replacement on Windows",
11835        });
11836    }
11837    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
11838    let name = dest
11839        .file_name()
11840        .and_then(|name| name.to_str())
11841        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
11842        .ok_or_else(|| LinkError::UnsafePath {
11843            path: dest.display().to_string(),
11844        })?;
11845    #[cfg(unix)]
11846    let parent_dir = open_or_create_dir_nofollow(parent)?;
11847    #[cfg(unix)]
11848    use std::os::fd::AsRawFd as _;
11849    #[cfg(unix)]
11850    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
11851    #[cfg(unix)]
11852    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
11853        None => false,
11854        Some(true) => true,
11855        Some(false) => {
11856            return Err(LinkError::UnsafePath {
11857                path: dest.display().to_string(),
11858            });
11859        }
11860    };
11861
11862    // A fixed backup was used by pre-hardening builds. Never interpret or
11863    // delete an attacker-planted entry at that name; require manual recovery.
11864    #[cfg(unix)]
11865    let legacy_backup_name = c_name(
11866        format!(".{name}.dbmd-backup").as_bytes(),
11867        &dest.display().to_string(),
11868    )?;
11869    #[cfg(unix)]
11870    if entry_is_dir_at(parent_dir.as_raw_fd(), &legacy_backup_name)?.is_some() {
11871        return Err(LinkError::UnsafePath {
11872            path: parent
11873                .join(format!(".{name}.dbmd-backup"))
11874                .display()
11875                .to_string(),
11876        });
11877    }
11878
11879    let nonce = std::time::SystemTime::now()
11880        .duration_since(std::time::UNIX_EPOCH)
11881        .unwrap_or_default()
11882        .as_nanos();
11883    let stage_label = format!(".{name}.dbmd-stage-{}-{nonce}", std::process::id());
11884    #[cfg(unix)]
11885    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
11886    #[cfg(unix)]
11887    let stage_dir = create_dir_exclusive_at(
11888        parent_dir.as_raw_fd(),
11889        &stage_name,
11890        &dest.display().to_string(),
11891    )?;
11892
11893    let assembled = (|| -> LinkResult<MirrorReport> {
11894        let remote = verified_remote_head(cfg, brain, true)?;
11895        let brain_id = remote.head.brain.clone();
11896        let identity = remote
11897            .identity
11898            .as_ref()
11899            .ok_or_else(|| invalid_feed("brain has no signed identity"))?;
11900        let anchor = remote
11901            .anchor
11902            .clone()
11903            .ok_or_else(|| invalid_feed("brain has no verified identity anchor"))?;
11904        let pack = download_verified_snapshot_pack(cfg, &brain_id, &remote)?;
11905        let snapshot_entries = parse_store_pack(pack.clone())?;
11906        let snapshot_count = snapshot_entries.len();
11907        let mut staged_entries = snapshot_entries;
11908        staged_entries.push((format!("{MIRROR_REL_DIR}/snapshot.pack"), pack));
11909        for item in &remote.entries {
11910            let mut exact = serde_json::to_vec(&item.entry)
11911                .map_err(|_| invalid_feed("could not serialize signed feed entry"))?;
11912            exact.push(b'\n');
11913            if format!("{:x}", Sha256::digest(&exact)) != item.hash {
11914                return Err(invalid_feed(
11915                    "serialized mirror entry differs from its verified hash",
11916                ));
11917            }
11918            staged_entries.push((
11919                format!("{MIRROR_REL_DIR}/feed/{}.json", item.entry.seq),
11920                exact,
11921            ));
11922        }
11923        let mut identity_bytes = serde_json::to_vec(identity)
11924            .map_err(|_| invalid_feed("could not serialize verified identity chain"))?;
11925        identity_bytes.push(b'\n');
11926        staged_entries.push((format!("{MIRROR_REL_DIR}/identity.json"), identity_bytes));
11927        let mut head_bytes = serde_json::to_vec(&json!({
11928            "brain": brain_id,
11929            "headSeq": remote.head.seq,
11930            "feedHash": remote.head.feed_hash,
11931        }))
11932        .map_err(|_| invalid_feed("could not serialize verified mirror head"))?;
11933        head_bytes.push(b'\n');
11934        staged_entries.push((format!("{MIRROR_REL_DIR}/head.json"), head_bytes));
11935        staged_entries.push((
11936            CONFIG_REL_PATH.to_string(),
11937            format!("hub = {}\npin = {anchor}\n", cfg.hub).into_bytes(),
11938        ));
11939        #[cfg(unix)]
11940        write_pull_entries_beneath_dir(&stage_dir, &staged_entries)?;
11941
11942        Ok(MirrorReport {
11943            brain: brain_id,
11944            head_seq: remote.head.seq,
11945            feed_hash: remote.head.feed_hash,
11946            entries: remote.entries.len() as u64,
11947            pinned: anchor,
11948            files: snapshot_count,
11949        })
11950    })();
11951
11952    let report = match assembled {
11953        Ok(report) => report,
11954        Err(error) => {
11955            #[cfg(unix)]
11956            let _ = remove_tree_at(
11957                parent_dir.as_raw_fd(),
11958                &stage_name,
11959                &dest.display().to_string(),
11960            );
11961            return Err(error);
11962        }
11963    };
11964
11965    #[cfg(unix)]
11966    if let Err(error) =
11967        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
11968    {
11969        let _ = remove_tree_at(
11970            parent_dir.as_raw_fd(),
11971            &stage_name,
11972            &dest.display().to_string(),
11973        );
11974        return Err(error);
11975    }
11976    // An exchange leaves the old mirror at the unique stage name. Cleanup is
11977    // capability-relative and never follows symlinks in hostile old content.
11978    #[cfg(unix)]
11979    if dest_exists {
11980        remove_tree_at(
11981            parent_dir.as_raw_fd(),
11982            &stage_name,
11983            &dest.display().to_string(),
11984        )?;
11985    }
11986    #[cfg(unix)]
11987    parent_dir.sync_all()?;
11988    Ok(report)
11989}
11990
11991fn verified_remote_head(
11992    cfg: &HubConfig,
11993    brain: &str,
11994    require_full_chain: bool,
11995) -> LinkResult<VerifiedRemote> {
11996    require_hardened_filesystem("verified link.md state")?;
11997    require_safe_ref(brain)?;
11998    // Refuse an unsafe state root before sending credentials or consulting an
11999    // untrusted card, and retain this exact directory inode for the complete
12000    // checkpoint transaction.
12001    let trust_directory = open_trust_dir(cfg)?;
12002    let path = format!("/api/hub/brains/{brain}");
12003    let body = ensure_ok(
12004        request(cfg, "GET", &path, None, Auth::Required)?,
12005        "subscribe",
12006    )?;
12007    let resolved_brain = body
12008        .get("id")
12009        .and_then(Value::as_str)
12010        .filter(|id| crate::ulid::is_ulid(id))
12011        .ok_or_else(|| invalid_feed("brain card has no canonical ULID id"))?
12012        .to_string();
12013    if crate::ulid::is_ulid(brain) && resolved_brain != brain {
12014        return Err(invalid_feed(
12015            "brain card id differs from the explicitly requested brain id",
12016        ));
12017    }
12018    // The card supplies the canonical ULID. Lock alias + canonical keys in
12019    // deterministic filename order, then hold both through verify + save.
12020    // Concurrent aliases for the same brain therefore converge on one
12021    // checkpoint instead of establishing independent TOFU universes.
12022    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &resolved_brain])?;
12023    let (pinned, alias_binding) =
12024        load_canonical_pin(cfg, &trust_directory, brain, &resolved_brain)?;
12025    let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
12026    let advertised_hash = body
12027        .get("feedHash")
12028        .and_then(Value::as_str)
12029        .map(str::to_string);
12030    let updated_at = body
12031        .get("updatedAt")
12032        .and_then(Value::as_str)
12033        .map(str::to_string);
12034    if let Some(pin) = &pinned {
12035        if seq < pin.head_seq {
12036            return Err(invalid_feed(format!(
12037                "feed rollback: hub advertised sequence {seq}, local checkpoint is {}",
12038                pin.head_seq
12039            )));
12040        }
12041        if seq == pin.head_seq && advertised_hash != pin.feed_hash {
12042            return Err(invalid_feed(
12043                "feed equivocation: the checkpoint sequence now has a different hash",
12044            ));
12045        }
12046    }
12047    if seq == 0 {
12048        if advertised_hash.is_some() {
12049            return Err(invalid_feed("an empty feed advertised a head hash"));
12050        }
12051        let identity: FeedIdentity = serde_json::from_value(
12052            body.get("identity")
12053                .cloned()
12054                .ok_or_else(|| invalid_feed("empty brain card has no signed identity"))?,
12055        )
12056        .map_err(|_| invalid_feed("empty brain card has an invalid identity"))?;
12057        let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
12058        // A valid old-key-signed rotation is still inconsistent if it commits
12059        // to history that the same card now claims never existed. Run the
12060        // identical feed-boundary proof used by non-empty heads before this
12061        // identity can become a durable TOFU checkpoint.
12062        verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], seq)?;
12063        save_canonical_pin_and_alias(
12064            cfg,
12065            &trust_directory,
12066            brain,
12067            &resolved_brain,
12068            TrustState {
12069                v: 2,
12070                origin: normalized_origin(&cfg.hub)?,
12071                requested: resolved_brain.clone(),
12072                brain: resolved_brain.clone(),
12073                home: None,
12074                anchor: anchor.clone(),
12075                current: format!("ed25519:{}", identity.fingerprint),
12076                head_seq: 0,
12077                feed_hash: None,
12078                rotations: identity.rotations.clone(),
12079                hub_signer: None,
12080                protocol_profile: None,
12081            },
12082            alias_binding.as_ref(),
12083        )?;
12084        return Ok(VerifiedRemote {
12085            head: Head {
12086                brain: resolved_brain,
12087                seq,
12088                updated_at,
12089                feed_hash: None,
12090                verified: true,
12091            },
12092            identity: Some(identity),
12093            head_entry: None,
12094            entries: Vec::new(),
12095            anchor: Some(anchor),
12096        });
12097    }
12098    if advertised_hash.as_ref().is_none_or(|hash| !is_sha256(hash)) {
12099        return Err(invalid_feed(
12100            "non-empty feed did not advertise a valid SHA-256 head",
12101        ));
12102    }
12103
12104    // On first contact the signed head itself is the TOFU checkpoint. A full
12105    // history replay cannot add authority before an anchor exists; mirrors
12106    // still request the complete chain because they promise an archival copy.
12107    let replay_head_only = !require_full_chain
12108        && pinned
12109            .as_ref()
12110            .is_none_or(|checkpoint| checkpoint.head_seq == seq);
12111    let mut after = if replay_head_only {
12112        seq - 1
12113    } else if require_full_chain || pinned.is_none() {
12114        0
12115    } else {
12116        pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq)
12117    };
12118    let mut expected_seq = after + 1;
12119    let mut previous_hash = if require_full_chain || pinned.is_none() || replay_head_only {
12120        None
12121    } else {
12122        pinned
12123            .as_ref()
12124            .and_then(|checkpoint| checkpoint.feed_hash.clone())
12125    };
12126    let mut identity: Option<FeedIdentity> = None;
12127    let mut anchor: Option<String> = None;
12128    let mut head_entry: Option<FeedItem> = None;
12129    let mut all_entries = Vec::new();
12130    let mut observed_entries = Vec::new();
12131    let replay_count = seq
12132        .checked_sub(after)
12133        .ok_or_else(|| invalid_feed("feed replay range moved backward"))?;
12134    if replay_count > MAX_FEED_REPLAY_ENTRIES {
12135        return Err(invalid_feed(format!(
12136            "feed replay requires {replay_count} entries, over the client cap"
12137        )));
12138    }
12139    let mut replay_bytes = 0u64;
12140
12141    loop {
12142        let feed_bytes = ensure_raw_ok(
12143            request_raw(
12144                cfg,
12145                "GET",
12146                &format!("/api/hub/brains/{brain}/feed?after={after}&limit=100"),
12147                None,
12148                Auth::Required,
12149                MAX_FEED_RESPONSE_BYTES,
12150            )?,
12151            "subscribe feed",
12152        )?;
12153        let feed: FeedResponse = serde_json::from_slice(&feed_bytes)
12154            .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
12155        if feed.head_seq != seq || feed.feed_hash != advertised_hash {
12156            return Err(invalid_feed("brain card and feed head disagree"));
12157        }
12158        if feed.entries.len() > FEED_PAGE_LIMIT {
12159            return Err(invalid_feed("feed page exceeds the requested entry limit"));
12160        }
12161        if feed.scope_limited {
12162            if require_full_chain {
12163                return Err(invalid_feed(
12164                    "path-scoped grants cannot verify a full snapshot chain",
12165                ));
12166            }
12167            return Ok(VerifiedRemote {
12168                head: Head {
12169                    brain: resolved_brain,
12170                    seq,
12171                    updated_at,
12172                    feed_hash: advertised_hash,
12173                    verified: false,
12174                },
12175                identity: None,
12176                head_entry: None,
12177                entries: Vec::new(),
12178                anchor: None,
12179            });
12180        }
12181        let page_identity = feed
12182            .identity
12183            .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
12184        let page_anchor = verify_identity_chain(&page_identity, pinned.as_ref())?;
12185        if identity
12186            .as_ref()
12187            .is_some_and(|existing| existing != &page_identity)
12188        {
12189            return Err(invalid_feed("identity changed while reading the feed"));
12190        }
12191        if anchor
12192            .as_ref()
12193            .is_some_and(|existing| existing != &page_anchor)
12194        {
12195            return Err(invalid_feed(
12196                "identity anchor changed while reading the feed",
12197            ));
12198        }
12199        identity = Some(page_identity.clone());
12200        if anchor.is_none() {
12201            anchor = Some(page_anchor);
12202        }
12203        if feed.entries.is_empty() {
12204            return Err(invalid_feed("feed page was empty before the signed head"));
12205        }
12206
12207        for item in feed.entries {
12208            if item.entry.seq != expected_seq {
12209                return Err(invalid_feed(format!(
12210                    "expected entry {expected_seq}, feed served {}",
12211                    item.entry.seq
12212                )));
12213            }
12214            if item.entry.seq > seq {
12215                return Err(invalid_feed("feed advanced past the card snapshot"));
12216            }
12217            if !replay_head_only && item.entry.prev_entry_hash != previous_hash {
12218                return Err(invalid_feed(format!(
12219                    "entry {} does not chain to the local checkpoint",
12220                    item.entry.seq
12221                )));
12222            }
12223            verify_feed_item(&item, &page_identity)?;
12224            reject_retired_signer_after_checkpoint(&page_identity, pinned.as_ref(), &item)?;
12225            replay_bytes = replay_bytes.saturating_add(
12226                serde_json::to_vec(&item)
12227                    .map_err(|_| invalid_feed("could not size feed entry"))?
12228                    .len() as u64,
12229            );
12230            if replay_bytes > MAX_FEED_REPLAY_BYTES {
12231                return Err(invalid_feed("feed replay metadata exceeds the client cap"));
12232            }
12233            previous_hash = Some(item.hash.clone());
12234            after = item.entry.seq;
12235            expected_seq = expected_seq
12236                .checked_add(1)
12237                .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
12238            if require_full_chain {
12239                all_entries.push(item.clone());
12240            }
12241            observed_entries.push(item.clone());
12242            head_entry = Some(item);
12243        }
12244        if after == seq {
12245            break;
12246        }
12247    }
12248
12249    if head_entry.as_ref().map(|item| &item.hash) != advertised_hash.as_ref() {
12250        return Err(invalid_feed(
12251            "verified chain does not converge on the advertised head",
12252        ));
12253    }
12254    let identity = identity.ok_or_else(|| invalid_feed("feed has no brain identity"))?;
12255    let anchor = anchor.ok_or_else(|| invalid_feed("feed has no identity anchor"))?;
12256    verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &observed_entries, seq)?;
12257    save_canonical_pin_and_alias(
12258        cfg,
12259        &trust_directory,
12260        brain,
12261        &resolved_brain,
12262        TrustState {
12263            v: 2,
12264            origin: normalized_origin(&cfg.hub)?,
12265            requested: resolved_brain.clone(),
12266            brain: resolved_brain.clone(),
12267            home: None,
12268            anchor: anchor.clone(),
12269            current: format!("ed25519:{}", identity.fingerprint),
12270            head_seq: seq,
12271            feed_hash: advertised_hash.clone(),
12272            rotations: identity.rotations.clone(),
12273            hub_signer: pinned.as_ref().and_then(|state| state.hub_signer.clone()),
12274            protocol_profile: pinned
12275                .as_ref()
12276                .and_then(|state| state.protocol_profile.clone()),
12277        },
12278        alias_binding.as_ref(),
12279    )?;
12280    Ok(VerifiedRemote {
12281        head: Head {
12282            brain: resolved_brain,
12283            seq,
12284            updated_at,
12285            feed_hash: advertised_hash,
12286            verified: true,
12287        },
12288        identity: Some(identity),
12289        head_entry,
12290        entries: all_entries,
12291        anchor: Some(anchor),
12292    })
12293}
12294
12295/// Read and locally verify the brain's current signed feed head. Identity
12296/// rotation is accepted only through an old-key-signed chain rooted at the
12297/// local TOFU anchor; sequence and hash checkpoints reject rollback and
12298/// equivocation across invocations.
12299pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
12300    Ok(verified_remote_head(cfg, brain, false)?.head)
12301}
12302
12303#[cfg(test)]
12304mod tests {
12305    use super::*;
12306
12307    const TEST_BRAIN_ID: &str = "01j5qc3v9k4ym8rwbn2tqe6f7d";
12308
12309    #[cfg(target_os = "linux")]
12310    #[test]
12311    fn linux_snapshot_install_is_atomic_for_create_and_exchange() {
12312        use std::os::fd::AsRawFd as _;
12313
12314        let sandbox = tempfile::TempDir::new().unwrap();
12315        let parent = std::fs::File::open(sandbox.path()).unwrap();
12316        let stage = std::ffi::CString::new("stage").unwrap();
12317        let destination = std::ffi::CString::new("brain").unwrap();
12318
12319        std::fs::create_dir(sandbox.path().join("stage")).unwrap();
12320        std::fs::write(sandbox.path().join("stage/value"), b"created").unwrap();
12321        install_stage_at(
12322            parent.as_raw_fd(),
12323            stage.as_c_str(),
12324            destination.as_c_str(),
12325            false,
12326        )
12327        .unwrap();
12328        assert!(!sandbox.path().join("stage").exists());
12329        assert_eq!(
12330            std::fs::read(sandbox.path().join("brain/value")).unwrap(),
12331            b"created"
12332        );
12333
12334        std::fs::create_dir(sandbox.path().join("stage")).unwrap();
12335        std::fs::write(sandbox.path().join("stage/value"), b"replacement").unwrap();
12336        install_stage_at(
12337            parent.as_raw_fd(),
12338            stage.as_c_str(),
12339            destination.as_c_str(),
12340            true,
12341        )
12342        .unwrap();
12343        assert_eq!(
12344            std::fs::read(sandbox.path().join("brain/value")).unwrap(),
12345            b"replacement"
12346        );
12347        assert_eq!(
12348            std::fs::read(sandbox.path().join("stage/value")).unwrap(),
12349            b"created",
12350            "RENAME_EXCHANGE must leave the predecessor at the unique stage name"
12351        );
12352    }
12353
12354    struct SignedRemoteFixture {
12355        card: String,
12356        feed: String,
12357        key: AgentSigningKey,
12358        identity: FeedIdentity,
12359    }
12360
12361    fn signed_remote_fixture() -> SignedRemoteFixture {
12362        let rng = ring::rand::SystemRandom::new();
12363        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
12364        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
12365        let (public_key, multikey) = public_identity_for(&pair);
12366        let identity = FeedIdentity {
12367            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
12368            public_key_spki: public_key.clone(),
12369            previous: Vec::new(),
12370            rotations: Vec::new(),
12371        };
12372        let mut entry = FeedEntry {
12373            v: 1,
12374            seq: 1,
12375            ts: "2026-07-30T12:00:00.000Z".to_string(),
12376            brain: multikey.clone(),
12377            public_key: public_key.clone(),
12378            kind: "push".to_string(),
12379            op: "snapshot".to_string(),
12380            pack_sha256: "a".repeat(64),
12381            files: Vec::new(),
12382            removed: Vec::new(),
12383            prev_entry_hash: None,
12384            sig: String::new(),
12385        };
12386        let unsigned = UnsignedFeedEntry {
12387            v: entry.v,
12388            seq: entry.seq,
12389            ts: &entry.ts,
12390            brain: &entry.brain,
12391            public_key: &entry.public_key,
12392            kind: &entry.kind,
12393            op: &entry.op,
12394            pack_sha256: &entry.pack_sha256,
12395            files: &entry.files,
12396            removed: &entry.removed,
12397            prev_entry_hash: &entry.prev_entry_hash,
12398        };
12399        entry.sig =
12400            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
12401        let mut exact = serde_json::to_vec(&entry).unwrap();
12402        exact.push(b'\n');
12403        let hash = content_sha256(&exact);
12404        let card = json!({
12405            "id": TEST_BRAIN_ID,
12406            "headSeq": 1,
12407            "feedHash": hash,
12408            "identity": identity.clone(),
12409        })
12410        .to_string();
12411        let feed = json!({
12412            "headSeq": 1,
12413            "feedHash": hash,
12414            "identity": identity.clone(),
12415            "entries": [{"hash": hash, "entry": entry}],
12416            "scopeLimited": false,
12417        })
12418        .to_string();
12419        SignedRemoteFixture {
12420            card,
12421            feed,
12422            key: AgentSigningKey {
12423                pkcs8: pkcs8.as_ref().to_vec(),
12424                multikey,
12425                public_key_spki: public_key,
12426            },
12427            identity,
12428        }
12429    }
12430
12431    #[test]
12432    fn v1_edit_disclosure_accepts_minimal_and_superset_forms() {
12433        let file = |path: &str, byte: char| FeedFile {
12434            path: path.to_string(),
12435            sha256: byte.to_string().repeat(64),
12436            bytes: 1,
12437        };
12438        let a0 = file("records/a.md", 'a');
12439        let a1 = file("records/a.md", 'b');
12440        let stable = file("records/stable.md", 'c');
12441        let added = file("records/added.md", 'd');
12442        let removed_file = file("records/removed.md", 'e');
12443        let previous = vec![a0, stable.clone(), removed_file.clone()];
12444        let resulting = vec![a1.clone(), stable.clone(), added.clone()];
12445        let removed = vec![removed_file.path.clone()];
12446
12447        assert_eq!(
12448            verify_v1_manifest_disclosure(
12449                "edit",
12450                &previous,
12451                &resulting,
12452                &[a1.clone(), added.clone()],
12453                &removed,
12454            ),
12455            Ok(())
12456        );
12457        assert_eq!(
12458            verify_v1_manifest_disclosure(
12459                "edit",
12460                &previous,
12461                &resulting,
12462                &[stable.clone(), added.clone(), a1.clone()],
12463                &removed,
12464            ),
12465            Ok(())
12466        );
12467        assert_eq!(
12468            verify_v1_manifest_disclosure(
12469                "edit",
12470                &previous,
12471                &resulting,
12472                std::slice::from_ref(&added),
12473                &removed,
12474            ),
12475            Err(V1DisclosureError::EditMissingChange)
12476        );
12477        assert_eq!(
12478            verify_v1_manifest_disclosure(
12479                "edit",
12480                &previous,
12481                &resulting,
12482                &[file("records/a.md", 'f'), added.clone()],
12483                &removed,
12484            ),
12485            Err(V1DisclosureError::EditFalseFile)
12486        );
12487        assert_eq!(
12488            verify_v1_manifest_disclosure(
12489                "edit",
12490                &previous,
12491                &resulting,
12492                &[a1.clone(), added.clone()],
12493                &[],
12494            ),
12495            Err(V1DisclosureError::RemovedMismatch)
12496        );
12497        assert_eq!(
12498            verify_v1_manifest_disclosure(
12499                "push",
12500                &previous,
12501                &resulting,
12502                &[added.clone(), stable, a1],
12503                &removed,
12504            ),
12505            Ok(())
12506        );
12507        assert_eq!(
12508            verify_v1_manifest_disclosure("push", &previous, &resulting, &[added], &removed,),
12509            Err(V1DisclosureError::PushManifestMismatch)
12510        );
12511    }
12512
12513    #[test]
12514    fn wire_sequences_are_rejected_at_their_endpoint_count_limits() {
12515        let fixture = signed_remote_fixture();
12516        let feed: Value = serde_json::from_str(&fixture.feed).unwrap();
12517        let item = feed["entries"][0].to_string();
12518        let oversized_page = format!(
12519            "{{\"headSeq\":1,\"feedHash\":null,\"identity\":null,\"entries\":[{}],\"scopeLimited\":false}}",
12520            std::iter::repeat_n(item.as_str(), FEED_PAGE_LIMIT + 1)
12521                .collect::<Vec<_>>()
12522                .join(",")
12523        );
12524        assert!(serde_json::from_str::<FeedResponse>(&oversized_page).is_err());
12525
12526        let oversized_identity = format!(
12527            "{{\"fingerprint\":\"fp\",\"publicKeySpki\":\"spki\",\"previous\":[],\"rotations\":[{}]}}",
12528            std::iter::repeat_n("\"rotation\"", MAX_IDENTITY_ROTATIONS + 1)
12529                .collect::<Vec<_>>()
12530                .join(",")
12531        );
12532        assert!(serde_json::from_str::<FeedIdentity>(&oversized_identity).is_err());
12533
12534        let file = r#"{"path":"a","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bytes":1}"#;
12535        let oversized_entry = format!(
12536            "{{\"v\":1,\"seq\":1,\"ts\":\"t\",\"brain\":\"b\",\"public_key\":\"k\",\"kind\":\"push\",\"op\":\"snapshot\",\"pack_sha256\":\"{}\",\"files\":[{}],\"removed\":[],\"prev_entry_hash\":null,\"sig\":\"s\"}}",
12537            "a".repeat(64),
12538            std::iter::repeat_n(file, MAX_PUSH_FILES + 1)
12539                .collect::<Vec<_>>()
12540                .join(",")
12541        );
12542        assert!(serde_json::from_str::<FeedEntry>(&oversized_entry).is_err());
12543    }
12544
12545    #[test]
12546    fn bulk_confirmation_parser_accepts_only_the_exact_wire_shape() {
12547        let id = "01arz3ndektsv4rrffq69g5fav";
12548        let digest = "a".repeat(64);
12549        assert_eq!(
12550            V2BulkConfirmation::parse(&format!("{id}:{digest}")).unwrap(),
12551            V2BulkConfirmation {
12552                id: id.to_string(),
12553                digest,
12554            }
12555        );
12556        for invalid in [
12557            "",
12558            "01arz3ndektsv4rrffq69g5fav",
12559            "01ARZ3NDEKTSV4RRFFQ69G5FAV:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
12560            "01arz3ndektsv4rrffq69g5fav:ABCDEF",
12561            "01arz3ndektsv4rrffq69g5fav:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
12562        ] {
12563            assert!(matches!(
12564                V2BulkConfirmation::parse(invalid),
12565                Err(LinkError::InvalidPack { .. })
12566            ));
12567        }
12568    }
12569
12570    fn scripted_json_hub(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle<()>) {
12571        use std::io::{BufRead as _, BufReader, Read as _, Write as _};
12572        use std::net::TcpListener;
12573
12574        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
12575        let url = format!("http://{}", listener.local_addr().unwrap());
12576        let handle = std::thread::spawn(move || {
12577            for (status, body) in responses {
12578                let (stream, _) = listener.accept().unwrap();
12579                let mut reader = BufReader::new(stream);
12580                let mut line = String::new();
12581                reader.read_line(&mut line).unwrap();
12582                let mut content_length = 0usize;
12583                loop {
12584                    line.clear();
12585                    reader.read_line(&mut line).unwrap();
12586                    if line == "\r\n" || line == "\n" || line.is_empty() {
12587                        break;
12588                    }
12589                    if let Some((name, value)) = line.split_once(':') {
12590                        if name.eq_ignore_ascii_case("content-length") {
12591                            content_length = value.trim().parse().unwrap();
12592                        }
12593                    }
12594                }
12595                let mut request_body = vec![0_u8; content_length];
12596                reader.read_exact(&mut request_body).unwrap();
12597                let response = format!(
12598                    "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
12599                    body.len()
12600                );
12601                reader.get_mut().write_all(response.as_bytes()).unwrap();
12602            }
12603        });
12604        (url, handle)
12605    }
12606
12607    fn routed_json_hub(
12608        requests: usize,
12609        mut respond: impl FnMut(&str) -> (u16, String) + Send + 'static,
12610    ) -> (String, std::thread::JoinHandle<()>) {
12611        use std::io::{BufRead as _, BufReader, Read as _, Write as _};
12612        use std::net::TcpListener;
12613
12614        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
12615        let url = format!("http://{}", listener.local_addr().unwrap());
12616        let handle = std::thread::spawn(move || {
12617            for _ in 0..requests {
12618                let (stream, _) = listener.accept().unwrap();
12619                let mut reader = BufReader::new(stream);
12620                let mut line = String::new();
12621                reader.read_line(&mut line).unwrap();
12622                let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
12623                let mut content_length = 0usize;
12624                loop {
12625                    line.clear();
12626                    reader.read_line(&mut line).unwrap();
12627                    if line == "\r\n" || line == "\n" || line.is_empty() {
12628                        break;
12629                    }
12630                    if let Some((name, value)) = line.split_once(':') {
12631                        if name.eq_ignore_ascii_case("content-length") {
12632                            content_length = value.trim().parse().unwrap();
12633                        }
12634                    }
12635                }
12636                let mut request_body = vec![0_u8; content_length];
12637                reader.read_exact(&mut request_body).unwrap();
12638                let (status, body) = respond(&path);
12639                let response = format!(
12640                    "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
12641                    body.len()
12642                );
12643                reader.get_mut().write_all(response.as_bytes()).unwrap();
12644            }
12645        });
12646        (url, handle)
12647    }
12648
12649    fn test_hub_config(hub: String, state_dir: PathBuf) -> HubConfig {
12650        HubConfig {
12651            hub,
12652            key: Some("test-key".to_string()),
12653            agent_key: None,
12654            brain_key: None,
12655            state_dir,
12656            store_selected: false,
12657        }
12658    }
12659
12660    #[test]
12661    fn linkmd_sig_v2_is_bound_to_the_exact_hub_origin() {
12662        use ring::signature::KeyPair as _;
12663
12664        let rng = ring::rand::SystemRandom::new();
12665        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
12666        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
12667        let (spki, multikey) = public_identity_for(&pair);
12668        let key = AgentSigningKey {
12669            pkcs8: pkcs8.as_ref().to_vec(),
12670            multikey,
12671            public_key_spki: spki,
12672        };
12673        let header = linkmd_sig_header(
12674            &key,
12675            "https://hub-a.example",
12676            "post",
12677            "/api/hub/brains/brain/push?mode=exact",
12678            Some("{\"ok\":true}"),
12679        )
12680        .unwrap();
12681        assert!(header.starts_with("LinkMD-Sig v2,"));
12682        let ts = header
12683            .split(",ts=")
12684            .nth(1)
12685            .unwrap()
12686            .split(',')
12687            .next()
12688            .unwrap();
12689        let signature = URL_SAFE_NO_PAD
12690            .decode(header.rsplit(",sig=").next().unwrap())
12691            .unwrap();
12692        let body_hash = format!("{:x}", Sha256::digest(b"{\"ok\":true}"));
12693        let accepted = format!(
12694            "v2\nhttps://hub-a.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
12695        );
12696        let replayed = format!(
12697            "v2\nhttps://hub-b.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
12698        );
12699        let public = pair.public_key().as_ref();
12700        assert!(UnparsedPublicKey::new(&ED25519, public)
12701            .verify(accepted.as_bytes(), &signature)
12702            .is_ok());
12703        assert!(
12704            UnparsedPublicKey::new(&ED25519, public)
12705                .verify(replayed.as_bytes(), &signature)
12706                .is_err(),
12707            "a proof captured at hub A must not authenticate at hub B"
12708        );
12709    }
12710
12711    #[test]
12712    fn explicit_brain_id_rejects_a_substituted_card_before_feed_trust() {
12713        let other = "01j5qc3v9k4ym8rwbn2tqe6f7e";
12714        let card = json!({
12715            "id": other,
12716            "headSeq": 0,
12717            "identity": signed_remote_fixture().identity,
12718        })
12719        .to_string();
12720        let (hub, server) = scripted_json_hub(vec![(200, card)]);
12721        let state = tempfile::tempdir().unwrap();
12722        let cfg = test_hub_config(hub, state.path().to_path_buf());
12723        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
12724        assert!(
12725            error.contains("differs from the explicitly requested"),
12726            "{error}"
12727        );
12728        server.join().unwrap();
12729    }
12730
12731    #[test]
12732    fn empty_brain_identity_is_pinned_and_cannot_be_substituted_later() {
12733        let first = signed_remote_fixture().identity;
12734        let second = signed_remote_fixture().identity;
12735        let card = |identity: FeedIdentity| {
12736            json!({
12737                "id": TEST_BRAIN_ID,
12738                "headSeq": 0,
12739                "identity": identity,
12740            })
12741            .to_string()
12742        };
12743        let (hub, server) = scripted_json_hub(vec![(200, card(first)), (200, card(second))]);
12744        let state = tempfile::tempdir().unwrap();
12745        let cfg = test_hub_config(hub, state.path().to_path_buf());
12746        assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
12747        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
12748        assert!(
12749            error.contains("pinned anchor") || error.contains("forked away"),
12750            "{error}"
12751        );
12752        server.join().unwrap();
12753    }
12754
12755    #[test]
12756    fn empty_head_rejects_a_rotation_that_commits_to_hidden_history() {
12757        let old = signed_remote_fixture();
12758        let new = signed_remote_fixture();
12759        let old_pair = ring::signature::Ed25519KeyPair::from_pkcs8(&old.key.pkcs8).unwrap();
12760        let unsigned = serde_json::to_string(&UnsignedRotation {
12761            v: 1,
12762            op: "rotate",
12763            brain: &old.key.multikey,
12764            public_key: &old.key.public_key_spki,
12765            new_brain: &new.key.multikey,
12766            new_public_key: &new.key.public_key_spki,
12767            prior_head_seq: 1,
12768            prior_feed_hash: Some(&"a".repeat(64)),
12769            ts: "2026-07-30T12:00:00.000Z".to_string(),
12770        })
12771        .unwrap();
12772        let signature = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
12773        let rotation = format!(
12774            "{},\"sig\":\"{}\"}}",
12775            &unsigned[..unsigned.len() - 1],
12776            signature
12777        );
12778        let identity = FeedIdentity {
12779            fingerprint: new.key.multikey.trim_start_matches("ed25519:").to_string(),
12780            public_key_spki: new.key.public_key_spki,
12781            previous: vec![PreviousIdentity {
12782                fingerprint: old.key.multikey.trim_start_matches("ed25519:").to_string(),
12783                public_key_spki: old.key.public_key_spki,
12784            }],
12785            rotations: vec![rotation],
12786        };
12787        let card = json!({
12788            "id": TEST_BRAIN_ID,
12789            "headSeq": 0,
12790            "feedHash": null,
12791            "identity": identity,
12792        })
12793        .to_string();
12794        let (hub, server) = scripted_json_hub(vec![(200, card)]);
12795        let state = tempfile::tempdir().unwrap();
12796        let cfg = test_hub_config(hub, state.path().to_path_buf());
12797        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
12798        assert!(
12799            error.contains("rotation claims a feed boundary beyond the advertised head"),
12800            "{error}"
12801        );
12802        assert!(
12803            load_trust(&cfg, TEST_BRAIN_ID).unwrap().is_none(),
12804            "an inconsistent empty-head identity must not become the TOFU checkpoint"
12805        );
12806        server.join().unwrap();
12807    }
12808
12809    #[test]
12810    fn trust_checkpoint_rejects_a_later_fork() {
12811        let fixture = signed_remote_fixture();
12812        let mut fork: Value = serde_json::from_str(&fixture.card).unwrap();
12813        fork["feedHash"] = Value::String("b".repeat(64));
12814        let (hub, server) = scripted_json_hub(vec![
12815            (200, fixture.card),
12816            (200, fixture.feed),
12817            (200, fork.to_string()),
12818        ]);
12819        let state = tempfile::tempdir().unwrap();
12820        let cfg = test_hub_config(hub, state.path().to_path_buf());
12821        assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
12822        assert!(head(&cfg, TEST_BRAIN_ID).is_err());
12823        server.join().unwrap();
12824    }
12825
12826    #[test]
12827    fn alias_and_canonical_id_share_one_identity_checkpoint() {
12828        let trusted = signed_remote_fixture();
12829        let attacker = signed_remote_fixture();
12830        let (hub, server) = scripted_json_hub(vec![
12831            (200, trusted.card),
12832            (200, trusted.feed),
12833            (200, attacker.card),
12834        ]);
12835        let state = tempfile::tempdir().unwrap();
12836        let cfg = test_hub_config(hub, state.path().to_path_buf());
12837        assert!(head(&cfg, "trusted-slug").unwrap().verified);
12838        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
12839        assert!(
12840            error.contains("equivocation")
12841                || error.contains("pinned")
12842                || error.contains("identity"),
12843            "{error}"
12844        );
12845        server.join().unwrap();
12846    }
12847
12848    #[test]
12849    fn concurrent_aliases_cannot_establish_conflicting_tofu_pins() {
12850        let alpha = signed_remote_fixture();
12851        let beta = signed_remote_fixture();
12852        let alpha_card = alpha.card.clone();
12853        let alpha_feed = alpha.feed.clone();
12854        let beta_card = beta.card.clone();
12855        let beta_feed = beta.feed.clone();
12856        let (hub, server) = routed_json_hub(3, move |path| {
12857            if path.contains("/alpha/feed?") {
12858                (200, alpha_feed.clone())
12859            } else if path.contains("/beta/feed?") {
12860                (200, beta_feed.clone())
12861            } else if path.ends_with("/alpha") {
12862                (200, alpha_card.clone())
12863            } else if path.ends_with("/beta") {
12864                (200, beta_card.clone())
12865            } else {
12866                (500, r#"{"error":"unexpected path"}"#.to_string())
12867            }
12868        });
12869        let state = tempfile::tempdir().unwrap();
12870        let cfg = test_hub_config(hub, state.path().to_path_buf());
12871        let alpha_cfg = cfg.clone();
12872        let beta_cfg = cfg;
12873        let first = std::thread::spawn(move || head(&alpha_cfg, "alpha"));
12874        let second = std::thread::spawn(move || head(&beta_cfg, "beta"));
12875        let results = [first.join().unwrap(), second.join().unwrap()];
12876        assert_eq!(
12877            results.iter().filter(|result| result.is_ok()).count(),
12878            1,
12879            "only one alias identity may establish canonical TOFU: {results:?}"
12880        );
12881        assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
12882        server.join().unwrap();
12883    }
12884
12885    #[cfg(unix)]
12886    #[test]
12887    fn trust_transaction_survives_an_ancestor_swap_without_writing_outside() {
12888        use std::os::unix::fs::symlink;
12889
12890        let fixture = signed_remote_fixture();
12891        let card = json!({
12892            "id": TEST_BRAIN_ID,
12893            "headSeq": 0,
12894            "feedHash": Value::Null,
12895            "identity": fixture.identity,
12896        })
12897        .to_string();
12898        let work = tempfile::tempdir().unwrap();
12899        let outside = tempfile::tempdir().unwrap();
12900        let state = work.path().join("state");
12901        let moved = work.path().join("state-held");
12902        let swap_state = state.clone();
12903        let swap_moved = moved.clone();
12904        let outside_path = outside.path().to_path_buf();
12905        let (hub, server) = routed_json_hub(1, move |_| {
12906            // The client has already opened state/trust before this response.
12907            std::fs::rename(&swap_state, &swap_moved).unwrap();
12908            symlink(&outside_path, &swap_state).unwrap();
12909            (200, card.clone())
12910        });
12911        let cfg = test_hub_config(hub, state);
12912
12913        let verified = verified_remote_head(&cfg, TEST_BRAIN_ID, false).unwrap();
12914        assert_eq!(verified.head.seq, 0);
12915        assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
12916        assert!(std::fs::read_dir(moved.join("trust"))
12917            .unwrap()
12918            .flatten()
12919            .any(|entry| entry.path().extension().is_some_and(|ext| ext == "json")));
12920        server.join().unwrap();
12921    }
12922
12923    #[test]
12924    fn self_custody_push_refuses_an_unrelated_key_before_requesting_an_upload() {
12925        let remote = signed_remote_fixture();
12926        let unrelated = signed_remote_fixture().key;
12927        let (hub, server) = scripted_json_hub(vec![(200, remote.card), (200, remote.feed)]);
12928        let state = tempfile::tempdir().unwrap();
12929        let mut cfg = test_hub_config(hub, state.path().to_path_buf());
12930        cfg.brain_key = Some(unrelated);
12931        let error = sync_push(
12932            &cfg,
12933            TEST_BRAIN_ID,
12934            &[("DB.md".to_string(), "signed local content".to_string())],
12935        )
12936        .unwrap_err()
12937        .to_string();
12938        assert!(
12939            error.contains("not the verified current brain identity"),
12940            "{error}"
12941        );
12942        server.join().unwrap();
12943    }
12944
12945    #[test]
12946    fn rotation_ignores_a_forged_2xx_body_and_requires_verified_postcondition() {
12947        let remote = signed_remote_fixture();
12948        let new = signed_remote_fixture().key;
12949        let state = tempfile::tempdir().unwrap();
12950        let new_file = state.path().join("new.key");
12951        std::fs::write(
12952            &new_file,
12953            format!("{}\n", URL_SAFE_NO_PAD.encode(&new.pkcs8)),
12954        )
12955        .unwrap();
12956        #[cfg(unix)]
12957        {
12958            use std::os::unix::fs::PermissionsExt as _;
12959            std::fs::set_permissions(&new_file, std::fs::Permissions::from_mode(0o600)).unwrap();
12960        }
12961        let forged = json!({
12962            "brain": TEST_BRAIN_ID,
12963            "identity": {
12964                "fingerprint": new.multikey.trim_start_matches("ed25519:"),
12965                "publicKeySpki": new.public_key_spki,
12966            }
12967        })
12968        .to_string();
12969        let (hub, server) = scripted_json_hub(vec![
12970            (200, remote.card.clone()),
12971            (200, remote.feed.clone()),
12972            (200, forged),
12973            (200, remote.card),
12974            (200, remote.feed),
12975        ]);
12976        let cfg = test_hub_config(hub, state.path().to_path_buf());
12977        let error = rotate_brain_key(&cfg, TEST_BRAIN_ID, &remote.key, &new_file)
12978            .unwrap_err()
12979            .to_string();
12980        assert!(
12981            error.contains("without committing the verified new identity"),
12982            "{error}"
12983        );
12984        server.join().unwrap();
12985    }
12986
12987    #[test]
12988    fn resolve_record_is_derived_from_signed_pack_not_a_query_response() {
12989        let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
12990        let raw = format!(
12991            "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
12992        );
12993        let pack = build_store_pack(&[
12994            (
12995                "DB.md".to_string(),
12996                "---\ntype: db-md\nscope: company\nowner: Test\n---\n".to_string(),
12997            ),
12998            ("records/clients/truth.md".to_string(), raw.clone()),
12999        ])
13000        .unwrap();
13001        let by_id = resolve_from_verified_pack(
13002            "01j5qc3v9k4ym8rwbn2tqe6f7d",
13003            &AddressTarget::Id(record_id.to_string()),
13004            pack.clone(),
13005        )
13006        .unwrap();
13007        assert_eq!(by_id["document"]["summary"], "Signed truth");
13008        assert_eq!(by_id["document"]["body"], "# Signed truth\n");
13009        assert_eq!(
13010            by_id["document"]["contentSha"],
13011            content_sha256(raw.as_bytes())
13012        );
13013
13014        let by_path = resolve_from_verified_pack(
13015            "01j5qc3v9k4ym8rwbn2tqe6f7d",
13016            &AddressTarget::Path("records/clients/truth.md".to_string()),
13017            pack,
13018        )
13019        .unwrap();
13020        assert_eq!(by_path["document"]["id"], record_id);
13021        assert_eq!(by_path["document"]["path"], "records/clients/truth.md");
13022    }
13023
13024    #[test]
13025    fn canonical_store_pack_matches_the_cross_language_zip32_golden() {
13026        let unsorted = vec![
13027            ("records/a.md".to_string(), "alpha\n".to_string()),
13028            ("DB.md".to_string(), "# db\n".to_string()),
13029        ];
13030        let sorted = vec![
13031            ("DB.md".to_string(), "# db\n".to_string()),
13032            ("records/a.md".to_string(), "alpha\n".to_string()),
13033        ];
13034        let pack = build_store_pack(&unsorted).unwrap();
13035
13036        // This digest is shared with the hub implementation. It locks every
13037        // byte of the wire profile: raw UTF-8 order, STORED payloads, fixed DOS
13038        // epoch, explicit CRC/sizes, Unix regular-0600 attributes, and the
13039        // absence of descriptors/extras/comments/ZIP64.
13040        assert_eq!(pack.len(), 219);
13041        assert_eq!(
13042            content_sha256(&pack),
13043            "972fb2045becaa21588baaf4b349e62a430687fa2c21167b53f4ca0efa6c9408"
13044        );
13045        assert_eq!(pack, build_store_pack(&sorted).unwrap());
13046        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x07\x08"));
13047        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x06"));
13048        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x07"));
13049
13050        assert_eq!(
13051            parse_store_pack(pack).unwrap(),
13052            vec![
13053                ("DB.md".to_string(), b"# db\n".to_vec()),
13054                ("records/a.md".to_string(), b"alpha\n".to_vec()),
13055            ]
13056        );
13057    }
13058
13059    #[test]
13060    fn canonical_store_pack_validates_every_path_before_writing() {
13061        let duplicate = vec![
13062            ("DB.md".to_string(), "first".to_string()),
13063            ("DB.md".to_string(), "second".to_string()),
13064        ];
13065        assert!(build_store_pack(&duplicate)
13066            .unwrap_err()
13067            .to_string()
13068            .contains("duplicate path"));
13069        assert!(matches!(
13070            build_store_pack(&[("../escape.md".to_string(), "no".to_string())]),
13071            Err(LinkError::UnsafePath { .. })
13072        ));
13073    }
13074
13075    #[test]
13076    fn zip64_preflight_rejects_an_entry_count_bomb_before_zip_parsing() {
13077        const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
13078        // One byte stands in for the central directory. The trailer itself is
13079        // structurally valid; the count is the sole reason for refusal.
13080        let mut bytes = vec![0_u8];
13081        let zip64_offset = bytes.len() as u64;
13082        bytes.extend_from_slice(b"PK\x06\x06");
13083        bytes.extend_from_slice(&44_u64.to_le_bytes());
13084        bytes.extend_from_slice(&[0_u8; 12]);
13085        bytes.extend_from_slice(&COUNT.to_le_bytes());
13086        bytes.extend_from_slice(&COUNT.to_le_bytes());
13087        bytes.extend_from_slice(&1_u64.to_le_bytes());
13088        bytes.extend_from_slice(&0_u64.to_le_bytes());
13089        bytes.extend_from_slice(b"PK\x06\x07");
13090        bytes.extend_from_slice(&0_u32.to_le_bytes());
13091        bytes.extend_from_slice(&zip64_offset.to_le_bytes());
13092        bytes.extend_from_slice(&1_u32.to_le_bytes());
13093        bytes.extend_from_slice(b"PK\x05\x06");
13094        bytes.extend_from_slice(&0_u16.to_le_bytes());
13095        bytes.extend_from_slice(&0_u16.to_le_bytes());
13096        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
13097        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
13098        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
13099        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
13100        bytes.extend_from_slice(&0_u16.to_le_bytes());
13101
13102        let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
13103            .unwrap_err()
13104            .to_string();
13105        assert!(error.contains("invalid file count"), "{error}");
13106    }
13107
13108    #[test]
13109    fn zip_preflight_rejects_a_fake_trailing_eocd_before_zip_parsing() {
13110        const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
13111        let mut bytes = vec![0_u8];
13112        let zip64_offset = bytes.len() as u64;
13113        bytes.extend_from_slice(b"PK\x06\x06");
13114        bytes.extend_from_slice(&44_u64.to_le_bytes());
13115        bytes.extend_from_slice(&[0_u8; 12]);
13116        bytes.extend_from_slice(&COUNT.to_le_bytes());
13117        bytes.extend_from_slice(&COUNT.to_le_bytes());
13118        bytes.extend_from_slice(&1_u64.to_le_bytes());
13119        bytes.extend_from_slice(&0_u64.to_le_bytes());
13120        bytes.extend_from_slice(b"PK\x06\x07");
13121        bytes.extend_from_slice(&0_u32.to_le_bytes());
13122        bytes.extend_from_slice(&zip64_offset.to_le_bytes());
13123        bytes.extend_from_slice(&1_u32.to_le_bytes());
13124        bytes.extend_from_slice(b"PK\x05\x06");
13125        bytes.extend_from_slice(&0_u16.to_le_bytes());
13126        bytes.extend_from_slice(&0_u16.to_le_bytes());
13127        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
13128        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
13129        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
13130        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
13131        bytes.extend_from_slice(&0_u16.to_le_bytes());
13132        // The old parser trusted this last low-count signature, while
13133        // ZipArchive fell back to the real Zip64 directory and allocated for
13134        // COUNT entries. Its central-directory offsets are deliberately fake.
13135        let fake_eocd = bytes.len() as u32;
13136        bytes.extend_from_slice(b"PK\x05\x06");
13137        bytes.extend_from_slice(&0_u16.to_le_bytes());
13138        bytes.extend_from_slice(&0_u16.to_le_bytes());
13139        bytes.extend_from_slice(&1_u16.to_le_bytes());
13140        bytes.extend_from_slice(&1_u16.to_le_bytes());
13141        bytes.extend_from_slice(&0_u32.to_le_bytes());
13142        bytes.extend_from_slice(&fake_eocd.to_le_bytes());
13143        bytes.extend_from_slice(&0_u16.to_le_bytes());
13144
13145        let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
13146            .unwrap_err()
13147            .to_string();
13148        assert!(error.contains("central directory"), "{error}");
13149    }
13150
13151    #[test]
13152    fn strict_http_status_handling_rejects_redirects_without_panicking() {
13153        let error = ensure_ok(
13154            HubResponse {
13155                status: 302,
13156                body: Some(json!({"redirect": "/elsewhere"})),
13157            },
13158            "mutation",
13159        )
13160        .unwrap_err();
13161        assert!(matches!(error, LinkError::Http { status: 302, .. }));
13162
13163        let error = ensure_raw_ok(
13164            RawHubResponse {
13165                status: 302,
13166                body: br#"{"redirect":"/elsewhere"}"#.to_vec(),
13167            },
13168            "feed",
13169        )
13170        .unwrap_err();
13171        assert!(matches!(error, LinkError::Http { status: 302, .. }));
13172    }
13173
13174    #[cfg(unix)]
13175    #[test]
13176    fn collect_push_files_refuses_external_symlink_and_nested_store() {
13177        use std::os::unix::fs::symlink;
13178
13179        let root = tempfile::tempdir().unwrap();
13180        std::fs::write(
13181            root.path().join("DB.md"),
13182            "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
13183        )
13184        .unwrap();
13185        std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
13186
13187        let external = tempfile::tempdir().unwrap();
13188        let secret = external.path().join("secret.md");
13189        std::fs::write(&secret, "TOP SECRET").unwrap();
13190        symlink(&secret, root.path().join("records/notes/secret.md")).unwrap();
13191
13192        let store = Store::open_strict(root.path()).unwrap();
13193        let err = collect_push_files(&store).unwrap_err().to_string();
13194        assert!(err.contains("cannot push"), "{err}");
13195        assert!(
13196            !err.contains("TOP SECRET"),
13197            "external bytes must never leak"
13198        );
13199
13200        std::fs::remove_file(root.path().join("records/notes/secret.md")).unwrap();
13201        let nested = root.path().join("records/nested");
13202        std::fs::create_dir_all(&nested).unwrap();
13203        std::fs::write(
13204            nested.join("DB.md"),
13205            "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
13206        )
13207        .unwrap();
13208        let err = collect_push_files(&store).unwrap_err().to_string();
13209        assert!(err.contains("nested db.md store"), "{err}");
13210    }
13211
13212    #[cfg(unix)]
13213    #[test]
13214    fn remote_push_uses_opened_root_after_path_replacement() {
13215        use std::os::unix::fs::symlink;
13216
13217        let sandbox = tempfile::tempdir().unwrap();
13218        let root = sandbox.path().join("store");
13219        std::fs::create_dir_all(root.join("records/notes")).unwrap();
13220        std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
13221        std::fs::write(
13222            root.join("records/notes/owned.md"),
13223            "---\ntype: note\nsummary: owned\n---\nowned upload\n",
13224        )
13225        .unwrap();
13226        let store = Store::open_strict(&root).unwrap();
13227        let detached = sandbox.path().join("detached");
13228        std::fs::rename(&root, &detached).unwrap();
13229
13230        let replacement = sandbox.path().join("replacement");
13231        std::fs::create_dir_all(replacement.join("records/notes")).unwrap();
13232        std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
13233        std::fs::write(
13234            replacement.join("records/notes/secret.md"),
13235            "---\ntype: note\nsummary: secret\n---\nreplacement sentinel\n",
13236        )
13237        .unwrap();
13238        symlink(&replacement, &root).unwrap();
13239
13240        let files = collect_push_files(&store).unwrap();
13241        let wire_text = files
13242            .iter()
13243            .map(|(path, content)| format!("{path}\n{content}"))
13244            .collect::<Vec<_>>()
13245            .join("\n");
13246        assert!(wire_text.contains("owned upload"));
13247        assert!(!wire_text.contains("replacement sentinel"));
13248        assert!(!wire_text.contains("records/notes/secret.md"));
13249
13250        let remote = signed_remote_fixture();
13251        let (hub, server) = scripted_json_hub(vec![
13252            (200, remote.card),
13253            (200, remote.feed),
13254            (200, json!({"ok": true}).to_string()),
13255        ]);
13256        let state = tempfile::tempdir().unwrap();
13257        let cfg = test_hub_config(hub, state.path().to_path_buf());
13258        let pushed = sync_push(&cfg, TEST_BRAIN_ID, &files).unwrap();
13259        assert_eq!(pushed, json!({"ok": true}));
13260        server.join().unwrap();
13261    }
13262
13263    #[test]
13264    fn signed_feed_item_verifies_identity_hash_and_signature() {
13265        use ring::rand::SystemRandom;
13266        use ring::signature::{Ed25519KeyPair, KeyPair};
13267
13268        const PREFIX: &[u8] = &[
13269            0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
13270        ];
13271        let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
13272        let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
13273        let mut spki = PREFIX.to_vec();
13274        spki.extend_from_slice(pair.public_key().as_ref());
13275        let public_key = URL_SAFE_NO_PAD.encode(&spki);
13276        let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
13277        let mut entry = FeedEntry {
13278            v: 1,
13279            seq: 1,
13280            ts: "2026-07-14T00:00:00.000Z".to_string(),
13281            brain: format!("ed25519:{fingerprint}"),
13282            public_key: public_key.clone(),
13283            kind: "push".to_string(),
13284            op: "snapshot".to_string(),
13285            pack_sha256: "a".repeat(64),
13286            files: vec![FeedFile {
13287                path: "DB.md".to_string(),
13288                sha256: "b".repeat(64),
13289                bytes: 3,
13290            }],
13291            removed: vec![],
13292            prev_entry_hash: None,
13293            sig: String::new(),
13294        };
13295        let unsigned = UnsignedFeedEntry {
13296            v: entry.v,
13297            seq: entry.seq,
13298            ts: &entry.ts,
13299            brain: &entry.brain,
13300            public_key: &entry.public_key,
13301            kind: &entry.kind,
13302            op: &entry.op,
13303            pack_sha256: &entry.pack_sha256,
13304            files: &entry.files,
13305            removed: &entry.removed,
13306            prev_entry_hash: &entry.prev_entry_hash,
13307        };
13308        entry.sig =
13309            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
13310        let mut exact = serde_json::to_vec(&entry).unwrap();
13311        exact.push(b'\n');
13312        let item = FeedItem {
13313            hash: format!("{:x}", Sha256::digest(&exact)),
13314            entry,
13315        };
13316        let identity = FeedIdentity {
13317            fingerprint,
13318            public_key_spki: public_key,
13319            previous: Vec::new(),
13320            rotations: Vec::new(),
13321        };
13322        assert!(verify_feed_item(&item, &identity).is_ok());
13323        let mut tampered = item;
13324        tampered.entry.pack_sha256 = "c".repeat(64);
13325        assert!(verify_feed_item(&tampered, &identity).is_err());
13326    }
13327
13328    #[test]
13329    fn v2_commit_requires_exact_fields_and_a_valid_genesis_bridge() {
13330        let rng = ring::rand::SystemRandom::new();
13331        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
13332        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
13333        let (spki, multikey) = public_identity_for(&pair);
13334        let identity = V2HeadIdentity {
13335            custody: "self".to_string(),
13336            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
13337            public_key_spki: spki.clone(),
13338            previous: Vec::new(),
13339            rotations: Vec::new(),
13340        };
13341        let unsigned = json!({
13342            "actor_ref": "a".repeat(64),
13343            "asset_root": Value::Null,
13344            "brain": multikey,
13345            "changes_sha256": "b".repeat(64),
13346            "control_revision": "c".repeat(64),
13347            "materializer": "dbmd-projection-v1",
13348            "op": "changeset",
13349            "parent_asset_root": Value::Null,
13350            "parent_commit": Value::Null,
13351            "parent_root": Value::Null,
13352            "prev_entry_hash": Value::Null,
13353            "public_key": spki,
13354            "seq": 1,
13355            "signer_epoch": 1,
13356            "state_root": "d".repeat(64),
13357            "ts": "2026-08-19T12:00:00.000Z",
13358            "v": 2,
13359            "v1_bridge": {
13360                "feed_hash": "e".repeat(64),
13361                "head_seq": 7,
13362                "pack_sha256": "f".repeat(64),
13363            },
13364        });
13365        let sign_value = |value: Value| {
13366            let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
13367            let mut object = value.as_object().unwrap().clone();
13368            object.insert(
13369                "sig".to_string(),
13370                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
13371            );
13372            crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
13373        };
13374        assert!(verified_v2_commit_object(&sign_value(unsigned.clone()), &identity).is_ok());
13375
13376        let mut extra = unsigned.clone();
13377        extra
13378            .as_object_mut()
13379            .unwrap()
13380            .insert("future".to_string(), Value::Bool(true));
13381        assert!(verified_v2_commit_object(&sign_value(extra), &identity).is_err());
13382
13383        let mut missing = unsigned.clone();
13384        missing.as_object_mut().unwrap().remove("v1_bridge");
13385        assert!(verified_v2_commit_object(&sign_value(missing), &identity).is_err());
13386
13387        let mut invalid_bridge = unsigned;
13388        invalid_bridge.as_object_mut().unwrap().insert(
13389            "v1_bridge".to_string(),
13390            json!({"feed_hash": "e".repeat(64), "head_seq": 0, "pack_sha256": "f".repeat(64)}),
13391        );
13392        assert!(verified_v2_commit_object(&sign_value(invalid_bridge), &identity).is_err());
13393    }
13394
13395    #[test]
13396    fn shared_v2_commit_bridge_vector_matches_the_typescript_signer() {
13397        let vector: Value = serde_json::from_str(include_str!(
13398            "../tests/vectors/linkmd-v2-commit-bridge.json"
13399        ))
13400        .unwrap();
13401        let identity_value = vector.get("identity").unwrap();
13402        let identity = V2HeadIdentity {
13403            custody: "self".to_string(),
13404            fingerprint: identity_value
13405                .get("fingerprint")
13406                .and_then(Value::as_str)
13407                .unwrap()
13408                .to_string(),
13409            public_key_spki: identity_value
13410                .get("public_key_spki")
13411                .and_then(Value::as_str)
13412                .unwrap()
13413                .to_string(),
13414            previous: Vec::new(),
13415            rotations: Vec::new(),
13416        };
13417        let private = URL_SAFE_NO_PAD
13418            .decode(
13419                identity_value
13420                    .get("private_key_pkcs8")
13421                    .and_then(Value::as_str)
13422                    .unwrap(),
13423            )
13424            .unwrap();
13425        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&private)
13426            .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(&private))
13427            .unwrap();
13428        let base = vector.get("body").unwrap().as_object().unwrap();
13429
13430        for item in vector.get("valid").unwrap().as_array().unwrap() {
13431            let mut body = base.clone();
13432            body.insert(
13433                "v1_bridge".to_string(),
13434                item.get("v1_bridge").unwrap().clone(),
13435            );
13436            body.insert(
13437                "sig".to_string(),
13438                item.get("signature_base64url").unwrap().clone(),
13439            );
13440            let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
13441            assert!(verified_v2_commit_object(&signed, &identity).is_ok());
13442            assert_eq!(
13443                crate::linkmd_v2::domain_hash_bytes("v2/commit", &signed).unwrap(),
13444                item.get("commit_hash").and_then(Value::as_str).unwrap()
13445            );
13446            assert_eq!(
13447                format!("{:x}", Sha256::digest(&signed)),
13448                item.get("feed_hash").and_then(Value::as_str).unwrap()
13449            );
13450        }
13451
13452        for item in vector.get("invalid").unwrap().as_array().unwrap() {
13453            let mut body = base.clone();
13454            if let Some(remove) = item.get("remove").and_then(Value::as_array) {
13455                for field in remove {
13456                    body.remove(field.as_str().unwrap());
13457                }
13458            }
13459            if let Some(set) = item.get("set").and_then(Value::as_object) {
13460                for (field, value) in set {
13461                    body.insert(field.clone(), value.clone());
13462                }
13463            }
13464            let message = crate::linkmd_v2::canonical_bytes(&Value::Object(body.clone())).unwrap();
13465            body.insert(
13466                "sig".to_string(),
13467                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
13468            );
13469            let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
13470            assert!(
13471                verified_v2_commit_object(&signed, &identity).is_err(),
13472                "accepted invalid shared vector {}",
13473                item.get("reason").and_then(Value::as_str).unwrap()
13474            );
13475        }
13476    }
13477
13478    #[test]
13479    fn v2_profile_transition_reverifies_the_exact_signed_v1_bridge() {
13480        let remote = signed_remote_fixture();
13481        let legacy: FeedResponse = serde_json::from_str(&remote.feed).unwrap();
13482        let legacy_item = legacy.entries.first().unwrap();
13483        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&remote.key.pkcs8).unwrap();
13484        let body = json!({
13485            "actor_ref": "a".repeat(64),
13486            "asset_root": Value::Null,
13487            "brain": remote.key.multikey,
13488            "changes_sha256": "b".repeat(64),
13489            "control_revision": "c".repeat(64),
13490            "materializer": "dbmd-projection-v1",
13491            "op": "changeset",
13492            "parent_asset_root": Value::Null,
13493            "parent_commit": Value::Null,
13494            "parent_root": Value::Null,
13495            "prev_entry_hash": Value::Null,
13496            "public_key": remote.key.public_key_spki,
13497            "seq": 1,
13498            "signer_epoch": 1,
13499            "state_root": "d".repeat(64),
13500            "ts": "2026-08-19T12:00:00.000Z",
13501            "v": 2,
13502            "v1_bridge": {
13503                "feed_hash": legacy_item.hash,
13504                "head_seq": legacy_item.entry.seq,
13505                "pack_sha256": legacy_item.entry.pack_sha256,
13506            },
13507        });
13508        let message = crate::linkmd_v2::canonical_bytes(&body).unwrap();
13509        let mut signed = body.as_object().unwrap().clone();
13510        signed.insert(
13511            "sig".to_string(),
13512            Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
13513        );
13514        let raw = crate::linkmd_v2::canonical_bytes(&Value::Object(signed)).unwrap();
13515        let commit_hash = crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw).unwrap();
13516        let feed_hash = content_sha256(&raw);
13517        let pointer = V2PointerBody {
13518            v: 2,
13519            brain: TEST_BRAIN_ID.to_string(),
13520            seq: 1,
13521            commit_hash: commit_hash.clone(),
13522            feed_hash: feed_hash.clone(),
13523            content_root: Some("d".repeat(64)),
13524            asset_root: None,
13525            materializer: "dbmd-projection-v1".to_string(),
13526            signer_epoch: 1,
13527            control_revision: "c".repeat(64),
13528            backup_preparation: "e".repeat(64),
13529            prior_pointer_hash: None,
13530            signed_at: "2026-08-19T12:00:00.000Z".to_string(),
13531        };
13532        let v2_page = json!({
13533            "v": 2,
13534            "head_seq": 1,
13535            "head_commit_hash": commit_hash,
13536            "head_feed_hash": feed_hash,
13537            "entries": [{
13538                "seq": 1,
13539                "commit_hash": pointer.commit_hash,
13540                "feed_hash": pointer.feed_hash,
13541                "bytes_base64": STANDARD.encode(&raw),
13542            }],
13543            "next_after": 1,
13544            "complete": true,
13545        })
13546        .to_string();
13547        let identity = V2HeadIdentity {
13548            custody: "self".to_string(),
13549            fingerprint: remote.identity.fingerprint.clone(),
13550            public_key_spki: remote.identity.public_key_spki.clone(),
13551            previous: Vec::new(),
13552            rotations: Vec::new(),
13553        };
13554        let checkpoint = TrustState {
13555            v: 2,
13556            origin: "unused".to_string(),
13557            requested: TEST_BRAIN_ID.to_string(),
13558            brain: TEST_BRAIN_ID.to_string(),
13559            home: None,
13560            anchor: remote.key.multikey.clone(),
13561            current: remote.key.multikey,
13562            head_seq: legacy_item.entry.seq,
13563            feed_hash: Some(legacy_item.hash.clone()),
13564            rotations: Vec::new(),
13565            hub_signer: None,
13566            protocol_profile: None,
13567        };
13568        let (hub, server) = scripted_json_hub(vec![(200, v2_page), (200, remote.feed)]);
13569        let state = tempfile::tempdir().unwrap();
13570        let cfg = test_hub_config(hub, state.path().to_path_buf());
13571        verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &checkpoint).unwrap();
13572        server.join().unwrap();
13573
13574        let mut wrong = checkpoint;
13575        wrong.feed_hash = Some("0".repeat(64));
13576        let (hub, server) = scripted_json_hub(vec![(
13577            200,
13578            json!({
13579                "v": 2,
13580                "head_seq": 1,
13581                "head_commit_hash": pointer.commit_hash,
13582                "head_feed_hash": pointer.feed_hash,
13583                "entries": [{
13584                    "seq": 1,
13585                    "commit_hash": pointer.commit_hash,
13586                    "feed_hash": pointer.feed_hash,
13587                    "bytes_base64": STANDARD.encode(&raw),
13588                }],
13589                "next_after": 1,
13590                "complete": true,
13591            })
13592            .to_string(),
13593        )]);
13594        let state = tempfile::tempdir().unwrap();
13595        let cfg = test_hub_config(hub, state.path().to_path_buf());
13596        assert!(verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &wrong,).is_err());
13597        server.join().unwrap();
13598    }
13599
13600    #[test]
13601    fn v2_commit_signer_epoch_follows_the_authenticated_rotation_boundary() {
13602        let rng = ring::rand::SystemRandom::new();
13603        let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
13604        let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
13605        let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
13606        let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
13607        let (old_spki, old_multikey) = public_identity_for(&old);
13608        let (new_spki, new_multikey) = public_identity_for(&new);
13609        let rotation_unsigned = serde_json::to_string(&UnsignedRotation {
13610            v: 1,
13611            op: "rotate",
13612            brain: &old_multikey,
13613            public_key: &old_spki,
13614            new_brain: &new_multikey,
13615            new_public_key: &new_spki,
13616            prior_head_seq: 1,
13617            prior_feed_hash: Some(&"9".repeat(64)),
13618            ts: "2026-08-19T12:01:00.000Z".to_string(),
13619        })
13620        .unwrap();
13621        let rotation_sig = URL_SAFE_NO_PAD.encode(old.sign(rotation_unsigned.as_bytes()).as_ref());
13622        let rotation = format!(
13623            "{},\"sig\":\"{}\"}}",
13624            &rotation_unsigned[..rotation_unsigned.len() - 1],
13625            rotation_sig
13626        );
13627        let identity = V2HeadIdentity {
13628            custody: "self".to_string(),
13629            fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
13630            public_key_spki: new_spki.clone(),
13631            previous: vec![V2PreviousIdentity {
13632                fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
13633                public_key_spki: old_spki.clone(),
13634            }],
13635            rotations: vec![rotation],
13636        };
13637        let commit = |seq: u64,
13638                      epoch: u64,
13639                      multikey: &str,
13640                      spki: &str,
13641                      pair: &ring::signature::Ed25519KeyPair| {
13642            let value = json!({
13643                "actor_ref": "a".repeat(64),
13644                "asset_root": Value::Null,
13645                "brain": multikey,
13646                "changes_sha256": "b".repeat(64),
13647                "control_revision": "c".repeat(64),
13648                "materializer": "dbmd-projection-v1",
13649                "op": "changeset",
13650                "parent_asset_root": Value::Null,
13651                "parent_commit": if seq == 1 { Value::Null } else { Value::String("d".repeat(64)) },
13652                "parent_root": if seq == 1 { Value::Null } else { Value::String("e".repeat(64)) },
13653                "prev_entry_hash": if seq == 1 { Value::Null } else { Value::String("f".repeat(64)) },
13654                "public_key": spki,
13655                "seq": seq,
13656                "signer_epoch": epoch,
13657                "state_root": "1".repeat(64),
13658                "ts": "2026-08-19T12:00:00.000Z",
13659                "v": 2,
13660                "v1_bridge": Value::Null,
13661            });
13662            let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
13663            let mut object = value.as_object().unwrap().clone();
13664            object.insert(
13665                "sig".to_string(),
13666                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
13667            );
13668            crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
13669        };
13670
13671        assert!(verified_v2_commit_object(
13672            &commit(1, 1, &old_multikey, &old_spki, &old),
13673            &identity,
13674        )
13675        .is_ok());
13676        assert!(verified_v2_commit_object(
13677            &commit(2, 2, &new_multikey, &new_spki, &new),
13678            &identity,
13679        )
13680        .is_ok());
13681        assert!(verified_v2_commit_object(
13682            &commit(2, 1, &old_multikey, &old_spki, &old),
13683            &identity,
13684        )
13685        .is_err());
13686        assert!(verified_v2_commit_object(
13687            &commit(1, 2, &new_multikey, &new_spki, &new),
13688            &identity,
13689        )
13690        .is_err());
13691    }
13692
13693    #[test]
13694    fn a_self_custody_entry_verifies_like_any_hub_entry() {
13695        let rng = ring::rand::SystemRandom::new();
13696        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
13697        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
13698        let (spki, multikey) = public_identity_for(&pair);
13699        let key = AgentSigningKey {
13700            pkcs8: pkcs8.as_ref().to_vec(),
13701            multikey: multikey.clone(),
13702            public_key_spki: spki.clone(),
13703        };
13704        let files = vec![WireFeedFile {
13705            path: "DB.md".to_string(),
13706            sha256: "a".repeat(64),
13707            bytes: 3,
13708        }];
13709        let raw = self_custody_entry(
13710            &key,
13711            1,
13712            "2026-07-23T12:00:00.000Z".to_string(),
13713            &"c".repeat(64),
13714            &files,
13715            None,
13716        )
13717        .unwrap();
13718        // The exact client serialization parses as a feed entry and passes the
13719        // SAME verifier every subscribe read runs — the self-custody path
13720        // produces first-class wire-profile-v1 entries.
13721        let entry: FeedEntry = serde_json::from_str(&raw).unwrap();
13722        let hash = format!("{:x}", Sha256::digest(format!("{raw}\n").as_bytes()));
13723        let item = FeedItem { hash, entry };
13724        let identity = FeedIdentity {
13725            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
13726            public_key_spki: spki,
13727            previous: Vec::new(),
13728            rotations: Vec::new(),
13729        };
13730        assert!(verify_feed_item(&item, &identity).is_ok());
13731    }
13732
13733    #[test]
13734    fn identity_rotation_requires_old_key_signature_and_preserves_the_pin() {
13735        let rng = ring::rand::SystemRandom::new();
13736        let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
13737        let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
13738        let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
13739        let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
13740        let (old_spki, old_multikey) = public_identity_for(&old);
13741        let (new_spki, new_multikey) = public_identity_for(&new);
13742        let unsigned = serde_json::to_string(&UnsignedRotation {
13743            v: 1,
13744            op: "rotate",
13745            brain: &old_multikey,
13746            public_key: &old_spki,
13747            new_brain: &new_multikey,
13748            new_public_key: &new_spki,
13749            prior_head_seq: 1,
13750            prior_feed_hash: Some(&"a".repeat(64)),
13751            ts: "2026-07-30T12:00:00.000Z".to_string(),
13752        })
13753        .unwrap();
13754        let signature = URL_SAFE_NO_PAD.encode(old.sign(unsigned.as_bytes()).as_ref());
13755        let rotation = format!(
13756            "{},\"sig\":\"{}\"}}",
13757            &unsigned[..unsigned.len() - 1],
13758            signature
13759        );
13760        let identity = FeedIdentity {
13761            fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
13762            public_key_spki: new_spki,
13763            previous: vec![PreviousIdentity {
13764                fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
13765                public_key_spki: old_spki,
13766            }],
13767            rotations: vec![rotation],
13768        };
13769        let pin = TrustState {
13770            v: 2,
13771            origin: "https://hub.example".to_string(),
13772            requested: "brain".to_string(),
13773            brain: "brain".to_string(),
13774            home: None,
13775            anchor: old_multikey.clone(),
13776            current: old_multikey.clone(),
13777            head_seq: 1,
13778            feed_hash: Some("a".repeat(64)),
13779            rotations: Vec::new(),
13780            hub_signer: None,
13781            protocol_profile: None,
13782        };
13783        assert_eq!(
13784            verify_identity_chain(&identity, Some(&pin)).unwrap(),
13785            old_multikey
13786        );
13787        let mut accepted = pin.clone();
13788        accepted.current = new_multikey.clone();
13789        accepted.rotations = identity.rotations.clone();
13790        let alternate_unsigned = serde_json::to_string(&UnsignedRotation {
13791            v: 1,
13792            op: "rotate",
13793            brain: &old_multikey,
13794            public_key: &identity.previous[0].public_key_spki,
13795            new_brain: &new_multikey,
13796            new_public_key: &identity.public_key_spki,
13797            prior_head_seq: 1,
13798            prior_feed_hash: Some(&"a".repeat(64)),
13799            ts: "2026-07-30T12:00:01.000Z".to_string(),
13800        })
13801        .unwrap();
13802        let alternate_signature =
13803            URL_SAFE_NO_PAD.encode(old.sign(alternate_unsigned.as_bytes()).as_ref());
13804        let mut rewritten = identity.clone();
13805        rewritten.rotations[0] = format!(
13806            "{},\"sig\":\"{}\"}}",
13807            &alternate_unsigned[..alternate_unsigned.len() - 1],
13808            alternate_signature
13809        );
13810        assert!(
13811            verify_identity_chain(&rewritten, Some(&accepted)).is_err(),
13812            "an alternate valid statement must not rewrite accepted history"
13813        );
13814
13815        let mut stale_entry = FeedEntry {
13816            v: 1,
13817            seq: 2,
13818            ts: "2026-07-30T12:01:00.000Z".to_string(),
13819            brain: pin.current.clone(),
13820            public_key: identity.previous[0].public_key_spki.clone(),
13821            kind: "push".to_string(),
13822            op: "snapshot".to_string(),
13823            pack_sha256: "b".repeat(64),
13824            files: Vec::new(),
13825            removed: Vec::new(),
13826            prev_entry_hash: pin.feed_hash.clone(),
13827            sig: String::new(),
13828        };
13829        let stale_unsigned = UnsignedFeedEntry {
13830            v: stale_entry.v,
13831            seq: stale_entry.seq,
13832            ts: &stale_entry.ts,
13833            brain: &stale_entry.brain,
13834            public_key: &stale_entry.public_key,
13835            kind: &stale_entry.kind,
13836            op: &stale_entry.op,
13837            pack_sha256: &stale_entry.pack_sha256,
13838            files: &stale_entry.files,
13839            removed: &stale_entry.removed,
13840            prev_entry_hash: &stale_entry.prev_entry_hash,
13841        };
13842        stale_entry.sig = URL_SAFE_NO_PAD.encode(
13843            old.sign(&serde_json::to_vec(&stale_unsigned).unwrap())
13844                .as_ref(),
13845        );
13846        let mut stale_exact = serde_json::to_vec(&stale_entry).unwrap();
13847        stale_exact.push(b'\n');
13848        let stale_item = FeedItem {
13849            hash: content_sha256(&stale_exact),
13850            entry: stale_entry,
13851        };
13852        assert!(
13853            reject_retired_signer_after_checkpoint(&identity, Some(&accepted), &stale_item)
13854                .is_err(),
13855            "a key retired before the checkpoint must never append after it"
13856        );
13857        assert!(
13858            verify_feed_item(&stale_item, &identity).is_err(),
13859            "an old key must never append after its signed rotation boundary"
13860        );
13861
13862        let mut missing = identity.clone();
13863        missing.rotations.clear();
13864        assert!(verify_identity_chain(&missing, Some(&pin)).is_err());
13865
13866        let mut tampered = identity;
13867        tampered.rotations[0] = tampered.rotations[0].replace("\"sig\":\"", "\"sig\":\"A");
13868        assert!(verify_identity_chain(&tampered, Some(&pin)).is_err());
13869    }
13870
13871    #[cfg(unix)]
13872    #[test]
13873    fn key_creation_refuses_a_planted_symlink_without_touching_its_target() {
13874        use std::os::unix::fs::symlink;
13875
13876        let dir = tempfile::tempdir().unwrap();
13877        let target = dir.path().join("valuable.txt");
13878        let planted = dir.path().join("agent.key");
13879        std::fs::write(&target, "do not overwrite").unwrap();
13880        symlink(&target, &planted).unwrap();
13881
13882        assert!(matches!(
13883            generate_agent_key(&planted),
13884            Err(LinkError::BadAgentKey { .. })
13885        ));
13886        assert_eq!(std::fs::read_to_string(target).unwrap(), "do not overwrite");
13887    }
13888
13889    #[cfg(unix)]
13890    #[test]
13891    fn key_creation_refuses_a_symlinked_parent_without_writing_through_it() {
13892        use std::os::unix::fs::symlink;
13893
13894        let root = tempfile::tempdir().unwrap();
13895        let outside = tempfile::tempdir().unwrap();
13896        symlink(outside.path(), root.path().join("redirect")).unwrap();
13897
13898        assert!(generate_agent_key(&root.path().join("redirect/agent.key")).is_err());
13899        assert!(!outside.path().join("agent.key").exists());
13900    }
13901
13902    // ── Address parsing ─────────────────────────────────────────────────────
13903
13904    #[test]
13905    fn address_bare_brain_with_and_without_sigil() {
13906        for raw in ["@acme-ops", "acme-ops"] {
13907            let a = Address::parse(raw).expect(raw);
13908            assert_eq!(a.brain, "acme-ops");
13909            assert_eq!(a.target, None);
13910        }
13911    }
13912
13913    #[test]
13914    fn address_ulid_target_parses_as_id() {
13915        let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
13916        assert_eq!(a.brain, "acme");
13917        assert_eq!(
13918            a.target,
13919            Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
13920        );
13921    }
13922
13923    #[test]
13924    fn address_md_path_target_parses_as_path() {
13925        let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
13926        assert_eq!(
13927            a.target,
13928            Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
13929        );
13930    }
13931
13932    #[test]
13933    fn address_rejects_malformed_forms() {
13934        for raw in [
13935            "",
13936            "@",
13937            "@/x",
13938            "@acme/",
13939            "@acme/../etc/passwd",
13940            "@acme/records/.hidden.md",
13941            "@ACME",             // uppercase is not a hub ref shape
13942            "@acme/notes/x.txt", // target is neither ULID nor .md path
13943            "@a b",              // whitespace
13944        ] {
13945            assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
13946        }
13947    }
13948
13949    // ── Path safety ─────────────────────────────────────────────────────────
13950
13951    #[test]
13952    fn safe_paths_accept_store_shapes_and_reject_escapes() {
13953        for ok in [
13954            "DB.md",
13955            "assets.jsonl",
13956            "records/clients/lumio.md",
13957            "sources/emails/2026/07/x.md",
13958        ] {
13959            assert!(safe_store_rel_path(ok), "should accept {ok:?}");
13960        }
13961        for bad in [
13962            "",
13963            "/etc/passwd",
13964            "../up.md",
13965            "records/../../up.md",
13966            "records//x.md",
13967            ".dbmd/config",
13968            "records/.hidden/x.md",
13969            "records/a b.md",
13970            "records\\win.md",
13971        ] {
13972            assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
13973        }
13974    }
13975
13976    #[cfg(unix)]
13977    #[test]
13978    fn opened_destination_capability_survives_an_ancestor_path_swap() {
13979        use std::os::unix::fs::symlink;
13980
13981        let work = tempfile::tempdir().unwrap();
13982        let outside = tempfile::tempdir().unwrap();
13983        let original = work.path().join("destination");
13984        let moved = work.path().join("destination-moved");
13985        let directory = open_or_create_dir_nofollow(&original).unwrap();
13986
13987        std::fs::rename(&original, &moved).unwrap();
13988        symlink(outside.path(), &original).unwrap();
13989        write_pull_entries_beneath_dir(
13990            &directory,
13991            &[("records/note.md".to_string(), b"held inode".to_vec())],
13992        )
13993        .unwrap();
13994
13995        assert_eq!(
13996            std::fs::read(moved.join("records/note.md")).unwrap(),
13997            b"held inode"
13998        );
13999        assert!(!outside.path().join("records/note.md").exists());
14000    }
14001
14002    // ── Config resolution (flag + file precedence; env is covered by the CLI
14003    //    integration tests, where a child process isolates it) ───────────────
14004
14005    #[test]
14006    fn hub_config_flag_beats_file_and_requires_some_source() {
14007        let dir = tempfile::tempdir().unwrap();
14008        std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
14009        std::fs::write(
14010            dir.path().join(CONFIG_REL_PATH),
14011            "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
14012        )
14013        .unwrap();
14014
14015        let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
14016        assert_eq!(from_flag.hub, "https://flag.example.com");
14017
14018        let from_file = hub_config(None, dir.path()).unwrap();
14019        assert_eq!(from_file.hub, "https://file.example.com");
14020
14021        let none = hub_config(None, tempfile::tempdir().unwrap().path());
14022        assert!(matches!(none, Err(LinkError::NoHub)));
14023    }
14024
14025    #[test]
14026    fn https_guard_allows_loopback_only_for_plain_http() {
14027        assert!(assert_safe_hub("https://hub.example.com").is_ok());
14028        assert!(assert_safe_hub("http://localhost:3000").is_ok());
14029        assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
14030        assert!(assert_safe_hub("http://[::1]:3000").is_ok());
14031        assert!(matches!(
14032            assert_safe_hub("http://hub.example.com"),
14033            Err(LinkError::UnsafeHub { .. })
14034        ));
14035        assert!(matches!(
14036            assert_safe_hub("hub.example.com"),
14037            Err(LinkError::UnsafeHub { .. })
14038        ));
14039        assert!(matches!(
14040            assert_safe_hub("http://localhost:80@127.0.0.1:1"),
14041            Err(LinkError::UnsafeHub { .. })
14042        ));
14043        assert!(matches!(
14044            assert_safe_hub("https://hub.example.com@attacker.example"),
14045            Err(LinkError::UnsafeHub { .. })
14046        ));
14047        assert!(matches!(
14048            assert_safe_hub("https://hub.example.com/base"),
14049            Err(LinkError::UnsafeHub { .. })
14050        ));
14051    }
14052
14053    #[test]
14054    fn registry_ssrf_classifier_rejects_local_private_and_documentation_ranges() {
14055        for blocked in [
14056            "127.0.0.1",
14057            "10.0.0.1",
14058            "100.64.0.1",
14059            "169.254.169.254",
14060            "172.16.0.1",
14061            "192.168.0.1",
14062            "192.88.99.1",
14063            "198.18.0.1",
14064            "203.0.113.1",
14065            "::1",
14066            "fe80::1",
14067            "fd00::1",
14068            "2001:db8::1",
14069            "2001:1::1",
14070            "2002:7f00:1::",
14071            "3fff::1",
14072        ] {
14073            assert!(
14074                !is_public_registry_ip(blocked.parse().unwrap()),
14075                "must block {blocked}"
14076            );
14077        }
14078        assert!(is_public_registry_ip("1.1.1.1".parse().unwrap()));
14079        assert!(is_public_registry_ip(
14080            "2606:4700:4700::1111".parse().unwrap()
14081        ));
14082        assert!(is_public_registry_ip("3fff:1000::1".parse().unwrap()));
14083    }
14084
14085    #[test]
14086    fn registry_dns_answer_is_pinned_and_cannot_rebind_or_change_authority() {
14087        use ureq::Resolver as _;
14088
14089        let pinned: std::net::SocketAddr = "1.1.1.1:443".parse().unwrap();
14090        let resolver = PinnedRegistryResolver {
14091            netloc: "home.example:443".to_string(),
14092            addresses: vec![pinned],
14093        };
14094        assert_eq!(resolver.resolve("home.example:443").unwrap(), vec![pinned]);
14095        assert!(resolver.resolve("127.0.0.1:443").is_err());
14096        assert_eq!(
14097            resolver.resolve("home.example:443").unwrap(),
14098            vec![pinned],
14099            "subsequent connects reuse the validated answer instead of DNS"
14100        );
14101    }
14102
14103    #[test]
14104    fn production_object_urls_and_store_selected_hubs_cannot_reach_private_ips() {
14105        let cfg = HubConfig {
14106            hub: "https://hub.example".to_string(),
14107            key: None,
14108            agent_key: None,
14109            brain_key: None,
14110            state_dir: tempfile::tempdir().unwrap().keep(),
14111            store_selected: false,
14112        };
14113        assert!(
14114            presigned_agent(&cfg, "https://127.0.0.1/private").is_err(),
14115            "a production hub must not turn its presigned URL into an SSRF primitive"
14116        );
14117
14118        let store_selected = HubConfig {
14119            hub: "https://127.0.0.1".to_string(),
14120            store_selected: true,
14121            ..cfg
14122        };
14123        assert!(
14124            hub_agent(&store_selected).is_err(),
14125            "bytes in a cloned store must not select a private-network hub"
14126        );
14127    }
14128
14129    #[test]
14130    fn presigned_download_ceiling_cannot_overflow_at_u64_max() {
14131        assert_eq!(
14132            one_past_bounded_limit(MAX_PACK_BYTES),
14133            Some(MAX_PACK_BYTES + 1),
14134            "the presigned reader consumes exactly one refusal byte beyond its fixed pack cap"
14135        );
14136        assert_eq!(
14137            presigned_download_read_limit(),
14138            MAX_PACK_BYTES + 1,
14139            "the presigned reader is capped by the client constant, not a hub response"
14140        );
14141        assert_eq!(
14142            one_past_bounded_limit(u64::MAX),
14143            None,
14144            "the former attacker-controlled u64::MAX + 1 shape must fail without overflow"
14145        );
14146    }
14147
14148    #[test]
14149    fn https_guard_matches_the_scheme_case_insensitively() {
14150        // RFC 3986 schemes are case-insensitive: an uppercase-scheme HTTPS
14151        // hub is still HTTPS, never a misleading non-HTTPS refusal.
14152        assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
14153        assert!(assert_safe_hub("Https://hub.example.com").is_ok());
14154        // And an uppercase plain-HTTP hub is still refused outside loopback.
14155        assert!(matches!(
14156            assert_safe_hub("HTTP://hub.example.com"),
14157            Err(LinkError::UnsafeHub { .. })
14158        ));
14159    }
14160
14161    #[test]
14162    fn clean_key_refuses_paste_artifacts_without_echoing() {
14163        assert_eq!(clean_key("  vc_account_abc  ").unwrap(), "vc_account_abc");
14164        for bad in ["vc account", "vc\naccount", "ключ", ""] {
14165            let err = clean_key(bad).unwrap_err();
14166            assert!(matches!(err, LinkError::BadKey));
14167            assert!(
14168                !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
14169                "error must not echo the key"
14170            );
14171        }
14172    }
14173
14174    // ── Verb entry gates: refs must never reshape the request path ──────────
14175
14176    /// A config whose hub passes the loopback guard but is never listened on:
14177    /// every refusal below must come from the entry gate BEFORE a request
14178    /// exists — a dial on this dead port would surface `Transport` instead.
14179    fn dead_hub() -> HubConfig {
14180        HubConfig {
14181            hub: "http://127.0.0.1:9".to_string(),
14182            key: Some("k".to_string()),
14183            agent_key: None,
14184            brain_key: None,
14185            state_dir: PathBuf::from("."),
14186            store_selected: false,
14187        }
14188    }
14189
14190    #[test]
14191    fn request_retries_a_connection_failure_before_sending() {
14192        use std::io::{Read as _, Write as _};
14193        use std::net::TcpListener;
14194        use std::thread;
14195        use std::time::Duration;
14196
14197        let probe = TcpListener::bind("127.0.0.1:0").unwrap();
14198        let address = probe.local_addr().unwrap();
14199        drop(probe);
14200        let server = thread::spawn(move || {
14201            thread::sleep(Duration::from_millis(40));
14202            let listener = TcpListener::bind(address).unwrap();
14203            let (mut stream, _) = listener.accept().unwrap();
14204            let mut request_bytes = [0_u8; 1024];
14205            let _ = stream.read(&mut request_bytes).unwrap();
14206            stream
14207                .write_all(
14208                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
14209                )
14210                .unwrap();
14211        });
14212        let cfg = HubConfig {
14213            hub: format!("http://{address}"),
14214            key: None,
14215            agent_key: None,
14216            brain_key: None,
14217            state_dir: tempfile::tempdir().unwrap().keep(),
14218            store_selected: false,
14219        };
14220
14221        let response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
14222        assert_eq!(response.status, 200);
14223        assert_eq!(response.body, Some(json!({ "ok": true })));
14224        server.join().unwrap();
14225    }
14226
14227    #[test]
14228    fn endpoint_cap_refuses_a_body_before_json_parsing() {
14229        let (hub, server) = scripted_json_hub(vec![(200, "x".repeat(2_048))]);
14230        let cfg = HubConfig {
14231            hub,
14232            key: None,
14233            agent_key: None,
14234            brain_key: None,
14235            state_dir: tempfile::tempdir().unwrap().keep(),
14236            store_selected: false,
14237        };
14238
14239        assert!(matches!(
14240            request_capped(&cfg, "GET", "/bounded", None, Auth::None, 1_024),
14241            Err(LinkError::ResponseTooLarge { .. })
14242        ));
14243        server.join().unwrap();
14244    }
14245
14246    #[test]
14247    fn overall_deadline_stops_a_dribbled_response_body() {
14248        use std::io::{Read as _, Write as _};
14249        use std::net::TcpListener;
14250        use std::time::{Duration, Instant};
14251
14252        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
14253        let url = format!("http://{}/dribble", listener.local_addr().unwrap());
14254        let server = std::thread::spawn(move || {
14255            let (mut stream, _) = listener.accept().unwrap();
14256            let mut request = [0_u8; 1024];
14257            let _ = stream.read(&mut request);
14258            stream
14259                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 32\r\nConnection: close\r\n\r\n")
14260                .unwrap();
14261            for byte in [b'x'; 32] {
14262                if stream.write_all(&[byte]).is_err() {
14263                    break;
14264                }
14265                std::thread::sleep(Duration::from_millis(40));
14266            }
14267        });
14268        let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
14269        let started = Instant::now();
14270        let response = http.get(&url).call().unwrap();
14271        let mut body = Vec::new();
14272        let error = response
14273            .into_reader()
14274            .read_to_end(&mut body)
14275            .expect_err("per-read progress must not reset the overall deadline");
14276        assert!(
14277            started.elapsed() < Duration::from_millis(700),
14278            "dribbled body exceeded the wall-clock budget: {error}"
14279        );
14280        server.join().unwrap();
14281    }
14282
14283    #[test]
14284    fn overall_deadline_stops_a_stalled_upload() {
14285        use std::net::TcpListener;
14286        use std::time::{Duration, Instant};
14287
14288        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
14289        let url = format!("http://{}/upload", listener.local_addr().unwrap());
14290        let server = std::thread::spawn(move || {
14291            let (_stream, _) = listener.accept().unwrap();
14292            // Never consume the request body. Once the kernel send buffer fills,
14293            // the client must leave on its absolute write deadline.
14294            std::thread::sleep(Duration::from_millis(600));
14295        });
14296        let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
14297        let body = vec![0x5a; 32 * 1024 * 1024];
14298        let started = Instant::now();
14299        let error = http
14300            .put(&url)
14301            .send_bytes(&body)
14302            .expect_err("stalled request-body writes must time out");
14303        assert!(
14304            started.elapsed() < Duration::from_millis(700),
14305            "stalled upload exceeded the wall-clock budget: {error}"
14306        );
14307        server.join().unwrap();
14308    }
14309
14310    #[test]
14311    fn verb_entry_gates_accept_the_hub_ref_shapes() {
14312        for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
14313            assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
14314            assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
14315            assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
14316        }
14317    }
14318
14319    #[test]
14320    fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
14321        let cfg = dead_hub();
14322        for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
14323            assert!(
14324                matches!(
14325                    sync_pull(&cfg, bad, None),
14326                    Err(LinkError::BadAddress { .. })
14327                ),
14328                "sync_pull must refuse {bad:?}"
14329            );
14330            assert!(
14331                matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
14332                "sync_push must refuse {bad:?}"
14333            );
14334            assert!(
14335                matches!(
14336                    grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
14337                    Err(LinkError::BadAddress { .. })
14338                ),
14339                "grant_issue must refuse {bad:?}"
14340            );
14341            assert!(
14342                matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
14343                "grant_list must refuse {bad:?}"
14344            );
14345            assert!(
14346                matches!(
14347                    grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
14348                    Err(LinkError::BadAddress { .. })
14349                ),
14350                "grant_revoke must refuse brain {bad:?}"
14351            );
14352            assert!(
14353                matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
14354                "head must refuse {bad:?}"
14355            );
14356        }
14357    }
14358
14359    #[test]
14360    fn grant_revoke_refuses_url_reshaping_grant_ids() {
14361        let cfg = dead_hub();
14362        for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
14363            assert!(
14364                matches!(
14365                    grant_revoke(&cfg, "acme", bad),
14366                    Err(LinkError::BadGrantId { .. })
14367                ),
14368                "grant_revoke must refuse grant id {bad:?}"
14369            );
14370        }
14371    }
14372
14373    #[test]
14374    fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
14375        let cfg = dead_hub();
14376        for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
14377            assert!(
14378                matches!(
14379                    propose(&cfg, bad, "intake", "hi"),
14380                    Err(LinkError::BadAddress { .. })
14381                ),
14382                "propose must refuse handle {bad:?}"
14383            );
14384        }
14385        let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
14386        assert!(matches!(
14387            propose(&cfg, "acme-site", "intake", &oversize),
14388            Err(LinkError::ProposeTooLarge { .. })
14389        ));
14390        // A clean handle + in-cap body passes both gates: the failure is now
14391        // the (dead) wire, proving the gates refuse shape, not the verb.
14392        assert!(matches!(
14393            propose(&cfg, "acme-site", "intake", "hi"),
14394            Err(LinkError::Transport { .. })
14395        ));
14396    }
14397
14398    #[test]
14399    fn resolve_refuses_a_hand_built_unsafe_address() {
14400        let cfg = dead_hub();
14401        for brain in ["../up", "a/b", "a?x", "a#f"] {
14402            let addr = Address {
14403                brain: brain.to_string(),
14404                target: None,
14405            };
14406            assert!(
14407                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
14408                "resolve must refuse brain {brain:?}"
14409            );
14410        }
14411        for target in [
14412            AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
14413            AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), // not the minted shape
14414            AddressTarget::Path("../up.md".to_string()),
14415            AddressTarget::Path("records/x.md#frag".to_string()),
14416        ] {
14417            let addr = Address {
14418                brain: "acme".to_string(),
14419                target: Some(target.clone()),
14420            };
14421            assert!(
14422                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
14423                "resolve must refuse target {target:?}"
14424            );
14425        }
14426    }
14427
14428    #[test]
14429    fn v2_final_barrier_refuses_to_advance_a_remote_ahead_checkout() {
14430        let mut local = std::collections::BTreeMap::new();
14431        local.insert("records/a.md".to_string(), ("a".repeat(64), 0));
14432        local.insert("records/b.md".to_string(), ("b".repeat(64), 0));
14433        let mut remote = std::collections::BTreeMap::new();
14434        remote.insert(
14435            "records/a.md".to_string(),
14436            V2BaselineFile {
14437                sha256: "c".repeat(64),
14438                bytes: 1,
14439                proof: None,
14440            },
14441        );
14442        remote.insert(
14443            "records/b.md".to_string(),
14444            V2BaselineFile {
14445                sha256: "b".repeat(64),
14446                bytes: 1,
14447                proof: None,
14448            },
14449        );
14450        assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
14451    }
14452
14453    #[test]
14454    fn v2_final_barrier_ignores_only_explicit_kept_home_paths() {
14455        let local = std::collections::BTreeMap::new();
14456        let mut remote = std::collections::BTreeMap::new();
14457        remote.insert(
14458            "private/local.md".to_string(),
14459            V2BaselineFile {
14460                sha256: "d".repeat(64),
14461                bytes: 1,
14462                proof: None,
14463            },
14464        );
14465        assert!(v2_riding_matches_remote(&local, &remote, |path| path == "private/local.md"));
14466        assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
14467    }
14468
14469    fn scoped_test_head(revision: &str) -> V2VerifiedHead {
14470        V2VerifiedHead {
14471            requested: TEST_BRAIN_ID.to_string(),
14472            brain_id: TEST_BRAIN_ID.to_string(),
14473            view_kind: "scoped".to_string(),
14474            view_revision: revision.to_string(),
14475            identity: V2HeadIdentity {
14476                custody: "hub".to_string(),
14477                fingerprint: "test".to_string(),
14478                public_key_spki: "test".to_string(),
14479                previous: Vec::new(),
14480                rotations: Vec::new(),
14481            },
14482            pointer: None,
14483            trust: TrustState {
14484                v: 2,
14485                origin: "https://hub.example".to_string(),
14486                requested: TEST_BRAIN_ID.to_string(),
14487                brain: TEST_BRAIN_ID.to_string(),
14488                home: None,
14489                anchor: "ed25519:test".to_string(),
14490                current: "ed25519:test".to_string(),
14491                head_seq: 0,
14492                feed_hash: None,
14493                rotations: Vec::new(),
14494                hub_signer: None,
14495                protocol_profile: Some("link-v2".to_string()),
14496            },
14497            alias: None,
14498        }
14499    }
14500
14501    #[test]
14502    fn accepted_v2_checkpoint_never_downgrades_after_profile_migration() {
14503        let mut trust = scoped_test_head(&"a".repeat(64)).trust;
14504        assert!(accepted_as_v2(&trust));
14505
14506        trust.protocol_profile = None;
14507        trust.hub_signer = Some("ed25519:hub".to_string());
14508        assert!(accepted_as_v2(&trust));
14509
14510        trust.hub_signer = None;
14511        assert!(!accepted_as_v2(&trust));
14512    }
14513
14514    fn scoped_test_baseline(revision: &str) -> V2SyncBaseline {
14515        V2SyncBaseline {
14516            v: 2,
14517            origin: "https://hub.example".to_string(),
14518            brain: TEST_BRAIN_ID.to_string(),
14519            head_seq: Some(0),
14520            commit_hash: None,
14521            content_root: None,
14522            asset_root: None,
14523            assets: std::collections::BTreeMap::new(),
14524            view_kind: Some("scoped".to_string()),
14525            view_revision: Some(revision.to_string()),
14526            projection_sha256: Some(scoped_projection_sha256(TEST_BRAIN_ID)),
14527            files: std::collections::BTreeMap::new(),
14528            local_policy_digest: None,
14529            local_eligibility: std::collections::BTreeMap::new(),
14530            remote_copy_remains: std::collections::BTreeMap::new(),
14531        }
14532    }
14533
14534    #[test]
14535    fn scoped_projection_is_a_valid_local_store_marker_but_never_rides() {
14536        let directory = tempfile::tempdir().unwrap();
14537        std::fs::write(
14538            directory.path().join("DB.md"),
14539            scoped_projection_bytes(TEST_BRAIN_ID),
14540        )
14541        .unwrap();
14542        let store = Store::open_strict(directory.path()).unwrap();
14543        let head = scoped_test_head(&"a".repeat(64));
14544        let baseline = scoped_test_baseline(&"a".repeat(64));
14545        let mut view = v2_local_files(&store).unwrap();
14546        remove_scoped_projection(&head, Some(&baseline), &mut view).unwrap();
14547        assert!(!view.riding.contains_key("DB.md"));
14548        assert!(!view.eligibility.contains_key("DB.md"));
14549    }
14550
14551    #[test]
14552    fn scoped_projection_edit_and_scope_transition_fail_closed() {
14553        let directory = tempfile::tempdir().unwrap();
14554        std::fs::write(
14555            directory.path().join("DB.md"),
14556            b"---\ntype: db-md\nscope: company\nowner: someone\n---\n",
14557        )
14558        .unwrap();
14559        let store = Store::open_strict(directory.path()).unwrap();
14560        let head = scoped_test_head(&"a".repeat(64));
14561        let baseline = scoped_test_baseline(&"a".repeat(64));
14562        let mut view = v2_local_files(&store).unwrap();
14563        assert!(matches!(
14564            remove_scoped_projection(&head, Some(&baseline), &mut view),
14565            Err(LinkError::ScopedProjectionModified)
14566        ));
14567
14568        let changed = scoped_test_head(&"b".repeat(64));
14569        assert!(matches!(
14570            ensure_v2_view_compatible(&changed, Some(&baseline)),
14571            Err(LinkError::ScopedViewChanged)
14572        ));
14573    }
14574
14575    #[test]
14576    fn scoped_view_metadata_is_explicitly_non_authoritative() {
14577        let head = scoped_test_head(&"a".repeat(64));
14578        let value: Value =
14579            serde_json::from_slice(&scoped_view_metadata(&head, 7).unwrap()).unwrap();
14580        assert_eq!(value["kind"], "link.md-scoped-view");
14581        assert_eq!(value["authoritative"], false);
14582        assert_eq!(value["visible_files"], 7);
14583        assert_eq!(value["brain"], TEST_BRAIN_ID);
14584    }
14585
14586    #[test]
14587    fn local_scoped_marker_requires_the_exact_generated_projection() {
14588        let directory = tempfile::tempdir().unwrap();
14589        std::fs::create_dir(directory.path().join(".dbmd")).unwrap();
14590        std::fs::write(
14591            directory.path().join("DB.md"),
14592            scoped_projection_bytes(TEST_BRAIN_ID),
14593        )
14594        .unwrap();
14595        let head = scoped_test_head(&"a".repeat(64));
14596        std::fs::write(
14597            directory.path().join(".dbmd/view.json"),
14598            scoped_view_metadata(&head, 0).unwrap(),
14599        )
14600        .unwrap();
14601        let store = Store::open_strict(directory.path()).unwrap();
14602        assert!(has_verified_local_scoped_view(&store));
14603
14604        std::fs::write(
14605            directory.path().join("DB.md"),
14606            b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
14607        )
14608        .unwrap();
14609        let altered = Store::open_strict(directory.path()).unwrap();
14610        assert!(!has_verified_local_scoped_view(&altered));
14611    }
14612
14613    fn signed_proposal_fixture() -> (V2VerifiedHead, String, Value) {
14614        use ring::signature::KeyPair as _;
14615
14616        let proposal_id = "01k2r7bm9w5x6e8nq3tjhv4cya".to_string();
14617        let rng = ring::rand::SystemRandom::new();
14618        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
14619        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
14620        let public_der = [ED25519_SPKI_PREFIX.as_slice(), pair.public_key().as_ref()].concat();
14621        let public_key = URL_SAFE_NO_PAD.encode(&public_der);
14622        let fingerprint = format!("{:x}", Sha256::digest(&public_der));
14623        let blob = b"new";
14624        let blob_hash = content_sha256(blob);
14625        let changes = json!({
14626            "mutation_id": "sync:proposal-fixture",
14627            "operations": [{
14628                "blob": blob_hash,
14629                "bytes": blob.len(),
14630                "expected": null,
14631                "op": "put",
14632                "path": "records/new.md",
14633            }],
14634            "reason": "fixture",
14635            "v": 2,
14636        });
14637        let changes_bytes = crate::linkmd_v2::canonical_bytes(&changes).unwrap();
14638        let changes_base64 = STANDARD.encode(&changes_bytes);
14639        let descriptor = json!({
14640            "base": null,
14641            "blobs": [{ "bytes": blob.len(), "sha256": blob_hash }],
14642            "changes_base64": changes_base64,
14643            "rebase": "strict",
14644            "v": 2,
14645        });
14646        let clear_hash = content_sha256(&crate::linkmd_v2::canonical_bytes(&descriptor).unwrap());
14647        let payload_hash = "b".repeat(64);
14648        let submitted_at = "2026-08-19T12:00:00.000Z";
14649        let claim = json!({
14650            "actor_root": {
14651                "actor_class": "foreign_key",
14652                "credential": "ed25519:fixture",
14653                "grants": ["01k2r7bm9w5x6e8nq3tjhv4cyb"],
14654                "organization": "01k2r7bm9w5x6e8nq3tjhv4cyc",
14655                "principal": "key:fixture",
14656                "role": null,
14657            },
14658            "brain": TEST_BRAIN_ID,
14659            "clear_sha256": clear_hash,
14660            "control_revision": "c".repeat(64),
14661            "mutation_id": "sync:proposal-fixture",
14662            "payload_sha256": payload_hash,
14663            "proposal_id": proposal_id,
14664            "submitted_at": submitted_at,
14665            "v": 2,
14666        });
14667        let claim_bytes = crate::linkmd_v2::canonical_bytes(&claim).unwrap();
14668        let envelope = json!({
14669            "claim": claim,
14670            "fingerprint": fingerprint,
14671            "public_key": public_key,
14672            "sig": URL_SAFE_NO_PAD.encode(pair.sign(&claim_bytes).as_ref()),
14673        });
14674        let envelope_bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
14675        let submission_hash =
14676            crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &envelope_bytes).unwrap();
14677        let mut head = scoped_test_head(&"c".repeat(64));
14678        head.view_kind = "full".to_string();
14679        head.trust.hub_signer = Some(format!("{fingerprint}:{public_key}"));
14680        let value = json!({
14681            "proposal": {
14682                "base": null,
14683                "blobs": [{
14684                    "bytes": blob.len(),
14685                    "endpoint": format!(
14686                        "/api/hub/brains/{TEST_BRAIN_ID}/v2/proposals/{proposal_id}/blob?sha256={blob_hash}"
14687                    ),
14688                    "sha256": blob_hash,
14689                }],
14690                "changes_base64": changes_base64,
14691                "clear_sha256": clear_hash,
14692                "expires_at": "2026-08-26T12:00:00.000Z",
14693                "id": proposal_id,
14694                "payload_sha256": payload_hash,
14695                "proposer": { "class": "foreign_key" },
14696                "rebase": "strict",
14697                "state": "pending",
14698                "submission_claim_base64": STANDARD.encode(envelope_bytes),
14699                "submission_claim_sha256": submission_hash,
14700                "submitted_at": submitted_at,
14701            },
14702            "v": 2,
14703        });
14704        (head, proposal_id, value)
14705    }
14706
14707    #[test]
14708    fn v2_proposal_verifier_accepts_exact_signed_payload() {
14709        let (head, proposal_id, value) = signed_proposal_fixture();
14710        let verified = verify_v2_proposal_value(&head, &proposal_id, value).unwrap();
14711        assert_eq!(verified.blobs.len(), 1);
14712        assert_eq!(verified.changes["operations"][0]["path"], "records/new.md");
14713    }
14714
14715    #[test]
14716    fn v2_proposal_verifier_refuses_payload_endpoint_and_signature_tampering() {
14717        let (head, proposal_id, value) = signed_proposal_fixture();
14718
14719        let mut changed = value.clone();
14720        changed["proposal"]["changes_base64"] = Value::String(STANDARD.encode(b"{}"));
14721        assert!(verify_v2_proposal_value(&head, &proposal_id, changed).is_err());
14722
14723        let mut redirected = value.clone();
14724        redirected["proposal"]["blobs"][0]["endpoint"] =
14725            Value::String("https://attacker.example/blob".to_string());
14726        assert!(verify_v2_proposal_value(&head, &proposal_id, redirected).is_err());
14727
14728        let mut forged = value;
14729        let encoded = forged["proposal"]["submission_claim_base64"]
14730            .as_str()
14731            .unwrap();
14732        let mut envelope: Value =
14733            serde_json::from_slice(&STANDARD.decode(encoded).unwrap()).unwrap();
14734        envelope["sig"] = Value::String(URL_SAFE_NO_PAD.encode([0_u8; 64]));
14735        let bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
14736        forged["proposal"]["submission_claim_base64"] = Value::String(STANDARD.encode(&bytes));
14737        forged["proposal"]["submission_claim_sha256"] = Value::String(
14738            crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &bytes).unwrap(),
14739        );
14740        assert!(verify_v2_proposal_value(&head, &proposal_id, forged).is_err());
14741    }
14742
14743    #[cfg(unix)]
14744    #[test]
14745    fn v2_atomic_install_materializes_only_local_catalogs_before_swap() {
14746        let sandbox = tempfile::tempdir().unwrap();
14747        let destination = sandbox.path().join("brain");
14748        let entries = vec![
14749            (
14750                "DB.md".to_string(),
14751                scoped_projection_bytes(TEST_BRAIN_ID),
14752            ),
14753            (
14754                "records/contacts/a.md".to_string(),
14755                b"---\ntype: contact\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Visible contact\n---\n\n# A\n"
14756                    .to_vec(),
14757            ),
14758        ];
14759        install_pulled_delta(&destination, &entries, &[], true).unwrap();
14760        assert!(destination.join("index.md").is_file());
14761        assert!(destination.join("records/index.md").is_file());
14762        assert!(destination.join("records/contacts/index.md").is_file());
14763        assert!(destination.join("records/contacts/index.jsonl").is_file());
14764    }
14765
14766    #[test]
14767    fn v2_bulk_stream_is_exact_ordered_and_tamper_evident() {
14768        let body = b"bounded bytes";
14769        let path = "records/example.md".to_string();
14770        let file = V2BaselineFile {
14771            sha256: content_sha256(body),
14772            bytes: body.len() as u64,
14773            proof: None,
14774        };
14775        let header = serde_json::to_vec(&json!({
14776            "bytes": body.len(),
14777            "path": path,
14778            "sha256": file.sha256,
14779            "v": 2,
14780        }))
14781        .unwrap();
14782        let mut stream = V2_BULK_STREAM_MAGIC.to_vec();
14783        stream.extend_from_slice(&(header.len() as u32).to_be_bytes());
14784        stream.extend_from_slice(&header);
14785        stream.extend_from_slice(body);
14786        stream.extend_from_slice(&0_u32.to_be_bytes());
14787        let parsed = parse_v2_bulk_stream(&stream, &[(&path, &file)]).unwrap();
14788        assert_eq!(parsed, vec![(path.clone(), body.to_vec())]);
14789
14790        let mut tampered = stream.clone();
14791        let body_offset = V2_BULK_STREAM_MAGIC.len() + 4 + header.len();
14792        tampered[body_offset] ^= 1;
14793        assert!(parse_v2_bulk_stream(&tampered, &[(&path, &file)]).is_err());
14794
14795        let mut trailing = stream;
14796        trailing.push(0);
14797        assert!(parse_v2_bulk_stream(&trailing, &[(&path, &file)]).is_err());
14798    }
14799
14800    #[test]
14801    fn conflict_cache_prunes_only_expired_or_incomplete_state_by_default() {
14802        let sandbox = tempfile::TempDir::new().unwrap();
14803        let root = sandbox.path().join("brain");
14804        std::fs::create_dir_all(&root).unwrap();
14805        std::fs::write(
14806            root.join("DB.md"),
14807            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
14808        )
14809        .unwrap();
14810        let store = Store::open_strict(&root).unwrap();
14811        let incomplete = crate::ulid::mint();
14812        store
14813            .create_dir_all(&v2_conflict_relative(&incomplete, "files"))
14814            .unwrap();
14815        let expired = crate::ulid::mint();
14816        store
14817            .create_dir_all(&v2_conflict_relative(&expired, "files"))
14818            .unwrap();
14819        let plan = V2ConflictPlan {
14820            v: 2,
14821            class: "content_resolution_required".to_string(),
14822            bundle: expired.clone(),
14823            brain: TEST_BRAIN_ID.to_string(),
14824            origin: "https://example.test".to_string(),
14825            created_unix: 0,
14826            expires_unix: 0,
14827            base_seq: None,
14828            base_commit: None,
14829            remote_seq: 0,
14830            remote_commit: None,
14831            remote_content_root: None,
14832            view_kind: "full".to_string(),
14833            view_revision: "a".repeat(64),
14834            files: vec![V2ConflictFile {
14835                path: "records/value.md".to_string(),
14836                base: V2ConflictCoordinate {
14837                    sha256: None,
14838                    bytes: None,
14839                    file: None,
14840                },
14841                local: V2ConflictCoordinate {
14842                    sha256: None,
14843                    bytes: None,
14844                    file: None,
14845                },
14846                remote: V2ConflictCoordinate {
14847                    sha256: None,
14848                    bytes: None,
14849                    file: None,
14850                },
14851            }],
14852        };
14853        let mut bytes = serde_json::to_vec(&plan).unwrap();
14854        bytes.push(b'\n');
14855        store
14856            .write_atomic_new(&v2_conflict_relative(&expired, "plan.json"), &bytes)
14857            .unwrap();
14858
14859        let listed = sync_conflicts(&root, false, false).unwrap();
14860        assert_eq!(listed["bundles"], 2);
14861        assert_eq!(listed["pruned"], 0);
14862        let pruned = sync_conflicts(&root, true, false).unwrap();
14863        assert_eq!(pruned["bundles"], 0);
14864        assert_eq!(pruned["pruned"], 2);
14865        assert!(!root.join(".dbmd/conflicts").join(incomplete).exists());
14866        assert!(!root.join(".dbmd/conflicts").join(expired).exists());
14867    }
14868
14869    #[test]
14870    fn corrupt_completed_conflict_state_requires_explicit_discard_all() {
14871        let sandbox = tempfile::TempDir::new().unwrap();
14872        let root = sandbox.path().join("brain");
14873        std::fs::create_dir_all(&root).unwrap();
14874        std::fs::write(
14875            root.join("DB.md"),
14876            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
14877        )
14878        .unwrap();
14879        let store = Store::open_strict(&root).unwrap();
14880        let bundle = crate::ulid::mint();
14881        store
14882            .create_dir_all(&v2_conflict_relative(&bundle, "files"))
14883            .unwrap();
14884        store
14885            .write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), b"not-json\n")
14886            .unwrap();
14887
14888        assert!(sync_conflicts(&root, true, false).is_err());
14889        assert!(sync_conflicts(&root, false, true).is_err());
14890        let pruned = sync_conflicts(&root, true, true).unwrap();
14891        assert_eq!(pruned["pruned"], 1);
14892        assert!(!root.join(".dbmd/conflicts").join(bundle).exists());
14893    }
14894
14895    #[cfg(windows)]
14896    #[test]
14897    fn windows_ready_pull_journal_rolls_back_exact_preimages() {
14898        let sandbox = tempfile::TempDir::new().unwrap();
14899        let root = sandbox.path().join("brain");
14900        std::fs::create_dir_all(root.join("records")).unwrap();
14901        std::fs::write(
14902            root.join("DB.md"),
14903            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
14904        )
14905        .unwrap();
14906        let path = "records/value.md";
14907        let old = b"---\ntype: note\n---\n\nold\n";
14908        let new = b"---\ntype: note\n---\n\nnew\n";
14909        std::fs::write(root.join(path), old).unwrap();
14910        let store = Store::open_strict(&root).unwrap();
14911        let bundle = crate::ulid::mint();
14912        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
14913        store.create_dir_all(Path::new(&backup_dir)).unwrap();
14914        store
14915            .write_atomic_new(&Path::new(&backup_dir).join("00000000"), old)
14916            .unwrap();
14917        let journal = WindowsPullJournal {
14918            v: 1,
14919            phase: WindowsPullPhase::Ready,
14920            brain: TEST_BRAIN_ID.to_string(),
14921            previous: WindowsPullCoordinate {
14922                head_seq: Some(1),
14923                commit_hash: Some("a".repeat(64)),
14924                view_kind: Some("full".to_string()),
14925                view_revision: Some("b".repeat(64)),
14926            },
14927            next: WindowsPullCoordinate {
14928                head_seq: Some(2),
14929                commit_hash: Some("c".repeat(64)),
14930                view_kind: Some("full".to_string()),
14931                view_revision: Some("d".repeat(64)),
14932            },
14933            backup_dir: backup_dir.clone(),
14934            entries: vec![WindowsPullJournalEntry {
14935                path: path.to_string(),
14936                old: Some(WindowsPullFileCoordinate {
14937                    sha256: content_sha256(old),
14938                    bytes: old.len() as u64,
14939                }),
14940                new: Some(WindowsPullFileCoordinate {
14941                    sha256: content_sha256(new),
14942                    bytes: new.len() as u64,
14943                }),
14944                backup: Some("00000000".to_string()),
14945            }],
14946        };
14947        validate_windows_pull_journal(&journal).unwrap();
14948        store
14949            .write_atomic_new(
14950                Path::new(WINDOWS_PULL_JOURNAL),
14951                &windows_pull_journal_bytes(&journal).unwrap(),
14952            )
14953            .unwrap();
14954        store.write_atomic(Path::new(path), new).unwrap();
14955
14956        let cfg = test_hub_config(
14957            "https://example.test".to_string(),
14958            sandbox.path().join("state"),
14959        );
14960        recover_windows_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
14961        assert_eq!(std::fs::read(root.join(path)).unwrap(), old);
14962        assert!(!root.join(WINDOWS_PULL_JOURNAL).exists());
14963        assert!(!root.join(backup_dir).exists());
14964    }
14965
14966    #[cfg(windows)]
14967    #[test]
14968    fn windows_preparing_pull_journal_discards_only_private_staging() {
14969        let sandbox = tempfile::TempDir::new().unwrap();
14970        let root = sandbox.path().join("brain");
14971        std::fs::create_dir_all(root.join(".dbmd")).unwrap();
14972        std::fs::write(
14973            root.join("DB.md"),
14974            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
14975        )
14976        .unwrap();
14977        let store = Store::open_strict(&root).unwrap();
14978        let bundle = crate::ulid::mint();
14979        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
14980        store.create_dir_all(Path::new(&backup_dir)).unwrap();
14981        let journal = WindowsPullJournal {
14982            v: 1,
14983            phase: WindowsPullPhase::Preparing,
14984            brain: TEST_BRAIN_ID.to_string(),
14985            previous: WindowsPullCoordinate {
14986                head_seq: None,
14987                commit_hash: None,
14988                view_kind: None,
14989                view_revision: None,
14990            },
14991            next: WindowsPullCoordinate {
14992                head_seq: Some(1),
14993                commit_hash: Some("a".repeat(64)),
14994                view_kind: Some("full".to_string()),
14995                view_revision: Some("b".repeat(64)),
14996            },
14997            backup_dir: backup_dir.clone(),
14998            entries: vec![WindowsPullJournalEntry {
14999                path: "records/new.md".to_string(),
15000                old: None,
15001                new: Some(WindowsPullFileCoordinate {
15002                    sha256: "c".repeat(64),
15003                    bytes: 1,
15004                }),
15005                backup: None,
15006            }],
15007        };
15008        store
15009            .write_atomic_new(
15010                Path::new(WINDOWS_PULL_JOURNAL),
15011                &windows_pull_journal_bytes(&journal).unwrap(),
15012            )
15013            .unwrap();
15014        let cfg = test_hub_config(
15015            "https://example.test".to_string(),
15016            sandbox.path().join("state"),
15017        );
15018
15019        recover_windows_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
15020
15021        assert!(root.join("DB.md").is_file());
15022        assert!(!root.join(WINDOWS_PULL_JOURNAL).exists());
15023        assert!(!root.join(backup_dir).exists());
15024    }
15025
15026    #[cfg(windows)]
15027    #[test]
15028    fn windows_committed_baseline_keeps_installed_bytes_and_prunes_orphans() {
15029        let sandbox = tempfile::TempDir::new().unwrap();
15030        let root = sandbox.path().join("brain");
15031        std::fs::create_dir_all(root.join("records")).unwrap();
15032        std::fs::write(
15033            root.join("DB.md"),
15034            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
15035        )
15036        .unwrap();
15037        let new = b"---\ntype: note\n---\n\nnew\n";
15038        std::fs::write(root.join("records/value.md"), new).unwrap();
15039        let store = Store::open_strict(&root).unwrap();
15040        let bundle = crate::ulid::mint();
15041        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
15042        store.create_dir_all(Path::new(&backup_dir)).unwrap();
15043        let orphan = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
15044        store.create_dir_all(Path::new(&orphan)).unwrap();
15045        let next = WindowsPullCoordinate {
15046            head_seq: Some(2),
15047            commit_hash: Some("c".repeat(64)),
15048            view_kind: Some("full".to_string()),
15049            view_revision: Some("d".repeat(64)),
15050        };
15051        let journal = WindowsPullJournal {
15052            v: 1,
15053            phase: WindowsPullPhase::Ready,
15054            brain: TEST_BRAIN_ID.to_string(),
15055            previous: WindowsPullCoordinate {
15056                head_seq: Some(1),
15057                commit_hash: Some("a".repeat(64)),
15058                view_kind: Some("full".to_string()),
15059                view_revision: Some("b".repeat(64)),
15060            },
15061            next: next.clone(),
15062            backup_dir: backup_dir.clone(),
15063            entries: vec![WindowsPullJournalEntry {
15064                path: "records/value.md".to_string(),
15065                old: Some(WindowsPullFileCoordinate {
15066                    sha256: "e".repeat(64),
15067                    bytes: new.len() as u64,
15068                }),
15069                new: Some(WindowsPullFileCoordinate {
15070                    sha256: content_sha256(new),
15071                    bytes: new.len() as u64,
15072                }),
15073                backup: Some("00000000".to_string()),
15074            }],
15075        };
15076        store
15077            .write_atomic_new(
15078                Path::new(WINDOWS_PULL_JOURNAL),
15079                &windows_pull_journal_bytes(&journal).unwrap(),
15080            )
15081            .unwrap();
15082        let cfg = test_hub_config(
15083            "https://example.test".to_string(),
15084            sandbox.path().join("state"),
15085        );
15086        save_v2_baseline(
15087            &cfg,
15088            TEST_BRAIN_ID,
15089            &root,
15090            &V2SyncBaseline {
15091                v: 2,
15092                origin: "https://example.test".to_string(),
15093                brain: TEST_BRAIN_ID.to_string(),
15094                head_seq: next.head_seq,
15095                commit_hash: next.commit_hash.clone(),
15096                content_root: Some("f".repeat(64)),
15097                asset_root: None,
15098                assets: Default::default(),
15099                view_kind: next.view_kind.clone(),
15100                view_revision: next.view_revision.clone(),
15101                projection_sha256: None,
15102                files: Default::default(),
15103                local_policy_digest: None,
15104                local_eligibility: Default::default(),
15105                remote_copy_remains: Default::default(),
15106            },
15107        )
15108        .unwrap();
15109
15110        recover_windows_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
15111
15112        assert_eq!(std::fs::read(root.join("records/value.md")).unwrap(), new);
15113        assert!(!root.join(WINDOWS_PULL_JOURNAL).exists());
15114        assert!(!root.join(backup_dir).exists());
15115        assert!(!root.join(orphan).exists());
15116    }
15117}