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/// File durability barriers are independent inside the private pull stage.
175/// Keep enough in flight to hide storage flush latency without approaching
176/// ordinary process descriptor limits.
177#[cfg(unix)]
178const V2_PULL_INSTALL_WORKERS: usize = 16;
179/// One authenticated link.md v2 bulk frame remains small enough for bounded
180/// retries and serverless streaming while amortizing tens of thousands of
181/// immutable blobs over a few hundred requests instead of one request each.
182const V2_BULK_STREAM_FILES: usize = 256;
183const V2_BULK_STREAM_CONTENT_BYTES: u64 = 8 * 1024 * 1024;
184const V2_BULK_STREAM_RESPONSE_BYTES: u64 = 10 * 1024 * 1024;
185const V2_BULK_STREAM_MAGIC: &[u8; 8] = b"LMD2STRM";
186
187/// Everything that can go wrong on the wire or at its edges. Each variant maps
188/// onto one stable CLI error code; messages are single-line and never echo the
189/// credential.
190#[derive(Debug, thiserror::Error)]
191pub enum LinkError {
192    /// No hub URL was configured anywhere (flag, env, `.dbmd/config`).
193    #[error(
194        "no hub configured — pass --hub <URL>, set {HUB_URL_ENV}, or add `hub = <URL>` to {CONFIG_REL_PATH}"
195    )]
196    NoHub,
197
198    /// The verb needs a credential and none was present.
199    #[error("no hub credential — set {HUB_KEY_ENV} (credentials never live in {CONFIG_REL_PATH})")]
200    NoCredential,
201
202    /// The credential contains whitespace / non-ASCII (a paste artifact). The
203    /// key is deliberately not echoed.
204    #[error(
205        "the hub credential in {HUB_KEY_ENV} contains whitespace or non-ASCII characters — re-copy it (the key is not shown here on purpose)"
206    )]
207    BadKey,
208
209    /// A store selected the destination while an ambient credential was
210    /// present, but the operator did not bind that credential to the same
211    /// origin. This is a hard refusal, not an anonymous fallback: silently
212    /// dropping a credential can turn an intended private operation into a
213    /// confusing public one.
214    #[error(
215        "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}"
216    )]
217    UnboundCredential,
218
219    /// The agent signing key file named by [`AGENT_KEY_FILE_ENV`] is missing,
220    /// unreadable, or not a valid Ed25519 PKCS#8 — key material is never
221    /// echoed.
222    #[error("invalid agent signing key ({message}) — mint one with `dbmd key generate`")]
223    BadAgentKey {
224        /// What failed, without any key material.
225        message: String,
226    },
227
228    /// A non-HTTPS hub outside loopback: the bearer key would travel in cleartext.
229    #[error("refusing non-HTTPS hub {hub} — the credential would travel in cleartext (localhost is exempt)")]
230    UnsafeHub {
231        /// The offending hub URL.
232        hub: String,
233    },
234
235    /// TCP/TLS-level failure: the hub never answered.
236    #[error("hub unreachable at {hub}: {message}")]
237    Transport {
238        /// The hub base URL.
239        hub: String,
240        /// The transport-layer error text.
241        message: String,
242    },
243
244    /// The hub answered with an HTTP error status.
245    #[error("{what} failed (HTTP {status}): {message}")]
246    Http {
247        /// What the client was doing (e.g. `"resolve"`, `"sync pull"`).
248        what: &'static str,
249        /// The HTTP status code.
250        status: u16,
251        /// The hub's own `error` string when it sent one, else a placeholder.
252        message: String,
253        /// The hub's machine `code` field when it sent one.
254        code: Option<String>,
255        /// Permission-filtered structured refusal details, when supplied.
256        details: Option<Value>,
257    },
258
259    /// A 2xx whose body is not JSON — a captive portal, a proxy, or a wrong
260    /// URL — refused here rather than deserializing into nothing downstream.
261    #[error("{what}: the hub answered HTTP {status} with a non-JSON body — check the hub URL")]
262    NotJson {
263        /// What the client was doing.
264        what: &'static str,
265        /// The (2xx) status that carried the non-JSON body.
266        status: u16,
267    },
268
269    /// The hub response exceeded the selected endpoint's byte cap.
270    #[error("hub response exceeded the {limit_bytes}-byte endpoint cap — refusing to buffer it")]
271    ResponseTooLarge {
272        /// The endpoint-specific cap applied before JSON parsing.
273        limit_bytes: u64,
274    },
275
276    /// A malformed `@brain/id` address.
277    #[error("invalid address `{given}`: {reason}")]
278    BadAddress {
279        /// The raw address as typed.
280        given: String,
281        /// Why it did not parse.
282        reason: String,
283    },
284
285    /// A grant id whose shape cannot travel as a URL path segment.
286    #[error(
287        "invalid grant id `{given}` — grant ids come from `grant list` (lowercase letters, digits, hyphens)"
288    )]
289    BadGrantId {
290        /// The raw id as typed.
291        given: String,
292    },
293
294    /// An exported file path that would escape or pollute the destination
295    /// (absolute, `..`, a dot-leading segment, or an illegal character). The
296    /// hub is not trusted with local path layout.
297    #[error("refusing unsafe path from the hub: `{path}`")]
298    UnsafePath {
299        /// The offending path as received.
300        path: String,
301    },
302
303    /// The store exceeds the hub's bounded whole-snapshot caps.
304    #[error(
305        "push too large ({detail}) — one snapshot caps at {} MB uncompressed, {} MB as a pack, and {MAX_PUSH_FILES} files",
306        MAX_STORE_BYTES / (1024 * 1024),
307        MAX_PACK_BYTES / (1024 * 1024)
308    )]
309    PushTooLarge {
310        /// Which cap was hit, human-readable.
311        detail: String,
312    },
313
314    /// The propose body exceeds the hub's inbox cap.
315    #[error(
316        "propose body too large ({bytes} bytes) — the hub's inbox caps one submission at {} KB",
317        MAX_PROPOSE_BYTES / 1024
318    )]
319    ProposeTooLarge {
320        /// The offending body size in bytes.
321        bytes: u64,
322    },
323
324    /// A store file that is not valid UTF-8 cannot travel the JSON push path.
325    #[error("store file `{path}` is not valid UTF-8 — the JSON push path carries text only")]
326    NotUtf8 {
327        /// The store-relative path of the offending file.
328        path: String,
329    },
330
331    /// A downloaded pack failed validation before any local write.
332    #[error("invalid store pack: {message}")]
333    InvalidPack {
334        /// Hash, ZIP, path, count, or expansion failure.
335        message: String,
336    },
337
338    /// A signed feed entry, hash chain, or advertised feed head did not verify.
339    #[error("invalid signed feed: {message}")]
340    InvalidFeed {
341        /// The failed integrity condition, without untrusted secret material.
342        message: String,
343    },
344
345    /// Local and remote both changed one or more coordinates since the last
346    /// verified sync baseline. No side was overwritten.
347    #[error("sync conflict on {paths:?} — resolve the named files and retry")]
348    Conflict {
349        /// Bounded, portable paths safe to show to the agent/operator.
350        paths: Vec<String>,
351    },
352
353    /// A readable v2 conflict was preserved as a private, exact-head bundle.
354    /// The bundle is local control state; none of its bytes ride with the
355    /// brain, and resolving it always creates a fresh explicit mutation.
356    #[error(
357        "sync conflict preserved in private bundle `{bundle}` for {paths:?} — run `dbmd sync resolve {bundle} --keep-local`, `--take-remote`, or `--from <safe-file>`"
358    )]
359    ConflictBundle {
360        /// Local ULID naming `.dbmd/conflicts/<bundle>/plan.json`.
361        bundle: String,
362        /// Bounded, readable conflict paths.
363        paths: Vec<String>,
364    },
365
366    /// `.sevralocal` newly made paths eligible to ride. Uploading them is an
367    /// explicit adoption boundary, never an accidental side effect of editing
368    /// or removing a local policy file.
369    #[error(
370        "local sync policy newly exposes {paths:?} — review and retry with --resume-local-policy"
371    )]
372    LocalPolicyTransition {
373        /// Bounded local-only paths. They are never sent in telemetry.
374        paths: Vec<String>,
375    },
376
377    /// The exact mutation crosses a permissioned bulk-impact boundary. The
378    /// hub has not committed it; `preview` is the bounded, permission-filtered
379    /// receipt an agent must inspect before explicitly confirming the same
380    /// request.
381    #[error(
382        "bulk change requires explicit confirmation — review the preview and retry the same sync with --confirm-bulk <id>:<digest>"
383    )]
384    BulkPreviewRequired {
385        /// Stable structured preview returned by the hub.
386        preview: Value,
387    },
388
389    /// A scoped checkout's generated store marker was edited or removed.
390    /// It is local projection metadata and is never accepted as brain data.
391    #[error(
392        "the generated DB.md for this scoped view was modified — clone a fresh scoped checkout"
393    )]
394    ScopedProjectionModified,
395
396    /// The effective permission slice changed after this checkout was pinned.
397    /// Reusing the directory could conceal removals or accidentally adopt files
398    /// revealed by a wider grant, so a new checkout is required.
399    #[error(
400        "the checkout's permission scope changed — clone into a new directory to accept the new view"
401    )]
402    ScopedViewChanged,
403
404    /// A v2 identity/head was previously accepted for this ref, but the hub
405    /// now hides it. Never reinterpret that as a v1 downgrade.
406    #[error("the previously verified brain is unavailable — access may have been revoked or the brain removed")]
407    BrainUnavailable,
408
409    /// The verified remote head advanced while a pull or post-commit barrier
410    /// was in flight. The old baseline is deliberately retained.
411    #[error(
412        "the remote brain advanced during sync — retry to converge from the new verified head"
413    )]
414    RemoteAdvancedDuringSync,
415
416    /// This build cannot provide the no-follow, directory-handle-relative
417    /// filesystem semantics required for trust/key/snapshot state.
418    #[error(
419        "{operation} is unavailable on this platform — use the official macOS/Linux build or WSL"
420    )]
421    UnsupportedPlatform {
422        /// The operation that requires hardened local filesystem primitives.
423        operation: &'static str,
424    },
425
426    /// Local filesystem failure while materializing a pull or reading a push.
427    #[error(transparent)]
428    Io(#[from] std::io::Error),
429
430    /// A store-level failure (walking the local store for a push).
431    #[error(transparent)]
432    Store(#[from] crate::StoreError),
433}
434
435/// Result alias for link.md client operations.
436pub type LinkResult<T> = std::result::Result<T, LinkError>;
437
438/// Exact approval token returned by a permissioned v2 bulk preview.
439#[derive(Debug, Clone, PartialEq, Eq)]
440pub struct V2BulkConfirmation {
441    /// Opaque preview receipt id.
442    pub id: String,
443    /// Digest binding the receipt to principal, head, control revision, and
444    /// the exact strict mutation.
445    pub digest: String,
446}
447
448impl V2BulkConfirmation {
449    /// Parse the CLI/wire handoff form `<id>:<digest>` and reject anything
450    /// outside the protocol's exact lowercase shapes before network I/O.
451    pub fn parse(value: &str) -> LinkResult<Self> {
452        let (id, digest) = value
453            .split_once(':')
454            .ok_or_else(|| LinkError::InvalidPack {
455                message: "bulk confirmation must be <id>:<digest>".to_string(),
456            })?;
457        if !crate::ulid::is_ulid(id) || !is_sha256(digest) {
458            return Err(LinkError::InvalidPack {
459                message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
460                    .to_string(),
461            });
462        }
463        Ok(Self {
464            id: id.to_string(),
465            digest: digest.to_string(),
466        })
467    }
468}
469
470/// Security-sensitive link.md state and destination writes require a hardened
471/// descriptor/handle-relative filesystem implementation. Unix uses
472/// `openat`/`O_NOFOLLOW`; Windows uses held directory handles, reparse-point
473/// refusal, exclusive share modes, and write-through replacement.
474fn require_hardened_filesystem(operation: &'static str) -> LinkResult<()> {
475    #[cfg(any(target_os = "linux", target_os = "macos", windows))]
476    {
477        let _ = operation;
478        Ok(())
479    }
480    #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
481    {
482        Err(LinkError::UnsupportedPlatform { operation })
483    }
484}
485
486// ─────────────────────────────────────────────────────────────────────────────
487// Addressing — `@brain[/id]`, the reserved shape (SPEC § Addressing)
488// ─────────────────────────────────────────────────────────────────────────────
489
490/// What the part after `@brain/` names.
491#[derive(Debug, Clone, PartialEq, Eq)]
492pub enum AddressTarget {
493    /// A record `id` — the db.md lowercase ULID (the reserved `@brain/id` shape).
494    Id(String),
495    /// A store-relative `.md` path — a client-side convenience the hub's
496    /// resolve endpoint also accepts (`?path=`). Not part of the reserved
497    /// shape; unambiguous because a ULID is never a path.
498    Path(String),
499}
500
501/// Why a brain reference failed [`is_safe_ref`] — shared by [`Address::parse`]
502/// and the per-verb entry gates so the two surfaces never drift.
503const BAD_BRAIN_REASON: &str =
504    "the brain reference must be a brain id (lowercase ULID) or a slug (lowercase letters, digits, hyphens)";
505
506/// Why an address target failed its shape check — shared by [`Address::parse`]
507/// and the [`resolve`] entry gate.
508const BAD_TARGET_REASON: &str =
509    "the part after `/` must be a record id (lowercase ULID) or a store-relative `.md` path";
510
511/// A parsed `@brain[/target]` address. `brain` is a hub brain reference — the
512/// brain's ULID id (works for any caller, including cross-party on a public
513/// brain) or a slug (which a hub resolves only against the caller's own
514/// brains; slugs are unique per owner, not globally).
515#[derive(Debug, Clone, PartialEq, Eq)]
516pub struct Address {
517    /// The brain reference (leading `@` stripped).
518    pub brain: String,
519    /// The record target, when the address names one.
520    pub target: Option<AddressTarget>,
521}
522
523impl Address {
524    /// Parse `@brain`, `@brain/<ulid>`, or `@brain/<store-path>.md`. The `@`
525    /// sigil is optional (an agent piping ids around should not have to quote
526    /// it back on). Whitespace and empty segments are malformed.
527    pub fn parse(raw: &str) -> LinkResult<Address> {
528        let bad = |reason: &str| LinkError::BadAddress {
529            given: raw.to_string(),
530            reason: reason.to_string(),
531        };
532
533        let trimmed = raw.trim();
534        let body = trimmed.strip_prefix('@').unwrap_or(trimmed);
535        if body.is_empty() {
536            return Err(bad("empty address"));
537        }
538
539        let (brain, rest) = match body.split_once('/') {
540            Some((b, r)) => (b, Some(r)),
541            None => (body, None),
542        };
543
544        if brain.is_empty() {
545            return Err(bad("missing brain reference before `/`"));
546        }
547        if !is_safe_ref(brain) {
548            return Err(bad(BAD_BRAIN_REASON));
549        }
550
551        let target = match rest {
552            None => None,
553            Some("") => return Err(bad("trailing `/` with no record id or path")),
554            Some(r) if crate::ulid::is_ulid(r) => Some(AddressTarget::Id(r.to_string())),
555            Some(r) => {
556                if !safe_store_rel_path(r) || !r.ends_with(".md") {
557                    return Err(bad(BAD_TARGET_REASON));
558                }
559                Some(AddressTarget::Path(r.to_string()))
560            }
561        };
562
563        Ok(Address {
564            brain: brain.to_string(),
565            target,
566        })
567    }
568}
569
570/// A brain reference safe to embed in a URL path segment: the shapes a hub
571/// accepts (ULID id or slug), which are also exactly URL-path-clean.
572fn is_safe_ref(s: &str) -> bool {
573    !s.is_empty()
574        && s.len() <= 64
575        && s.bytes()
576            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
577}
578
579/// A published-site handle (the `propose` target). Same lexical shape as a
580/// slug.
581pub fn is_valid_handle(s: &str) -> bool {
582    is_safe_ref(s)
583}
584
585/// True when `p` is a store-relative path this client will read from or write
586/// to disk: relative, no `..`, no empty or dot-leading segment (which shields
587/// `.dbmd/` and `.git/`), and only the hub-portable character set. Applied to
588/// every path an export hands us (the hub is not trusted with local layout)
589/// and to every path a push sends (mirroring the hub's own gate).
590pub fn safe_store_rel_path(p: &str) -> bool {
591    if p.is_empty() || p.len() > MAX_STORE_PATH_BYTES || p.starts_with('/') {
592        return false;
593    }
594    if !p
595        .bytes()
596        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/'))
597    {
598        return false;
599    }
600    p.split('/')
601        .all(|seg| !seg.is_empty() && seg != "." && seg != ".." && !seg.starts_with('.'))
602}
603
604/// Entry gate for every verb that embeds a caller-supplied brain reference in
605/// a URL path segment. `resolve` reaches the same check through
606/// [`Address::parse`]; the raw-ref verbs (`sync`, `grant`, `subscribe`) call
607/// this directly, so a ref carrying `/`, `..`, `?`, `#`, or any other
608/// URL-reshaping byte is refused before a request exists (the `url` crate
609/// normalizes dot segments, so an unvalidated ref would redirect the
610/// authenticated request to a different hub path).
611fn require_safe_ref(brain: &str) -> LinkResult<()> {
612    if is_safe_ref(brain) {
613        Ok(())
614    } else {
615        Err(LinkError::BadAddress {
616            given: brain.to_string(),
617            reason: BAD_BRAIN_REASON.to_string(),
618        })
619    }
620}
621
622/// Entry gate for the published-site handle `propose` embeds in its URL path.
623fn require_valid_handle(handle: &str) -> LinkResult<()> {
624    if is_valid_handle(handle) {
625        Ok(())
626    } else {
627        Err(LinkError::BadAddress {
628            given: handle.to_string(),
629            reason: "the site handle must be lowercase letters, digits, hyphens".to_string(),
630        })
631    }
632}
633
634/// Entry gate for the grant id `grant revoke` embeds in its URL path. Hub
635/// grant ids are lowercase ULIDs; the gate accepts the same URL-path-clean
636/// shape as a brain ref rather than pinning one mint scheme.
637fn require_safe_grant_id(id: &str) -> LinkResult<()> {
638    if is_safe_ref(id) {
639        Ok(())
640    } else {
641        Err(LinkError::BadGrantId {
642            given: id.to_string(),
643        })
644    }
645}
646
647// ─────────────────────────────────────────────────────────────────────────────
648// Configuration — flag > env > .dbmd/config; credential from env only
649// ─────────────────────────────────────────────────────────────────────────────
650
651/// The resolved client configuration for one invocation.
652#[derive(Debug, Clone)]
653pub struct HubConfig {
654    /// The hub base URL, trailing slash stripped, HTTPS-or-loopback enforced.
655    pub hub: String,
656    /// The bearer credential, when the environment carries one.
657    pub key: Option<String>,
658    /// The agent signing key, when [`AGENT_KEY_FILE_ENV`] names one. Wins
659    /// over the bearer for authenticated requests (link.md §8).
660    pub agent_key: Option<AgentSigningKey>,
661    /// The self-custodied brain signing key, when [`BRAIN_KEY_FILE_ENV`]
662    /// names one — `sync --push` then signs feed entries locally (§2.4).
663    pub brain_key: Option<AgentSigningKey>,
664    /// User-owned global toolkit state root. Identity pins and monotonic feed
665    /// checkpoints live below `<state_dir>/trust/`, never under a store.
666    pub state_dir: PathBuf,
667    /// True only when the origin came from untrusted store-local configuration.
668    /// Such origins are resolved and public-IP-pinned for every request.
669    store_selected: bool,
670}
671
672/// A loaded agent signing key: the PKCS#8 secret plus its derived public
673/// multikey. Debug never prints key material.
674#[derive(Clone)]
675pub struct AgentSigningKey {
676    pkcs8: Vec<u8>,
677    /// The key's public identity, `ed25519:<base64url sha256(SPKI)>`.
678    pub multikey: String,
679    /// The full public key, `base64url(SPKI DER)` — what feed entries carry.
680    pub public_key_spki: String,
681}
682
683impl std::fmt::Debug for AgentSigningKey {
684    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
685        f.debug_struct("AgentSigningKey")
686            .field("multikey", &self.multikey)
687            .field("pkcs8", &"<redacted>")
688            .finish()
689    }
690}
691
692impl HubConfig {
693    /// The credential, or the canonical "not configured" error. Verbs that
694    /// authenticate call this; `propose` never does.
695    pub fn require_key(&self) -> LinkResult<&str> {
696        self.key.as_deref().ok_or(LinkError::NoCredential)
697    }
698}
699
700/// Resolve the client configuration: `flag_hub` beats [`HUB_URL_ENV`] beats
701/// the `hub =` line in `<dir>/.dbmd/config`; no fallback default exists. The
702/// credential comes from [`HUB_KEY_ENV`] alone and is validated as a clean
703/// header token (never echoed on failure).
704pub fn hub_config(flag_hub: Option<&str>, dir: &Path) -> LinkResult<HubConfig> {
705    let explicit_hub = flag_hub
706        .map(str::to_string)
707        .or_else(|| env_nonempty(HUB_URL_ENV));
708    let selected_by_store = explicit_hub.is_none();
709    let hub = explicit_hub
710        .or_else(|| config_file_hub(&dir.join(CONFIG_REL_PATH)))
711        .ok_or(LinkError::NoHub)?;
712    let hub = hub.trim().trim_end_matches('/').to_string();
713    assert_safe_hub(&hub)?;
714    if selected_by_store {
715        let parsed =
716            url::Url::parse(&hub).map_err(|_| LinkError::UnsafeHub { hub: hub.clone() })?;
717        // A cloned store is not an operator opt-in to local-network access.
718        // Local/private development hubs must be selected explicitly by flag or
719        // environment, never by bytes inside the store.
720        if !parsed.scheme().eq_ignore_ascii_case("https")
721            || (parsed.path() != "/" && !parsed.path().is_empty())
722        {
723            return Err(LinkError::UnsafeHub { hub });
724        }
725    }
726
727    let key = match env_nonempty(HUB_KEY_ENV) {
728        Some(raw) => Some(clean_key(&raw)?),
729        None => None,
730    };
731
732    let agent_key = match env_nonempty(AGENT_KEY_FILE_ENV) {
733        Some(path) => Some(load_agent_key(Path::new(&path))?),
734        None => None,
735    };
736
737    let brain_key = match env_nonempty(BRAIN_KEY_FILE_ENV) {
738        Some(path) => Some(load_agent_key(Path::new(&path))?),
739        None => None,
740    };
741
742    // A cloned store controls `.dbmd/config`. It must never be able to point
743    // the process at an attacker origin and harvest the user's ambient account
744    // bearer, signed agent identity, or self-custodied brain signatures/content.
745    // Explicit --hub/DBMD_HUB_URL selection already pairs target + credential
746    // in the invocation environment; a store-selected target additionally
747    // needs an exact origin binding.
748    if selected_by_store && (key.is_some() || agent_key.is_some() || brain_key.is_some()) {
749        let bound = env_nonempty(HUB_CREDENTIAL_ORIGIN_ENV)
750            .and_then(|value| normalized_origin(&value).ok());
751        let selected_origin = normalized_origin(&hub)?;
752        if bound.as_deref() != Some(selected_origin.as_str()) {
753            return Err(LinkError::UnboundCredential);
754        }
755    }
756
757    Ok(HubConfig {
758        hub,
759        key,
760        agent_key,
761        brain_key,
762        state_dir: toolkit_state_dir()?,
763        store_selected: selected_by_store,
764    })
765}
766
767fn toolkit_state_dir() -> LinkResult<PathBuf> {
768    if let Some(path) = env_nonempty(STATE_DIR_ENV) {
769        let path = PathBuf::from(path);
770        if !path.is_absolute() {
771            return Err(LinkError::UnsafePath {
772                path: path.display().to_string(),
773            });
774        }
775        return Ok(path);
776    }
777    #[cfg(windows)]
778    if let Some(base) = env_nonempty("LOCALAPPDATA") {
779        let base = PathBuf::from(base);
780        if base.is_absolute() {
781            return Ok(base.join("dbmd").join("state"));
782        }
783    }
784    #[cfg(windows)]
785    {
786        Err(LinkError::Io(std::io::Error::new(
787            std::io::ErrorKind::NotFound,
788            format!("cannot locate user state; set {STATE_DIR_ENV} or LOCALAPPDATA"),
789        )))
790    }
791    #[cfg(not(windows))]
792    if let Some(base) = env_nonempty("XDG_STATE_HOME") {
793        let base = PathBuf::from(base);
794        if base.is_absolute() {
795            return Ok(base.join("dbmd"));
796        }
797    }
798    #[cfg(not(windows))]
799    let home = PathBuf::from(env_nonempty("HOME").ok_or_else(|| {
800        LinkError::Io(std::io::Error::new(
801            std::io::ErrorKind::NotFound,
802            format!("cannot locate user state; set {STATE_DIR_ENV}"),
803        ))
804    })?);
805    #[cfg(not(windows))]
806    if !home.is_absolute() {
807        return Err(LinkError::UnsafePath {
808            path: home.display().to_string(),
809        });
810    }
811    #[cfg(target_os = "macos")]
812    {
813        Ok(home
814            .join("Library")
815            .join("Application Support")
816            .join("dbmd")
817            .join("state"))
818    }
819    #[cfg(all(not(target_os = "macos"), not(windows)))]
820    {
821        Ok(home.join(".local").join("state").join("dbmd"))
822    }
823}
824
825fn normalized_origin(value: &str) -> LinkResult<String> {
826    let parsed = url::Url::parse(value).map_err(|_| LinkError::UnsafeHub {
827        hub: value.to_string(),
828    })?;
829    if !(parsed.scheme().eq_ignore_ascii_case("https")
830        || parsed.scheme().eq_ignore_ascii_case("http"))
831        || !parsed.username().is_empty()
832        || parsed.password().is_some()
833        || (parsed.path() != "/" && !parsed.path().is_empty())
834        || parsed.query().is_some()
835        || parsed.fragment().is_some()
836    {
837        return Err(LinkError::UnsafeHub {
838            hub: value.to_string(),
839        });
840    }
841    let host = parsed.host_str().ok_or_else(|| LinkError::UnsafeHub {
842        hub: value.to_string(),
843    })?;
844    let host = if host.contains(':') {
845        format!("[{host}]")
846    } else {
847        host.to_ascii_lowercase()
848    };
849    let port = parsed
850        .port_or_known_default()
851        .ok_or_else(|| LinkError::UnsafeHub {
852            hub: value.to_string(),
853        })?;
854    let default = (parsed.scheme().eq_ignore_ascii_case("https") && port == 443)
855        || (parsed.scheme().eq_ignore_ascii_case("http") && port == 80);
856    Ok(format!(
857        "{}://{}{}",
858        parsed.scheme().to_ascii_lowercase(),
859        host,
860        if default {
861            String::new()
862        } else {
863            format!(":{port}")
864        }
865    ))
866}
867
868// ─────────────────────────────────────────────────────────────────────────────
869// Agent signing keys — link.md §8 `LinkMD-Sig` proof of possession
870// ─────────────────────────────────────────────────────────────────────────────
871
872/// The DER prefix that wraps a raw Ed25519 public key into a
873/// SubjectPublicKeyInfo (RFC 8410).
874const ED25519_SPKI_PREFIX: [u8; 12] = [
875    0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
876];
877
878fn bad_agent_key(message: &str) -> LinkError {
879    LinkError::BadAgentKey {
880        message: message.to_string(),
881    }
882}
883
884fn agent_keypair(pkcs8: &[u8]) -> LinkResult<ring::signature::Ed25519KeyPair> {
885    // `from_pkcs8` wants ring's own v2 encoding (private + public); keys from
886    // other tools are often PKCS#8 v1, which `maybe_unchecked` accepts by
887    // deriving the public half itself.
888    ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8)
889        .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(pkcs8))
890        .map_err(|_| bad_agent_key("not an Ed25519 PKCS#8 key"))
891}
892
893/// Derive `(publicKeySpki b64u, multikey)` from a keypair.
894fn public_identity_for(pair: &ring::signature::Ed25519KeyPair) -> (String, String) {
895    use ring::signature::KeyPair as _;
896    let mut spki = Vec::with_capacity(44);
897    spki.extend_from_slice(&ED25519_SPKI_PREFIX);
898    spki.extend_from_slice(pair.public_key().as_ref());
899    (
900        URL_SAFE_NO_PAD.encode(&spki),
901        format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&spki))),
902    )
903}
904
905/// Load and validate a signing-key file (agent or brain — same format):
906/// one base64url line of PKCS#8. Public so `dbmd key rotate` can load the
907/// old key explicitly.
908pub fn load_signing_key(path: &Path) -> LinkResult<AgentSigningKey> {
909    load_agent_key(path)
910}
911
912/// Load and validate the agent key file: one base64url line of PKCS#8.
913fn load_agent_key(path: &Path) -> LinkResult<AgentSigningKey> {
914    #[cfg(unix)]
915    let file = {
916        use std::os::fd::{AsRawFd as _, FromRawFd as _};
917        use std::os::unix::ffi::OsStrExt as _;
918        let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
919            .map_err(|e| bad_agent_key(&format!("cannot open the key parent: {e}")))?;
920        let leaf = path
921            .file_name()
922            .ok_or_else(|| bad_agent_key("the key path has no file name"))?;
923        let leaf = c_name(leaf.as_bytes(), &path.display().to_string())?;
924        let fd = unsafe {
925            libc::openat(
926                parent.as_raw_fd(),
927                leaf.as_ptr(),
928                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
929            )
930        };
931        if fd < 0 {
932            return Err(bad_agent_key(
933                "the key path must be an existing regular file without symlink ancestors",
934            ));
935        }
936        unsafe { std::fs::File::from_raw_fd(fd) }
937    };
938    #[cfg(not(unix))]
939    let file = std::fs::File::open(path)
940        .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
941    let metadata = file
942        .metadata()
943        .map_err(|e| bad_agent_key(&format!("cannot inspect the key file: {e}")))?;
944    if !metadata.is_file() {
945        return Err(bad_agent_key("the key path must be a regular file"));
946    }
947    #[cfg(unix)]
948    {
949        use std::os::unix::fs::PermissionsExt as _;
950        if metadata.permissions().mode() & 0o077 != 0 {
951            return Err(bad_agent_key(
952                "the key file is accessible to group/other; set mode 0600",
953            ));
954        }
955    }
956    let mut text = String::new();
957    file.take(1024 * 1024 + 1)
958        .read_to_string(&mut text)
959        .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
960    if text.len() > 1024 * 1024 {
961        return Err(bad_agent_key("the key file exceeds the size limit"));
962    }
963    let pkcs8 = URL_SAFE_NO_PAD
964        .decode(text.trim())
965        .map_err(|_| bad_agent_key("the key file is not one base64url line"))?;
966    let (public_key_spki, multikey) = public_identity_for(&agent_keypair(&pkcs8)?);
967    Ok(AgentSigningKey {
968        pkcs8,
969        multikey,
970        public_key_spki,
971    })
972}
973
974/// Durably create a new secret file without an exists→write race and without
975/// ever exposing a default-mode (commonly 0644) key between write and chmod.
976/// `create_new` also refuses a planted symlink. The file and its parent
977/// directory are synced before the caller can publish the corresponding
978/// public identity remotely.
979fn write_secret_new(path: &Path, bytes: &[u8]) -> LinkResult<()> {
980    #[cfg(unix)]
981    let (mut file, parent, leaf) = {
982        use std::os::fd::{AsRawFd as _, FromRawFd as _};
983        use std::os::unix::ffi::OsStrExt as _;
984        let parent = open_or_create_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))?;
985        let leaf_name = path
986            .file_name()
987            .ok_or_else(|| bad_agent_key("the output key path has no file name"))?;
988        let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
989        let fd = unsafe {
990            libc::openat(
991                parent.as_raw_fd(),
992                leaf.as_ptr(),
993                libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
994                0o600,
995            )
996        };
997        if fd < 0 {
998            let error = std::io::Error::last_os_error();
999            if error.kind() == std::io::ErrorKind::AlreadyExists {
1000                return Err(bad_agent_key(
1001                    "the output file already exists — refusing to overwrite a key",
1002                ));
1003            }
1004            return Err(error.into());
1005        }
1006        (unsafe { std::fs::File::from_raw_fd(fd) }, parent, leaf)
1007    };
1008    #[cfg(not(unix))]
1009    let mut file = std::fs::OpenOptions::new()
1010        .write(true)
1011        .create_new(true)
1012        .open(path)
1013        .map_err(|error| {
1014            if error.kind() == std::io::ErrorKind::AlreadyExists {
1015                bad_agent_key("the output file already exists — refusing to overwrite a key")
1016            } else {
1017                LinkError::Io(error)
1018            }
1019        })?;
1020    if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_all()) {
1021        drop(file);
1022        #[cfg(unix)]
1023        let _ =
1024            unsafe { libc::unlinkat(std::os::fd::AsRawFd::as_raw_fd(&parent), leaf.as_ptr(), 0) };
1025        #[cfg(not(unix))]
1026        let _ = std::fs::remove_file(path);
1027        return Err(LinkError::Io(error));
1028    }
1029    drop(file);
1030    #[cfg(unix)]
1031    parent.sync_all()?;
1032    Ok(())
1033}
1034
1035/// What `dbmd key generate` returns: the public identity to register plus
1036/// where the secret landed.
1037#[derive(Debug, Serialize)]
1038pub struct GeneratedAgentKey {
1039    /// `ed25519:<fingerprint>` — the grantable/registerable identity.
1040    pub multikey: String,
1041    /// base64url SPKI DER — what a hub's register endpoint takes.
1042    #[serde(rename = "publicKeySpki")]
1043    pub public_key_spki: String,
1044    /// Where the PKCS#8 secret was written (mode 0600).
1045    #[serde(rename = "keyFile")]
1046    pub key_file: String,
1047}
1048
1049/// Mint a fresh Ed25519 agent keypair. The secret is written to `out`
1050/// (base64url PKCS#8, one line, 0600, refusing to overwrite); only public
1051/// identity is returned. The private key never enters a store and never
1052/// travels — requests carry per-request signatures instead (link.md §8).
1053pub fn generate_agent_key(out: &Path) -> LinkResult<GeneratedAgentKey> {
1054    require_hardened_filesystem("key generation")?;
1055    let rng = ring::rand::SystemRandom::new();
1056    let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
1057        .map_err(|_| bad_agent_key("key generation failed"))?;
1058    let pair = agent_keypair(pkcs8.as_ref())?;
1059    let (spki_b64u, multikey) = public_identity_for(&pair);
1060
1061    write_secret_new(
1062        out,
1063        format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
1064    )?;
1065
1066    Ok(GeneratedAgentKey {
1067        multikey,
1068        public_key_spki: spki_b64u,
1069        key_file: out.display().to_string(),
1070    })
1071}
1072
1073/// Build the origin-bound `LinkMD-Sig` v2 header for one request:
1074/// `canonical = "v2" LF origin LF METHOD LF path+query LF ts LF
1075/// (sha256hex(body) | "-")`.
1076///
1077/// The origin is derived from the already-validated hub URL, never from
1078/// attacker-controlled request metadata. Binding it closes the v1 replay
1079/// class where a proof captured by one hub could be replayed to another hub
1080/// serving the same path inside the timestamp window.
1081fn linkmd_sig_header(
1082    key: &AgentSigningKey,
1083    origin: &str,
1084    method: &str,
1085    path: &str,
1086    body: Option<&str>,
1087) -> LinkResult<String> {
1088    let ts = std::time::SystemTime::now()
1089        .duration_since(std::time::UNIX_EPOCH)
1090        .map_err(|_| bad_agent_key("system clock is before the epoch"))?
1091        .as_secs();
1092    let body_hash = match body {
1093        Some(b) => format!("{:x}", Sha256::digest(b.as_bytes())),
1094        None => "-".to_string(),
1095    };
1096    let canonical = format!(
1097        "v2\n{}\n{}\n{}\n{}\n{}",
1098        origin,
1099        method.to_uppercase(),
1100        path,
1101        ts,
1102        body_hash
1103    );
1104    let pair = agent_keypair(&key.pkcs8)?;
1105    let sig = URL_SAFE_NO_PAD.encode(pair.sign(canonical.as_bytes()).as_ref());
1106    let fingerprint = key.multikey.trim_start_matches("ed25519:");
1107    Ok(format!(
1108        "LinkMD-Sig v2,key=ed25519:{fingerprint},ts={ts},sig={sig}"
1109    ))
1110}
1111
1112// ─────────────────────────────────────────────────────────────────────────────
1113// Self-custody feed entries — the client signs what the hub only verifies
1114// ─────────────────────────────────────────────────────────────────────────────
1115
1116/// One `files` element of a wire-profile-v1 feed entry (SPEC §5.1: fields in
1117/// exactly this order).
1118#[derive(Serialize)]
1119struct WireFeedFile {
1120    path: String,
1121    sha256: String,
1122    bytes: u64,
1123}
1124
1125/// The unsigned entry in the normative §5.1 field order — serde serializes
1126/// struct fields in declaration order, which IS the wire contract.
1127#[derive(Serialize)]
1128struct UnsignedWireEntry<'a> {
1129    v: u8,
1130    seq: u64,
1131    ts: String,
1132    brain: &'a str,
1133    public_key: &'a str,
1134    kind: &'a str,
1135    op: &'a str,
1136    pack_sha256: &'a str,
1137    files: &'a [WireFeedFile],
1138    removed: &'a [String],
1139    prev_entry_hash: Option<&'a str>,
1140}
1141
1142/// Build and sign a wire-profile-v1 `push` feed entry with a self-custodied
1143/// brain key: serialize the unsigned entry compactly in the normative order,
1144/// Ed25519-sign those exact bytes, splice `sig` on as the final field. The
1145/// returned string is the exact serialization the hub stores verbatim (plus
1146/// one trailing newline) and every independent reader re-derives.
1147fn self_custody_entry(
1148    key: &AgentSigningKey,
1149    seq: u64,
1150    ts: String,
1151    pack_sha256: &str,
1152    files: &[WireFeedFile],
1153    prev_entry_hash: Option<&str>,
1154) -> LinkResult<String> {
1155    let removed: [String; 0] = [];
1156    let unsigned = serde_json::to_string(&UnsignedWireEntry {
1157        v: 1,
1158        seq,
1159        ts,
1160        brain: &key.multikey,
1161        public_key: &key.public_key_spki,
1162        kind: "push",
1163        op: "snapshot",
1164        pack_sha256,
1165        files,
1166        removed: &removed,
1167        prev_entry_hash,
1168    })
1169    .expect("serialize feed entry");
1170    let pair = agent_keypair(&key.pkcs8)?;
1171    let sig = URL_SAFE_NO_PAD.encode(pair.sign(unsigned.as_bytes()).as_ref());
1172    Ok(format!(
1173        "{},\"sig\":\"{}\"}}",
1174        &unsigned[..unsigned.len() - 1],
1175        sig
1176    ))
1177}
1178
1179/// An env var, treated as absent when unset or empty (an empty
1180/// `DBMD_HUB_KEY=` falls through rather than becoming an empty credential).
1181fn env_nonempty(name: &str) -> Option<String> {
1182    std::env::var(name).ok().filter(|v| !v.trim().is_empty())
1183}
1184
1185/// Read the `hub = <URL>` line out of a `.dbmd/config` file. The format is
1186/// deliberately minimal: `key = value` lines, `#` comments, unknown keys
1187/// ignored (forward-compatible). A missing or unreadable file is simply "not
1188/// configured here".
1189fn config_file_hub(path: &Path) -> Option<String> {
1190    const MAX_CONFIG_BYTES: u64 = 64 * 1024;
1191    #[cfg(unix)]
1192    let file = {
1193        use std::os::fd::{AsRawFd as _, FromRawFd as _};
1194        use std::os::unix::ffi::OsStrExt as _;
1195        let parent =
1196            open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new("."))).ok()?;
1197        let leaf = c_name(path.file_name()?.as_bytes(), &path.display().to_string()).ok()?;
1198        let fd = unsafe {
1199            libc::openat(
1200                parent.as_raw_fd(),
1201                leaf.as_ptr(),
1202                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1203            )
1204        };
1205        if fd < 0 {
1206            return None;
1207        }
1208        unsafe { std::fs::File::from_raw_fd(fd) }
1209    };
1210    #[cfg(not(unix))]
1211    let file = std::fs::File::open(path).ok()?;
1212    let metadata = file.metadata().ok()?;
1213    if !metadata.is_file() || metadata.len() > MAX_CONFIG_BYTES {
1214        return None;
1215    }
1216    let mut bytes = Vec::with_capacity(metadata.len() as usize);
1217    file.take(MAX_CONFIG_BYTES + 1)
1218        .read_to_end(&mut bytes)
1219        .ok()?;
1220    if bytes.len() as u64 > MAX_CONFIG_BYTES {
1221        return None;
1222    }
1223    let text = String::from_utf8(bytes).ok()?;
1224    for line in text.lines() {
1225        let line = line.trim();
1226        if line.is_empty() || line.starts_with('#') {
1227            continue;
1228        }
1229        if let Some((k, v)) = line.split_once('=') {
1230            if k.trim() == "hub" {
1231                let v = v.trim();
1232                if !v.is_empty() {
1233                    return Some(v.to_string());
1234                }
1235            }
1236        }
1237    }
1238    None
1239}
1240
1241/// The bearer key must never travel in cleartext; only loopback hosts may
1242/// skip TLS (local development against a hub on localhost).
1243fn assert_safe_hub(hub: &str) -> LinkResult<()> {
1244    let parsed = url::Url::parse(hub).map_err(|_| LinkError::UnsafeHub {
1245        hub: hub.to_string(),
1246    })?;
1247    if !(parsed.scheme().eq_ignore_ascii_case("https")
1248        || parsed.scheme().eq_ignore_ascii_case("http"))
1249        || !parsed.username().is_empty()
1250        || parsed.password().is_some()
1251        || (parsed.path() != "/" && !parsed.path().is_empty())
1252        || parsed.query().is_some()
1253        || parsed.fragment().is_some()
1254    {
1255        return Err(LinkError::UnsafeHub {
1256            hub: hub.to_string(),
1257        });
1258    }
1259    let loopback = match parsed.host() {
1260        Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
1261        Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
1262        Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
1263        None => false,
1264    };
1265    if parsed.scheme().eq_ignore_ascii_case("https") || loopback {
1266        Ok(())
1267    } else {
1268        Err(LinkError::UnsafeHub {
1269            hub: hub.to_string(),
1270        })
1271    }
1272}
1273
1274/// Trim paste artifacts and refuse anything outside the printable-ASCII token
1275/// range WITHOUT echoing the key — an HTTP library rejecting a bad header
1276/// value tends to echo the whole header line, credential included, so the
1277/// gate sits here instead.
1278fn clean_key(raw: &str) -> LinkResult<String> {
1279    let k = raw.trim();
1280    if k.is_empty() || k.bytes().any(|b| !(0x21..=0x7e).contains(&b)) {
1281        return Err(LinkError::BadKey);
1282    }
1283    Ok(k.to_string())
1284}
1285
1286// ─────────────────────────────────────────────────────────────────────────────
1287// Transport — one blocking agent, capped reads, the JSON-or-refuse contract
1288// ─────────────────────────────────────────────────────────────────────────────
1289
1290/// One hub response: the status plus the parsed JSON body when there was one.
1291#[derive(Debug)]
1292pub struct HubResponse {
1293    /// The HTTP status code.
1294    pub status: u16,
1295    /// The parsed JSON body, `None` when the body was empty or not JSON.
1296    pub body: Option<Value>,
1297}
1298
1299struct RawHubResponse {
1300    status: u16,
1301    body: Vec<u8>,
1302}
1303
1304/// Whether a request carries the bearer credential.
1305#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1306enum Auth {
1307    /// Send `authorization: Bearer <key>`; error without a key.
1308    Required,
1309    /// Send no credential — the propose door is unauthenticated by design.
1310    None,
1311    /// Send the configured credential when one exists, otherwise nothing —
1312    /// brain-addressed propose works anonymously on public brains, and an
1313    /// authenticated caller earns a bigger actor-class budget.
1314    Optional,
1315}
1316
1317fn agent_builder_with_timeout(overall: std::time::Duration) -> ureq::AgentBuilder {
1318    ureq::AgentBuilder::new()
1319        .user_agent(concat!("dbmd/", env!("CARGO_PKG_VERSION")))
1320        // Never follow a redirect while a bearer, a store pack, or a signed
1321        // response is in flight. Callers see the 3xx as a non-success instead
1322        // of letting an origin steer sensitive material elsewhere.
1323        .redirects(0)
1324        .timeout_connect(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
1325        .timeout_read(std::time::Duration::from_secs(READ_TIMEOUT_SECS))
1326        .timeout_write(overall)
1327        .timeout(overall)
1328}
1329
1330fn agent_builder() -> ureq::AgentBuilder {
1331    agent_builder_with_timeout(std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS))
1332}
1333
1334fn agent() -> ureq::Agent {
1335    agent_builder().build()
1336}
1337
1338fn hub_agent(cfg: &HubConfig) -> LinkResult<ureq::Agent> {
1339    if !cfg.store_selected {
1340        return Ok(agent());
1341    }
1342    let parsed = url::Url::parse(&cfg.hub).map_err(|_| LinkError::UnsafeHub {
1343        hub: cfg.hub.clone(),
1344    })?;
1345    pinned_public_agent(&parsed, false, "store-selected hub")
1346}
1347
1348/// Perform one hub request. `path` is the binding path (starts with `/`);
1349/// `body` posts JSON. Transport failures, oversized bodies, and non-UTF-8 are
1350/// all surfaced as typed [`LinkError`]s; HTTP error statuses are returned in
1351/// the [`HubResponse`] for [`ensure_ok`] to shape.
1352fn request_raw(
1353    cfg: &HubConfig,
1354    method: &str,
1355    path: &str,
1356    body: Option<&Value>,
1357    auth: Auth,
1358    max_response_bytes: u64,
1359) -> LinkResult<RawHubResponse> {
1360    let http = hub_agent(cfg)?;
1361    request_raw_with_agent(cfg, &http, method, path, body, auth, max_response_bytes)
1362}
1363
1364fn request_raw_with_agent(
1365    cfg: &HubConfig,
1366    http: &ureq::Agent,
1367    method: &str,
1368    path: &str,
1369    body: Option<&Value>,
1370    auth: Auth,
1371    max_response_bytes: u64,
1372) -> LinkResult<RawHubResponse> {
1373    let url = format!("{}{}", cfg.hub, path);
1374    let encoded_body = body.map(Value::to_string);
1375    let origin = normalized_origin(&cfg.hub)?;
1376    // An agent signing key outranks the bearer: possession proofs put nothing
1377    // reusable on the wire, so when both are configured the stronger one wins.
1378    let credential = match auth {
1379        Auth::Required => Some(match &cfg.agent_key {
1380            Some(key) => linkmd_sig_header(key, &origin, method, path, encoded_body.as_deref())?,
1381            None => format!("Bearer {}", cfg.require_key()?),
1382        }),
1383        Auth::Optional => match &cfg.agent_key {
1384            Some(key) => Some(linkmd_sig_header(
1385                key,
1386                &origin,
1387                method,
1388                path,
1389                encoded_body.as_deref(),
1390            )?),
1391            None => cfg.key.as_deref().map(|k| format!("Bearer {k}")),
1392        },
1393        Auth::None => None,
1394    };
1395    let result = with_connect_retries(|| {
1396        let mut req = http.request(method, &url);
1397        if let Some(value) = &credential {
1398            req = req.set("authorization", value);
1399        }
1400        match &encoded_body {
1401            Some(value) => req
1402                .set("content-type", "application/json")
1403                .send_string(value)
1404                .map_err(Box::new),
1405            None => req.call().map_err(Box::new),
1406        }
1407    });
1408    let resp = match result {
1409        Ok(resp) => resp,
1410        Err(error) => match *error {
1411            ureq::Error::Status(_, resp) => resp,
1412            ureq::Error::Transport(error) => {
1413                return Err(LinkError::Transport {
1414                    hub: cfg.hub.clone(),
1415                    message: error.to_string(),
1416                });
1417            }
1418        },
1419    };
1420
1421    let status = resp.status();
1422    let mut buf = Vec::new();
1423    resp.into_reader()
1424        .take(max_response_bytes + 1)
1425        .read_to_end(&mut buf)?;
1426    if buf.len() as u64 > max_response_bytes {
1427        return Err(LinkError::ResponseTooLarge {
1428            limit_bytes: max_response_bytes,
1429        });
1430    }
1431    Ok(RawHubResponse { status, body: buf })
1432}
1433
1434fn request_capped(
1435    cfg: &HubConfig,
1436    method: &str,
1437    path: &str,
1438    body: Option<&Value>,
1439    auth: Auth,
1440    max_response_bytes: u64,
1441) -> LinkResult<HubResponse> {
1442    let raw = request_raw(cfg, method, path, body, auth, max_response_bytes)?;
1443    let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
1444    Ok(HubResponse {
1445        status: raw.status,
1446        body: parsed,
1447    })
1448}
1449
1450fn request(
1451    cfg: &HubConfig,
1452    method: &str,
1453    path: &str,
1454    body: Option<&Value>,
1455    auth: Auth,
1456) -> LinkResult<HubResponse> {
1457    request_capped(cfg, method, path, body, auth, MAX_RESPONSE_BYTES)
1458}
1459
1460fn ensure_raw_ok(r: RawHubResponse, what: &'static str) -> LinkResult<Vec<u8>> {
1461    if (200..300).contains(&r.status) {
1462        return Ok(r.body);
1463    }
1464    ensure_ok(
1465        HubResponse {
1466            status: r.status,
1467            body: serde_json::from_slice(&r.body).ok(),
1468        },
1469        what,
1470    )
1471    .and_then(|_| Err(invalid_feed("a non-2xx response was accepted unexpectedly")))
1472}
1473
1474/// These failures happen before any HTTP request reaches the hub, so retrying
1475/// cannot duplicate a mutation. Mid-stream I/O is deliberately excluded: once
1476/// bytes may have crossed the wire, the caller must rely on the verb's own
1477/// idempotency contract instead of guessing.
1478fn is_pre_request_transport(kind: ureq::ErrorKind) -> bool {
1479    matches!(
1480        kind,
1481        ureq::ErrorKind::Dns | ureq::ErrorKind::ConnectionFailed | ureq::ErrorKind::ProxyConnect
1482    )
1483}
1484
1485fn with_connect_retries(
1486    mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
1487) -> Result<ureq::Response, Box<ureq::Error>> {
1488    let mut attempt = 0;
1489    loop {
1490        match send() {
1491            Err(error)
1492                if matches!(
1493                    error.as_ref(),
1494                    ureq::Error::Transport(transport)
1495                        if is_pre_request_transport(transport.kind())
1496                ) && attempt + 1 < CONNECT_ATTEMPTS =>
1497            {
1498                std::thread::sleep(std::time::Duration::from_millis(
1499                    CONNECT_RETRY_BACKOFF_MS[attempt],
1500                ));
1501                attempt += 1;
1502            }
1503            result => return result,
1504        }
1505    }
1506}
1507
1508fn hub_is_loopback(hub: &str) -> bool {
1509    url::Url::parse(hub).ok().is_some_and(|parsed| {
1510        parsed.host().is_some_and(|host| match host {
1511            url::Host::Domain(host) => host.eq_ignore_ascii_case("localhost"),
1512            url::Host::Ipv4(ip) => ip.is_loopback(),
1513            url::Host::Ipv6(ip) => ip.is_loopback(),
1514        })
1515    })
1516}
1517
1518fn presigned_agent(cfg: &HubConfig, raw: &str) -> LinkResult<ureq::Agent> {
1519    let parsed = url::Url::parse(raw).map_err(|_| LinkError::InvalidPack {
1520        message: "the hub returned an invalid object-store URL".to_string(),
1521    })?;
1522    let allow_private = hub_is_loopback(&cfg.hub)
1523        || env_nonempty(ALLOW_PRIVATE_OBJECT_URL_ENV).as_deref() == Some("1");
1524    if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1525        || !parsed.username().is_empty()
1526        || parsed.password().is_some()
1527        || parsed.fragment().is_some()
1528    {
1529        return Err(LinkError::InvalidPack {
1530            message: "the hub returned an unsafe object-store URL".to_string(),
1531        });
1532    }
1533    pinned_public_agent(&parsed, allow_private, "object-store URL").map_err(|_| {
1534        LinkError::InvalidPack {
1535            message: "the hub returned an object-store URL with an unsafe network target"
1536                .to_string(),
1537        }
1538    })
1539}
1540
1541fn put_presigned(cfg: &HubConfig, raw: &str, headers: &Value, bytes: &[u8]) -> LinkResult<()> {
1542    let http = presigned_agent(cfg, raw)?;
1543    let result = with_connect_retries(|| {
1544        let mut req = http.put(raw);
1545        if let Some(map) = headers.as_object() {
1546            for (name, value) in map {
1547                if let Some(value) = value.as_str() {
1548                    req = req.set(name, value);
1549                }
1550            }
1551        }
1552        req.send_bytes(bytes).map_err(Box::new)
1553    });
1554    match result {
1555        Ok(resp) if (200..300).contains(&resp.status()) => Ok(()),
1556        Ok(resp) => Err(LinkError::Http {
1557            what: "pack upload",
1558            status: resp.status(),
1559            message: "object store rejected the upload".to_string(),
1560            code: None,
1561            details: None,
1562        }),
1563        Err(error) => match *error {
1564            // Immutable uploads use If-None-Match. A concurrent writer may
1565            // win the same content address; the commit/confirm endpoint reads
1566            // and hashes that object before accepting it, so 412 safely means
1567            // "continue to verification", never "trust the upload".
1568            ureq::Error::Status(412, _) => Ok(()),
1569            ureq::Error::Status(_, resp) => Err(LinkError::Http {
1570                what: "pack upload",
1571                status: resp.status(),
1572                message: "object store rejected the upload".to_string(),
1573                code: None,
1574                details: None,
1575            }),
1576            ureq::Error::Transport(err) => Err(LinkError::Transport {
1577                hub: "the object store".to_string(),
1578                message: err.to_string(),
1579            }),
1580        },
1581    }
1582}
1583
1584fn one_past_bounded_limit(max_bytes: u64) -> Option<u64> {
1585    max_bytes.checked_add(1)
1586}
1587
1588fn presigned_download_read_limit() -> u64 {
1589    one_past_bounded_limit(MAX_PACK_BYTES)
1590        .expect("the fixed presigned-download ceiling must leave room for the refusal byte")
1591}
1592
1593fn get_presigned(cfg: &HubConfig, raw: &str) -> LinkResult<Vec<u8>> {
1594    let http = presigned_agent(cfg, raw)?;
1595    let resp = match with_connect_retries(|| http.get(raw).call().map_err(Box::new)) {
1596        Ok(resp) => resp,
1597        Err(error) => match *error {
1598            ureq::Error::Status(_, resp) => {
1599                return Err(LinkError::Http {
1600                    what: "pack download",
1601                    status: resp.status(),
1602                    message: "object store rejected the download".to_string(),
1603                    code: None,
1604                    details: None,
1605                });
1606            }
1607            ureq::Error::Transport(err) => {
1608                return Err(LinkError::Transport {
1609                    hub: "the object store".to_string(),
1610                    message: err.to_string(),
1611                });
1612            }
1613        },
1614    };
1615    if !(200..300).contains(&resp.status()) {
1616        return Err(LinkError::Http {
1617            what: "pack download",
1618            status: resp.status(),
1619            message: "object store rejected the download".to_string(),
1620            code: None,
1621            details: None,
1622        });
1623    }
1624    let mut bytes = Vec::new();
1625    resp.into_reader()
1626        .take(presigned_download_read_limit())
1627        .read_to_end(&mut bytes)?;
1628    if bytes.len() as u64 > MAX_PACK_BYTES {
1629        return Err(LinkError::InvalidPack {
1630            message: "download exceeds the compressed-size limit".to_string(),
1631        });
1632    }
1633    Ok(bytes)
1634}
1635
1636/// Unwrap a successful JSON body, or shape the failure: a >=400 surfaces the
1637/// hub's own `error` + `code`; a 2xx without JSON is refused as not a hub
1638/// answer.
1639fn ensure_ok(r: HubResponse, what: &'static str) -> LinkResult<Value> {
1640    if !(200..300).contains(&r.status) {
1641        let message = r
1642            .body
1643            .as_ref()
1644            .and_then(|b| b.get("error"))
1645            .and_then(Value::as_str)
1646            .unwrap_or("unknown error")
1647            .to_string();
1648        let code = r
1649            .body
1650            .as_ref()
1651            .and_then(|b| b.get("code"))
1652            .and_then(Value::as_str)
1653            .map(str::to_string);
1654        let details = r.body.as_ref().and_then(|b| b.get("details")).cloned();
1655        return Err(LinkError::Http {
1656            what,
1657            status: r.status,
1658            message,
1659            code,
1660            details,
1661        });
1662    }
1663    r.body.ok_or(LinkError::NotJson {
1664        what,
1665        status: r.status,
1666    })
1667}
1668
1669// ─────────────────────────────────────────────────────────────────────────────
1670// resolve — handle → brain card; @brain/id → the record
1671// ─────────────────────────────────────────────────────────────────────────────
1672
1673/// Resolve an address. A bare `@brain` returns the brain card (metadata +
1674/// index stats — the v0 form of the card; keys arrive with the protocol's
1675/// signing layer). `@brain/<id>` and `@brain/<path>.md` return the full
1676/// record, frontmatter + body.
1677fn is_public_registry_ip(ip: std::net::IpAddr) -> bool {
1678    match ip {
1679        std::net::IpAddr::V4(ip) => {
1680            let [a, b, c, _] = ip.octets();
1681            !(a == 0
1682                || a == 10
1683                || a == 127
1684                || (a == 100 && (64..=127).contains(&b))
1685                || (a == 169 && b == 254)
1686                || (a == 172 && (16..=31).contains(&b))
1687                || (a == 192 && b == 0 && c == 0)
1688                || (a == 192 && b == 0 && c == 2)
1689                || (a == 192 && b == 88 && c == 99)
1690                || (a == 192 && b == 168)
1691                || (a == 198 && (b == 18 || b == 19))
1692                || (a == 198 && b == 51 && c == 100)
1693                || (a == 203 && b == 0 && c == 113)
1694                || a >= 224)
1695        }
1696        std::net::IpAddr::V6(ip) => {
1697            let segments = ip.segments();
1698            // Conservatively accept only global unicast 2000::/3, excluding
1699            // special-purpose/transition blocks. In particular, 6to4 embeds
1700            // an IPv4 destination and must not tunnel a validated public
1701            // connect to 127/8 or RFC1918.
1702            (segments[0] & 0xe000) == 0x2000
1703                && !(segments[0] == 0x2001 && (segments[1] & 0xfe00) == 0)
1704                && !(segments[0] == 0x2001 && segments[1] == 0x0db8)
1705                && segments[0] != 0x2002
1706                && !(segments[0] == 0x3fff && (segments[1] & 0xf000) == 0)
1707        }
1708    }
1709}
1710
1711#[derive(Clone)]
1712struct PinnedRegistryResolver {
1713    netloc: String,
1714    addresses: Vec<std::net::SocketAddr>,
1715}
1716
1717impl ureq::Resolver for PinnedRegistryResolver {
1718    fn resolve(&self, requested: &str) -> std::io::Result<Vec<std::net::SocketAddr>> {
1719        if requested == self.netloc {
1720            Ok(self.addresses.clone())
1721        } else {
1722            Err(std::io::Error::new(
1723                std::io::ErrorKind::PermissionDenied,
1724                "registry request attempted to resolve an unvalidated authority",
1725            ))
1726        }
1727    }
1728}
1729
1730fn pinned_public_agent(
1731    url: &url::Url,
1732    allow_private: bool,
1733    label: &str,
1734) -> LinkResult<ureq::Agent> {
1735    let host = url
1736        .host_str()
1737        .ok_or_else(|| invalid_feed(format!("{label} has no host")))?;
1738    let port = url
1739        .port_or_known_default()
1740        .ok_or_else(|| invalid_feed(format!("{label} has no port")))?;
1741    let addresses = resolve_addresses_with_deadline(
1742        host,
1743        port,
1744        std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS),
1745    )
1746    .map_err(|error| invalid_feed(format!("{label} DNS resolution failed: {error}")))?;
1747    if addresses.is_empty() {
1748        return Err(invalid_feed(format!("{label} DNS returned no addresses")));
1749    }
1750    if !allow_private
1751        && addresses
1752            .iter()
1753            .any(|address| !is_public_registry_ip(address.ip()))
1754    {
1755        return Err(invalid_feed(format!(
1756            "{label} resolves to a non-public address"
1757        )));
1758    }
1759    let netloc = if host.contains(':') {
1760        format!("[{host}]:{port}")
1761    } else {
1762        format!("{host}:{port}")
1763    };
1764    Ok(agent_builder()
1765        .resolver(PinnedRegistryResolver { netloc, addresses })
1766        .build())
1767}
1768
1769/// Resolve one authority without allowing libc DNS to hold the caller forever.
1770/// `ToSocketAddrs` itself has no timeout API, so resolution runs in a detached
1771/// worker and only its result channel is awaited. A late resolver result has no
1772/// side effects and is discarded after the deadline.
1773fn resolve_addresses_with_deadline(
1774    host: &str,
1775    port: u16,
1776    timeout: std::time::Duration,
1777) -> std::io::Result<Vec<std::net::SocketAddr>> {
1778    use std::net::ToSocketAddrs as _;
1779
1780    let host = host.to_string();
1781    let (send, receive) = std::sync::mpsc::sync_channel(1);
1782    std::thread::Builder::new()
1783        .name("dbmd-dns".to_string())
1784        .spawn(move || {
1785            let result = (host.as_str(), port)
1786                .to_socket_addrs()
1787                .map(|addresses| addresses.collect());
1788            let _ = send.send(result);
1789        })
1790        .map_err(|error| std::io::Error::other(format!("cannot start resolver: {error}")))?;
1791    match receive.recv_timeout(timeout) {
1792        Ok(result) => result,
1793        Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(std::io::Error::new(
1794            std::io::ErrorKind::TimedOut,
1795            "resolution exceeded its deadline",
1796        )),
1797        Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(std::io::Error::other(
1798            "resolver stopped without returning a result",
1799        )),
1800    }
1801}
1802
1803fn registry_agent(url: &url::Url) -> LinkResult<ureq::Agent> {
1804    let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
1805    pinned_public_agent(url, allow_private, "registry home")
1806}
1807
1808/// GET an absolute URL as JSON with NO credential — used to fetch a brain card
1809/// from a FOREIGN home during registry resolution. DNS is resolved once,
1810/// every answer is required to be public, and that exact answer set is pinned
1811/// into a no-redirect HTTP agent to close private-network SSRF and rebinding.
1812fn get_json_absolute(url: &str) -> LinkResult<Value> {
1813    let parsed = url::Url::parse(url).map_err(|_| invalid_feed("invalid registry home URL"))?;
1814    let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
1815    if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1816        || !parsed.username().is_empty()
1817        || parsed.password().is_some()
1818        || parsed.query().is_some()
1819        || parsed.fragment().is_some()
1820    {
1821        return Err(invalid_feed("unsafe registry home URL"));
1822    }
1823    let http = registry_agent(&parsed)?;
1824    let resp = match with_connect_retries(|| http.get(url).call().map_err(Box::new)) {
1825        Ok(resp) => resp,
1826        Err(error) => match *error {
1827            ureq::Error::Status(status, resp) => {
1828                let _ = resp;
1829                return Err(LinkError::Http {
1830                    what: "registry home fetch",
1831                    status,
1832                    message: "the home node rejected the card request".to_string(),
1833                    code: None,
1834                    details: None,
1835                });
1836            }
1837            ureq::Error::Transport(err) => {
1838                return Err(LinkError::Transport {
1839                    hub: url.to_string(),
1840                    message: err.to_string(),
1841                });
1842            }
1843        },
1844    };
1845    if !(200..300).contains(&resp.status()) {
1846        return Err(LinkError::Http {
1847            what: "registry home fetch",
1848            status: resp.status(),
1849            message: "the home node returned a redirect or error".to_string(),
1850            code: None,
1851            details: None,
1852        });
1853    }
1854    let mut buf = Vec::new();
1855    resp.into_reader()
1856        .take(MAX_REGISTRY_CARD_BYTES + 1)
1857        .read_to_end(&mut buf)?;
1858    if buf.len() as u64 > MAX_REGISTRY_CARD_BYTES {
1859        return Err(LinkError::ResponseTooLarge {
1860            limit_bytes: MAX_REGISTRY_CARD_BYTES,
1861        });
1862    }
1863    serde_json::from_slice(&buf).map_err(|_| LinkError::InvalidFeed {
1864        message: "the home node returned invalid JSON".to_string(),
1865    })
1866}
1867
1868/// Resolve a bare `@handle` through the federation registry (link.md §7.1,
1869/// E5): look the handle up in the hub's registry, fetch the brain card from
1870/// the returned HOME node, and PIN — the card's identity fingerprint must
1871/// equal the registry's, or resolution fails. Returns the card enriched with
1872/// the resolved `home`, or `Ok(None)` when the registry has no such handle
1873/// (so the caller can fall back to a direct lookup).
1874pub fn resolve_registry(cfg: &HubConfig, handle: &str) -> LinkResult<Option<Value>> {
1875    require_safe_ref(handle)?;
1876    // Hold the validated trust-directory capability before any network I/O.
1877    // Every lock/load/save below remains relative to this exact inode even if
1878    // an attacker swaps an ancestor while the registry or home is answering.
1879    let trust_directory = open_trust_dir(cfg)?;
1880    let reg = request_capped(
1881        cfg,
1882        "GET",
1883        &format!("/api/hub/registry/{handle}"),
1884        None,
1885        Auth::None,
1886        MAX_REGISTRY_CARD_BYTES,
1887    )?;
1888    if reg.status == 404 {
1889        return Ok(None);
1890    }
1891    let body = ensure_ok(reg, "registry resolve")?;
1892    let home = body
1893        .get("home")
1894        .and_then(Value::as_str)
1895        .ok_or_else(|| invalid_feed("registry entry has no home"))?;
1896    let brain = body
1897        .get("brain")
1898        .and_then(Value::as_str)
1899        .ok_or_else(|| invalid_feed("registry entry has no brain"))?;
1900    if !crate::ulid::is_ulid(brain) {
1901        return Err(invalid_feed(
1902            "registry entry brain is not a canonical lowercase ULID",
1903        ));
1904    }
1905    let want_fp = body
1906        .get("identity")
1907        .and_then(|i| i.get("fingerprint"))
1908        .and_then(Value::as_str)
1909        .ok_or_else(|| invalid_feed("registry entry has no identity fingerprint"))?;
1910
1911    let home = home.trim_end_matches('/');
1912    let origin = normalized_origin(home)?;
1913    if origin != home {
1914        return Err(invalid_feed(
1915            "registry home must be an origin without a path, query, or fragment",
1916        ));
1917    }
1918    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[handle, brain])?;
1919    let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, handle, brain)?;
1920    if let Some(binding) = &alias_binding {
1921        if binding
1922            .home
1923            .as_deref()
1924            .is_some_and(|pinned_home| pinned_home != home)
1925        {
1926            return Err(invalid_feed(
1927                "registry relocated a pinned handle to a different home",
1928            ));
1929        }
1930    }
1931    let card = get_json_absolute(&format!("{home}/api/hub/brains/{brain}"))?;
1932    if card.get("id").and_then(Value::as_str) != Some(brain) {
1933        return Err(invalid_feed(
1934            "the home node served a card for a different brain",
1935        ));
1936    }
1937    let identity: FeedIdentity = serde_json::from_value(
1938        card.get("identity")
1939            .cloned()
1940            .ok_or_else(|| invalid_feed("the home node served no identity"))?,
1941    )
1942    .map_err(|_| invalid_feed("the home node served an invalid identity"))?;
1943    let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
1944    let got_fp = card
1945        .get("identity")
1946        .and_then(|i| i.get("fingerprint"))
1947        .and_then(Value::as_str)
1948        .unwrap_or_default();
1949    if got_fp != want_fp {
1950        return Err(invalid_feed(
1951            "the home node served an identity that does not match the registry — refusing",
1952        ));
1953    }
1954    let current = format!("ed25519:{}", identity.fingerprint);
1955    let advertised_seq = card
1956        .get("headSeq")
1957        .and_then(Value::as_u64)
1958        .ok_or_else(|| invalid_feed("the home node served an invalid head sequence"))?;
1959    let advertised_hash = card.get("feedHash").and_then(Value::as_str);
1960    if (advertised_seq == 0 && !card.get("feedHash").is_some_and(Value::is_null))
1961        || (advertised_seq > 0 && advertised_hash.is_none_or(|hash| !is_sha256(hash)))
1962    {
1963        return Err(invalid_feed(
1964            "the home node served an invalid feed head boundary",
1965        ));
1966    }
1967    // This is required even on first contact. Otherwise a syntactically valid
1968    // identity may claim that a rotation happened after a head the home has
1969    // never reached, then become the permanent TOFU anchor.
1970    verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], advertised_seq)?;
1971    let registry_alias = AliasBinding {
1972        v: 1,
1973        origin: normalized_origin(&cfg.hub)?,
1974        requested: handle.to_string(),
1975        brain: brain.to_string(),
1976        home: Some(home.to_string()),
1977    };
1978    save_canonical_pin_and_alias(
1979        cfg,
1980        &trust_directory,
1981        handle,
1982        brain,
1983        TrustState {
1984            v: 2,
1985            origin: normalized_origin(&cfg.hub)?,
1986            requested: brain.to_string(),
1987            brain: brain.to_string(),
1988            home: None,
1989            anchor,
1990            current,
1991            head_seq: pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq),
1992            feed_hash: pinned
1993                .as_ref()
1994                .and_then(|checkpoint| checkpoint.feed_hash.clone()),
1995            rotations: identity.rotations.clone(),
1996            hub_signer: None,
1997            protocol_profile: None,
1998        },
1999        Some(&registry_alias),
2000    )?;
2001    let mut out = card;
2002    if let Value::Object(map) = &mut out {
2003        map.insert("home".to_string(), Value::String(home.to_string()));
2004        map.insert(
2005            "resolvedVia".to_string(),
2006            Value::String("registry".to_string()),
2007        );
2008    }
2009    Ok(Some(out))
2010}
2011
2012pub fn resolve(cfg: &HubConfig, addr: &Address) -> LinkResult<Value> {
2013    // `Address::parse` refuses these shapes already, but `Address` has public
2014    // fields — re-assert at the wire so a hand-built address can never
2015    // reshape the request path.
2016    require_safe_ref(&addr.brain)?;
2017    if let Some(target) = &addr.target {
2018        let (given, ok) = match target {
2019            AddressTarget::Id(id) => (id, crate::ulid::is_ulid(id)),
2020            AddressTarget::Path(p) => (p, safe_store_rel_path(p) && p.ends_with(".md")),
2021        };
2022        if !ok {
2023            return Err(LinkError::BadAddress {
2024                given: given.clone(),
2025                reason: BAD_TARGET_REASON.to_string(),
2026            });
2027        }
2028    }
2029
2030    // A record is never accepted from the hub's mutable query/index response:
2031    // that response was not covered by the brain's feed signature and could
2032    // return arbitrary frontmatter/body while an unrelated signed head still
2033    // verified. Materialize the record from the exact content-addressed pack
2034    // named by the verified signed head instead.
2035    if let Some(target) = &addr.target {
2036        let remote = verified_remote_head(cfg, &addr.brain, false)?;
2037        if !remote.head.verified {
2038            return Err(invalid_feed(
2039                "a path-scoped feed cannot prove a record against the full signed snapshot",
2040            ));
2041        }
2042        if remote.head.seq == 0 {
2043            return Err(LinkError::Http {
2044                what: "resolve",
2045                status: 404,
2046                message: "record not found".to_string(),
2047                code: Some("NOT_FOUND".to_string()),
2048                details: None,
2049            });
2050        }
2051        let brain = remote.head.brain.clone();
2052        let pack = download_verified_snapshot_pack(cfg, &brain, &remote)?;
2053        return resolve_from_verified_pack(&brain, target, pack);
2054    }
2055
2056    let path = format!("/api/hub/brains/{}", addr.brain);
2057    // Direct first: the caller's own slug and hub-hosted public handles resolve
2058    // here unchanged. Only a bare `@handle` the hub can't resolve directly
2059    // (404) falls through to the federation registry — how a handle reaches a
2060    // brain on ANOTHER node (link.md §7.1, E5).
2061    let direct = request(cfg, "GET", &path, None, Auth::Required)?;
2062    if direct.status == 404 && addr.target.is_none() && !crate::ulid::is_ulid(&addr.brain) {
2063        if let Some(card) = resolve_registry(cfg, &addr.brain)? {
2064            return Ok(card);
2065        }
2066    }
2067    let resolved = ensure_ok(direct, "resolve")?;
2068    // A successful direct response is accepted only after the same centralized
2069    // identity/rotation/feed checkpoint verification used by sync and
2070    // subscribe. No verb gets a weaker ad-hoc pinning path.
2071    let remote = verified_remote_head(cfg, &addr.brain, false)?;
2072    if resolved.get("id").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2073        || resolved.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2074        || resolved.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
2075    {
2076        return Err(invalid_feed(
2077            "resolve card is not bound to the exact verified feed checkpoint",
2078        ));
2079    }
2080    let card_identity: FeedIdentity = serde_json::from_value(
2081        resolved
2082            .get("identity")
2083            .cloned()
2084            .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2085    )
2086    .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2087    if remote.identity.as_ref() != Some(&card_identity) {
2088        return Err(invalid_feed(
2089            "resolve card identity differs from the verified feed identity",
2090        ));
2091    }
2092    Ok(resolved)
2093}
2094
2095/// Resolve one record strictly from the exact signed snapshot pack. The hub's
2096/// mutable query/index result is intentionally not consulted: a signed pack
2097/// digest is the only cryptographic binding between a feed checkpoint and
2098/// record bytes in wire profile v1.
2099fn resolve_from_verified_pack(
2100    brain: &str,
2101    target: &AddressTarget,
2102    pack: Vec<u8>,
2103) -> LinkResult<Value> {
2104    let entries = parse_store_pack(pack)?;
2105    let mut matched: Option<(String, Vec<u8>)> = None;
2106
2107    for (path, bytes) in entries {
2108        let is_candidate = match target {
2109            AddressTarget::Path(want) => &path == want,
2110            AddressTarget::Id(_) => {
2111                path.ends_with(".md")
2112                    && (path.starts_with("records/") || path.starts_with("sources/"))
2113            }
2114        };
2115        if !is_candidate {
2116            continue;
2117        }
2118        let text = std::str::from_utf8(&bytes)
2119            .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2120        let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2121            .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2122        if let AddressTarget::Id(want) = target {
2123            let frontmatter =
2124                crate::parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(&path))
2125                    .map_err(|_| {
2126                        invalid_feed(format!("signed snapshot record `{path}` is malformed"))
2127                    })?;
2128            if frontmatter.id.as_deref() != Some(want) {
2129                continue;
2130            }
2131        }
2132        if matched.is_some() {
2133            return Err(invalid_feed(
2134                "signed snapshot contains more than one record for the requested target",
2135            ));
2136        }
2137        matched = Some((path, bytes));
2138    }
2139
2140    let (path, bytes) = matched.ok_or_else(|| LinkError::Http {
2141        what: "resolve",
2142        status: 404,
2143        message: "record not found".to_string(),
2144        code: Some("NOT_FOUND".to_string()),
2145        details: None,
2146    })?;
2147    let text = std::str::from_utf8(&bytes)
2148        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2149    let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2150        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2151    let frontmatter: Value = serde_norway::from_str(&parsed.frontmatter_yaml)
2152        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2153    let Value::Object(fields) = frontmatter else {
2154        return Err(invalid_feed(format!(
2155            "signed snapshot record `{path}` frontmatter is not a mapping"
2156        )));
2157    };
2158    let mut document = serde_json::Map::new();
2159    document.insert("path".to_string(), Value::String(path));
2160    for (key, value) in fields {
2161        document.insert(key, value);
2162    }
2163    document.insert("body".to_string(), Value::String(parsed.body));
2164    document.insert(
2165        "contentSha".to_string(),
2166        Value::String(content_sha256(&bytes)),
2167    );
2168    Ok(json!({
2169        "brain": brain,
2170        "document": Value::Object(document),
2171    }))
2172}
2173
2174// ─────────────────────────────────────────────────────────────────────────────
2175// sync — pull the granted slice as files; push the local store as a snapshot
2176// ─────────────────────────────────────────────────────────────────────────────
2177
2178/// What a pull materialized.
2179#[derive(Debug, Clone, serde::Serialize)]
2180pub struct PullReport {
2181    /// The brain id the hub reported.
2182    pub brain: String,
2183    /// The brain's slug.
2184    pub slug: String,
2185    /// The hub's feed head at export time.
2186    #[serde(rename = "headSeq")]
2187    pub head_seq: u64,
2188    /// How many files were written.
2189    pub files: usize,
2190    /// Where they were written (as given or derived from the slug).
2191    pub dest: String,
2192    /// Local content files that the export did not carry — present so a
2193    /// caller sees divergence; nothing is ever deleted locally.
2194    #[serde(rename = "extraLocal")]
2195    pub extra_local: Vec<String>,
2196    /// Exact convergence result from the post-install barrier.
2197    #[serde(rename = "syncStatus")]
2198    pub sync_status: String,
2199}
2200
2201struct V2PulledSnapshot {
2202    report: PullReport,
2203    head: V2VerifiedHead,
2204    files: std::collections::BTreeMap<String, V2BaselineFile>,
2205    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
2206    local: V2LocalView,
2207    local_assets: std::collections::BTreeMap<String, crate::AssetRecord>,
2208}
2209
2210fn download_verified_snapshot_pack(
2211    cfg: &HubConfig,
2212    brain: &str,
2213    remote: &VerifiedRemote,
2214) -> LinkResult<Vec<u8>> {
2215    let feed_hash = remote
2216        .head
2217        .feed_hash
2218        .as_deref()
2219        .ok_or_else(|| invalid_feed("non-empty snapshot has no verified feed hash"))?;
2220    let signed_head = remote
2221        .head_entry
2222        .as_ref()
2223        .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
2224    let expected = &signed_head.entry.pack_sha256;
2225    if !is_sha256(expected) {
2226        return Err(invalid_feed(
2227            "signed head carries an invalid snapshot pack digest",
2228        ));
2229    }
2230    let path = format!(
2231        "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={feed_hash}",
2232        remote.head.seq
2233    );
2234    let body = ensure_ok(
2235        request(cfg, "GET", &path, None, Auth::Required)?,
2236        "sync pull",
2237    )?;
2238    if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2239        || body.get("feedHash").and_then(Value::as_str) != Some(feed_hash)
2240        || body.get("brain").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2241        || body.get("sha256").and_then(Value::as_str) != Some(expected.as_str())
2242    {
2243        return Err(invalid_feed(
2244            "export response is not bound to the exact verified snapshot",
2245        ));
2246    }
2247    let url = body
2248        .get("url")
2249        .and_then(Value::as_str)
2250        .ok_or_else(|| invalid_feed("verified snapshot export carried no exact pack URL"))?;
2251    let bytes = get_presigned(cfg, url)?;
2252    if content_sha256(&bytes) != *expected {
2253        return Err(LinkError::InvalidPack {
2254            message: "downloaded pack does not match the signed snapshot digest".to_string(),
2255        });
2256    }
2257    let entries = parse_store_pack(bytes.clone())?;
2258    if signed_head.entry.kind == "push" {
2259        verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
2260    }
2261    Ok(bytes)
2262}
2263
2264#[derive(Debug, Clone, Deserialize, Serialize)]
2265struct V2PointerBody {
2266    v: u8,
2267    brain: String,
2268    seq: u64,
2269    commit_hash: String,
2270    feed_hash: String,
2271    content_root: Option<String>,
2272    asset_root: Option<String>,
2273    materializer: String,
2274    signer_epoch: u64,
2275    control_revision: String,
2276    backup_preparation: String,
2277    prior_pointer_hash: Option<String>,
2278    signed_at: String,
2279}
2280
2281#[derive(Debug, Clone, Deserialize)]
2282struct V2SignedPointer {
2283    pointer: V2PointerBody,
2284    hub_public_key: String,
2285    hub_fingerprint: String,
2286    sig: String,
2287}
2288
2289#[derive(Debug, Clone, Deserialize)]
2290struct V2HeadIdentity {
2291    #[serde(default)]
2292    custody: String,
2293    fingerprint: String,
2294    public_key_spki: String,
2295    #[serde(default)]
2296    previous: Vec<V2PreviousIdentity>,
2297    #[serde(default)]
2298    rotations: Vec<String>,
2299}
2300
2301#[derive(Debug, Clone, Deserialize)]
2302struct V2PreviousIdentity {
2303    fingerprint: String,
2304    public_key_spki: String,
2305}
2306
2307#[derive(Debug, Deserialize)]
2308struct V2HeadResponse {
2309    v: u8,
2310    brain_id: String,
2311    profile: String,
2312    view: Option<V2HeadView>,
2313    pointer: Option<V2SignedPointer>,
2314    identity: Option<V2HeadIdentity>,
2315}
2316
2317#[derive(Debug, Clone, Deserialize)]
2318struct V2HeadView {
2319    kind: String,
2320    #[serde(default)]
2321    id: Option<String>,
2322    control_revision: String,
2323}
2324
2325#[derive(Debug, Clone)]
2326struct V2VerifiedHead {
2327    requested: String,
2328    brain_id: String,
2329    view_kind: String,
2330    /// Stable identity of the exact current-content path projection.
2331    view_revision: String,
2332    /// Use-time authority revision independently bound into writes/signatures.
2333    control_revision: String,
2334    identity: V2HeadIdentity,
2335    pointer: Option<V2PointerBody>,
2336    trust: TrustState,
2337    alias: Option<AliasBinding>,
2338}
2339
2340fn verify_v2_spki_signature(
2341    public_key: &str,
2342    message: &[u8],
2343    signature: &str,
2344) -> LinkResult<Vec<u8>> {
2345    let der = URL_SAFE_NO_PAD
2346        .decode(public_key)
2347        .map_err(|_| invalid_feed("v2 signer public key is not base64url"))?;
2348    if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
2349        return Err(invalid_feed("v2 signer public key is not Ed25519 SPKI"));
2350    }
2351    let sig = URL_SAFE_NO_PAD
2352        .decode(signature)
2353        .map_err(|_| invalid_feed("v2 signature is not base64url"))?;
2354    UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
2355        .verify(message, &sig)
2356        .map_err(|_| invalid_feed("v2 Ed25519 signature failed"))?;
2357    Ok(der)
2358}
2359
2360fn verify_v2_pointer(pointer: &V2SignedPointer, expected_brain: &str) -> LinkResult<String> {
2361    if pointer.pointer.v != 2
2362        || pointer.pointer.brain != expected_brain
2363        || pointer.pointer.seq == 0
2364        || !is_sha256(&pointer.pointer.commit_hash)
2365        || !is_sha256(&pointer.pointer.feed_hash)
2366        || pointer
2367            .pointer
2368            .content_root
2369            .as_deref()
2370            .is_some_and(|hash| !is_sha256(hash))
2371        || !is_sha256(&pointer.pointer.backup_preparation)
2372    {
2373        return Err(invalid_feed("v2 pointer fields are invalid"));
2374    }
2375    let value = serde_json::to_value(&pointer.pointer)
2376        .map_err(|_| invalid_feed("v2 pointer could not be canonicalized"))?;
2377    let message = crate::linkmd_v2::canonical_bytes(&value)
2378        .map_err(|error| invalid_feed(error.to_string()))?;
2379    let der = verify_v2_spki_signature(&pointer.hub_public_key, &message, &pointer.sig)?;
2380    let fingerprint = format!("{:x}", Sha256::digest(&der));
2381    if fingerprint != pointer.hub_fingerprint {
2382        return Err(invalid_feed("v2 hub signer fingerprint mismatch"));
2383    }
2384    Ok(format!(
2385        "{}:{}",
2386        pointer.hub_fingerprint, pointer.hub_public_key
2387    ))
2388}
2389
2390fn v2_identity(identity: &V2HeadIdentity) -> FeedIdentity {
2391    FeedIdentity {
2392        fingerprint: identity.fingerprint.clone(),
2393        public_key_spki: identity.public_key_spki.clone(),
2394        previous: identity
2395            .previous
2396            .iter()
2397            .map(|previous| PreviousIdentity {
2398                fingerprint: previous.fingerprint.clone(),
2399                public_key_spki: previous.public_key_spki.clone(),
2400            })
2401            .collect(),
2402        rotations: identity.rotations.clone(),
2403    }
2404}
2405
2406fn verified_v2_commit_object(
2407    raw: &[u8],
2408    identity: &V2HeadIdentity,
2409) -> LinkResult<serde_json::Map<String, Value>> {
2410    let mut value: Value =
2411        serde_json::from_slice(raw).map_err(|_| invalid_feed("v2 commit is not JSON"))?;
2412    let canonical = crate::linkmd_v2::canonical_bytes(&value)
2413        .map_err(|error| invalid_feed(error.to_string()))?;
2414    if canonical != raw {
2415        return Err(invalid_feed("v2 commit is not canonical JSON"));
2416    }
2417    let object = value
2418        .as_object_mut()
2419        .ok_or_else(|| invalid_feed("v2 commit is not an object"))?;
2420    let sig = object
2421        .remove("sig")
2422        .and_then(|value| value.as_str().map(str::to_string))
2423        .ok_or_else(|| invalid_feed("v2 commit has no signature"))?;
2424    const FIELDS: [&str; 18] = [
2425        "actor_ref",
2426        "asset_root",
2427        "brain",
2428        "changes_sha256",
2429        "control_revision",
2430        "materializer",
2431        "op",
2432        "parent_asset_root",
2433        "parent_commit",
2434        "parent_root",
2435        "prev_entry_hash",
2436        "public_key",
2437        "seq",
2438        "signer_epoch",
2439        "state_root",
2440        "ts",
2441        "v",
2442        "v1_bridge",
2443    ];
2444    if object.len() != FIELDS.len() || FIELDS.iter().any(|field| !object.contains_key(*field)) {
2445        return Err(invalid_feed("v2 commit has a non-normative field set"));
2446    }
2447    let seq = object
2448        .get("seq")
2449        .and_then(Value::as_u64)
2450        .filter(|seq| *seq > 0)
2451        .ok_or_else(|| invalid_feed("v2 commit has an invalid sequence"))?;
2452    let signer_epoch = object
2453        .get("signer_epoch")
2454        .and_then(Value::as_u64)
2455        .filter(|epoch| *epoch > 0)
2456        .ok_or_else(|| invalid_feed("v2 commit has an invalid signer epoch"))?;
2457    let hash_or_null = |field: &str| {
2458        object
2459            .get(field)
2460            .is_some_and(|value| value.is_null() || value.as_str().is_some_and(is_sha256))
2461    };
2462    if object.get("v").and_then(Value::as_u64) != Some(2)
2463        || object.get("op").and_then(Value::as_str) != Some("changeset")
2464        || !object
2465            .get("changes_sha256")
2466            .and_then(Value::as_str)
2467            .is_some_and(is_sha256)
2468        || !object
2469            .get("actor_ref")
2470            .and_then(Value::as_str)
2471            .is_some_and(is_sha256)
2472        || !object
2473            .get("control_revision")
2474            .and_then(Value::as_str)
2475            .is_some_and(is_sha256)
2476        || !object
2477            .get("state_root")
2478            .and_then(Value::as_str)
2479            .is_some_and(is_sha256)
2480        || !hash_or_null("parent_commit")
2481        || !hash_or_null("parent_root")
2482        || !hash_or_null("parent_asset_root")
2483        || !hash_or_null("asset_root")
2484        || !hash_or_null("prev_entry_hash")
2485        || !object
2486            .get("materializer")
2487            .and_then(Value::as_str)
2488            .is_some_and(|value| !value.is_empty() && value.len() <= 128)
2489        || !object
2490            .get("ts")
2491            .and_then(Value::as_str)
2492            .is_some_and(|value| value.len() == 24 && value.ends_with('Z'))
2493    {
2494        return Err(invalid_feed("v2 commit fields are invalid"));
2495    }
2496    if (seq == 1
2497        && [
2498            "parent_commit",
2499            "parent_root",
2500            "parent_asset_root",
2501            "prev_entry_hash",
2502        ]
2503        .iter()
2504        .any(|field| !object.get(*field).is_some_and(Value::is_null)))
2505        || (seq > 1
2506            && ["parent_commit", "parent_root", "prev_entry_hash"]
2507                .iter()
2508                .any(|field| object.get(*field).and_then(Value::as_str).is_none()))
2509    {
2510        return Err(invalid_feed("v2 commit parent shape is invalid"));
2511    }
2512    match object.get("v1_bridge") {
2513        Some(Value::Null) => {}
2514        Some(Value::Object(bridge))
2515            if seq == 1
2516                && bridge.len() == 3
2517                && bridge
2518                    .get("head_seq")
2519                    .and_then(Value::as_u64)
2520                    .is_some_and(|v| v > 0)
2521                && bridge
2522                    .get("feed_hash")
2523                    .and_then(Value::as_str)
2524                    .is_some_and(is_sha256)
2525                && bridge
2526                    .get("pack_sha256")
2527                    .and_then(Value::as_str)
2528                    .is_some_and(is_sha256) => {}
2529        _ => return Err(invalid_feed("v2 commit has an invalid v1 bridge")),
2530    }
2531    let public_key = object
2532        .get("public_key")
2533        .and_then(Value::as_str)
2534        .ok_or_else(|| invalid_feed("v2 commit has no public key"))?;
2535    let der = URL_SAFE_NO_PAD
2536        .decode(public_key)
2537        .map_err(|_| invalid_feed("v2 brain public key is not base64url"))?;
2538    let expected_multikey = format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&der)));
2539    if object.get("brain").and_then(Value::as_str) != Some(expected_multikey.as_str()) {
2540        return Err(invalid_feed("v2 commit brain identity mismatch"));
2541    }
2542    // Verify the old-key-signed chain before trusting any prior public key.
2543    verify_identity_chain(&v2_identity(identity), None)?;
2544    // Old commits remain verifiable after brain-key rotation. `previous` is
2545    // newest-first, while signer epochs and rotation statements are oldest-first.
2546    let mut chain: Vec<(&str, &str)> = identity
2547        .previous
2548        .iter()
2549        .rev()
2550        .map(|previous| {
2551            (
2552                previous.fingerprint.as_str(),
2553                previous.public_key_spki.as_str(),
2554            )
2555        })
2556        .collect();
2557    chain.push((&identity.fingerprint, &identity.public_key_spki));
2558    let signer_index = chain.iter().position(|(fingerprint, spki)| {
2559        *fingerprint == expected_multikey.trim_start_matches("ed25519:") && *spki == public_key
2560    });
2561    let Some(signer_index) = signer_index else {
2562        return Err(invalid_feed("v2 commit uses an unrecognized brain key"));
2563    };
2564    if signer_epoch != signer_index as u64 + 1 {
2565        return Err(invalid_feed("v2 commit signer epoch differs from its key"));
2566    }
2567    let lower_boundary = if signer_index == 0 {
2568        None
2569    } else {
2570        let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
2571            .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
2572        Some(prior.prior_head_seq)
2573    };
2574    let upper_boundary = if signer_index == identity.rotations.len() {
2575        None
2576    } else {
2577        let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
2578            .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
2579        Some(next.prior_head_seq)
2580    };
2581    if lower_boundary.is_some_and(|boundary| seq <= boundary)
2582        || upper_boundary.is_some_and(|boundary| seq > boundary)
2583    {
2584        return Err(invalid_feed(
2585            "v2 commit signer is outside its authenticated rotation epoch",
2586        ));
2587    }
2588    let unsigned = crate::linkmd_v2::canonical_bytes(&Value::Object(object.clone()))
2589        .map_err(|error| invalid_feed(error.to_string()))?;
2590    verify_v2_spki_signature(public_key, &unsigned, &sig)?;
2591    Ok(object.clone())
2592}
2593
2594#[derive(Debug, Deserialize)]
2595struct V2FeedWireEntry {
2596    seq: u64,
2597    commit_hash: String,
2598    feed_hash: String,
2599    bytes_base64: String,
2600}
2601
2602#[derive(Debug, Deserialize)]
2603struct V2FeedPage {
2604    v: u8,
2605    head_seq: u64,
2606    head_commit_hash: String,
2607    head_feed_hash: String,
2608    entries: Vec<V2FeedWireEntry>,
2609    next_after: u64,
2610    complete: bool,
2611}
2612
2613fn replay_v2_feed(
2614    cfg: &HubConfig,
2615    brain: &str,
2616    pointer: &V2PointerBody,
2617    identity: &V2HeadIdentity,
2618    start_after: u64,
2619    start_feed: Option<String>,
2620) -> LinkResult<()> {
2621    let mut after = start_after;
2622    let mut prior_feed = start_feed;
2623    let mut final_object = None;
2624    let mut replayed_entries = 0_u64;
2625    let mut replayed_bytes = 0_u64;
2626    while after < pointer.seq {
2627        let path = format!("/api/hub/brains/{brain}/v2/feed?after={after}&limit=100");
2628        let value = ensure_ok(
2629            request_capped(
2630                cfg,
2631                "GET",
2632                &path,
2633                None,
2634                Auth::Required,
2635                MAX_FEED_REPLAY_BYTES,
2636            )?,
2637            "v2 feed replay",
2638        )?;
2639        let page: V2FeedPage = serde_json::from_value(value)
2640            .map_err(|_| invalid_feed("v2 feed page has an invalid shape"))?;
2641        if page.v != 2
2642            || page.head_seq != pointer.seq
2643            || page.head_commit_hash != pointer.commit_hash
2644            || page.head_feed_hash != pointer.feed_hash
2645            || page.entries.is_empty()
2646            || page.entries.len() > FEED_PAGE_LIMIT
2647        {
2648            return Err(invalid_feed("v2 feed page differs from the signed head"));
2649        }
2650        for entry in page.entries {
2651            if entry.seq != after + 1
2652                || !is_sha256(&entry.commit_hash)
2653                || !is_sha256(&entry.feed_hash)
2654            {
2655                return Err(invalid_feed("v2 feed sequence is not contiguous"));
2656            }
2657            let raw = base64::engine::general_purpose::STANDARD
2658                .decode(&entry.bytes_base64)
2659                .map_err(|_| invalid_feed("v2 feed bytes are not canonical base64"))?;
2660            replayed_entries = replayed_entries
2661                .checked_add(1)
2662                .ok_or_else(|| invalid_feed("v2 feed replay count overflow"))?;
2663            replayed_bytes = replayed_bytes
2664                .checked_add(raw.len() as u64)
2665                .ok_or_else(|| invalid_feed("v2 feed replay byte count overflow"))?;
2666            if replayed_entries > MAX_FEED_REPLAY_ENTRIES || replayed_bytes > MAX_FEED_REPLAY_BYTES
2667            {
2668                return Err(invalid_feed("v2 feed replay exceeds its safety bound"));
2669            }
2670            if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
2671                .map_err(|error| invalid_feed(error.to_string()))?
2672                != entry.commit_hash
2673                || content_sha256(&raw) != entry.feed_hash
2674            {
2675                return Err(invalid_feed("v2 feed entry address mismatch"));
2676            }
2677            let object = verified_v2_commit_object(&raw, identity)?;
2678            if object.get("seq").and_then(Value::as_u64) != Some(entry.seq)
2679                || object.get("prev_entry_hash").and_then(Value::as_str) != prior_feed.as_deref()
2680            {
2681                return Err(invalid_feed(
2682                    "v2 feed entry does not extend its predecessor",
2683                ));
2684            }
2685            after = entry.seq;
2686            prior_feed = Some(entry.feed_hash);
2687            final_object = Some((entry.commit_hash, object));
2688        }
2689        if page.next_after != after || (page.complete != (after == pointer.seq)) {
2690            return Err(invalid_feed("v2 feed page cursor is inconsistent"));
2691        }
2692    }
2693    let (final_hash, object) =
2694        final_object.ok_or_else(|| invalid_feed("v2 feed replay made no progress"))?;
2695    if final_hash != pointer.commit_hash
2696        || prior_feed.as_deref() != Some(pointer.feed_hash.as_str())
2697        || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
2698        || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
2699        || object.get("control_revision").and_then(Value::as_str)
2700            != Some(pointer.control_revision.as_str())
2701        || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
2702    {
2703        return Err(invalid_feed(
2704            "v2 replay did not converge on the signed pointer",
2705        ));
2706    }
2707    Ok(())
2708}
2709
2710fn verify_v1_to_v2_bridge(
2711    cfg: &HubConfig,
2712    brain: &str,
2713    pointer: &V2PointerBody,
2714    identity: &V2HeadIdentity,
2715    checkpoint: &TrustState,
2716) -> LinkResult<()> {
2717    let value = ensure_ok(
2718        request_capped(
2719            cfg,
2720            "GET",
2721            &format!("/api/hub/brains/{brain}/v2/feed?after=0&limit=1"),
2722            None,
2723            Auth::Required,
2724            MAX_FEED_RESPONSE_BYTES,
2725        )?,
2726        "v2 genesis bridge",
2727    )?;
2728    let page: V2FeedPage = serde_json::from_value(value)
2729        .map_err(|_| invalid_feed("v2 genesis bridge page has an invalid shape"))?;
2730    if page.v != 2
2731        || page.head_seq != pointer.seq
2732        || page.head_commit_hash != pointer.commit_hash
2733        || page.head_feed_hash != pointer.feed_hash
2734        || page.entries.len() != 1
2735        || page.entries[0].seq != 1
2736        || !is_sha256(&page.entries[0].commit_hash)
2737        || !is_sha256(&page.entries[0].feed_hash)
2738    {
2739        return Err(invalid_feed(
2740            "v2 genesis bridge page differs from the signed head",
2741        ));
2742    }
2743    let first = &page.entries[0];
2744    let raw = STANDARD
2745        .decode(&first.bytes_base64)
2746        .map_err(|_| invalid_feed("v2 genesis bridge bytes are not canonical base64"))?;
2747    if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
2748        .map_err(|error| invalid_feed(error.to_string()))?
2749        != first.commit_hash
2750        || content_sha256(&raw) != first.feed_hash
2751    {
2752        return Err(invalid_feed("v2 genesis bridge address mismatch"));
2753    }
2754    let object = verified_v2_commit_object(&raw, identity)?;
2755    if checkpoint.head_seq == 0 {
2756        if checkpoint.feed_hash.is_some() || object.get("v1_bridge") != Some(&Value::Null) {
2757            return Err(invalid_feed(
2758                "empty v1 checkpoint did not transition through an empty v2 genesis",
2759            ));
2760        }
2761        return Ok(());
2762    }
2763    let bridge = object
2764        .get("v1_bridge")
2765        .and_then(Value::as_object)
2766        .ok_or_else(|| invalid_feed("v2 genesis omitted the pinned v1 boundary"))?;
2767    let checkpoint_feed = checkpoint
2768        .feed_hash
2769        .as_deref()
2770        .ok_or_else(|| invalid_feed("non-empty v1 checkpoint has no feed hash"))?;
2771    if bridge.get("head_seq").and_then(Value::as_u64) != Some(checkpoint.head_seq)
2772        || bridge.get("feed_hash").and_then(Value::as_str) != Some(checkpoint_feed)
2773    {
2774        return Err(invalid_feed(
2775            "v2 genesis bridge differs from the pinned v1 checkpoint",
2776        ));
2777    }
2778    let legacy_raw = ensure_raw_ok(
2779        request_raw(
2780            cfg,
2781            "GET",
2782            &format!(
2783                "/api/hub/brains/{brain}/feed?after={}&limit=1",
2784                checkpoint.head_seq - 1
2785            ),
2786            None,
2787            Auth::Required,
2788            MAX_FEED_RESPONSE_BYTES,
2789        )?,
2790        "v1 bridge boundary",
2791    )?;
2792    let legacy: FeedResponse = serde_json::from_slice(&legacy_raw)
2793        .map_err(|_| invalid_feed("v1 bridge boundary has an invalid feed shape"))?;
2794    let legacy_identity = legacy
2795        .identity
2796        .ok_or_else(|| invalid_feed("v1 bridge boundary has no identity"))?;
2797    let item = legacy
2798        .entries
2799        .first()
2800        .filter(|_| legacy.entries.len() == 1)
2801        .ok_or_else(|| invalid_feed("v1 bridge boundary did not return one exact entry"))?;
2802    if legacy.scope_limited
2803        || legacy.head_seq != checkpoint.head_seq
2804        || legacy.feed_hash.as_deref() != Some(checkpoint_feed)
2805        || item.entry.seq != checkpoint.head_seq
2806        || item.hash != checkpoint_feed
2807        || legacy_identity != v2_identity(identity)
2808        || bridge.get("pack_sha256").and_then(Value::as_str)
2809            != Some(item.entry.pack_sha256.as_str())
2810    {
2811        return Err(invalid_feed(
2812            "v1 bridge boundary differs from its signed legacy head",
2813        ));
2814    }
2815    let anchor = verify_identity_chain(&legacy_identity, Some(checkpoint))?;
2816    if anchor != checkpoint.anchor {
2817        return Err(invalid_feed("v1 bridge changed the pinned identity anchor"));
2818    }
2819    verify_feed_item(item, &legacy_identity)?;
2820    verify_rotation_feed_boundaries(
2821        &legacy_identity,
2822        Some(checkpoint),
2823        std::slice::from_ref(item),
2824        checkpoint.head_seq,
2825    )?;
2826    Ok(())
2827}
2828
2829fn verify_v2_commit(
2830    cfg: &HubConfig,
2831    brain: &str,
2832    pointer: &V2PointerBody,
2833    identity: &V2HeadIdentity,
2834    pinned: Option<&TrustState>,
2835) -> LinkResult<()> {
2836    let path = format!(
2837        "/api/hub/brains/{brain}/v2/commit?commit={}",
2838        pointer.commit_hash
2839    );
2840    let raw = ensure_raw_ok(
2841        request_raw(cfg, "GET", &path, None, Auth::Required, MAX_RESPONSE_BYTES)?,
2842        "v2 commit",
2843    )?;
2844    if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
2845        .map_err(|error| invalid_feed(error.to_string()))?
2846        != pointer.commit_hash
2847        || content_sha256(&raw) != pointer.feed_hash
2848    {
2849        return Err(invalid_feed("v2 commit address differs from the pointer"));
2850    }
2851    let object = verified_v2_commit_object(&raw, identity)?;
2852    if object.get("seq").and_then(Value::as_u64) != Some(pointer.seq)
2853        || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
2854        || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
2855        || object.get("control_revision").and_then(Value::as_str)
2856            != Some(pointer.control_revision.as_str())
2857        || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
2858    {
2859        return Err(invalid_feed("v2 commit fields differ from the pointer"));
2860    }
2861    if let Some(checkpoint) = pinned.filter(|checkpoint| accepted_as_v2(checkpoint)) {
2862        if pointer.seq == checkpoint.head_seq + 1
2863            && object.get("prev_entry_hash").and_then(Value::as_str)
2864                != checkpoint.feed_hash.as_deref()
2865        {
2866            return Err(invalid_feed(
2867                "v2 commit does not extend the pinned feed hash",
2868            ));
2869        }
2870        if pointer.seq > checkpoint.head_seq + 1 {
2871            return replay_v2_feed(
2872                cfg,
2873                brain,
2874                pointer,
2875                identity,
2876                checkpoint.head_seq,
2877                checkpoint.feed_hash.clone(),
2878            );
2879        }
2880    } else {
2881        if let Some(checkpoint) = pinned {
2882            verify_v1_to_v2_bridge(cfg, brain, pointer, identity, checkpoint)?;
2883        }
2884        if pointer.seq > 1 {
2885            return replay_v2_feed(cfg, brain, pointer, identity, 0, None);
2886        }
2887    }
2888    Ok(())
2889}
2890
2891fn v2_verified_head(cfg: &HubConfig, brain: &str) -> LinkResult<Option<V2VerifiedHead>> {
2892    require_hardened_filesystem("verified link.md v2 state")?;
2893    require_safe_ref(brain)?;
2894    let path = format!("/api/hub/brains/{brain}/v2/head");
2895    let response = request(cfg, "GET", &path, None, Auth::Required)?;
2896    if response.status == 404 {
2897        if has_accepted_v2_ref(cfg, brain)? {
2898            return Err(LinkError::BrainUnavailable);
2899        }
2900        return Ok(None);
2901    }
2902    let body = ensure_ok(response, "v2 head")?;
2903    let head: V2HeadResponse = serde_json::from_value(body)
2904        .map_err(|_| invalid_feed("v2 head response has an invalid shape"))?;
2905    if head.v != 2 || !crate::ulid::is_ulid(&head.brain_id) {
2906        return Err(invalid_feed("v2 head has no canonical brain id"));
2907    }
2908    if crate::ulid::is_ulid(brain) && head.brain_id != brain {
2909        return Err(invalid_feed("v2 head resolved a different brain id"));
2910    }
2911    if head.profile == "v1" {
2912        return Ok(None);
2913    }
2914    if head.profile != "v2" && head.profile != "v2-empty" {
2915        return Err(invalid_feed("v2 head advertised an unknown profile"));
2916    }
2917    let view = head
2918        .view
2919        .as_ref()
2920        .ok_or_else(|| invalid_feed("v2 head has no permission view"))?;
2921    if !matches!(view.kind.as_str(), "full" | "scoped")
2922        || !is_sha256(&view.control_revision)
2923        || view.id.as_deref().is_some_and(|id| !is_sha256(id))
2924    {
2925        return Err(invalid_feed("v2 head has an invalid permission view"));
2926    }
2927    let view_kind = view.kind.clone();
2928    // `id` is the stable materialized namespace. Older v2 hubs did not expose
2929    // it, so retain the fail-closed control-revision fallback for compatibility.
2930    let view_revision = view
2931        .id
2932        .clone()
2933        .unwrap_or_else(|| view.control_revision.clone());
2934    let control_revision = view.control_revision.clone();
2935    let identity = head
2936        .identity
2937        .as_ref()
2938        .ok_or_else(|| invalid_feed("v2 head has no brain identity"))?;
2939    let trust_directory = open_trust_dir(cfg)?;
2940    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &head.brain_id])?;
2941    let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, brain, &head.brain_id)?;
2942    let feed_identity = v2_identity(identity);
2943    let anchor = verify_identity_chain(&feed_identity, pinned.as_ref())?;
2944    let (seq, feed_hash, hub_signer) = match &head.pointer {
2945        None => {
2946            if head.profile != "v2-empty" {
2947                return Err(invalid_feed("initialized v2 head has no pointer"));
2948            }
2949            (
2950                0,
2951                None,
2952                pinned.as_ref().and_then(|state| state.hub_signer.clone()),
2953            )
2954        }
2955        Some(signed) => {
2956            let signer = verify_v2_pointer(signed, &head.brain_id)?;
2957            if pinned
2958                .as_ref()
2959                .and_then(|state| state.hub_signer.as_ref())
2960                .is_some_and(|known| known != &signer)
2961            {
2962                return Err(invalid_feed(
2963                    "v2 hub pointer signer changed without a trust transition",
2964                ));
2965            }
2966            if let Some(checkpoint) = pinned.as_ref().filter(|state| accepted_as_v2(state)) {
2967                if signed.pointer.seq < checkpoint.head_seq
2968                    || (signed.pointer.seq == checkpoint.head_seq
2969                        && checkpoint.feed_hash.as_deref()
2970                            != Some(signed.pointer.feed_hash.as_str()))
2971                {
2972                    return Err(invalid_feed("v2 pointer rolled back or equivocated"));
2973                }
2974            }
2975            verify_v2_commit(
2976                cfg,
2977                &head.brain_id,
2978                &signed.pointer,
2979                identity,
2980                pinned.as_ref(),
2981            )?;
2982            (
2983                signed.pointer.seq,
2984                Some(signed.pointer.feed_hash.clone()),
2985                Some(signer),
2986            )
2987        }
2988    };
2989    let trust = TrustState {
2990        v: 2,
2991        origin: normalized_origin(&cfg.hub)?,
2992        requested: head.brain_id.clone(),
2993        brain: head.brain_id.clone(),
2994        home: None,
2995        anchor,
2996        current: format!("ed25519:{}", identity.fingerprint),
2997        head_seq: seq,
2998        feed_hash,
2999        rotations: identity.rotations.clone(),
3000        hub_signer,
3001        protocol_profile: Some("link-v2".to_string()),
3002    };
3003    Ok(Some(V2VerifiedHead {
3004        requested: brain.to_string(),
3005        brain_id: head.brain_id,
3006        view_kind,
3007        view_revision,
3008        control_revision,
3009        identity: identity.clone(),
3010        pointer: head.pointer.map(|signed| signed.pointer),
3011        trust,
3012        alias: alias_binding,
3013    }))
3014}
3015
3016fn accept_v2_head(cfg: &HubConfig, head: &V2VerifiedHead) -> LinkResult<()> {
3017    let directory = open_trust_dir(cfg)?;
3018    let _locks = lock_trust_many(cfg, &directory, &[&head.requested, &head.brain_id])?;
3019    let (current, alias) = load_canonical_pin(cfg, &directory, &head.requested, &head.brain_id)?;
3020    if let Some(current) = current {
3021        let common_invalid = head.trust.anchor != current.anchor
3022            || !head.trust.rotations.starts_with(&current.rotations);
3023        let profile_invalid = if accepted_as_v2(&current) {
3024            head.trust.head_seq < current.head_seq
3025                || (head.trust.head_seq == current.head_seq
3026                    && head.trust.feed_hash != current.feed_hash)
3027                || current
3028                    .hub_signer
3029                    .as_ref()
3030                    .is_some_and(|known| head.trust.hub_signer.as_ref() != Some(known))
3031        } else {
3032            head.trust.protocol_profile.as_deref() != Some("link-v2")
3033                || head.trust.hub_signer.is_none()
3034        };
3035        if common_invalid || profile_invalid {
3036            return Err(invalid_feed(
3037                "v2 head cannot advance the currently accepted trust checkpoint",
3038            ));
3039        }
3040    }
3041    save_canonical_pin_and_alias(
3042        cfg,
3043        &directory,
3044        &head.requested,
3045        &head.brain_id,
3046        head.trust.clone(),
3047        alias.as_ref().or(head.alias.as_ref()),
3048    )
3049}
3050
3051#[derive(Debug, Clone, Deserialize, Serialize)]
3052struct V2BaselineFile {
3053    sha256: String,
3054    bytes: u64,
3055    #[serde(skip)]
3056    proof: Option<Vec<V2ProofStep>>,
3057}
3058
3059#[derive(Debug, Clone, Deserialize, Serialize)]
3060struct V2SyncBaseline {
3061    v: u8,
3062    origin: String,
3063    brain: String,
3064    #[serde(default)]
3065    head_seq: Option<u64>,
3066    commit_hash: Option<String>,
3067    content_root: Option<String>,
3068    #[serde(default)]
3069    asset_root: Option<String>,
3070    #[serde(default)]
3071    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
3072    #[serde(default)]
3073    view_kind: Option<String>,
3074    #[serde(default)]
3075    view_revision: Option<String>,
3076    #[serde(default)]
3077    projection_sha256: Option<String>,
3078    files: std::collections::BTreeMap<String, V2BaselineFile>,
3079    #[serde(default)]
3080    local_policy_digest: Option<String>,
3081    #[serde(default)]
3082    local_eligibility: std::collections::BTreeMap<String, bool>,
3083    #[serde(default)]
3084    remote_copy_remains: std::collections::BTreeMap<String, String>,
3085}
3086
3087struct V2LocalView {
3088    riding: std::collections::BTreeMap<String, (String, u64)>,
3089    eligibility: std::collections::BTreeMap<String, bool>,
3090    policy: crate::linkmd_sync_policy::SyncPolicy,
3091}
3092
3093#[derive(Debug, Clone, Deserialize, Serialize)]
3094struct V2ProofStep {
3095    directory_root: String,
3096    component: String,
3097    proof: crate::linkmd_v2::HamtProof,
3098}
3099
3100#[derive(Debug, Deserialize)]
3101struct V2ManifestFile {
3102    path: String,
3103    sha256: String,
3104    bytes: u64,
3105    proof: Vec<V2ProofStep>,
3106}
3107
3108#[derive(Debug, Deserialize)]
3109struct V2ManifestPage {
3110    v: u8,
3111    commit: String,
3112    content_root: Option<String>,
3113    files: Vec<V2ManifestFile>,
3114    next_cursor: Option<String>,
3115}
3116
3117#[derive(Debug, Clone, Deserialize, Serialize)]
3118struct V2BaselineAsset {
3119    blob_sha256: String,
3120    bytes: u64,
3121    media_type: String,
3122    wrappers: Vec<String>,
3123    required: bool,
3124    disposition: String,
3125    leaf_hash: String,
3126}
3127
3128#[derive(Debug, Deserialize)]
3129struct V2AssetManifestItem {
3130    path: String,
3131    blob_sha256: String,
3132    bytes: u64,
3133    media_type: String,
3134    wrappers: Vec<String>,
3135    required: bool,
3136    disposition: String,
3137    leaf_hash: String,
3138    proof: crate::linkmd_v2::HamtProof,
3139}
3140
3141#[derive(Debug, Deserialize)]
3142struct V2AssetManifestPage {
3143    v: u8,
3144    commit: String,
3145    asset_root: Option<String>,
3146    assets: Vec<V2AssetManifestItem>,
3147    next_cursor: Option<String>,
3148}
3149
3150#[derive(Debug, Deserialize)]
3151struct V2SigningCandidate {
3152    seq: u64,
3153    content_root: Option<String>,
3154    asset_root: Option<String>,
3155    signing_bytes_base64: String,
3156    changes_base64: String,
3157    actor_claim_base64: String,
3158}
3159
3160#[derive(Debug, Deserialize)]
3161struct V2SigningCandidatePage {
3162    v: u8,
3163    challenge_id: String,
3164    mutation_id: String,
3165    request_hash: String,
3166    parent: V2SigningParent,
3167    candidate: V2SigningCandidate,
3168    files: Vec<V2ManifestFile>,
3169    #[serde(default)]
3170    assets: Vec<V2AssetManifestItem>,
3171    next_cursor: Option<String>,
3172    expires_at: String,
3173}
3174
3175#[derive(Debug, Deserialize)]
3176struct V2SigningParent {
3177    seq: u64,
3178    commit_hash: Option<String>,
3179}
3180
3181fn verify_v2_file_proof(root: &str, file: &V2ManifestFile) -> LinkResult<()> {
3182    let normalized = crate::linkmd_v2::normalize_path(&file.path)
3183        .map_err(|error| invalid_feed(error.to_string()))?;
3184    let components = normalized.split('/').collect::<Vec<_>>();
3185    if components.len() != file.proof.len() || !is_sha256(&file.sha256) {
3186        return Err(invalid_feed("v2 file proof has the wrong shape"));
3187    }
3188    let mut directory_root = root.to_string();
3189    for (index, step) in file.proof.iter().enumerate() {
3190        if step.directory_root != directory_root || step.component != components[index] {
3191            return Err(invalid_feed(
3192                "v2 file proof path chain differs from its manifest",
3193            ));
3194        }
3195        if !crate::linkmd_v2::verify_proof(&directory_root, &step.component, &step.proof)
3196            .map_err(|error| invalid_feed(error.to_string()))?
3197        {
3198            return Err(invalid_feed("v2 file proof failed verification"));
3199        }
3200        let entry = match &step.proof {
3201            crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry,
3202            crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
3203                return Err(invalid_feed("v2 manifest carried a non-inclusion proof"));
3204            }
3205        };
3206        if index + 1 == components.len() {
3207            if entry.kind != crate::linkmd_v2::EntryKind::Blob
3208                || entry.child_hash != file.sha256
3209                || entry.bytes != Some(file.bytes)
3210            {
3211                return Err(invalid_feed("v2 file proof leaf differs from its manifest"));
3212            }
3213        } else if entry.kind != crate::linkmd_v2::EntryKind::Tree {
3214            return Err(invalid_feed("v2 file proof traversed a non-directory"));
3215        } else {
3216            directory_root = entry.child_hash.clone();
3217        }
3218    }
3219    Ok(())
3220}
3221
3222fn v2_manifest(
3223    cfg: &HubConfig,
3224    brain: &str,
3225    pointer: Option<&V2PointerBody>,
3226) -> LinkResult<std::collections::BTreeMap<String, V2BaselineFile>> {
3227    let Some(pointer) = pointer else {
3228        return Ok(std::collections::BTreeMap::new());
3229    };
3230    let Some(root) = pointer.content_root.as_deref() else {
3231        return Ok(std::collections::BTreeMap::new());
3232    };
3233    let mut files = std::collections::BTreeMap::new();
3234    let mut after = String::new();
3235    loop {
3236        let encoded_after: String =
3237            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3238        let path = format!(
3239            "/api/hub/brains/{brain}/v2/files?commit={}&limit=500&after={encoded_after}",
3240            pointer.commit_hash
3241        );
3242        let value = ensure_ok(
3243            request_capped(
3244                cfg,
3245                "GET",
3246                &path,
3247                None,
3248                Auth::Required,
3249                MAX_FEED_RESPONSE_BYTES,
3250            )?,
3251            "v2 file manifest",
3252        )?;
3253        let page: V2ManifestPage = serde_json::from_value(value)
3254            .map_err(|_| invalid_feed("v2 file manifest has an invalid shape"))?;
3255        if page.v != 2
3256            || page.commit != pointer.commit_hash
3257            || page.content_root.as_deref() != Some(root)
3258            || page.files.len() > 500
3259        {
3260            return Err(invalid_feed(
3261                "v2 file manifest is not bound to the verified head",
3262            ));
3263        }
3264        for file in page.files {
3265            verify_v2_file_proof(root, &file)?;
3266            if files
3267                .insert(
3268                    file.path.clone(),
3269                    V2BaselineFile {
3270                        sha256: file.sha256,
3271                        bytes: file.bytes,
3272                        proof: Some(file.proof),
3273                    },
3274                )
3275                .is_some()
3276            {
3277                return Err(invalid_feed("v2 file manifest repeats a path"));
3278            }
3279            if files.len() > MAX_PUSH_FILES {
3280                return Err(invalid_feed(
3281                    "v2 file manifest exceeds the file-count bound",
3282                ));
3283            }
3284        }
3285        match page.next_cursor {
3286            None => break,
3287            Some(next) if next > after => after = next,
3288            Some(_) => return Err(invalid_feed("v2 file manifest cursor did not advance")),
3289        }
3290    }
3291    Ok(files)
3292}
3293
3294fn verify_v2_asset_proof(root: &str, item: &V2AssetManifestItem) -> LinkResult<()> {
3295    crate::linkmd_v2::normalize_path(&item.path)
3296        .map_err(|error| invalid_feed(error.to_string()))?;
3297    if !is_sha256(&item.blob_sha256)
3298        || !is_sha256(&item.leaf_hash)
3299        || item.wrappers.is_empty()
3300        || !matches!(item.disposition.as_str(), "hosted" | "withheld")
3301        || item
3302            .wrappers
3303            .iter()
3304            .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
3305    {
3306        return Err(invalid_feed("v2 asset manifest item is invalid"));
3307    }
3308    let leaf = json!({
3309        "blob_sha256": item.blob_sha256,
3310        "bytes": item.bytes,
3311        "disposition": item.disposition,
3312        "media_type": item.media_type,
3313        "path": item.path,
3314        "required": item.required,
3315        "v": 2,
3316        "wrappers": item.wrappers,
3317    });
3318    if crate::linkmd_v2::domain_hash("v2/asset-leaf", &leaf)
3319        .map_err(|error| invalid_feed(error.to_string()))?
3320        != item.leaf_hash
3321        || !crate::linkmd_v2::verify_proof_with_domain(
3322            root,
3323            &item.path,
3324            &item.proof,
3325            crate::linkmd_v2::ASSET_TREE_HASH_DOMAIN,
3326        )
3327        .map_err(|error| invalid_feed(error.to_string()))?
3328    {
3329        return Err(invalid_feed("v2 asset inclusion proof failed"));
3330    }
3331    match &item.proof {
3332        crate::linkmd_v2::HamtProof::Inclusion { entry, .. }
3333            if entry.name == item.path
3334                && entry.kind == crate::linkmd_v2::EntryKind::Blob
3335                && entry.child_hash == item.leaf_hash
3336                && entry.bytes == Some(item.bytes) =>
3337        {
3338            Ok(())
3339        }
3340        _ => Err(invalid_feed(
3341            "v2 asset proof leaf differs from its manifest",
3342        )),
3343    }
3344}
3345
3346fn v2_asset_manifest(
3347    cfg: &HubConfig,
3348    brain: &str,
3349    pointer: Option<&V2PointerBody>,
3350) -> LinkResult<std::collections::BTreeMap<String, V2BaselineAsset>> {
3351    let Some(pointer) = pointer else {
3352        return Ok(std::collections::BTreeMap::new());
3353    };
3354    let Some(root) = pointer.asset_root.as_deref() else {
3355        return Ok(std::collections::BTreeMap::new());
3356    };
3357    let mut assets = std::collections::BTreeMap::new();
3358    let mut after = String::new();
3359    loop {
3360        let encoded_after: String =
3361            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3362        let path = format!(
3363            "/api/hub/brains/{brain}/v2/assets?commit={}&limit=500&after={encoded_after}",
3364            pointer.commit_hash
3365        );
3366        let value = ensure_ok(
3367            request_capped(
3368                cfg,
3369                "GET",
3370                &path,
3371                None,
3372                Auth::Required,
3373                MAX_FEED_RESPONSE_BYTES,
3374            )?,
3375            "v2 asset manifest",
3376        )?;
3377        let page: V2AssetManifestPage = serde_json::from_value(value)
3378            .map_err(|_| invalid_feed("v2 asset manifest has an invalid shape"))?;
3379        if page.v != 2
3380            || page.commit != pointer.commit_hash
3381            || page.asset_root.as_deref() != Some(root)
3382            || page.assets.len() > 500
3383        {
3384            return Err(invalid_feed(
3385                "v2 asset manifest is not bound to the verified head",
3386            ));
3387        }
3388        for item in page.assets {
3389            verify_v2_asset_proof(root, &item)?;
3390            let path = item.path.clone();
3391            if assets
3392                .insert(
3393                    path,
3394                    V2BaselineAsset {
3395                        blob_sha256: item.blob_sha256,
3396                        bytes: item.bytes,
3397                        media_type: item.media_type,
3398                        wrappers: item.wrappers,
3399                        required: item.required,
3400                        disposition: item.disposition,
3401                        leaf_hash: item.leaf_hash,
3402                    },
3403                )
3404                .is_some()
3405            {
3406                return Err(invalid_feed("v2 asset manifest repeats a path"));
3407            }
3408            if assets.len() > MAX_PUSH_FILES {
3409                return Err(invalid_feed(
3410                    "v2 asset manifest exceeds the item-count bound",
3411                ));
3412            }
3413        }
3414        match page.next_cursor {
3415            None => break,
3416            Some(next) if next > after => after = next,
3417            Some(_) => return Err(invalid_feed("v2 asset manifest cursor did not advance")),
3418        }
3419    }
3420    Ok(assets)
3421}
3422
3423fn v2_asset_record(asset: &V2BaselineAsset, path: &str) -> crate::AssetRecord {
3424    crate::AssetRecord {
3425        path: path.to_string(),
3426        sha256: asset.blob_sha256.clone(),
3427        bytes: asset.bytes,
3428        media_type: asset.media_type.clone(),
3429        wrappers: asset.wrappers.clone(),
3430        required: asset.required,
3431    }
3432}
3433
3434fn v2_asset_resumes_hosting(
3435    remote: Option<&V2BaselineAsset>,
3436    path: &str,
3437    record: &crate::AssetRecord,
3438    disposition: &str,
3439) -> bool {
3440    remote.is_some_and(|asset| {
3441        asset.disposition == "withheld"
3442            && disposition == "hosted"
3443            && v2_asset_record(asset, path) == *record
3444    })
3445}
3446
3447fn v2_asset_record_manifest_bytes(
3448    assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
3449) -> LinkResult<Vec<u8>> {
3450    let mut bytes = Vec::new();
3451    for (path, asset) in assets {
3452        if asset.path != *path {
3453            return Err(invalid_feed(
3454                "local asset manifest key differs from its record path",
3455            ));
3456        }
3457        serde_json::to_writer(&mut bytes, asset)
3458            .map_err(|_| invalid_feed("could not materialize merged v2 assets.jsonl"))?;
3459        bytes.push(b'\n');
3460    }
3461    Ok(bytes)
3462}
3463
3464fn v2_local_asset_records(
3465    store: &Store,
3466) -> LinkResult<std::collections::BTreeMap<String, crate::AssetRecord>> {
3467    Ok(crate::assets::read_manifest(store)
3468        .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
3469        .into_iter()
3470        .map(|asset| (asset.path.clone(), asset))
3471        .collect())
3472}
3473
3474fn v2_asset_records_match_remote(
3475    local: &std::collections::BTreeMap<String, crate::AssetRecord>,
3476    remote: &std::collections::BTreeMap<String, V2BaselineAsset>,
3477) -> bool {
3478    local.len() == remote.len()
3479        && remote
3480            .iter()
3481            .all(|(path, asset)| local.get(path) == Some(&v2_asset_record(asset, path)))
3482}
3483
3484#[derive(Debug, Clone, PartialEq, Eq)]
3485struct V2PulledMerge<T> {
3486    records: std::collections::BTreeMap<String, T>,
3487    accept_remote: std::collections::BTreeSet<String>,
3488    conflicts: Vec<String>,
3489}
3490
3491/// Compute the local checkout that a pull may install without discarding a
3492/// local-only edit. Remote-only changes are accepted, identical concurrent
3493/// changes converge, and divergent concurrent changes are reported. The
3494/// caller can exempt kept-home coordinates from both remote installation and
3495/// conflict disclosure.
3496fn merge_v2_pulled_records<Base, Remote, Record, BaseRecord, RemoteRecord, KeepLocal>(
3497    base: &std::collections::BTreeMap<String, Base>,
3498    remote: &std::collections::BTreeMap<String, Remote>,
3499    local: &std::collections::BTreeMap<String, Record>,
3500    base_record: BaseRecord,
3501    remote_record: RemoteRecord,
3502    keep_local: KeepLocal,
3503) -> V2PulledMerge<Record>
3504where
3505    Record: Clone + Eq,
3506    BaseRecord: Fn(&Base, &str) -> Record,
3507    RemoteRecord: Fn(&Remote, &str) -> Record,
3508    KeepLocal: Fn(&str) -> bool,
3509{
3510    let paths = base
3511        .keys()
3512        .chain(remote.keys())
3513        .chain(local.keys())
3514        .cloned()
3515        .collect::<std::collections::BTreeSet<_>>();
3516    let mut records = local.clone();
3517    let mut accept_remote = std::collections::BTreeSet::new();
3518    let mut conflicts = Vec::new();
3519    for path in paths {
3520        if keep_local(&path) {
3521            continue;
3522        }
3523        let base_value = base.get(&path).map(|value| base_record(value, &path));
3524        let remote_value = remote.get(&path).map(|value| remote_record(value, &path));
3525        let local_value = local.get(&path).cloned();
3526        if local_value != base_value && remote_value != base_value && local_value != remote_value {
3527            conflicts.push(path);
3528            continue;
3529        }
3530        if local_value == base_value || local_value == remote_value {
3531            accept_remote.insert(path.clone());
3532            match remote_value {
3533                Some(value) => {
3534                    records.insert(path, value);
3535                }
3536                None => {
3537                    records.remove(&path);
3538                }
3539            }
3540        }
3541    }
3542    V2PulledMerge {
3543        records,
3544        accept_remote,
3545        conflicts,
3546    }
3547}
3548
3549fn sign_verified_v2_candidate(
3550    cfg: &HubConfig,
3551    head: &V2VerifiedHead,
3552    expected: &std::collections::BTreeMap<String, V2BaselineFile>,
3553    expected_assets: &std::collections::BTreeMap<String, V2BaselineAsset>,
3554    mutation_id: &str,
3555    request_body: &Value,
3556    challenge_value: &Value,
3557) -> LinkResult<(String, String, String)> {
3558    if head.view_kind != "full" {
3559        return Err(invalid_feed(
3560            "a scoped self-custody writer must use the proposal workflow",
3561        ));
3562    }
3563    if head.identity.custody != "self" {
3564        return Err(invalid_feed(
3565            "a hub-custodied brain unexpectedly requested an external signature",
3566        ));
3567    }
3568    let key = cfg
3569        .brain_key
3570        .as_ref()
3571        .ok_or_else(|| bad_agent_key("this self-custodied brain requires DBMD_BRAIN_KEY_FILE"))?;
3572    if key.multikey != format!("ed25519:{}", head.identity.fingerprint)
3573        || key.public_key_spki != head.identity.public_key_spki
3574    {
3575        return Err(bad_agent_key(
3576            "DBMD_BRAIN_KEY_FILE does not match the verified brain identity",
3577        ));
3578    }
3579    let challenge_id = challenge_value
3580        .get("id")
3581        .and_then(Value::as_str)
3582        .filter(|id| crate::ulid::is_ulid(id))
3583        .ok_or_else(|| invalid_feed("self-custody challenge has no canonical id"))?;
3584    let expected_endpoint = format!(
3585        "/api/hub/brains/{}/v2/signing-challenges/{challenge_id}",
3586        head.brain_id
3587    );
3588    if challenge_value
3589        .get("candidate_endpoint")
3590        .and_then(Value::as_str)
3591        != Some(expected_endpoint.as_str())
3592    {
3593        return Err(invalid_feed(
3594            "self-custody challenge candidate endpoint is not origin-bound",
3595        ));
3596    }
3597
3598    let mut files = std::collections::BTreeMap::new();
3599    let mut after = String::new();
3600    type CandidateCoordinate = (
3601        String,
3602        String,
3603        String,
3604        String,
3605        Option<String>,
3606        Option<String>,
3607        u64,
3608        Option<String>,
3609    );
3610    let mut pinned: Option<CandidateCoordinate> = None;
3611    loop {
3612        let encoded_after: String =
3613            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3614        let path = format!("{expected_endpoint}?limit=500&after={encoded_after}");
3615        let value = ensure_ok(
3616            request_capped(
3617                cfg,
3618                "GET",
3619                &path,
3620                None,
3621                Auth::Required,
3622                MAX_FEED_RESPONSE_BYTES,
3623            )?,
3624            "v2 self-custody candidate",
3625        )?;
3626        let page: V2SigningCandidatePage = serde_json::from_value(value)
3627            .map_err(|_| invalid_feed("self-custody candidate has an invalid shape"))?;
3628        if page.v != 2
3629            || page.challenge_id != challenge_id
3630            || page.mutation_id != mutation_id
3631            || page.candidate.seq != page.parent.seq + 1
3632            || page.files.len() > 500
3633            || page.expires_at.is_empty()
3634        {
3635            return Err(invalid_feed(
3636                "self-custody candidate is not bound to this mutation",
3637            ));
3638        }
3639        let coordinate = (
3640            page.request_hash.clone(),
3641            page.candidate.signing_bytes_base64.clone(),
3642            page.candidate.changes_base64.clone(),
3643            page.candidate.actor_claim_base64.clone(),
3644            page.candidate.content_root.clone(),
3645            page.candidate.asset_root.clone(),
3646            page.parent.seq,
3647            page.parent.commit_hash.clone(),
3648        );
3649        if pinned.as_ref().is_some_and(|prior| prior != &coordinate) {
3650            return Err(invalid_feed(
3651                "self-custody candidate changed between manifest pages",
3652            ));
3653        }
3654        pinned = Some(coordinate);
3655        let root = page
3656            .candidate
3657            .content_root
3658            .as_deref()
3659            .ok_or_else(|| invalid_feed("self-custody candidate has no content root"))?;
3660        for file in page.files {
3661            verify_v2_file_proof(root, &file)?;
3662            if files
3663                .insert(
3664                    file.path.clone(),
3665                    V2BaselineFile {
3666                        sha256: file.sha256,
3667                        bytes: file.bytes,
3668                        proof: Some(file.proof),
3669                    },
3670                )
3671                .is_some()
3672            {
3673                return Err(invalid_feed(
3674                    "self-custody candidate repeats a manifest path",
3675                ));
3676            }
3677            if files.len() > MAX_PUSH_FILES {
3678                return Err(invalid_feed(
3679                    "self-custody candidate exceeds the file-count bound",
3680                ));
3681            }
3682        }
3683        match page.next_cursor {
3684            None => break,
3685            Some(next) if next > after => after = next,
3686            Some(_) => {
3687                return Err(invalid_feed(
3688                    "self-custody candidate cursor did not advance",
3689                ))
3690            }
3691        }
3692    }
3693    if files.len() != expected.len()
3694        || files.iter().any(|(path, file)| {
3695            expected.get(path).is_none_or(|expected| {
3696                expected.sha256 != file.sha256 || expected.bytes != file.bytes
3697            })
3698        })
3699    {
3700        return Err(invalid_feed(
3701            "self-custody candidate contains an unexpected file mutation",
3702        ));
3703    }
3704    let mut assets = std::collections::BTreeMap::new();
3705    after.clear();
3706    loop {
3707        let encoded_after: String =
3708            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3709        let path = format!("{expected_endpoint}?kind=assets&limit=500&after={encoded_after}");
3710        let value = ensure_ok(
3711            request_capped(
3712                cfg,
3713                "GET",
3714                &path,
3715                None,
3716                Auth::Required,
3717                MAX_FEED_RESPONSE_BYTES,
3718            )?,
3719            "v2 self-custody asset candidate",
3720        )?;
3721        let page: V2SigningCandidatePage = serde_json::from_value(value)
3722            .map_err(|_| invalid_feed("self-custody asset candidate has an invalid shape"))?;
3723        let coordinate = (
3724            page.request_hash.clone(),
3725            page.candidate.signing_bytes_base64.clone(),
3726            page.candidate.changes_base64.clone(),
3727            page.candidate.actor_claim_base64.clone(),
3728            page.candidate.content_root.clone(),
3729            page.candidate.asset_root.clone(),
3730            page.parent.seq,
3731            page.parent.commit_hash.clone(),
3732        );
3733        if page.v != 2
3734            || page.challenge_id != challenge_id
3735            || page.mutation_id != mutation_id
3736            || page.assets.len() > 500
3737            || pinned.as_ref() != Some(&coordinate)
3738        {
3739            return Err(invalid_feed(
3740                "self-custody asset candidate changed or is not bound",
3741            ));
3742        }
3743        let root = page.candidate.asset_root.as_deref();
3744        if !page.assets.is_empty() && root.is_none() {
3745            return Err(invalid_feed("asset candidate has no asset root"));
3746        }
3747        for item in page.assets {
3748            verify_v2_asset_proof(root.expect("non-empty assets checked"), &item)?;
3749            if assets
3750                .insert(
3751                    item.path.clone(),
3752                    V2BaselineAsset {
3753                        blob_sha256: item.blob_sha256,
3754                        bytes: item.bytes,
3755                        media_type: item.media_type,
3756                        wrappers: item.wrappers,
3757                        required: item.required,
3758                        disposition: item.disposition,
3759                        leaf_hash: item.leaf_hash,
3760                    },
3761                )
3762                .is_some()
3763            {
3764                return Err(invalid_feed("self-custody candidate repeats an asset"));
3765            }
3766        }
3767        match page.next_cursor {
3768            None => break,
3769            Some(next) if next > after => after = next,
3770            Some(_) => {
3771                return Err(invalid_feed(
3772                    "self-custody asset candidate cursor did not advance",
3773                ))
3774            }
3775        }
3776    }
3777    if assets.len() != expected_assets.len()
3778        || assets.iter().any(|(path, asset)| {
3779            expected_assets.get(path).is_none_or(|expected| {
3780                asset.blob_sha256 != expected.blob_sha256
3781                    || asset.bytes != expected.bytes
3782                    || asset.media_type != expected.media_type
3783                    || asset.wrappers != expected.wrappers
3784                    || asset.required != expected.required
3785                    || asset.disposition != expected.disposition
3786            })
3787        })
3788    {
3789        return Err(invalid_feed(
3790            "self-custody candidate contains an unexpected asset mutation",
3791        ));
3792    }
3793    let Some((
3794        request_hash,
3795        signing_b64,
3796        changes_b64,
3797        actor_b64,
3798        root,
3799        asset_root,
3800        parent_seq,
3801        parent,
3802    )) = pinned
3803    else {
3804        return Err(invalid_feed("self-custody candidate has no manifest"));
3805    };
3806    let current_seq = head.pointer.as_ref().map_or(0, |pointer| pointer.seq);
3807    let current_commit = head
3808        .pointer
3809        .as_ref()
3810        .map(|pointer| pointer.commit_hash.clone());
3811    if parent_seq != current_seq || parent != current_commit {
3812        return Err(LinkError::RemoteAdvancedDuringSync);
3813    }
3814    let changes = STANDARD
3815        .decode(changes_b64)
3816        .map_err(|_| invalid_feed("self-custody changeset is not base64"))?;
3817    let expected_changes = json!({
3818        "mutation_id": mutation_id,
3819        "operations": request_body.get("operations").cloned().unwrap_or(Value::Null),
3820        "reason": request_body.get("reason").cloned().unwrap_or(Value::Null),
3821        "v": 2,
3822    });
3823    let expected_changes_bytes = crate::linkmd_v2::canonical_bytes(&expected_changes)
3824        .map_err(|error| invalid_feed(error.to_string()))?;
3825    if changes != expected_changes_bytes {
3826        return Err(invalid_feed(
3827            "self-custody changeset differs from the requested mutation",
3828        ));
3829    }
3830    let changes_hash = crate::linkmd_v2::domain_hash_bytes("v2/changeset", &changes)
3831        .map_err(|error| invalid_feed(error.to_string()))?;
3832    let request_value = json!({
3833        "base": request_body.get("base").cloned().unwrap_or(Value::Null),
3834        "brain": head.brain_id,
3835        "changes_sha256": changes_hash,
3836        "rebase": request_body.get("rebase").cloned().unwrap_or(Value::Null),
3837        "v": 2,
3838        "v1_bridge": Value::Null,
3839    });
3840    let expected_request_hash = crate::linkmd_v2::domain_hash("v2/request", &request_value)
3841        .map_err(|error| invalid_feed(error.to_string()))?;
3842    if request_hash != expected_request_hash {
3843        return Err(invalid_feed(
3844            "self-custody request hash differs from the requested mutation",
3845        ));
3846    }
3847    let actor = STANDARD
3848        .decode(actor_b64)
3849        .map_err(|_| invalid_feed("self-custody actor claim is not base64"))?;
3850    let actor_value: Value = serde_json::from_slice(&actor)
3851        .map_err(|_| invalid_feed("self-custody actor claim is not JSON"))?;
3852    if crate::linkmd_v2::canonical_bytes(&actor_value)
3853        .map_err(|error| invalid_feed(error.to_string()))?
3854        != actor
3855    {
3856        return Err(invalid_feed("self-custody actor claim is not canonical"));
3857    }
3858    let actor_object = actor_value
3859        .as_object()
3860        .ok_or_else(|| invalid_feed("self-custody actor claim is not an object"))?;
3861    let actor_claim = actor_object
3862        .get("claim")
3863        .ok_or_else(|| invalid_feed("self-custody actor claim body is missing"))?;
3864    let actor_public_key = actor_object
3865        .get("public_key")
3866        .and_then(Value::as_str)
3867        .ok_or_else(|| invalid_feed("self-custody actor signer is missing"))?;
3868    let actor_fingerprint = actor_object
3869        .get("fingerprint")
3870        .and_then(Value::as_str)
3871        .ok_or_else(|| invalid_feed("self-custody actor fingerprint is missing"))?;
3872    let actor_signature = actor_object
3873        .get("sig")
3874        .and_then(Value::as_str)
3875        .ok_or_else(|| invalid_feed("self-custody actor signature is missing"))?;
3876    let actor_message = crate::linkmd_v2::canonical_bytes(actor_claim)
3877        .map_err(|error| invalid_feed(error.to_string()))?;
3878    let actor_der = verify_v2_spki_signature(actor_public_key, &actor_message, actor_signature)?;
3879    let expected_actor_signer = format!("{actor_fingerprint}:{actor_public_key}");
3880    let expected_actor_root = root.clone().map(Value::String).unwrap_or(Value::Null);
3881    let expected_actor_asset_root = asset_root.clone().map(Value::String).unwrap_or(Value::Null);
3882    let impact = actor_claim
3883        .get("result")
3884        .and_then(|result| result.get("impact"))
3885        .and_then(Value::as_object);
3886    let impact_fields = [
3887        "creates",
3888        "updates",
3889        "deletes",
3890        "withdrawals",
3891        "renames",
3892        "restores",
3893        "asset_changes",
3894        "public_expansions",
3895        "executable_activations",
3896    ];
3897    let impact_is_valid = impact.is_some_and(|impact| {
3898        impact.len() == impact_fields.len() + 1
3899            && impact.get("v").and_then(Value::as_u64) == Some(1)
3900            && impact_fields
3901                .iter()
3902                .all(|field| impact.get(*field).and_then(Value::as_u64).is_some())
3903    });
3904    if format!("{:x}", Sha256::digest(&actor_der)) != actor_fingerprint
3905        || head
3906            .trust
3907            .hub_signer
3908            .as_ref()
3909            .is_some_and(|known| known != &expected_actor_signer)
3910        || actor_claim.get("mutation_id").and_then(Value::as_str) != Some(mutation_id)
3911        || actor_claim.get("request_hash").and_then(Value::as_str) != Some(request_hash.as_str())
3912        || actor_claim
3913            .get("candidate")
3914            .and_then(|candidate| candidate.get("changes_sha256"))
3915            .and_then(Value::as_str)
3916            != Some(changes_hash.as_str())
3917        || actor_claim
3918            .get("candidate")
3919            .and_then(|candidate| candidate.get("state_root"))
3920            != Some(&expected_actor_root)
3921        || actor_claim
3922            .get("candidate")
3923            .and_then(|candidate| candidate.get("asset_root"))
3924            != Some(&expected_actor_asset_root)
3925        || actor_claim
3926            .get("candidate")
3927            .and_then(|candidate| candidate.get("control_revision"))
3928            .and_then(Value::as_str)
3929            != Some(head.control_revision.as_str())
3930        || !impact_is_valid
3931    {
3932        return Err(invalid_feed(
3933            "self-custody actor claim does not bind the verified authority",
3934        ));
3935    }
3936    let actor_hash = crate::linkmd_v2::domain_hash_bytes("v2/actor-claim", &actor)
3937        .map_err(|error| invalid_feed(error.to_string()))?;
3938    let signing = STANDARD
3939        .decode(signing_b64)
3940        .map_err(|_| invalid_feed("self-custody signing bytes are not base64"))?;
3941    let signing_value: Value = serde_json::from_slice(&signing)
3942        .map_err(|_| invalid_feed("self-custody signing bytes are not JSON"))?;
3943    if crate::linkmd_v2::canonical_bytes(&signing_value)
3944        .map_err(|error| invalid_feed(error.to_string()))?
3945        != signing
3946    {
3947        return Err(invalid_feed("self-custody signing bytes are not canonical"));
3948    }
3949    let pointer = head.pointer.as_ref();
3950    let expected_materializer = pointer
3951        .map(|value| value.materializer.as_str())
3952        .unwrap_or("dbmd-projection-v1");
3953    let expected_parent_commit = request_body
3954        .get("base")
3955        .and_then(|base| base.get("commit_hash"))
3956        .cloned()
3957        .unwrap_or(Value::Null);
3958    let expected_parent_root = request_body
3959        .get("base")
3960        .and_then(|base| base.get("content_root"))
3961        .cloned()
3962        .unwrap_or(Value::Null);
3963    let expected_state_root = root.clone().map(Value::String).unwrap_or(Value::Null);
3964    let expected_parent_asset_root = request_body
3965        .get("base")
3966        .and_then(|base| base.get("asset_root"))
3967        .cloned()
3968        .unwrap_or(Value::Null);
3969    let expected_asset_root = asset_root.map(Value::String).unwrap_or(Value::Null);
3970    let expected_prev_entry = pointer
3971        .map(|value| Value::String(value.feed_hash.clone()))
3972        .unwrap_or(Value::Null);
3973    let expected_signer_epoch = u64::try_from(head.identity.previous.len())
3974        .map_err(|_| invalid_feed("brain identity history is too large"))?
3975        + 1;
3976    if signing_value.get("v").and_then(Value::as_u64) != Some(2)
3977        || signing_value.get("seq").and_then(Value::as_u64) != Some(current_seq + 1)
3978        || signing_value.get("signer_epoch").and_then(Value::as_u64) != Some(expected_signer_epoch)
3979        || signing_value.get("brain").and_then(Value::as_str) != Some(key.multikey.as_str())
3980        || signing_value.get("public_key").and_then(Value::as_str)
3981            != Some(key.public_key_spki.as_str())
3982        || signing_value.get("parent_commit") != Some(&expected_parent_commit)
3983        || signing_value.get("parent_root") != Some(&expected_parent_root)
3984        || signing_value.get("state_root") != Some(&expected_state_root)
3985        || signing_value.get("parent_asset_root") != Some(&expected_parent_asset_root)
3986        || signing_value.get("asset_root") != Some(&expected_asset_root)
3987        || signing_value.get("materializer").and_then(Value::as_str) != Some(expected_materializer)
3988        || signing_value.get("changes_sha256").and_then(Value::as_str)
3989            != Some(changes_hash.as_str())
3990        || signing_value.get("actor_ref").and_then(Value::as_str) != Some(actor_hash.as_str())
3991        || signing_value
3992            .get("control_revision")
3993            .and_then(Value::as_str)
3994            != Some(head.control_revision.as_str())
3995        || signing_value.get("prev_entry_hash") != Some(&expected_prev_entry)
3996        || signing_value.get("v1_bridge") != Some(&Value::Null)
3997        || signing_value.get("op").and_then(Value::as_str) != Some("changeset")
3998    {
3999        return Err(invalid_feed(
4000            "self-custody signing bytes do not bind the verified candidate",
4001        ));
4002    }
4003    let pair = agent_keypair(&key.pkcs8)?;
4004    let signature = URL_SAFE_NO_PAD.encode(pair.sign(&signing).as_ref());
4005    Ok((challenge_id.to_string(), signature, expected_actor_signer))
4006}
4007
4008fn v2_baseline_name(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<String> {
4009    let origin = normalized_origin(&cfg.hub)?;
4010    let absolute = if checkout.is_absolute() {
4011        checkout.to_path_buf()
4012    } else {
4013        std::env::current_dir()?.join(checkout)
4014    };
4015    Ok(format!(
4016        "sync-{}.json",
4017        content_sha256(format!("{origin}\0{brain}\0{}", absolute.display()).as_bytes())
4018    ))
4019}
4020
4021#[cfg(any(unix, windows))]
4022fn lock_v2_sync_operation(cfg: &HubConfig, brain: &str) -> LinkResult<TrustLock> {
4023    let directory = open_trust_dir(cfg)?;
4024    let origin = normalized_origin(&cfg.hub)?;
4025    let name = format!(
4026        "operation-{}.lock",
4027        content_sha256(format!("{origin}\0{brain}").as_bytes())
4028    );
4029    lock_trust_name(&directory, &name)
4030}
4031
4032#[cfg(not(any(unix, windows)))]
4033fn lock_v2_sync_operation(_cfg: &HubConfig, _brain: &str) -> LinkResult<()> {
4034    Err(LinkError::UnsupportedPlatform {
4035        operation: "serialized link.md v2 sync",
4036    })
4037}
4038
4039fn same_v2_head(left: &V2VerifiedHead, right: &V2VerifiedHead) -> bool {
4040    left.brain_id == right.brain_id
4041        && left.view_kind == right.view_kind
4042        && left.view_revision == right.view_revision
4043        && left.control_revision == right.control_revision
4044        && match (&left.pointer, &right.pointer) {
4045            (None, None) => true,
4046            (Some(left), Some(right)) => {
4047                left.seq == right.seq
4048                    && left.commit_hash == right.commit_hash
4049                    && left.content_root == right.content_root
4050                    && left.asset_root == right.asset_root
4051                    && left.feed_hash == right.feed_hash
4052            }
4053            _ => false,
4054        }
4055}
4056
4057fn scoped_projection_bytes(brain: &str) -> Vec<u8> {
4058    format!(
4059        "---\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"
4060    )
4061    .into_bytes()
4062}
4063
4064fn scoped_projection_sha256(brain: &str) -> String {
4065    content_sha256(&scoped_projection_bytes(brain))
4066}
4067
4068#[derive(Deserialize)]
4069struct LocalScopedViewMarker {
4070    v: u8,
4071    kind: String,
4072    authoritative: bool,
4073    brain: String,
4074    projection_sha256: String,
4075}
4076
4077/// True only when this store carries the exact generated marker for a local
4078/// link.md scoped view. This is presentation context, never authorization;
4079/// the hub remains the only authority for reads and writes.
4080pub fn has_verified_local_scoped_view(store: &Store) -> bool {
4081    let marker = store
4082        .read_bounded(Path::new(".dbmd/view.json"), 64 * 1024)
4083        .ok()
4084        .and_then(|bytes| serde_json::from_slice::<LocalScopedViewMarker>(&bytes).ok());
4085    let Some(marker) = marker else {
4086        return false;
4087    };
4088    if marker.v != 1
4089        || marker.kind != "link.md-scoped-view"
4090        || marker.authoritative
4091        || !crate::ulid::is_ulid(&marker.brain)
4092        || marker.projection_sha256 != scoped_projection_sha256(&marker.brain)
4093    {
4094        return false;
4095    }
4096    store
4097        .read_bounded(Path::new("DB.md"), crate::parser::MAX_DBMD_FILE_BYTES)
4098        .is_ok_and(|bytes| content_sha256(&bytes) == marker.projection_sha256)
4099}
4100
4101fn scoped_view_metadata(head: &V2VerifiedHead, files: usize) -> LinkResult<Vec<u8>> {
4102    let mut bytes = serde_json::to_vec_pretty(&json!({
4103        "v": 1,
4104        "kind": "link.md-scoped-view",
4105        "authoritative": false,
4106        "brain": head.brain_id,
4107        "view_revision": head.view_revision,
4108        "head_seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
4109        "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
4110        "content_root": head.pointer.as_ref().and_then(|pointer| pointer.content_root.as_ref()),
4111        "visible_files": files,
4112        "projection_sha256": scoped_projection_sha256(&head.brain_id),
4113    }))
4114    .map_err(|_| invalid_feed("could not serialize scoped view metadata"))?;
4115    bytes.push(b'\n');
4116    Ok(bytes)
4117}
4118
4119fn refresh_scoped_view_marker(
4120    store: &Store,
4121    head: &V2VerifiedHead,
4122    files: usize,
4123) -> LinkResult<()> {
4124    if head.view_kind == "scoped" {
4125        store.write_atomic(
4126            Path::new(".dbmd/view.json"),
4127            &scoped_view_metadata(head, files)?,
4128        )?;
4129    }
4130    Ok(())
4131}
4132
4133fn ensure_v2_view_compatible(
4134    head: &V2VerifiedHead,
4135    baseline: Option<&V2SyncBaseline>,
4136) -> LinkResult<()> {
4137    let Some(baseline) = baseline else {
4138        return Ok(());
4139    };
4140    match (
4141        baseline.view_kind.as_deref(),
4142        baseline.view_revision.as_deref(),
4143    ) {
4144        (None, None) if head.view_kind == "full" => Ok(()),
4145        (Some(kind), Some(revision))
4146            if kind == head.view_kind && revision == head.view_revision =>
4147        {
4148            Ok(())
4149        }
4150        _ => Err(LinkError::ScopedViewChanged),
4151    }
4152}
4153
4154fn ensure_established_v2_checkout_opened(
4155    head: &V2VerifiedHead,
4156    baseline: Option<&V2SyncBaseline>,
4157    opened: bool,
4158) -> LinkResult<()> {
4159    if baseline.is_none() || opened {
4160        return Ok(());
4161    }
4162    if head.view_kind == "scoped" {
4163        return Err(LinkError::ScopedProjectionModified);
4164    }
4165    Err(LinkError::InvalidPack {
4166        message: "the established v2 checkout is no longer a valid db.md store; repair its DB.md before syncing".to_string(),
4167    })
4168}
4169
4170fn remove_scoped_projection(
4171    head: &V2VerifiedHead,
4172    baseline: Option<&V2SyncBaseline>,
4173    view: &mut V2LocalView,
4174) -> LinkResult<()> {
4175    if head.view_kind != "scoped" {
4176        return Ok(());
4177    }
4178    let expected = scoped_projection_sha256(&head.brain_id);
4179    if baseline
4180        .and_then(|state| state.projection_sha256.as_deref())
4181        .is_some_and(|pinned| pinned != expected)
4182    {
4183        return Err(LinkError::ScopedViewChanged);
4184    }
4185    if view.riding.get("DB.md").map(|(sha256, _)| sha256.as_str()) != Some(expected.as_str()) {
4186        return Err(LinkError::ScopedProjectionModified);
4187    }
4188    view.riding.remove("DB.md");
4189    view.eligibility.remove("DB.md");
4190    Ok(())
4191}
4192
4193fn files_for_v2_view(
4194    head: &V2VerifiedHead,
4195    mut files: std::collections::BTreeMap<String, V2BaselineFile>,
4196) -> std::collections::BTreeMap<String, V2BaselineFile> {
4197    if head.view_kind == "scoped" {
4198        // Even when a grant explicitly includes canonical DB.md, a partial
4199        // checkout uses a generated marker. Canonical validation remains a
4200        // server-side operation over the complete brain.
4201        files.remove("DB.md");
4202    }
4203    files
4204}
4205
4206fn parse_v2_baseline(cfg: &HubConfig, brain: &str, bytes: &[u8]) -> LinkResult<V2SyncBaseline> {
4207    let baseline: V2SyncBaseline =
4208        serde_json::from_slice(bytes).map_err(|_| invalid_feed("v2 sync baseline is corrupt"))?;
4209    if baseline.v != 2
4210        || baseline.origin != normalized_origin(&cfg.hub)?
4211        || baseline.brain != brain
4212        || baseline
4213            .commit_hash
4214            .as_deref()
4215            .is_some_and(|hash| !is_sha256(hash))
4216        || baseline
4217            .content_root
4218            .as_deref()
4219            .is_some_and(|hash| !is_sha256(hash))
4220        || baseline
4221            .asset_root
4222            .as_deref()
4223            .is_some_and(|hash| !is_sha256(hash))
4224        || baseline
4225            .local_policy_digest
4226            .as_deref()
4227            .is_some_and(|hash| !is_sha256(hash))
4228        || baseline
4229            .view_kind
4230            .as_deref()
4231            .is_some_and(|kind| !matches!(kind, "full" | "scoped"))
4232        || baseline
4233            .view_revision
4234            .as_deref()
4235            .is_some_and(|hash| !is_sha256(hash))
4236        || baseline
4237            .projection_sha256
4238            .as_deref()
4239            .is_some_and(|hash| !is_sha256(hash))
4240        || (baseline.view_kind.as_deref() == Some("scoped")
4241            && (baseline.view_revision.is_none() || baseline.projection_sha256.is_none()))
4242        || baseline.files.len() > MAX_PUSH_FILES
4243        || baseline.assets.len() > MAX_PUSH_FILES
4244        || baseline.local_eligibility.len() > MAX_PUSH_FILES
4245        || baseline.remote_copy_remains.len() > MAX_PUSH_FILES
4246        || baseline.files.iter().any(|(path, file)| {
4247            crate::linkmd_v2::normalize_path(path).is_err()
4248                || !is_sha256(&file.sha256)
4249                || file.bytes > MAX_STORE_BYTES
4250        })
4251        || baseline.assets.iter().any(|(path, asset)| {
4252            crate::linkmd_v2::normalize_path(path).is_err()
4253                || !is_sha256(&asset.blob_sha256)
4254                || !is_sha256(&asset.leaf_hash)
4255                || asset.bytes > MAX_STORE_BYTES
4256                || !matches!(asset.disposition.as_str(), "hosted" | "withheld")
4257                || asset.wrappers.is_empty()
4258                || asset
4259                    .wrappers
4260                    .iter()
4261                    .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
4262        })
4263        || baseline
4264            .local_eligibility
4265            .keys()
4266            .chain(baseline.remote_copy_remains.keys())
4267            .any(|path| crate::linkmd_v2::normalize_path(path).is_err())
4268        || baseline
4269            .remote_copy_remains
4270            .values()
4271            .any(|hash| !is_sha256(hash))
4272    {
4273        return Err(invalid_feed("v2 sync baseline failed validation"));
4274    }
4275    Ok(baseline)
4276}
4277
4278#[cfg(unix)]
4279fn load_v2_baseline(
4280    cfg: &HubConfig,
4281    brain: &str,
4282    checkout: &Path,
4283) -> LinkResult<Option<V2SyncBaseline>> {
4284    use std::os::fd::{AsRawFd as _, FromRawFd as _};
4285    let directory = open_trust_dir(cfg)?;
4286    let name_string = v2_baseline_name(cfg, brain, checkout)?;
4287    let _lock = lock_trust_name(&directory, &name_string)?;
4288    let name = c_name(name_string.as_bytes(), &name_string)?;
4289    let fd = unsafe {
4290        libc::openat(
4291            directory.as_raw_fd(),
4292            name.as_ptr(),
4293            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4294        )
4295    };
4296    if fd < 0 {
4297        let error = std::io::Error::last_os_error();
4298        return if error.kind() == std::io::ErrorKind::NotFound {
4299            Ok(None)
4300        } else {
4301            Err(LinkError::UnsafePath { path: name_string })
4302        };
4303    }
4304    let file = unsafe { std::fs::File::from_raw_fd(fd) };
4305    let mut bytes = Vec::new();
4306    file.take(MAX_FEED_RESPONSE_BYTES + 1)
4307        .read_to_end(&mut bytes)?;
4308    if bytes.len() as u64 > MAX_FEED_RESPONSE_BYTES {
4309        return Err(invalid_feed("v2 sync baseline is oversized"));
4310    }
4311    Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?))
4312}
4313
4314#[cfg(windows)]
4315fn load_v2_baseline(
4316    cfg: &HubConfig,
4317    brain: &str,
4318    checkout: &Path,
4319) -> LinkResult<Option<V2SyncBaseline>> {
4320    let directory = open_trust_dir(cfg)?;
4321    let name = v2_baseline_name(cfg, brain, checkout)?;
4322    let _lock = lock_trust_name(&directory, &name)?;
4323    let mut reader = crate::fsx::BoundedDirReader::from_root(&directory)?;
4324    match reader.read(Path::new(&name), MAX_FEED_RESPONSE_BYTES) {
4325        Ok(bytes) => Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?)),
4326        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
4327        Err(_) => Err(LinkError::UnsafePath { path: name }),
4328    }
4329}
4330
4331#[cfg(not(any(unix, windows)))]
4332fn load_v2_baseline(
4333    _cfg: &HubConfig,
4334    _brain: &str,
4335    _checkout: &Path,
4336) -> LinkResult<Option<V2SyncBaseline>> {
4337    Err(LinkError::UnsupportedPlatform {
4338        operation: "verified link.md v2 baseline",
4339    })
4340}
4341
4342#[cfg(unix)]
4343fn save_v2_baseline(
4344    cfg: &HubConfig,
4345    brain: &str,
4346    checkout: &Path,
4347    baseline: &V2SyncBaseline,
4348) -> LinkResult<()> {
4349    use std::os::fd::{AsRawFd as _, FromRawFd as _};
4350    let directory = open_trust_dir(cfg)?;
4351    let name_string = v2_baseline_name(cfg, brain, checkout)?;
4352    let _lock = lock_trust_name(&directory, &name_string)?;
4353    let name = c_name(name_string.as_bytes(), &name_string)?;
4354    let mut bytes = serde_json::to_vec(baseline)
4355        .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
4356    bytes.push(b'\n');
4357    let temp_string = format!(
4358        ".{name_string}.tmp.{}-{}",
4359        std::process::id(),
4360        std::time::SystemTime::now()
4361            .duration_since(std::time::UNIX_EPOCH)
4362            .unwrap_or_default()
4363            .as_nanos()
4364    );
4365    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
4366    let fd = unsafe {
4367        libc::openat(
4368            directory.as_raw_fd(),
4369            temp.as_ptr(),
4370            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4371            0o600,
4372        )
4373    };
4374    if fd < 0 {
4375        return Err(std::io::Error::last_os_error().into());
4376    }
4377    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
4378    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
4379        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4380        return Err(error.into());
4381    }
4382    drop(file);
4383    if unsafe {
4384        libc::renameat(
4385            directory.as_raw_fd(),
4386            temp.as_ptr(),
4387            directory.as_raw_fd(),
4388            name.as_ptr(),
4389        )
4390    } != 0
4391    {
4392        let error = std::io::Error::last_os_error();
4393        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4394        return Err(error.into());
4395    }
4396    directory.sync_all()?;
4397    Ok(())
4398}
4399
4400#[cfg(windows)]
4401fn save_v2_baseline(
4402    cfg: &HubConfig,
4403    brain: &str,
4404    checkout: &Path,
4405    baseline: &V2SyncBaseline,
4406) -> LinkResult<()> {
4407    let directory = open_trust_dir(cfg)?;
4408    let name = v2_baseline_name(cfg, brain, checkout)?;
4409    let _lock = lock_trust_name(&directory, &name)?;
4410    let mut bytes = serde_json::to_vec(baseline)
4411        .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
4412    bytes.push(b'\n');
4413    crate::fsx::write_atomic_beneath(&directory, Path::new(&name), &bytes, false, true)?;
4414    Ok(())
4415}
4416
4417#[cfg(not(any(unix, windows)))]
4418fn save_v2_baseline(
4419    _cfg: &HubConfig,
4420    _brain: &str,
4421    _checkout: &Path,
4422    _baseline: &V2SyncBaseline,
4423) -> LinkResult<()> {
4424    Err(LinkError::UnsupportedPlatform {
4425        operation: "verified link.md v2 baseline",
4426    })
4427}
4428
4429fn v2_baseline_from_head(
4430    cfg: &HubConfig,
4431    head: &V2VerifiedHead,
4432    files: std::collections::BTreeMap<String, V2BaselineFile>,
4433    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
4434    local: Option<&V2LocalView>,
4435) -> LinkResult<V2SyncBaseline> {
4436    let mut local_eligibility = local
4437        .map(|view| view.eligibility.clone())
4438        .unwrap_or_default();
4439    if let Some(view) = local {
4440        for path in files.keys() {
4441            local_eligibility
4442                .entry(path.clone())
4443                .or_insert_with(|| !view.policy.keeps_home(path));
4444        }
4445    }
4446    let remote_copy_remains = local_eligibility
4447        .iter()
4448        .filter(|(_, riding)| !**riding)
4449        .filter_map(|(path, _)| {
4450            files
4451                .get(path)
4452                .map(|file| (path.clone(), file.sha256.clone()))
4453        })
4454        .collect();
4455    Ok(V2SyncBaseline {
4456        v: 2,
4457        origin: normalized_origin(&cfg.hub)?,
4458        brain: head.brain_id.clone(),
4459        head_seq: Some(head.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
4460        commit_hash: head
4461            .pointer
4462            .as_ref()
4463            .map(|pointer| pointer.commit_hash.clone()),
4464        content_root: head
4465            .pointer
4466            .as_ref()
4467            .and_then(|pointer| pointer.content_root.clone()),
4468        asset_root: head
4469            .pointer
4470            .as_ref()
4471            .and_then(|pointer| pointer.asset_root.clone()),
4472        assets,
4473        view_kind: Some(head.view_kind.clone()),
4474        view_revision: Some(head.view_revision.clone()),
4475        projection_sha256: (head.view_kind == "scoped")
4476            .then(|| scoped_projection_sha256(&head.brain_id)),
4477        files,
4478        local_policy_digest: local.map(|view| view.policy.digest.clone()),
4479        local_eligibility,
4480        remote_copy_remains,
4481    })
4482}
4483
4484fn v2_local_files(store: &Store) -> LinkResult<V2LocalView> {
4485    let policy = crate::linkmd_sync_policy::load(store)
4486        .map_err(|message| LinkError::InvalidPack { message })?;
4487    let asset_paths = crate::assets::read_manifest(store)
4488        .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
4489        .into_iter()
4490        .map(|asset| asset.path)
4491        .collect::<std::collections::BTreeSet<_>>();
4492    let mut result = std::collections::BTreeMap::new();
4493    let mut eligibility = std::collections::BTreeMap::new();
4494    let mut total = 0_u64;
4495    let mut paths = vec![PathBuf::from("DB.md")];
4496    paths.extend(store.walk()?);
4497    for relative in paths {
4498        let path = relative.to_string_lossy().replace('\\', "/");
4499        // v2 catalogs and asset inventory are materialized/signed separately.
4500        if matches!(path.as_str(), "assets.jsonl" | "index.md" | "index.jsonl") {
4501            continue;
4502        }
4503        if asset_paths.contains(&path) {
4504            continue;
4505        }
4506        crate::linkmd_v2::normalize_path(&path).map_err(|error| LinkError::UnsafePath {
4507            path: error.to_string(),
4508        })?;
4509        let riding = !policy.keeps_home(&path);
4510        eligibility.insert(path.clone(), riding);
4511        if !riding {
4512            continue;
4513        }
4514        let remaining = MAX_STORE_BYTES.saturating_sub(total);
4515        let bytes = store.read_bounded(&relative, remaining)?;
4516        total = total
4517            .checked_add(bytes.len() as u64)
4518            .ok_or_else(|| LinkError::PushTooLarge {
4519                detail: "v2 local byte count overflow".to_string(),
4520            })?;
4521        if total > MAX_STORE_BYTES {
4522            return Err(LinkError::PushTooLarge {
4523                detail: format!("{total} uncompressed bytes"),
4524            });
4525        }
4526        if std::str::from_utf8(&bytes).is_err() {
4527            return Err(LinkError::NotUtf8 { path });
4528        }
4529        result.insert(path, (content_sha256(&bytes), bytes.len() as u64));
4530    }
4531    Ok(V2LocalView {
4532        riding: result,
4533        eligibility,
4534        policy,
4535    })
4536}
4537
4538#[derive(Debug, Deserialize)]
4539struct V2DownloadItem {
4540    path: String,
4541    sha256: String,
4542    bytes: u64,
4543    url: String,
4544    method: String,
4545}
4546
4547#[derive(Debug, Deserialize)]
4548struct V2DownloadWindow {
4549    v: u8,
4550    commit: String,
4551    downloads: Vec<V2DownloadItem>,
4552}
4553
4554#[derive(Debug, Deserialize)]
4555struct V2BulkStreamHeader {
4556    v: u8,
4557    path: String,
4558    sha256: String,
4559    bytes: u64,
4560}
4561
4562fn parse_v2_bulk_stream(
4563    bytes: &[u8],
4564    expected: &[(&String, &V2BaselineFile)],
4565) -> LinkResult<Vec<(String, Vec<u8>)>> {
4566    if !bytes.starts_with(V2_BULK_STREAM_MAGIC) {
4567        return Err(invalid_feed("v2 bulk stream has an invalid magic"));
4568    }
4569    let mut cursor = V2_BULK_STREAM_MAGIC.len();
4570    let mut result = Vec::with_capacity(expected.len());
4571    for (expected_path, expected_file) in expected {
4572        let length_bytes = bytes
4573            .get(cursor..cursor + 4)
4574            .ok_or_else(|| invalid_feed("v2 bulk stream ended before a frame header"))?;
4575        cursor += 4;
4576        let header_len = u32::from_be_bytes(length_bytes.try_into().expect("four bytes")) as usize;
4577        if header_len == 0 || header_len > 4 * 1024 {
4578            return Err(invalid_feed("v2 bulk stream has an invalid frame length"));
4579        }
4580        let header_bytes = bytes
4581            .get(cursor..cursor + header_len)
4582            .ok_or_else(|| invalid_feed("v2 bulk stream ended inside a frame header"))?;
4583        cursor += header_len;
4584        let header: V2BulkStreamHeader = serde_json::from_slice(header_bytes)
4585            .map_err(|_| invalid_feed("v2 bulk stream has an invalid frame header"))?;
4586        if header.v != 2
4587            || &header.path != *expected_path
4588            || header.sha256 != expected_file.sha256
4589            || header.bytes != expected_file.bytes
4590            || header.bytes > V2_BULK_STREAM_CONTENT_BYTES
4591        {
4592            return Err(invalid_feed(
4593                "v2 bulk stream frame differs from its proven manifest entry",
4594            ));
4595        }
4596        let body_len = usize::try_from(header.bytes)
4597            .map_err(|_| invalid_feed("v2 bulk stream frame length overflows this platform"))?;
4598        let body = bytes
4599            .get(cursor..cursor + body_len)
4600            .ok_or_else(|| invalid_feed("v2 bulk stream ended inside file bytes"))?;
4601        cursor += body_len;
4602        if content_sha256(body) != header.sha256 {
4603            return Err(invalid_feed(
4604                "v2 bulk stream file differs from its proven manifest entry",
4605            ));
4606        }
4607        result.push((header.path, body.to_vec()));
4608    }
4609    if bytes.get(cursor..cursor + 4) != Some(&[0, 0, 0, 0]) {
4610        return Err(invalid_feed("v2 bulk stream has no exact end marker"));
4611    }
4612    cursor += 4;
4613    if cursor != bytes.len() {
4614        return Err(invalid_feed("v2 bulk stream carries trailing data"));
4615    }
4616    Ok(result)
4617}
4618
4619fn download_v2_bulk_stream(
4620    cfg: &HubConfig,
4621    brain: &str,
4622    pointer: &V2PointerBody,
4623    pending: &[(&String, &V2BaselineFile)],
4624) -> LinkResult<Vec<(String, Vec<u8>)>> {
4625    let claims = pending
4626        .iter()
4627        .map(|(path, file)| {
4628            Ok(json!({
4629                "path": path,
4630                "sha256": file.sha256,
4631                "bytes": file.bytes,
4632                "proof": file.proof.as_ref().ok_or_else(|| {
4633                    invalid_feed("v2 manifest omitted a bulk-stream proof")
4634                })?,
4635            }))
4636        })
4637        .collect::<LinkResult<Vec<_>>>()?;
4638    let raw = request_raw(
4639        cfg,
4640        "POST",
4641        &format!("/api/hub/brains/{brain}/v2/stream"),
4642        Some(&json!({
4643            "commit": pointer.commit_hash,
4644            "files": claims,
4645        })),
4646        Auth::Required,
4647        V2_BULK_STREAM_RESPONSE_BYTES,
4648    )?;
4649    let body = ensure_raw_ok(raw, "download v2 bulk stream")?;
4650    parse_v2_bulk_stream(&body, pending)
4651}
4652
4653fn prepare_v2_downloads(
4654    cfg: &HubConfig,
4655    brain: &str,
4656    pointer: &V2PointerBody,
4657    pending: &[(&String, &V2BaselineFile)],
4658) -> LinkResult<Vec<V2DownloadItem>> {
4659    let mut result = Vec::with_capacity(pending.len());
4660    for chunk in pending.chunks(128) {
4661        let claims = chunk
4662            .iter()
4663            .map(|(path, file)| {
4664                Ok(json!({
4665                    "path": path,
4666                    "sha256": file.sha256,
4667                    "bytes": file.bytes,
4668                    "proof": file.proof.as_ref().ok_or_else(|| {
4669                        invalid_feed("v2 manifest omitted a download proof")
4670                    })?,
4671                }))
4672            })
4673            .collect::<LinkResult<Vec<_>>>()?;
4674        let value = ensure_ok(
4675            request_capped(
4676                cfg,
4677                "POST",
4678                &format!("/api/hub/brains/{brain}/v2/downloads"),
4679                Some(&json!({
4680                    "commit": pointer.commit_hash,
4681                    "files": claims,
4682                })),
4683                Auth::Required,
4684                MAX_FEED_RESPONSE_BYTES,
4685            )?,
4686            "prepare v2 blob downloads",
4687        )?;
4688        let window: V2DownloadWindow = serde_json::from_value(value)
4689            .map_err(|_| invalid_feed("v2 download window has an invalid shape"))?;
4690        if window.v != 2
4691            || window.commit != pointer.commit_hash
4692            || window.downloads.len() != chunk.len()
4693        {
4694            return Err(invalid_feed(
4695                "v2 download window is not bound to the requested files",
4696            ));
4697        }
4698        let mut by_path = window
4699            .downloads
4700            .into_iter()
4701            .map(|item| (item.path.clone(), item))
4702            .collect::<std::collections::BTreeMap<_, _>>();
4703        if by_path.len() != chunk.len() {
4704            return Err(invalid_feed("v2 download window repeats a path"));
4705        }
4706        for (path, file) in chunk {
4707            let item = by_path
4708                .remove(*path)
4709                .ok_or_else(|| invalid_feed("v2 download window omitted a path"))?;
4710            if item.method != "GET"
4711                || item.sha256 != file.sha256
4712                || item.bytes != file.bytes
4713                || item.url.is_empty()
4714            {
4715                return Err(invalid_feed(
4716                    "v2 download capability differs from its proven file",
4717                ));
4718            }
4719            result.push(item);
4720        }
4721    }
4722    Ok(result)
4723}
4724
4725fn prepare_v2_asset_downloads(
4726    cfg: &HubConfig,
4727    brain: &str,
4728    pointer: &V2PointerBody,
4729    pending: &[(&String, &V2BaselineAsset)],
4730) -> LinkResult<Vec<V2DownloadItem>> {
4731    let mut result = Vec::with_capacity(pending.len());
4732    for chunk in pending.chunks(128) {
4733        let claims = chunk
4734            .iter()
4735            .map(|(path, asset)| {
4736                json!({
4737                    "path": path,
4738                    "sha256": asset.blob_sha256,
4739                    "bytes": asset.bytes,
4740                    "leaf_hash": asset.leaf_hash,
4741                })
4742            })
4743            .collect::<Vec<_>>();
4744        let value = ensure_ok(
4745            request_capped(
4746                cfg,
4747                "POST",
4748                &format!("/api/hub/brains/{brain}/v2/assets/downloads"),
4749                Some(&json!({
4750                    "commit": pointer.commit_hash,
4751                    "assets": claims,
4752                })),
4753                Auth::Required,
4754                MAX_FEED_RESPONSE_BYTES,
4755            )?,
4756            "prepare v2 asset downloads",
4757        )?;
4758        let window: V2DownloadWindow = serde_json::from_value(value)
4759            .map_err(|_| invalid_feed("v2 asset download window has an invalid shape"))?;
4760        if window.v != 2
4761            || window.commit != pointer.commit_hash
4762            || window.downloads.len() != chunk.len()
4763        {
4764            return Err(invalid_feed(
4765                "v2 asset download window is not bound to the requested assets",
4766            ));
4767        }
4768        let mut by_path = window
4769            .downloads
4770            .into_iter()
4771            .map(|item| (item.path.clone(), item))
4772            .collect::<std::collections::BTreeMap<_, _>>();
4773        if by_path.len() != chunk.len() {
4774            return Err(invalid_feed("v2 asset download window repeats a path"));
4775        }
4776        for (path, asset) in chunk {
4777            let item = by_path
4778                .remove(*path)
4779                .ok_or_else(|| invalid_feed("v2 asset download window omitted a path"))?;
4780            if item.method != "GET"
4781                || item.sha256 != asset.blob_sha256
4782                || item.bytes != asset.bytes
4783                || item.url.is_empty()
4784            {
4785                return Err(invalid_feed(
4786                    "v2 asset download capability differs from its signed leaf",
4787                ));
4788            }
4789            result.push(item);
4790        }
4791    }
4792    Ok(result)
4793}
4794
4795fn download_v2_blob(cfg: &HubConfig, item: &V2DownloadItem) -> LinkResult<Vec<u8>> {
4796    let bytes = get_presigned(cfg, &item.url)?;
4797    if bytes.len() as u64 != item.bytes || content_sha256(&bytes) != item.sha256 {
4798        return Err(invalid_feed("v2 blob differs from its proven path entry"));
4799    }
4800    Ok(bytes)
4801}
4802
4803#[derive(Debug, Clone)]
4804struct V2StagedFile {
4805    path: String,
4806    source: PathBuf,
4807    sha256: String,
4808    bytes: u64,
4809}
4810
4811#[cfg(unix)]
4812fn v2_download_cache_dir(
4813    cfg: &HubConfig,
4814    brain: &str,
4815    pointer: &V2PointerBody,
4816) -> LinkResult<PathBuf> {
4817    v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
4818}
4819
4820#[cfg(unix)]
4821fn v2_download_cache_dir_for(
4822    cfg: &HubConfig,
4823    brain: &str,
4824    transaction: &str,
4825) -> LinkResult<PathBuf> {
4826    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
4827        return Err(invalid_feed("v2 download cache address is invalid"));
4828    }
4829    let path = cfg
4830        .state_dir
4831        .join("downloads")
4832        .join(brain)
4833        .join(transaction);
4834    let directory = open_or_create_dir_nofollow(&path)?;
4835    use std::os::fd::AsRawFd as _;
4836    if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
4837        return Err(std::io::Error::last_os_error().into());
4838    }
4839    directory.sync_all()?;
4840    Ok(path)
4841}
4842
4843#[cfg(windows)]
4844fn v2_download_cache_dir(
4845    cfg: &HubConfig,
4846    brain: &str,
4847    pointer: &V2PointerBody,
4848) -> LinkResult<PathBuf> {
4849    v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
4850}
4851
4852#[cfg(windows)]
4853fn v2_download_cache_dir_for(
4854    cfg: &HubConfig,
4855    brain: &str,
4856    transaction: &str,
4857) -> LinkResult<PathBuf> {
4858    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
4859        return Err(invalid_feed("v2 download cache address is invalid"));
4860    }
4861    let path = cfg
4862        .state_dir
4863        .join("downloads")
4864        .join(brain)
4865        .join(transaction);
4866    crate::fsx::write_atomic(&path.join(".directory"), b"v2 download cache\n")?;
4867    crate::fsx::open_directory_nofollow(&path)?;
4868    Ok(path)
4869}
4870
4871#[cfg(unix)]
4872fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
4873    use std::os::fd::AsRawFd as _;
4874    let parent = cfg.state_dir.join("downloads").join(brain);
4875    let Ok(directory) = open_existing_dir_nofollow(&parent) else {
4876        return;
4877    };
4878    let Ok(name) = c_name(transaction.as_bytes(), transaction) else {
4879        return;
4880    };
4881    let _ = remove_tree_at(directory.as_raw_fd(), &name, &parent.display().to_string());
4882    let _ = directory.sync_all();
4883}
4884
4885#[cfg(windows)]
4886fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
4887    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
4888        return;
4889    }
4890    let parent = cfg.state_dir.join("downloads").join(brain);
4891    let Ok(root) = crate::fsx::open_directory_nofollow(&parent) else {
4892        return;
4893    };
4894    let _ = crate::fsx::remove_tree_beneath(&root, Path::new(transaction));
4895}
4896
4897#[cfg(not(any(unix, windows)))]
4898fn cleanup_v2_download_cache(_cfg: &HubConfig, _brain: &str, _transaction: &str) {}
4899
4900#[cfg(not(any(unix, windows)))]
4901fn v2_download_cache_dir_for(
4902    _cfg: &HubConfig,
4903    _brain: &str,
4904    _transaction: &str,
4905) -> LinkResult<PathBuf> {
4906    Err(LinkError::UnsupportedPlatform {
4907        operation: "resumable v2 download staging",
4908    })
4909}
4910
4911#[cfg(any(unix, windows))]
4912fn cached_blob_is_exact(path: &Path, sha256: &str, bytes: u64) -> LinkResult<bool> {
4913    let file = match crate::fsx::open_regular_nofollow(path) {
4914        Ok(file) => file,
4915        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
4916        Err(error) => return Err(error.into()),
4917    };
4918    if file.metadata()?.len() != bytes {
4919        return Ok(false);
4920    }
4921    Ok(content_sha256_reader(file)? == sha256)
4922}
4923
4924#[cfg(any(unix, windows))]
4925fn cache_v2_blob_bytes(
4926    cache_dir: &Path,
4927    sha256: &str,
4928    expected_bytes: u64,
4929    bytes: &[u8],
4930) -> LinkResult<PathBuf> {
4931    if bytes.len() as u64 != expected_bytes || content_sha256(bytes) != sha256 {
4932        return Err(invalid_feed("v2 cached blob differs from its declaration"));
4933    }
4934    let path = cache_dir.join(sha256);
4935    if !cached_blob_is_exact(&path, sha256, expected_bytes)? {
4936        crate::fsx::write_atomic(&path, bytes)?;
4937    }
4938    Ok(path)
4939}
4940
4941#[cfg(not(any(unix, windows)))]
4942fn cache_v2_blob_bytes(
4943    _cache_dir: &Path,
4944    _sha256: &str,
4945    _expected_bytes: u64,
4946    _bytes: &[u8],
4947) -> LinkResult<PathBuf> {
4948    Err(LinkError::UnsupportedPlatform {
4949        operation: "resumable v2 download staging",
4950    })
4951}
4952
4953#[cfg(unix)]
4954fn download_presigned_to_cache(
4955    cfg: &HubConfig,
4956    url: &str,
4957    cache_dir: &Path,
4958    sha256: &str,
4959    expected_bytes: u64,
4960) -> LinkResult<PathBuf> {
4961    use std::os::fd::{AsRawFd as _, FromRawFd as _};
4962
4963    let target = cache_dir.join(sha256);
4964    if cached_blob_is_exact(&target, sha256, expected_bytes)? {
4965        return Ok(target);
4966    }
4967    let directory = open_existing_dir_nofollow(cache_dir)?;
4968    let mut nonce = [0_u8; 16];
4969    ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut nonce)
4970        .map_err(|_| invalid_feed("could not mint a download cache name"))?;
4971    let temp_string = format!(".download-{}", URL_SAFE_NO_PAD.encode(nonce));
4972    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
4973    let fd = unsafe {
4974        libc::openat(
4975            directory.as_raw_fd(),
4976            temp.as_ptr(),
4977            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4978            0o600,
4979        )
4980    };
4981    if fd < 0 {
4982        return Err(std::io::Error::last_os_error().into());
4983    }
4984    let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
4985    let response = match presigned_agent(cfg, url)?.get(url).call() {
4986        Ok(response) => response,
4987        Err(ureq::Error::Status(_, response)) => {
4988            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4989            return Err(LinkError::Http {
4990                what: "v2 direct download",
4991                status: response.status(),
4992                message: "object store rejected the download".to_string(),
4993                code: None,
4994                details: None,
4995            });
4996        }
4997        Err(ureq::Error::Transport(error)) => {
4998            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4999            return Err(LinkError::Transport {
5000                hub: cfg.hub.clone(),
5001                message: error.to_string(),
5002            });
5003        }
5004    };
5005    let mut reader = response
5006        .into_reader()
5007        .take(expected_bytes.saturating_add(1));
5008    let mut digest = Sha256::new();
5009    let mut total = 0_u64;
5010    let mut buffer = [0_u8; 64 * 1024];
5011    let write_result = (|| -> std::io::Result<()> {
5012        loop {
5013            let read = reader.read(&mut buffer)?;
5014            if read == 0 {
5015                break;
5016            }
5017            total = total.saturating_add(read as u64);
5018            digest.update(&buffer[..read]);
5019            output.write_all(&buffer[..read])?;
5020        }
5021        output.sync_all()
5022    })();
5023    if let Err(error) = write_result {
5024        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5025        return Err(error.into());
5026    }
5027    drop(output);
5028    if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
5029        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5030        return Err(invalid_feed(
5031            "v2 direct download failed integrity verification",
5032        ));
5033    }
5034    let target_name = c_name(sha256.as_bytes(), sha256)?;
5035    // Replace only a previously inspected cache leaf. Cache bytes are private
5036    // and content-addressed; final install re-verifies them again.
5037    if unsafe {
5038        libc::renameat(
5039            directory.as_raw_fd(),
5040            temp.as_ptr(),
5041            directory.as_raw_fd(),
5042            target_name.as_ptr(),
5043        )
5044    } != 0
5045    {
5046        let error = std::io::Error::last_os_error();
5047        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5048        return Err(error.into());
5049    }
5050    directory.sync_all()?;
5051    Ok(target)
5052}
5053
5054#[cfg(windows)]
5055fn download_presigned_to_cache(
5056    cfg: &HubConfig,
5057    url: &str,
5058    cache_dir: &Path,
5059    sha256: &str,
5060    expected_bytes: u64,
5061) -> LinkResult<PathBuf> {
5062    use std::fs::OpenOptions;
5063
5064    let target = cache_dir.join(sha256);
5065    if cached_blob_is_exact(&target, sha256, expected_bytes)? {
5066        return Ok(target);
5067    }
5068    // Keep the checked directory handle alive while the private cache path is
5069    // used. Windows opens omit FILE_SHARE_DELETE, so neither this directory nor
5070    // an ancestor can be swapped after validation.
5071    let _directory = crate::fsx::open_directory_nofollow(cache_dir)?;
5072    let temp = cache_dir.join(format!(".download-{}", crate::ulid::mint()));
5073    let mut output = OpenOptions::new()
5074        .write(true)
5075        .create_new(true)
5076        .open(&temp)?;
5077    let response = match presigned_agent(cfg, url)?.get(url).call() {
5078        Ok(response) => response,
5079        Err(ureq::Error::Status(_, response)) => {
5080            let _ = std::fs::remove_file(&temp);
5081            return Err(LinkError::Http {
5082                what: "v2 direct download",
5083                status: response.status(),
5084                message: "object store rejected the download".to_string(),
5085                code: None,
5086                details: None,
5087            });
5088        }
5089        Err(ureq::Error::Transport(error)) => {
5090            let _ = std::fs::remove_file(&temp);
5091            return Err(LinkError::Transport {
5092                hub: cfg.hub.clone(),
5093                message: error.to_string(),
5094            });
5095        }
5096    };
5097    let mut reader = response
5098        .into_reader()
5099        .take(expected_bytes.saturating_add(1));
5100    let mut digest = Sha256::new();
5101    let mut total = 0_u64;
5102    let mut buffer = [0_u8; 64 * 1024];
5103    let copied = (|| -> std::io::Result<()> {
5104        loop {
5105            let read = reader.read(&mut buffer)?;
5106            if read == 0 {
5107                break;
5108            }
5109            total = total.saturating_add(read as u64);
5110            digest.update(&buffer[..read]);
5111            output.write_all(&buffer[..read])?;
5112        }
5113        output.sync_all()
5114    })();
5115    if let Err(error) = copied {
5116        let _ = std::fs::remove_file(&temp);
5117        return Err(error.into());
5118    }
5119    drop(output);
5120    if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
5121        let _ = std::fs::remove_file(&temp);
5122        return Err(invalid_feed(
5123            "v2 direct download failed integrity verification",
5124        ));
5125    }
5126    if target.exists() {
5127        std::fs::remove_file(&target)?;
5128    }
5129    if let Err(error) = std::fs::rename(&temp, &target) {
5130        let _ = std::fs::remove_file(&temp);
5131        return Err(error.into());
5132    }
5133    Ok(target)
5134}
5135
5136#[cfg(not(any(unix, windows)))]
5137fn download_presigned_to_cache(
5138    _cfg: &HubConfig,
5139    _url: &str,
5140    _cache_dir: &Path,
5141    _sha256: &str,
5142    _expected_bytes: u64,
5143) -> LinkResult<PathBuf> {
5144    Err(LinkError::UnsupportedPlatform {
5145        operation: "resumable v2 download staging",
5146    })
5147}
5148
5149fn download_v2_blobs(
5150    cfg: &HubConfig,
5151    brain: &str,
5152    pointer: &V2PointerBody,
5153    pending: Vec<(&String, &V2BaselineFile)>,
5154) -> LinkResult<Vec<(String, Vec<u8>)>> {
5155    if pending.is_empty() {
5156        return Ok(Vec::new());
5157    }
5158    let expected_order = pending
5159        .iter()
5160        .map(|(path, _)| (*path).clone())
5161        .collect::<Vec<_>>();
5162    let mut streamed = std::collections::BTreeMap::new();
5163    let mut direct = Vec::new();
5164    let mut window = Vec::new();
5165    let mut window_bytes = 0_u64;
5166    let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
5167                 window_bytes: &mut u64,
5168                 streamed: &mut std::collections::BTreeMap<String, Vec<u8>>|
5169     -> LinkResult<()> {
5170        if window.is_empty() {
5171            return Ok(());
5172        }
5173        for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, window)? {
5174            if streamed.insert(path, bytes).is_some() {
5175                return Err(invalid_feed("v2 bulk streams repeated a path"));
5176            }
5177        }
5178        window.clear();
5179        *window_bytes = 0;
5180        Ok(())
5181    };
5182    for &(path, file) in &pending {
5183        if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
5184            flush(&mut window, &mut window_bytes, &mut streamed)?;
5185            direct.push((path, file));
5186            continue;
5187        }
5188        if window.len() == V2_BULK_STREAM_FILES
5189            || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
5190        {
5191            flush(&mut window, &mut window_bytes, &mut streamed)?;
5192        }
5193        window.push((path, file));
5194        window_bytes += file.bytes;
5195    }
5196    flush(&mut window, &mut window_bytes, &mut streamed)?;
5197
5198    let downloads = prepare_v2_downloads(cfg, brain, pointer, &direct)?;
5199    let next = std::sync::atomic::AtomicUsize::new(0);
5200    let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
5201    let mut results = std::iter::repeat_with(|| None)
5202        .take(downloads.len())
5203        .collect::<Vec<Option<LinkResult<(String, Vec<u8>)>>>>();
5204    std::thread::scope(|scope| {
5205        let (sender, receiver) = std::sync::mpsc::channel();
5206        for _ in 0..worker_count {
5207            let sender = sender.clone();
5208            let downloads = &downloads;
5209            let next = &next;
5210            scope.spawn(move || loop {
5211                let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5212                let Some(item) = downloads.get(index) else {
5213                    break;
5214                };
5215                let result = download_v2_blob(cfg, item).map(|bytes| (item.path.clone(), bytes));
5216                if sender.send((index, result)).is_err() {
5217                    break;
5218                }
5219            });
5220        }
5221        drop(sender);
5222        for (index, result) in receiver {
5223            results[index] = Some(result);
5224        }
5225    });
5226    for result in results.into_iter().map(|result| {
5227        result.ok_or_else(|| LinkError::Transport {
5228            hub: cfg.hub.clone(),
5229            message: "a bounded v2 blob worker stopped before reporting its result".to_string(),
5230        })?
5231    }) {
5232        let (path, bytes) = result?;
5233        if streamed.insert(path, bytes).is_some() {
5234            return Err(invalid_feed("v2 download lanes repeated a path"));
5235        }
5236    }
5237    expected_order
5238        .into_iter()
5239        .map(|path| {
5240            streamed
5241                .remove(&path)
5242                .map(|bytes| (path, bytes))
5243                .ok_or_else(|| invalid_feed("v2 download lanes omitted a proven path"))
5244        })
5245        .collect()
5246}
5247
5248/// Resume an exact-head materialization into the private global cache. Each
5249/// verified frame is durable before the next network window begins, so a
5250/// retry skips it and memory never grows with total changed bytes.
5251#[cfg(any(unix, windows))]
5252fn stage_v2_blobs(
5253    cfg: &HubConfig,
5254    brain: &str,
5255    pointer: &V2PointerBody,
5256    pending: Vec<(&String, &V2BaselineFile)>,
5257) -> LinkResult<Vec<V2StagedFile>> {
5258    let cache_dir = v2_download_cache_dir(cfg, brain, pointer)?;
5259    let mut staged = std::collections::BTreeMap::<String, V2StagedFile>::new();
5260    let mut direct = Vec::new();
5261    let mut window = Vec::new();
5262    let mut window_bytes = 0_u64;
5263    let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
5264                 window_bytes: &mut u64,
5265                 staged: &mut std::collections::BTreeMap<String, V2StagedFile>|
5266     -> LinkResult<()> {
5267        if window.is_empty() {
5268            return Ok(());
5269        }
5270        let missing = window
5271            .iter()
5272            .filter_map(|(path, file)| {
5273                let target = cache_dir.join(&file.sha256);
5274                match cached_blob_is_exact(&target, &file.sha256, file.bytes) {
5275                    Ok(true) => {
5276                        staged.insert(
5277                            (*path).clone(),
5278                            V2StagedFile {
5279                                path: (*path).clone(),
5280                                source: target,
5281                                sha256: file.sha256.clone(),
5282                                bytes: file.bytes,
5283                            },
5284                        );
5285                        None
5286                    }
5287                    Ok(false) => Some(Ok((*path, *file))),
5288                    Err(error) => Some(Err(error)),
5289                }
5290            })
5291            .collect::<LinkResult<Vec<_>>>()?;
5292        if !missing.is_empty() {
5293            for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, &missing)? {
5294                let file = missing
5295                    .iter()
5296                    .find_map(|(expected_path, file)| (*expected_path == &path).then_some(*file))
5297                    .ok_or_else(|| invalid_feed("v2 stream returned an unrequested cache path"))?;
5298                let source = cache_v2_blob_bytes(&cache_dir, &file.sha256, file.bytes, &bytes)?;
5299                staged.insert(
5300                    path.clone(),
5301                    V2StagedFile {
5302                        path,
5303                        source,
5304                        sha256: file.sha256.clone(),
5305                        bytes: file.bytes,
5306                    },
5307                );
5308            }
5309        }
5310        window.clear();
5311        *window_bytes = 0;
5312        Ok(())
5313    };
5314    for &(path, file) in &pending {
5315        if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
5316            flush(&mut window, &mut window_bytes, &mut staged)?;
5317            direct.push((path, file));
5318            continue;
5319        }
5320        if window.len() == V2_BULK_STREAM_FILES
5321            || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
5322        {
5323            flush(&mut window, &mut window_bytes, &mut staged)?;
5324        }
5325        window.push((path, file));
5326        window_bytes += file.bytes;
5327    }
5328    flush(&mut window, &mut window_bytes, &mut staged)?;
5329    for item in prepare_v2_downloads(cfg, brain, pointer, &direct)? {
5330        let source =
5331            download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
5332        staged.insert(
5333            item.path.clone(),
5334            V2StagedFile {
5335                path: item.path,
5336                source,
5337                sha256: item.sha256,
5338                bytes: item.bytes,
5339            },
5340        );
5341    }
5342    pending
5343        .into_iter()
5344        .map(|(path, _)| {
5345            staged
5346                .remove(path)
5347                .ok_or_else(|| invalid_feed("v2 download cache omitted a proven path"))
5348        })
5349        .collect()
5350}
5351
5352#[cfg(not(any(unix, windows)))]
5353fn stage_v2_blobs(
5354    _cfg: &HubConfig,
5355    _brain: &str,
5356    _pointer: &V2PointerBody,
5357    _pending: Vec<(&String, &V2BaselineFile)>,
5358) -> LinkResult<Vec<V2StagedFile>> {
5359    Err(LinkError::UnsupportedPlatform {
5360        operation: "resumable v2 download staging",
5361    })
5362}
5363
5364const V2_CONFLICT_BUNDLE_MAX: usize = 32;
5365const V2_CONFLICT_BUNDLE_TTL_SECS: u64 = 7 * 24 * 60 * 60;
5366const V2_CONFLICT_REMOTE_BYTES_MAX: u64 = 64 * 1024 * 1024;
5367
5368#[derive(Debug, Clone, Deserialize, Serialize)]
5369struct V2ConflictCoordinate {
5370    sha256: Option<String>,
5371    bytes: Option<u64>,
5372    file: Option<String>,
5373}
5374
5375#[derive(Debug, Clone, Deserialize, Serialize)]
5376struct V2ConflictFile {
5377    path: String,
5378    base: V2ConflictCoordinate,
5379    local: V2ConflictCoordinate,
5380    remote: V2ConflictCoordinate,
5381}
5382
5383#[derive(Debug, Clone, Deserialize, Serialize)]
5384struct V2ConflictPlan {
5385    v: u8,
5386    class: String,
5387    bundle: String,
5388    brain: String,
5389    origin: String,
5390    created_unix: u64,
5391    expires_unix: u64,
5392    base_seq: Option<u64>,
5393    base_commit: Option<String>,
5394    remote_seq: u64,
5395    remote_commit: Option<String>,
5396    remote_content_root: Option<String>,
5397    view_kind: String,
5398    view_revision: String,
5399    files: Vec<V2ConflictFile>,
5400}
5401
5402fn v2_take_remote_selection(
5403    files: &[V2ConflictFile],
5404    current: &std::collections::BTreeMap<String, V2BaselineFile>,
5405) -> LinkResult<(
5406    std::collections::BTreeMap<String, V2BaselineFile>,
5407    Vec<String>,
5408)> {
5409    let mut selected = std::collections::BTreeMap::new();
5410    let mut deleted = Vec::new();
5411    for file in files {
5412        match (&file.remote.sha256, file.remote.bytes) {
5413            (Some(sha256), Some(bytes)) => {
5414                let proven = current.get(&file.path).ok_or_else(|| {
5415                    invalid_feed("conflict remote coordinate disappeared from the exact head")
5416                })?;
5417                if proven.sha256 != *sha256 || proven.bytes != bytes {
5418                    return Err(invalid_feed(
5419                        "conflict remote coordinate differs from the exact head",
5420                    ));
5421                }
5422                if selected.insert(file.path.clone(), proven.clone()).is_some() {
5423                    return Err(invalid_feed("conflict plan repeats a remote coordinate"));
5424                }
5425            }
5426            (None, None) => {
5427                if current.contains_key(&file.path) {
5428                    return Err(invalid_feed(
5429                        "conflict remote deletion differs from the exact head",
5430                    ));
5431                }
5432                deleted.push(file.path.clone());
5433            }
5434            _ => return Err(invalid_feed("conflict remote coordinate is incomplete")),
5435        }
5436    }
5437    Ok((selected, deleted))
5438}
5439
5440fn v2_conflict_relative(bundle: &str, suffix: &str) -> PathBuf {
5441    PathBuf::from(".dbmd")
5442        .join("conflicts")
5443        .join(bundle)
5444        .join(suffix)
5445}
5446
5447fn read_historical_conflict_blob(
5448    cfg: &HubConfig,
5449    brain: &str,
5450    baseline: &V2SyncBaseline,
5451    path: &str,
5452    file: &V2BaselineFile,
5453) -> LinkResult<Option<Vec<u8>>> {
5454    let (Some(seq), Some(commit)) = (baseline.head_seq, baseline.commit_hash.as_deref()) else {
5455        return Ok(None);
5456    };
5457    if seq == 0 {
5458        return Ok(None);
5459    }
5460    let encoded_path: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
5461    let endpoint = format!(
5462        "/api/hub/brains/{brain}/v2/history/blob?seq={seq}&commit={commit}&path={encoded_path}&sha256={}",
5463        file.sha256
5464    );
5465    let raw = request_raw(cfg, "GET", &endpoint, None, Auth::Required, file.bytes)?;
5466    if raw.status == 404 || raw.status == 403 {
5467        return Ok(None);
5468    }
5469    let bytes = ensure_raw_ok(raw, "v2 conflict base")?;
5470    if bytes.len() as u64 != file.bytes || content_sha256(&bytes) != file.sha256 {
5471        return Err(invalid_feed(
5472            "v2 conflict base failed integrity verification",
5473        ));
5474    }
5475    Ok(Some(bytes))
5476}
5477
5478/// Persist a readable content conflict without modifying either side. The
5479/// plan is written last, so an interrupted bundle build is never actionable.
5480fn create_v2_conflict_bundle(
5481    cfg: &HubConfig,
5482    store: &Store,
5483    head: &V2VerifiedHead,
5484    baseline: Option<&V2SyncBaseline>,
5485    local: &std::collections::BTreeMap<String, (String, u64)>,
5486    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
5487    paths: &[String],
5488) -> LinkResult<(String, Vec<String>)> {
5489    let conflicts_root = Path::new(".dbmd/conflicts");
5490    store.create_dir_all(conflicts_root)?;
5491    let completed = store
5492        .directory_names(conflicts_root)?
5493        .into_iter()
5494        .filter(|name| name.to_str().is_some_and(crate::ulid::is_ulid))
5495        .count();
5496    if completed >= V2_CONFLICT_BUNDLE_MAX {
5497        return Err(LinkError::InvalidPack {
5498            message: format!(
5499                "private conflict cache has {completed} bundles; resolve or prune one before syncing"
5500            ),
5501        });
5502    }
5503
5504    // A conflict bundle is diagnostic state, not a second whole-brain cache.
5505    // Materialize a deterministic prefix whose remote artifacts fit one
5506    // bounded window. Resolving it exposes the next prefix on the next sync.
5507    let mut selected_paths = Vec::new();
5508    let mut selected_remote_bytes = 0_u64;
5509    for path in paths {
5510        let bytes = remote.get(path).map_or(0, |file| file.bytes);
5511        if !selected_paths.is_empty()
5512            && selected_remote_bytes.saturating_add(bytes) > V2_CONFLICT_REMOTE_BYTES_MAX
5513        {
5514            break;
5515        }
5516        selected_remote_bytes = selected_remote_bytes.saturating_add(bytes);
5517        selected_paths.push(path.clone());
5518        if selected_remote_bytes >= V2_CONFLICT_REMOTE_BYTES_MAX {
5519            break;
5520        }
5521    }
5522    if selected_paths.is_empty() {
5523        return Err(invalid_feed("content conflict set is empty"));
5524    }
5525    let bundle = crate::ulid::mint();
5526    let bundle_root = v2_conflict_relative(&bundle, "");
5527    store.create_dir_all(&bundle_root.join("files"))?;
5528    let pointer = head.pointer.as_ref();
5529    let remote_bytes = match pointer {
5530        Some(pointer) => download_v2_blobs(
5531            cfg,
5532            &head.brain_id,
5533            pointer,
5534            selected_paths
5535                .iter()
5536                .filter_map(|path| {
5537                    remote
5538                        .get(path)
5539                        .filter(|file| file.bytes <= V2_CONFLICT_REMOTE_BYTES_MAX)
5540                        .map(|file| (path, file))
5541                })
5542                .collect(),
5543        )?
5544        .into_iter()
5545        .collect::<std::collections::BTreeMap<_, _>>(),
5546        None => std::collections::BTreeMap::new(),
5547    };
5548
5549    let mut files = Vec::with_capacity(selected_paths.len());
5550    for (index, path) in selected_paths.iter().enumerate() {
5551        let base_file = baseline.and_then(|state| state.files.get(path));
5552        let base_bytes = match (baseline, base_file) {
5553            (Some(state), Some(file)) => {
5554                read_historical_conflict_blob(cfg, &head.brain_id, state, path, file)?
5555            }
5556            _ => None,
5557        };
5558        let local_file = local.get(path);
5559        let remote_file = remote.get(path);
5560        let remote_content = remote_bytes.get(path);
5561        let prefix = format!("files/{index:04}");
5562        let base_name = base_bytes.as_ref().map(|_| format!("{prefix}.base"));
5563        let local_name = local_file.as_ref().map(|_| format!("{prefix}.local"));
5564        let remote_name = remote_content.as_ref().map(|_| format!("{prefix}.remote"));
5565        if let (Some(name), Some(bytes)) = (&base_name, &base_bytes) {
5566            store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
5567        }
5568        if let (Some(name), Some((expected_hash, expected_bytes))) = (&local_name, local_file) {
5569            let bytes = store.read_bounded(Path::new(path), *expected_bytes)?;
5570            if bytes.len() as u64 != *expected_bytes || content_sha256(&bytes) != *expected_hash {
5571                return Err(LinkError::InvalidPack {
5572                    message: format!("local conflict path `{path}` changed while bundling"),
5573                });
5574            }
5575            store.write_atomic_new(&v2_conflict_relative(&bundle, name), &bytes)?;
5576        }
5577        if let (Some(name), Some(bytes)) = (&remote_name, remote_content) {
5578            store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
5579        }
5580        files.push(V2ConflictFile {
5581            path: path.clone(),
5582            base: V2ConflictCoordinate {
5583                sha256: base_file.map(|file| file.sha256.clone()),
5584                bytes: base_file.map(|file| file.bytes),
5585                file: base_name,
5586            },
5587            local: V2ConflictCoordinate {
5588                sha256: local_file.map(|(sha256, _)| sha256.clone()),
5589                bytes: local_file.map(|(_, bytes)| *bytes),
5590                file: local_name,
5591            },
5592            remote: V2ConflictCoordinate {
5593                sha256: remote_file.map(|file| file.sha256.clone()),
5594                bytes: remote_file.map(|file| file.bytes),
5595                file: remote_name,
5596            },
5597        });
5598    }
5599    let now = SystemTime::now()
5600        .duration_since(UNIX_EPOCH)
5601        .unwrap_or_default()
5602        .as_secs();
5603    let plan = V2ConflictPlan {
5604        v: 2,
5605        class: "content_resolution_required".to_string(),
5606        bundle: bundle.clone(),
5607        brain: head.brain_id.clone(),
5608        origin: normalized_origin(&cfg.hub)?,
5609        created_unix: now,
5610        expires_unix: now.saturating_add(V2_CONFLICT_BUNDLE_TTL_SECS),
5611        base_seq: baseline.and_then(|state| state.head_seq),
5612        base_commit: baseline.and_then(|state| state.commit_hash.clone()),
5613        remote_seq: pointer.map_or(0, |value| value.seq),
5614        remote_commit: pointer.map(|value| value.commit_hash.clone()),
5615        remote_content_root: pointer.and_then(|value| value.content_root.clone()),
5616        view_kind: head.view_kind.clone(),
5617        view_revision: head.view_revision.clone(),
5618        files,
5619    };
5620    let mut bytes = serde_json::to_vec_pretty(&plan)
5621        .map_err(|_| invalid_feed("could not serialize v2 conflict plan"))?;
5622    bytes.push(b'\n');
5623    store.write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), &bytes)?;
5624    Ok((bundle, selected_paths))
5625}
5626
5627fn v2_sync_pull_with_resolution(
5628    cfg: &HubConfig,
5629    requested_brain: &str,
5630    expected_head: V2VerifiedHead,
5631    out: Option<&Path>,
5632    take_remote: Option<&std::collections::BTreeSet<String>>,
5633) -> LinkResult<V2PulledSnapshot> {
5634    let dest = out
5635        .map(Path::to_path_buf)
5636        .unwrap_or_else(|| PathBuf::from(requested_brain));
5637    let _operation_lock = lock_v2_sync_operation(cfg, &expected_head.brain_id)?;
5638    recover_v2_pull(cfg, &expected_head.brain_id, &dest)?;
5639    let head = v2_verified_head(cfg, requested_brain)?
5640        .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
5641    if take_remote.is_some() && !same_v2_head(&expected_head, &head) {
5642        return Err(LinkError::RemoteAdvancedDuringSync);
5643    }
5644    let remote = files_for_v2_view(
5645        &head,
5646        v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
5647    );
5648    let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
5649    let baseline = load_v2_baseline(cfg, &head.brain_id, &dest)?;
5650    ensure_v2_view_compatible(&head, baseline.as_ref())?;
5651    let local_store = Store::open_strict(&dest).ok();
5652    // A baseline proves this path is an established checkout. Never reinterpret
5653    // a damaged/edited store marker as an empty clone destination: doing so
5654    // would let the pull half erase the local evidence before the push half can
5655    // reject it (most importantly for the generated scoped-view DB.md).
5656    ensure_established_v2_checkout_opened(&head, baseline.as_ref(), local_store.is_some())?;
5657    let mut local_view = local_store.as_ref().map(v2_local_files).transpose()?;
5658    if head.view_kind == "scoped" && baseline.is_none() && local_view.is_some() {
5659        return Err(LinkError::ScopedViewChanged);
5660    }
5661    if let Some(view) = local_view.as_mut() {
5662        remove_scoped_projection(&head, baseline.as_ref(), view)?;
5663    }
5664    let empty_local = std::collections::BTreeMap::new();
5665    let local = local_view
5666        .as_ref()
5667        .map_or(&empty_local, |view| &view.riding);
5668    let kept_home = |path: &str| {
5669        local_view
5670            .as_ref()
5671            .is_some_and(|view| view.policy.keeps_home(path))
5672    };
5673    let empty_base = std::collections::BTreeMap::new();
5674    let base = baseline.as_ref().map_or(&empty_base, |state| &state.files);
5675    let empty_base_assets = std::collections::BTreeMap::new();
5676    let base_assets = baseline
5677        .as_ref()
5678        .map_or(&empty_base_assets, |state| &state.assets);
5679    let mut local_assets = local_store
5680        .as_ref()
5681        .map(v2_local_asset_records)
5682        .transpose()?
5683        .unwrap_or_default();
5684    let mut content_merge = merge_v2_pulled_records(
5685        base,
5686        &remote,
5687        local,
5688        |file, _| (file.sha256.clone(), file.bytes),
5689        |file, _| (file.sha256.clone(), file.bytes),
5690        kept_home,
5691    );
5692    if let Some(selected) = take_remote {
5693        for path in selected {
5694            if let Some(position) = content_merge
5695                .conflicts
5696                .iter()
5697                .position(|conflict| conflict == path)
5698            {
5699                content_merge.conflicts.remove(position);
5700                content_merge.accept_remote.insert(path.clone());
5701                match remote.get(path) {
5702                    Some(file) => {
5703                        content_merge
5704                            .records
5705                            .insert(path.clone(), (file.sha256.clone(), file.bytes));
5706                    }
5707                    None => {
5708                        content_merge.records.remove(path);
5709                    }
5710                }
5711            } else if !content_merge.accept_remote.contains(path) {
5712                return Err(LinkError::InvalidPack {
5713                    message: format!(
5714                        "take-remote path `{path}` is no longer at its conflict coordinate"
5715                    ),
5716                });
5717            }
5718        }
5719    }
5720    if !content_merge.conflicts.is_empty() {
5721        let mut conflicts = content_merge.conflicts.clone();
5722        conflicts.truncate(100);
5723        if let Some(store) = local_store.as_ref() {
5724            let (bundle, paths) = create_v2_conflict_bundle(
5725                cfg,
5726                store,
5727                &head,
5728                baseline.as_ref(),
5729                local,
5730                &remote,
5731                &conflicts,
5732            )?;
5733            return Err(LinkError::ConflictBundle { bundle, paths });
5734        }
5735        return Err(LinkError::Conflict { paths: conflicts });
5736    }
5737    let asset_merge = merge_v2_pulled_records(
5738        base_assets,
5739        &remote_assets,
5740        &local_assets,
5741        v2_asset_record,
5742        v2_asset_record,
5743        |_| false,
5744    );
5745    if !asset_merge.conflicts.is_empty() {
5746        let mut conflicts = asset_merge.conflicts.clone();
5747        conflicts.truncate(100);
5748        return Err(LinkError::Conflict { paths: conflicts });
5749    }
5750    let pointer = head.pointer.as_ref();
5751    let cache_transaction = pointer.map_or_else(
5752        || content_sha256(format!("empty\0{}", head.view_revision).as_bytes()),
5753        |value| value.commit_hash.clone(),
5754    );
5755    let cache_dir = v2_download_cache_dir_for(cfg, &head.brain_id, &cache_transaction)?;
5756    let mut changed = match pointer {
5757        Some(pointer) => stage_v2_blobs(
5758            cfg,
5759            &head.brain_id,
5760            pointer,
5761            remote
5762                .iter()
5763                .filter(|(path, file)| {
5764                    content_merge.accept_remote.contains(*path)
5765                        && local.get(*path).map(|value| value.0.as_str())
5766                            != Some(file.sha256.as_str())
5767                })
5768                .collect(),
5769        )?,
5770        None => Vec::new(),
5771    };
5772    let mut deleted = content_merge
5773        .accept_remote
5774        .iter()
5775        .filter(|path| !remote.contains_key(*path) && local.contains_key(*path))
5776        .cloned()
5777        .collect::<Vec<_>>();
5778    if local_assets != asset_merge.records {
5779        if asset_merge.records.is_empty() {
5780            deleted.push("assets.jsonl".to_string());
5781        } else {
5782            let bytes = v2_asset_record_manifest_bytes(&asset_merge.records)?;
5783            let sha256 = content_sha256(&bytes);
5784            let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
5785            changed.push(V2StagedFile {
5786                path: "assets.jsonl".to_string(),
5787                source,
5788                sha256,
5789                bytes: bytes.len() as u64,
5790            });
5791        }
5792    }
5793    if let Some(pointer) = pointer {
5794        let mut pending_assets = Vec::new();
5795        for (path, asset) in &remote_assets {
5796            if asset.disposition != "hosted"
5797                || kept_home(path)
5798                || !asset_merge.accept_remote.contains(path)
5799            {
5800                continue;
5801            }
5802            let already_current = local_store.as_ref().is_some_and(|store| {
5803                matches!(store.regular_file_exists(Path::new(path)), Ok(true))
5804                    && store
5805                        .read_bounded(Path::new(path), asset.bytes)
5806                        .ok()
5807                        .is_some_and(|bytes| {
5808                            bytes.len() as u64 == asset.bytes
5809                                && content_sha256(&bytes) == asset.blob_sha256
5810                        })
5811            });
5812            if !already_current {
5813                pending_assets.push((path, asset));
5814            }
5815        }
5816        for item in prepare_v2_asset_downloads(cfg, &head.brain_id, pointer, &pending_assets)? {
5817            let source =
5818                download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
5819            changed.push(V2StagedFile {
5820                path: item.path,
5821                source,
5822                sha256: item.sha256,
5823                bytes: item.bytes,
5824            });
5825        }
5826    }
5827    for (path, prior) in base_assets {
5828        if remote_assets.contains_key(path)
5829            || kept_home(path)
5830            || !asset_merge.accept_remote.contains(path)
5831        {
5832            continue;
5833        }
5834        let unchanged = local_store.as_ref().is_some_and(|store| {
5835            matches!(store.regular_file_exists(Path::new(path)), Ok(true))
5836                && store
5837                    .read_bounded(Path::new(path), prior.bytes)
5838                    .ok()
5839                    .is_some_and(|bytes| content_sha256(&bytes) == prior.blob_sha256)
5840        });
5841        if unchanged {
5842            deleted.push(path.clone());
5843        }
5844    }
5845    let extra_local = content_merge
5846        .records
5847        .keys()
5848        .filter(|path| !remote.contains_key(*path))
5849        .cloned()
5850        .collect::<Vec<_>>();
5851    if head.view_kind == "scoped" {
5852        for (path, bytes) in [
5853            ("DB.md".to_string(), scoped_projection_bytes(&head.brain_id)),
5854            (
5855                ".dbmd/view.json".to_string(),
5856                scoped_view_metadata(&head, remote.len())?,
5857            ),
5858        ] {
5859            let sha256 = content_sha256(&bytes);
5860            let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
5861            changed.push(V2StagedFile {
5862                path,
5863                source,
5864                sha256,
5865                bytes: bytes.len() as u64,
5866            });
5867        }
5868    }
5869    let install_changed = !changed.is_empty() || !deleted.is_empty();
5870    install_pulled_delta_sources(&dest, &changed, &deleted, true, baseline.as_ref(), &head)?;
5871    let finalized = (|| -> LinkResult<(bool, V2LocalView, std::collections::BTreeMap<String, crate::AssetRecord>)> {
5872        let installed_store =
5873            Store::open_strict(&dest).map_err(|error| LinkError::InvalidPack {
5874                message: format!("installed v2 checkout is not a valid db.md store: {error}"),
5875            })?;
5876        let installed_local = if install_changed {
5877            let mut scanned = v2_local_files(&installed_store)?;
5878            remove_scoped_projection(&head, baseline.as_ref(), &mut scanned)?;
5879            scanned
5880        } else {
5881            local_view
5882                .take()
5883                .ok_or_else(|| invalid_feed("an unchanged pull has no installed local view"))?
5884        };
5885        if installed_local.riding != content_merge.records {
5886            return Err(LinkError::InvalidPack {
5887                message: "local content changed while installing the v2 pull".to_string(),
5888            });
5889        }
5890        let installed_assets = if install_changed {
5891            v2_local_asset_records(&installed_store)?
5892        } else {
5893            std::mem::take(&mut local_assets)
5894        };
5895        if installed_assets != asset_merge.records {
5896            return Err(LinkError::InvalidPack {
5897                message: "local assets changed while installing the v2 pull".to_string(),
5898            });
5899        }
5900        let local_dirty = !v2_riding_matches_remote(&installed_local.riding, &remote, |path| {
5901            installed_local.policy.keeps_home(path)
5902        })
5903            || !v2_asset_records_match_remote(&installed_assets, &remote_assets);
5904        let final_head = v2_verified_head(cfg, requested_brain)?
5905            .ok_or_else(|| invalid_feed("v2 head disappeared during pull"))?;
5906        if !same_v2_head(&head, &final_head) {
5907            return Err(LinkError::RemoteAdvancedDuringSync);
5908        }
5909        accept_v2_head(cfg, &final_head)?;
5910        save_v2_baseline(
5911            cfg,
5912            &head.brain_id,
5913            &dest,
5914            &v2_baseline_from_head(
5915                cfg,
5916                &head,
5917                remote.clone(),
5918                remote_assets.clone(),
5919                Some(&installed_local),
5920            )?,
5921        )?;
5922        complete_v2_pull(&dest)?;
5923        Ok((local_dirty, installed_local, installed_assets))
5924    })();
5925    let (local_dirty, installed_local, installed_assets) = match finalized {
5926        Ok(value) => value,
5927        Err(error) => {
5928            if let Err(recovery) = recover_v2_pull(cfg, &head.brain_id, &dest) {
5929                return Err(LinkError::InvalidPack {
5930                    message: format!("{error}; durable pull recovery also failed: {recovery}"),
5931                });
5932            }
5933            return Err(error);
5934        }
5935    };
5936    cleanup_v2_download_cache(cfg, &head.brain_id, &cache_transaction);
5937    let report = PullReport {
5938        brain: head.brain_id.clone(),
5939        slug: requested_brain.to_string(),
5940        head_seq: pointer.map_or(0, |value| value.seq),
5941        files: remote.len() + remote_assets.len(),
5942        dest: dest.to_string_lossy().into_owned(),
5943        extra_local,
5944        sync_status: if local_dirty {
5945            "local_dirty_after_install".to_string()
5946        } else {
5947            "synced".to_string()
5948        },
5949    };
5950    Ok(V2PulledSnapshot {
5951        report,
5952        head,
5953        files: remote,
5954        assets: remote_assets,
5955        local: installed_local,
5956        local_assets: installed_assets,
5957    })
5958}
5959
5960fn v2_sync_pull(
5961    cfg: &HubConfig,
5962    requested_brain: &str,
5963    head: V2VerifiedHead,
5964    out: Option<&Path>,
5965) -> LinkResult<PullReport> {
5966    Ok(v2_sync_pull_with_resolution(cfg, requested_brain, head, out, None)?.report)
5967}
5968
5969fn v2_expected(remote: Option<&V2BaselineFile>) -> Value {
5970    match remote {
5971        Some(file) => json!({ "kind": "blob", "hash": file.sha256 }),
5972        None => json!({ "kind": "absent" }),
5973    }
5974}
5975
5976fn v2_asset_expected(remote: Option<&V2BaselineAsset>) -> Value {
5977    match remote {
5978        Some(asset) => json!({ "kind": "asset", "hash": asset.leaf_hash }),
5979        None => json!({ "kind": "absent" }),
5980    }
5981}
5982
5983fn v2_asset_value(record: &crate::AssetRecord, disposition: &str) -> Value {
5984    json!({
5985        "blob_sha256": record.sha256,
5986        "bytes": record.bytes,
5987        "media_type": record.media_type,
5988        "wrappers": record.wrappers,
5989        "required": record.required,
5990        "disposition": disposition,
5991    })
5992}
5993
5994/// Apply the exact operation subset generated by this client to an already
5995/// verified manifest. This avoids downloading the same 50k-path listing again
5996/// after an unrebased commit; a rebased response still re-reads the manifest.
5997fn apply_generated_v2_operations(
5998    operations: &[Value],
5999    local_assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
6000    candidate: &mut std::collections::BTreeMap<String, V2BaselineFile>,
6001    candidate_assets: &mut std::collections::BTreeMap<String, V2BaselineAsset>,
6002) -> LinkResult<bool> {
6003    let mut asset_changed = false;
6004    for operation in operations {
6005        match operation.get("op").and_then(Value::as_str) {
6006            Some("put") => {
6007                let path = operation
6008                    .get("path")
6009                    .and_then(Value::as_str)
6010                    .ok_or_else(|| invalid_feed("v2 put has no path"))?;
6011                let sha256 = operation
6012                    .get("blob")
6013                    .and_then(Value::as_str)
6014                    .ok_or_else(|| invalid_feed("v2 put has no blob"))?;
6015                let bytes = operation
6016                    .get("bytes")
6017                    .and_then(Value::as_u64)
6018                    .ok_or_else(|| invalid_feed("v2 put has no byte count"))?;
6019                candidate.insert(
6020                    path.to_string(),
6021                    V2BaselineFile {
6022                        sha256: sha256.to_string(),
6023                        bytes,
6024                        proof: None,
6025                    },
6026                );
6027            }
6028            Some("delete") => {
6029                let path = operation
6030                    .get("path")
6031                    .and_then(Value::as_str)
6032                    .ok_or_else(|| invalid_feed("v2 delete has no path"))?;
6033                candidate.remove(path);
6034            }
6035            Some("asset_delete") => {
6036                let path = operation
6037                    .get("path")
6038                    .and_then(Value::as_str)
6039                    .ok_or_else(|| invalid_feed("v2 asset delete has no path"))?;
6040                candidate_assets.remove(path);
6041                asset_changed = true;
6042            }
6043            Some("asset_put" | "asset_resume") => {
6044                let path = operation
6045                    .get("path")
6046                    .and_then(Value::as_str)
6047                    .ok_or_else(|| invalid_feed("v2 asset write has no path"))?;
6048                let record = local_assets
6049                    .get(path)
6050                    .ok_or_else(|| invalid_feed("v2 asset write has no local record"))?;
6051                let disposition = operation
6052                    .get("asset")
6053                    .and_then(|asset| asset.get("disposition"))
6054                    .and_then(Value::as_str)
6055                    .ok_or_else(|| invalid_feed("v2 asset write has no disposition"))?;
6056                candidate_assets.insert(
6057                    path.to_string(),
6058                    V2BaselineAsset {
6059                        blob_sha256: record.sha256.clone(),
6060                        bytes: record.bytes,
6061                        media_type: record.media_type.clone(),
6062                        wrappers: record.wrappers.clone(),
6063                        required: record.required,
6064                        disposition: disposition.to_string(),
6065                        // The server assigns the signed leaf address. Callers
6066                        // that need an exact asset manifest re-read it below.
6067                        leaf_hash: String::new(),
6068                    },
6069                );
6070                asset_changed = true;
6071            }
6072            _ => return Err(invalid_feed("dbmd generated an unsupported v2 operation")),
6073        }
6074    }
6075    Ok(asset_changed)
6076}
6077
6078fn v2_riding_matches_remote(
6079    local: &std::collections::BTreeMap<String, (String, u64)>,
6080    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6081    keeps_home: impl Fn(&str) -> bool,
6082) -> bool {
6083    remote.iter().all(|(path, file)| {
6084        keeps_home(path)
6085            || local.get(path).map(|value| value.0.as_str()) == Some(file.sha256.as_str())
6086    }) && local.iter().all(|(path, (hash, _))| {
6087        remote.get(path).map(|file| file.sha256.as_str()) == Some(hash.as_str())
6088    })
6089}
6090
6091#[derive(Debug, Clone)]
6092struct V2ResolutionOverride {
6093    expected_remote: Option<String>,
6094    selected_local: Option<String>,
6095}
6096
6097#[derive(Debug, Clone)]
6098struct V2UploadSource {
6099    path: String,
6100    bytes: u64,
6101}
6102
6103struct V2SyncPushOptions<'a> {
6104    resume_local_policy: bool,
6105    bulk_confirmation: Option<&'a V2BulkConfirmation>,
6106    resolution: Option<&'a std::collections::BTreeMap<String, V2ResolutionOverride>>,
6107    pulled: Option<V2PulledSnapshot>,
6108}
6109
6110fn verify_v2_upload_source(
6111    store: &Store,
6112    path: &str,
6113    sha256: &str,
6114    expected_bytes: u64,
6115) -> LinkResult<()> {
6116    let file = store.open_regular(Path::new(path))?;
6117    if file.metadata()?.len() != expected_bytes || content_sha256_reader(file)? != sha256 {
6118        return Err(LinkError::InvalidPack {
6119            message: format!("local path `{path}` changed during sync planning"),
6120        });
6121    }
6122    Ok(())
6123}
6124
6125fn put_presigned_source(
6126    cfg: &HubConfig,
6127    raw: &str,
6128    headers: &Value,
6129    store: &Store,
6130    source: &V2UploadSource,
6131) -> LinkResult<()> {
6132    let http = presigned_agent(cfg, raw)?;
6133    let mut attempt = 0;
6134    let result = loop {
6135        let file = store.open_regular(Path::new(&source.path))?;
6136        if file.metadata()?.len() != source.bytes {
6137            return Err(LinkError::InvalidPack {
6138                message: format!("local path `{}` changed before upload", source.path),
6139            });
6140        }
6141        let mut req = http
6142            .put(raw)
6143            .set("Content-Length", &source.bytes.to_string());
6144        if let Some(map) = headers.as_object() {
6145            for (name, value) in map {
6146                if let Some(value) = value.as_str() {
6147                    req = req.set(name, value);
6148                }
6149            }
6150        }
6151        match req.send(file) {
6152            Err(ureq::Error::Transport(error))
6153                if is_pre_request_transport(error.kind()) && attempt + 1 < CONNECT_ATTEMPTS =>
6154            {
6155                std::thread::sleep(std::time::Duration::from_millis(
6156                    CONNECT_RETRY_BACKOFF_MS[attempt],
6157                ));
6158                attempt += 1;
6159            }
6160            result => break result,
6161        }
6162    };
6163    match result {
6164        Ok(response) if (200..300).contains(&response.status()) => Ok(()),
6165        Ok(response) => Err(LinkError::Http {
6166            what: "v2 changed-byte upload",
6167            status: response.status(),
6168            message: "object store rejected the upload".to_string(),
6169            code: None,
6170            details: None,
6171        }),
6172        Err(error) => match error {
6173            ureq::Error::Status(412, _) => Ok(()),
6174            ureq::Error::Status(_, response) => Err(LinkError::Http {
6175                what: "v2 changed-byte upload",
6176                status: response.status(),
6177                message: "object store rejected the upload".to_string(),
6178                code: None,
6179                details: None,
6180            }),
6181            ureq::Error::Transport(error) => Err(LinkError::Transport {
6182                hub: "the object store".to_string(),
6183                message: error.to_string(),
6184            }),
6185        },
6186    }
6187}
6188
6189fn v2_sync_push(
6190    cfg: &HubConfig,
6191    requested_brain: &str,
6192    store: &Store,
6193    head: V2VerifiedHead,
6194    options: V2SyncPushOptions<'_>,
6195) -> LinkResult<Value> {
6196    let V2SyncPushOptions {
6197        resume_local_policy,
6198        bulk_confirmation,
6199        resolution,
6200        pulled,
6201    } = options;
6202    let _operation_lock = lock_v2_sync_operation(cfg, &head.brain_id)?;
6203    let head = v2_verified_head(cfg, requested_brain)?
6204        .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
6205    let (mut remote, mut remote_assets, carried_local, carried_local_assets) =
6206        match pulled.filter(|snapshot| same_v2_head(&snapshot.head, &head)) {
6207            Some(snapshot) => (
6208                snapshot.files,
6209                snapshot.assets,
6210                Some(snapshot.local),
6211                Some(snapshot.local_assets),
6212            ),
6213            None => (
6214                files_for_v2_view(
6215                    &head,
6216                    v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6217                ),
6218                v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6219                None,
6220                None,
6221            ),
6222        };
6223    let baseline = load_v2_baseline(cfg, &head.brain_id, &store.root)?;
6224    ensure_v2_view_compatible(&head, baseline.as_ref())?;
6225    if head.view_kind == "scoped" && baseline.is_none() {
6226        return Err(LinkError::ScopedViewChanged);
6227    }
6228    let mut local_view = match carried_local {
6229        Some(view) => view,
6230        None => v2_local_files(store)?,
6231    };
6232    remove_scoped_projection(&head, baseline.as_ref(), &mut local_view)?;
6233    let local = &local_view.riding;
6234    let local_assets = match carried_local_assets {
6235        Some(assets) => assets,
6236        None => v2_local_asset_records(store)?,
6237    };
6238    if let Some(previous) = baseline.as_ref() {
6239        if previous.local_policy_digest.as_deref() != Some(&local_view.policy.digest)
6240            && !resume_local_policy
6241        {
6242            let mut newly_eligible = previous
6243                .local_eligibility
6244                .iter()
6245                .filter(|(path, riding)| !**riding && !local_view.policy.keeps_home(path))
6246                .map(|(path, _)| path.clone())
6247                .collect::<Vec<_>>();
6248            if !newly_eligible.is_empty() {
6249                newly_eligible.truncate(100);
6250                return Err(LinkError::LocalPolicyTransition {
6251                    paths: newly_eligible,
6252                });
6253            }
6254        }
6255    }
6256    let base = match baseline.as_ref() {
6257        Some(state) => &state.files,
6258        None if remote.is_empty() => &remote,
6259        None => {
6260            let mut conflicts = remote
6261                .iter()
6262                .filter(|(path, file)| {
6263                    local.get(*path).map(|value| value.0.as_str()) != Some(file.sha256.as_str())
6264                })
6265                .map(|(path, _)| path.clone())
6266                .collect::<Vec<_>>();
6267            if !conflicts.is_empty() {
6268                conflicts.truncate(100);
6269                let (bundle, paths) =
6270                    create_v2_conflict_bundle(cfg, store, &head, None, local, &remote, &conflicts)?;
6271                return Err(LinkError::ConflictBundle { bundle, paths });
6272            }
6273            &remote
6274        }
6275    };
6276    let all_paths = base
6277        .keys()
6278        .chain(remote.keys())
6279        .chain(local.keys())
6280        .cloned()
6281        .collect::<std::collections::BTreeSet<_>>();
6282    let mut conflicts = Vec::new();
6283    let mut operations = Vec::new();
6284    let mut upload_sources = std::collections::BTreeMap::<String, V2UploadSource>::new();
6285    for path in all_paths {
6286        let base_hash = base.get(&path).map(|file| file.sha256.as_str());
6287        let remote_file = remote.get(&path);
6288        let remote_hash = remote_file.map(|file| file.sha256.as_str());
6289        let local_file = local.get(&path);
6290        let local_hash = local_file.map(|file| file.0.as_str());
6291        if local_hash == base_hash {
6292            continue;
6293        }
6294        if resolution.is_some_and(|allowed| !allowed.contains_key(&path)) {
6295            continue;
6296        }
6297        if local_view.policy.keeps_home(&path) {
6298            // Kept-home is a local transfer exclusion, never an implicit
6299            // delete of the company's already-hosted coordinate.
6300            continue;
6301        }
6302        if remote_hash != base_hash && local_hash != remote_hash {
6303            let explicitly_resolved = resolution
6304                .and_then(|allowed| allowed.get(&path))
6305                .is_some_and(|selected| {
6306                    selected.expected_remote.as_deref() == remote_hash
6307                        && selected.selected_local.as_deref() == local_hash
6308                });
6309            if !explicitly_resolved {
6310                conflicts.push(path);
6311                continue;
6312            }
6313        }
6314        match local_file {
6315            Some((sha256, byte_count)) => {
6316                verify_v2_upload_source(store, &path, sha256, *byte_count)?;
6317                operations.push(json!({
6318                    "op": "put",
6319                    "path": path,
6320                    "expected": v2_expected(remote_file),
6321                    "blob": sha256,
6322                    "bytes": byte_count,
6323                }));
6324                upload_sources
6325                    .entry(sha256.clone())
6326                    .or_insert_with(|| V2UploadSource {
6327                        path: path.clone(),
6328                        bytes: *byte_count,
6329                    });
6330            }
6331            None => {
6332                let Some(current) = remote_file else {
6333                    continue;
6334                };
6335                operations.push(json!({
6336                    "op": "delete",
6337                    "path": path,
6338                    "expected": { "kind": "blob", "hash": current.sha256 },
6339                }));
6340            }
6341        }
6342    }
6343    if !conflicts.is_empty() {
6344        conflicts.truncate(100);
6345        let (bundle, paths) = create_v2_conflict_bundle(
6346            cfg,
6347            store,
6348            &head,
6349            baseline.as_ref(),
6350            local,
6351            &remote,
6352            &conflicts,
6353        )?;
6354        return Err(LinkError::ConflictBundle { bundle, paths });
6355    }
6356    let base_assets = match baseline.as_ref() {
6357        Some(state) => &state.assets,
6358        None if remote_assets.is_empty() => &remote_assets,
6359        None => {
6360            let mismatched = remote_assets.iter().any(|(path, remote)| {
6361                local_assets.get(path) != Some(&v2_asset_record(remote, path))
6362            }) || local_assets.len() != remote_assets.len();
6363            if mismatched {
6364                return Err(LinkError::Conflict {
6365                    paths: vec!["assets.jsonl".to_string()],
6366                });
6367            }
6368            &remote_assets
6369        }
6370    };
6371    let asset_paths = base_assets
6372        .keys()
6373        .chain(remote_assets.keys())
6374        .chain(local_assets.keys())
6375        .cloned()
6376        .collect::<std::collections::BTreeSet<_>>();
6377    let mut asset_policy_transitions = Vec::new();
6378    for path in asset_paths {
6379        let base_record = base_assets
6380            .get(&path)
6381            .map(|asset| v2_asset_record(asset, &path));
6382        let remote = remote_assets.get(&path);
6383        let remote_record = remote.map(|asset| v2_asset_record(asset, &path));
6384        let local_record = local_assets.get(&path);
6385        let mut raw_present = false;
6386        let mut disposition = "withheld";
6387        let mut resumes_hosting = false;
6388        if let Some(record) = local_record {
6389            crate::linkmd_v2::normalize_path(&record.path)
6390                .map_err(|error| invalid_feed(error.to_string()))?;
6391            let kept_home = local_view.policy.keeps_home(&path);
6392            raw_present = matches!(store.regular_file_exists(Path::new(&path)), Ok(true));
6393            disposition = if kept_home || !raw_present {
6394                "withheld"
6395            } else {
6396                "hosted"
6397            };
6398            if !raw_present && record.required && !kept_home {
6399                return Err(LinkError::InvalidPack {
6400                    message: format!("required asset {path} is missing"),
6401                });
6402            }
6403            resumes_hosting = v2_asset_resumes_hosting(remote, &path, record, disposition);
6404        }
6405        if local_record == base_record.as_ref() && !resumes_hosting {
6406            continue;
6407        }
6408        if remote_record != base_record && local_record != remote_record.as_ref() {
6409            conflicts.push(path);
6410            continue;
6411        }
6412        let Some(record) = local_record else {
6413            if let Some(remote) = remote {
6414                operations.push(json!({
6415                    "op": "asset_delete",
6416                    "path": path,
6417                    "expected": v2_asset_expected(Some(remote)),
6418                }));
6419            }
6420            continue;
6421        };
6422        let raw = if raw_present {
6423            verify_v2_upload_source(store, &path, &record.sha256, record.bytes)?;
6424            Some(())
6425        } else {
6426            None
6427        };
6428        let op = if resumes_hosting {
6429            if !resume_local_policy {
6430                asset_policy_transitions.push(path);
6431                continue;
6432            }
6433            "asset_resume"
6434        } else {
6435            "asset_put"
6436        };
6437        operations.push(json!({
6438            "op": op,
6439            "path": path,
6440            "expected": v2_asset_expected(remote),
6441            "asset": v2_asset_value(record, disposition),
6442        }));
6443        if disposition == "hosted" {
6444            raw.expect("hosted asset was checked present");
6445            upload_sources
6446                .entry(record.sha256.clone())
6447                .or_insert_with(|| V2UploadSource {
6448                    path: path.clone(),
6449                    bytes: record.bytes,
6450                });
6451        }
6452    }
6453    if !conflicts.is_empty() {
6454        conflicts.truncate(100);
6455        return Err(LinkError::Conflict { paths: conflicts });
6456    }
6457    if !asset_policy_transitions.is_empty() {
6458        asset_policy_transitions.truncate(100);
6459        return Err(LinkError::LocalPolicyTransition {
6460            paths: asset_policy_transitions,
6461        });
6462    }
6463    if operations.is_empty() {
6464        let final_head = v2_verified_head(cfg, requested_brain)?
6465            .ok_or_else(|| invalid_feed("v2 head disappeared during sync"))?;
6466        if !same_v2_head(&head, &final_head) {
6467            return Err(LinkError::RemoteAdvancedDuringSync);
6468        }
6469        let mut final_local = v2_local_files(store)?;
6470        remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
6471        let final_assets = v2_local_asset_records(store)?;
6472        let local_changed = final_local.riding != local_view.riding || final_assets != local_assets;
6473        let remote_ahead = !v2_riding_matches_remote(&final_local.riding, &remote, |path| {
6474            final_local.policy.keeps_home(path)
6475        }) || !v2_asset_records_match_remote(&final_assets, &remote_assets);
6476        let next = v2_baseline_from_head(cfg, &head, remote, remote_assets, Some(&final_local))?;
6477        let split_count = next.remote_copy_remains.len();
6478        accept_v2_head(cfg, &final_head)?;
6479        if !local_changed && !remote_ahead {
6480            refresh_scoped_view_marker(store, &head, next.files.len())?;
6481            save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
6482        }
6483        return Ok(json!({
6484            "v": 2,
6485            "outcome": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "no_change" },
6486            "sync_status": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "synced" },
6487            "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
6488            "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
6489            "local_policy": {
6490                "remote_copy_remains": split_count,
6491            },
6492        }));
6493    }
6494    let includes_contract = operations
6495        .iter()
6496        .any(|operation| operation.get("path").and_then(Value::as_str) == Some("DB.md"));
6497    let rebase = if head.pointer.is_none() || includes_contract {
6498        "strict"
6499    } else {
6500        "disjoint"
6501    };
6502    let base_value = head.pointer.as_ref().map(|pointer| {
6503        json!({
6504            "seq": pointer.seq,
6505            "commit_hash": pointer.commit_hash,
6506            "content_root": pointer.content_root,
6507            "asset_root": pointer.asset_root,
6508        })
6509    });
6510    // The mutation coordinate is stable for the exact observed base and local
6511    // operation set. A lost response can therefore be retried without a
6512    // duplicate commit/proposal even across process restarts.
6513    let entropy = format!(
6514        "{}\0{}\0{}\0{}",
6515        normalized_origin(&cfg.hub)?,
6516        head.brain_id,
6517        serde_json::to_string(&base_value).unwrap_or_default(),
6518        serde_json::to_string(&operations).unwrap_or_default()
6519    );
6520    let mutation_id = format!("dbmd-{}", content_sha256(entropy.as_bytes()));
6521    let changed_bytes = upload_sources.values().try_fold(0_u64, |total, source| {
6522        total
6523            .checked_add(source.bytes)
6524            .ok_or_else(|| LinkError::PushTooLarge {
6525                detail: "v2 changed-byte total overflow".to_string(),
6526            })
6527    })?;
6528    let inline = changed_bytes <= 3 * 1024 * 1024;
6529    let inline_blobs = if inline {
6530        upload_sources
6531            .iter()
6532            .map(|(sha256, source)| {
6533                let bytes = store.read_bounded(Path::new(&source.path), source.bytes)?;
6534                if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != *sha256 {
6535                    return Err(LinkError::InvalidPack {
6536                        message: format!("local path `{}` changed before upload", source.path),
6537                    });
6538                }
6539                Ok(json!({
6540                    "sha256": sha256,
6541                    "bytes": source.bytes,
6542                    "content_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
6543                }))
6544            })
6545            .collect::<LinkResult<Vec<_>>>()?
6546    } else {
6547        Vec::new()
6548    };
6549    let mut body = json!({
6550        "mutation_id": mutation_id,
6551        "base": base_value,
6552        "rebase": rebase,
6553        "reason": "dbmd sync",
6554        "operations": operations,
6555        "blobs": inline_blobs,
6556    });
6557    if let Some(confirmation) = bulk_confirmation {
6558        if !crate::ulid::is_ulid(&confirmation.id) || !is_sha256(&confirmation.digest) {
6559            return Err(LinkError::InvalidPack {
6560                message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
6561                    .to_string(),
6562            });
6563        }
6564        // A preview is bound to the exact observed head. Confirmation can
6565        // therefore never use disjoint rebase semantics, even when the normal
6566        // small-mutation path could.
6567        body["rebase"] = Value::String("strict".to_string());
6568        body["bulk_preview_id"] = Value::String(confirmation.id.clone());
6569        body["bulk_preview_digest"] = Value::String(confirmation.digest.clone());
6570    }
6571    if !inline || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
6572        let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
6573        for operation in &operations {
6574            let Some(kind) = operation.get("op").and_then(Value::as_str) else {
6575                return Err(invalid_feed("v2 upload operation has no kind"));
6576            };
6577            let hash = match kind {
6578                "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
6579                "asset_put" | "asset_resume" => operation
6580                    .get("asset")
6581                    .and_then(|asset| asset.get("blob_sha256"))
6582                    .and_then(Value::as_str),
6583                _ => None,
6584            };
6585            let Some(hash) = hash else { continue };
6586            let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
6587            if kind == "rename" {
6588                for field in ["from", "to"] {
6589                    coordinates.insert(
6590                        operation
6591                            .get(field)
6592                            .and_then(Value::as_str)
6593                            .ok_or_else(|| invalid_feed("v2 rename upload has no coordinate"))?
6594                            .to_string(),
6595                    );
6596                }
6597            } else {
6598                let path = operation
6599                    .get("path")
6600                    .and_then(Value::as_str)
6601                    .ok_or_else(|| invalid_feed("v2 upload has no coordinate"))?;
6602                coordinates.insert(if kind.starts_with("asset_") {
6603                    format!("assets/{path}")
6604                } else {
6605                    path.to_string()
6606                });
6607            }
6608        }
6609        let declarations = upload_sources
6610            .iter()
6611            .map(|(sha256, source)| {
6612                json!({
6613                    "sha256": sha256,
6614                    "bytes": source.bytes,
6615                    "coordinates": coordinates_by_hash
6616                        .get(sha256)
6617                        .into_iter()
6618                        .flatten()
6619                        .collect::<Vec<_>>(),
6620                })
6621            })
6622            .collect::<Vec<_>>();
6623        let reserved = ensure_ok(
6624            request(
6625                cfg,
6626                "POST",
6627                &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
6628                Some(&json!({ "blobs": declarations })),
6629                Auth::Required,
6630            )?,
6631            "prepare v2 changed-byte uploads",
6632        )?;
6633        let items = reserved
6634            .get("uploads")
6635            .and_then(Value::as_array)
6636            .ok_or_else(|| invalid_feed("v2 upload reservation response has no items"))?;
6637        if items.len() != upload_sources.len() {
6638            return Err(invalid_feed(
6639                "v2 upload reservation response changed the requested set",
6640            ));
6641        }
6642        let mut references = Vec::with_capacity(items.len());
6643        let mut seen = std::collections::BTreeSet::new();
6644        for item in items {
6645            let sha256 = item
6646                .get("sha256")
6647                .and_then(Value::as_str)
6648                .ok_or_else(|| invalid_feed("v2 upload reservation has no hash"))?;
6649            let source = upload_sources
6650                .get(sha256)
6651                .ok_or_else(|| invalid_feed("v2 upload reservation introduced a blob"))?;
6652            let declared_bytes = item
6653                .get("bytes")
6654                .and_then(Value::as_u64)
6655                .ok_or_else(|| invalid_feed("v2 upload reservation has no byte length"))?;
6656            let reservation_id = item
6657                .get("reservation_id")
6658                .and_then(Value::as_str)
6659                .ok_or_else(|| invalid_feed("v2 upload reservation has no opaque id"))?;
6660            let expected_coordinates = coordinates_by_hash
6661                .get(sha256)
6662                .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinate binding"))?;
6663            let returned_coordinates = item
6664                .get("coordinates")
6665                .and_then(Value::as_array)
6666                .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinates"))?;
6667            if declared_bytes != source.bytes
6668                || !crate::ulid::is_ulid(reservation_id)
6669                || !seen.insert(sha256.to_string())
6670                || returned_coordinates.len() != expected_coordinates.len()
6671                || returned_coordinates
6672                    .iter()
6673                    .zip(expected_coordinates)
6674                    .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
6675            {
6676                return Err(invalid_feed("v2 upload reservation item is inconsistent"));
6677            }
6678            match item.get("status").and_then(Value::as_str) {
6679                Some("upload") => {
6680                    let url = item
6681                        .get("url")
6682                        .and_then(Value::as_str)
6683                        .ok_or_else(|| invalid_feed("v2 upload reservation has no URL"))?;
6684                    put_presigned_source(
6685                        cfg,
6686                        url,
6687                        item.get("headers").unwrap_or(&Value::Null),
6688                        store,
6689                        source,
6690                    )?;
6691                    verify_v2_upload_source(store, &source.path, sha256, source.bytes)?;
6692                }
6693                Some("already_present") => {}
6694                _ => return Err(invalid_feed("v2 upload reservation has an unknown status")),
6695            }
6696            references.push(json!({
6697                "sha256": sha256,
6698                "bytes": source.bytes,
6699                "reservation_id": reservation_id,
6700            }));
6701        }
6702        body["blobs"] = Value::Array(references);
6703    }
6704    if body.to_string().len() > MAX_PUSH_BYTES {
6705        return Err(LinkError::PushTooLarge {
6706            detail: "v2 operation metadata exceeds the bounded commit request".to_string(),
6707        });
6708    }
6709    let path = format!("/api/hub/brains/{requested_brain}/v2/commits");
6710    let mut candidate_hub_signer: Option<String> = None;
6711    let mut response = request(cfg, "POST", &path, Some(&body), Auth::Required)?;
6712    let bulk_preview_required = !(200..300).contains(&response.status)
6713        && response.body.as_ref().is_some_and(|value| {
6714            value.get("code").and_then(Value::as_str) == Some("bulk_preview_required")
6715                || value
6716                    .get("details")
6717                    .and_then(|details| details.get("code"))
6718                    .and_then(Value::as_str)
6719                    == Some("bulk_preview_required")
6720        });
6721    if bulk_preview_required && bulk_confirmation.is_none() {
6722        body["rebase"] = Value::String("strict".to_string());
6723        body["preview_only"] = Value::Bool(true);
6724        let preview = ensure_ok(
6725            request(cfg, "POST", &path, Some(&body), Auth::Required)?,
6726            "v2 bulk preview",
6727        )?;
6728        let preview_code = preview.get("code").and_then(Value::as_str);
6729        let required = preview.get("required").and_then(Value::as_bool);
6730        if preview.get("v").and_then(Value::as_u64) != Some(2)
6731            || preview.get("mutation_id").and_then(Value::as_str) != Some(mutation_id.as_str())
6732            || !matches!(
6733                preview_code,
6734                Some("bulk_preview_created" | "bulk_preview_not_required")
6735            )
6736            || required.is_none()
6737        {
6738            return Err(invalid_feed(
6739                "bulk preview response is not bound to the requested mutation",
6740            ));
6741        }
6742        if required == Some(true) {
6743            let preview_id = preview.get("bulk_preview_id").and_then(Value::as_str);
6744            let preview_digest = preview.get("bulk_preview_digest").and_then(Value::as_str);
6745            if preview_code != Some("bulk_preview_created")
6746                || preview_id.is_none_or(|value| !crate::ulid::is_ulid(value))
6747                || preview_digest.is_none_or(|value| !is_sha256(value))
6748                || preview.get("expires_at").and_then(Value::as_str).is_none()
6749                || !preview.get("impact").is_some_and(Value::is_object)
6750            {
6751                return Err(invalid_feed("bulk preview receipt is malformed"));
6752            }
6753            return Err(LinkError::BulkPreviewRequired { preview });
6754        }
6755        if preview_code != Some("bulk_preview_not_required") {
6756            return Err(invalid_feed("bulk preview requirement is inconsistent"));
6757        }
6758        // The rolling activity window can expire between the refusal and
6759        // preview. Retry once, still pinned strictly to the observed head.
6760        body.as_object_mut()
6761            .expect("v2 commit request is an object")
6762            .remove("preview_only");
6763        response = request(cfg, "POST", &path, Some(&body), Auth::Required)?;
6764    }
6765    let mut result = ensure_ok(response, "v2 sync push")?;
6766    if result.get("code").and_then(Value::as_str) == Some("proposal_queued") {
6767        if let Some(object) = result.as_object_mut() {
6768            object.insert(
6769                "sync_status".to_string(),
6770                Value::String("proposal_pending".to_string()),
6771            );
6772        }
6773        return Ok(result);
6774    }
6775    if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
6776        let challenge = result
6777            .get("signing_challenge")
6778            .ok_or_else(|| invalid_feed("self-custody response has no signing challenge"))?;
6779        let mut expected_candidate = remote.clone();
6780        let mut expected_candidate_assets = remote_assets.clone();
6781        apply_generated_v2_operations(
6782            &operations,
6783            &local_assets,
6784            &mut expected_candidate,
6785            &mut expected_candidate_assets,
6786        )?;
6787        let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
6788            cfg,
6789            &head,
6790            &expected_candidate,
6791            &expected_candidate_assets,
6792            &mutation_id,
6793            &body,
6794            challenge,
6795        )?;
6796        body["signing_challenge_id"] = Value::String(challenge_id);
6797        body["signature_base64url"] = Value::String(signature);
6798        candidate_hub_signer = Some(actor_signer);
6799        result = ensure_ok(
6800            request(cfg, "POST", &path, Some(&body), Auth::Required)?,
6801            "v2 self-custody commit",
6802        )?;
6803    }
6804    let refreshed = v2_verified_head(cfg, requested_brain)?
6805        .ok_or_else(|| invalid_feed("v2 head disappeared after commit"))?;
6806    if candidate_hub_signer
6807        .as_ref()
6808        .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
6809    {
6810        return Err(invalid_feed(
6811            "self-custody actor signer differs from the committed hub pointer signer",
6812        ));
6813    }
6814    let accepted_hash = result.get("commit_hash").and_then(Value::as_str);
6815    if refreshed
6816        .pointer
6817        .as_ref()
6818        .map(|pointer| pointer.commit_hash.as_str())
6819        != accepted_hash
6820    {
6821        return Err(LinkError::RemoteAdvancedDuringSync);
6822    }
6823    ensure_v2_view_compatible(&refreshed, baseline.as_ref())?;
6824    let rebased = result
6825        .get("rebased")
6826        .and_then(Value::as_bool)
6827        .ok_or_else(|| invalid_feed("v2 commit receipt has no rebase result"))?;
6828    let (refreshed_files, refreshed_assets) = if rebased {
6829        (
6830            files_for_v2_view(
6831                &refreshed,
6832                v2_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
6833            ),
6834            v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
6835        )
6836    } else {
6837        let asset_changed = apply_generated_v2_operations(
6838            &operations,
6839            &local_assets,
6840            &mut remote,
6841            &mut remote_assets,
6842        )?;
6843        let assets = if asset_changed {
6844            // Asset leaf hashes are server-generated signed coordinates; only
6845            // asset-changing commits need this usually tiny post-commit read.
6846            v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?
6847        } else {
6848            remote_assets
6849        };
6850        (remote, assets)
6851    };
6852    let mut final_local = v2_local_files(store)?;
6853    remove_scoped_projection(&refreshed, baseline.as_ref(), &mut final_local)?;
6854    let final_assets = v2_local_asset_records(store)?;
6855    let local_dirty = final_local.riding != local_view.riding
6856        || !v2_riding_matches_remote(&final_local.riding, &refreshed_files, |path| {
6857            final_local.policy.keeps_home(path)
6858        })
6859        || final_assets != local_assets
6860        || !v2_asset_records_match_remote(&final_assets, &refreshed_assets);
6861    let next = v2_baseline_from_head(
6862        cfg,
6863        &refreshed,
6864        refreshed_files,
6865        refreshed_assets,
6866        Some(&final_local),
6867    )?;
6868    let split_count = next.remote_copy_remains.len();
6869    accept_v2_head(cfg, &refreshed)?;
6870    if !local_dirty {
6871        refresh_scoped_view_marker(store, &refreshed, next.files.len())?;
6872        save_v2_baseline(cfg, &refreshed.brain_id, &store.root, &next)?;
6873    }
6874    if let Some(object) = result.as_object_mut() {
6875        object.insert(
6876            "local_policy".to_string(),
6877            json!({ "remote_copy_remains": split_count }),
6878        );
6879        object.insert(
6880            "sync_status".to_string(),
6881            Value::String(if local_dirty {
6882                "remote_committed_local_dirty".to_string()
6883            } else {
6884                "synced".to_string()
6885            }),
6886        );
6887    }
6888    Ok(result)
6889}
6890
6891/// Negotiate v2 and send only local changes. A v1 hub retains the existing
6892/// whole-snapshot behavior until its brain advertises the new profile.
6893pub fn sync_push_incremental(cfg: &HubConfig, brain: &str, store: &Store) -> LinkResult<Value> {
6894    sync_push_incremental_with_policy(cfg, brain, store, false)
6895}
6896
6897/// The explicit `.sevralocal` adoption form. Ordinary sync never uploads a
6898/// path that became eligible because local policy was weakened or removed.
6899pub fn sync_push_incremental_with_policy(
6900    cfg: &HubConfig,
6901    brain: &str,
6902    store: &Store,
6903    resume_local_policy: bool,
6904) -> LinkResult<Value> {
6905    sync_push_incremental_with_options(cfg, brain, store, resume_local_policy, None)
6906}
6907
6908/// Incremental push with both local-policy adoption and an optional exact
6909/// permissioned bulk-preview confirmation.
6910pub fn sync_push_incremental_with_options(
6911    cfg: &HubConfig,
6912    brain: &str,
6913    store: &Store,
6914    resume_local_policy: bool,
6915    bulk_confirmation: Option<&V2BulkConfirmation>,
6916) -> LinkResult<Value> {
6917    require_safe_ref(brain)?;
6918    if let Some(head) = v2_verified_head(cfg, brain)? {
6919        return v2_sync_push(
6920            cfg,
6921            brain,
6922            store,
6923            head,
6924            V2SyncPushOptions {
6925                resume_local_policy,
6926                bulk_confirmation,
6927                resolution: None,
6928                pulled: None,
6929            },
6930        );
6931    }
6932    legacy_sync_push_incremental(cfg, brain, store, resume_local_policy, bulk_confirmation)
6933}
6934
6935/// Whether this exact hub/brain/path coordinate has an accepted v2 sync
6936/// baseline. CLI dispatch uses this to keep checkout identity sticky even when
6937/// a damaged `DB.md` makes strict store-open fail.
6938pub fn has_v2_sync_baseline(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<bool> {
6939    require_safe_ref(brain)?;
6940    Ok(load_v2_baseline(cfg, brain, checkout)?.is_some())
6941}
6942
6943#[cfg(windows)]
6944fn legacy_sync_push_incremental(
6945    _cfg: &HubConfig,
6946    _brain: &str,
6947    _store: &Store,
6948    _resume_local_policy: bool,
6949    _bulk_confirmation: Option<&V2BulkConfirmation>,
6950) -> LinkResult<Value> {
6951    Err(LinkError::UnsupportedPlatform {
6952        operation: "legacy v1 whole-snapshot push on Windows; upgrade the brain to link.md v2",
6953    })
6954}
6955
6956#[cfg(not(windows))]
6957fn legacy_sync_push_incremental(
6958    cfg: &HubConfig,
6959    brain: &str,
6960    store: &Store,
6961    resume_local_policy: bool,
6962    bulk_confirmation: Option<&V2BulkConfirmation>,
6963) -> LinkResult<Value> {
6964    if resume_local_policy || bulk_confirmation.is_some() {
6965        return Err(LinkError::InvalidPack {
6966            message: "v2 sync options require a link.md v2 brain".to_string(),
6967        });
6968    }
6969    let files = collect_push_files(store)?;
6970    sync_push(cfg, brain, &files)
6971}
6972
6973/// One explicit resolution for a previously preserved private conflict bundle.
6974#[derive(Debug, Clone)]
6975pub enum V2ConflictChoice {
6976    KeepLocal,
6977    TakeRemote,
6978    From(PathBuf),
6979}
6980
6981fn load_v2_conflict_plan(store: &Store, bundle: &str) -> LinkResult<V2ConflictPlan> {
6982    if !crate::ulid::is_ulid(bundle) {
6983        return Err(LinkError::InvalidPack {
6984            message: "conflict bundle must be a lowercase ULID".to_string(),
6985        });
6986    }
6987    let bytes = store.read_bounded(&v2_conflict_relative(bundle, "plan.json"), 1024 * 1024)?;
6988    let plan: V2ConflictPlan = serde_json::from_slice(&bytes)
6989        .map_err(|_| invalid_feed("private conflict plan is corrupt"))?;
6990    if plan.v != 2
6991        || plan.class != "content_resolution_required"
6992        || plan.bundle != bundle
6993        || !crate::ulid::is_ulid(&plan.brain)
6994        || plan.files.is_empty()
6995        || plan.files.len() > 100
6996        || plan.files.iter().any(|file| {
6997            crate::linkmd_v2::normalize_path(&file.path).is_err()
6998                || [&file.base, &file.local, &file.remote]
6999                    .into_iter()
7000                    .any(|coordinate| {
7001                        coordinate
7002                            .sha256
7003                            .as_deref()
7004                            .is_some_and(|hash| !is_sha256(hash))
7005                            || coordinate.file.as_deref().is_some_and(|name| {
7006                                name.starts_with('/')
7007                                    || name
7008                                        .split('/')
7009                                        .any(|part| part.is_empty() || part == "." || part == "..")
7010                            })
7011                    })
7012        })
7013    {
7014        return Err(invalid_feed("private conflict plan failed validation"));
7015    }
7016    Ok(plan)
7017}
7018
7019/// Inspect or prune private conflict-control state. Ordinary pruning removes
7020/// only expired completed bundles and interrupted bundles with no `plan.json`;
7021/// corrupt completed plans fail closed. `all` is an explicit local discard of
7022/// every well-addressed bundle and never changes hosted brain data.
7023pub fn sync_conflicts(checkout: &Path, prune: bool, all: bool) -> LinkResult<Value> {
7024    require_hardened_filesystem("private conflict maintenance")?;
7025    if all && !prune {
7026        return Err(LinkError::InvalidPack {
7027            message: "discarding all conflict bundles requires prune=true".to_string(),
7028        });
7029    }
7030    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
7031        message: format!("conflict checkout is not a valid db.md store: {error}"),
7032    })?;
7033    let _transaction = store.transaction()?;
7034    let root = Path::new(".dbmd/conflicts");
7035    let names = match store.directory_names(root) {
7036        Ok(names) => names,
7037        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
7038        Err(error) => return Err(error.into()),
7039    };
7040    let now = SystemTime::now()
7041        .duration_since(UNIX_EPOCH)
7042        .unwrap_or_default()
7043        .as_secs();
7044    let mut bundles = Vec::new();
7045    let mut pruned = 0_u64;
7046    for name in names {
7047        let Some(bundle) = name.to_str().filter(|value| crate::ulid::is_ulid(value)) else {
7048            continue;
7049        };
7050        let plan_path = v2_conflict_relative(bundle, "plan.json");
7051        let plan_exists = store.regular_file_exists(&plan_path)?;
7052        let expired = if plan_exists {
7053            match load_v2_conflict_plan(&store, bundle) {
7054                Ok(plan) => plan.expires_unix < now,
7055                Err(error) if all => {
7056                    let _ = error;
7057                    true
7058                }
7059                Err(error) => return Err(error),
7060            }
7061        } else {
7062            true
7063        };
7064        if prune && (all || expired) {
7065            store.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
7066            pruned += 1;
7067            continue;
7068        }
7069        bundles.push(json!({
7070            "bundle": bundle,
7071            "complete": plan_exists,
7072            "expired": expired,
7073        }));
7074    }
7075    Ok(json!({
7076        "v": 2,
7077        "class": "private_conflict_state",
7078        "bundles": bundles.len(),
7079        "pruned": pruned,
7080        "items": bundles,
7081    }))
7082}
7083
7084/// Resolve one exact private conflict plan. Drift never lowers a precondition:
7085/// the remote head/view and every original local coordinate are rechecked
7086/// before either an explicit local install or a fresh normal commit.
7087pub fn sync_resolve_conflict(
7088    cfg: &HubConfig,
7089    checkout: &Path,
7090    bundle: &str,
7091    choice: V2ConflictChoice,
7092    bulk_confirmation: Option<&V2BulkConfirmation>,
7093) -> LinkResult<Value> {
7094    require_hardened_filesystem("conflict resolution")?;
7095    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
7096        message: format!("conflict checkout is not a valid db.md store: {error}"),
7097    })?;
7098    let plan = load_v2_conflict_plan(&store, bundle)?;
7099    if plan.origin != normalized_origin(&cfg.hub)? {
7100        return Err(invalid_feed(
7101            "conflict bundle belongs to another hub origin",
7102        ));
7103    }
7104    let now = SystemTime::now()
7105        .duration_since(UNIX_EPOCH)
7106        .unwrap_or_default()
7107        .as_secs();
7108    if now > plan.expires_unix {
7109        return Err(LinkError::InvalidPack {
7110            message: "conflict bundle expired; rerun sync to obtain current coordinates"
7111                .to_string(),
7112        });
7113    }
7114    let head = v2_verified_head(cfg, &plan.brain)?
7115        .ok_or_else(|| invalid_feed("conflict brain no longer advertises v2"))?;
7116    let pointer = head.pointer.as_ref();
7117    if pointer.map_or(0, |value| value.seq) != plan.remote_seq
7118        || pointer.map(|value| value.commit_hash.as_str()) != plan.remote_commit.as_deref()
7119        || pointer.and_then(|value| value.content_root.as_deref())
7120            != plan.remote_content_root.as_deref()
7121        || head.view_kind != plan.view_kind
7122        || head.view_revision != plan.view_revision
7123    {
7124        return Err(LinkError::RemoteAdvancedDuringSync);
7125    }
7126
7127    // A resolution never overwrites a post-bundle local edit.
7128    for file in &plan.files {
7129        let actual = match store.regular_file_exists(Path::new(&file.path))? {
7130            true => Some(content_sha256(&store.read_bounded(
7131                Path::new(&file.path),
7132                file.local.bytes.unwrap_or(MAX_STORE_BYTES),
7133            )?)),
7134            false => None,
7135        };
7136        if actual.as_deref() != file.local.sha256.as_deref() {
7137            return Err(LinkError::InvalidPack {
7138                message: format!(
7139                    "local conflict path `{}` changed after the bundle was created",
7140                    file.path
7141                ),
7142            });
7143        }
7144    }
7145
7146    let from_source = match &choice {
7147        V2ConflictChoice::From(source) => Some(source.clone()),
7148        _ => None,
7149    };
7150    let result = match choice {
7151        V2ConflictChoice::TakeRemote => {
7152            if bulk_confirmation.is_some() {
7153                return Err(LinkError::InvalidPack {
7154                    message: "bulk confirmation applies to keep-local/from commits, not a local take-remote install".to_string(),
7155                });
7156            }
7157            // Re-read the exact current manifest so every selected remote byte
7158            // retains its signed inclusion proof. The private bundle is useful
7159            // for inspection, but is never promoted into live state as trust.
7160            let current_remote =
7161                files_for_v2_view(&head, v2_manifest(cfg, &plan.brain, head.pointer.as_ref())?);
7162            let _ = v2_take_remote_selection(&plan.files, &current_remote)?;
7163            let selected = plan
7164                .files
7165                .iter()
7166                .map(|file| file.path.clone())
7167                .collect::<std::collections::BTreeSet<_>>();
7168            serde_json::to_value(
7169                v2_sync_pull_with_resolution(
7170                    cfg,
7171                    &plan.brain,
7172                    head,
7173                    Some(checkout),
7174                    Some(&selected),
7175                )?
7176                .report,
7177            )
7178            .map_err(|_| invalid_feed("could not serialize conflict pull receipt"))?
7179        }
7180        V2ConflictChoice::KeepLocal | V2ConflictChoice::From(_) => {
7181            if let Some(source) = from_source.as_ref() {
7182                if plan.files.len() != 1 {
7183                    return Err(LinkError::InvalidPack {
7184                        message: "--from requires a bundle with exactly one conflict".to_string(),
7185                    });
7186                }
7187                let candidate = crate::fsx::read_bounded_nofollow(source, MAX_STORE_BYTES)?;
7188                if std::str::from_utf8(&candidate).is_err() {
7189                    return Err(LinkError::NotUtf8 {
7190                        path: source.display().to_string(),
7191                    });
7192                }
7193                store.write_atomic(Path::new(&plan.files[0].path), &candidate)?;
7194            }
7195            let refreshed_store =
7196                Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
7197                    message: format!("resolved checkout is not a valid db.md store: {error}"),
7198                })?;
7199            let mut overrides = std::collections::BTreeMap::new();
7200            for file in &plan.files {
7201                let selected_local = match refreshed_store
7202                    .regular_file_exists(Path::new(&file.path))?
7203                {
7204                    true => Some(content_sha256(
7205                        &refreshed_store.read_bounded(Path::new(&file.path), MAX_STORE_BYTES)?,
7206                    )),
7207                    false => None,
7208                };
7209                overrides.insert(
7210                    file.path.clone(),
7211                    V2ResolutionOverride {
7212                        expected_remote: file.remote.sha256.clone(),
7213                        selected_local,
7214                    },
7215                );
7216            }
7217            v2_sync_push(
7218                cfg,
7219                &plan.brain,
7220                &refreshed_store,
7221                head,
7222                V2SyncPushOptions {
7223                    resume_local_policy: true,
7224                    bulk_confirmation,
7225                    resolution: Some(&overrides),
7226                    pulled: None,
7227                },
7228            )?
7229        }
7230    };
7231
7232    if result.get("code").and_then(Value::as_str) != Some("proposal_queued") {
7233        let installed = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
7234            message: format!("resolved checkout is not a valid db.md store: {error}"),
7235        })?;
7236        installed.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
7237    }
7238    Ok(json!({
7239        "v": 2,
7240        "class": "auto_converged",
7241        "bundle": bundle,
7242        "receipt": result,
7243    }))
7244}
7245
7246/// Converge one established permissioned-v2 checkout in both directions.
7247///
7248/// The pull half first performs the ordinary three-way conflict check and
7249/// crash-safe install against the accepted baseline. The push half then opens
7250/// that exact installed checkout, takes its local transaction guard, computes
7251/// a fresh three-way delta, and submits it with mandatory preconditions. A
7252/// remote movement between halves is therefore a normal rebase/conflict input,
7253/// never a blind overwrite. Legacy v1 remains explicit pull-only/push-only so
7254/// its whole-pack replacement semantics are never presented as granular
7255/// convergence.
7256pub fn sync_converge(
7257    cfg: &HubConfig,
7258    brain: &str,
7259    checkout: &Path,
7260    resume_local_policy: bool,
7261) -> LinkResult<Value> {
7262    sync_converge_with_options(cfg, brain, checkout, resume_local_policy, None)
7263}
7264
7265/// Bidirectional convergence with optional exact bulk-preview confirmation.
7266pub fn sync_converge_with_options(
7267    cfg: &HubConfig,
7268    brain: &str,
7269    checkout: &Path,
7270    resume_local_policy: bool,
7271    bulk_confirmation: Option<&V2BulkConfirmation>,
7272) -> LinkResult<Value> {
7273    require_hardened_filesystem("bidirectional sync")?;
7274    require_safe_ref(brain)?;
7275    let head = v2_verified_head(cfg, brain)?.ok_or_else(|| LinkError::InvalidPack {
7276        message:
7277            "bidirectional sync requires link.md v2; use --pull-only or --push-only for a legacy brain"
7278                .to_string(),
7279    })?;
7280    let pulled = v2_sync_pull_with_resolution(cfg, brain, head, Some(checkout), None)?;
7281    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
7282        message: format!("installed v2 checkout is not a valid db.md store: {error}"),
7283    })?;
7284    let _transaction = store.transaction()?;
7285    let pulled_report = pulled.report.clone();
7286    let pulled_head = pulled.head.clone();
7287    let mut result = v2_sync_push(
7288        cfg,
7289        brain,
7290        &store,
7291        pulled_head,
7292        V2SyncPushOptions {
7293            resume_local_policy,
7294            bulk_confirmation,
7295            resolution: None,
7296            pulled: Some(pulled),
7297        },
7298    )?;
7299    if let Some(object) = result.as_object_mut() {
7300        object.insert("pulled_files".to_string(), json!(pulled_report.files));
7301        object.insert("checkout".to_string(), Value::String(pulled_report.dest));
7302        object.insert(
7303            "mode".to_string(),
7304            Value::String("bidirectional".to_string()),
7305        );
7306    }
7307    Ok(result)
7308}
7309
7310/// Pull the granted slice of `brain` to `out` (default: `./<slug>`). Every
7311/// exported path is safety-gated before it touches disk; files are written
7312/// atomically; nothing local is ever deleted (locals the export lacks are
7313/// *reported* in `extra_local` instead). Returns the report; rebuilding the
7314/// local index catalog afterwards is the caller's (cheap, optional) step.
7315pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
7316    require_hardened_filesystem("sync pull")?;
7317    require_safe_ref(brain)?;
7318    if let Some(head) = v2_verified_head(cfg, brain)? {
7319        return v2_sync_pull(cfg, brain, head, out);
7320    }
7321    legacy_sync_pull(cfg, brain, out)
7322}
7323
7324#[cfg(windows)]
7325fn legacy_sync_pull(_cfg: &HubConfig, _brain: &str, _out: Option<&Path>) -> LinkResult<PullReport> {
7326    Err(LinkError::UnsupportedPlatform {
7327        operation: "legacy v1 whole-snapshot pull on Windows; upgrade the brain to link.md v2",
7328    })
7329}
7330
7331#[cfg(not(windows))]
7332fn legacy_sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
7333    let remote = verified_remote_head(cfg, brain, false)?;
7334    if !remote.head.verified {
7335        return Err(invalid_feed(
7336            "the grant exposes head movement but no signed snapshot; refusing an unverifiable pull",
7337        ));
7338    }
7339    let snapshot_hash = remote.head.feed_hash.as_deref().unwrap_or("none");
7340    let path = format!(
7341        "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={snapshot_hash}",
7342        remote.head.seq
7343    );
7344    let body = ensure_ok(
7345        request(cfg, "GET", &path, None, Auth::Required)?,
7346        "sync pull",
7347    )?;
7348    if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
7349        || body.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
7350    {
7351        return Err(invalid_feed(
7352            "export response is not bound to the verified snapshot token",
7353        ));
7354    }
7355
7356    let remote_slug = body
7357        .get("slug")
7358        .and_then(Value::as_str)
7359        .filter(|slug| is_safe_slug(slug));
7360    let slug = remote_slug
7361        .or_else(|| is_safe_slug(brain).then_some(brain))
7362        .unwrap_or("brain")
7363        .to_string();
7364    let brain_id = body
7365        .get("brain")
7366        .and_then(Value::as_str)
7367        .unwrap_or(&remote.head.brain)
7368        .to_string();
7369    if brain_id != remote.head.brain {
7370        return Err(invalid_feed(
7371            "export response names a different brain than the verified head",
7372        ));
7373    }
7374    let head_seq = remote.head.seq;
7375    let dest: PathBuf = match out {
7376        Some(p) => p.to_path_buf(),
7377        None => PathBuf::from(&slug),
7378    };
7379    let entries = if head_seq == 0 {
7380        let files = body
7381            .get("files")
7382            .and_then(Value::as_array)
7383            .ok_or_else(|| invalid_feed("empty snapshot export did not carry an empty manifest"))?;
7384        if !files.is_empty() || body.get("url").is_some() {
7385            return Err(invalid_feed(
7386                "empty signed feed cannot authorize non-empty exported content",
7387            ));
7388        }
7389        Vec::new()
7390    } else {
7391        let signed_head = remote
7392            .head_entry
7393            .as_ref()
7394            .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
7395        let expected = &signed_head.entry.pack_sha256;
7396        if !is_sha256(expected) {
7397            return Err(invalid_feed(
7398                "signed head carries an invalid snapshot pack digest",
7399            ));
7400        }
7401        if let Some(url) = body.get("url").and_then(Value::as_str) {
7402            if body.get("sha256").and_then(Value::as_str) != Some(expected.as_str()) {
7403                return Err(invalid_feed(
7404                    "export pack digest does not match the signed head entry",
7405                ));
7406            }
7407            let bytes = get_presigned(cfg, url)?;
7408            let actual = format!("{:x}", Sha256::digest(&bytes));
7409            if actual != *expected {
7410                return Err(LinkError::InvalidPack {
7411                    message: "downloaded pack does not match the signed snapshot digest"
7412                        .to_string(),
7413                });
7414            }
7415            let entries = parse_store_pack(bytes)?;
7416            if signed_head.entry.kind == "push" {
7417                verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
7418            }
7419            entries
7420        } else {
7421            if signed_head.entry.kind != "push" {
7422                return Err(invalid_feed(
7423                    "delta snapshots must export the exact signed pack",
7424                ));
7425            }
7426            let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
7427                invalid_feed("verified snapshot export carried neither a pack nor files")
7428            })?;
7429            let mut entries = Vec::with_capacity(files.len());
7430            for file in files {
7431                let path = file
7432                    .get("path")
7433                    .and_then(Value::as_str)
7434                    .ok_or_else(|| invalid_feed("exported file path is not a string"))?;
7435                let content = file
7436                    .get("content")
7437                    .and_then(Value::as_str)
7438                    .ok_or_else(|| invalid_feed("exported inline content is not UTF-8 text"))?;
7439                entries.push((path.to_string(), content.as_bytes().to_vec()));
7440            }
7441            verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
7442            entries
7443        }
7444    };
7445
7446    // Gate the complete manifest before the first filesystem mutation.
7447    let mut seen = std::collections::HashSet::new();
7448    for (path, _) in &entries {
7449        if !safe_store_rel_path(path) {
7450            return Err(LinkError::UnsafePath { path: path.clone() });
7451        }
7452        if !seen.insert(path) {
7453            return Err(LinkError::InvalidPack {
7454                message: format!("duplicate path `{path}`"),
7455            });
7456        }
7457    }
7458    // Compute divergence against the still-live tree. The staged clone keeps
7459    // these extra locals byte-for-byte while overlaying the signed snapshot.
7460    let pulled: std::collections::BTreeSet<&str> =
7461        entries.iter().map(|(p, _)| p.as_str()).collect();
7462    let mut extra_local = Vec::new();
7463    if let Ok(store) = Store::open(&dest) {
7464        if let Ok(walked) = store.walk() {
7465            for rel in walked {
7466                let rel_str = rel.to_string_lossy().replace('\\', "/");
7467                if !pulled.contains(rel_str.as_str()) {
7468                    extra_local.push(rel_str);
7469                }
7470            }
7471        }
7472    }
7473    #[cfg(unix)]
7474    install_pulled_snapshot(&dest, &entries)?;
7475
7476    Ok(PullReport {
7477        brain: brain_id,
7478        slug,
7479        head_seq,
7480        files: entries.len(),
7481        dest: dest.to_string_lossy().into_owned(),
7482        extra_local,
7483        sync_status: "synced".to_string(),
7484    })
7485}
7486
7487#[cfg(unix)]
7488fn c_name(bytes: &[u8], display: &str) -> LinkResult<std::ffi::CString> {
7489    std::ffi::CString::new(bytes).map_err(|_| LinkError::UnsafePath {
7490        path: display.to_string(),
7491    })
7492}
7493
7494#[cfg(unix)]
7495fn open_dir_at(
7496    parent: std::os::fd::RawFd,
7497    name: &std::ffi::CStr,
7498    display: &str,
7499) -> LinkResult<std::fs::File> {
7500    use std::os::fd::FromRawFd as _;
7501    let fd = unsafe {
7502        libc::openat(
7503            parent,
7504            name.as_ptr(),
7505            libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
7506        )
7507    };
7508    if fd < 0 {
7509        return Err(LinkError::UnsafePath {
7510            path: display.to_string(),
7511        });
7512    }
7513    Ok(unsafe { std::fs::File::from_raw_fd(fd) })
7514}
7515
7516/// Open (and, where absent, create) a directory path without following a
7517/// symlink in any component. The returned directory capability remains bound
7518/// to the opened inode even if an attacker renames or replaces an ancestor.
7519#[cfg(unix)]
7520fn open_dir_path_nofollow(path: &Path, create: bool) -> LinkResult<std::fs::File> {
7521    use std::os::fd::AsRawFd as _;
7522
7523    // macOS exposes a few system-owned compatibility symlinks at the root.
7524    // Normalize only those fixed OS aliases; never canonicalize an arbitrary
7525    // caller-controlled ancestor.
7526    #[cfg(target_os = "macos")]
7527    let normalized = [("/var", "/private/var"), ("/tmp", "/private/tmp")]
7528        .into_iter()
7529        .find_map(|(alias, real)| {
7530            path.strip_prefix(alias)
7531                .ok()
7532                .map(|rest| Path::new(real).join(rest))
7533        })
7534        .unwrap_or_else(|| path.to_path_buf());
7535    #[cfg(not(target_os = "macos"))]
7536    let normalized = path.to_path_buf();
7537
7538    let start = if normalized.is_absolute() {
7539        std::fs::File::open("/")?
7540    } else {
7541        std::fs::File::open(".")?
7542    };
7543    let mut directory = start;
7544    for component in normalized.components() {
7545        use std::path::Component;
7546        let name = match component {
7547            Component::RootDir | Component::CurDir => continue,
7548            Component::Normal(name) => name,
7549            Component::ParentDir | Component::Prefix(_) => {
7550                return Err(LinkError::UnsafePath {
7551                    path: path.display().to_string(),
7552                });
7553            }
7554        };
7555        use std::os::unix::ffi::OsStrExt as _;
7556        let name = c_name(name.as_bytes(), &path.display().to_string())?;
7557        if create {
7558            let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
7559            if made != 0 {
7560                let error = std::io::Error::last_os_error();
7561                if error.raw_os_error() != Some(libc::EEXIST) {
7562                    return Err(error.into());
7563                }
7564            }
7565        }
7566        directory = open_dir_at(directory.as_raw_fd(), &name, &path.display().to_string())?;
7567    }
7568    Ok(directory)
7569}
7570
7571#[cfg(unix)]
7572fn open_or_create_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
7573    open_dir_path_nofollow(path, true)
7574}
7575
7576#[cfg(unix)]
7577fn open_existing_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
7578    open_dir_path_nofollow(path, false)
7579}
7580
7581#[cfg(unix)]
7582fn entry_is_dir_at(parent: std::os::fd::RawFd, name: &std::ffi::CStr) -> LinkResult<Option<bool>> {
7583    let mut stat: libc::stat = unsafe { std::mem::zeroed() };
7584    let result =
7585        unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) };
7586    if result == 0 {
7587        return Ok(Some((stat.st_mode & libc::S_IFMT) == libc::S_IFDIR));
7588    }
7589    let error = std::io::Error::last_os_error();
7590    if error.kind() == std::io::ErrorKind::NotFound {
7591        Ok(None)
7592    } else {
7593        Err(error.into())
7594    }
7595}
7596
7597#[cfg(unix)]
7598fn create_dir_exclusive_at(
7599    parent: std::os::fd::RawFd,
7600    name: &std::ffi::CStr,
7601    display: &str,
7602) -> LinkResult<std::fs::File> {
7603    let made = unsafe { libc::mkdirat(parent, name.as_ptr(), 0o700) };
7604    if made != 0 {
7605        return Err(LinkError::UnsafePath {
7606            path: display.to_string(),
7607        });
7608    }
7609    open_dir_at(parent, name, display)
7610}
7611
7612#[cfg(unix)]
7613fn directory_entry_names(directory: &std::fs::File) -> LinkResult<Vec<std::ffi::CString>> {
7614    use std::os::fd::AsRawFd as _;
7615
7616    let duplicate = unsafe { libc::dup(directory.as_raw_fd()) };
7617    if duplicate < 0 {
7618        return Err(std::io::Error::last_os_error().into());
7619    }
7620    let stream = unsafe { libc::fdopendir(duplicate) };
7621    if stream.is_null() {
7622        let error = std::io::Error::last_os_error();
7623        unsafe {
7624            libc::close(duplicate);
7625        }
7626        return Err(error.into());
7627    }
7628    let mut names = Vec::new();
7629    loop {
7630        let entry = unsafe { libc::readdir(stream) };
7631        if entry.is_null() {
7632            break;
7633        }
7634        let raw = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) };
7635        if raw.to_bytes() != b"." && raw.to_bytes() != b".." {
7636            names.push(raw.to_owned());
7637        }
7638    }
7639    if unsafe { libc::closedir(stream) } != 0 {
7640        return Err(std::io::Error::last_os_error().into());
7641    }
7642    Ok(names)
7643}
7644
7645/// Remove an entry tree relative to a held directory capability. Symlinks are
7646/// unlinked, never traversed, even when an old mirror contains hostile names.
7647#[cfg(unix)]
7648fn remove_tree_at(
7649    parent: std::os::fd::RawFd,
7650    name: &std::ffi::CStr,
7651    display: &str,
7652) -> LinkResult<()> {
7653    use std::os::fd::AsRawFd as _;
7654
7655    match entry_is_dir_at(parent, name)? {
7656        None => return Ok(()),
7657        Some(false) => {
7658            if unsafe { libc::unlinkat(parent, name.as_ptr(), 0) } != 0 {
7659                return Err(std::io::Error::last_os_error().into());
7660            }
7661        }
7662        Some(true) => {
7663            let directory = open_dir_at(parent, name, display)?;
7664            for child in directory_entry_names(&directory)? {
7665                let child_display =
7666                    format!("{display}/{}", String::from_utf8_lossy(child.to_bytes()));
7667                remove_tree_at(directory.as_raw_fd(), &child, &child_display)?;
7668            }
7669            drop(directory);
7670            if unsafe { libc::unlinkat(parent, name.as_ptr(), libc::AT_REMOVEDIR) } != 0 {
7671                return Err(std::io::Error::last_os_error().into());
7672            }
7673        }
7674    }
7675    Ok(())
7676}
7677
7678/// Clone a live destination into a private sibling stage without following
7679/// any symlink. Regular files are streamed between held descriptors; symlinks
7680/// are reproduced as links, never opened. Special files are refused.
7681#[cfg(unix)]
7682fn clone_tree_contents(
7683    source: &std::fs::File,
7684    destination: &std::fs::File,
7685    display: &str,
7686) -> LinkResult<()> {
7687    use std::os::fd::{AsRawFd as _, FromRawFd as _};
7688
7689    for name in directory_entry_names(source)? {
7690        let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
7691        let mut stat: libc::stat = unsafe { std::mem::zeroed() };
7692        if unsafe {
7693            libc::fstatat(
7694                source.as_raw_fd(),
7695                name.as_ptr(),
7696                &mut stat,
7697                libc::AT_SYMLINK_NOFOLLOW,
7698            )
7699        } != 0
7700        {
7701            return Err(std::io::Error::last_os_error().into());
7702        }
7703        match stat.st_mode & libc::S_IFMT {
7704            libc::S_IFDIR => {
7705                if unsafe {
7706                    libc::mkdirat(destination.as_raw_fd(), name.as_ptr(), stat.st_mode & 0o777)
7707                } != 0
7708                {
7709                    return Err(std::io::Error::last_os_error().into());
7710                }
7711                let source_child = open_dir_at(source.as_raw_fd(), &name, &child_display)?;
7712                let destination_child =
7713                    open_dir_at(destination.as_raw_fd(), &name, &child_display)?;
7714                clone_tree_contents(&source_child, &destination_child, &child_display)?;
7715                destination_child.sync_all()?;
7716            }
7717            libc::S_IFREG => {
7718                let source_fd = unsafe {
7719                    libc::openat(
7720                        source.as_raw_fd(),
7721                        name.as_ptr(),
7722                        libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
7723                    )
7724                };
7725                if source_fd < 0 {
7726                    return Err(std::io::Error::last_os_error().into());
7727                }
7728                let destination_fd = unsafe {
7729                    libc::openat(
7730                        destination.as_raw_fd(),
7731                        name.as_ptr(),
7732                        libc::O_WRONLY
7733                            | libc::O_CREAT
7734                            | libc::O_EXCL
7735                            | libc::O_CLOEXEC
7736                            | libc::O_NOFOLLOW,
7737                        (stat.st_mode & 0o777) as libc::c_uint,
7738                    )
7739                };
7740                if destination_fd < 0 {
7741                    unsafe {
7742                        libc::close(source_fd);
7743                    }
7744                    return Err(std::io::Error::last_os_error().into());
7745                }
7746                let mut input = unsafe { std::fs::File::from_raw_fd(source_fd) };
7747                let mut output = unsafe { std::fs::File::from_raw_fd(destination_fd) };
7748                std::io::copy(&mut input, &mut output)?;
7749                output.sync_all()?;
7750            }
7751            libc::S_IFLNK => {
7752                let mut target = vec![0_u8; 4097];
7753                let length = unsafe {
7754                    libc::readlinkat(
7755                        source.as_raw_fd(),
7756                        name.as_ptr(),
7757                        target.as_mut_ptr().cast(),
7758                        target.len(),
7759                    )
7760                };
7761                if length < 0 || length as usize >= target.len() {
7762                    return Err(LinkError::UnsafePath {
7763                        path: child_display,
7764                    });
7765                }
7766                target.truncate(length as usize);
7767                let target = c_name(&target, &child_display)?;
7768                if unsafe {
7769                    libc::symlinkat(target.as_ptr(), destination.as_raw_fd(), name.as_ptr())
7770                } != 0
7771                {
7772                    return Err(std::io::Error::last_os_error().into());
7773                }
7774            }
7775            _ => {
7776                return Err(LinkError::UnsafePath {
7777                    path: child_display,
7778                });
7779            }
7780        }
7781    }
7782    destination.sync_all()?;
7783    Ok(())
7784}
7785
7786#[cfg(target_os = "linux")]
7787fn install_stage_at(
7788    parent: std::os::fd::RawFd,
7789    stage: &std::ffi::CStr,
7790    dest: &std::ffi::CStr,
7791    dest_exists: bool,
7792) -> LinkResult<()> {
7793    let flags = if dest_exists {
7794        libc::RENAME_EXCHANGE
7795    } else {
7796        libc::RENAME_NOREPLACE
7797    };
7798    // `libc::renameat2` is not exported for the musl targets we ship. Invoke
7799    // the kernel ABI directly, as `fsx::renameat_noreplace` does, so the same
7800    // atomic exchange/no-replace boundary compiles for glibc and musl.
7801    let result = unsafe {
7802        libc::syscall(
7803            libc::SYS_renameat2,
7804            parent,
7805            stage.as_ptr(),
7806            parent,
7807            dest.as_ptr(),
7808            flags,
7809        )
7810    };
7811    if result == 0 {
7812        Ok(())
7813    } else {
7814        Err(std::io::Error::last_os_error().into())
7815    }
7816}
7817
7818#[cfg(target_os = "macos")]
7819fn install_stage_at(
7820    parent: std::os::fd::RawFd,
7821    stage: &std::ffi::CStr,
7822    dest: &std::ffi::CStr,
7823    dest_exists: bool,
7824) -> LinkResult<()> {
7825    let flags = if dest_exists {
7826        libc::RENAME_SWAP
7827    } else {
7828        libc::RENAME_EXCL
7829    };
7830    let result =
7831        unsafe { libc::renameatx_np(parent, stage.as_ptr(), parent, dest.as_ptr(), flags) };
7832    if result == 0 {
7833        Ok(())
7834    } else {
7835        Err(std::io::Error::last_os_error().into())
7836    }
7837}
7838
7839#[cfg(unix)]
7840fn write_pull_entries_beneath_dir(
7841    root: &std::fs::File,
7842    entries: &[(String, Vec<u8>)],
7843) -> LinkResult<()> {
7844    use std::os::fd::{AsRawFd as _, FromRawFd as _};
7845
7846    for (path, content) in entries {
7847        let components: Vec<&str> = path.split('/').collect();
7848        let (leaf, parents) = components
7849            .split_last()
7850            .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
7851        let mut directory = root.try_clone()?;
7852        for component in parents {
7853            let name = c_name(component.as_bytes(), path)?;
7854            let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
7855            if made != 0 {
7856                let error = std::io::Error::last_os_error();
7857                if error.raw_os_error() != Some(libc::EEXIST) {
7858                    return Err(error.into());
7859                }
7860            }
7861            directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
7862        }
7863
7864        let leaf_name = c_name(leaf.as_bytes(), path)?;
7865        let mut existing: libc::stat = unsafe { std::mem::zeroed() };
7866        let inspected = unsafe {
7867            libc::fstatat(
7868                directory.as_raw_fd(),
7869                leaf_name.as_ptr(),
7870                &mut existing,
7871                libc::AT_SYMLINK_NOFOLLOW,
7872            )
7873        };
7874        if inspected == 0 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK {
7875            return Err(LinkError::UnsafePath { path: path.clone() });
7876        }
7877
7878        let nonce = std::time::SystemTime::now()
7879            .duration_since(std::time::UNIX_EPOCH)
7880            .unwrap_or_default()
7881            .as_nanos();
7882        let temp_name = format!(
7883            ".dbmd-pull-{}-{nonce}-{}",
7884            std::process::id(),
7885            content_sha256(format!("{path}\0{}", content.len()).as_bytes())
7886        );
7887        let temp = c_name(temp_name.as_bytes(), path)?;
7888        let fd = unsafe {
7889            libc::openat(
7890                directory.as_raw_fd(),
7891                temp.as_ptr(),
7892                libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
7893                0o600,
7894            )
7895        };
7896        if fd < 0 {
7897            return Err(std::io::Error::last_os_error().into());
7898        }
7899        let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
7900        if let Err(error) = file.write_all(content).and_then(|_| file.sync_all()) {
7901            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
7902            return Err(error.into());
7903        }
7904        drop(file);
7905        let renamed = unsafe {
7906            libc::renameat(
7907                directory.as_raw_fd(),
7908                temp.as_ptr(),
7909                directory.as_raw_fd(),
7910                leaf_name.as_ptr(),
7911            )
7912        };
7913        if renamed != 0 {
7914            let error = std::io::Error::last_os_error();
7915            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
7916            return Err(error.into());
7917        }
7918        directory.sync_all()?;
7919    }
7920    root.sync_all()?;
7921    Ok(())
7922}
7923
7924#[cfg(unix)]
7925fn write_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
7926    use std::os::fd::{AsRawFd as _, FromRawFd as _};
7927
7928    let path = &entry.path;
7929    let components: Vec<&str> = path.split('/').collect();
7930    let (leaf, parents) = components
7931        .split_last()
7932        .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
7933    let mut directory = root.try_clone()?;
7934    for component in parents {
7935        let name = c_name(component.as_bytes(), path)?;
7936        let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
7937        if made != 0 {
7938            let error = std::io::Error::last_os_error();
7939            if error.raw_os_error() != Some(libc::EEXIST) {
7940                return Err(error.into());
7941            }
7942        }
7943        directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
7944    }
7945    let leaf_name = c_name(leaf.as_bytes(), path)?;
7946    let mut existing: libc::stat = unsafe { std::mem::zeroed() };
7947    if unsafe {
7948        libc::fstatat(
7949            directory.as_raw_fd(),
7950            leaf_name.as_ptr(),
7951            &mut existing,
7952            libc::AT_SYMLINK_NOFOLLOW,
7953        )
7954    } == 0
7955        && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK
7956    {
7957        return Err(LinkError::UnsafePath { path: path.clone() });
7958    }
7959    let nonce = SystemTime::now()
7960        .duration_since(UNIX_EPOCH)
7961        .unwrap_or_default()
7962        .as_nanos();
7963    let temp_name = format!(
7964        ".dbmd-pull-{}-{nonce}-{}",
7965        std::process::id(),
7966        content_sha256(path.as_bytes())
7967    );
7968    let temp = c_name(temp_name.as_bytes(), path)?;
7969    let fd = unsafe {
7970        libc::openat(
7971            directory.as_raw_fd(),
7972            temp.as_ptr(),
7973            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
7974            0o600,
7975        )
7976    };
7977    if fd < 0 {
7978        return Err(std::io::Error::last_os_error().into());
7979    }
7980    let mut input = crate::fsx::open_regular_nofollow(&entry.source)?;
7981    let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
7982    let mut digest = Sha256::new();
7983    let mut total = 0_u64;
7984    let mut buffer = [0_u8; 64 * 1024];
7985    let copied = (|| -> std::io::Result<()> {
7986        loop {
7987            let read = input.read(&mut buffer)?;
7988            if read == 0 {
7989                break;
7990            }
7991            total = total.saturating_add(read as u64);
7992            if total > entry.bytes {
7993                return Err(std::io::Error::new(
7994                    std::io::ErrorKind::InvalidData,
7995                    "staged sync source grew beyond its verified length",
7996                ));
7997            }
7998            digest.update(&buffer[..read]);
7999            output.write_all(&buffer[..read])?;
8000        }
8001        Ok(())
8002    })();
8003    if let Err(error) = copied {
8004        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8005        return Err(error.into());
8006    }
8007    drop(output);
8008    if total != entry.bytes || format!("{:x}", digest.finalize()) != entry.sha256 {
8009        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8010        return Err(invalid_feed(
8011            "private staged sync source failed final integrity verification",
8012        ));
8013    }
8014    if unsafe {
8015        libc::renameat(
8016            directory.as_raw_fd(),
8017            temp.as_ptr(),
8018            directory.as_raw_fd(),
8019            leaf_name.as_ptr(),
8020        )
8021    } != 0
8022    {
8023        let error = std::io::Error::last_os_error();
8024        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8025        return Err(error.into());
8026    }
8027    Ok(())
8028}
8029
8030#[cfg(unix)]
8031fn sync_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
8032    use std::os::fd::{AsRawFd as _, FromRawFd as _};
8033
8034    let path = &entry.path;
8035    let components: Vec<&str> = path.split('/').collect();
8036    let (leaf, parents) = components
8037        .split_last()
8038        .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
8039    let mut directory = root.try_clone()?;
8040    for component in parents {
8041        directory = open_dir_at(
8042            directory.as_raw_fd(),
8043            &c_name(component.as_bytes(), path)?,
8044            path,
8045        )?;
8046    }
8047    let leaf = c_name(leaf.as_bytes(), path)?;
8048    let fd = unsafe {
8049        libc::openat(
8050            directory.as_raw_fd(),
8051            leaf.as_ptr(),
8052            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
8053        )
8054    };
8055    if fd < 0 {
8056        return Err(std::io::Error::last_os_error().into());
8057    }
8058    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
8059    if file.metadata()?.len() != entry.bytes || content_sha256_reader(&mut file)? != entry.sha256 {
8060        return Err(invalid_feed(
8061            "private pull stage changed before its durability barrier",
8062        ));
8063    }
8064    file.sync_all()?;
8065    Ok(())
8066}
8067
8068#[cfg(unix)]
8069fn run_pull_source_workers(
8070    root: &std::fs::File,
8071    entries: &[V2StagedFile],
8072    operation: fn(&std::fs::File, &V2StagedFile) -> LinkResult<()>,
8073) -> LinkResult<()> {
8074    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
8075
8076    let next = AtomicUsize::new(0);
8077    let failed = AtomicBool::new(false);
8078    let worker_count = entries.len().min(V2_PULL_INSTALL_WORKERS);
8079    let mut first_error = None;
8080    std::thread::scope(|scope| {
8081        let (sender, receiver) = std::sync::mpsc::channel();
8082        for _ in 0..worker_count {
8083            let sender = sender.clone();
8084            let next = &next;
8085            let failed = &failed;
8086            scope.spawn(move || {
8087                while !failed.load(Ordering::Acquire) {
8088                    let index = next.fetch_add(1, Ordering::Relaxed);
8089                    let Some(entry) = entries.get(index) else {
8090                        break;
8091                    };
8092                    let result = operation(root, entry);
8093                    if result.is_err() {
8094                        failed.store(true, Ordering::Release);
8095                    }
8096                    if sender.send(result).is_err() {
8097                        break;
8098                    }
8099                }
8100            });
8101        }
8102        drop(sender);
8103        for result in receiver {
8104            if let Err(error) = result {
8105                if first_error.is_none() {
8106                    first_error = Some(error);
8107                }
8108            }
8109        }
8110    });
8111    if let Some(error) = first_error {
8112        return Err(error);
8113    }
8114    if next.load(Ordering::Relaxed) < entries.len() {
8115        return Err(invalid_feed(
8116            "a bounded pull worker stopped before reporting every file",
8117        ));
8118    }
8119    Ok(())
8120}
8121
8122#[cfg(unix)]
8123fn sync_pull_directory_tree(root: &std::fs::File, display: &str) -> LinkResult<()> {
8124    use std::os::fd::AsRawFd as _;
8125
8126    for name in directory_entry_names(root)? {
8127        if entry_is_dir_at(root.as_raw_fd(), &name)? == Some(true) {
8128            let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
8129            let child = open_dir_at(root.as_raw_fd(), &name, &child_display)?;
8130            sync_pull_directory_tree(&child, &child_display)?;
8131        }
8132    }
8133    root.sync_all()?;
8134    Ok(())
8135}
8136
8137#[cfg(unix)]
8138fn write_pull_sources_beneath_dir(
8139    root: &std::fs::File,
8140    entries: &[V2StagedFile],
8141) -> LinkResult<()> {
8142    // The stage is still private and unreachable as the checkout here. Write
8143    // and verify every leaf first so the filesystem can coalesce dirty data,
8144    // then re-open/re-hash/fsync leaves through the held root capability. Only
8145    // after every file barrier succeeds do we flush each directory once. This
8146    // preserves the same crash durability as file-fsync + directory-fsync per
8147    // path without serializing 50,000 redundant directory barriers.
8148    run_pull_source_workers(root, entries, write_pull_source_beneath_dir)?;
8149    run_pull_source_workers(root, entries, sync_pull_source_beneath_dir)?;
8150    sync_pull_directory_tree(root, "v2 pull stage")
8151}
8152
8153#[cfg(unix)]
8154fn remove_pull_paths_beneath_dir(root: &std::fs::File, paths: &[String]) -> LinkResult<()> {
8155    use std::os::fd::AsRawFd as _;
8156    for path in paths {
8157        if !safe_store_rel_path(path) {
8158            return Err(LinkError::UnsafePath { path: path.clone() });
8159        }
8160        let components = path.split('/').collect::<Vec<_>>();
8161        let Some((leaf, parents)) = components.split_last() else {
8162            return Err(LinkError::UnsafePath { path: path.clone() });
8163        };
8164        let mut directory = root.try_clone()?;
8165        let mut missing = false;
8166        for component in parents {
8167            let name = c_name(component.as_bytes(), path)?;
8168            match entry_is_dir_at(directory.as_raw_fd(), &name)? {
8169                None => {
8170                    missing = true;
8171                    break;
8172                }
8173                Some(false) => return Err(LinkError::UnsafePath { path: path.clone() }),
8174                Some(true) => {
8175                    directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
8176                }
8177            }
8178        }
8179        if missing {
8180            continue;
8181        }
8182        let leaf = c_name(leaf.as_bytes(), path)?;
8183        match entry_is_dir_at(directory.as_raw_fd(), &leaf)? {
8184            None => {}
8185            Some(true) => return Err(LinkError::UnsafePath { path: path.clone() }),
8186            Some(false) => {
8187                if unsafe { libc::unlinkat(directory.as_raw_fd(), leaf.as_ptr(), 0) } != 0 {
8188                    return Err(std::io::Error::last_os_error().into());
8189                }
8190                directory.sync_all()?;
8191            }
8192        }
8193    }
8194    Ok(())
8195}
8196
8197#[cfg(unix)]
8198fn install_pulled_delta(
8199    dest: &Path,
8200    entries: &[(String, Vec<u8>)],
8201    deleted: &[String],
8202    rebuild_indexes: bool,
8203) -> LinkResult<()> {
8204    use ring::rand::SecureRandom as _;
8205    use std::os::fd::AsRawFd as _;
8206    use std::os::unix::ffi::OsStrExt as _;
8207
8208    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
8209    let name = dest
8210        .file_name()
8211        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
8212        .ok_or_else(|| LinkError::UnsafePath {
8213            path: dest.display().to_string(),
8214        })?;
8215    let parent_dir = open_or_create_dir_nofollow(parent)?;
8216    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
8217    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
8218        None => false,
8219        Some(true) => true,
8220        Some(false) => {
8221            return Err(LinkError::UnsafePath {
8222                path: dest.display().to_string(),
8223            });
8224        }
8225    };
8226
8227    let mut nonce = [0_u8; 16];
8228    ring::rand::SystemRandom::new()
8229        .fill(&mut nonce)
8230        .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
8231    let stage_label = format!(
8232        ".{}.dbmd-pull-stage-{}",
8233        name.to_string_lossy(),
8234        URL_SAFE_NO_PAD.encode(nonce)
8235    );
8236    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
8237    let stage_dir = create_dir_exclusive_at(
8238        parent_dir.as_raw_fd(),
8239        &stage_name,
8240        &dest.display().to_string(),
8241    )?;
8242
8243    let prepared = (|| -> LinkResult<()> {
8244        if dest_exists {
8245            let live = open_dir_at(
8246                parent_dir.as_raw_fd(),
8247                &dest_name,
8248                &dest.display().to_string(),
8249            )?;
8250            clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
8251        }
8252        remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
8253        write_pull_entries_beneath_dir(&stage_dir, entries)?;
8254        if rebuild_indexes {
8255            let stage_store =
8256                Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
8257                    .map_err(|error| LinkError::InvalidPack {
8258                        message: format!("v2 staging tree is not a valid db.md store: {error}"),
8259                    })?;
8260            crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
8261                LinkError::InvalidPack {
8262                    message: format!("could not materialize v2 local catalogs: {error}"),
8263                }
8264            })?;
8265        }
8266        stage_dir.sync_all()?;
8267        Ok(())
8268    })();
8269    if let Err(error) = prepared {
8270        let _ = remove_tree_at(
8271            parent_dir.as_raw_fd(),
8272            &stage_name,
8273            &dest.display().to_string(),
8274        );
8275        return Err(error);
8276    }
8277
8278    if let Err(error) =
8279        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
8280    {
8281        let _ = remove_tree_at(
8282            parent_dir.as_raw_fd(),
8283            &stage_name,
8284            &dest.display().to_string(),
8285        );
8286        return Err(error);
8287    }
8288    parent_dir.sync_all()?;
8289    if dest_exists {
8290        // Commit already happened atomically. Cleanup is best-effort so a
8291        // failure cannot be reported as a failed pull after the live tree
8292        // changed; a crash may leave only this private old-tree sibling.
8293        let _ = remove_tree_at(
8294            parent_dir.as_raw_fd(),
8295            &stage_name,
8296            &dest.display().to_string(),
8297        );
8298        let _ = parent_dir.sync_all();
8299    }
8300    Ok(())
8301}
8302
8303#[cfg(unix)]
8304fn install_pulled_delta_sources(
8305    dest: &Path,
8306    entries: &[V2StagedFile],
8307    deleted: &[String],
8308    rebuild_indexes: bool,
8309    _previous: Option<&V2SyncBaseline>,
8310    _next: &V2VerifiedHead,
8311) -> LinkResult<()> {
8312    use ring::rand::SecureRandom as _;
8313    use std::os::fd::AsRawFd as _;
8314    use std::os::unix::ffi::OsStrExt as _;
8315
8316    // An established checkout is updated only at the changed coordinates
8317    // through the durable pull journal shared with Windows. Initial clones
8318    // still use a private complete stage and one atomic directory install.
8319    if let Ok(store) = Store::open_strict(dest) {
8320        return install_established_v2_delta(
8321            store,
8322            entries,
8323            deleted,
8324            rebuild_indexes,
8325            _previous,
8326            _next,
8327        );
8328    }
8329
8330    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
8331    let name = dest
8332        .file_name()
8333        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
8334        .ok_or_else(|| LinkError::UnsafePath {
8335            path: dest.display().to_string(),
8336        })?;
8337    let parent_dir = open_or_create_dir_nofollow(parent)?;
8338    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
8339    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
8340        None => false,
8341        Some(true) => true,
8342        Some(false) => {
8343            return Err(LinkError::UnsafePath {
8344                path: dest.display().to_string(),
8345            })
8346        }
8347    };
8348    let mut nonce = [0_u8; 16];
8349    ring::rand::SystemRandom::new()
8350        .fill(&mut nonce)
8351        .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
8352    let stage_label = format!(
8353        ".{}.dbmd-pull-stage-{}",
8354        name.to_string_lossy(),
8355        URL_SAFE_NO_PAD.encode(nonce)
8356    );
8357    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
8358    let stage_dir = create_dir_exclusive_at(
8359        parent_dir.as_raw_fd(),
8360        &stage_name,
8361        &dest.display().to_string(),
8362    )?;
8363    let prepared = (|| -> LinkResult<()> {
8364        if dest_exists {
8365            let live = open_dir_at(
8366                parent_dir.as_raw_fd(),
8367                &dest_name,
8368                &dest.display().to_string(),
8369            )?;
8370            clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
8371        }
8372        remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
8373        write_pull_sources_beneath_dir(&stage_dir, entries)?;
8374        if rebuild_indexes {
8375            let stage_store =
8376                Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
8377                    .map_err(|error| LinkError::InvalidPack {
8378                        message: format!("v2 staging tree is not a valid db.md store: {error}"),
8379                    })?;
8380            crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
8381                LinkError::InvalidPack {
8382                    message: format!("could not materialize v2 local catalogs: {error}"),
8383                }
8384            })?;
8385        }
8386        stage_dir.sync_all()?;
8387        Ok(())
8388    })();
8389    if let Err(error) = prepared {
8390        let _ = remove_tree_at(
8391            parent_dir.as_raw_fd(),
8392            &stage_name,
8393            &dest.display().to_string(),
8394        );
8395        return Err(error);
8396    }
8397    if let Err(error) =
8398        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
8399    {
8400        let _ = remove_tree_at(
8401            parent_dir.as_raw_fd(),
8402            &stage_name,
8403            &dest.display().to_string(),
8404        );
8405        return Err(error);
8406    }
8407    parent_dir.sync_all()?;
8408    if dest_exists {
8409        let _ = remove_tree_at(
8410            parent_dir.as_raw_fd(),
8411            &stage_name,
8412            &dest.display().to_string(),
8413        );
8414        let _ = parent_dir.sync_all();
8415    }
8416    Ok(())
8417}
8418
8419#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
8420struct V2PullCoordinate {
8421    head_seq: Option<u64>,
8422    commit_hash: Option<String>,
8423    view_kind: Option<String>,
8424    view_revision: Option<String>,
8425}
8426
8427#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
8428struct V2PullFileCoordinate {
8429    sha256: String,
8430    bytes: u64,
8431}
8432
8433#[derive(Debug, Clone, Deserialize, Serialize)]
8434struct V2PullJournalEntry {
8435    path: String,
8436    old: Option<V2PullFileCoordinate>,
8437    new: Option<V2PullFileCoordinate>,
8438    backup: Option<String>,
8439}
8440
8441#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
8442#[serde(rename_all = "snake_case")]
8443enum V2PullPhase {
8444    Preparing,
8445    Ready,
8446}
8447
8448#[derive(Debug, Clone, Deserialize, Serialize)]
8449struct V2PullJournal {
8450    v: u8,
8451    phase: V2PullPhase,
8452    brain: String,
8453    previous: V2PullCoordinate,
8454    next: V2PullCoordinate,
8455    backup_dir: String,
8456    entries: Vec<V2PullJournalEntry>,
8457}
8458
8459const V2_PULL_JOURNAL: &str = ".dbmd/pull-journal.json";
8460
8461fn v2_pull_baseline_coordinate(baseline: Option<&V2SyncBaseline>) -> V2PullCoordinate {
8462    V2PullCoordinate {
8463        head_seq: baseline.and_then(|value| value.head_seq),
8464        commit_hash: baseline.and_then(|value| value.commit_hash.clone()),
8465        view_kind: baseline.and_then(|value| value.view_kind.clone()),
8466        view_revision: baseline.and_then(|value| value.view_revision.clone()),
8467    }
8468}
8469
8470fn v2_pull_head_coordinate(head: &V2VerifiedHead) -> V2PullCoordinate {
8471    V2PullCoordinate {
8472        head_seq: head.pointer.as_ref().map(|value| value.seq),
8473        commit_hash: head.pointer.as_ref().map(|value| value.commit_hash.clone()),
8474        view_kind: Some(head.view_kind.clone()),
8475        view_revision: Some(head.view_revision.clone()),
8476    }
8477}
8478
8479fn v2_pull_file_coordinate(
8480    store: &Store,
8481    path: &str,
8482    limit: u64,
8483) -> LinkResult<Option<V2PullFileCoordinate>> {
8484    let file = match store.open_regular(Path::new(path)) {
8485        Ok(file) => file,
8486        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
8487        Err(error) => return Err(error.into()),
8488    };
8489    let bytes = file.metadata()?.len();
8490    if bytes > limit || bytes > MAX_STORE_BYTES {
8491        return Err(invalid_feed(
8492            "pull transaction file exceeds its declared bound",
8493        ));
8494    }
8495    Ok(Some(V2PullFileCoordinate {
8496        sha256: content_sha256_reader(file)?,
8497        bytes,
8498    }))
8499}
8500
8501fn v2_pull_journal_bytes(journal: &V2PullJournal) -> LinkResult<Vec<u8>> {
8502    let mut bytes = serde_json::to_vec_pretty(journal)
8503        .map_err(|_| invalid_feed("could not serialize v2 pull journal"))?;
8504    bytes.push(b'\n');
8505    Ok(bytes)
8506}
8507
8508fn validate_v2_pull_journal(journal: &V2PullJournal) -> LinkResult<()> {
8509    let backup_prefix = ".dbmd/pull-backup-";
8510    let suffix = journal
8511        .backup_dir
8512        .strip_prefix(backup_prefix)
8513        .ok_or_else(|| invalid_feed("v2 pull journal backup address is invalid"))?;
8514    let mut paths = std::collections::BTreeSet::new();
8515    if journal.v != 1
8516        || !crate::ulid::is_ulid(&journal.brain)
8517        || !crate::ulid::is_ulid(suffix)
8518        || journal.entries.is_empty()
8519        || journal.entries.len() > MAX_PUSH_FILES + 4
8520        || journal.previous == journal.next
8521    {
8522        return Err(invalid_feed("v2 pull journal failed validation"));
8523    }
8524    for (index, entry) in journal.entries.iter().enumerate() {
8525        if !safe_store_rel_path(&entry.path)
8526            || entry.path == V2_PULL_JOURNAL
8527            || entry.path.starts_with(backup_prefix)
8528            || !paths.insert(entry.path.clone())
8529            || (entry.old.is_none() && entry.new.is_none())
8530            || entry
8531                .old
8532                .iter()
8533                .chain(entry.new.iter())
8534                .any(|value| !is_sha256(&value.sha256) || value.bytes > MAX_STORE_BYTES)
8535            || entry.backup.as_deref()
8536                != entry
8537                    .old
8538                    .as_ref()
8539                    .map(|_| format!("{index:08x}"))
8540                    .as_deref()
8541        {
8542            return Err(invalid_feed("v2 pull journal entry failed validation"));
8543        }
8544    }
8545    Ok(())
8546}
8547
8548fn load_v2_pull_journal(store: &Store) -> LinkResult<Option<V2PullJournal>> {
8549    #[cfg(unix)]
8550    {
8551        use std::os::unix::fs::PermissionsExt as _;
8552        match store.regular_metadata(Path::new(V2_PULL_JOURNAL)) {
8553            Ok(metadata) if metadata.permissions().mode() & 0o077 != 0 => {
8554                return Err(invalid_feed(
8555                    "v2 pull journal is accessible to group/other; set mode 0600",
8556                ));
8557            }
8558            Ok(_) => {}
8559            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
8560            Err(error) => return Err(error.into()),
8561        }
8562    }
8563    let bytes = match store.read_bounded(Path::new(V2_PULL_JOURNAL), 64 * 1024 * 1024) {
8564        Ok(bytes) => bytes,
8565        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
8566        Err(error) => return Err(error.into()),
8567    };
8568    let journal: V2PullJournal =
8569        serde_json::from_slice(&bytes).map_err(|_| invalid_feed("v2 pull journal is corrupt"))?;
8570    validate_v2_pull_journal(&journal)?;
8571    Ok(Some(journal))
8572}
8573
8574fn cleanup_v2_pull_journal(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
8575    // The journal is the only recovery authority. Remove it first only after
8576    // rollback or baseline commit is durable; an orphan private backup is safe
8577    // and can be pruned, while a journal without its backups is not recoverable.
8578    match store.remove_file(Path::new(V2_PULL_JOURNAL)) {
8579        Ok(()) => {}
8580        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
8581        Err(error) => return Err(error.into()),
8582    }
8583    match store.remove_private_tree(Path::new(&journal.backup_dir)) {
8584        Ok(()) => Ok(()),
8585        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
8586        Err(error) => Err(error.into()),
8587    }
8588}
8589
8590fn prune_orphan_v2_pull_backups(store: &Store) -> LinkResult<()> {
8591    let names = match store.directory_names(Path::new(".dbmd")) {
8592        Ok(names) => names,
8593        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
8594        Err(error) => return Err(error.into()),
8595    };
8596    for name in names {
8597        let Some(name) = name.to_str() else {
8598            continue;
8599        };
8600        let Some(suffix) = name.strip_prefix("pull-backup-") else {
8601            continue;
8602        };
8603        if crate::ulid::is_ulid(suffix) {
8604            store.remove_private_tree(&Path::new(".dbmd").join(name))?;
8605        }
8606    }
8607    Ok(())
8608}
8609
8610fn rollback_v2_pull(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
8611    // Validate every live coordinate and every backup before the first restore.
8612    for entry in &journal.entries {
8613        let limit = entry
8614            .old
8615            .as_ref()
8616            .into_iter()
8617            .chain(entry.new.iter())
8618            .map(|value| value.bytes)
8619            .max()
8620            .unwrap_or(0);
8621        let current = v2_pull_file_coordinate(store, &entry.path, limit)?;
8622        if current != entry.old && current != entry.new {
8623            return Err(LinkError::InvalidPack {
8624                message: format!(
8625                    "cannot recover interrupted pull because `{}` changed afterward",
8626                    entry.path
8627                ),
8628            });
8629        }
8630        if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
8631            let path = Path::new(&journal.backup_dir).join(backup);
8632            let file = store.open_regular(&path)?;
8633            if file.metadata()?.len() != old.bytes || content_sha256_reader(file)? != old.sha256 {
8634                return Err(invalid_feed("v2 pull recovery backup failed verification"));
8635            }
8636        }
8637    }
8638    for entry in journal.entries.iter().rev() {
8639        match (&entry.old, &entry.backup) {
8640            (Some(old), Some(backup)) => {
8641                let bytes =
8642                    store.read_bounded(&Path::new(&journal.backup_dir).join(backup), old.bytes)?;
8643                store.write_atomic(Path::new(&entry.path), &bytes)?;
8644            }
8645            (None, None) if store.regular_file_exists(Path::new(&entry.path))? => {
8646                store.remove_file(Path::new(&entry.path))?;
8647            }
8648            (None, None) => {}
8649            _ => return Err(invalid_feed("v2 pull recovery entry is inconsistent")),
8650        }
8651    }
8652    crate::index::Index::rebuild_all(store).map_err(|error| LinkError::InvalidPack {
8653        message: format!("could not rebuild catalogs after pull recovery: {error}"),
8654    })?;
8655    cleanup_v2_pull_journal(store, journal)
8656}
8657
8658fn recover_v2_pull(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<()> {
8659    let Ok(store) = Store::open_strict(dest) else {
8660        return Ok(());
8661    };
8662    if let Some(journal) = load_v2_pull_journal(&store)? {
8663        if journal.brain != brain {
8664            return Err(invalid_feed("v2 pull journal belongs to another brain"));
8665        }
8666        if journal.phase == V2PullPhase::Preparing {
8667            cleanup_v2_pull_journal(&store, &journal)?;
8668        } else {
8669            let baseline = load_v2_baseline(cfg, brain, dest)?;
8670            let current = v2_pull_baseline_coordinate(baseline.as_ref());
8671            if current == journal.next {
8672                cleanup_v2_pull_journal(&store, &journal)?;
8673            } else {
8674                if current != journal.previous {
8675                    return Err(invalid_feed(
8676                        "cannot recover interrupted pull because its baseline changed afterward",
8677                    ));
8678                }
8679                rollback_v2_pull(&store, &journal)?;
8680            }
8681        }
8682    }
8683    // Cleanup removes the journal first after the state is durably old or new.
8684    // A hard kill in the tiny interval before deleting its backup directory can
8685    // therefore leave only an inert, private orphan. The per-brain operation
8686    // lock is held by the caller, so no live pull can own one here.
8687    prune_orphan_v2_pull_backups(&store)
8688}
8689
8690fn complete_v2_pull(dest: &Path) -> LinkResult<()> {
8691    let store = Store::open_strict(dest).map_err(|error| LinkError::InvalidPack {
8692        message: format!("installed v2 checkout is not a valid db.md store: {error}"),
8693    })?;
8694    if let Some(journal) = load_v2_pull_journal(&store)? {
8695        cleanup_v2_pull_journal(&store, &journal)?;
8696    }
8697    Ok(())
8698}
8699
8700#[cfg(windows)]
8701fn install_windows_initial_sources(
8702    dest: &Path,
8703    entries: &[V2StagedFile],
8704    rebuild_indexes: bool,
8705) -> LinkResult<()> {
8706    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
8707    let name = dest.file_name().ok_or_else(|| LinkError::UnsafePath {
8708        path: dest.display().to_string(),
8709    })?;
8710    let parent_capability = crate::fsx::open_or_create_directory_nofollow(parent)?;
8711    if crate::fsx::directory_exists_beneath(&parent_capability, Path::new(name))? {
8712        return Err(LinkError::UnsafePath {
8713            path: dest.display().to_string(),
8714        });
8715    }
8716    let stage_name = format!(
8717        ".{}.dbmd-pull-stage-{}",
8718        name.to_string_lossy(),
8719        crate::ulid::mint()
8720    );
8721    let stage_path = parent.join(&stage_name);
8722    let stage_capability =
8723        crate::fsx::open_directory_beneath(&parent_capability, Path::new(&stage_name), true)?;
8724    let stage = Store::from_root_and_config(&stage_path, crate::Config::default())?;
8725    let prepared = (|| -> LinkResult<()> {
8726        for entry in entries {
8727            let bytes = crate::fsx::read_bounded_nofollow(&entry.source, entry.bytes)?;
8728            if bytes.len() as u64 != entry.bytes || content_sha256(&bytes) != entry.sha256 {
8729                return Err(invalid_feed(
8730                    "private staged sync source failed final integrity verification",
8731                ));
8732            }
8733            stage.write_atomic(Path::new(&entry.path), &bytes)?;
8734        }
8735        let strict = Store::from_held_root_strict(&stage_path, stage_capability.try_clone()?)
8736            .map_err(|error| LinkError::InvalidPack {
8737                message: format!("v2 staging tree is not a valid db.md store: {error}"),
8738            })?;
8739        if rebuild_indexes {
8740            crate::index::Index::rebuild_all(&strict).map_err(|error| LinkError::InvalidPack {
8741                message: format!("could not materialize v2 local catalogs: {error}"),
8742            })?;
8743        }
8744        Ok(())
8745    })();
8746    if let Err(error) = prepared {
8747        let _ = crate::fsx::remove_tree_beneath(&parent_capability, Path::new(&stage_name));
8748        return Err(error);
8749    }
8750    crate::fsx::rename_directory_beneath(
8751        &parent_capability,
8752        Path::new(&stage_name),
8753        Path::new(name),
8754    )?;
8755    Ok(())
8756}
8757
8758fn install_established_v2_delta(
8759    store: Store,
8760    entries: &[V2StagedFile],
8761    deleted: &[String],
8762    rebuild_indexes: bool,
8763    previous: Option<&V2SyncBaseline>,
8764    next: &V2VerifiedHead,
8765) -> LinkResult<()> {
8766    if load_v2_pull_journal(&store)?.is_some() {
8767        return Err(invalid_feed(
8768            "an interrupted pull must be recovered before installing",
8769        ));
8770    }
8771    let mut sources = std::collections::BTreeMap::new();
8772    for entry in entries {
8773        if sources.insert(entry.path.clone(), entry).is_some() || deleted.contains(&entry.path) {
8774            return Err(invalid_feed("pull mutation repeats a path"));
8775        }
8776    }
8777    let mut paths = sources.keys().cloned().collect::<Vec<_>>();
8778    paths.extend(deleted.iter().cloned());
8779    paths.sort();
8780    paths.dedup();
8781    if paths.is_empty() {
8782        return Ok(());
8783    }
8784    let backup_dir = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
8785    let mut journal = V2PullJournal {
8786        v: 1,
8787        phase: V2PullPhase::Preparing,
8788        brain: next.brain_id.clone(),
8789        previous: v2_pull_baseline_coordinate(previous),
8790        next: v2_pull_head_coordinate(next),
8791        backup_dir: backup_dir.clone(),
8792        entries: Vec::with_capacity(paths.len()),
8793    };
8794    for path in &paths {
8795        let old = v2_pull_file_coordinate(&store, path, MAX_STORE_BYTES)?;
8796        let new = sources.get(path).map(|entry| V2PullFileCoordinate {
8797            sha256: entry.sha256.clone(),
8798            bytes: entry.bytes,
8799        });
8800        if old == new {
8801            continue;
8802        }
8803        let index = journal.entries.len();
8804        journal.entries.push(V2PullJournalEntry {
8805            path: path.clone(),
8806            backup: old.as_ref().map(|_| format!("{index:08x}")),
8807            old,
8808            new,
8809        });
8810    }
8811    if journal.entries.is_empty() {
8812        return Ok(());
8813    }
8814    let backup_bytes = journal.entries.iter().try_fold(0_u64, |total, entry| {
8815        entry
8816            .old
8817            .as_ref()
8818            .map_or(Some(total), |old| total.checked_add(old.bytes))
8819    });
8820    if backup_bytes.is_none_or(|bytes| bytes > MAX_STORE_BYTES) {
8821        return Err(LinkError::InvalidPack {
8822            message: "pull recovery preimages exceed the 512 MB transaction limit".to_string(),
8823        });
8824    }
8825    validate_v2_pull_journal(&journal)?;
8826    store.write_private_atomic_new(
8827        Path::new(V2_PULL_JOURNAL),
8828        &v2_pull_journal_bytes(&journal)?,
8829    )?;
8830    let prepared = (|| -> LinkResult<()> {
8831        store.create_private_dir_all(Path::new(&backup_dir))?;
8832        for entry in &journal.entries {
8833            if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
8834                let bytes = store.read_bounded(Path::new(&entry.path), old.bytes)?;
8835                if content_sha256(&bytes) != old.sha256 {
8836                    return Err(invalid_feed("live pull source changed during backup"));
8837                }
8838                store.write_private_atomic_new(&Path::new(&backup_dir).join(backup), &bytes)?;
8839            }
8840        }
8841        journal.phase = V2PullPhase::Ready;
8842        store.write_private_atomic(
8843            Path::new(V2_PULL_JOURNAL),
8844            &v2_pull_journal_bytes(&journal)?,
8845        )?;
8846        Ok(())
8847    })();
8848    if let Err(error) = prepared {
8849        let cleanup = cleanup_v2_pull_journal(&store, &journal);
8850        return match cleanup {
8851            Ok(()) => Err(error),
8852            Err(cleanup) => Err(LinkError::InvalidPack {
8853                message: format!("{error}; recovery metadata cleanup also failed: {cleanup}"),
8854            }),
8855        };
8856    }
8857    let installed = (|| -> LinkResult<()> {
8858        for entry in &journal.entries {
8859            if v2_pull_file_coordinate(&store, &entry.path, MAX_STORE_BYTES)? != entry.old {
8860                return Err(LinkError::InvalidPack {
8861                    message: format!("local path `{}` changed during pull", entry.path),
8862                });
8863            }
8864            if let Some(source) = sources.get(&entry.path) {
8865                let bytes = crate::fsx::read_bounded_nofollow(&source.source, source.bytes)?;
8866                if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != source.sha256 {
8867                    return Err(invalid_feed(
8868                        "private staged sync source failed final integrity verification",
8869                    ));
8870                }
8871                store.write_atomic(Path::new(&entry.path), &bytes)?;
8872            } else if entry.old.is_some() {
8873                store.remove_file(Path::new(&entry.path))?;
8874            }
8875        }
8876        if rebuild_indexes {
8877            crate::index::Index::rebuild_all(&store).map_err(|error| LinkError::InvalidPack {
8878                message: format!("could not materialize v2 local catalogs: {error}"),
8879            })?;
8880        }
8881        Ok(())
8882    })();
8883    if let Err(error) = installed {
8884        return match rollback_v2_pull(&store, &journal) {
8885            Ok(()) => Err(error),
8886            Err(rollback) => Err(LinkError::InvalidPack {
8887                message: format!("{error}; durable pull rollback also failed: {rollback}"),
8888            }),
8889        };
8890    }
8891    Ok(())
8892}
8893
8894#[cfg(windows)]
8895fn install_pulled_delta_sources(
8896    dest: &Path,
8897    entries: &[V2StagedFile],
8898    deleted: &[String],
8899    rebuild_indexes: bool,
8900    previous: Option<&V2SyncBaseline>,
8901    next: &V2VerifiedHead,
8902) -> LinkResult<()> {
8903    match Store::open_strict(dest) {
8904        Ok(store) => {
8905            install_established_v2_delta(store, entries, deleted, rebuild_indexes, previous, next)
8906        }
8907        Err(_) => install_windows_initial_sources(dest, entries, rebuild_indexes),
8908    }
8909}
8910
8911#[cfg(not(any(unix, windows)))]
8912fn install_pulled_delta_sources(
8913    _dest: &Path,
8914    _entries: &[V2StagedFile],
8915    _deleted: &[String],
8916    _rebuild_indexes: bool,
8917    _previous: Option<&V2SyncBaseline>,
8918    _next: &V2VerifiedHead,
8919) -> LinkResult<()> {
8920    Err(LinkError::UnsupportedPlatform {
8921        operation: "atomic v2 pull install",
8922    })
8923}
8924
8925#[cfg(unix)]
8926fn install_pulled_snapshot(dest: &Path, entries: &[(String, Vec<u8>)]) -> LinkResult<()> {
8927    install_pulled_delta(dest, entries, &[], false)
8928}
8929
8930#[cfg(not(windows))]
8931fn is_safe_slug(slug: &str) -> bool {
8932    !slug.is_empty()
8933        && slug.len() <= 63
8934        && !slug.starts_with('-')
8935        && !slug.ends_with('-')
8936        && slug
8937            .bytes()
8938            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
8939}
8940
8941fn le_u16(bytes: &[u8], at: usize) -> Option<u16> {
8942    Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
8943}
8944
8945fn le_u32(bytes: &[u8], at: usize) -> Option<u32> {
8946    Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
8947}
8948
8949fn le_u64(bytes: &[u8], at: usize) -> Option<u64> {
8950    Some(u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?))
8951}
8952
8953fn preflight_zip_central_directory(
8954    bytes: &[u8],
8955    offset: usize,
8956    size: usize,
8957    count: u64,
8958) -> LinkResult<()> {
8959    const CENTRAL_ENTRY_SIG: &[u8; 4] = b"PK\x01\x02";
8960    let end = offset
8961        .checked_add(size)
8962        .filter(|end| *end <= bytes.len())
8963        .ok_or_else(|| LinkError::InvalidPack {
8964            message: "ZIP central directory is out of bounds".to_string(),
8965        })?;
8966    let mut cursor = offset;
8967    for _ in 0..count {
8968        if bytes.get(cursor..cursor + 4) != Some(CENTRAL_ENTRY_SIG.as_slice()) {
8969            return Err(LinkError::InvalidPack {
8970                message: "ZIP central directory entry count is inconsistent".to_string(),
8971            });
8972        }
8973        if le_u16(bytes, cursor + 34) != Some(0) {
8974            return Err(LinkError::InvalidPack {
8975                message: "multi-disk ZIP archives are not supported".to_string(),
8976            });
8977        }
8978        let variable = [28, 30, 32].into_iter().try_fold(0usize, |total, at| {
8979            total.checked_add(le_u16(bytes, cursor + at)? as usize)
8980        });
8981        cursor = cursor
8982            .checked_add(46)
8983            .and_then(|fixed| fixed.checked_add(variable?))
8984            .filter(|cursor| *cursor <= end)
8985            .ok_or_else(|| LinkError::InvalidPack {
8986                message: "ZIP central directory entry is truncated".to_string(),
8987            })?;
8988    }
8989    if cursor != end {
8990        return Err(LinkError::InvalidPack {
8991            message: "ZIP central directory size is inconsistent".to_string(),
8992        });
8993    }
8994    Ok(())
8995}
8996
8997/// Read only the bounded ZIP trailer before `ZipArchive::new` allocates one
8998/// metadata object per central-directory entry. Supports ordinary EOCD and the
8999/// Zip64 locator/record emitted for >65,535-entry archives.
9000fn preflight_zip_entry_count(bytes: &[u8], max_entries: usize) -> LinkResult<()> {
9001    const EOCD_SIG: &[u8; 4] = b"PK\x05\x06";
9002    const ZIP64_LOCATOR_SIG: &[u8; 4] = b"PK\x06\x07";
9003    const ZIP64_EOCD_SIG: &[u8; 4] = b"PK\x06\x06";
9004    let search_start = bytes.len().saturating_sub(22 + u16::MAX as usize);
9005    let eocd = bytes[search_start..]
9006        .windows(4)
9007        .rposition(|window| window == EOCD_SIG)
9008        .map(|offset| search_start + offset)
9009        .ok_or_else(|| LinkError::InvalidPack {
9010            message: "ZIP has no end-of-central-directory record".to_string(),
9011        })?;
9012    let invalid_end = || LinkError::InvalidPack {
9013        message: "ZIP has an invalid end-of-central-directory structure".to_string(),
9014    };
9015    let comment_len = le_u16(bytes, eocd + 20).ok_or_else(invalid_end)? as usize;
9016    if eocd
9017        .checked_add(22)
9018        .and_then(|end| end.checked_add(comment_len))
9019        != Some(bytes.len())
9020    {
9021        // Do not fall back to an earlier signature. ZipArchive does, and a
9022        // fake low-count EOCD appended after a real Zip64 entry-count bomb
9023        // would otherwise bypass this allocation preflight.
9024        return Err(invalid_end());
9025    }
9026    let disk = le_u16(bytes, eocd + 4);
9027    let central_disk = le_u16(bytes, eocd + 6);
9028    if disk != Some(0) || central_disk != Some(0) {
9029        return Err(LinkError::InvalidPack {
9030            message: "multi-disk ZIP archives are not supported".to_string(),
9031        });
9032    }
9033    let entries_on_disk = le_u16(bytes, eocd + 8).ok_or_else(invalid_end)?;
9034    let ordinary = le_u16(bytes, eocd + 10).ok_or_else(invalid_end)?;
9035    if entries_on_disk != ordinary {
9036        return Err(LinkError::InvalidPack {
9037            message: "multi-disk ZIP archives are not supported".to_string(),
9038        });
9039    }
9040    let zip64_locator = eocd
9041        .checked_sub(20)
9042        .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_LOCATOR_SIG.as_slice()));
9043    let (count, central_offset, central_size) = if ordinary != u16::MAX || zip64_locator.is_none() {
9044        let central_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)? as usize;
9045        let central_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)? as usize;
9046        if central_offset
9047            .checked_add(central_size)
9048            .filter(|end| *end == eocd)
9049            .is_none()
9050        {
9051            return Err(invalid_end());
9052        }
9053        (ordinary as u64, central_offset, central_size)
9054    } else {
9055        let Some(locator) = zip64_locator else {
9056            return Err(invalid_end());
9057        };
9058        if le_u32(bytes, locator + 4) != Some(0) || le_u32(bytes, locator + 16) != Some(1) {
9059            return Err(LinkError::InvalidPack {
9060                message: "multi-disk ZIP64 archives are not supported".to_string(),
9061            });
9062        }
9063        let record = le_u64(bytes, locator + 8)
9064            .and_then(|offset| usize::try_from(offset).ok())
9065            .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_EOCD_SIG.as_slice()))
9066            .ok_or_else(|| LinkError::InvalidPack {
9067                message: "ZIP64 archive has an invalid end record".to_string(),
9068            })?;
9069        let record_size = le_u64(bytes, record + 4)
9070            .and_then(|size| usize::try_from(size).ok())
9071            .filter(|size| *size >= 44)
9072            .ok_or_else(invalid_end)?;
9073        if record
9074            .checked_add(12)
9075            .and_then(|end| end.checked_add(record_size))
9076            != Some(locator)
9077            || le_u32(bytes, record + 16) != Some(0)
9078            || le_u32(bytes, record + 20) != Some(0)
9079        {
9080            return Err(invalid_end());
9081        }
9082        let zip64_on_disk = le_u64(bytes, record + 24).ok_or_else(invalid_end)?;
9083        let zip64_total = le_u64(bytes, record + 32).ok_or_else(invalid_end)?;
9084        let central_size = le_u64(bytes, record + 40)
9085            .and_then(|size| usize::try_from(size).ok())
9086            .ok_or_else(invalid_end)?;
9087        let central_offset = le_u64(bytes, record + 48)
9088            .and_then(|offset| usize::try_from(offset).ok())
9089            .ok_or_else(invalid_end)?;
9090        if zip64_on_disk != zip64_total
9091            || central_offset
9092                .checked_add(central_size)
9093                .filter(|end| *end == record)
9094                .is_none()
9095        {
9096            return Err(invalid_end());
9097        }
9098        let legacy_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)?;
9099        let legacy_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)?;
9100        if (legacy_size != u32::MAX && legacy_size as usize != central_size)
9101            || (legacy_offset != u32::MAX && legacy_offset as usize != central_offset)
9102        {
9103            return Err(invalid_end());
9104        }
9105        (zip64_total, central_offset, central_size)
9106    };
9107    if count == 0 || count > max_entries as u64 {
9108        return Err(LinkError::InvalidPack {
9109            message: format!("invalid file count {count}"),
9110        });
9111    }
9112    preflight_zip_central_directory(bytes, central_offset, central_size, count)?;
9113    Ok(())
9114}
9115
9116fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
9117    preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)?;
9118    let mut archive =
9119        zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
9120            message: format!("ZIP parse failed: {err}"),
9121        })?;
9122    if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
9123        return Err(LinkError::InvalidPack {
9124            message: format!("invalid file count {}", archive.len()),
9125        });
9126    }
9127    let mut total = 0u64;
9128    let mut seen = std::collections::HashSet::new();
9129    let mut entries = Vec::with_capacity(archive.len());
9130    for index in 0..archive.len() {
9131        let mut file = archive
9132            .by_index(index)
9133            .map_err(|err| LinkError::InvalidPack {
9134                message: format!("ZIP entry failed: {err}"),
9135            })?;
9136        if file.is_dir() {
9137            continue;
9138        }
9139        let path = file.name().to_string();
9140        if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
9141            return Err(LinkError::UnsafePath { path });
9142        }
9143        if file
9144            .unix_mode()
9145            .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
9146        {
9147            return Err(LinkError::InvalidPack {
9148                message: format!("non-file entry `{path}`"),
9149            });
9150        }
9151        if !seen.insert(path.clone()) {
9152            return Err(LinkError::InvalidPack {
9153                message: format!("duplicate path `{path}`"),
9154            });
9155        }
9156        let remaining = MAX_STORE_BYTES.saturating_sub(total);
9157        if file.size() > remaining {
9158            return Err(LinkError::InvalidPack {
9159                message: "expanded content exceeds the 512 MB limit".to_string(),
9160            });
9161        }
9162        let mut content = Vec::new();
9163        (&mut file)
9164            .take(remaining + 1)
9165            .read_to_end(&mut content)
9166            .map_err(|err| LinkError::InvalidPack {
9167                message: format!("could not decompress `{path}`: {err}"),
9168            })?;
9169        if content.len() as u64 > remaining {
9170            return Err(LinkError::InvalidPack {
9171                message: "expanded content exceeds the 512 MB limit".to_string(),
9172            });
9173        }
9174        if content.len() as u64 != file.size() {
9175            return Err(LinkError::InvalidPack {
9176                message: format!("length mismatch for `{path}`"),
9177            });
9178        }
9179        total += content.len() as u64;
9180        entries.push((path, content));
9181    }
9182    if entries.is_empty() {
9183        return Err(LinkError::InvalidPack {
9184            message: "pack contains no files".to_string(),
9185        });
9186    }
9187    Ok(entries)
9188}
9189
9190fn verify_snapshot_manifest(entries: &[(String, Vec<u8>)], signed: &[FeedFile]) -> LinkResult<()> {
9191    let mut expected = std::collections::BTreeMap::new();
9192    for file in signed {
9193        if !safe_store_rel_path(&file.path) {
9194            return Err(LinkError::UnsafePath {
9195                path: file.path.clone(),
9196            });
9197        }
9198        if !is_sha256(&file.sha256)
9199            || expected
9200                .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
9201                .is_some()
9202        {
9203            return Err(invalid_feed(
9204                "signed snapshot manifest contains an invalid or duplicate file",
9205            ));
9206        }
9207    }
9208    if expected.len() != entries.len() {
9209        return Err(invalid_feed(
9210            "downloaded pack file set differs from the signed snapshot manifest",
9211        ));
9212    }
9213    for (path, bytes) in entries {
9214        let Some((sha256, declared_bytes)) = expected.get(path.as_str()) else {
9215            return Err(invalid_feed(format!(
9216                "downloaded pack contains unsigned path `{path}`"
9217            )));
9218        };
9219        if *declared_bytes != bytes.len() as u64
9220            || *sha256 != format!("{:x}", Sha256::digest(bytes))
9221        {
9222            return Err(invalid_feed(format!(
9223                "downloaded file `{path}` differs from its signed manifest"
9224            )));
9225        }
9226    }
9227    Ok(())
9228}
9229
9230/// Collect the files a push sends: the store's owned text — `DB.md`,
9231/// `assets.jsonl` when present, and every content `.md` under `records/` and
9232/// `sources/` (the store walk, which already excludes hidden dirs like
9233/// `.dbmd/`, the `log/` archive, and derived `index.*` catalogs; the hub
9234/// derives its own index, and local history stays local). Returns
9235/// `(store-relative path, content)` pairs, path-sorted.
9236pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
9237    require_hardened_filesystem("sync push")?;
9238    preflight_push_ownership(store)?;
9239    let mut out: Vec<(String, String)> = Vec::new();
9240    let mut total = 0u64;
9241
9242    let mut read_text = |rel: &str| -> LinkResult<String> {
9243        let bytes = store.read_bounded(Path::new(rel), MAX_STORE_BYTES.saturating_sub(total))?;
9244        total = total
9245            .checked_add(bytes.len() as u64)
9246            .ok_or_else(|| LinkError::PushTooLarge {
9247                detail: "uncompressed byte count overflow".to_string(),
9248            })?;
9249        if total > MAX_STORE_BYTES {
9250            return Err(LinkError::PushTooLarge {
9251                detail: format!("{total} uncompressed bytes"),
9252            });
9253        }
9254        String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
9255            path: rel.to_string(),
9256        })
9257    };
9258
9259    out.push(("DB.md".to_string(), read_text("DB.md")?));
9260    if store
9261        .regular_file_exists(Path::new("assets.jsonl"))
9262        .unwrap_or(false)
9263    {
9264        out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
9265    }
9266
9267    for rel in store.walk()? {
9268        let rel_str = rel.to_string_lossy().replace('\\', "/");
9269        if !safe_store_rel_path(&rel_str) {
9270            // A locally-legal name outside the hub's portable charset cannot
9271            // travel this wire; refusing beats silently dropping it.
9272            return Err(LinkError::UnsafePath { path: rel_str });
9273        }
9274        let content = read_text(&rel_str)?;
9275        out.push((rel_str, content));
9276    }
9277
9278    out.sort_by(|a, b| a.0.cmp(&b.0));
9279    Ok(out)
9280}
9281
9282/// Refuse to build a destructive whole-store snapshot from an ambiguous local
9283/// tree. Ordinary read-only walks safely prune foreign paths, but a push that
9284/// silently omitted them could delete the hosted copies of those paths.
9285fn preflight_push_ownership(store: &Store) -> LinkResult<()> {
9286    if let Some(nested) = store.nested_store_roots()?.into_iter().next() {
9287        return Err(LinkError::from(std::io::Error::new(
9288            std::io::ErrorKind::PermissionDenied,
9289            format!("cannot push: nested db.md store at {}", nested.display()),
9290        )));
9291    }
9292
9293    if let Some(symlink) = store.unowned_symlinks()?.into_iter().next() {
9294        return Err(LinkError::from(std::io::Error::new(
9295            std::io::ErrorKind::PermissionDenied,
9296            format!(
9297                "cannot push: {} is a symlink outside the store ownership model",
9298                symlink.display()
9299            ),
9300        )));
9301    }
9302    Ok(())
9303}
9304
9305/// Push `files` to `brain` as a whole-store snapshot — the hub's push
9306/// semantics: the hosted copy becomes exactly this set (pull first if the
9307/// hosted side may have records the local copy lacks). Client-side caps
9308/// mirror the hub's JSON-path limits so an oversized push fails before the
9309/// upload.
9310pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
9311    require_safe_ref(brain)?;
9312    let remote = verified_remote_head(cfg, brain, false)?;
9313    if files.len() > MAX_PUSH_FILES {
9314        return Err(LinkError::PushTooLarge {
9315            detail: format!("{} files", files.len()),
9316        });
9317    }
9318    let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
9319    if raw_total > MAX_STORE_BYTES {
9320        return Err(LinkError::PushTooLarge {
9321            detail: format!("{raw_total} uncompressed bytes"),
9322        });
9323    }
9324
9325    // Self-custody (a brain key is configured): the JSON fast path is
9326    // hub-signed by construction, so every push goes through the pack flow
9327    // with a locally signed entry — the hub verifies and can never sign.
9328    if cfg.brain_key.is_none() {
9329        let body = json!({
9330            "files": files
9331                .iter()
9332                .map(|(p, c)| json!({ "path": p, "content": c }))
9333                .collect::<Vec<_>>(),
9334        });
9335        if body.to_string().len() <= MAX_PUSH_BYTES {
9336            let path = format!("/api/hub/brains/{brain}/push");
9337            let pushed = ensure_ok(
9338                request(cfg, "POST", &path, Some(&body), Auth::Required)?,
9339                "sync push",
9340            )?;
9341            return Ok(pushed);
9342        }
9343    }
9344
9345    let pack = build_store_pack(files)?;
9346    if pack.len() as u64 > MAX_PACK_BYTES {
9347        return Err(LinkError::PushTooLarge {
9348            detail: format!("{} pack bytes", pack.len()),
9349        });
9350    }
9351    let sha256 = format!("{:x}", Sha256::digest(&pack));
9352    let mut meta = json!({ "sha256": sha256, "bytes": pack.len() });
9353    if let Some(key) = &cfg.brain_key {
9354        if !remote.head.verified {
9355            return Err(invalid_feed(
9356                "self-custody push requires a fully verified, unscoped feed head",
9357            ));
9358        }
9359        let identity = remote
9360            .identity
9361            .as_ref()
9362            .ok_or_else(|| invalid_feed("verified brain has no current identity"))?;
9363        let current_multikey = format!("ed25519:{}", identity.fingerprint);
9364        if key.multikey != current_multikey || key.public_key_spki != identity.public_key_spki {
9365            return Err(invalid_feed(
9366                "configured brain key is not the verified current brain identity",
9367            ));
9368        }
9369        // The verified state pins seq + prev; a concurrent writer surfaces as
9370        // the hub's 422 on commit (re-run to retry against the new head).
9371        let next_seq = remote
9372            .head
9373            .seq
9374            .checked_add(1)
9375            .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
9376        let mut manifest: Vec<WireFeedFile> = files
9377            .iter()
9378            .map(|(path, content)| WireFeedFile {
9379                path: path.clone(),
9380                sha256: format!("{:x}", Sha256::digest(content.as_bytes())),
9381                bytes: content.len() as u64,
9382            })
9383            .collect();
9384        manifest.sort_by(|a, b| a.path.cmp(&b.path));
9385        let ts = crate::now()
9386            .with_timezone(&chrono::Utc)
9387            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
9388            .to_string();
9389        let entry = self_custody_entry(
9390            key,
9391            next_seq,
9392            ts,
9393            &sha256,
9394            &manifest,
9395            remote.head.feed_hash.as_deref(),
9396        )?;
9397        meta["entry"] = Value::String(entry);
9398    }
9399    let presigned = ensure_ok(
9400        request(
9401            cfg,
9402            "POST",
9403            &format!("/api/hub/brains/{brain}/packs/presign"),
9404            Some(&meta),
9405            Auth::Required,
9406        )?,
9407        "prepare pack upload",
9408    )?;
9409    let url = presigned
9410        .get("url")
9411        .and_then(Value::as_str)
9412        .ok_or_else(|| LinkError::InvalidPack {
9413            message: "the hub returned no upload URL".to_string(),
9414        })?;
9415    put_presigned(
9416        cfg,
9417        url,
9418        presigned.get("headers").unwrap_or(&Value::Null),
9419        &pack,
9420    )?;
9421    let committed = ensure_ok(
9422        request(
9423            cfg,
9424            "POST",
9425            &format!("/api/hub/brains/{brain}/packs/commit"),
9426            Some(&meta),
9427            Auth::Required,
9428        )?,
9429        "commit pack",
9430    )?;
9431    Ok(committed)
9432}
9433
9434fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
9435    const LOCAL_HEADER: u32 = 0x0403_4b50;
9436    const CENTRAL_HEADER: u32 = 0x0201_4b50;
9437    const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
9438    const VERSION_20: u16 = 20;
9439    const MADE_BY_UNIX_20: u16 = (3 << 8) | VERSION_20;
9440    const UTF8_FLAG: u16 = 1 << 11;
9441    const STORED: u16 = 0;
9442    const DOS_TIME_MIDNIGHT: u16 = 0;
9443    const DOS_DATE_1980_01_01: u16 = (1 << 5) | 1;
9444    const UNIX_REGULAR_0600: u32 = 0o100600 << 16;
9445
9446    struct CentralEntry<'a> {
9447        name: &'a [u8],
9448        crc32: u32,
9449        size: u32,
9450        local_offset: u32,
9451    }
9452
9453    fn push_u16(out: &mut Vec<u8>, value: u16) {
9454        out.extend_from_slice(&value.to_le_bytes());
9455    }
9456
9457    fn push_u32(out: &mut Vec<u8>, value: u32) {
9458        out.extend_from_slice(&value.to_le_bytes());
9459    }
9460
9461    if files.is_empty() {
9462        return Err(LinkError::InvalidPack {
9463            message: "cannot create an empty snapshot pack".to_string(),
9464        });
9465    }
9466    if files.len() > u16::MAX as usize {
9467        return Err(LinkError::PushTooLarge {
9468            detail: format!(
9469                "{} files (canonical ZIP32 packs cap at {})",
9470                files.len(),
9471                u16::MAX
9472            ),
9473        });
9474    }
9475
9476    let mut sorted: Vec<_> = files.iter().collect();
9477    sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
9478    let mut previous: Option<&str> = None;
9479    for (path, content) in &sorted {
9480        if !safe_store_rel_path(path) {
9481            return Err(LinkError::UnsafePath {
9482                path: (*path).clone(),
9483            });
9484        }
9485        if previous == Some(path.as_str()) {
9486            return Err(LinkError::InvalidPack {
9487                message: format!("duplicate path `{path}`"),
9488            });
9489        }
9490        previous = Some(path.as_str());
9491        u32::try_from(content.len()).map_err(|_| LinkError::PushTooLarge {
9492            detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
9493        })?;
9494    }
9495
9496    let mut out = Vec::new();
9497    let mut central = Vec::with_capacity(sorted.len());
9498    for (path, content) in sorted {
9499        let name = path.as_bytes();
9500        let name_len = u16::try_from(name.len()).map_err(|_| LinkError::InvalidPack {
9501            message: format!("ZIP entry name is too long: `{path}`"),
9502        })?;
9503        let bytes = content.as_bytes();
9504        let size = u32::try_from(bytes.len()).map_err(|_| LinkError::PushTooLarge {
9505            detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
9506        })?;
9507        let local_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
9508            detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
9509        })?;
9510        let crc32 = crc32fast::hash(bytes);
9511
9512        // Canonical local header: sizes and CRC are known up front. Bit 3 is
9513        // deliberately clear, so there is no trailing data descriptor.
9514        push_u32(&mut out, LOCAL_HEADER);
9515        push_u16(&mut out, VERSION_20);
9516        push_u16(&mut out, UTF8_FLAG);
9517        push_u16(&mut out, STORED);
9518        push_u16(&mut out, DOS_TIME_MIDNIGHT);
9519        push_u16(&mut out, DOS_DATE_1980_01_01);
9520        push_u32(&mut out, crc32);
9521        push_u32(&mut out, size);
9522        push_u32(&mut out, size);
9523        push_u16(&mut out, name_len);
9524        push_u16(&mut out, 0); // no local extra data
9525        out.extend_from_slice(name);
9526        out.extend_from_slice(bytes);
9527
9528        central.push(CentralEntry {
9529            name,
9530            crc32,
9531            size,
9532            local_offset,
9533        });
9534    }
9535
9536    let central_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
9537        detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
9538    })?;
9539    for entry in &central {
9540        push_u32(&mut out, CENTRAL_HEADER);
9541        push_u16(&mut out, MADE_BY_UNIX_20);
9542        push_u16(&mut out, VERSION_20);
9543        push_u16(&mut out, UTF8_FLAG);
9544        push_u16(&mut out, STORED);
9545        push_u16(&mut out, DOS_TIME_MIDNIGHT);
9546        push_u16(&mut out, DOS_DATE_1980_01_01);
9547        push_u32(&mut out, entry.crc32);
9548        push_u32(&mut out, entry.size);
9549        push_u32(&mut out, entry.size);
9550        push_u16(&mut out, entry.name.len() as u16);
9551        push_u16(&mut out, 0); // no central extra data
9552        push_u16(&mut out, 0); // no file comment
9553        push_u16(&mut out, 0); // disk number
9554        push_u16(&mut out, 0); // internal attributes
9555        push_u32(&mut out, UNIX_REGULAR_0600);
9556        push_u32(&mut out, entry.local_offset);
9557        out.extend_from_slice(entry.name);
9558    }
9559    let central_size = u32::try_from(out.len())
9560        .ok()
9561        .and_then(|end| end.checked_sub(central_offset))
9562        .ok_or_else(|| LinkError::PushTooLarge {
9563            detail: "canonical ZIP32 central directory exceeds its limit".to_string(),
9564        })?;
9565    let entry_count = central.len() as u16;
9566
9567    push_u32(&mut out, END_OF_CENTRAL_DIRECTORY);
9568    push_u16(&mut out, 0); // this disk
9569    push_u16(&mut out, 0); // central directory disk
9570    push_u16(&mut out, entry_count);
9571    push_u16(&mut out, entry_count);
9572    push_u32(&mut out, central_size);
9573    push_u32(&mut out, central_offset);
9574    push_u16(&mut out, 0); // no archive comment
9575
9576    if out.len() > u32::MAX as usize {
9577        return Err(LinkError::PushTooLarge {
9578            detail: "canonical ZIP32 pack exceeds 4 GiB".to_string(),
9579        });
9580    }
9581    Ok(out)
9582}
9583
9584// ─────────────────────────────────────────────────────────────────────────────
9585// grant — issue / list / revoke capabilities (owner-side)
9586// ─────────────────────────────────────────────────────────────────────────────
9587
9588/// The two capabilities a v0 hub enforces.
9589#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9590pub enum Capability {
9591    /// Read the granted slice.
9592    Read,
9593    /// Read and push (whole-store; a path-scoped grant is read-only).
9594    Write,
9595}
9596
9597impl Capability {
9598    /// The wire form.
9599    pub fn as_str(self) -> &'static str {
9600        match self {
9601            Capability::Read => "read",
9602            Capability::Write => "write",
9603        }
9604    }
9605}
9606
9607/// Issue (or refresh) a grant on `brain` to `grantee` — a hub principal named
9608/// by email in v0 (the protocol's near-term simplification; key-named
9609/// grantees arrive with the signing layer). `scope` is a store-path prefix
9610/// (the hub's enforcement unit); `until` an ISO 8601 expiry, absent = until
9611/// revoked.
9612pub fn grant_issue(
9613    cfg: &HubConfig,
9614    brain: &str,
9615    grantee: &str,
9616    can: Capability,
9617    scope: Option<&str>,
9618    until: Option<&str>,
9619) -> LinkResult<Value> {
9620    require_safe_ref(brain)?;
9621    let _ = verified_remote_head(cfg, brain, false)?;
9622    // Grantee shape decides the axis: a base64url Ed25519 SPKI is a bare
9623    // multikey holder (link.md §6 cross-party keys — no hub account; the
9624    // printed `publicKeySpki` from `dbmd key generate`); anything else is a
9625    // hub principal named by email.
9626    let is_key_grantee = URL_SAFE_NO_PAD
9627        .decode(grantee)
9628        .map(|der| der.len() == 44 && der.starts_with(&ED25519_SPKI_PREFIX))
9629        .unwrap_or(false);
9630    let mut body = if is_key_grantee {
9631        json!({ "keySpki": grantee, "capability": can.as_str() })
9632    } else {
9633        json!({ "email": grantee, "capability": can.as_str() })
9634    };
9635    if let Some(s) = scope {
9636        body["scopePrefix"] = json!(s);
9637    }
9638    if let Some(u) = until {
9639        body["expiresAt"] = json!(u);
9640    }
9641    let path = format!("/api/hub/brains/{brain}/grants");
9642    ensure_ok(
9643        request(cfg, "POST", &path, Some(&body), Auth::Required)?,
9644        "grant issue",
9645    )
9646}
9647
9648/// List the active grants (and pending invites) on `brain`. Owner-side.
9649pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
9650    require_safe_ref(brain)?;
9651    let _ = verified_remote_head(cfg, brain, false)?;
9652    let path = format!("/api/hub/brains/{brain}/grants");
9653    ensure_ok(
9654        request(cfg, "GET", &path, None, Auth::Required)?,
9655        "grant list",
9656    )
9657}
9658
9659/// Revoke a grant (or cancel a pending invite) by id. Owner-side; revocation
9660/// is soft on the hub (the audit trail survives).
9661pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
9662    require_safe_ref(brain)?;
9663    require_safe_grant_id(grant_id)?;
9664    let _ = verified_remote_head(cfg, brain, false)?;
9665    let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
9666    ensure_ok(
9667        request(cfg, "DELETE", &path, None, Auth::Required)?,
9668        "grant revoke",
9669    )
9670}
9671
9672// ─────────────────────────────────────────────────────────────────────────────
9673// permissioned v2 proposals — inspect, accept, reject
9674// ─────────────────────────────────────────────────────────────────────────────
9675
9676#[derive(Debug)]
9677struct VerifiedV2Proposal {
9678    value: Value,
9679    changes: Value,
9680    blobs: Vec<(String, u64, String)>,
9681}
9682
9683fn require_proposal_id(id: &str) -> LinkResult<()> {
9684    if crate::ulid::is_ulid(id) {
9685        Ok(())
9686    } else {
9687        Err(invalid_feed("proposal id is not a lowercase ULID"))
9688    }
9689}
9690
9691fn verified_v2_proposal(
9692    cfg: &HubConfig,
9693    head: &V2VerifiedHead,
9694    proposal_id: &str,
9695) -> LinkResult<VerifiedV2Proposal> {
9696    require_proposal_id(proposal_id)?;
9697    if head.view_kind != "full" {
9698        return Err(invalid_feed(
9699            "proposal review requires a full readable view",
9700        ));
9701    }
9702    let path = format!(
9703        "/api/hub/brains/{}/v2/proposals/{proposal_id}",
9704        head.brain_id
9705    );
9706    let value = ensure_ok(
9707        request_capped(
9708            cfg,
9709            "GET",
9710            &path,
9711            None,
9712            Auth::Required,
9713            MAX_FEED_RESPONSE_BYTES,
9714        )?,
9715        "v2 proposal",
9716    )?;
9717    verify_v2_proposal_value(head, proposal_id, value)
9718}
9719
9720fn verify_v2_proposal_value(
9721    head: &V2VerifiedHead,
9722    proposal_id: &str,
9723    value: Value,
9724) -> LinkResult<VerifiedV2Proposal> {
9725    if value.get("v").and_then(Value::as_u64) != Some(2) {
9726        return Err(invalid_feed("proposal response has an invalid version"));
9727    }
9728    let proposal = value
9729        .get("proposal")
9730        .and_then(Value::as_object)
9731        .ok_or_else(|| invalid_feed("proposal response has no proposal"))?;
9732    if proposal.get("id").and_then(Value::as_str) != Some(proposal_id) {
9733        return Err(invalid_feed("proposal response changed its id"));
9734    }
9735    let payload_hash = proposal
9736        .get("payload_sha256")
9737        .and_then(Value::as_str)
9738        .filter(|hash| is_sha256(hash))
9739        .ok_or_else(|| invalid_feed("proposal has no payload address"))?;
9740    let clear_hash = proposal
9741        .get("clear_sha256")
9742        .and_then(Value::as_str)
9743        .filter(|hash| is_sha256(hash))
9744        .ok_or_else(|| invalid_feed("proposal has no clear payload digest"))?;
9745    let submission_hash = proposal
9746        .get("submission_claim_sha256")
9747        .and_then(Value::as_str)
9748        .filter(|hash| is_sha256(hash))
9749        .ok_or_else(|| invalid_feed("proposal has no submission claim address"))?;
9750    let submission = STANDARD
9751        .decode(
9752            proposal
9753                .get("submission_claim_base64")
9754                .and_then(Value::as_str)
9755                .ok_or_else(|| invalid_feed("proposal has no submission claim"))?,
9756        )
9757        .map_err(|_| invalid_feed("proposal submission claim is not base64"))?;
9758    let submission_value: Value = serde_json::from_slice(&submission)
9759        .map_err(|_| invalid_feed("proposal submission claim is not JSON"))?;
9760    if crate::linkmd_v2::canonical_bytes(&submission_value)
9761        .map_err(|error| invalid_feed(error.to_string()))?
9762        != submission
9763        || crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &submission)
9764            .map_err(|error| invalid_feed(error.to_string()))?
9765            != submission_hash
9766    {
9767        return Err(invalid_feed(
9768            "proposal submission claim is not canonical or addressed",
9769        ));
9770    }
9771    let envelope = submission_value
9772        .as_object()
9773        .ok_or_else(|| invalid_feed("proposal submission claim is not an object"))?;
9774    let claim = envelope
9775        .get("claim")
9776        .ok_or_else(|| invalid_feed("proposal submission claim body is missing"))?;
9777    let claim_object = claim
9778        .as_object()
9779        .ok_or_else(|| invalid_feed("proposal submission claim body is not an object"))?;
9780    let actor_root = claim_object
9781        .get("actor_root")
9782        .and_then(Value::as_object)
9783        .ok_or_else(|| invalid_feed("proposal submission actor root is missing"))?;
9784    let public_key = envelope
9785        .get("public_key")
9786        .and_then(Value::as_str)
9787        .ok_or_else(|| invalid_feed("proposal submission signer is missing"))?;
9788    let fingerprint = envelope
9789        .get("fingerprint")
9790        .and_then(Value::as_str)
9791        .ok_or_else(|| invalid_feed("proposal submission fingerprint is missing"))?;
9792    let signature = envelope
9793        .get("sig")
9794        .and_then(Value::as_str)
9795        .ok_or_else(|| invalid_feed("proposal submission signature is missing"))?;
9796    let claim_bytes = crate::linkmd_v2::canonical_bytes(claim)
9797        .map_err(|error| invalid_feed(error.to_string()))?;
9798    let der = verify_v2_spki_signature(public_key, &claim_bytes, signature)?;
9799    let signer = format!("{fingerprint}:{public_key}");
9800    let actor_class = actor_root.get("actor_class").and_then(Value::as_str);
9801    let grants = actor_root.get("grants").and_then(Value::as_array);
9802    let grants_are_canonical = grants.is_some_and(|items| {
9803        let mut prior: Option<&str> = None;
9804        items.iter().all(|item| {
9805            let Some(grant) = item.as_str() else {
9806                return false;
9807            };
9808            if !crate::ulid::is_ulid(grant) || prior.is_some_and(|value| value >= grant) {
9809                return false;
9810            }
9811            prior = Some(grant);
9812            true
9813        })
9814    });
9815    let optional_actor_field = |name: &str| {
9816        actor_root.get(name).is_some_and(|value| {
9817            value.is_null() || value.as_str().is_some_and(|text| !text.is_empty())
9818        })
9819    };
9820    let submitted_at = claim_object.get("submitted_at").and_then(Value::as_str);
9821    if claim_object.get("v").and_then(Value::as_u64) != Some(2)
9822        || format!("{:x}", Sha256::digest(&der)) != fingerprint
9823        || head
9824            .trust
9825            .hub_signer
9826            .as_ref()
9827            .is_some_and(|known| known != &signer)
9828        || !matches!(
9829            actor_class,
9830            Some(
9831                "user"
9832                    | "owned_agent"
9833                    | "foreign_key"
9834                    | "curation"
9835                    | "inbox"
9836                    | "restore"
9837                    | "migration"
9838                    | "operator_recovery"
9839            )
9840        )
9841        || actor_root
9842            .get("principal")
9843            .and_then(Value::as_str)
9844            .is_none_or(|value| value.is_empty())
9845        || actor_root
9846            .get("credential")
9847            .and_then(Value::as_str)
9848            .is_none_or(|value| value.is_empty())
9849        || !optional_actor_field("organization")
9850        || !optional_actor_field("role")
9851        || !grants_are_canonical
9852        || claim.get("brain").and_then(Value::as_str) != Some(head.brain_id.as_str())
9853        || claim.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
9854        || !claim_object
9855            .get("mutation_id")
9856            .and_then(Value::as_str)
9857            .is_some_and(|value| {
9858                !value.is_empty()
9859                    && value.len() <= 128
9860                    && value.chars().enumerate().all(|(index, char)| {
9861                        char.is_ascii_alphanumeric()
9862                            || (index > 0 && matches!(char, '.' | '_' | ':' | '-'))
9863                    })
9864            })
9865        || claim.get("payload_sha256").and_then(Value::as_str) != Some(payload_hash)
9866        || claim.get("clear_sha256").and_then(Value::as_str) != Some(clear_hash)
9867        || !claim_object
9868            .get("control_revision")
9869            .and_then(Value::as_str)
9870            .is_some_and(is_sha256)
9871        || submitted_at.is_none_or(|value| {
9872            chrono::DateTime::parse_from_rfc3339(value).is_err()
9873                || proposal.get("submitted_at").and_then(Value::as_str) != Some(value)
9874        })
9875        || !proposal
9876            .get("state")
9877            .and_then(Value::as_str)
9878            .is_some_and(|value| matches!(value, "pending" | "accepted" | "rejected" | "expired"))
9879        || proposal
9880            .get("expires_at")
9881            .and_then(Value::as_str)
9882            .is_none_or(|value| chrono::DateTime::parse_from_rfc3339(value).is_err())
9883        || proposal
9884            .get("proposer")
9885            .and_then(Value::as_object)
9886            .and_then(|value| value.get("class"))
9887            .and_then(Value::as_str)
9888            != actor_class
9889    {
9890        return Err(invalid_feed(
9891            "proposal submission claim does not bind the verified proposal",
9892        ));
9893    }
9894    let changes_b64 = proposal
9895        .get("changes_base64")
9896        .and_then(Value::as_str)
9897        .ok_or_else(|| invalid_feed("proposal has no changeset"))?;
9898    let changes_bytes = STANDARD
9899        .decode(changes_b64)
9900        .map_err(|_| invalid_feed("proposal changeset is not base64"))?;
9901    let changes: Value = serde_json::from_slice(&changes_bytes)
9902        .map_err(|_| invalid_feed("proposal changeset is not JSON"))?;
9903    if crate::linkmd_v2::canonical_bytes(&changes)
9904        .map_err(|error| invalid_feed(error.to_string()))?
9905        != changes_bytes
9906        || changes.get("v").and_then(Value::as_u64) != Some(2)
9907        || !changes.get("operations").is_some_and(Value::is_array)
9908    {
9909        return Err(invalid_feed("proposal changeset is not canonical v2"));
9910    }
9911    let blob_values = proposal
9912        .get("blobs")
9913        .and_then(Value::as_array)
9914        .ok_or_else(|| invalid_feed("proposal has no blob declarations"))?;
9915    let mut blobs = Vec::with_capacity(blob_values.len());
9916    let mut descriptor_blobs = Vec::with_capacity(blob_values.len());
9917    let mut prior_hash: Option<String> = None;
9918    for item in blob_values {
9919        let hash = item
9920            .get("sha256")
9921            .and_then(Value::as_str)
9922            .filter(|hash| is_sha256(hash))
9923            .ok_or_else(|| invalid_feed("proposal blob has no address"))?;
9924        let bytes = item
9925            .get("bytes")
9926            .and_then(Value::as_u64)
9927            .filter(|bytes| *bytes <= MAX_STORE_BYTES)
9928            .ok_or_else(|| invalid_feed("proposal blob has an invalid size"))?;
9929        if prior_hash.as_deref().is_some_and(|prior| prior >= hash) {
9930            return Err(invalid_feed(
9931                "proposal blob declarations are not unique and sorted",
9932            ));
9933        }
9934        prior_hash = Some(hash.to_string());
9935        let endpoint = item
9936            .get("endpoint")
9937            .and_then(Value::as_str)
9938            .ok_or_else(|| invalid_feed("proposal blob has no endpoint"))?;
9939        let expected_endpoint = format!(
9940            "/api/hub/brains/{}/v2/proposals/{proposal_id}/blob?sha256={hash}",
9941            head.brain_id
9942        );
9943        if endpoint != expected_endpoint {
9944            return Err(invalid_feed("proposal blob endpoint is not origin-bound"));
9945        }
9946        descriptor_blobs.push(json!({ "bytes": bytes, "sha256": hash }));
9947        blobs.push((hash.to_string(), bytes, endpoint.to_string()));
9948    }
9949    let descriptor = json!({
9950        "base": proposal.get("base").cloned().unwrap_or(Value::Null),
9951        "blobs": descriptor_blobs,
9952        "changes_base64": changes_b64,
9953        "rebase": proposal.get("rebase").cloned().unwrap_or(Value::Null),
9954        "v": 2,
9955    });
9956    let descriptor_bytes = crate::linkmd_v2::canonical_bytes(&descriptor)
9957        .map_err(|error| invalid_feed(error.to_string()))?;
9958    if content_sha256(&descriptor_bytes) != clear_hash {
9959        return Err(invalid_feed(
9960            "proposal clear payload differs from its signed submission claim",
9961        ));
9962    }
9963    Ok(VerifiedV2Proposal {
9964        value,
9965        changes,
9966        blobs,
9967    })
9968}
9969
9970pub fn proposal_list(
9971    cfg: &HubConfig,
9972    brain: &str,
9973    state: &str,
9974    after: Option<&str>,
9975    limit: usize,
9976) -> LinkResult<Value> {
9977    require_safe_ref(brain)?;
9978    if !matches!(state, "pending" | "accepted" | "rejected" | "expired") {
9979        return Err(invalid_feed("proposal state is invalid"));
9980    }
9981    if after.is_some_and(|value| !crate::ulid::is_ulid(value)) {
9982        return Err(invalid_feed("proposal cursor is invalid"));
9983    }
9984    let head = v2_verified_head(cfg, brain)?
9985        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
9986    let path = format!(
9987        "/api/hub/brains/{}/v2/proposals?state={state}&limit={}{}",
9988        head.brain_id,
9989        limit.clamp(1, 100),
9990        after.map_or_else(String::new, |value| format!("&after={value}"))
9991    );
9992    ensure_ok(
9993        request_capped(
9994            cfg,
9995            "GET",
9996            &path,
9997            None,
9998            Auth::Required,
9999            MAX_FEED_RESPONSE_BYTES,
10000        )?,
10001        "v2 proposal list",
10002    )
10003}
10004
10005pub fn proposal_show(cfg: &HubConfig, brain: &str, proposal_id: &str) -> LinkResult<Value> {
10006    require_safe_ref(brain)?;
10007    let head = v2_verified_head(cfg, brain)?
10008        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
10009    Ok(verified_v2_proposal(cfg, &head, proposal_id)?.value)
10010}
10011
10012pub fn proposal_reject(
10013    cfg: &HubConfig,
10014    brain: &str,
10015    proposal_id: &str,
10016    mutation_id: &str,
10017    reason: &str,
10018) -> LinkResult<Value> {
10019    require_safe_ref(brain)?;
10020    require_proposal_id(proposal_id)?;
10021    let head = v2_verified_head(cfg, brain)?
10022        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
10023    let _ = verified_v2_proposal(cfg, &head, proposal_id)?;
10024    let body = json!({
10025        "mutation_id": mutation_id,
10026        "control_revision": head.control_revision,
10027        "reason": reason,
10028    });
10029    let path = format!(
10030        "/api/hub/brains/{}/v2/proposals/{proposal_id}",
10031        head.brain_id
10032    );
10033    ensure_ok(
10034        request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
10035        "v2 proposal rejection",
10036    )
10037}
10038
10039pub fn proposal_accept_exact(
10040    cfg: &HubConfig,
10041    brain: &str,
10042    proposal_id: &str,
10043    mutation_id: &str,
10044    reason: &str,
10045) -> LinkResult<Value> {
10046    require_safe_ref(brain)?;
10047    require_proposal_id(proposal_id)?;
10048    let head = v2_verified_head(cfg, brain)?
10049        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
10050    let proposal = verified_v2_proposal(cfg, &head, proposal_id)?;
10051    let operations = proposal
10052        .changes
10053        .get("operations")
10054        .and_then(Value::as_array)
10055        .cloned()
10056        .ok_or_else(|| invalid_feed("proposal changeset has no operations"))?;
10057    if operations.is_empty() || operations.len() > MAX_PUSH_FILES {
10058        return Err(invalid_feed("proposal operation count is invalid"));
10059    }
10060    let mut downloaded = std::collections::BTreeMap::new();
10061    for (hash, bytes, endpoint) in &proposal.blobs {
10062        let body = ensure_raw_ok(
10063            request_raw(cfg, "GET", endpoint, None, Auth::Required, *bytes)?,
10064            "v2 proposal blob",
10065        )?;
10066        if body.len() as u64 != *bytes || content_sha256(&body) != *hash {
10067            return Err(invalid_feed("proposal blob does not match its declaration"));
10068        }
10069        downloaded.insert(hash.clone(), body);
10070    }
10071    let remote = files_for_v2_view(
10072        &head,
10073        v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
10074    );
10075    let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
10076    let mut expected_candidate = remote.clone();
10077    let mut expected_candidate_assets = remote_assets;
10078    for operation in &operations {
10079        let op = operation
10080            .get("op")
10081            .and_then(Value::as_str)
10082            .ok_or_else(|| invalid_feed("proposal operation has no kind"))?;
10083        match op {
10084            "put" | "restore" => {
10085                let path = operation
10086                    .get("path")
10087                    .and_then(Value::as_str)
10088                    .ok_or_else(|| invalid_feed("proposal write has no path"))?;
10089                crate::linkmd_v2::normalize_path(path)
10090                    .map_err(|error| invalid_feed(error.to_string()))?;
10091                let hash = operation
10092                    .get("blob")
10093                    .and_then(Value::as_str)
10094                    .filter(|hash| is_sha256(hash))
10095                    .ok_or_else(|| invalid_feed("proposal write has no blob"))?;
10096                let bytes = operation
10097                    .get("bytes")
10098                    .and_then(Value::as_u64)
10099                    .ok_or_else(|| invalid_feed("proposal write has no size"))?;
10100                expected_candidate.insert(
10101                    path.to_string(),
10102                    V2BaselineFile {
10103                        sha256: hash.to_string(),
10104                        bytes,
10105                        proof: None,
10106                    },
10107                );
10108            }
10109            "delete" | "withdraw_from_hosting" => {
10110                let path = operation
10111                    .get("path")
10112                    .and_then(Value::as_str)
10113                    .ok_or_else(|| invalid_feed("proposal removal has no path"))?;
10114                crate::linkmd_v2::normalize_path(path)
10115                    .map_err(|error| invalid_feed(error.to_string()))?;
10116                expected_candidate.remove(path);
10117            }
10118            "rename" => {
10119                let from = operation
10120                    .get("from")
10121                    .and_then(Value::as_str)
10122                    .ok_or_else(|| invalid_feed("proposal rename has no source"))?;
10123                let to = operation
10124                    .get("to")
10125                    .and_then(Value::as_str)
10126                    .ok_or_else(|| invalid_feed("proposal rename has no destination"))?;
10127                crate::linkmd_v2::normalize_path(from)
10128                    .and_then(|_| crate::linkmd_v2::normalize_path(to))
10129                    .map_err(|error| invalid_feed(error.to_string()))?;
10130                let hash = operation
10131                    .get("blob")
10132                    .and_then(Value::as_str)
10133                    .filter(|hash| is_sha256(hash))
10134                    .ok_or_else(|| invalid_feed("proposal rename has no blob"))?;
10135                let bytes = operation
10136                    .get("bytes")
10137                    .and_then(Value::as_u64)
10138                    .ok_or_else(|| invalid_feed("proposal rename has no size"))?;
10139                expected_candidate.remove(from);
10140                expected_candidate.insert(
10141                    to.to_string(),
10142                    V2BaselineFile {
10143                        sha256: hash.to_string(),
10144                        bytes,
10145                        proof: None,
10146                    },
10147                );
10148            }
10149            "asset_delete" => {
10150                let path = operation
10151                    .get("path")
10152                    .and_then(Value::as_str)
10153                    .ok_or_else(|| invalid_feed("proposal asset delete has no path"))?;
10154                expected_candidate_assets.remove(path);
10155            }
10156            "asset_withdraw" => {
10157                let path = operation
10158                    .get("path")
10159                    .and_then(Value::as_str)
10160                    .ok_or_else(|| invalid_feed("proposal asset withdrawal has no path"))?;
10161                let asset = expected_candidate_assets
10162                    .get_mut(path)
10163                    .ok_or_else(|| invalid_feed("proposal withdraws an unknown asset"))?;
10164                asset.disposition = "withheld".to_string();
10165                asset.leaf_hash.clear();
10166            }
10167            "asset_put" | "asset_resume" => {
10168                let path = operation
10169                    .get("path")
10170                    .and_then(Value::as_str)
10171                    .ok_or_else(|| invalid_feed("proposal asset write has no path"))?;
10172                let asset = operation
10173                    .get("asset")
10174                    .and_then(Value::as_object)
10175                    .ok_or_else(|| invalid_feed("proposal asset write has no value"))?;
10176                let blob_sha256 = asset
10177                    .get("blob_sha256")
10178                    .and_then(Value::as_str)
10179                    .filter(|hash| is_sha256(hash))
10180                    .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
10181                let bytes = asset
10182                    .get("bytes")
10183                    .and_then(Value::as_u64)
10184                    .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
10185                let media_type = asset
10186                    .get("media_type")
10187                    .and_then(Value::as_str)
10188                    .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
10189                let wrappers = asset
10190                    .get("wrappers")
10191                    .and_then(Value::as_array)
10192                    .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
10193                    .iter()
10194                    .map(|wrapper| {
10195                        wrapper
10196                            .as_str()
10197                            .map(str::to_string)
10198                            .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
10199                    })
10200                    .collect::<LinkResult<Vec<_>>>()?;
10201                let required = asset
10202                    .get("required")
10203                    .and_then(Value::as_bool)
10204                    .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
10205                let disposition = asset
10206                    .get("disposition")
10207                    .and_then(Value::as_str)
10208                    .filter(|value| matches!(*value, "hosted" | "withheld"))
10209                    .ok_or_else(|| invalid_feed("proposal asset disposition is invalid"))?;
10210                expected_candidate_assets.insert(
10211                    path.to_string(),
10212                    V2BaselineAsset {
10213                        blob_sha256: blob_sha256.to_string(),
10214                        bytes,
10215                        media_type: media_type.to_string(),
10216                        wrappers,
10217                        required,
10218                        disposition: disposition.to_string(),
10219                        leaf_hash: String::new(),
10220                    },
10221                );
10222            }
10223            _ => return Err(invalid_feed("proposal operation kind is unsupported")),
10224        }
10225    }
10226    let base = head.pointer.as_ref().map(|pointer| {
10227        json!({
10228            "seq": pointer.seq,
10229            "commit_hash": pointer.commit_hash,
10230            "content_root": pointer.content_root,
10231            "asset_root": pointer.asset_root,
10232        })
10233    });
10234    let mut body = json!({
10235        "mutation_id": mutation_id,
10236        "base": base,
10237        "rebase": "strict",
10238        "reason": reason,
10239        "operations": operations,
10240        "blobs": downloaded
10241            .iter()
10242            .map(|(sha256, bytes)| json!({
10243                "sha256": sha256,
10244                "bytes": bytes.len(),
10245                "content_base64": STANDARD.encode(bytes),
10246            }))
10247            .collect::<Vec<_>>(),
10248        "proposal_id": proposal_id,
10249        "proposal_mode": "exact",
10250    });
10251    let changed_bytes = downloaded.values().try_fold(0_usize, |total, bytes| {
10252        total
10253            .checked_add(bytes.len())
10254            .ok_or_else(|| LinkError::PushTooLarge {
10255                detail: "proposal changed-byte total overflow".to_string(),
10256            })
10257    })?;
10258    if changed_bytes > 3 * 1024 * 1024 || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
10259        let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
10260        for operation in &operations {
10261            let Some(kind) = operation.get("op").and_then(Value::as_str) else {
10262                return Err(invalid_feed("proposal upload operation has no kind"));
10263            };
10264            let hash = match kind {
10265                "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
10266                "asset_put" | "asset_resume" => operation
10267                    .get("asset")
10268                    .and_then(|asset| asset.get("blob_sha256"))
10269                    .and_then(Value::as_str),
10270                _ => None,
10271            };
10272            let Some(hash) = hash else { continue };
10273            let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
10274            if kind == "rename" {
10275                for field in ["from", "to"] {
10276                    coordinates.insert(
10277                        operation
10278                            .get(field)
10279                            .and_then(Value::as_str)
10280                            .ok_or_else(|| invalid_feed("proposal rename has no coordinate"))?
10281                            .to_string(),
10282                    );
10283                }
10284            } else {
10285                let path = operation
10286                    .get("path")
10287                    .and_then(Value::as_str)
10288                    .ok_or_else(|| invalid_feed("proposal upload has no coordinate"))?;
10289                coordinates.insert(if kind.starts_with("asset_") {
10290                    format!("assets/{path}")
10291                } else {
10292                    path.to_string()
10293                });
10294            }
10295        }
10296        let declarations = downloaded
10297            .iter()
10298            .map(|(sha256, bytes)| {
10299                json!({
10300                    "sha256": sha256,
10301                    "bytes": bytes.len(),
10302                    "coordinates": coordinates_by_hash
10303                        .get(sha256)
10304                        .into_iter()
10305                        .flatten()
10306                        .collect::<Vec<_>>(),
10307                })
10308            })
10309            .collect::<Vec<_>>();
10310        let reserved = ensure_ok(
10311            request(
10312                cfg,
10313                "POST",
10314                &format!("/api/hub/brains/{}/v2/uploads", head.brain_id),
10315                Some(&json!({ "blobs": declarations })),
10316                Auth::Required,
10317            )?,
10318            "prepare proposal blob transport",
10319        )?;
10320        let items = reserved
10321            .get("uploads")
10322            .and_then(Value::as_array)
10323            .ok_or_else(|| invalid_feed("proposal upload reservation has no items"))?;
10324        if items.len() != downloaded.len() {
10325            return Err(invalid_feed("proposal upload reservation changed the set"));
10326        }
10327        let mut references = Vec::with_capacity(items.len());
10328        for item in items {
10329            let hash = item
10330                .get("sha256")
10331                .and_then(Value::as_str)
10332                .ok_or_else(|| invalid_feed("proposal upload reservation has no hash"))?;
10333            let bytes = downloaded
10334                .get(hash)
10335                .ok_or_else(|| invalid_feed("proposal upload reservation introduced a blob"))?;
10336            let reservation_id = item
10337                .get("reservation_id")
10338                .and_then(Value::as_str)
10339                .filter(|id| crate::ulid::is_ulid(id))
10340                .ok_or_else(|| invalid_feed("proposal upload reservation has no id"))?;
10341            let expected_coordinates = coordinates_by_hash
10342                .get(hash)
10343                .ok_or_else(|| invalid_feed("proposal upload has no coordinate binding"))?;
10344            let returned_coordinates = item
10345                .get("coordinates")
10346                .and_then(Value::as_array)
10347                .ok_or_else(|| invalid_feed("proposal upload reservation has no coordinates"))?;
10348            if returned_coordinates.len() != expected_coordinates.len()
10349                || returned_coordinates
10350                    .iter()
10351                    .zip(expected_coordinates)
10352                    .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
10353            {
10354                return Err(invalid_feed(
10355                    "proposal upload reservation changed its coordinates",
10356                ));
10357            }
10358            match item.get("status").and_then(Value::as_str) {
10359                Some("upload") => put_presigned(
10360                    cfg,
10361                    item.get("url")
10362                        .and_then(Value::as_str)
10363                        .ok_or_else(|| invalid_feed("proposal upload has no URL"))?,
10364                    item.get("headers").unwrap_or(&Value::Null),
10365                    bytes,
10366                )?,
10367                Some("already_present") => {}
10368                _ => return Err(invalid_feed("proposal upload status is invalid")),
10369            }
10370            references.push(json!({
10371                "sha256": hash,
10372                "bytes": bytes.len(),
10373                "reservation_id": reservation_id,
10374            }));
10375        }
10376        body["blobs"] = Value::Array(references);
10377    }
10378    if body.to_string().len() > MAX_PUSH_BYTES {
10379        return Err(LinkError::PushTooLarge {
10380            detail: "proposal operation metadata exceeds the commit request cap".to_string(),
10381        });
10382    }
10383    let path = format!("/api/hub/brains/{}/v2/commits", head.brain_id);
10384    let mut result = ensure_ok(
10385        request(cfg, "POST", &path, Some(&body), Auth::Required)?,
10386        "exact proposal acceptance",
10387    )?;
10388    let mut candidate_hub_signer = None;
10389    if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
10390        let challenge = result
10391            .get("signing_challenge")
10392            .ok_or_else(|| invalid_feed("proposal acceptance has no signing challenge"))?;
10393        let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
10394            cfg,
10395            &head,
10396            &expected_candidate,
10397            &expected_candidate_assets,
10398            mutation_id,
10399            &body,
10400            challenge,
10401        )?;
10402        body["signing_challenge_id"] = Value::String(challenge_id);
10403        body["signature_base64url"] = Value::String(signature);
10404        candidate_hub_signer = Some(actor_signer);
10405        result = ensure_ok(
10406            request(cfg, "POST", &path, Some(&body), Auth::Required)?,
10407            "signed exact proposal acceptance",
10408        )?;
10409    }
10410    let refreshed = v2_verified_head(cfg, brain)?
10411        .ok_or_else(|| invalid_feed("v2 head disappeared after proposal acceptance"))?;
10412    if candidate_hub_signer
10413        .as_ref()
10414        .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
10415        || refreshed
10416            .pointer
10417            .as_ref()
10418            .map(|pointer| pointer.commit_hash.as_str())
10419            != result.get("commit_hash").and_then(Value::as_str)
10420        || result.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
10421        || result.get("proposal_state").and_then(Value::as_str) != Some("accepted")
10422    {
10423        return Err(LinkError::RemoteAdvancedDuringSync);
10424    }
10425    accept_v2_head(cfg, &refreshed)?;
10426    Ok(result)
10427}
10428
10429// ─────────────────────────────────────────────────────────────────────────────
10430// propose — write without trust: evidence into the owner's inbox
10431// ─────────────────────────────────────────────────────────────────────────────
10432
10433/// Submit `body` to the published site `handle`, addressed to its app page
10434/// `app` (a page that declares the `write-inbox` capability). Deliberately
10435/// unauthenticated — this is the cross-party door; the submission lands as
10436/// *evidence* in the owner's `sources/inbox/`, never as truth, and the
10437/// owner's curator accepts or rejects it. Returns the hub's `{id, path}`
10438/// receipt.
10439pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
10440    require_valid_handle(handle)?;
10441    if body.len() as u64 > MAX_PROPOSE_BYTES {
10442        return Err(LinkError::ProposeTooLarge {
10443            bytes: body.len() as u64,
10444        });
10445    }
10446    let payload = json!({ "app": app, "body": body });
10447    // A ULID-shaped target is a bare brain address (link.md §7.4's
10448    // generalization): the brain inbox door, open on public brains, where a
10449    // configured credential earns a bigger actor-class budget. Anything else
10450    // is a published-site handle: that door is unauthenticated by design.
10451    let (path, auth) = if crate::ulid::is_ulid(handle) {
10452        (format!("/api/hub/brains/{handle}/inbox"), Auth::Optional)
10453    } else {
10454        (format!("/api/hub/sites/{handle}/inbox"), Auth::None)
10455    };
10456    ensure_ok(
10457        request(cfg, "POST", &path, Some(&payload), auth)?,
10458        "propose",
10459    )
10460}
10461
10462// ─────────────────────────────────────────────────────────────────────────────
10463// subscribe — follow feed-head movement
10464// ─────────────────────────────────────────────────────────────────────────────
10465
10466/// One observation of a brain's feed head.
10467#[derive(Debug, serde::Serialize)]
10468pub struct Head {
10469    /// The brain id.
10470    pub brain: String,
10471    /// The hub's durable feed cursor — advances on every accepted write.
10472    pub seq: u64,
10473    /// The hub's `updatedAt` for the brain, when present.
10474    #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
10475    pub updated_at: Option<String>,
10476    /// SHA-256 of the exact signed head entry.
10477    #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
10478    pub feed_hash: Option<String>,
10479    /// Whether the head entry's content hash, identity, and Ed25519 signature
10480    /// were verified locally. Path-scoped grants get head movement only.
10481    pub verified: bool,
10482}
10483
10484struct BoundedVecVisitor<T, const MAX: usize> {
10485    label: &'static str,
10486    marker: std::marker::PhantomData<T>,
10487}
10488
10489impl<'de, T, const MAX: usize> serde::de::Visitor<'de> for BoundedVecVisitor<T, MAX>
10490where
10491    T: Deserialize<'de>,
10492{
10493    type Value = Vec<T>;
10494
10495    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
10496        write!(formatter, "at most {MAX} {}", self.label)
10497    }
10498
10499    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
10500    where
10501        A: serde::de::SeqAccess<'de>,
10502    {
10503        if sequence.size_hint().is_some_and(|size| size > MAX) {
10504            return Err(serde::de::Error::custom(format!(
10505                "{} exceeds the {MAX}-item limit",
10506                self.label
10507            )));
10508        }
10509        let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX));
10510        while let Some(value) = sequence.next_element()? {
10511            if values.len() == MAX {
10512                return Err(serde::de::Error::custom(format!(
10513                    "{} exceeds the {MAX}-item limit",
10514                    self.label
10515                )));
10516            }
10517            values.push(value);
10518        }
10519        Ok(values)
10520    }
10521}
10522
10523fn deserialize_bounded_vec<'de, D, T, const MAX: usize>(
10524    deserializer: D,
10525    label: &'static str,
10526) -> Result<Vec<T>, D::Error>
10527where
10528    D: serde::Deserializer<'de>,
10529    T: Deserialize<'de>,
10530{
10531    deserializer.deserialize_seq(BoundedVecVisitor::<T, MAX> {
10532        label,
10533        marker: std::marker::PhantomData,
10534    })
10535}
10536
10537fn deserialize_feed_files<'de, D>(deserializer: D) -> Result<Vec<FeedFile>, D::Error>
10538where
10539    D: serde::Deserializer<'de>,
10540{
10541    deserialize_bounded_vec::<D, FeedFile, MAX_PUSH_FILES>(deserializer, "feed files")
10542}
10543
10544fn deserialize_removed_paths<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
10545where
10546    D: serde::Deserializer<'de>,
10547{
10548    deserialize_bounded_vec::<D, String, MAX_PUSH_FILES>(deserializer, "removed paths")
10549}
10550
10551fn deserialize_previous_identities<'de, D>(
10552    deserializer: D,
10553) -> Result<Vec<PreviousIdentity>, D::Error>
10554where
10555    D: serde::Deserializer<'de>,
10556{
10557    deserialize_bounded_vec::<D, PreviousIdentity, MAX_IDENTITY_ROTATIONS>(
10558        deserializer,
10559        "previous identities",
10560    )
10561}
10562
10563fn deserialize_rotations<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
10564where
10565    D: serde::Deserializer<'de>,
10566{
10567    deserialize_bounded_vec::<D, String, MAX_IDENTITY_ROTATIONS>(
10568        deserializer,
10569        "rotation statements",
10570    )
10571}
10572
10573fn deserialize_feed_items<'de, D>(deserializer: D) -> Result<Vec<FeedItem>, D::Error>
10574where
10575    D: serde::Deserializer<'de>,
10576{
10577    deserialize_bounded_vec::<D, FeedItem, FEED_PAGE_LIMIT>(deserializer, "feed entries")
10578}
10579
10580#[derive(Debug, Clone, Deserialize, Serialize)]
10581struct FeedFile {
10582    path: String,
10583    sha256: String,
10584    bytes: u64,
10585}
10586
10587#[cfg(test)]
10588#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10589enum V1DisclosureError {
10590    DuplicateFile,
10591    DuplicateRemoved,
10592    PushManifestMismatch,
10593    EditMissingChange,
10594    EditFalseFile,
10595    RemovedMismatch,
10596}
10597
10598/// Validate wire-profile-v1 `files`/`removed` disclosure semantics against
10599/// the exact previous and resulting pack manifests. `edit` accepts the
10600/// historical superset form while still requiring every actual changed path.
10601#[cfg(test)]
10602fn verify_v1_manifest_disclosure(
10603    kind: &str,
10604    previous: &[FeedFile],
10605    resulting: &[FeedFile],
10606    files: &[FeedFile],
10607    removed: &[String],
10608) -> Result<(), V1DisclosureError> {
10609    fn as_map(
10610        files: &[FeedFile],
10611    ) -> Result<std::collections::BTreeMap<&str, (&str, u64)>, V1DisclosureError> {
10612        let mut result = std::collections::BTreeMap::new();
10613        for file in files {
10614            if result
10615                .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
10616                .is_some()
10617            {
10618                return Err(V1DisclosureError::DuplicateFile);
10619            }
10620        }
10621        Ok(result)
10622    }
10623    let previous = as_map(previous)?;
10624    let resulting = as_map(resulting)?;
10625    let disclosed = as_map(files)?;
10626    let removed_set: std::collections::BTreeSet<&str> =
10627        removed.iter().map(String::as_str).collect();
10628    if removed_set.len() != removed.len() {
10629        return Err(V1DisclosureError::DuplicateRemoved);
10630    }
10631    let expected_removed: std::collections::BTreeSet<&str> = previous
10632        .keys()
10633        .copied()
10634        .filter(|path| !resulting.contains_key(path))
10635        .collect();
10636    if removed_set != expected_removed {
10637        return Err(V1DisclosureError::RemovedMismatch);
10638    }
10639    if kind == "push" {
10640        return if disclosed == resulting {
10641            Ok(())
10642        } else {
10643            Err(V1DisclosureError::PushManifestMismatch)
10644        };
10645    }
10646    if kind != "edit" {
10647        return Err(V1DisclosureError::EditFalseFile);
10648    }
10649    if disclosed
10650        .iter()
10651        .any(|(path, value)| resulting.get(path) != Some(value))
10652    {
10653        return Err(V1DisclosureError::EditFalseFile);
10654    }
10655    for (path, value) in &resulting {
10656        if previous.get(path) != Some(value) && !disclosed.contains_key(path) {
10657            return Err(V1DisclosureError::EditMissingChange);
10658        }
10659    }
10660    Ok(())
10661}
10662
10663#[derive(Debug, Clone, Deserialize, Serialize)]
10664struct FeedEntry {
10665    v: u8,
10666    seq: u64,
10667    ts: String,
10668    brain: String,
10669    public_key: String,
10670    kind: String,
10671    op: String,
10672    pack_sha256: String,
10673    #[serde(deserialize_with = "deserialize_feed_files")]
10674    files: Vec<FeedFile>,
10675    #[serde(deserialize_with = "deserialize_removed_paths")]
10676    removed: Vec<String>,
10677    prev_entry_hash: Option<String>,
10678    sig: String,
10679}
10680
10681#[derive(Serialize)]
10682struct UnsignedFeedEntry<'a> {
10683    v: u8,
10684    seq: u64,
10685    ts: &'a str,
10686    brain: &'a str,
10687    public_key: &'a str,
10688    kind: &'a str,
10689    op: &'a str,
10690    pack_sha256: &'a str,
10691    files: &'a [FeedFile],
10692    removed: &'a [String],
10693    prev_entry_hash: &'a Option<String>,
10694}
10695
10696#[derive(Debug, Clone, Deserialize, Serialize)]
10697struct FeedItem {
10698    hash: String,
10699    entry: FeedEntry,
10700}
10701
10702#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
10703struct FeedIdentity {
10704    fingerprint: String,
10705    #[serde(rename = "publicKeySpki")]
10706    public_key_spki: String,
10707    /// Rotation history (link.md §9.1): identities this brain previously
10708    /// signed as. Entries verify against current OR previous — rotation
10709    /// never invalidates history.
10710    #[serde(default, deserialize_with = "deserialize_previous_identities")]
10711    previous: Vec<PreviousIdentity>,
10712    /// Exact normative rotation statements, oldest first. A list of previous
10713    /// public keys without these old-key signatures is not a trust chain.
10714    #[serde(default, deserialize_with = "deserialize_rotations")]
10715    rotations: Vec<String>,
10716}
10717
10718#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
10719struct PreviousIdentity {
10720    fingerprint: String,
10721    #[serde(rename = "publicKeySpki")]
10722    public_key_spki: String,
10723}
10724
10725#[derive(Debug, Deserialize)]
10726struct FeedResponse {
10727    #[serde(rename = "headSeq")]
10728    head_seq: u64,
10729    #[serde(rename = "feedHash")]
10730    feed_hash: Option<String>,
10731    identity: Option<FeedIdentity>,
10732    #[serde(deserialize_with = "deserialize_feed_items")]
10733    entries: Vec<FeedItem>,
10734    #[serde(rename = "scopeLimited")]
10735    scope_limited: bool,
10736}
10737
10738#[derive(Debug, Deserialize, Serialize)]
10739#[serde(deny_unknown_fields)]
10740struct RotationStatement {
10741    v: u8,
10742    op: String,
10743    brain: String,
10744    public_key: String,
10745    new_brain: String,
10746    new_public_key: String,
10747    prior_head_seq: u64,
10748    prior_feed_hash: Option<String>,
10749    ts: String,
10750    sig: String,
10751}
10752
10753#[derive(Debug, Clone, Deserialize, Serialize)]
10754struct TrustState {
10755    v: u8,
10756    origin: String,
10757    /// The exact caller-visible ref whose resolution this checkpoint pins.
10758    /// Slugs/handles are mutable names at the hub; once observed, they may not
10759    /// silently resolve to a different canonical brain.
10760    #[serde(default)]
10761    requested: String,
10762    /// Canonical hub brain id returned for `requested`.
10763    brain: String,
10764    /// Federation home origin when this ref was learned from a registry.
10765    /// Once observed, the registry cannot silently relocate the same handle.
10766    #[serde(default, skip_serializing_if = "Option::is_none")]
10767    home: Option<String>,
10768    anchor: String,
10769    current: String,
10770    #[serde(rename = "headSeq")]
10771    head_seq: u64,
10772    #[serde(rename = "feedHash")]
10773    feed_hash: Option<String>,
10774    /// Exact accepted old-key-signed rotation statements, oldest first. v2
10775    /// checkpoints require this vector to be an immutable prefix of every
10776    /// subsequently served identity chain.
10777    #[serde(default)]
10778    rotations: Vec<String>,
10779    /// Hub pointer-signing identity pinned on first v2 observation. This is
10780    /// separate from the brain key, which signs commits.
10781    #[serde(default, skip_serializing_if = "Option::is_none")]
10782    hub_signer: Option<String>,
10783    /// Explicit transport profile. Older accepted non-empty v2 checkpoints
10784    /// are recognized by their pinned hub signer during one-way migration.
10785    #[serde(default, skip_serializing_if = "Option::is_none")]
10786    protocol_profile: Option<String>,
10787}
10788
10789fn accepted_as_v2(state: &TrustState) -> bool {
10790    state.protocol_profile.as_deref() == Some("link-v2") || state.hub_signer.is_some()
10791}
10792
10793fn has_accepted_v2_ref(cfg: &HubConfig, requested: &str) -> LinkResult<bool> {
10794    let directory = open_trust_dir(cfg)?;
10795    if load_trust_in(cfg, &directory, requested)?.is_some_and(|state| accepted_as_v2(&state)) {
10796        return Ok(true);
10797    }
10798    let Some(alias) = load_alias_in(cfg, &directory, requested)? else {
10799        return Ok(false);
10800    };
10801    Ok(load_trust_in(cfg, &directory, &alias.brain)?.is_some_and(|state| accepted_as_v2(&state)))
10802}
10803
10804#[derive(Debug, Clone, Deserialize, Serialize)]
10805struct AliasBinding {
10806    v: u8,
10807    origin: String,
10808    requested: String,
10809    brain: String,
10810    #[serde(default, skip_serializing_if = "Option::is_none")]
10811    home: Option<String>,
10812}
10813
10814struct VerifiedRemote {
10815    head: Head,
10816    identity: Option<FeedIdentity>,
10817    head_entry: Option<FeedItem>,
10818    /// Present when the caller requested and verified the complete chain.
10819    entries: Vec<FeedItem>,
10820    anchor: Option<String>,
10821}
10822
10823fn invalid_feed(message: impl Into<String>) -> LinkError {
10824    LinkError::InvalidFeed {
10825        message: message.into(),
10826    }
10827}
10828
10829fn is_sha256(value: &str) -> bool {
10830    value.len() == 64
10831        && value
10832            .bytes()
10833            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
10834}
10835
10836fn identity_fingerprint(public_key_spki: &str) -> LinkResult<String> {
10837    let der = URL_SAFE_NO_PAD
10838        .decode(public_key_spki)
10839        .map_err(|_| invalid_feed("identity public key is not base64url"))?;
10840    if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
10841        return Err(invalid_feed(
10842            "identity public key is not a valid Ed25519 SPKI",
10843        ));
10844    }
10845    Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(&der)))
10846}
10847
10848/// Verify the hub's current identity and every old-key-signed rotation that
10849/// leads from the original TOFU anchor to it. `previous` alone is metadata,
10850/// never authority.
10851fn verify_identity_chain(
10852    identity: &FeedIdentity,
10853    pinned: Option<&TrustState>,
10854) -> LinkResult<String> {
10855    if identity.previous.len() > MAX_IDENTITY_ROTATIONS
10856        || identity.rotations.len() > MAX_IDENTITY_ROTATIONS
10857    {
10858        return Err(invalid_feed(
10859            "identity rotation history exceeds the client cap",
10860        ));
10861    }
10862    if identity.fingerprint != identity_fingerprint(&identity.public_key_spki)? {
10863        return Err(invalid_feed(
10864            "current identity fingerprint does not match its public key",
10865        ));
10866    }
10867    for previous in &identity.previous {
10868        if previous.fingerprint != identity_fingerprint(&previous.public_key_spki)? {
10869            return Err(invalid_feed(
10870                "previous identity fingerprint does not match its public key",
10871            ));
10872        }
10873    }
10874    if identity.rotations.len() != identity.previous.len() {
10875        return Err(invalid_feed(
10876            "identity history is missing an old-key-signed rotation statement",
10877        ));
10878    }
10879
10880    // The HTTP identity lists prior identities newest first. Rotation
10881    // statements are chronological, so verification walks the reversed list
10882    // and ends at the current identity.
10883    let mut chain: Vec<(&str, &str)> = identity
10884        .previous
10885        .iter()
10886        .rev()
10887        .map(|p| (p.fingerprint.as_str(), p.public_key_spki.as_str()))
10888        .collect();
10889    chain.push((&identity.fingerprint, &identity.public_key_spki));
10890
10891    for (index, raw) in identity.rotations.iter().enumerate() {
10892        let statement: RotationStatement = serde_json::from_str(raw)
10893            .map_err(|_| invalid_feed("rotation statement did not parse exactly"))?;
10894        let (old_fingerprint, old_spki) = chain[index];
10895        let (new_fingerprint, new_spki) = chain[index + 1];
10896        if statement.v != 1
10897            || statement.op != "rotate"
10898            || statement.brain != format!("ed25519:{old_fingerprint}")
10899            || statement.public_key != old_spki
10900            || statement.new_brain != format!("ed25519:{new_fingerprint}")
10901            || statement.new_public_key != new_spki
10902            || (statement.prior_head_seq == 0 && statement.prior_feed_hash.is_some())
10903            || (statement.prior_head_seq > 0
10904                && statement
10905                    .prior_feed_hash
10906                    .as_deref()
10907                    .is_none_or(|hash| !is_sha256(hash)))
10908        {
10909            return Err(invalid_feed(
10910                "rotation statement does not connect adjacent identities",
10911            ));
10912        }
10913        let unsigned = serde_json::to_string(&UnsignedRotation {
10914            v: statement.v,
10915            op: &statement.op,
10916            brain: &statement.brain,
10917            public_key: &statement.public_key,
10918            new_brain: &statement.new_brain,
10919            new_public_key: &statement.new_public_key,
10920            prior_head_seq: statement.prior_head_seq,
10921            prior_feed_hash: statement.prior_feed_hash.as_deref(),
10922            ts: statement.ts.clone(),
10923        })
10924        .map_err(|_| invalid_feed("could not canonicalize rotation statement"))?;
10925        let exact = format!(
10926            "{},\"sig\":\"{}\"}}",
10927            &unsigned[..unsigned.len() - 1],
10928            statement.sig
10929        );
10930        if exact != *raw {
10931            return Err(invalid_feed(
10932                "rotation statement is not in normative serialization",
10933            ));
10934        }
10935        let der = URL_SAFE_NO_PAD
10936            .decode(old_spki)
10937            .map_err(|_| invalid_feed("rotation public key is not base64url"))?;
10938        let signature = URL_SAFE_NO_PAD
10939            .decode(&statement.sig)
10940            .map_err(|_| invalid_feed("rotation signature is not base64url"))?;
10941        UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
10942            .verify(unsigned.as_bytes(), &signature)
10943            .map_err(|_| invalid_feed("rotation signature verification failed"))?;
10944        if index > 0 {
10945            let prior: RotationStatement = serde_json::from_str(&identity.rotations[index - 1])
10946                .map_err(|_| invalid_feed("prior rotation statement did not parse"))?;
10947            if statement.prior_head_seq < prior.prior_head_seq {
10948                return Err(invalid_feed("rotation feed boundaries move backward"));
10949            }
10950        }
10951    }
10952
10953    let anchor = format!("ed25519:{}", chain[0].0);
10954    let current = format!("ed25519:{}", identity.fingerprint);
10955    if let Some(pin) = pinned {
10956        if pin.anchor != anchor {
10957            return Err(invalid_feed(
10958                "served identity chain does not descend from the pinned anchor",
10959            ));
10960        }
10961        if !chain
10962            .iter()
10963            .any(|(fingerprint, _)| pin.current == format!("ed25519:{fingerprint}"))
10964        {
10965            return Err(invalid_feed(
10966                "served identity chain forked away from the last pinned identity",
10967            ));
10968        }
10969        if pin.current == current && pin.anchor != current && identity.rotations.is_empty() {
10970            return Err(invalid_feed("served identity discarded its rotation chain"));
10971        }
10972        if pin.v >= 2
10973            && (identity.rotations.len() < pin.rotations.len()
10974                || identity.rotations[..pin.rotations.len()] != pin.rotations)
10975        {
10976            return Err(invalid_feed(
10977                "served identity rewrote the locally accepted rotation history",
10978            ));
10979        }
10980    }
10981    Ok(anchor)
10982}
10983
10984fn verify_rotation_feed_boundaries(
10985    identity: &FeedIdentity,
10986    pinned: Option<&TrustState>,
10987    observed: &[FeedItem],
10988    advertised_seq: u64,
10989) -> LinkResult<()> {
10990    let mut chain: Vec<String> = identity
10991        .previous
10992        .iter()
10993        .rev()
10994        .map(|previous| format!("ed25519:{}", previous.fingerprint))
10995        .collect();
10996    chain.push(format!("ed25519:{}", identity.fingerprint));
10997    let pinned_index = pinned.and_then(|pin| chain.iter().position(|key| key == &pin.current));
10998
10999    for (index, raw) in identity.rotations.iter().enumerate() {
11000        let rotation: RotationStatement = serde_json::from_str(raw)
11001            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
11002        if rotation.prior_head_seq > advertised_seq {
11003            return Err(invalid_feed(
11004                "rotation claims a feed boundary beyond the advertised head",
11005            ));
11006        }
11007        if let (Some(pin), Some(pin_index)) = (pinned, pinned_index) {
11008            if index >= pin_index && rotation.prior_head_seq < pin.head_seq {
11009                return Err(invalid_feed(
11010                    "newly disclosed rotation predates the local feed checkpoint",
11011                ));
11012            }
11013        }
11014        let actual = if rotation.prior_head_seq == 0 {
11015            None
11016        } else if pinned.is_some_and(|pin| pin.head_seq == rotation.prior_head_seq) {
11017            pinned.and_then(|pin| pin.feed_hash.as_deref())
11018        } else {
11019            observed
11020                .iter()
11021                .find(|item| item.entry.seq == rotation.prior_head_seq)
11022                .map(|item| item.hash.as_str())
11023        };
11024        if let Some(actual) = actual {
11025            if rotation.prior_feed_hash.as_deref() != Some(actual) {
11026                return Err(invalid_feed(
11027                    "rotation statement does not commit the verified feed boundary",
11028                ));
11029            }
11030        } else if rotation.prior_head_seq == 0 {
11031            // The empty-feed boundary is represented by (0, null); there is
11032            // no entry hash to find in `observed`.
11033        } else if pinned.is_some_and(|pin| {
11034            pinned_index.is_some_and(|pin_index| index >= pin_index)
11035                || rotation.prior_head_seq >= pin.head_seq
11036        }) {
11037            return Err(invalid_feed(
11038                "rotation feed boundary was not present in the verified chain",
11039            ));
11040        }
11041    }
11042    Ok(())
11043}
11044
11045/// Once a client has checkpointed identity K, identities retired before K may
11046/// verify old history but can never regain authority over a later sequence.
11047/// This is also the safe migration rule for legacy v1 checkpoints that did not
11048/// persist the exact accepted rotation statements.
11049fn reject_retired_signer_after_checkpoint(
11050    identity: &FeedIdentity,
11051    pinned: Option<&TrustState>,
11052    item: &FeedItem,
11053) -> LinkResult<()> {
11054    let Some(pin) = pinned else {
11055        return Ok(());
11056    };
11057    if item.entry.seq <= pin.head_seq {
11058        return Ok(());
11059    }
11060    let mut chain: Vec<String> = identity
11061        .previous
11062        .iter()
11063        .rev()
11064        .map(|previous| format!("ed25519:{}", previous.fingerprint))
11065        .collect();
11066    chain.push(format!("ed25519:{}", identity.fingerprint));
11067    let pinned_index = chain
11068        .iter()
11069        .position(|key| key == &pin.current)
11070        .ok_or_else(|| invalid_feed("pinned identity is absent from the served chain"))?;
11071    let signer_index = chain
11072        .iter()
11073        .position(|key| key == &item.entry.brain)
11074        .ok_or_else(|| invalid_feed("feed signer is absent from the served identity chain"))?;
11075    if signer_index < pinned_index {
11076        return Err(invalid_feed(
11077            "a retired identity attempted to sign after the local checkpoint",
11078        ));
11079    }
11080    Ok(())
11081}
11082
11083fn trust_file_name(cfg: &HubConfig, brain: &str) -> LinkResult<String> {
11084    let origin = normalized_origin(&cfg.hub)?;
11085    let key = format!(
11086        "{:x}",
11087        Sha256::digest(format!("{origin}\0{brain}").as_bytes())
11088    );
11089    Ok(format!("{key}.json"))
11090}
11091
11092fn alias_file_name(cfg: &HubConfig, alias: &str) -> LinkResult<String> {
11093    let origin = normalized_origin(&cfg.hub)?;
11094    let key = format!(
11095        "{:x}",
11096        Sha256::digest(format!("{origin}\0alias\0{alias}").as_bytes())
11097    );
11098    Ok(format!("alias-{key}.json"))
11099}
11100
11101#[cfg(any(unix, windows))]
11102struct TrustLock {
11103    _file: std::fs::File,
11104}
11105
11106#[cfg(unix)]
11107fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
11108    use std::os::fd::{AsRawFd as _, FromRawFd as _};
11109
11110    let lock_string = format!(".{state_name}.lock");
11111    let lock_name = c_name(lock_string.as_bytes(), &lock_string)?;
11112    let fd = unsafe {
11113        libc::openat(
11114            directory.as_raw_fd(),
11115            lock_name.as_ptr(),
11116            libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11117            0o600,
11118        )
11119    };
11120    if fd < 0 {
11121        return Err(std::io::Error::last_os_error().into());
11122    }
11123    let file = unsafe { std::fs::File::from_raw_fd(fd) };
11124    if !file.metadata()?.is_file() {
11125        return Err(LinkError::UnsafePath { path: lock_string });
11126    }
11127    if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
11128        return Err(std::io::Error::last_os_error().into());
11129    }
11130    Ok(TrustLock { _file: file })
11131}
11132
11133#[cfg(windows)]
11134fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
11135    let lock_name = format!(".{state_name}.lock");
11136    let file = crate::fsx::lock_exclusive_beneath(directory, Path::new(&lock_name))?;
11137    Ok(TrustLock { _file: file })
11138}
11139
11140#[cfg(any(unix, windows))]
11141fn lock_trust_many(
11142    cfg: &HubConfig,
11143    directory: &std::fs::File,
11144    refs: &[&str],
11145) -> LinkResult<Vec<TrustLock>> {
11146    let mut names = refs
11147        .iter()
11148        .map(|reference| trust_file_name(cfg, reference))
11149        .collect::<LinkResult<Vec<_>>>()?;
11150    names.sort();
11151    names.dedup();
11152    names
11153        .iter()
11154        .map(|name| lock_trust_name(directory, name))
11155        .collect()
11156}
11157
11158#[cfg(not(any(unix, windows)))]
11159fn lock_trust_many(
11160    _cfg: &HubConfig,
11161    _directory: &TrustDirectory,
11162    _refs: &[&str],
11163) -> LinkResult<Vec<()>> {
11164    Err(LinkError::UnsupportedPlatform {
11165        operation: "verified link.md state",
11166    })
11167}
11168
11169#[cfg(any(unix, windows))]
11170type TrustDirectory = std::fs::File;
11171
11172#[cfg(not(any(unix, windows)))]
11173struct TrustDirectory;
11174
11175#[cfg(unix)]
11176fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
11177    use std::os::fd::AsRawFd as _;
11178
11179    let directory = open_or_create_dir_nofollow(&cfg.state_dir.join("trust"))?;
11180    if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
11181        return Err(std::io::Error::last_os_error().into());
11182    }
11183    directory.sync_all()?;
11184    Ok(directory)
11185}
11186
11187#[cfg(windows)]
11188fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
11189    let marker = cfg.state_dir.join("trust").join(".directory");
11190    crate::fsx::write_atomic(&marker, b"link.md trust directory\n")?;
11191    Ok(crate::fsx::open_directory_nofollow(
11192        marker.parent().expect("trust marker has a parent"),
11193    )?)
11194}
11195
11196#[cfg(not(any(unix, windows)))]
11197fn open_trust_dir(_cfg: &HubConfig) -> LinkResult<TrustDirectory> {
11198    Err(LinkError::UnsupportedPlatform {
11199        operation: "verified link.md state",
11200    })
11201}
11202
11203#[cfg(unix)]
11204fn load_trust_in(
11205    cfg: &HubConfig,
11206    directory: &TrustDirectory,
11207    requested: &str,
11208) -> LinkResult<Option<TrustState>> {
11209    use std::os::fd::{AsRawFd as _, FromRawFd as _};
11210
11211    let name_string = trust_file_name(cfg, requested)?;
11212    let name = c_name(name_string.as_bytes(), &name_string)?;
11213    let fd = unsafe {
11214        libc::openat(
11215            directory.as_raw_fd(),
11216            name.as_ptr(),
11217            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11218        )
11219    };
11220    if fd < 0 {
11221        let error = std::io::Error::last_os_error();
11222        if error.kind() == std::io::ErrorKind::NotFound {
11223            return Ok(None);
11224        }
11225        return Err(LinkError::UnsafePath { path: name_string });
11226    }
11227    let file = unsafe { std::fs::File::from_raw_fd(fd) };
11228    if !file.metadata()?.is_file() {
11229        return Err(LinkError::UnsafePath { path: name_string });
11230    }
11231    let mut bytes = Vec::new();
11232    file.take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
11233    if bytes.len() > 1024 * 1024 {
11234        return Err(invalid_feed("local identity/feed checkpoint is oversized"));
11235    }
11236    let mut state: TrustState = serde_json::from_slice(&bytes)
11237        .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
11238    if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
11239        return Err(invalid_feed(
11240            "local identity/feed checkpoint does not match this hub and brain",
11241        ));
11242    }
11243    if state.v == 1 {
11244        // Legacy files were keyed by canonical brain id. They can migrate only
11245        // when the caller used that exact id; old slug lookups had no durable
11246        // alias binding and therefore cannot be guessed safely.
11247        if state.brain != requested {
11248            return Err(invalid_feed(
11249                "legacy checkpoint is not bound to the requested brain id",
11250            ));
11251        }
11252        state.requested = requested.to_string();
11253    } else if state.requested != requested {
11254        return Err(invalid_feed(
11255            "local identity/feed checkpoint is bound to a different requested ref",
11256        ));
11257    }
11258    Ok(Some(state))
11259}
11260
11261#[cfg(windows)]
11262fn load_trust_in(
11263    cfg: &HubConfig,
11264    directory: &TrustDirectory,
11265    requested: &str,
11266) -> LinkResult<Option<TrustState>> {
11267    let name = trust_file_name(cfg, requested)?;
11268    let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
11269    let bytes = match reader.read(Path::new(&name), 1024 * 1024) {
11270        Ok(bytes) => bytes,
11271        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
11272        Err(_) => return Err(LinkError::UnsafePath { path: name }),
11273    };
11274    let mut state: TrustState = serde_json::from_slice(&bytes)
11275        .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
11276    if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
11277        return Err(invalid_feed(
11278            "local identity/feed checkpoint does not match this hub and brain",
11279        ));
11280    }
11281    if state.v == 1 {
11282        if state.brain != requested {
11283            return Err(invalid_feed(
11284                "legacy checkpoint is not bound to the requested brain id",
11285            ));
11286        }
11287        state.requested = requested.to_string();
11288    } else if state.requested != requested {
11289        return Err(invalid_feed(
11290            "local identity/feed checkpoint is bound to a different requested ref",
11291        ));
11292    }
11293    Ok(Some(state))
11294}
11295
11296#[cfg(not(any(unix, windows)))]
11297fn load_trust_in(
11298    _cfg: &HubConfig,
11299    _directory: &TrustDirectory,
11300    _brain: &str,
11301) -> LinkResult<Option<TrustState>> {
11302    Err(LinkError::UnsupportedPlatform {
11303        operation: "verified link.md state",
11304    })
11305}
11306
11307#[cfg(all(test, any(unix, windows)))]
11308fn load_trust(cfg: &HubConfig, requested: &str) -> LinkResult<Option<TrustState>> {
11309    let directory = open_trust_dir(cfg)?;
11310    load_trust_in(cfg, &directory, requested)
11311}
11312
11313#[cfg(unix)]
11314fn save_trust_in(
11315    cfg: &HubConfig,
11316    directory: &TrustDirectory,
11317    state: &TrustState,
11318) -> LinkResult<()> {
11319    use std::os::fd::{AsRawFd as _, FromRawFd as _};
11320
11321    let name_string = trust_file_name(cfg, &state.requested)?;
11322    let name = c_name(name_string.as_bytes(), &name_string)?;
11323    let mut bytes = serde_json::to_vec(state)
11324        .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
11325    bytes.push(b'\n');
11326
11327    let nonce = std::time::SystemTime::now()
11328        .duration_since(std::time::UNIX_EPOCH)
11329        .unwrap_or_default()
11330        .as_nanos();
11331    let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
11332    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
11333    let fd = unsafe {
11334        libc::openat(
11335            directory.as_raw_fd(),
11336            temp.as_ptr(),
11337            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11338            0o600,
11339        )
11340    };
11341    if fd < 0 {
11342        return Err(std::io::Error::last_os_error().into());
11343    }
11344    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
11345    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
11346        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
11347        return Err(error.into());
11348    }
11349    drop(file);
11350    if unsafe {
11351        libc::renameat(
11352            directory.as_raw_fd(),
11353            temp.as_ptr(),
11354            directory.as_raw_fd(),
11355            name.as_ptr(),
11356        )
11357    } != 0
11358    {
11359        let error = std::io::Error::last_os_error();
11360        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
11361        return Err(error.into());
11362    }
11363    directory.sync_all()?;
11364    Ok(())
11365}
11366
11367#[cfg(windows)]
11368fn save_trust_in(
11369    cfg: &HubConfig,
11370    directory: &TrustDirectory,
11371    state: &TrustState,
11372) -> LinkResult<()> {
11373    let name = trust_file_name(cfg, &state.requested)?;
11374    let mut bytes = serde_json::to_vec(state)
11375        .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
11376    bytes.push(b'\n');
11377    crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
11378    Ok(())
11379}
11380
11381#[cfg(not(any(unix, windows)))]
11382fn save_trust_in(
11383    _cfg: &HubConfig,
11384    _directory: &TrustDirectory,
11385    _state: &TrustState,
11386) -> LinkResult<()> {
11387    Err(LinkError::UnsupportedPlatform {
11388        operation: "verified link.md state",
11389    })
11390}
11391
11392#[cfg(unix)]
11393fn load_alias_in(
11394    cfg: &HubConfig,
11395    directory: &TrustDirectory,
11396    requested: &str,
11397) -> LinkResult<Option<AliasBinding>> {
11398    use std::os::fd::{AsRawFd as _, FromRawFd as _};
11399
11400    let name_string = alias_file_name(cfg, requested)?;
11401    let name = c_name(name_string.as_bytes(), &name_string)?;
11402    let fd = unsafe {
11403        libc::openat(
11404            directory.as_raw_fd(),
11405            name.as_ptr(),
11406            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11407        )
11408    };
11409    if fd < 0 {
11410        let error = std::io::Error::last_os_error();
11411        if error.kind() == std::io::ErrorKind::NotFound {
11412            return Ok(None);
11413        }
11414        return Err(LinkError::UnsafePath { path: name_string });
11415    }
11416    let file = unsafe { std::fs::File::from_raw_fd(fd) };
11417    if !file.metadata()?.is_file() {
11418        return Err(LinkError::UnsafePath { path: name_string });
11419    }
11420    let mut bytes = Vec::new();
11421    file.take(64 * 1024 + 1).read_to_end(&mut bytes)?;
11422    if bytes.len() > 64 * 1024 {
11423        return Err(invalid_feed("local alias binding is oversized"));
11424    }
11425    let alias: AliasBinding = serde_json::from_slice(&bytes)
11426        .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
11427    if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
11428    {
11429        return Err(invalid_feed(
11430            "local alias binding does not match this hub and requested ref",
11431        ));
11432    }
11433    Ok(Some(alias))
11434}
11435
11436#[cfg(windows)]
11437fn load_alias_in(
11438    cfg: &HubConfig,
11439    directory: &TrustDirectory,
11440    requested: &str,
11441) -> LinkResult<Option<AliasBinding>> {
11442    let name = alias_file_name(cfg, requested)?;
11443    let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
11444    let bytes = match reader.read(Path::new(&name), 64 * 1024) {
11445        Ok(bytes) => bytes,
11446        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
11447        Err(_) => return Err(LinkError::UnsafePath { path: name }),
11448    };
11449    let alias: AliasBinding = serde_json::from_slice(&bytes)
11450        .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
11451    if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
11452    {
11453        return Err(invalid_feed(
11454            "local alias binding does not match this hub and requested ref",
11455        ));
11456    }
11457    Ok(Some(alias))
11458}
11459
11460#[cfg(not(any(unix, windows)))]
11461fn load_alias_in(
11462    _cfg: &HubConfig,
11463    _directory: &TrustDirectory,
11464    _requested: &str,
11465) -> LinkResult<Option<AliasBinding>> {
11466    Err(LinkError::UnsupportedPlatform {
11467        operation: "verified link.md state",
11468    })
11469}
11470
11471#[cfg(unix)]
11472fn save_alias_in(
11473    cfg: &HubConfig,
11474    directory: &TrustDirectory,
11475    alias: &AliasBinding,
11476) -> LinkResult<()> {
11477    use std::os::fd::{AsRawFd as _, FromRawFd as _};
11478
11479    let name_string = alias_file_name(cfg, &alias.requested)?;
11480    let name = c_name(name_string.as_bytes(), &name_string)?;
11481    let mut bytes = serde_json::to_vec(alias)
11482        .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
11483    bytes.push(b'\n');
11484    let nonce = std::time::SystemTime::now()
11485        .duration_since(std::time::UNIX_EPOCH)
11486        .unwrap_or_default()
11487        .as_nanos();
11488    let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
11489    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
11490    let fd = unsafe {
11491        libc::openat(
11492            directory.as_raw_fd(),
11493            temp.as_ptr(),
11494            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11495            0o600,
11496        )
11497    };
11498    if fd < 0 {
11499        return Err(std::io::Error::last_os_error().into());
11500    }
11501    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
11502    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
11503        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
11504        return Err(error.into());
11505    }
11506    drop(file);
11507    if unsafe {
11508        libc::renameat(
11509            directory.as_raw_fd(),
11510            temp.as_ptr(),
11511            directory.as_raw_fd(),
11512            name.as_ptr(),
11513        )
11514    } != 0
11515    {
11516        let error = std::io::Error::last_os_error();
11517        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
11518        return Err(error.into());
11519    }
11520    directory.sync_all()?;
11521    Ok(())
11522}
11523
11524#[cfg(windows)]
11525fn save_alias_in(
11526    cfg: &HubConfig,
11527    directory: &TrustDirectory,
11528    alias: &AliasBinding,
11529) -> LinkResult<()> {
11530    let name = alias_file_name(cfg, &alias.requested)?;
11531    let mut bytes = serde_json::to_vec(alias)
11532        .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
11533    bytes.push(b'\n');
11534    crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
11535    Ok(())
11536}
11537
11538#[cfg(not(any(unix, windows)))]
11539fn save_alias_in(
11540    _cfg: &HubConfig,
11541    _directory: &TrustDirectory,
11542    _alias: &AliasBinding,
11543) -> LinkResult<()> {
11544    Err(LinkError::UnsupportedPlatform {
11545        operation: "verified link.md state",
11546    })
11547}
11548
11549/// Load the canonical checkpoint shared by every spelling of one brain and
11550/// the separate alias binding. This also performs the one-way migration from
11551/// pre-v3 checkpoints that stored a full trust state under the alias itself.
11552/// Callers hold both alias and canonical locks before entering.
11553fn load_canonical_pin(
11554    cfg: &HubConfig,
11555    directory: &TrustDirectory,
11556    requested: &str,
11557    resolved_brain: &str,
11558) -> LinkResult<(Option<TrustState>, Option<AliasBinding>)> {
11559    let mut canonical = load_trust_in(cfg, directory, resolved_brain)?;
11560    if requested == resolved_brain {
11561        return Ok((canonical, None));
11562    }
11563
11564    let mut alias = load_alias_in(cfg, directory, requested)?;
11565    if let Some(binding) = &alias {
11566        if binding.brain != resolved_brain {
11567            return Err(invalid_feed(
11568                "requested brain alias now resolves to a different canonical brain",
11569            ));
11570        }
11571        return Ok((canonical, alias));
11572    }
11573
11574    // A v2 build stored the complete checkpoint under the requested slug.
11575    // Promote it to the canonical ULID key before creating the lightweight
11576    // alias binding. Never silently merge two independently advanced states.
11577    if let Some(legacy) = load_trust_in(cfg, directory, requested)? {
11578        if legacy.brain != resolved_brain {
11579            return Err(invalid_feed(
11580                "legacy alias checkpoint names a different canonical brain",
11581            ));
11582        }
11583        if let Some(existing) = &canonical {
11584            if existing.brain != legacy.brain
11585                || existing.anchor != legacy.anchor
11586                || existing.current != legacy.current
11587                || existing.head_seq != legacy.head_seq
11588                || existing.feed_hash != legacy.feed_hash
11589                || existing.rotations != legacy.rotations
11590            {
11591                return Err(invalid_feed(
11592                    "legacy alias checkpoint conflicts with the canonical checkpoint",
11593                ));
11594            }
11595        } else {
11596            let mut promoted = legacy.clone();
11597            promoted.requested = resolved_brain.to_string();
11598            promoted.home = None;
11599            save_trust_in(cfg, directory, &promoted)?;
11600            canonical = Some(promoted);
11601        }
11602        alias = Some(AliasBinding {
11603            v: 1,
11604            origin: normalized_origin(&cfg.hub)?,
11605            requested: requested.to_string(),
11606            brain: resolved_brain.to_string(),
11607            home: legacy.home,
11608        });
11609        save_alias_in(cfg, directory, alias.as_ref().expect("alias just created"))?;
11610    }
11611    Ok((canonical, alias))
11612}
11613
11614fn save_canonical_pin_and_alias(
11615    cfg: &HubConfig,
11616    directory: &TrustDirectory,
11617    requested: &str,
11618    resolved_brain: &str,
11619    mut state: TrustState,
11620    existing_alias: Option<&AliasBinding>,
11621) -> LinkResult<()> {
11622    state.requested = resolved_brain.to_string();
11623    state.brain = resolved_brain.to_string();
11624    state.home = None;
11625    save_trust_in(cfg, directory, &state)?;
11626    if requested != resolved_brain {
11627        save_alias_in(
11628            cfg,
11629            directory,
11630            &AliasBinding {
11631                v: 1,
11632                origin: normalized_origin(&cfg.hub)?,
11633                requested: requested.to_string(),
11634                brain: resolved_brain.to_string(),
11635                home: existing_alias.and_then(|alias| alias.home.clone()),
11636            },
11637        )?;
11638    }
11639    Ok(())
11640}
11641
11642fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
11643    const ED25519_SPKI_PREFIX: &[u8] = &[
11644        0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
11645    ];
11646    let entry = &item.entry;
11647    let public_der = URL_SAFE_NO_PAD
11648        .decode(&entry.public_key)
11649        .map_err(|_| invalid_feed("public key is not base64url"))?;
11650    if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
11651        || !public_der.starts_with(ED25519_SPKI_PREFIX)
11652    {
11653        return Err(invalid_feed("entry public key is not a valid Ed25519 SPKI"));
11654    }
11655    let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
11656    if entry.brain != format!("ed25519:{fingerprint}") {
11657        return Err(invalid_feed(
11658            "brain fingerprint does not match its public key",
11659        ));
11660    }
11661    // Verify the complete rotation authority before using any previous key.
11662    let _ = verify_identity_chain(identity, None)?;
11663    let mut chain: Vec<(&str, &str)> = identity
11664        .previous
11665        .iter()
11666        .rev()
11667        .map(|previous| {
11668            (
11669                previous.fingerprint.as_str(),
11670                previous.public_key_spki.as_str(),
11671            )
11672        })
11673        .collect();
11674    chain.push((&identity.fingerprint, &identity.public_key_spki));
11675    let signer_index = chain.iter().position(|(known_fingerprint, spki)| {
11676        *known_fingerprint == fingerprint && *spki == entry.public_key
11677    });
11678    let Some(signer_index) = signer_index else {
11679        return Err(invalid_feed(
11680            "entry signer is not this brain's identity (current or rotated-from)",
11681        ));
11682    };
11683    let lower_boundary = if signer_index == 0 {
11684        None
11685    } else {
11686        let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
11687            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
11688        Some(prior.prior_head_seq)
11689    };
11690    let upper_boundary = if signer_index == identity.rotations.len() {
11691        None
11692    } else {
11693        let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
11694            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
11695        Some(next.prior_head_seq)
11696    };
11697    if lower_boundary.is_some_and(|boundary| entry.seq <= boundary)
11698        || upper_boundary.is_some_and(|boundary| entry.seq > boundary)
11699    {
11700        return Err(invalid_feed(
11701            "entry signer is outside its authenticated rotation epoch",
11702        ));
11703    }
11704    let unsigned = UnsignedFeedEntry {
11705        v: entry.v,
11706        seq: entry.seq,
11707        ts: &entry.ts,
11708        brain: &entry.brain,
11709        public_key: &entry.public_key,
11710        kind: &entry.kind,
11711        op: &entry.op,
11712        pack_sha256: &entry.pack_sha256,
11713        files: &entry.files,
11714        removed: &entry.removed,
11715        prev_entry_hash: &entry.prev_entry_hash,
11716    };
11717    let message =
11718        serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
11719    let signature = URL_SAFE_NO_PAD
11720        .decode(&entry.sig)
11721        .map_err(|_| invalid_feed("signature is not base64url"))?;
11722    UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
11723        .verify(&message, &signature)
11724        .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
11725
11726    let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
11727    exact.push(b'\n');
11728    let actual_hash = format!("{:x}", Sha256::digest(&exact));
11729    if actual_hash != item.hash {
11730        return Err(invalid_feed("entry SHA-256 does not match"));
11731    }
11732    Ok(())
11733}
11734
11735// ─────────────────────────────────────────────────────────────────────────────
11736// key rotation — link.md §9.1: the new key, signed by the old one
11737// ─────────────────────────────────────────────────────────────────────────────
11738
11739/// The unsigned rotation statement in its normative field order.
11740#[derive(Serialize)]
11741struct UnsignedRotation<'a> {
11742    v: u8,
11743    op: &'a str,
11744    brain: &'a str,
11745    public_key: &'a str,
11746    new_brain: &'a str,
11747    new_public_key: &'a str,
11748    prior_head_seq: u64,
11749    prior_feed_hash: Option<&'a str>,
11750    ts: String,
11751}
11752
11753/// Durable intent for an in-flight key rotation. The hub's recovery contract
11754/// identifies an ambiguous retry by the exact statement bytes, so the
11755/// statement cannot be reconstructed from the key and feed boundary later:
11756/// its timestamp and signature would differ.
11757#[derive(Debug, Deserialize, Serialize)]
11758#[serde(deny_unknown_fields)]
11759struct RotationJournal {
11760    v: u8,
11761    origin: String,
11762    brain: String,
11763    old_brain: String,
11764    new_brain: String,
11765    prior_head_seq: u64,
11766    prior_feed_hash: Option<String>,
11767    statement: String,
11768}
11769
11770fn rotation_journal_path(key_path: &Path) -> PathBuf {
11771    let mut path = key_path.as_os_str().to_os_string();
11772    path.push(".rotation.json");
11773    PathBuf::from(path)
11774}
11775
11776fn read_rotation_journal(path: &Path) -> LinkResult<RotationJournal> {
11777    #[cfg(unix)]
11778    let file = {
11779        use std::os::fd::{AsRawFd as _, FromRawFd as _};
11780        use std::os::unix::ffi::OsStrExt as _;
11781        let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
11782            .map_err(|error| {
11783                bad_agent_key(&format!("cannot open the rotation journal parent: {error}"))
11784            })?;
11785        let leaf_name = path
11786            .file_name()
11787            .ok_or_else(|| bad_agent_key("the rotation journal path has no file name"))?;
11788        let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
11789        let fd = unsafe {
11790            libc::openat(
11791                parent.as_raw_fd(),
11792                leaf.as_ptr(),
11793                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11794            )
11795        };
11796        if fd < 0 {
11797            return Err(bad_agent_key(
11798                "the rotation journal must be an existing regular file without symlink ancestors",
11799            ));
11800        }
11801        unsafe { std::fs::File::from_raw_fd(fd) }
11802    };
11803    #[cfg(not(unix))]
11804    let file = std::fs::File::open(path)
11805        .map_err(|error| bad_agent_key(&format!("cannot read the rotation journal: {error}")))?;
11806    let metadata = file
11807        .metadata()
11808        .map_err(|error| bad_agent_key(&format!("cannot inspect the rotation journal: {error}")))?;
11809    if !metadata.is_file() || metadata.len() > MAX_REGISTRY_CARD_BYTES {
11810        return Err(bad_agent_key(
11811            "the rotation journal must be a bounded regular file",
11812        ));
11813    }
11814    #[cfg(unix)]
11815    {
11816        use std::os::unix::fs::PermissionsExt as _;
11817        if metadata.permissions().mode() & 0o077 != 0 {
11818            return Err(bad_agent_key(
11819                "the rotation journal is accessible to group/other; set mode 0600",
11820            ));
11821        }
11822    }
11823    serde_json::from_reader(file)
11824        .map_err(|_| bad_agent_key("the rotation journal is not valid exact JSON"))
11825}
11826
11827fn remove_rotation_journal(path: &Path) {
11828    #[cfg(unix)]
11829    {
11830        use std::os::fd::AsRawFd as _;
11831        use std::os::unix::ffi::OsStrExt as _;
11832        let Ok(parent) =
11833            open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
11834        else {
11835            return;
11836        };
11837        let Some(leaf_name) = path.file_name() else {
11838            return;
11839        };
11840        let Ok(leaf) = c_name(leaf_name.as_bytes(), &path.display().to_string()) else {
11841            return;
11842        };
11843        if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), 0) } == 0 {
11844            let _ = parent.sync_all();
11845        }
11846    }
11847    #[cfg(not(unix))]
11848    {
11849        let _ = std::fs::remove_file(path);
11850    }
11851}
11852
11853fn validate_rotation_journal(
11854    journal: &RotationJournal,
11855    cfg: &HubConfig,
11856    canonical_brain: &str,
11857    old_key: &AgentSigningKey,
11858    new_key: &AgentSigningKey,
11859    head: &Head,
11860) -> LinkResult<()> {
11861    if journal.v != 1
11862        || journal.origin != normalized_origin(&cfg.hub)?
11863        || journal.brain != canonical_brain
11864        || journal.old_brain != old_key.multikey
11865        || journal.new_brain != new_key.multikey
11866        || journal.prior_head_seq != head.seq
11867        || journal.prior_feed_hash != head.feed_hash
11868    {
11869        return Err(invalid_feed(
11870            "rotation journal does not match the verified key and feed boundary",
11871        ));
11872    }
11873    let statement: RotationStatement = serde_json::from_str(&journal.statement)
11874        .map_err(|_| invalid_feed("rotation journal statement did not parse exactly"))?;
11875    if statement.prior_head_seq != journal.prior_head_seq
11876        || statement.prior_feed_hash != journal.prior_feed_hash
11877        || statement.brain != old_key.multikey
11878        || statement.public_key != old_key.public_key_spki
11879        || statement.new_brain != new_key.multikey
11880        || statement.new_public_key != new_key.public_key_spki
11881    {
11882        return Err(invalid_feed(
11883            "rotation journal statement does not match its durable intent",
11884        ));
11885    }
11886    let identity = FeedIdentity {
11887        fingerprint: new_key.multikey.trim_start_matches("ed25519:").to_string(),
11888        public_key_spki: new_key.public_key_spki.clone(),
11889        previous: vec![PreviousIdentity {
11890            fingerprint: old_key.multikey.trim_start_matches("ed25519:").to_string(),
11891            public_key_spki: old_key.public_key_spki.clone(),
11892        }],
11893        rotations: vec![journal.statement.clone()],
11894    };
11895    verify_identity_chain(&identity, None)?;
11896    Ok(())
11897}
11898
11899/// What `dbmd key rotate` returns.
11900#[derive(Debug, Serialize)]
11901pub struct RotationReport {
11902    /// The brain id rotated.
11903    pub brain: String,
11904    /// The NEW identity the hub now serves.
11905    pub multikey: String,
11906    /// Where the new PKCS#8 secret landed (0600).
11907    #[serde(rename = "keyFile")]
11908    pub key_file: String,
11909    /// Prior identities (newest first) the feed still verifies against.
11910    pub previous: Vec<String>,
11911}
11912
11913/// Rotate a self-custodied brain's key: mint a fresh keypair, build the
11914/// §9.1 statement — the new key plus exact prior feed boundary, signed by the
11915/// OLD key in normative serialization — and send it to the hub. The new secret
11916/// is durably created at 0600 before the POST; an existing output is reused for
11917/// idempotent retry/reconciliation. The old key is left untouched.
11918pub fn rotate_brain_key(
11919    cfg: &HubConfig,
11920    brain: &str,
11921    old_key: &AgentSigningKey,
11922    out: &Path,
11923) -> LinkResult<RotationReport> {
11924    require_hardened_filesystem("key rotation")?;
11925    require_safe_ref(brain)?;
11926    // The new private key must be durable *before* the hub can accept its
11927    // public half. An existing file is the retry/reconciliation path after an
11928    // ambiguous network failure: reuse it, never generate another identity.
11929    let new_key = if out.exists() {
11930        load_signing_key(out)?
11931    } else {
11932        let rng = ring::rand::SystemRandom::new();
11933        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
11934            .map_err(|_| bad_agent_key("key generation failed"))?;
11935        let pair = agent_keypair(pkcs8.as_ref())?;
11936        let (public_key_spki, multikey) = public_identity_for(&pair);
11937        write_secret_new(
11938            out,
11939            format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
11940        )?;
11941        AgentSigningKey {
11942            pkcs8: pkcs8.as_ref().to_vec(),
11943            multikey,
11944            public_key_spki,
11945        }
11946    };
11947    let new_spki = new_key.public_key_spki.clone();
11948    let new_multikey = new_key.multikey.clone();
11949    let journal_path = rotation_journal_path(out);
11950    let before = verified_remote_head(cfg, brain, false)?;
11951    let served_identity = before
11952        .identity
11953        .as_ref()
11954        .ok_or_else(|| invalid_feed("cannot rotate a brain with no signed identity"))?;
11955    let served_multikey = format!("ed25519:{}", served_identity.fingerprint);
11956    if served_multikey == new_multikey {
11957        remove_rotation_journal(&journal_path);
11958        return Ok(RotationReport {
11959            brain: brain.to_string(),
11960            multikey: new_multikey,
11961            key_file: out.display().to_string(),
11962            previous: served_identity
11963                .previous
11964                .iter()
11965                .map(|identity| format!("ed25519:{}", identity.fingerprint))
11966                .collect(),
11967        });
11968    }
11969    if served_multikey != old_key.multikey {
11970        return Err(invalid_feed(
11971            "the supplied old key is not the brain's verified current identity",
11972        ));
11973    }
11974
11975    let journal = if journal_path.exists() {
11976        read_rotation_journal(&journal_path)?
11977    } else {
11978        let ts = crate::now()
11979            .with_timezone(&chrono::Utc)
11980            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
11981            .to_string();
11982        let unsigned = serde_json::to_string(&UnsignedRotation {
11983            v: 1,
11984            op: "rotate",
11985            brain: &old_key.multikey,
11986            public_key: &old_key.public_key_spki,
11987            new_brain: &new_multikey,
11988            new_public_key: &new_spki,
11989            prior_head_seq: before.head.seq,
11990            prior_feed_hash: before.head.feed_hash.as_deref(),
11991            ts,
11992        })
11993        .expect("serialize rotation");
11994        let old_pair = agent_keypair(&old_key.pkcs8)?;
11995        let sig = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
11996        let statement = format!("{},\"sig\":\"{}\"}}", &unsigned[..unsigned.len() - 1], sig);
11997        let journal = RotationJournal {
11998            v: 1,
11999            origin: normalized_origin(&cfg.hub)?,
12000            brain: before.head.brain.clone(),
12001            old_brain: old_key.multikey.clone(),
12002            new_brain: new_multikey.clone(),
12003            prior_head_seq: before.head.seq,
12004            prior_feed_hash: before.head.feed_hash.clone(),
12005            statement,
12006        };
12007        let mut exact = serde_json::to_vec(&journal)
12008            .map_err(|_| invalid_feed("could not serialize rotation journal"))?;
12009        exact.push(b'\n');
12010        if write_secret_new(&journal_path, &exact).is_err() {
12011            // A concurrent retry may have won the O_EXCL race. Only an exact,
12012            // fully validated journal is allowed to recover that race.
12013            read_rotation_journal(&journal_path)?
12014        } else {
12015            journal
12016        }
12017    };
12018    validate_rotation_journal(
12019        &journal,
12020        cfg,
12021        &before.head.brain,
12022        old_key,
12023        &new_key,
12024        &before.head,
12025    )?;
12026
12027    let body = json!({ "statement": journal.statement });
12028    let path = format!("/api/hub/brains/{brain}/rotate");
12029    let attempted = request(cfg, "POST", &path, Some(&body), Auth::Required);
12030    let attempted_failure = match attempted {
12031        Ok(response) if (200..300).contains(&response.status) => None,
12032        Ok(response) => Some(ensure_ok(response, "key rotate").unwrap_err()),
12033        Err(error) => Some(error),
12034    };
12035
12036    // A 2xx body is not authority, and a failed response may have followed a
12037    // committed mutation. In both cases success means the normal verifier sees
12038    // an append-only rotation chain ending at the durable new key.
12039    let after = match verified_remote_head(cfg, brain, false) {
12040        Ok(after) => after,
12041        Err(error) => return Err(attempted_failure.unwrap_or(error)),
12042    };
12043    let identity = after
12044        .identity
12045        .as_ref()
12046        .ok_or_else(|| invalid_feed("rotated brain has no verified identity"))?;
12047    if format!("ed25519:{}", identity.fingerprint) != new_multikey
12048        || identity.public_key_spki != new_spki
12049    {
12050        return Err(attempted_failure.unwrap_or_else(|| {
12051            invalid_feed("hub acknowledged rotation without committing the verified new identity")
12052        }));
12053    }
12054    let previous = identity
12055        .previous
12056        .iter()
12057        .map(|prior| format!("ed25519:{}", prior.fingerprint))
12058        .collect();
12059    remove_rotation_journal(&journal_path);
12060
12061    Ok(RotationReport {
12062        brain: brain.to_string(),
12063        multikey: new_multikey,
12064        key_file: out.display().to_string(),
12065        previous,
12066    })
12067}
12068
12069// ─────────────────────────────────────────────────────────────────────────────
12070// mirror — verified replication: the whole feed + files, re-servable
12071// ─────────────────────────────────────────────────────────────────────────────
12072
12073/// What `dbmd mirror` materialized.
12074#[derive(Debug, Serialize)]
12075pub struct MirrorReport {
12076    /// The brain id.
12077    pub brain: String,
12078    /// The mirrored feed head.
12079    #[serde(rename = "headSeq")]
12080    pub head_seq: u64,
12081    /// The head entry hash (the feed's advertised converged state).
12082    #[serde(rename = "feedHash")]
12083    pub feed_hash: Option<String>,
12084    /// Signed feed entries verified and stored.
12085    pub entries: u64,
12086    /// The brain's multikey, pinned in `.dbmd/config` (TOFU).
12087    pub pinned: String,
12088    /// Store files materialized by the pull.
12089    pub files: usize,
12090}
12091
12092/// The mirror state directory, relative to the mirror root.
12093pub const MIRROR_REL_DIR: &str = ".dbmd/mirror";
12094
12095/// Fully re-verified mirror material suitable for a read-only re-server.
12096#[derive(Debug)]
12097pub struct VerifiedMirrorMaterial {
12098    pub brain: String,
12099    pub head_seq: u64,
12100    pub feed_hash: Option<String>,
12101    pub identity: serde_json::Value,
12102    /// Sequence, exact normative entry JSON without newline, and entry hash.
12103    pub entries: Vec<(u64, String, String)>,
12104    pub pack_sha256: Option<String>,
12105}
12106
12107#[derive(Deserialize)]
12108#[serde(deny_unknown_fields)]
12109struct StoredMirrorHead {
12110    brain: String,
12111    #[serde(rename = "headSeq")]
12112    head_seq: u64,
12113    #[serde(rename = "feedHash")]
12114    feed_hash: Option<String>,
12115}
12116
12117/// Re-verify mirror metadata, every signature/hash/rotation boundary, and the
12118/// exact snapshot pack before `dbmd serve` exposes any bytes.
12119pub fn verify_mirror_material(
12120    head_bytes: &[u8],
12121    identity_bytes: &[u8],
12122    feed_bytes: &[Vec<u8>],
12123    snapshot_pack: Option<&[u8]>,
12124    expected_anchor: &str,
12125) -> LinkResult<VerifiedMirrorMaterial> {
12126    let snapshot_hash = snapshot_pack
12127        .filter(|pack| !pack.is_empty())
12128        .map(content_sha256);
12129    verify_mirror_material_with_pack_hash(
12130        head_bytes,
12131        identity_bytes,
12132        feed_bytes,
12133        snapshot_hash.as_deref(),
12134        expected_anchor,
12135    )
12136}
12137
12138/// Re-verify mirror metadata against a snapshot digest computed from a held
12139/// no-follow file capability. This lets `dbmd serve` authenticate and retain a
12140/// large pack without ever buffering the pack in process memory.
12141pub fn verify_mirror_material_with_pack_hash(
12142    head_bytes: &[u8],
12143    identity_bytes: &[u8],
12144    feed_bytes: &[Vec<u8>],
12145    snapshot_pack_sha256: Option<&str>,
12146    expected_anchor: &str,
12147) -> LinkResult<VerifiedMirrorMaterial> {
12148    let head: StoredMirrorHead = serde_json::from_slice(head_bytes)
12149        .map_err(|_| invalid_feed("stored mirror head did not parse exactly"))?;
12150    require_safe_ref(&head.brain)?;
12151    if head.head_seq > MAX_FEED_REPLAY_ENTRIES || feed_bytes.len() as u64 != head.head_seq {
12152        return Err(invalid_feed(
12153            "stored mirror feed count does not match its bounded head sequence",
12154        ));
12155    }
12156    let aggregate = feed_bytes
12157        .iter()
12158        .try_fold(0u64, |total, bytes| total.checked_add(bytes.len() as u64))
12159        .ok_or_else(|| invalid_feed("stored mirror feed size overflow"))?;
12160    if aggregate > MAX_FEED_REPLAY_BYTES {
12161        return Err(invalid_feed(
12162            "stored mirror feed metadata exceeds the aggregate limit",
12163        ));
12164    }
12165    let identity: FeedIdentity = serde_json::from_slice(identity_bytes)
12166        .map_err(|_| invalid_feed("stored mirror identity did not parse exactly"))?;
12167    let anchor = verify_identity_chain(&identity, None)?;
12168    if anchor != expected_anchor {
12169        return Err(invalid_feed(
12170            "stored mirror identity does not descend from the explicitly trusted anchor",
12171        ));
12172    }
12173
12174    let mut entries = Vec::with_capacity(feed_bytes.len());
12175    let mut items = Vec::with_capacity(feed_bytes.len());
12176    let mut previous_hash = None;
12177    let mut pack_sha256 = None;
12178    for (index, bytes) in feed_bytes.iter().enumerate() {
12179        let exact = bytes
12180            .strip_suffix(b"\n")
12181            .ok_or_else(|| invalid_feed("stored feed entry lacks its exact trailing newline"))?;
12182        if exact.ends_with(b"\n") {
12183            return Err(invalid_feed("stored feed entry has extra trailing bytes"));
12184        }
12185        let entry: FeedEntry = serde_json::from_slice(exact)
12186            .map_err(|_| invalid_feed("stored feed entry did not parse exactly"))?;
12187        let expected_seq = index as u64 + 1;
12188        if entry.seq != expected_seq || entry.prev_entry_hash != previous_hash {
12189            return Err(invalid_feed(
12190                "stored mirror feed is not contiguous and hash-chained",
12191            ));
12192        }
12193        let canonical = serde_json::to_vec(&entry)
12194            .map_err(|_| invalid_feed("could not canonicalize stored feed entry"))?;
12195        if canonical != exact {
12196            return Err(invalid_feed(
12197                "stored feed entry is not in normative serialization",
12198            ));
12199        }
12200        let hash = content_sha256(bytes);
12201        let item = FeedItem {
12202            hash: hash.clone(),
12203            entry,
12204        };
12205        verify_feed_item(&item, &identity)?;
12206        previous_hash = Some(hash.clone());
12207        if expected_seq == head.head_seq {
12208            pack_sha256 = Some(item.entry.pack_sha256.clone());
12209        }
12210        entries.push((
12211            expected_seq,
12212            std::str::from_utf8(exact)
12213                .map_err(|_| invalid_feed("stored feed entry is not UTF-8"))?
12214                .to_string(),
12215            hash,
12216        ));
12217        items.push(item);
12218    }
12219    if previous_hash != head.feed_hash {
12220        return Err(invalid_feed(
12221            "stored mirror feed does not converge on its advertised head",
12222        ));
12223    }
12224    verify_rotation_feed_boundaries(&identity, None, &items, head.head_seq)?;
12225    match (head.head_seq, snapshot_pack_sha256, pack_sha256.as_deref()) {
12226        (0, None, None) => {}
12227        (_, Some(actual), Some(expected)) if actual == expected => {}
12228        _ => {
12229            return Err(LinkError::InvalidPack {
12230                message: "stored snapshot pack does not match the signed head digest".to_string(),
12231            });
12232        }
12233    }
12234    let identity_value = serde_json::to_value(&identity)
12235        .map_err(|_| invalid_feed("could not serialize verified mirror identity"))?;
12236    Ok(VerifiedMirrorMaterial {
12237        brain: head.brain,
12238        head_seq: head.head_seq,
12239        feed_hash: head.feed_hash,
12240        identity: identity_value,
12241        entries,
12242        pack_sha256,
12243    })
12244}
12245
12246/// SHA-256 hex of one feed entry's stored bytes (`exact JSON + "\n"`) — the
12247/// entry hash every consumer recomputes (SPEC §5.3).
12248pub fn feed_entry_hash(exact_sans_newline: &str) -> String {
12249    format!(
12250        "{:x}",
12251        Sha256::digest(format!("{exact_sans_newline}\n").as_bytes())
12252    )
12253}
12254
12255/// SHA-256 hex for content a signed manifest names. Exposed for the thin
12256/// `dbmd serve` adapter; cryptographic verification remains centralized here.
12257pub fn content_sha256(bytes: &[u8]) -> String {
12258    format!("{:x}", Sha256::digest(bytes))
12259}
12260
12261/// SHA-256 a stream with a fixed-size working buffer.
12262pub fn content_sha256_reader(mut reader: impl Read) -> std::io::Result<String> {
12263    let mut digest = Sha256::new();
12264    let mut buffer = [0u8; 64 * 1024];
12265    loop {
12266        let read = reader.read(&mut buffer)?;
12267        if read == 0 {
12268            break;
12269        }
12270        digest.update(&buffer[..read]);
12271    }
12272    Ok(format!("{:x}", digest.finalize()))
12273}
12274
12275/// Replicate a brain with full verification (link.md §5.4 over the WHOLE
12276/// chain, not just the head): every entry's signature, hash, sequence
12277/// contiguity, prev-hash linkage, rotation chain, and exact signed pack are
12278/// checked in a sibling staging directory. Only then is the old mirror swapped
12279/// out through an atomic directory exchange. Every stage, install, and cleanup
12280/// operation is relative to one held no-follow parent-directory capability, so
12281/// renaming an ancestor cannot redirect any write or deletion.
12282#[cfg_attr(windows, allow(unreachable_code, unused_variables))]
12283pub fn mirror(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<MirrorReport> {
12284    require_hardened_filesystem("mirror")?;
12285    require_safe_ref(brain)?;
12286    #[cfg(windows)]
12287    {
12288        let _ = (cfg, dest);
12289        return Err(LinkError::UnsupportedPlatform {
12290            operation: "atomic whole-mirror replacement on Windows",
12291        });
12292    }
12293    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
12294    let name = dest
12295        .file_name()
12296        .and_then(|name| name.to_str())
12297        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
12298        .ok_or_else(|| LinkError::UnsafePath {
12299            path: dest.display().to_string(),
12300        })?;
12301    #[cfg(unix)]
12302    let parent_dir = open_or_create_dir_nofollow(parent)?;
12303    #[cfg(unix)]
12304    use std::os::fd::AsRawFd as _;
12305    #[cfg(unix)]
12306    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
12307    #[cfg(unix)]
12308    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
12309        None => false,
12310        Some(true) => true,
12311        Some(false) => {
12312            return Err(LinkError::UnsafePath {
12313                path: dest.display().to_string(),
12314            });
12315        }
12316    };
12317
12318    // A fixed backup was used by pre-hardening builds. Never interpret or
12319    // delete an attacker-planted entry at that name; require manual recovery.
12320    #[cfg(unix)]
12321    let legacy_backup_name = c_name(
12322        format!(".{name}.dbmd-backup").as_bytes(),
12323        &dest.display().to_string(),
12324    )?;
12325    #[cfg(unix)]
12326    if entry_is_dir_at(parent_dir.as_raw_fd(), &legacy_backup_name)?.is_some() {
12327        return Err(LinkError::UnsafePath {
12328            path: parent
12329                .join(format!(".{name}.dbmd-backup"))
12330                .display()
12331                .to_string(),
12332        });
12333    }
12334
12335    let nonce = std::time::SystemTime::now()
12336        .duration_since(std::time::UNIX_EPOCH)
12337        .unwrap_or_default()
12338        .as_nanos();
12339    let stage_label = format!(".{name}.dbmd-stage-{}-{nonce}", std::process::id());
12340    #[cfg(unix)]
12341    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
12342    #[cfg(unix)]
12343    let stage_dir = create_dir_exclusive_at(
12344        parent_dir.as_raw_fd(),
12345        &stage_name,
12346        &dest.display().to_string(),
12347    )?;
12348
12349    let assembled = (|| -> LinkResult<MirrorReport> {
12350        let remote = verified_remote_head(cfg, brain, true)?;
12351        let brain_id = remote.head.brain.clone();
12352        let identity = remote
12353            .identity
12354            .as_ref()
12355            .ok_or_else(|| invalid_feed("brain has no signed identity"))?;
12356        let anchor = remote
12357            .anchor
12358            .clone()
12359            .ok_or_else(|| invalid_feed("brain has no verified identity anchor"))?;
12360        let pack = download_verified_snapshot_pack(cfg, &brain_id, &remote)?;
12361        let snapshot_entries = parse_store_pack(pack.clone())?;
12362        let snapshot_count = snapshot_entries.len();
12363        let mut staged_entries = snapshot_entries;
12364        staged_entries.push((format!("{MIRROR_REL_DIR}/snapshot.pack"), pack));
12365        for item in &remote.entries {
12366            let mut exact = serde_json::to_vec(&item.entry)
12367                .map_err(|_| invalid_feed("could not serialize signed feed entry"))?;
12368            exact.push(b'\n');
12369            if format!("{:x}", Sha256::digest(&exact)) != item.hash {
12370                return Err(invalid_feed(
12371                    "serialized mirror entry differs from its verified hash",
12372                ));
12373            }
12374            staged_entries.push((
12375                format!("{MIRROR_REL_DIR}/feed/{}.json", item.entry.seq),
12376                exact,
12377            ));
12378        }
12379        let mut identity_bytes = serde_json::to_vec(identity)
12380            .map_err(|_| invalid_feed("could not serialize verified identity chain"))?;
12381        identity_bytes.push(b'\n');
12382        staged_entries.push((format!("{MIRROR_REL_DIR}/identity.json"), identity_bytes));
12383        let mut head_bytes = serde_json::to_vec(&json!({
12384            "brain": brain_id,
12385            "headSeq": remote.head.seq,
12386            "feedHash": remote.head.feed_hash,
12387        }))
12388        .map_err(|_| invalid_feed("could not serialize verified mirror head"))?;
12389        head_bytes.push(b'\n');
12390        staged_entries.push((format!("{MIRROR_REL_DIR}/head.json"), head_bytes));
12391        staged_entries.push((
12392            CONFIG_REL_PATH.to_string(),
12393            format!("hub = {}\npin = {anchor}\n", cfg.hub).into_bytes(),
12394        ));
12395        #[cfg(unix)]
12396        write_pull_entries_beneath_dir(&stage_dir, &staged_entries)?;
12397
12398        Ok(MirrorReport {
12399            brain: brain_id,
12400            head_seq: remote.head.seq,
12401            feed_hash: remote.head.feed_hash,
12402            entries: remote.entries.len() as u64,
12403            pinned: anchor,
12404            files: snapshot_count,
12405        })
12406    })();
12407
12408    let report = match assembled {
12409        Ok(report) => report,
12410        Err(error) => {
12411            #[cfg(unix)]
12412            let _ = remove_tree_at(
12413                parent_dir.as_raw_fd(),
12414                &stage_name,
12415                &dest.display().to_string(),
12416            );
12417            return Err(error);
12418        }
12419    };
12420
12421    #[cfg(unix)]
12422    if let Err(error) =
12423        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
12424    {
12425        let _ = remove_tree_at(
12426            parent_dir.as_raw_fd(),
12427            &stage_name,
12428            &dest.display().to_string(),
12429        );
12430        return Err(error);
12431    }
12432    // An exchange leaves the old mirror at the unique stage name. Cleanup is
12433    // capability-relative and never follows symlinks in hostile old content.
12434    #[cfg(unix)]
12435    if dest_exists {
12436        remove_tree_at(
12437            parent_dir.as_raw_fd(),
12438            &stage_name,
12439            &dest.display().to_string(),
12440        )?;
12441    }
12442    #[cfg(unix)]
12443    parent_dir.sync_all()?;
12444    Ok(report)
12445}
12446
12447fn verified_remote_head(
12448    cfg: &HubConfig,
12449    brain: &str,
12450    require_full_chain: bool,
12451) -> LinkResult<VerifiedRemote> {
12452    require_hardened_filesystem("verified link.md state")?;
12453    require_safe_ref(brain)?;
12454    // Refuse an unsafe state root before sending credentials or consulting an
12455    // untrusted card, and retain this exact directory inode for the complete
12456    // checkpoint transaction.
12457    let trust_directory = open_trust_dir(cfg)?;
12458    let path = format!("/api/hub/brains/{brain}");
12459    let body = ensure_ok(
12460        request(cfg, "GET", &path, None, Auth::Required)?,
12461        "subscribe",
12462    )?;
12463    let resolved_brain = body
12464        .get("id")
12465        .and_then(Value::as_str)
12466        .filter(|id| crate::ulid::is_ulid(id))
12467        .ok_or_else(|| invalid_feed("brain card has no canonical ULID id"))?
12468        .to_string();
12469    if crate::ulid::is_ulid(brain) && resolved_brain != brain {
12470        return Err(invalid_feed(
12471            "brain card id differs from the explicitly requested brain id",
12472        ));
12473    }
12474    // The card supplies the canonical ULID. Lock alias + canonical keys in
12475    // deterministic filename order, then hold both through verify + save.
12476    // Concurrent aliases for the same brain therefore converge on one
12477    // checkpoint instead of establishing independent TOFU universes.
12478    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &resolved_brain])?;
12479    let (pinned, alias_binding) =
12480        load_canonical_pin(cfg, &trust_directory, brain, &resolved_brain)?;
12481    let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
12482    let advertised_hash = body
12483        .get("feedHash")
12484        .and_then(Value::as_str)
12485        .map(str::to_string);
12486    let updated_at = body
12487        .get("updatedAt")
12488        .and_then(Value::as_str)
12489        .map(str::to_string);
12490    if let Some(pin) = &pinned {
12491        if seq < pin.head_seq {
12492            return Err(invalid_feed(format!(
12493                "feed rollback: hub advertised sequence {seq}, local checkpoint is {}",
12494                pin.head_seq
12495            )));
12496        }
12497        if seq == pin.head_seq && advertised_hash != pin.feed_hash {
12498            return Err(invalid_feed(
12499                "feed equivocation: the checkpoint sequence now has a different hash",
12500            ));
12501        }
12502    }
12503    if seq == 0 {
12504        if advertised_hash.is_some() {
12505            return Err(invalid_feed("an empty feed advertised a head hash"));
12506        }
12507        let identity: FeedIdentity = serde_json::from_value(
12508            body.get("identity")
12509                .cloned()
12510                .ok_or_else(|| invalid_feed("empty brain card has no signed identity"))?,
12511        )
12512        .map_err(|_| invalid_feed("empty brain card has an invalid identity"))?;
12513        let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
12514        // A valid old-key-signed rotation is still inconsistent if it commits
12515        // to history that the same card now claims never existed. Run the
12516        // identical feed-boundary proof used by non-empty heads before this
12517        // identity can become a durable TOFU checkpoint.
12518        verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], seq)?;
12519        save_canonical_pin_and_alias(
12520            cfg,
12521            &trust_directory,
12522            brain,
12523            &resolved_brain,
12524            TrustState {
12525                v: 2,
12526                origin: normalized_origin(&cfg.hub)?,
12527                requested: resolved_brain.clone(),
12528                brain: resolved_brain.clone(),
12529                home: None,
12530                anchor: anchor.clone(),
12531                current: format!("ed25519:{}", identity.fingerprint),
12532                head_seq: 0,
12533                feed_hash: None,
12534                rotations: identity.rotations.clone(),
12535                hub_signer: None,
12536                protocol_profile: None,
12537            },
12538            alias_binding.as_ref(),
12539        )?;
12540        return Ok(VerifiedRemote {
12541            head: Head {
12542                brain: resolved_brain,
12543                seq,
12544                updated_at,
12545                feed_hash: None,
12546                verified: true,
12547            },
12548            identity: Some(identity),
12549            head_entry: None,
12550            entries: Vec::new(),
12551            anchor: Some(anchor),
12552        });
12553    }
12554    if advertised_hash.as_ref().is_none_or(|hash| !is_sha256(hash)) {
12555        return Err(invalid_feed(
12556            "non-empty feed did not advertise a valid SHA-256 head",
12557        ));
12558    }
12559
12560    // On first contact the signed head itself is the TOFU checkpoint. A full
12561    // history replay cannot add authority before an anchor exists; mirrors
12562    // still request the complete chain because they promise an archival copy.
12563    let replay_head_only = !require_full_chain
12564        && pinned
12565            .as_ref()
12566            .is_none_or(|checkpoint| checkpoint.head_seq == seq);
12567    let mut after = if replay_head_only {
12568        seq - 1
12569    } else if require_full_chain || pinned.is_none() {
12570        0
12571    } else {
12572        pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq)
12573    };
12574    let mut expected_seq = after + 1;
12575    let mut previous_hash = if require_full_chain || pinned.is_none() || replay_head_only {
12576        None
12577    } else {
12578        pinned
12579            .as_ref()
12580            .and_then(|checkpoint| checkpoint.feed_hash.clone())
12581    };
12582    let mut identity: Option<FeedIdentity> = None;
12583    let mut anchor: Option<String> = None;
12584    let mut head_entry: Option<FeedItem> = None;
12585    let mut all_entries = Vec::new();
12586    let mut observed_entries = Vec::new();
12587    let replay_count = seq
12588        .checked_sub(after)
12589        .ok_or_else(|| invalid_feed("feed replay range moved backward"))?;
12590    if replay_count > MAX_FEED_REPLAY_ENTRIES {
12591        return Err(invalid_feed(format!(
12592            "feed replay requires {replay_count} entries, over the client cap"
12593        )));
12594    }
12595    let mut replay_bytes = 0u64;
12596
12597    loop {
12598        let feed_bytes = ensure_raw_ok(
12599            request_raw(
12600                cfg,
12601                "GET",
12602                &format!("/api/hub/brains/{brain}/feed?after={after}&limit=100"),
12603                None,
12604                Auth::Required,
12605                MAX_FEED_RESPONSE_BYTES,
12606            )?,
12607            "subscribe feed",
12608        )?;
12609        let feed: FeedResponse = serde_json::from_slice(&feed_bytes)
12610            .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
12611        if feed.head_seq != seq || feed.feed_hash != advertised_hash {
12612            return Err(invalid_feed("brain card and feed head disagree"));
12613        }
12614        if feed.entries.len() > FEED_PAGE_LIMIT {
12615            return Err(invalid_feed("feed page exceeds the requested entry limit"));
12616        }
12617        if feed.scope_limited {
12618            if require_full_chain {
12619                return Err(invalid_feed(
12620                    "path-scoped grants cannot verify a full snapshot chain",
12621                ));
12622            }
12623            return Ok(VerifiedRemote {
12624                head: Head {
12625                    brain: resolved_brain,
12626                    seq,
12627                    updated_at,
12628                    feed_hash: advertised_hash,
12629                    verified: false,
12630                },
12631                identity: None,
12632                head_entry: None,
12633                entries: Vec::new(),
12634                anchor: None,
12635            });
12636        }
12637        let page_identity = feed
12638            .identity
12639            .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
12640        let page_anchor = verify_identity_chain(&page_identity, pinned.as_ref())?;
12641        if identity
12642            .as_ref()
12643            .is_some_and(|existing| existing != &page_identity)
12644        {
12645            return Err(invalid_feed("identity changed while reading the feed"));
12646        }
12647        if anchor
12648            .as_ref()
12649            .is_some_and(|existing| existing != &page_anchor)
12650        {
12651            return Err(invalid_feed(
12652                "identity anchor changed while reading the feed",
12653            ));
12654        }
12655        identity = Some(page_identity.clone());
12656        if anchor.is_none() {
12657            anchor = Some(page_anchor);
12658        }
12659        if feed.entries.is_empty() {
12660            return Err(invalid_feed("feed page was empty before the signed head"));
12661        }
12662
12663        for item in feed.entries {
12664            if item.entry.seq != expected_seq {
12665                return Err(invalid_feed(format!(
12666                    "expected entry {expected_seq}, feed served {}",
12667                    item.entry.seq
12668                )));
12669            }
12670            if item.entry.seq > seq {
12671                return Err(invalid_feed("feed advanced past the card snapshot"));
12672            }
12673            if !replay_head_only && item.entry.prev_entry_hash != previous_hash {
12674                return Err(invalid_feed(format!(
12675                    "entry {} does not chain to the local checkpoint",
12676                    item.entry.seq
12677                )));
12678            }
12679            verify_feed_item(&item, &page_identity)?;
12680            reject_retired_signer_after_checkpoint(&page_identity, pinned.as_ref(), &item)?;
12681            replay_bytes = replay_bytes.saturating_add(
12682                serde_json::to_vec(&item)
12683                    .map_err(|_| invalid_feed("could not size feed entry"))?
12684                    .len() as u64,
12685            );
12686            if replay_bytes > MAX_FEED_REPLAY_BYTES {
12687                return Err(invalid_feed("feed replay metadata exceeds the client cap"));
12688            }
12689            previous_hash = Some(item.hash.clone());
12690            after = item.entry.seq;
12691            expected_seq = expected_seq
12692                .checked_add(1)
12693                .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
12694            if require_full_chain {
12695                all_entries.push(item.clone());
12696            }
12697            observed_entries.push(item.clone());
12698            head_entry = Some(item);
12699        }
12700        if after == seq {
12701            break;
12702        }
12703    }
12704
12705    if head_entry.as_ref().map(|item| &item.hash) != advertised_hash.as_ref() {
12706        return Err(invalid_feed(
12707            "verified chain does not converge on the advertised head",
12708        ));
12709    }
12710    let identity = identity.ok_or_else(|| invalid_feed("feed has no brain identity"))?;
12711    let anchor = anchor.ok_or_else(|| invalid_feed("feed has no identity anchor"))?;
12712    verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &observed_entries, seq)?;
12713    save_canonical_pin_and_alias(
12714        cfg,
12715        &trust_directory,
12716        brain,
12717        &resolved_brain,
12718        TrustState {
12719            v: 2,
12720            origin: normalized_origin(&cfg.hub)?,
12721            requested: resolved_brain.clone(),
12722            brain: resolved_brain.clone(),
12723            home: None,
12724            anchor: anchor.clone(),
12725            current: format!("ed25519:{}", identity.fingerprint),
12726            head_seq: seq,
12727            feed_hash: advertised_hash.clone(),
12728            rotations: identity.rotations.clone(),
12729            hub_signer: pinned.as_ref().and_then(|state| state.hub_signer.clone()),
12730            protocol_profile: pinned
12731                .as_ref()
12732                .and_then(|state| state.protocol_profile.clone()),
12733        },
12734        alias_binding.as_ref(),
12735    )?;
12736    Ok(VerifiedRemote {
12737        head: Head {
12738            brain: resolved_brain,
12739            seq,
12740            updated_at,
12741            feed_hash: advertised_hash,
12742            verified: true,
12743        },
12744        identity: Some(identity),
12745        head_entry,
12746        entries: all_entries,
12747        anchor: Some(anchor),
12748    })
12749}
12750
12751/// Read and locally verify the brain's current signed feed head. Identity
12752/// rotation is accepted only through an old-key-signed chain rooted at the
12753/// local TOFU anchor; sequence and hash checkpoints reject rollback and
12754/// equivocation across invocations.
12755pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
12756    Ok(verified_remote_head(cfg, brain, false)?.head)
12757}
12758
12759#[cfg(test)]
12760mod tests {
12761    use super::*;
12762
12763    const TEST_BRAIN_ID: &str = "01j5qc3v9k4ym8rwbn2tqe6f7d";
12764
12765    fn merge_fixture(
12766        base: Option<&str>,
12767        remote: Option<&str>,
12768        local: Option<&str>,
12769        keep_local: bool,
12770    ) -> V2PulledMerge<String> {
12771        let map = |value: Option<&str>| {
12772            value
12773                .map(|value| [("records/a.md".to_string(), value.to_string())])
12774                .into_iter()
12775                .flatten()
12776                .collect::<std::collections::BTreeMap<_, _>>()
12777        };
12778        merge_v2_pulled_records(
12779            &map(base),
12780            &map(remote),
12781            &map(local),
12782            |value, _| value.clone(),
12783            |value, _| value.clone(),
12784            |_| keep_local,
12785        )
12786    }
12787
12788    #[test]
12789    fn v2_pull_three_way_merge_never_discards_a_local_only_change() {
12790        let path = "records/a.md".to_string();
12791
12792        let local_add = merge_fixture(None, None, Some("local"), false);
12793        assert_eq!(
12794            local_add.records.get(&path).map(String::as_str),
12795            Some("local")
12796        );
12797        assert!(local_add.accept_remote.is_empty());
12798        assert!(local_add.conflicts.is_empty());
12799
12800        let local_edit = merge_fixture(Some("old"), Some("old"), Some("local"), false);
12801        assert_eq!(
12802            local_edit.records.get(&path).map(String::as_str),
12803            Some("local")
12804        );
12805        assert!(local_edit.accept_remote.is_empty());
12806        assert!(local_edit.conflicts.is_empty());
12807
12808        let local_delete = merge_fixture(Some("old"), Some("old"), None, false);
12809        assert!(!local_delete.records.contains_key(&path));
12810        assert!(local_delete.accept_remote.is_empty());
12811        assert!(local_delete.conflicts.is_empty());
12812
12813        let remote_edit = merge_fixture(Some("old"), Some("remote"), Some("old"), false);
12814        assert_eq!(
12815            remote_edit.records.get(&path).map(String::as_str),
12816            Some("remote")
12817        );
12818        assert!(remote_edit.accept_remote.contains(&path));
12819        assert!(remote_edit.conflicts.is_empty());
12820
12821        let remote_delete = merge_fixture(Some("old"), None, Some("old"), false);
12822        assert!(!remote_delete.records.contains_key(&path));
12823        assert!(remote_delete.accept_remote.contains(&path));
12824        assert!(remote_delete.conflicts.is_empty());
12825
12826        let same_edit = merge_fixture(Some("old"), Some("same"), Some("same"), false);
12827        assert_eq!(
12828            same_edit.records.get(&path).map(String::as_str),
12829            Some("same")
12830        );
12831        assert!(same_edit.accept_remote.contains(&path));
12832        assert!(same_edit.conflicts.is_empty());
12833
12834        let conflict = merge_fixture(Some("old"), Some("remote"), Some("local"), false);
12835        assert_eq!(conflict.conflicts, vec![path.clone()]);
12836        assert_eq!(
12837            conflict.records.get(&path).map(String::as_str),
12838            Some("local")
12839        );
12840        assert!(conflict.accept_remote.is_empty());
12841
12842        let kept_home = merge_fixture(Some("old"), Some("remote"), Some("local"), true);
12843        assert_eq!(
12844            kept_home.records.get(&path).map(String::as_str),
12845            Some("local")
12846        );
12847        assert!(kept_home.accept_remote.is_empty());
12848        assert!(kept_home.conflicts.is_empty());
12849    }
12850
12851    #[test]
12852    fn v2_asset_hosting_resume_is_an_explicit_local_policy_transition() {
12853        let path = "sources/report.pdf";
12854        let record = crate::AssetRecord {
12855            path: path.to_string(),
12856            sha256: "a".repeat(64),
12857            bytes: 42,
12858            media_type: "application/pdf".to_string(),
12859            wrappers: vec!["gzip".to_string()],
12860            required: true,
12861        };
12862        let mut remote = V2BaselineAsset {
12863            blob_sha256: record.sha256.clone(),
12864            bytes: record.bytes,
12865            media_type: record.media_type.clone(),
12866            wrappers: record.wrappers.clone(),
12867            required: record.required,
12868            disposition: "withheld".to_string(),
12869            leaf_hash: "b".repeat(64),
12870        };
12871
12872        assert!(v2_asset_resumes_hosting(
12873            Some(&remote),
12874            path,
12875            &record,
12876            "hosted"
12877        ));
12878        assert!(!v2_asset_resumes_hosting(
12879            Some(&remote),
12880            path,
12881            &record,
12882            "withheld"
12883        ));
12884
12885        remote.disposition = "hosted".to_string();
12886        assert!(!v2_asset_resumes_hosting(
12887            Some(&remote),
12888            path,
12889            &record,
12890            "hosted"
12891        ));
12892
12893        remote.disposition = "withheld".to_string();
12894        remote.blob_sha256 = "c".repeat(64);
12895        assert!(!v2_asset_resumes_hosting(
12896            Some(&remote),
12897            path,
12898            &record,
12899            "hosted"
12900        ));
12901        assert!(!v2_asset_resumes_hosting(None, path, &record, "hosted"));
12902    }
12903
12904    #[test]
12905    fn take_remote_resolution_rebinds_to_the_exact_current_manifest() {
12906        let path = "records/team/alpha.md".to_string();
12907        let deleted_path = "records/team/deleted.md".to_string();
12908        let coordinate = |sha256: Option<String>, bytes: Option<u64>| V2ConflictCoordinate {
12909            sha256,
12910            bytes,
12911            file: None,
12912        };
12913        let files = vec![
12914            V2ConflictFile {
12915                path: path.clone(),
12916                base: coordinate(None, None),
12917                local: coordinate(Some("b".repeat(64)), Some(7)),
12918                remote: coordinate(Some("a".repeat(64)), Some(5)),
12919            },
12920            V2ConflictFile {
12921                path: deleted_path.clone(),
12922                base: coordinate(Some("c".repeat(64)), Some(9)),
12923                local: coordinate(Some("d".repeat(64)), Some(11)),
12924                remote: coordinate(None, None),
12925            },
12926        ];
12927        let proven = V2BaselineFile {
12928            sha256: "a".repeat(64),
12929            bytes: 5,
12930            proof: None,
12931        };
12932        let current = [(path.clone(), proven.clone())]
12933            .into_iter()
12934            .collect::<std::collections::BTreeMap<_, _>>();
12935
12936        let (selected, deleted) = v2_take_remote_selection(&files, &current).unwrap();
12937        assert_eq!(selected.get(&path).unwrap().sha256, proven.sha256);
12938        assert_eq!(deleted, vec![deleted_path.clone()]);
12939
12940        let changed = [(
12941            path.clone(),
12942            V2BaselineFile {
12943                sha256: "e".repeat(64),
12944                bytes: 5,
12945                proof: None,
12946            },
12947        )]
12948        .into_iter()
12949        .collect::<std::collections::BTreeMap<_, _>>();
12950        assert!(v2_take_remote_selection(&files, &changed).is_err());
12951
12952        let resurrected = [
12953            (path, proven),
12954            (
12955                deleted_path,
12956                V2BaselineFile {
12957                    sha256: "f".repeat(64),
12958                    bytes: 13,
12959                    proof: None,
12960                },
12961            ),
12962        ]
12963        .into_iter()
12964        .collect::<std::collections::BTreeMap<_, _>>();
12965        assert!(v2_take_remote_selection(&files, &resurrected).is_err());
12966    }
12967
12968    #[cfg(target_os = "linux")]
12969    #[test]
12970    fn linux_snapshot_install_is_atomic_for_create_and_exchange() {
12971        use std::os::fd::AsRawFd as _;
12972
12973        let sandbox = tempfile::TempDir::new().unwrap();
12974        let parent = std::fs::File::open(sandbox.path()).unwrap();
12975        let stage = std::ffi::CString::new("stage").unwrap();
12976        let destination = std::ffi::CString::new("brain").unwrap();
12977
12978        std::fs::create_dir(sandbox.path().join("stage")).unwrap();
12979        std::fs::write(sandbox.path().join("stage/value"), b"created").unwrap();
12980        install_stage_at(
12981            parent.as_raw_fd(),
12982            stage.as_c_str(),
12983            destination.as_c_str(),
12984            false,
12985        )
12986        .unwrap();
12987        assert!(!sandbox.path().join("stage").exists());
12988        assert_eq!(
12989            std::fs::read(sandbox.path().join("brain/value")).unwrap(),
12990            b"created"
12991        );
12992
12993        std::fs::create_dir(sandbox.path().join("stage")).unwrap();
12994        std::fs::write(sandbox.path().join("stage/value"), b"replacement").unwrap();
12995        install_stage_at(
12996            parent.as_raw_fd(),
12997            stage.as_c_str(),
12998            destination.as_c_str(),
12999            true,
13000        )
13001        .unwrap();
13002        assert_eq!(
13003            std::fs::read(sandbox.path().join("brain/value")).unwrap(),
13004            b"replacement"
13005        );
13006        assert_eq!(
13007            std::fs::read(sandbox.path().join("stage/value")).unwrap(),
13008            b"created",
13009            "RENAME_EXCHANGE must leave the predecessor at the unique stage name"
13010        );
13011    }
13012
13013    struct SignedRemoteFixture {
13014        card: String,
13015        feed: String,
13016        key: AgentSigningKey,
13017        identity: FeedIdentity,
13018    }
13019
13020    fn signed_remote_fixture() -> SignedRemoteFixture {
13021        let rng = ring::rand::SystemRandom::new();
13022        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
13023        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
13024        let (public_key, multikey) = public_identity_for(&pair);
13025        let identity = FeedIdentity {
13026            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
13027            public_key_spki: public_key.clone(),
13028            previous: Vec::new(),
13029            rotations: Vec::new(),
13030        };
13031        let mut entry = FeedEntry {
13032            v: 1,
13033            seq: 1,
13034            ts: "2026-07-30T12:00:00.000Z".to_string(),
13035            brain: multikey.clone(),
13036            public_key: public_key.clone(),
13037            kind: "push".to_string(),
13038            op: "snapshot".to_string(),
13039            pack_sha256: "a".repeat(64),
13040            files: Vec::new(),
13041            removed: Vec::new(),
13042            prev_entry_hash: None,
13043            sig: String::new(),
13044        };
13045        let unsigned = UnsignedFeedEntry {
13046            v: entry.v,
13047            seq: entry.seq,
13048            ts: &entry.ts,
13049            brain: &entry.brain,
13050            public_key: &entry.public_key,
13051            kind: &entry.kind,
13052            op: &entry.op,
13053            pack_sha256: &entry.pack_sha256,
13054            files: &entry.files,
13055            removed: &entry.removed,
13056            prev_entry_hash: &entry.prev_entry_hash,
13057        };
13058        entry.sig =
13059            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
13060        let mut exact = serde_json::to_vec(&entry).unwrap();
13061        exact.push(b'\n');
13062        let hash = content_sha256(&exact);
13063        let card = json!({
13064            "id": TEST_BRAIN_ID,
13065            "headSeq": 1,
13066            "feedHash": hash,
13067            "identity": identity.clone(),
13068        })
13069        .to_string();
13070        let feed = json!({
13071            "headSeq": 1,
13072            "feedHash": hash,
13073            "identity": identity.clone(),
13074            "entries": [{"hash": hash, "entry": entry}],
13075            "scopeLimited": false,
13076        })
13077        .to_string();
13078        SignedRemoteFixture {
13079            card,
13080            feed,
13081            key: AgentSigningKey {
13082                pkcs8: pkcs8.as_ref().to_vec(),
13083                multikey,
13084                public_key_spki: public_key,
13085            },
13086            identity,
13087        }
13088    }
13089
13090    #[test]
13091    fn v1_edit_disclosure_accepts_minimal_and_superset_forms() {
13092        let file = |path: &str, byte: char| FeedFile {
13093            path: path.to_string(),
13094            sha256: byte.to_string().repeat(64),
13095            bytes: 1,
13096        };
13097        let a0 = file("records/a.md", 'a');
13098        let a1 = file("records/a.md", 'b');
13099        let stable = file("records/stable.md", 'c');
13100        let added = file("records/added.md", 'd');
13101        let removed_file = file("records/removed.md", 'e');
13102        let previous = vec![a0, stable.clone(), removed_file.clone()];
13103        let resulting = vec![a1.clone(), stable.clone(), added.clone()];
13104        let removed = vec![removed_file.path.clone()];
13105
13106        assert_eq!(
13107            verify_v1_manifest_disclosure(
13108                "edit",
13109                &previous,
13110                &resulting,
13111                &[a1.clone(), added.clone()],
13112                &removed,
13113            ),
13114            Ok(())
13115        );
13116        assert_eq!(
13117            verify_v1_manifest_disclosure(
13118                "edit",
13119                &previous,
13120                &resulting,
13121                &[stable.clone(), added.clone(), a1.clone()],
13122                &removed,
13123            ),
13124            Ok(())
13125        );
13126        assert_eq!(
13127            verify_v1_manifest_disclosure(
13128                "edit",
13129                &previous,
13130                &resulting,
13131                std::slice::from_ref(&added),
13132                &removed,
13133            ),
13134            Err(V1DisclosureError::EditMissingChange)
13135        );
13136        assert_eq!(
13137            verify_v1_manifest_disclosure(
13138                "edit",
13139                &previous,
13140                &resulting,
13141                &[file("records/a.md", 'f'), added.clone()],
13142                &removed,
13143            ),
13144            Err(V1DisclosureError::EditFalseFile)
13145        );
13146        assert_eq!(
13147            verify_v1_manifest_disclosure(
13148                "edit",
13149                &previous,
13150                &resulting,
13151                &[a1.clone(), added.clone()],
13152                &[],
13153            ),
13154            Err(V1DisclosureError::RemovedMismatch)
13155        );
13156        assert_eq!(
13157            verify_v1_manifest_disclosure(
13158                "push",
13159                &previous,
13160                &resulting,
13161                &[added.clone(), stable, a1],
13162                &removed,
13163            ),
13164            Ok(())
13165        );
13166        assert_eq!(
13167            verify_v1_manifest_disclosure("push", &previous, &resulting, &[added], &removed,),
13168            Err(V1DisclosureError::PushManifestMismatch)
13169        );
13170    }
13171
13172    #[test]
13173    fn wire_sequences_are_rejected_at_their_endpoint_count_limits() {
13174        let fixture = signed_remote_fixture();
13175        let feed: Value = serde_json::from_str(&fixture.feed).unwrap();
13176        let item = feed["entries"][0].to_string();
13177        let oversized_page = format!(
13178            "{{\"headSeq\":1,\"feedHash\":null,\"identity\":null,\"entries\":[{}],\"scopeLimited\":false}}",
13179            std::iter::repeat_n(item.as_str(), FEED_PAGE_LIMIT + 1)
13180                .collect::<Vec<_>>()
13181                .join(",")
13182        );
13183        assert!(serde_json::from_str::<FeedResponse>(&oversized_page).is_err());
13184
13185        let oversized_identity = format!(
13186            "{{\"fingerprint\":\"fp\",\"publicKeySpki\":\"spki\",\"previous\":[],\"rotations\":[{}]}}",
13187            std::iter::repeat_n("\"rotation\"", MAX_IDENTITY_ROTATIONS + 1)
13188                .collect::<Vec<_>>()
13189                .join(",")
13190        );
13191        assert!(serde_json::from_str::<FeedIdentity>(&oversized_identity).is_err());
13192
13193        let file = r#"{"path":"a","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bytes":1}"#;
13194        let oversized_entry = format!(
13195            "{{\"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\"}}",
13196            "a".repeat(64),
13197            std::iter::repeat_n(file, MAX_PUSH_FILES + 1)
13198                .collect::<Vec<_>>()
13199                .join(",")
13200        );
13201        assert!(serde_json::from_str::<FeedEntry>(&oversized_entry).is_err());
13202    }
13203
13204    #[test]
13205    fn bulk_confirmation_parser_accepts_only_the_exact_wire_shape() {
13206        let id = "01arz3ndektsv4rrffq69g5fav";
13207        let digest = "a".repeat(64);
13208        assert_eq!(
13209            V2BulkConfirmation::parse(&format!("{id}:{digest}")).unwrap(),
13210            V2BulkConfirmation {
13211                id: id.to_string(),
13212                digest,
13213            }
13214        );
13215        for invalid in [
13216            "",
13217            "01arz3ndektsv4rrffq69g5fav",
13218            "01ARZ3NDEKTSV4RRFFQ69G5FAV:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
13219            "01arz3ndektsv4rrffq69g5fav:ABCDEF",
13220            "01arz3ndektsv4rrffq69g5fav:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
13221        ] {
13222            assert!(matches!(
13223                V2BulkConfirmation::parse(invalid),
13224                Err(LinkError::InvalidPack { .. })
13225            ));
13226        }
13227    }
13228
13229    fn scripted_json_hub(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle<()>) {
13230        use std::io::{BufRead as _, BufReader, Read as _, Write as _};
13231        use std::net::TcpListener;
13232
13233        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
13234        let url = format!("http://{}", listener.local_addr().unwrap());
13235        let handle = std::thread::spawn(move || {
13236            for (status, body) in responses {
13237                let (stream, _) = listener.accept().unwrap();
13238                let mut reader = BufReader::new(stream);
13239                let mut line = String::new();
13240                reader.read_line(&mut line).unwrap();
13241                let mut content_length = 0usize;
13242                loop {
13243                    line.clear();
13244                    reader.read_line(&mut line).unwrap();
13245                    if line == "\r\n" || line == "\n" || line.is_empty() {
13246                        break;
13247                    }
13248                    if let Some((name, value)) = line.split_once(':') {
13249                        if name.eq_ignore_ascii_case("content-length") {
13250                            content_length = value.trim().parse().unwrap();
13251                        }
13252                    }
13253                }
13254                let mut request_body = vec![0_u8; content_length];
13255                reader.read_exact(&mut request_body).unwrap();
13256                let response = format!(
13257                    "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
13258                    body.len()
13259                );
13260                reader.get_mut().write_all(response.as_bytes()).unwrap();
13261            }
13262        });
13263        (url, handle)
13264    }
13265
13266    fn routed_json_hub(
13267        requests: usize,
13268        mut respond: impl FnMut(&str) -> (u16, String) + Send + 'static,
13269    ) -> (String, std::thread::JoinHandle<()>) {
13270        use std::io::{BufRead as _, BufReader, Read as _, Write as _};
13271        use std::net::TcpListener;
13272
13273        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
13274        let url = format!("http://{}", listener.local_addr().unwrap());
13275        let handle = std::thread::spawn(move || {
13276            for _ in 0..requests {
13277                let (stream, _) = listener.accept().unwrap();
13278                let mut reader = BufReader::new(stream);
13279                let mut line = String::new();
13280                reader.read_line(&mut line).unwrap();
13281                let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
13282                let mut content_length = 0usize;
13283                loop {
13284                    line.clear();
13285                    reader.read_line(&mut line).unwrap();
13286                    if line == "\r\n" || line == "\n" || line.is_empty() {
13287                        break;
13288                    }
13289                    if let Some((name, value)) = line.split_once(':') {
13290                        if name.eq_ignore_ascii_case("content-length") {
13291                            content_length = value.trim().parse().unwrap();
13292                        }
13293                    }
13294                }
13295                let mut request_body = vec![0_u8; content_length];
13296                reader.read_exact(&mut request_body).unwrap();
13297                let (status, body) = respond(&path);
13298                let response = format!(
13299                    "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
13300                    body.len()
13301                );
13302                reader.get_mut().write_all(response.as_bytes()).unwrap();
13303            }
13304        });
13305        (url, handle)
13306    }
13307
13308    fn test_hub_config(hub: String, state_dir: PathBuf) -> HubConfig {
13309        HubConfig {
13310            hub,
13311            key: Some("test-key".to_string()),
13312            agent_key: None,
13313            brain_key: None,
13314            state_dir,
13315            store_selected: false,
13316        }
13317    }
13318
13319    #[test]
13320    fn linkmd_sig_v2_is_bound_to_the_exact_hub_origin() {
13321        use ring::signature::KeyPair as _;
13322
13323        let rng = ring::rand::SystemRandom::new();
13324        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
13325        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
13326        let (spki, multikey) = public_identity_for(&pair);
13327        let key = AgentSigningKey {
13328            pkcs8: pkcs8.as_ref().to_vec(),
13329            multikey,
13330            public_key_spki: spki,
13331        };
13332        let header = linkmd_sig_header(
13333            &key,
13334            "https://hub-a.example",
13335            "post",
13336            "/api/hub/brains/brain/push?mode=exact",
13337            Some("{\"ok\":true}"),
13338        )
13339        .unwrap();
13340        assert!(header.starts_with("LinkMD-Sig v2,"));
13341        let ts = header
13342            .split(",ts=")
13343            .nth(1)
13344            .unwrap()
13345            .split(',')
13346            .next()
13347            .unwrap();
13348        let signature = URL_SAFE_NO_PAD
13349            .decode(header.rsplit(",sig=").next().unwrap())
13350            .unwrap();
13351        let body_hash = format!("{:x}", Sha256::digest(b"{\"ok\":true}"));
13352        let accepted = format!(
13353            "v2\nhttps://hub-a.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
13354        );
13355        let replayed = format!(
13356            "v2\nhttps://hub-b.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
13357        );
13358        let public = pair.public_key().as_ref();
13359        assert!(UnparsedPublicKey::new(&ED25519, public)
13360            .verify(accepted.as_bytes(), &signature)
13361            .is_ok());
13362        assert!(
13363            UnparsedPublicKey::new(&ED25519, public)
13364                .verify(replayed.as_bytes(), &signature)
13365                .is_err(),
13366            "a proof captured at hub A must not authenticate at hub B"
13367        );
13368    }
13369
13370    #[test]
13371    fn explicit_brain_id_rejects_a_substituted_card_before_feed_trust() {
13372        let other = "01j5qc3v9k4ym8rwbn2tqe6f7e";
13373        let card = json!({
13374            "id": other,
13375            "headSeq": 0,
13376            "identity": signed_remote_fixture().identity,
13377        })
13378        .to_string();
13379        let (hub, server) = scripted_json_hub(vec![(200, card)]);
13380        let state = tempfile::tempdir().unwrap();
13381        let cfg = test_hub_config(hub, state.path().to_path_buf());
13382        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
13383        assert!(
13384            error.contains("differs from the explicitly requested"),
13385            "{error}"
13386        );
13387        server.join().unwrap();
13388    }
13389
13390    #[test]
13391    fn empty_brain_identity_is_pinned_and_cannot_be_substituted_later() {
13392        let first = signed_remote_fixture().identity;
13393        let second = signed_remote_fixture().identity;
13394        let card = |identity: FeedIdentity| {
13395            json!({
13396                "id": TEST_BRAIN_ID,
13397                "headSeq": 0,
13398                "identity": identity,
13399            })
13400            .to_string()
13401        };
13402        let (hub, server) = scripted_json_hub(vec![(200, card(first)), (200, card(second))]);
13403        let state = tempfile::tempdir().unwrap();
13404        let cfg = test_hub_config(hub, state.path().to_path_buf());
13405        assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
13406        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
13407        assert!(
13408            error.contains("pinned anchor") || error.contains("forked away"),
13409            "{error}"
13410        );
13411        server.join().unwrap();
13412    }
13413
13414    #[test]
13415    fn empty_head_rejects_a_rotation_that_commits_to_hidden_history() {
13416        let old = signed_remote_fixture();
13417        let new = signed_remote_fixture();
13418        let old_pair = ring::signature::Ed25519KeyPair::from_pkcs8(&old.key.pkcs8).unwrap();
13419        let unsigned = serde_json::to_string(&UnsignedRotation {
13420            v: 1,
13421            op: "rotate",
13422            brain: &old.key.multikey,
13423            public_key: &old.key.public_key_spki,
13424            new_brain: &new.key.multikey,
13425            new_public_key: &new.key.public_key_spki,
13426            prior_head_seq: 1,
13427            prior_feed_hash: Some(&"a".repeat(64)),
13428            ts: "2026-07-30T12:00:00.000Z".to_string(),
13429        })
13430        .unwrap();
13431        let signature = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
13432        let rotation = format!(
13433            "{},\"sig\":\"{}\"}}",
13434            &unsigned[..unsigned.len() - 1],
13435            signature
13436        );
13437        let identity = FeedIdentity {
13438            fingerprint: new.key.multikey.trim_start_matches("ed25519:").to_string(),
13439            public_key_spki: new.key.public_key_spki,
13440            previous: vec![PreviousIdentity {
13441                fingerprint: old.key.multikey.trim_start_matches("ed25519:").to_string(),
13442                public_key_spki: old.key.public_key_spki,
13443            }],
13444            rotations: vec![rotation],
13445        };
13446        let card = json!({
13447            "id": TEST_BRAIN_ID,
13448            "headSeq": 0,
13449            "feedHash": null,
13450            "identity": identity,
13451        })
13452        .to_string();
13453        let (hub, server) = scripted_json_hub(vec![(200, card)]);
13454        let state = tempfile::tempdir().unwrap();
13455        let cfg = test_hub_config(hub, state.path().to_path_buf());
13456        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
13457        assert!(
13458            error.contains("rotation claims a feed boundary beyond the advertised head"),
13459            "{error}"
13460        );
13461        assert!(
13462            load_trust(&cfg, TEST_BRAIN_ID).unwrap().is_none(),
13463            "an inconsistent empty-head identity must not become the TOFU checkpoint"
13464        );
13465        server.join().unwrap();
13466    }
13467
13468    #[test]
13469    fn trust_checkpoint_rejects_a_later_fork() {
13470        let fixture = signed_remote_fixture();
13471        let mut fork: Value = serde_json::from_str(&fixture.card).unwrap();
13472        fork["feedHash"] = Value::String("b".repeat(64));
13473        let (hub, server) = scripted_json_hub(vec![
13474            (200, fixture.card),
13475            (200, fixture.feed),
13476            (200, fork.to_string()),
13477        ]);
13478        let state = tempfile::tempdir().unwrap();
13479        let cfg = test_hub_config(hub, state.path().to_path_buf());
13480        assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
13481        assert!(head(&cfg, TEST_BRAIN_ID).is_err());
13482        server.join().unwrap();
13483    }
13484
13485    #[test]
13486    fn alias_and_canonical_id_share_one_identity_checkpoint() {
13487        let trusted = signed_remote_fixture();
13488        let attacker = signed_remote_fixture();
13489        let (hub, server) = scripted_json_hub(vec![
13490            (200, trusted.card),
13491            (200, trusted.feed),
13492            (200, attacker.card),
13493        ]);
13494        let state = tempfile::tempdir().unwrap();
13495        let cfg = test_hub_config(hub, state.path().to_path_buf());
13496        assert!(head(&cfg, "trusted-slug").unwrap().verified);
13497        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
13498        assert!(
13499            error.contains("equivocation")
13500                || error.contains("pinned")
13501                || error.contains("identity"),
13502            "{error}"
13503        );
13504        server.join().unwrap();
13505    }
13506
13507    #[test]
13508    fn concurrent_aliases_cannot_establish_conflicting_tofu_pins() {
13509        let alpha = signed_remote_fixture();
13510        let beta = signed_remote_fixture();
13511        let alpha_card = alpha.card.clone();
13512        let alpha_feed = alpha.feed.clone();
13513        let beta_card = beta.card.clone();
13514        let beta_feed = beta.feed.clone();
13515        let (hub, server) = routed_json_hub(3, move |path| {
13516            if path.contains("/alpha/feed?") {
13517                (200, alpha_feed.clone())
13518            } else if path.contains("/beta/feed?") {
13519                (200, beta_feed.clone())
13520            } else if path.ends_with("/alpha") {
13521                (200, alpha_card.clone())
13522            } else if path.ends_with("/beta") {
13523                (200, beta_card.clone())
13524            } else {
13525                (500, r#"{"error":"unexpected path"}"#.to_string())
13526            }
13527        });
13528        let state = tempfile::tempdir().unwrap();
13529        let cfg = test_hub_config(hub, state.path().to_path_buf());
13530        let alpha_cfg = cfg.clone();
13531        let beta_cfg = cfg;
13532        let first = std::thread::spawn(move || head(&alpha_cfg, "alpha"));
13533        let second = std::thread::spawn(move || head(&beta_cfg, "beta"));
13534        let results = [first.join().unwrap(), second.join().unwrap()];
13535        assert_eq!(
13536            results.iter().filter(|result| result.is_ok()).count(),
13537            1,
13538            "only one alias identity may establish canonical TOFU: {results:?}"
13539        );
13540        assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
13541        server.join().unwrap();
13542    }
13543
13544    #[cfg(unix)]
13545    #[test]
13546    fn trust_transaction_survives_an_ancestor_swap_without_writing_outside() {
13547        use std::os::unix::fs::symlink;
13548
13549        let fixture = signed_remote_fixture();
13550        let card = json!({
13551            "id": TEST_BRAIN_ID,
13552            "headSeq": 0,
13553            "feedHash": Value::Null,
13554            "identity": fixture.identity,
13555        })
13556        .to_string();
13557        let work = tempfile::tempdir().unwrap();
13558        let outside = tempfile::tempdir().unwrap();
13559        let state = work.path().join("state");
13560        let moved = work.path().join("state-held");
13561        let swap_state = state.clone();
13562        let swap_moved = moved.clone();
13563        let outside_path = outside.path().to_path_buf();
13564        let (hub, server) = routed_json_hub(1, move |_| {
13565            // The client has already opened state/trust before this response.
13566            std::fs::rename(&swap_state, &swap_moved).unwrap();
13567            symlink(&outside_path, &swap_state).unwrap();
13568            (200, card.clone())
13569        });
13570        let cfg = test_hub_config(hub, state);
13571
13572        let verified = verified_remote_head(&cfg, TEST_BRAIN_ID, false).unwrap();
13573        assert_eq!(verified.head.seq, 0);
13574        assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
13575        assert!(std::fs::read_dir(moved.join("trust"))
13576            .unwrap()
13577            .flatten()
13578            .any(|entry| entry.path().extension().is_some_and(|ext| ext == "json")));
13579        server.join().unwrap();
13580    }
13581
13582    #[test]
13583    fn self_custody_push_refuses_an_unrelated_key_before_requesting_an_upload() {
13584        let remote = signed_remote_fixture();
13585        let unrelated = signed_remote_fixture().key;
13586        let (hub, server) = scripted_json_hub(vec![(200, remote.card), (200, remote.feed)]);
13587        let state = tempfile::tempdir().unwrap();
13588        let mut cfg = test_hub_config(hub, state.path().to_path_buf());
13589        cfg.brain_key = Some(unrelated);
13590        let error = sync_push(
13591            &cfg,
13592            TEST_BRAIN_ID,
13593            &[("DB.md".to_string(), "signed local content".to_string())],
13594        )
13595        .unwrap_err()
13596        .to_string();
13597        assert!(
13598            error.contains("not the verified current brain identity"),
13599            "{error}"
13600        );
13601        server.join().unwrap();
13602    }
13603
13604    #[test]
13605    fn rotation_ignores_a_forged_2xx_body_and_requires_verified_postcondition() {
13606        let remote = signed_remote_fixture();
13607        let new = signed_remote_fixture().key;
13608        let state = tempfile::tempdir().unwrap();
13609        let new_file = state.path().join("new.key");
13610        std::fs::write(
13611            &new_file,
13612            format!("{}\n", URL_SAFE_NO_PAD.encode(&new.pkcs8)),
13613        )
13614        .unwrap();
13615        #[cfg(unix)]
13616        {
13617            use std::os::unix::fs::PermissionsExt as _;
13618            std::fs::set_permissions(&new_file, std::fs::Permissions::from_mode(0o600)).unwrap();
13619        }
13620        let forged = json!({
13621            "brain": TEST_BRAIN_ID,
13622            "identity": {
13623                "fingerprint": new.multikey.trim_start_matches("ed25519:"),
13624                "publicKeySpki": new.public_key_spki,
13625            }
13626        })
13627        .to_string();
13628        let (hub, server) = scripted_json_hub(vec![
13629            (200, remote.card.clone()),
13630            (200, remote.feed.clone()),
13631            (200, forged),
13632            (200, remote.card),
13633            (200, remote.feed),
13634        ]);
13635        let cfg = test_hub_config(hub, state.path().to_path_buf());
13636        let error = rotate_brain_key(&cfg, TEST_BRAIN_ID, &remote.key, &new_file)
13637            .unwrap_err()
13638            .to_string();
13639        assert!(
13640            error.contains("without committing the verified new identity"),
13641            "{error}"
13642        );
13643        server.join().unwrap();
13644    }
13645
13646    #[test]
13647    fn resolve_record_is_derived_from_signed_pack_not_a_query_response() {
13648        let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
13649        let raw = format!(
13650            "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
13651        );
13652        let pack = build_store_pack(&[
13653            (
13654                "DB.md".to_string(),
13655                "---\ntype: db-md\nscope: company\nowner: Test\n---\n".to_string(),
13656            ),
13657            ("records/clients/truth.md".to_string(), raw.clone()),
13658        ])
13659        .unwrap();
13660        let by_id = resolve_from_verified_pack(
13661            "01j5qc3v9k4ym8rwbn2tqe6f7d",
13662            &AddressTarget::Id(record_id.to_string()),
13663            pack.clone(),
13664        )
13665        .unwrap();
13666        assert_eq!(by_id["document"]["summary"], "Signed truth");
13667        assert_eq!(by_id["document"]["body"], "# Signed truth\n");
13668        assert_eq!(
13669            by_id["document"]["contentSha"],
13670            content_sha256(raw.as_bytes())
13671        );
13672
13673        let by_path = resolve_from_verified_pack(
13674            "01j5qc3v9k4ym8rwbn2tqe6f7d",
13675            &AddressTarget::Path("records/clients/truth.md".to_string()),
13676            pack,
13677        )
13678        .unwrap();
13679        assert_eq!(by_path["document"]["id"], record_id);
13680        assert_eq!(by_path["document"]["path"], "records/clients/truth.md");
13681    }
13682
13683    #[test]
13684    fn canonical_store_pack_matches_the_cross_language_zip32_golden() {
13685        let unsorted = vec![
13686            ("records/a.md".to_string(), "alpha\n".to_string()),
13687            ("DB.md".to_string(), "# db\n".to_string()),
13688        ];
13689        let sorted = vec![
13690            ("DB.md".to_string(), "# db\n".to_string()),
13691            ("records/a.md".to_string(), "alpha\n".to_string()),
13692        ];
13693        let pack = build_store_pack(&unsorted).unwrap();
13694
13695        // This digest is shared with the hub implementation. It locks every
13696        // byte of the wire profile: raw UTF-8 order, STORED payloads, fixed DOS
13697        // epoch, explicit CRC/sizes, Unix regular-0600 attributes, and the
13698        // absence of descriptors/extras/comments/ZIP64.
13699        assert_eq!(pack.len(), 219);
13700        assert_eq!(
13701            content_sha256(&pack),
13702            "972fb2045becaa21588baaf4b349e62a430687fa2c21167b53f4ca0efa6c9408"
13703        );
13704        assert_eq!(pack, build_store_pack(&sorted).unwrap());
13705        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x07\x08"));
13706        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x06"));
13707        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x07"));
13708
13709        assert_eq!(
13710            parse_store_pack(pack).unwrap(),
13711            vec![
13712                ("DB.md".to_string(), b"# db\n".to_vec()),
13713                ("records/a.md".to_string(), b"alpha\n".to_vec()),
13714            ]
13715        );
13716    }
13717
13718    #[test]
13719    fn canonical_store_pack_validates_every_path_before_writing() {
13720        let duplicate = vec![
13721            ("DB.md".to_string(), "first".to_string()),
13722            ("DB.md".to_string(), "second".to_string()),
13723        ];
13724        assert!(build_store_pack(&duplicate)
13725            .unwrap_err()
13726            .to_string()
13727            .contains("duplicate path"));
13728        assert!(matches!(
13729            build_store_pack(&[("../escape.md".to_string(), "no".to_string())]),
13730            Err(LinkError::UnsafePath { .. })
13731        ));
13732    }
13733
13734    #[test]
13735    fn zip64_preflight_rejects_an_entry_count_bomb_before_zip_parsing() {
13736        const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
13737        // One byte stands in for the central directory. The trailer itself is
13738        // structurally valid; the count is the sole reason for refusal.
13739        let mut bytes = vec![0_u8];
13740        let zip64_offset = bytes.len() as u64;
13741        bytes.extend_from_slice(b"PK\x06\x06");
13742        bytes.extend_from_slice(&44_u64.to_le_bytes());
13743        bytes.extend_from_slice(&[0_u8; 12]);
13744        bytes.extend_from_slice(&COUNT.to_le_bytes());
13745        bytes.extend_from_slice(&COUNT.to_le_bytes());
13746        bytes.extend_from_slice(&1_u64.to_le_bytes());
13747        bytes.extend_from_slice(&0_u64.to_le_bytes());
13748        bytes.extend_from_slice(b"PK\x06\x07");
13749        bytes.extend_from_slice(&0_u32.to_le_bytes());
13750        bytes.extend_from_slice(&zip64_offset.to_le_bytes());
13751        bytes.extend_from_slice(&1_u32.to_le_bytes());
13752        bytes.extend_from_slice(b"PK\x05\x06");
13753        bytes.extend_from_slice(&0_u16.to_le_bytes());
13754        bytes.extend_from_slice(&0_u16.to_le_bytes());
13755        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
13756        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
13757        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
13758        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
13759        bytes.extend_from_slice(&0_u16.to_le_bytes());
13760
13761        let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
13762            .unwrap_err()
13763            .to_string();
13764        assert!(error.contains("invalid file count"), "{error}");
13765    }
13766
13767    #[test]
13768    fn zip_preflight_rejects_a_fake_trailing_eocd_before_zip_parsing() {
13769        const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
13770        let mut bytes = vec![0_u8];
13771        let zip64_offset = bytes.len() as u64;
13772        bytes.extend_from_slice(b"PK\x06\x06");
13773        bytes.extend_from_slice(&44_u64.to_le_bytes());
13774        bytes.extend_from_slice(&[0_u8; 12]);
13775        bytes.extend_from_slice(&COUNT.to_le_bytes());
13776        bytes.extend_from_slice(&COUNT.to_le_bytes());
13777        bytes.extend_from_slice(&1_u64.to_le_bytes());
13778        bytes.extend_from_slice(&0_u64.to_le_bytes());
13779        bytes.extend_from_slice(b"PK\x06\x07");
13780        bytes.extend_from_slice(&0_u32.to_le_bytes());
13781        bytes.extend_from_slice(&zip64_offset.to_le_bytes());
13782        bytes.extend_from_slice(&1_u32.to_le_bytes());
13783        bytes.extend_from_slice(b"PK\x05\x06");
13784        bytes.extend_from_slice(&0_u16.to_le_bytes());
13785        bytes.extend_from_slice(&0_u16.to_le_bytes());
13786        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
13787        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
13788        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
13789        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
13790        bytes.extend_from_slice(&0_u16.to_le_bytes());
13791        // The old parser trusted this last low-count signature, while
13792        // ZipArchive fell back to the real Zip64 directory and allocated for
13793        // COUNT entries. Its central-directory offsets are deliberately fake.
13794        let fake_eocd = bytes.len() as u32;
13795        bytes.extend_from_slice(b"PK\x05\x06");
13796        bytes.extend_from_slice(&0_u16.to_le_bytes());
13797        bytes.extend_from_slice(&0_u16.to_le_bytes());
13798        bytes.extend_from_slice(&1_u16.to_le_bytes());
13799        bytes.extend_from_slice(&1_u16.to_le_bytes());
13800        bytes.extend_from_slice(&0_u32.to_le_bytes());
13801        bytes.extend_from_slice(&fake_eocd.to_le_bytes());
13802        bytes.extend_from_slice(&0_u16.to_le_bytes());
13803
13804        let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
13805            .unwrap_err()
13806            .to_string();
13807        assert!(error.contains("central directory"), "{error}");
13808    }
13809
13810    #[test]
13811    fn strict_http_status_handling_rejects_redirects_without_panicking() {
13812        let error = ensure_ok(
13813            HubResponse {
13814                status: 302,
13815                body: Some(json!({"redirect": "/elsewhere"})),
13816            },
13817            "mutation",
13818        )
13819        .unwrap_err();
13820        assert!(matches!(error, LinkError::Http { status: 302, .. }));
13821
13822        let error = ensure_raw_ok(
13823            RawHubResponse {
13824                status: 302,
13825                body: br#"{"redirect":"/elsewhere"}"#.to_vec(),
13826            },
13827            "feed",
13828        )
13829        .unwrap_err();
13830        assert!(matches!(error, LinkError::Http { status: 302, .. }));
13831    }
13832
13833    #[cfg(unix)]
13834    #[test]
13835    fn collect_push_files_refuses_external_symlink_and_nested_store() {
13836        use std::os::unix::fs::symlink;
13837
13838        let root = tempfile::tempdir().unwrap();
13839        std::fs::write(
13840            root.path().join("DB.md"),
13841            "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
13842        )
13843        .unwrap();
13844        std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
13845
13846        let external = tempfile::tempdir().unwrap();
13847        let secret = external.path().join("secret.md");
13848        std::fs::write(&secret, "TOP SECRET").unwrap();
13849        symlink(&secret, root.path().join("records/notes/secret.md")).unwrap();
13850
13851        let store = Store::open_strict(root.path()).unwrap();
13852        let err = collect_push_files(&store).unwrap_err().to_string();
13853        assert!(err.contains("cannot push"), "{err}");
13854        assert!(
13855            !err.contains("TOP SECRET"),
13856            "external bytes must never leak"
13857        );
13858
13859        std::fs::remove_file(root.path().join("records/notes/secret.md")).unwrap();
13860        let nested = root.path().join("records/nested");
13861        std::fs::create_dir_all(&nested).unwrap();
13862        std::fs::write(
13863            nested.join("DB.md"),
13864            "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
13865        )
13866        .unwrap();
13867        let err = collect_push_files(&store).unwrap_err().to_string();
13868        assert!(err.contains("nested db.md store"), "{err}");
13869    }
13870
13871    #[cfg(unix)]
13872    #[test]
13873    fn remote_push_uses_opened_root_after_path_replacement() {
13874        use std::os::unix::fs::symlink;
13875
13876        let sandbox = tempfile::tempdir().unwrap();
13877        let root = sandbox.path().join("store");
13878        std::fs::create_dir_all(root.join("records/notes")).unwrap();
13879        std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
13880        std::fs::write(
13881            root.join("records/notes/owned.md"),
13882            "---\ntype: note\nsummary: owned\n---\nowned upload\n",
13883        )
13884        .unwrap();
13885        let store = Store::open_strict(&root).unwrap();
13886        let detached = sandbox.path().join("detached");
13887        std::fs::rename(&root, &detached).unwrap();
13888
13889        let replacement = sandbox.path().join("replacement");
13890        std::fs::create_dir_all(replacement.join("records/notes")).unwrap();
13891        std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
13892        std::fs::write(
13893            replacement.join("records/notes/secret.md"),
13894            "---\ntype: note\nsummary: secret\n---\nreplacement sentinel\n",
13895        )
13896        .unwrap();
13897        symlink(&replacement, &root).unwrap();
13898
13899        let files = collect_push_files(&store).unwrap();
13900        let wire_text = files
13901            .iter()
13902            .map(|(path, content)| format!("{path}\n{content}"))
13903            .collect::<Vec<_>>()
13904            .join("\n");
13905        assert!(wire_text.contains("owned upload"));
13906        assert!(!wire_text.contains("replacement sentinel"));
13907        assert!(!wire_text.contains("records/notes/secret.md"));
13908
13909        let remote = signed_remote_fixture();
13910        let (hub, server) = scripted_json_hub(vec![
13911            (200, remote.card),
13912            (200, remote.feed),
13913            (200, json!({"ok": true}).to_string()),
13914        ]);
13915        let state = tempfile::tempdir().unwrap();
13916        let cfg = test_hub_config(hub, state.path().to_path_buf());
13917        let pushed = sync_push(&cfg, TEST_BRAIN_ID, &files).unwrap();
13918        assert_eq!(pushed, json!({"ok": true}));
13919        server.join().unwrap();
13920    }
13921
13922    #[test]
13923    fn signed_feed_item_verifies_identity_hash_and_signature() {
13924        use ring::rand::SystemRandom;
13925        use ring::signature::{Ed25519KeyPair, KeyPair};
13926
13927        const PREFIX: &[u8] = &[
13928            0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
13929        ];
13930        let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
13931        let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
13932        let mut spki = PREFIX.to_vec();
13933        spki.extend_from_slice(pair.public_key().as_ref());
13934        let public_key = URL_SAFE_NO_PAD.encode(&spki);
13935        let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
13936        let mut entry = FeedEntry {
13937            v: 1,
13938            seq: 1,
13939            ts: "2026-07-14T00:00:00.000Z".to_string(),
13940            brain: format!("ed25519:{fingerprint}"),
13941            public_key: public_key.clone(),
13942            kind: "push".to_string(),
13943            op: "snapshot".to_string(),
13944            pack_sha256: "a".repeat(64),
13945            files: vec![FeedFile {
13946                path: "DB.md".to_string(),
13947                sha256: "b".repeat(64),
13948                bytes: 3,
13949            }],
13950            removed: vec![],
13951            prev_entry_hash: None,
13952            sig: String::new(),
13953        };
13954        let unsigned = UnsignedFeedEntry {
13955            v: entry.v,
13956            seq: entry.seq,
13957            ts: &entry.ts,
13958            brain: &entry.brain,
13959            public_key: &entry.public_key,
13960            kind: &entry.kind,
13961            op: &entry.op,
13962            pack_sha256: &entry.pack_sha256,
13963            files: &entry.files,
13964            removed: &entry.removed,
13965            prev_entry_hash: &entry.prev_entry_hash,
13966        };
13967        entry.sig =
13968            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
13969        let mut exact = serde_json::to_vec(&entry).unwrap();
13970        exact.push(b'\n');
13971        let item = FeedItem {
13972            hash: format!("{:x}", Sha256::digest(&exact)),
13973            entry,
13974        };
13975        let identity = FeedIdentity {
13976            fingerprint,
13977            public_key_spki: public_key,
13978            previous: Vec::new(),
13979            rotations: Vec::new(),
13980        };
13981        assert!(verify_feed_item(&item, &identity).is_ok());
13982        let mut tampered = item;
13983        tampered.entry.pack_sha256 = "c".repeat(64);
13984        assert!(verify_feed_item(&tampered, &identity).is_err());
13985    }
13986
13987    #[test]
13988    fn v2_commit_requires_exact_fields_and_a_valid_genesis_bridge() {
13989        let rng = ring::rand::SystemRandom::new();
13990        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
13991        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
13992        let (spki, multikey) = public_identity_for(&pair);
13993        let identity = V2HeadIdentity {
13994            custody: "self".to_string(),
13995            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
13996            public_key_spki: spki.clone(),
13997            previous: Vec::new(),
13998            rotations: Vec::new(),
13999        };
14000        let unsigned = json!({
14001            "actor_ref": "a".repeat(64),
14002            "asset_root": Value::Null,
14003            "brain": multikey,
14004            "changes_sha256": "b".repeat(64),
14005            "control_revision": "c".repeat(64),
14006            "materializer": "dbmd-projection-v1",
14007            "op": "changeset",
14008            "parent_asset_root": Value::Null,
14009            "parent_commit": Value::Null,
14010            "parent_root": Value::Null,
14011            "prev_entry_hash": Value::Null,
14012            "public_key": spki,
14013            "seq": 1,
14014            "signer_epoch": 1,
14015            "state_root": "d".repeat(64),
14016            "ts": "2026-08-19T12:00:00.000Z",
14017            "v": 2,
14018            "v1_bridge": {
14019                "feed_hash": "e".repeat(64),
14020                "head_seq": 7,
14021                "pack_sha256": "f".repeat(64),
14022            },
14023        });
14024        let sign_value = |value: Value| {
14025            let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
14026            let mut object = value.as_object().unwrap().clone();
14027            object.insert(
14028                "sig".to_string(),
14029                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
14030            );
14031            crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
14032        };
14033        assert!(verified_v2_commit_object(&sign_value(unsigned.clone()), &identity).is_ok());
14034
14035        let mut extra = unsigned.clone();
14036        extra
14037            .as_object_mut()
14038            .unwrap()
14039            .insert("future".to_string(), Value::Bool(true));
14040        assert!(verified_v2_commit_object(&sign_value(extra), &identity).is_err());
14041
14042        let mut missing = unsigned.clone();
14043        missing.as_object_mut().unwrap().remove("v1_bridge");
14044        assert!(verified_v2_commit_object(&sign_value(missing), &identity).is_err());
14045
14046        let mut invalid_bridge = unsigned;
14047        invalid_bridge.as_object_mut().unwrap().insert(
14048            "v1_bridge".to_string(),
14049            json!({"feed_hash": "e".repeat(64), "head_seq": 0, "pack_sha256": "f".repeat(64)}),
14050        );
14051        assert!(verified_v2_commit_object(&sign_value(invalid_bridge), &identity).is_err());
14052    }
14053
14054    #[test]
14055    fn shared_v2_commit_bridge_vector_matches_the_typescript_signer() {
14056        let vector: Value = serde_json::from_str(include_str!(
14057            "../tests/vectors/linkmd-v2-commit-bridge.json"
14058        ))
14059        .unwrap();
14060        let identity_value = vector.get("identity").unwrap();
14061        let identity = V2HeadIdentity {
14062            custody: "self".to_string(),
14063            fingerprint: identity_value
14064                .get("fingerprint")
14065                .and_then(Value::as_str)
14066                .unwrap()
14067                .to_string(),
14068            public_key_spki: identity_value
14069                .get("public_key_spki")
14070                .and_then(Value::as_str)
14071                .unwrap()
14072                .to_string(),
14073            previous: Vec::new(),
14074            rotations: Vec::new(),
14075        };
14076        let private = URL_SAFE_NO_PAD
14077            .decode(
14078                identity_value
14079                    .get("private_key_pkcs8")
14080                    .and_then(Value::as_str)
14081                    .unwrap(),
14082            )
14083            .unwrap();
14084        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&private)
14085            .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(&private))
14086            .unwrap();
14087        let base = vector.get("body").unwrap().as_object().unwrap();
14088
14089        for item in vector.get("valid").unwrap().as_array().unwrap() {
14090            let mut body = base.clone();
14091            body.insert(
14092                "v1_bridge".to_string(),
14093                item.get("v1_bridge").unwrap().clone(),
14094            );
14095            body.insert(
14096                "sig".to_string(),
14097                item.get("signature_base64url").unwrap().clone(),
14098            );
14099            let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
14100            assert!(verified_v2_commit_object(&signed, &identity).is_ok());
14101            assert_eq!(
14102                crate::linkmd_v2::domain_hash_bytes("v2/commit", &signed).unwrap(),
14103                item.get("commit_hash").and_then(Value::as_str).unwrap()
14104            );
14105            assert_eq!(
14106                format!("{:x}", Sha256::digest(&signed)),
14107                item.get("feed_hash").and_then(Value::as_str).unwrap()
14108            );
14109        }
14110
14111        for item in vector.get("invalid").unwrap().as_array().unwrap() {
14112            let mut body = base.clone();
14113            if let Some(remove) = item.get("remove").and_then(Value::as_array) {
14114                for field in remove {
14115                    body.remove(field.as_str().unwrap());
14116                }
14117            }
14118            if let Some(set) = item.get("set").and_then(Value::as_object) {
14119                for (field, value) in set {
14120                    body.insert(field.clone(), value.clone());
14121                }
14122            }
14123            let message = crate::linkmd_v2::canonical_bytes(&Value::Object(body.clone())).unwrap();
14124            body.insert(
14125                "sig".to_string(),
14126                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
14127            );
14128            let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
14129            assert!(
14130                verified_v2_commit_object(&signed, &identity).is_err(),
14131                "accepted invalid shared vector {}",
14132                item.get("reason").and_then(Value::as_str).unwrap()
14133            );
14134        }
14135    }
14136
14137    #[test]
14138    fn v2_profile_transition_reverifies_the_exact_signed_v1_bridge() {
14139        let remote = signed_remote_fixture();
14140        let legacy: FeedResponse = serde_json::from_str(&remote.feed).unwrap();
14141        let legacy_item = legacy.entries.first().unwrap();
14142        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&remote.key.pkcs8).unwrap();
14143        let body = json!({
14144            "actor_ref": "a".repeat(64),
14145            "asset_root": Value::Null,
14146            "brain": remote.key.multikey,
14147            "changes_sha256": "b".repeat(64),
14148            "control_revision": "c".repeat(64),
14149            "materializer": "dbmd-projection-v1",
14150            "op": "changeset",
14151            "parent_asset_root": Value::Null,
14152            "parent_commit": Value::Null,
14153            "parent_root": Value::Null,
14154            "prev_entry_hash": Value::Null,
14155            "public_key": remote.key.public_key_spki,
14156            "seq": 1,
14157            "signer_epoch": 1,
14158            "state_root": "d".repeat(64),
14159            "ts": "2026-08-19T12:00:00.000Z",
14160            "v": 2,
14161            "v1_bridge": {
14162                "feed_hash": legacy_item.hash,
14163                "head_seq": legacy_item.entry.seq,
14164                "pack_sha256": legacy_item.entry.pack_sha256,
14165            },
14166        });
14167        let message = crate::linkmd_v2::canonical_bytes(&body).unwrap();
14168        let mut signed = body.as_object().unwrap().clone();
14169        signed.insert(
14170            "sig".to_string(),
14171            Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
14172        );
14173        let raw = crate::linkmd_v2::canonical_bytes(&Value::Object(signed)).unwrap();
14174        let commit_hash = crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw).unwrap();
14175        let feed_hash = content_sha256(&raw);
14176        let pointer = V2PointerBody {
14177            v: 2,
14178            brain: TEST_BRAIN_ID.to_string(),
14179            seq: 1,
14180            commit_hash: commit_hash.clone(),
14181            feed_hash: feed_hash.clone(),
14182            content_root: Some("d".repeat(64)),
14183            asset_root: None,
14184            materializer: "dbmd-projection-v1".to_string(),
14185            signer_epoch: 1,
14186            control_revision: "c".repeat(64),
14187            backup_preparation: "e".repeat(64),
14188            prior_pointer_hash: None,
14189            signed_at: "2026-08-19T12:00:00.000Z".to_string(),
14190        };
14191        let v2_page = json!({
14192            "v": 2,
14193            "head_seq": 1,
14194            "head_commit_hash": commit_hash,
14195            "head_feed_hash": feed_hash,
14196            "entries": [{
14197                "seq": 1,
14198                "commit_hash": pointer.commit_hash,
14199                "feed_hash": pointer.feed_hash,
14200                "bytes_base64": STANDARD.encode(&raw),
14201            }],
14202            "next_after": 1,
14203            "complete": true,
14204        })
14205        .to_string();
14206        let identity = V2HeadIdentity {
14207            custody: "self".to_string(),
14208            fingerprint: remote.identity.fingerprint.clone(),
14209            public_key_spki: remote.identity.public_key_spki.clone(),
14210            previous: Vec::new(),
14211            rotations: Vec::new(),
14212        };
14213        let checkpoint = TrustState {
14214            v: 2,
14215            origin: "unused".to_string(),
14216            requested: TEST_BRAIN_ID.to_string(),
14217            brain: TEST_BRAIN_ID.to_string(),
14218            home: None,
14219            anchor: remote.key.multikey.clone(),
14220            current: remote.key.multikey,
14221            head_seq: legacy_item.entry.seq,
14222            feed_hash: Some(legacy_item.hash.clone()),
14223            rotations: Vec::new(),
14224            hub_signer: None,
14225            protocol_profile: None,
14226        };
14227        let (hub, server) = scripted_json_hub(vec![(200, v2_page), (200, remote.feed)]);
14228        let state = tempfile::tempdir().unwrap();
14229        let cfg = test_hub_config(hub, state.path().to_path_buf());
14230        verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &checkpoint).unwrap();
14231        server.join().unwrap();
14232
14233        let mut wrong = checkpoint;
14234        wrong.feed_hash = Some("0".repeat(64));
14235        let (hub, server) = scripted_json_hub(vec![(
14236            200,
14237            json!({
14238                "v": 2,
14239                "head_seq": 1,
14240                "head_commit_hash": pointer.commit_hash,
14241                "head_feed_hash": pointer.feed_hash,
14242                "entries": [{
14243                    "seq": 1,
14244                    "commit_hash": pointer.commit_hash,
14245                    "feed_hash": pointer.feed_hash,
14246                    "bytes_base64": STANDARD.encode(&raw),
14247                }],
14248                "next_after": 1,
14249                "complete": true,
14250            })
14251            .to_string(),
14252        )]);
14253        let state = tempfile::tempdir().unwrap();
14254        let cfg = test_hub_config(hub, state.path().to_path_buf());
14255        assert!(verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &wrong,).is_err());
14256        server.join().unwrap();
14257    }
14258
14259    #[test]
14260    fn v2_commit_signer_epoch_follows_the_authenticated_rotation_boundary() {
14261        let rng = ring::rand::SystemRandom::new();
14262        let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
14263        let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
14264        let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
14265        let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
14266        let (old_spki, old_multikey) = public_identity_for(&old);
14267        let (new_spki, new_multikey) = public_identity_for(&new);
14268        let rotation_unsigned = serde_json::to_string(&UnsignedRotation {
14269            v: 1,
14270            op: "rotate",
14271            brain: &old_multikey,
14272            public_key: &old_spki,
14273            new_brain: &new_multikey,
14274            new_public_key: &new_spki,
14275            prior_head_seq: 1,
14276            prior_feed_hash: Some(&"9".repeat(64)),
14277            ts: "2026-08-19T12:01:00.000Z".to_string(),
14278        })
14279        .unwrap();
14280        let rotation_sig = URL_SAFE_NO_PAD.encode(old.sign(rotation_unsigned.as_bytes()).as_ref());
14281        let rotation = format!(
14282            "{},\"sig\":\"{}\"}}",
14283            &rotation_unsigned[..rotation_unsigned.len() - 1],
14284            rotation_sig
14285        );
14286        let identity = V2HeadIdentity {
14287            custody: "self".to_string(),
14288            fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
14289            public_key_spki: new_spki.clone(),
14290            previous: vec![V2PreviousIdentity {
14291                fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
14292                public_key_spki: old_spki.clone(),
14293            }],
14294            rotations: vec![rotation],
14295        };
14296        let commit = |seq: u64,
14297                      epoch: u64,
14298                      multikey: &str,
14299                      spki: &str,
14300                      pair: &ring::signature::Ed25519KeyPair| {
14301            let value = json!({
14302                "actor_ref": "a".repeat(64),
14303                "asset_root": Value::Null,
14304                "brain": multikey,
14305                "changes_sha256": "b".repeat(64),
14306                "control_revision": "c".repeat(64),
14307                "materializer": "dbmd-projection-v1",
14308                "op": "changeset",
14309                "parent_asset_root": Value::Null,
14310                "parent_commit": if seq == 1 { Value::Null } else { Value::String("d".repeat(64)) },
14311                "parent_root": if seq == 1 { Value::Null } else { Value::String("e".repeat(64)) },
14312                "prev_entry_hash": if seq == 1 { Value::Null } else { Value::String("f".repeat(64)) },
14313                "public_key": spki,
14314                "seq": seq,
14315                "signer_epoch": epoch,
14316                "state_root": "1".repeat(64),
14317                "ts": "2026-08-19T12:00:00.000Z",
14318                "v": 2,
14319                "v1_bridge": Value::Null,
14320            });
14321            let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
14322            let mut object = value.as_object().unwrap().clone();
14323            object.insert(
14324                "sig".to_string(),
14325                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
14326            );
14327            crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
14328        };
14329
14330        assert!(verified_v2_commit_object(
14331            &commit(1, 1, &old_multikey, &old_spki, &old),
14332            &identity,
14333        )
14334        .is_ok());
14335        assert!(verified_v2_commit_object(
14336            &commit(2, 2, &new_multikey, &new_spki, &new),
14337            &identity,
14338        )
14339        .is_ok());
14340        assert!(verified_v2_commit_object(
14341            &commit(2, 1, &old_multikey, &old_spki, &old),
14342            &identity,
14343        )
14344        .is_err());
14345        assert!(verified_v2_commit_object(
14346            &commit(1, 2, &new_multikey, &new_spki, &new),
14347            &identity,
14348        )
14349        .is_err());
14350    }
14351
14352    #[test]
14353    fn a_self_custody_entry_verifies_like_any_hub_entry() {
14354        let rng = ring::rand::SystemRandom::new();
14355        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
14356        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
14357        let (spki, multikey) = public_identity_for(&pair);
14358        let key = AgentSigningKey {
14359            pkcs8: pkcs8.as_ref().to_vec(),
14360            multikey: multikey.clone(),
14361            public_key_spki: spki.clone(),
14362        };
14363        let files = vec![WireFeedFile {
14364            path: "DB.md".to_string(),
14365            sha256: "a".repeat(64),
14366            bytes: 3,
14367        }];
14368        let raw = self_custody_entry(
14369            &key,
14370            1,
14371            "2026-07-23T12:00:00.000Z".to_string(),
14372            &"c".repeat(64),
14373            &files,
14374            None,
14375        )
14376        .unwrap();
14377        // The exact client serialization parses as a feed entry and passes the
14378        // SAME verifier every subscribe read runs — the self-custody path
14379        // produces first-class wire-profile-v1 entries.
14380        let entry: FeedEntry = serde_json::from_str(&raw).unwrap();
14381        let hash = format!("{:x}", Sha256::digest(format!("{raw}\n").as_bytes()));
14382        let item = FeedItem { hash, entry };
14383        let identity = FeedIdentity {
14384            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
14385            public_key_spki: spki,
14386            previous: Vec::new(),
14387            rotations: Vec::new(),
14388        };
14389        assert!(verify_feed_item(&item, &identity).is_ok());
14390    }
14391
14392    #[test]
14393    fn identity_rotation_requires_old_key_signature_and_preserves_the_pin() {
14394        let rng = ring::rand::SystemRandom::new();
14395        let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
14396        let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
14397        let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
14398        let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
14399        let (old_spki, old_multikey) = public_identity_for(&old);
14400        let (new_spki, new_multikey) = public_identity_for(&new);
14401        let unsigned = serde_json::to_string(&UnsignedRotation {
14402            v: 1,
14403            op: "rotate",
14404            brain: &old_multikey,
14405            public_key: &old_spki,
14406            new_brain: &new_multikey,
14407            new_public_key: &new_spki,
14408            prior_head_seq: 1,
14409            prior_feed_hash: Some(&"a".repeat(64)),
14410            ts: "2026-07-30T12:00:00.000Z".to_string(),
14411        })
14412        .unwrap();
14413        let signature = URL_SAFE_NO_PAD.encode(old.sign(unsigned.as_bytes()).as_ref());
14414        let rotation = format!(
14415            "{},\"sig\":\"{}\"}}",
14416            &unsigned[..unsigned.len() - 1],
14417            signature
14418        );
14419        let identity = FeedIdentity {
14420            fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
14421            public_key_spki: new_spki,
14422            previous: vec![PreviousIdentity {
14423                fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
14424                public_key_spki: old_spki,
14425            }],
14426            rotations: vec![rotation],
14427        };
14428        let pin = TrustState {
14429            v: 2,
14430            origin: "https://hub.example".to_string(),
14431            requested: "brain".to_string(),
14432            brain: "brain".to_string(),
14433            home: None,
14434            anchor: old_multikey.clone(),
14435            current: old_multikey.clone(),
14436            head_seq: 1,
14437            feed_hash: Some("a".repeat(64)),
14438            rotations: Vec::new(),
14439            hub_signer: None,
14440            protocol_profile: None,
14441        };
14442        assert_eq!(
14443            verify_identity_chain(&identity, Some(&pin)).unwrap(),
14444            old_multikey
14445        );
14446        let mut accepted = pin.clone();
14447        accepted.current = new_multikey.clone();
14448        accepted.rotations = identity.rotations.clone();
14449        let alternate_unsigned = serde_json::to_string(&UnsignedRotation {
14450            v: 1,
14451            op: "rotate",
14452            brain: &old_multikey,
14453            public_key: &identity.previous[0].public_key_spki,
14454            new_brain: &new_multikey,
14455            new_public_key: &identity.public_key_spki,
14456            prior_head_seq: 1,
14457            prior_feed_hash: Some(&"a".repeat(64)),
14458            ts: "2026-07-30T12:00:01.000Z".to_string(),
14459        })
14460        .unwrap();
14461        let alternate_signature =
14462            URL_SAFE_NO_PAD.encode(old.sign(alternate_unsigned.as_bytes()).as_ref());
14463        let mut rewritten = identity.clone();
14464        rewritten.rotations[0] = format!(
14465            "{},\"sig\":\"{}\"}}",
14466            &alternate_unsigned[..alternate_unsigned.len() - 1],
14467            alternate_signature
14468        );
14469        assert!(
14470            verify_identity_chain(&rewritten, Some(&accepted)).is_err(),
14471            "an alternate valid statement must not rewrite accepted history"
14472        );
14473
14474        let mut stale_entry = FeedEntry {
14475            v: 1,
14476            seq: 2,
14477            ts: "2026-07-30T12:01:00.000Z".to_string(),
14478            brain: pin.current.clone(),
14479            public_key: identity.previous[0].public_key_spki.clone(),
14480            kind: "push".to_string(),
14481            op: "snapshot".to_string(),
14482            pack_sha256: "b".repeat(64),
14483            files: Vec::new(),
14484            removed: Vec::new(),
14485            prev_entry_hash: pin.feed_hash.clone(),
14486            sig: String::new(),
14487        };
14488        let stale_unsigned = UnsignedFeedEntry {
14489            v: stale_entry.v,
14490            seq: stale_entry.seq,
14491            ts: &stale_entry.ts,
14492            brain: &stale_entry.brain,
14493            public_key: &stale_entry.public_key,
14494            kind: &stale_entry.kind,
14495            op: &stale_entry.op,
14496            pack_sha256: &stale_entry.pack_sha256,
14497            files: &stale_entry.files,
14498            removed: &stale_entry.removed,
14499            prev_entry_hash: &stale_entry.prev_entry_hash,
14500        };
14501        stale_entry.sig = URL_SAFE_NO_PAD.encode(
14502            old.sign(&serde_json::to_vec(&stale_unsigned).unwrap())
14503                .as_ref(),
14504        );
14505        let mut stale_exact = serde_json::to_vec(&stale_entry).unwrap();
14506        stale_exact.push(b'\n');
14507        let stale_item = FeedItem {
14508            hash: content_sha256(&stale_exact),
14509            entry: stale_entry,
14510        };
14511        assert!(
14512            reject_retired_signer_after_checkpoint(&identity, Some(&accepted), &stale_item)
14513                .is_err(),
14514            "a key retired before the checkpoint must never append after it"
14515        );
14516        assert!(
14517            verify_feed_item(&stale_item, &identity).is_err(),
14518            "an old key must never append after its signed rotation boundary"
14519        );
14520
14521        let mut missing = identity.clone();
14522        missing.rotations.clear();
14523        assert!(verify_identity_chain(&missing, Some(&pin)).is_err());
14524
14525        let mut tampered = identity;
14526        tampered.rotations[0] = tampered.rotations[0].replace("\"sig\":\"", "\"sig\":\"A");
14527        assert!(verify_identity_chain(&tampered, Some(&pin)).is_err());
14528    }
14529
14530    #[cfg(unix)]
14531    #[test]
14532    fn key_creation_refuses_a_planted_symlink_without_touching_its_target() {
14533        use std::os::unix::fs::symlink;
14534
14535        let dir = tempfile::tempdir().unwrap();
14536        let target = dir.path().join("valuable.txt");
14537        let planted = dir.path().join("agent.key");
14538        std::fs::write(&target, "do not overwrite").unwrap();
14539        symlink(&target, &planted).unwrap();
14540
14541        assert!(matches!(
14542            generate_agent_key(&planted),
14543            Err(LinkError::BadAgentKey { .. })
14544        ));
14545        assert_eq!(std::fs::read_to_string(target).unwrap(), "do not overwrite");
14546    }
14547
14548    #[cfg(unix)]
14549    #[test]
14550    fn key_creation_refuses_a_symlinked_parent_without_writing_through_it() {
14551        use std::os::unix::fs::symlink;
14552
14553        let root = tempfile::tempdir().unwrap();
14554        let outside = tempfile::tempdir().unwrap();
14555        symlink(outside.path(), root.path().join("redirect")).unwrap();
14556
14557        assert!(generate_agent_key(&root.path().join("redirect/agent.key")).is_err());
14558        assert!(!outside.path().join("agent.key").exists());
14559    }
14560
14561    // ── Address parsing ─────────────────────────────────────────────────────
14562
14563    #[test]
14564    fn address_bare_brain_with_and_without_sigil() {
14565        for raw in ["@acme-ops", "acme-ops"] {
14566            let a = Address::parse(raw).expect(raw);
14567            assert_eq!(a.brain, "acme-ops");
14568            assert_eq!(a.target, None);
14569        }
14570    }
14571
14572    #[test]
14573    fn address_ulid_target_parses_as_id() {
14574        let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
14575        assert_eq!(a.brain, "acme");
14576        assert_eq!(
14577            a.target,
14578            Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
14579        );
14580    }
14581
14582    #[test]
14583    fn address_md_path_target_parses_as_path() {
14584        let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
14585        assert_eq!(
14586            a.target,
14587            Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
14588        );
14589    }
14590
14591    #[test]
14592    fn address_rejects_malformed_forms() {
14593        for raw in [
14594            "",
14595            "@",
14596            "@/x",
14597            "@acme/",
14598            "@acme/../etc/passwd",
14599            "@acme/records/.hidden.md",
14600            "@ACME",             // uppercase is not a hub ref shape
14601            "@acme/notes/x.txt", // target is neither ULID nor .md path
14602            "@a b",              // whitespace
14603        ] {
14604            assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
14605        }
14606    }
14607
14608    // ── Path safety ─────────────────────────────────────────────────────────
14609
14610    #[test]
14611    fn safe_paths_accept_store_shapes_and_reject_escapes() {
14612        for ok in [
14613            "DB.md",
14614            "assets.jsonl",
14615            "records/clients/lumio.md",
14616            "sources/emails/2026/07/x.md",
14617        ] {
14618            assert!(safe_store_rel_path(ok), "should accept {ok:?}");
14619        }
14620        for bad in [
14621            "",
14622            "/etc/passwd",
14623            "../up.md",
14624            "records/../../up.md",
14625            "records//x.md",
14626            ".dbmd/config",
14627            "records/.hidden/x.md",
14628            "records/a b.md",
14629            "records\\win.md",
14630        ] {
14631            assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
14632        }
14633    }
14634
14635    #[cfg(unix)]
14636    #[test]
14637    fn opened_destination_capability_survives_an_ancestor_path_swap() {
14638        use std::os::unix::fs::symlink;
14639
14640        let work = tempfile::tempdir().unwrap();
14641        let outside = tempfile::tempdir().unwrap();
14642        let original = work.path().join("destination");
14643        let moved = work.path().join("destination-moved");
14644        let directory = open_or_create_dir_nofollow(&original).unwrap();
14645
14646        std::fs::rename(&original, &moved).unwrap();
14647        symlink(outside.path(), &original).unwrap();
14648        write_pull_entries_beneath_dir(
14649            &directory,
14650            &[("records/note.md".to_string(), b"held inode".to_vec())],
14651        )
14652        .unwrap();
14653
14654        assert_eq!(
14655            std::fs::read(moved.join("records/note.md")).unwrap(),
14656            b"held inode"
14657        );
14658        assert!(!outside.path().join("records/note.md").exists());
14659    }
14660
14661    // ── Config resolution (flag + file precedence; env is covered by the CLI
14662    //    integration tests, where a child process isolates it) ───────────────
14663
14664    #[test]
14665    fn hub_config_flag_beats_file_and_requires_some_source() {
14666        let dir = tempfile::tempdir().unwrap();
14667        std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
14668        std::fs::write(
14669            dir.path().join(CONFIG_REL_PATH),
14670            "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
14671        )
14672        .unwrap();
14673
14674        let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
14675        assert_eq!(from_flag.hub, "https://flag.example.com");
14676
14677        let from_file = hub_config(None, dir.path()).unwrap();
14678        assert_eq!(from_file.hub, "https://file.example.com");
14679
14680        let none = hub_config(None, tempfile::tempdir().unwrap().path());
14681        assert!(matches!(none, Err(LinkError::NoHub)));
14682    }
14683
14684    #[test]
14685    fn https_guard_allows_loopback_only_for_plain_http() {
14686        assert!(assert_safe_hub("https://hub.example.com").is_ok());
14687        assert!(assert_safe_hub("http://localhost:3000").is_ok());
14688        assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
14689        assert!(assert_safe_hub("http://[::1]:3000").is_ok());
14690        assert!(matches!(
14691            assert_safe_hub("http://hub.example.com"),
14692            Err(LinkError::UnsafeHub { .. })
14693        ));
14694        assert!(matches!(
14695            assert_safe_hub("hub.example.com"),
14696            Err(LinkError::UnsafeHub { .. })
14697        ));
14698        assert!(matches!(
14699            assert_safe_hub("http://localhost:80@127.0.0.1:1"),
14700            Err(LinkError::UnsafeHub { .. })
14701        ));
14702        assert!(matches!(
14703            assert_safe_hub("https://hub.example.com@attacker.example"),
14704            Err(LinkError::UnsafeHub { .. })
14705        ));
14706        assert!(matches!(
14707            assert_safe_hub("https://hub.example.com/base"),
14708            Err(LinkError::UnsafeHub { .. })
14709        ));
14710    }
14711
14712    #[test]
14713    fn registry_ssrf_classifier_rejects_local_private_and_documentation_ranges() {
14714        for blocked in [
14715            "127.0.0.1",
14716            "10.0.0.1",
14717            "100.64.0.1",
14718            "169.254.169.254",
14719            "172.16.0.1",
14720            "192.168.0.1",
14721            "192.88.99.1",
14722            "198.18.0.1",
14723            "203.0.113.1",
14724            "::1",
14725            "fe80::1",
14726            "fd00::1",
14727            "2001:db8::1",
14728            "2001:1::1",
14729            "2002:7f00:1::",
14730            "3fff::1",
14731        ] {
14732            assert!(
14733                !is_public_registry_ip(blocked.parse().unwrap()),
14734                "must block {blocked}"
14735            );
14736        }
14737        assert!(is_public_registry_ip("1.1.1.1".parse().unwrap()));
14738        assert!(is_public_registry_ip(
14739            "2606:4700:4700::1111".parse().unwrap()
14740        ));
14741        assert!(is_public_registry_ip("3fff:1000::1".parse().unwrap()));
14742    }
14743
14744    #[test]
14745    fn registry_dns_answer_is_pinned_and_cannot_rebind_or_change_authority() {
14746        use ureq::Resolver as _;
14747
14748        let pinned: std::net::SocketAddr = "1.1.1.1:443".parse().unwrap();
14749        let resolver = PinnedRegistryResolver {
14750            netloc: "home.example:443".to_string(),
14751            addresses: vec![pinned],
14752        };
14753        assert_eq!(resolver.resolve("home.example:443").unwrap(), vec![pinned]);
14754        assert!(resolver.resolve("127.0.0.1:443").is_err());
14755        assert_eq!(
14756            resolver.resolve("home.example:443").unwrap(),
14757            vec![pinned],
14758            "subsequent connects reuse the validated answer instead of DNS"
14759        );
14760    }
14761
14762    #[test]
14763    fn production_object_urls_and_store_selected_hubs_cannot_reach_private_ips() {
14764        let cfg = HubConfig {
14765            hub: "https://hub.example".to_string(),
14766            key: None,
14767            agent_key: None,
14768            brain_key: None,
14769            state_dir: tempfile::tempdir().unwrap().keep(),
14770            store_selected: false,
14771        };
14772        assert!(
14773            presigned_agent(&cfg, "https://127.0.0.1/private").is_err(),
14774            "a production hub must not turn its presigned URL into an SSRF primitive"
14775        );
14776
14777        let store_selected = HubConfig {
14778            hub: "https://127.0.0.1".to_string(),
14779            store_selected: true,
14780            ..cfg
14781        };
14782        assert!(
14783            hub_agent(&store_selected).is_err(),
14784            "bytes in a cloned store must not select a private-network hub"
14785        );
14786    }
14787
14788    #[test]
14789    fn presigned_download_ceiling_cannot_overflow_at_u64_max() {
14790        assert_eq!(
14791            one_past_bounded_limit(MAX_PACK_BYTES),
14792            Some(MAX_PACK_BYTES + 1),
14793            "the presigned reader consumes exactly one refusal byte beyond its fixed pack cap"
14794        );
14795        assert_eq!(
14796            presigned_download_read_limit(),
14797            MAX_PACK_BYTES + 1,
14798            "the presigned reader is capped by the client constant, not a hub response"
14799        );
14800        assert_eq!(
14801            one_past_bounded_limit(u64::MAX),
14802            None,
14803            "the former attacker-controlled u64::MAX + 1 shape must fail without overflow"
14804        );
14805    }
14806
14807    #[test]
14808    fn https_guard_matches_the_scheme_case_insensitively() {
14809        // RFC 3986 schemes are case-insensitive: an uppercase-scheme HTTPS
14810        // hub is still HTTPS, never a misleading non-HTTPS refusal.
14811        assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
14812        assert!(assert_safe_hub("Https://hub.example.com").is_ok());
14813        // And an uppercase plain-HTTP hub is still refused outside loopback.
14814        assert!(matches!(
14815            assert_safe_hub("HTTP://hub.example.com"),
14816            Err(LinkError::UnsafeHub { .. })
14817        ));
14818    }
14819
14820    #[test]
14821    fn clean_key_refuses_paste_artifacts_without_echoing() {
14822        assert_eq!(clean_key("  vc_account_abc  ").unwrap(), "vc_account_abc");
14823        for bad in ["vc account", "vc\naccount", "ключ", ""] {
14824            let err = clean_key(bad).unwrap_err();
14825            assert!(matches!(err, LinkError::BadKey));
14826            assert!(
14827                !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
14828                "error must not echo the key"
14829            );
14830        }
14831    }
14832
14833    // ── Verb entry gates: refs must never reshape the request path ──────────
14834
14835    /// A config whose hub passes the loopback guard but is never listened on:
14836    /// every refusal below must come from the entry gate BEFORE a request
14837    /// exists — a dial on this dead port would surface `Transport` instead.
14838    fn dead_hub() -> HubConfig {
14839        HubConfig {
14840            hub: "http://127.0.0.1:9".to_string(),
14841            key: Some("k".to_string()),
14842            agent_key: None,
14843            brain_key: None,
14844            state_dir: PathBuf::from("."),
14845            store_selected: false,
14846        }
14847    }
14848
14849    #[test]
14850    fn request_retries_a_connection_failure_before_sending() {
14851        use std::io::{Read as _, Write as _};
14852        use std::net::TcpListener;
14853        use std::thread;
14854        use std::time::Duration;
14855
14856        let probe = TcpListener::bind("127.0.0.1:0").unwrap();
14857        let address = probe.local_addr().unwrap();
14858        drop(probe);
14859        let server = thread::spawn(move || {
14860            thread::sleep(Duration::from_millis(40));
14861            let listener = TcpListener::bind(address).unwrap();
14862            let (mut stream, _) = listener.accept().unwrap();
14863            let mut request_bytes = [0_u8; 1024];
14864            let _ = stream.read(&mut request_bytes).unwrap();
14865            stream
14866                .write_all(
14867                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
14868                )
14869                .unwrap();
14870        });
14871        let cfg = HubConfig {
14872            hub: format!("http://{address}"),
14873            key: None,
14874            agent_key: None,
14875            brain_key: None,
14876            state_dir: tempfile::tempdir().unwrap().keep(),
14877            store_selected: false,
14878        };
14879
14880        let response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
14881        assert_eq!(response.status, 200);
14882        assert_eq!(response.body, Some(json!({ "ok": true })));
14883        server.join().unwrap();
14884    }
14885
14886    #[test]
14887    fn endpoint_cap_refuses_a_body_before_json_parsing() {
14888        let (hub, server) = scripted_json_hub(vec![(200, "x".repeat(2_048))]);
14889        let cfg = HubConfig {
14890            hub,
14891            key: None,
14892            agent_key: None,
14893            brain_key: None,
14894            state_dir: tempfile::tempdir().unwrap().keep(),
14895            store_selected: false,
14896        };
14897
14898        assert!(matches!(
14899            request_capped(&cfg, "GET", "/bounded", None, Auth::None, 1_024),
14900            Err(LinkError::ResponseTooLarge { .. })
14901        ));
14902        server.join().unwrap();
14903    }
14904
14905    #[test]
14906    fn overall_deadline_stops_a_dribbled_response_body() {
14907        use std::io::{Read as _, Write as _};
14908        use std::net::TcpListener;
14909        use std::time::{Duration, Instant};
14910
14911        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
14912        let url = format!("http://{}/dribble", listener.local_addr().unwrap());
14913        let server = std::thread::spawn(move || {
14914            let (mut stream, _) = listener.accept().unwrap();
14915            let mut request = [0_u8; 1024];
14916            let _ = stream.read(&mut request);
14917            stream
14918                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 32\r\nConnection: close\r\n\r\n")
14919                .unwrap();
14920            for byte in [b'x'; 32] {
14921                if stream.write_all(&[byte]).is_err() {
14922                    break;
14923                }
14924                std::thread::sleep(Duration::from_millis(40));
14925            }
14926        });
14927        let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
14928        let started = Instant::now();
14929        let response = http.get(&url).call().unwrap();
14930        let mut body = Vec::new();
14931        let error = response
14932            .into_reader()
14933            .read_to_end(&mut body)
14934            .expect_err("per-read progress must not reset the overall deadline");
14935        assert!(
14936            started.elapsed() < Duration::from_millis(700),
14937            "dribbled body exceeded the wall-clock budget: {error}"
14938        );
14939        server.join().unwrap();
14940    }
14941
14942    #[test]
14943    fn overall_deadline_stops_a_stalled_upload() {
14944        use std::net::TcpListener;
14945        use std::time::{Duration, Instant};
14946
14947        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
14948        let url = format!("http://{}/upload", listener.local_addr().unwrap());
14949        let server = std::thread::spawn(move || {
14950            let (_stream, _) = listener.accept().unwrap();
14951            // Never consume the request body. Once the kernel send buffer fills,
14952            // the client must leave on its absolute write deadline.
14953            std::thread::sleep(Duration::from_millis(600));
14954        });
14955        let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
14956        let body = vec![0x5a; 32 * 1024 * 1024];
14957        let started = Instant::now();
14958        let error = http
14959            .put(&url)
14960            .send_bytes(&body)
14961            .expect_err("stalled request-body writes must time out");
14962        assert!(
14963            started.elapsed() < Duration::from_millis(700),
14964            "stalled upload exceeded the wall-clock budget: {error}"
14965        );
14966        server.join().unwrap();
14967    }
14968
14969    #[test]
14970    fn verb_entry_gates_accept_the_hub_ref_shapes() {
14971        for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
14972            assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
14973            assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
14974            assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
14975        }
14976    }
14977
14978    #[test]
14979    fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
14980        let cfg = dead_hub();
14981        for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
14982            assert!(
14983                matches!(
14984                    sync_pull(&cfg, bad, None),
14985                    Err(LinkError::BadAddress { .. })
14986                ),
14987                "sync_pull must refuse {bad:?}"
14988            );
14989            assert!(
14990                matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
14991                "sync_push must refuse {bad:?}"
14992            );
14993            assert!(
14994                matches!(
14995                    grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
14996                    Err(LinkError::BadAddress { .. })
14997                ),
14998                "grant_issue must refuse {bad:?}"
14999            );
15000            assert!(
15001                matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
15002                "grant_list must refuse {bad:?}"
15003            );
15004            assert!(
15005                matches!(
15006                    grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
15007                    Err(LinkError::BadAddress { .. })
15008                ),
15009                "grant_revoke must refuse brain {bad:?}"
15010            );
15011            assert!(
15012                matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
15013                "head must refuse {bad:?}"
15014            );
15015        }
15016    }
15017
15018    #[test]
15019    fn grant_revoke_refuses_url_reshaping_grant_ids() {
15020        let cfg = dead_hub();
15021        for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
15022            assert!(
15023                matches!(
15024                    grant_revoke(&cfg, "acme", bad),
15025                    Err(LinkError::BadGrantId { .. })
15026                ),
15027                "grant_revoke must refuse grant id {bad:?}"
15028            );
15029        }
15030    }
15031
15032    #[test]
15033    fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
15034        let cfg = dead_hub();
15035        for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
15036            assert!(
15037                matches!(
15038                    propose(&cfg, bad, "intake", "hi"),
15039                    Err(LinkError::BadAddress { .. })
15040                ),
15041                "propose must refuse handle {bad:?}"
15042            );
15043        }
15044        let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
15045        assert!(matches!(
15046            propose(&cfg, "acme-site", "intake", &oversize),
15047            Err(LinkError::ProposeTooLarge { .. })
15048        ));
15049        // A clean handle + in-cap body passes both gates: the failure is now
15050        // the (dead) wire, proving the gates refuse shape, not the verb.
15051        assert!(matches!(
15052            propose(&cfg, "acme-site", "intake", "hi"),
15053            Err(LinkError::Transport { .. })
15054        ));
15055    }
15056
15057    #[test]
15058    fn resolve_refuses_a_hand_built_unsafe_address() {
15059        let cfg = dead_hub();
15060        for brain in ["../up", "a/b", "a?x", "a#f"] {
15061            let addr = Address {
15062                brain: brain.to_string(),
15063                target: None,
15064            };
15065            assert!(
15066                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
15067                "resolve must refuse brain {brain:?}"
15068            );
15069        }
15070        for target in [
15071            AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
15072            AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), // not the minted shape
15073            AddressTarget::Path("../up.md".to_string()),
15074            AddressTarget::Path("records/x.md#frag".to_string()),
15075        ] {
15076            let addr = Address {
15077                brain: "acme".to_string(),
15078                target: Some(target.clone()),
15079            };
15080            assert!(
15081                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
15082                "resolve must refuse target {target:?}"
15083            );
15084        }
15085    }
15086
15087    #[test]
15088    fn v2_final_barrier_refuses_to_advance_a_remote_ahead_checkout() {
15089        let mut local = std::collections::BTreeMap::new();
15090        local.insert("records/a.md".to_string(), ("a".repeat(64), 0));
15091        local.insert("records/b.md".to_string(), ("b".repeat(64), 0));
15092        let mut remote = std::collections::BTreeMap::new();
15093        remote.insert(
15094            "records/a.md".to_string(),
15095            V2BaselineFile {
15096                sha256: "c".repeat(64),
15097                bytes: 1,
15098                proof: None,
15099            },
15100        );
15101        remote.insert(
15102            "records/b.md".to_string(),
15103            V2BaselineFile {
15104                sha256: "b".repeat(64),
15105                bytes: 1,
15106                proof: None,
15107            },
15108        );
15109        assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
15110    }
15111
15112    #[test]
15113    fn v2_final_barrier_ignores_only_explicit_kept_home_paths() {
15114        let local = std::collections::BTreeMap::new();
15115        let mut remote = std::collections::BTreeMap::new();
15116        remote.insert(
15117            "private/local.md".to_string(),
15118            V2BaselineFile {
15119                sha256: "d".repeat(64),
15120                bytes: 1,
15121                proof: None,
15122            },
15123        );
15124        assert!(v2_riding_matches_remote(&local, &remote, |path| path == "private/local.md"));
15125        assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
15126    }
15127
15128    fn scoped_test_head(revision: &str) -> V2VerifiedHead {
15129        V2VerifiedHead {
15130            requested: TEST_BRAIN_ID.to_string(),
15131            brain_id: TEST_BRAIN_ID.to_string(),
15132            view_kind: "scoped".to_string(),
15133            view_revision: revision.to_string(),
15134            control_revision: revision.to_string(),
15135            identity: V2HeadIdentity {
15136                custody: "hub".to_string(),
15137                fingerprint: "test".to_string(),
15138                public_key_spki: "test".to_string(),
15139                previous: Vec::new(),
15140                rotations: Vec::new(),
15141            },
15142            pointer: None,
15143            trust: TrustState {
15144                v: 2,
15145                origin: "https://hub.example".to_string(),
15146                requested: TEST_BRAIN_ID.to_string(),
15147                brain: TEST_BRAIN_ID.to_string(),
15148                home: None,
15149                anchor: "ed25519:test".to_string(),
15150                current: "ed25519:test".to_string(),
15151                head_seq: 0,
15152                feed_hash: None,
15153                rotations: Vec::new(),
15154                hub_signer: None,
15155                protocol_profile: Some("link-v2".to_string()),
15156            },
15157            alias: None,
15158        }
15159    }
15160
15161    #[test]
15162    fn accepted_v2_checkpoint_never_downgrades_after_profile_migration() {
15163        let mut trust = scoped_test_head(&"a".repeat(64)).trust;
15164        assert!(accepted_as_v2(&trust));
15165
15166        trust.protocol_profile = None;
15167        trust.hub_signer = Some("ed25519:hub".to_string());
15168        assert!(accepted_as_v2(&trust));
15169
15170        trust.hub_signer = None;
15171        assert!(!accepted_as_v2(&trust));
15172    }
15173
15174    fn scoped_test_baseline(revision: &str) -> V2SyncBaseline {
15175        V2SyncBaseline {
15176            v: 2,
15177            origin: "https://hub.example".to_string(),
15178            brain: TEST_BRAIN_ID.to_string(),
15179            head_seq: Some(0),
15180            commit_hash: None,
15181            content_root: None,
15182            asset_root: None,
15183            assets: std::collections::BTreeMap::new(),
15184            view_kind: Some("scoped".to_string()),
15185            view_revision: Some(revision.to_string()),
15186            projection_sha256: Some(scoped_projection_sha256(TEST_BRAIN_ID)),
15187            files: std::collections::BTreeMap::new(),
15188            local_policy_digest: None,
15189            local_eligibility: std::collections::BTreeMap::new(),
15190            remote_copy_remains: std::collections::BTreeMap::new(),
15191        }
15192    }
15193
15194    #[test]
15195    fn scoped_projection_is_a_valid_local_store_marker_but_never_rides() {
15196        let directory = tempfile::tempdir().unwrap();
15197        std::fs::write(
15198            directory.path().join("DB.md"),
15199            scoped_projection_bytes(TEST_BRAIN_ID),
15200        )
15201        .unwrap();
15202        let store = Store::open_strict(directory.path()).unwrap();
15203        let head = scoped_test_head(&"a".repeat(64));
15204        let baseline = scoped_test_baseline(&"a".repeat(64));
15205        let mut view = v2_local_files(&store).unwrap();
15206        remove_scoped_projection(&head, Some(&baseline), &mut view).unwrap();
15207        assert!(!view.riding.contains_key("DB.md"));
15208        assert!(!view.eligibility.contains_key("DB.md"));
15209    }
15210
15211    #[test]
15212    fn scoped_projection_edit_and_scope_transition_fail_closed() {
15213        let directory = tempfile::tempdir().unwrap();
15214        std::fs::write(
15215            directory.path().join("DB.md"),
15216            b"---\ntype: db-md\nscope: company\nowner: someone\n---\n",
15217        )
15218        .unwrap();
15219        let store = Store::open_strict(directory.path()).unwrap();
15220        let head = scoped_test_head(&"a".repeat(64));
15221        let baseline = scoped_test_baseline(&"a".repeat(64));
15222        let mut view = v2_local_files(&store).unwrap();
15223        assert!(matches!(
15224            remove_scoped_projection(&head, Some(&baseline), &mut view),
15225            Err(LinkError::ScopedProjectionModified)
15226        ));
15227
15228        let changed = scoped_test_head(&"b".repeat(64));
15229        assert!(matches!(
15230            ensure_v2_view_compatible(&changed, Some(&baseline)),
15231            Err(LinkError::ScopedViewChanged)
15232        ));
15233
15234        let mut same_view_new_control = head.clone();
15235        same_view_new_control.control_revision = "c".repeat(64);
15236        assert!(ensure_v2_view_compatible(&same_view_new_control, Some(&baseline)).is_ok());
15237        assert!(!same_v2_head(&head, &same_view_new_control));
15238    }
15239
15240    #[test]
15241    fn established_checkout_never_becomes_an_empty_clone_when_db_md_is_invalid() {
15242        let scoped = scoped_test_head(&"a".repeat(64));
15243        let scoped_baseline = scoped_test_baseline(&"a".repeat(64));
15244        assert!(matches!(
15245            ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), false),
15246            Err(LinkError::ScopedProjectionModified)
15247        ));
15248
15249        let mut full = scoped.clone();
15250        full.view_kind = "full".to_string();
15251        let mut full_baseline = scoped_baseline.clone();
15252        full_baseline.view_kind = Some("full".to_string());
15253        full_baseline.projection_sha256 = None;
15254        assert!(matches!(
15255            ensure_established_v2_checkout_opened(&full, Some(&full_baseline), false),
15256            Err(LinkError::InvalidPack { .. })
15257        ));
15258
15259        assert!(ensure_established_v2_checkout_opened(&scoped, None, false).is_ok());
15260        assert!(
15261            ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), true).is_ok()
15262        );
15263    }
15264
15265    #[test]
15266    fn scoped_view_metadata_is_explicitly_non_authoritative() {
15267        let head = scoped_test_head(&"a".repeat(64));
15268        let value: Value =
15269            serde_json::from_slice(&scoped_view_metadata(&head, 7).unwrap()).unwrap();
15270        assert_eq!(value["kind"], "link.md-scoped-view");
15271        assert_eq!(value["authoritative"], false);
15272        assert_eq!(value["visible_files"], 7);
15273        assert_eq!(value["brain"], TEST_BRAIN_ID);
15274    }
15275
15276    #[test]
15277    fn local_scoped_marker_requires_the_exact_generated_projection() {
15278        let directory = tempfile::tempdir().unwrap();
15279        std::fs::create_dir(directory.path().join(".dbmd")).unwrap();
15280        std::fs::write(
15281            directory.path().join("DB.md"),
15282            scoped_projection_bytes(TEST_BRAIN_ID),
15283        )
15284        .unwrap();
15285        let head = scoped_test_head(&"a".repeat(64));
15286        std::fs::write(
15287            directory.path().join(".dbmd/view.json"),
15288            scoped_view_metadata(&head, 0).unwrap(),
15289        )
15290        .unwrap();
15291        let store = Store::open_strict(directory.path()).unwrap();
15292        assert!(has_verified_local_scoped_view(&store));
15293
15294        std::fs::write(
15295            directory.path().join("DB.md"),
15296            b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
15297        )
15298        .unwrap();
15299        let altered = Store::open_strict(directory.path()).unwrap();
15300        assert!(!has_verified_local_scoped_view(&altered));
15301    }
15302
15303    fn signed_proposal_fixture() -> (V2VerifiedHead, String, Value) {
15304        use ring::signature::KeyPair as _;
15305
15306        let proposal_id = "01k2r7bm9w5x6e8nq3tjhv4cya".to_string();
15307        let rng = ring::rand::SystemRandom::new();
15308        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15309        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15310        let public_der = [ED25519_SPKI_PREFIX.as_slice(), pair.public_key().as_ref()].concat();
15311        let public_key = URL_SAFE_NO_PAD.encode(&public_der);
15312        let fingerprint = format!("{:x}", Sha256::digest(&public_der));
15313        let blob = b"new";
15314        let blob_hash = content_sha256(blob);
15315        let changes = json!({
15316            "mutation_id": "sync:proposal-fixture",
15317            "operations": [{
15318                "blob": blob_hash,
15319                "bytes": blob.len(),
15320                "expected": null,
15321                "op": "put",
15322                "path": "records/new.md",
15323            }],
15324            "reason": "fixture",
15325            "v": 2,
15326        });
15327        let changes_bytes = crate::linkmd_v2::canonical_bytes(&changes).unwrap();
15328        let changes_base64 = STANDARD.encode(&changes_bytes);
15329        let descriptor = json!({
15330            "base": null,
15331            "blobs": [{ "bytes": blob.len(), "sha256": blob_hash }],
15332            "changes_base64": changes_base64,
15333            "rebase": "strict",
15334            "v": 2,
15335        });
15336        let clear_hash = content_sha256(&crate::linkmd_v2::canonical_bytes(&descriptor).unwrap());
15337        let payload_hash = "b".repeat(64);
15338        let submitted_at = "2026-08-19T12:00:00.000Z";
15339        let claim = json!({
15340            "actor_root": {
15341                "actor_class": "foreign_key",
15342                "credential": "ed25519:fixture",
15343                "grants": ["01k2r7bm9w5x6e8nq3tjhv4cyb"],
15344                "organization": "01k2r7bm9w5x6e8nq3tjhv4cyc",
15345                "principal": "key:fixture",
15346                "role": null,
15347            },
15348            "brain": TEST_BRAIN_ID,
15349            "clear_sha256": clear_hash,
15350            "control_revision": "c".repeat(64),
15351            "mutation_id": "sync:proposal-fixture",
15352            "payload_sha256": payload_hash,
15353            "proposal_id": proposal_id,
15354            "submitted_at": submitted_at,
15355            "v": 2,
15356        });
15357        let claim_bytes = crate::linkmd_v2::canonical_bytes(&claim).unwrap();
15358        let envelope = json!({
15359            "claim": claim,
15360            "fingerprint": fingerprint,
15361            "public_key": public_key,
15362            "sig": URL_SAFE_NO_PAD.encode(pair.sign(&claim_bytes).as_ref()),
15363        });
15364        let envelope_bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
15365        let submission_hash =
15366            crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &envelope_bytes).unwrap();
15367        let mut head = scoped_test_head(&"c".repeat(64));
15368        head.view_kind = "full".to_string();
15369        head.trust.hub_signer = Some(format!("{fingerprint}:{public_key}"));
15370        let value = json!({
15371            "proposal": {
15372                "base": null,
15373                "blobs": [{
15374                    "bytes": blob.len(),
15375                    "endpoint": format!(
15376                        "/api/hub/brains/{TEST_BRAIN_ID}/v2/proposals/{proposal_id}/blob?sha256={blob_hash}"
15377                    ),
15378                    "sha256": blob_hash,
15379                }],
15380                "changes_base64": changes_base64,
15381                "clear_sha256": clear_hash,
15382                "expires_at": "2026-08-26T12:00:00.000Z",
15383                "id": proposal_id,
15384                "payload_sha256": payload_hash,
15385                "proposer": { "class": "foreign_key" },
15386                "rebase": "strict",
15387                "state": "pending",
15388                "submission_claim_base64": STANDARD.encode(envelope_bytes),
15389                "submission_claim_sha256": submission_hash,
15390                "submitted_at": submitted_at,
15391            },
15392            "v": 2,
15393        });
15394        (head, proposal_id, value)
15395    }
15396
15397    #[test]
15398    fn v2_proposal_verifier_accepts_exact_signed_payload() {
15399        let (head, proposal_id, value) = signed_proposal_fixture();
15400        let verified = verify_v2_proposal_value(&head, &proposal_id, value).unwrap();
15401        assert_eq!(verified.blobs.len(), 1);
15402        assert_eq!(verified.changes["operations"][0]["path"], "records/new.md");
15403    }
15404
15405    #[test]
15406    fn v2_proposal_verifier_refuses_payload_endpoint_and_signature_tampering() {
15407        let (head, proposal_id, value) = signed_proposal_fixture();
15408
15409        let mut changed = value.clone();
15410        changed["proposal"]["changes_base64"] = Value::String(STANDARD.encode(b"{}"));
15411        assert!(verify_v2_proposal_value(&head, &proposal_id, changed).is_err());
15412
15413        let mut redirected = value.clone();
15414        redirected["proposal"]["blobs"][0]["endpoint"] =
15415            Value::String("https://attacker.example/blob".to_string());
15416        assert!(verify_v2_proposal_value(&head, &proposal_id, redirected).is_err());
15417
15418        let mut forged = value;
15419        let encoded = forged["proposal"]["submission_claim_base64"]
15420            .as_str()
15421            .unwrap();
15422        let mut envelope: Value =
15423            serde_json::from_slice(&STANDARD.decode(encoded).unwrap()).unwrap();
15424        envelope["sig"] = Value::String(URL_SAFE_NO_PAD.encode([0_u8; 64]));
15425        let bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
15426        forged["proposal"]["submission_claim_base64"] = Value::String(STANDARD.encode(&bytes));
15427        forged["proposal"]["submission_claim_sha256"] = Value::String(
15428            crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &bytes).unwrap(),
15429        );
15430        assert!(verify_v2_proposal_value(&head, &proposal_id, forged).is_err());
15431    }
15432
15433    #[cfg(unix)]
15434    #[test]
15435    fn v2_atomic_install_materializes_only_local_catalogs_before_swap() {
15436        let sandbox = tempfile::tempdir().unwrap();
15437        let destination = sandbox.path().join("brain");
15438        let entries = vec![
15439            (
15440                "DB.md".to_string(),
15441                scoped_projection_bytes(TEST_BRAIN_ID),
15442            ),
15443            (
15444                "records/contacts/a.md".to_string(),
15445                b"---\ntype: contact\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Visible contact\n---\n\n# A\n"
15446                    .to_vec(),
15447            ),
15448        ];
15449        install_pulled_delta(&destination, &entries, &[], true).unwrap();
15450        assert!(destination.join("index.md").is_file());
15451        assert!(destination.join("records/index.md").is_file());
15452        assert!(destination.join("records/contacts/index.md").is_file());
15453        assert!(destination.join("records/contacts/index.jsonl").is_file());
15454    }
15455
15456    #[cfg(unix)]
15457    #[test]
15458    fn v2_bounded_parallel_source_install_is_exact_and_atomic() {
15459        let sandbox = tempfile::tempdir().unwrap();
15460        let destination = sandbox.path().join("brain");
15461        let cache = sandbox.path().join("cache");
15462        std::fs::create_dir(&cache).unwrap();
15463        let db = scoped_projection_bytes(TEST_BRAIN_ID);
15464        let shared = b"---\ntype: note\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Shared\n---\n\n# Shared\n";
15465        let db_source = cache.join("db");
15466        let shared_source = cache.join("shared");
15467        crate::fsx::write_atomic(&db_source, &db).unwrap();
15468        crate::fsx::write_atomic(&shared_source, shared).unwrap();
15469        let mut entries = vec![V2StagedFile {
15470            path: "DB.md".to_string(),
15471            source: db_source,
15472            sha256: content_sha256(&db),
15473            bytes: db.len() as u64,
15474        }];
15475        for index in 0..512 {
15476            entries.push(V2StagedFile {
15477                path: format!("records/items/{index:05}.md"),
15478                source: shared_source.clone(),
15479                sha256: content_sha256(shared),
15480                bytes: shared.len() as u64,
15481            });
15482        }
15483        install_pulled_delta_sources(
15484            &destination,
15485            &entries,
15486            &[],
15487            false,
15488            None,
15489            &scoped_test_head(&"c".repeat(64)),
15490        )
15491        .unwrap();
15492        assert_eq!(std::fs::read(destination.join("DB.md")).unwrap(), db);
15493        for index in 0..512 {
15494            assert_eq!(
15495                std::fs::read(destination.join(format!("records/items/{index:05}.md"))).unwrap(),
15496                shared
15497            );
15498        }
15499        assert!(
15500            std::fs::read_dir(sandbox.path())
15501                .unwrap()
15502                .all(|entry| !entry
15503                    .unwrap()
15504                    .file_name()
15505                    .to_string_lossy()
15506                    .contains("pull-stage")),
15507            "the private stage must be atomically installed or removed"
15508        );
15509    }
15510
15511    #[cfg(unix)]
15512    #[test]
15513    fn v2_established_pull_touches_only_delta_and_rolls_back_exactly() {
15514        use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
15515
15516        let sandbox = tempfile::tempdir().unwrap();
15517        let root = sandbox.path().join("brain");
15518        std::fs::create_dir_all(root.join("records/items")).unwrap();
15519        let db = scoped_projection_bytes(TEST_BRAIN_ID);
15520        let old = b"---\ntype: note\n---\n\nold\n";
15521        let new = b"---\ntype: note\n---\n\nnew\n";
15522        let removed = b"---\ntype: note\n---\n\nremove me\n";
15523        std::fs::write(root.join("DB.md"), &db).unwrap();
15524        std::fs::write(root.join("records/items/change.md"), old).unwrap();
15525        std::fs::write(root.join("records/items/delete.md"), removed).unwrap();
15526        for index in 0..512 {
15527            std::fs::write(
15528                root.join(format!("records/items/untouched-{index:04}.md")),
15529                old,
15530            )
15531            .unwrap();
15532        }
15533        let untouched = root.join("records/items/untouched-0256.md");
15534        let untouched_inode = std::fs::metadata(&untouched).unwrap().ino();
15535        let source = sandbox.path().join("changed-source");
15536        crate::fsx::write_atomic(&source, new).unwrap();
15537        let same_source = sandbox.path().join("unchanged-source");
15538        crate::fsx::write_atomic(&same_source, old).unwrap();
15539        let same_entry = V2StagedFile {
15540            path: "records/items/change.md".to_string(),
15541            source: same_source,
15542            sha256: content_sha256(old),
15543            bytes: old.len() as u64,
15544        };
15545        let entry = V2StagedFile {
15546            path: "records/items/change.md".to_string(),
15547            source,
15548            sha256: content_sha256(new),
15549            bytes: new.len() as u64,
15550        };
15551        let head = scoped_test_head(&"c".repeat(64));
15552
15553        // A no-op established pull neither clones the checkout nor arms a
15554        // journal. Inode identity is a direct regression guard against the
15555        // former brain-wide private-copy implementation.
15556        install_established_v2_delta(
15557            Store::open_strict(&root).unwrap(),
15558            &[same_entry],
15559            &["records/items/already-absent.md".to_string()],
15560            true,
15561            None,
15562            &head,
15563        )
15564        .unwrap();
15565        assert_eq!(
15566            std::fs::metadata(&untouched).unwrap().ino(),
15567            untouched_inode
15568        );
15569        assert!(!root.join(V2_PULL_JOURNAL).exists());
15570
15571        install_established_v2_delta(
15572            Store::open_strict(&root).unwrap(),
15573            &[entry],
15574            &["records/items/delete.md".to_string()],
15575            false,
15576            None,
15577            &head,
15578        )
15579        .unwrap();
15580        assert_eq!(
15581            std::fs::read(root.join("records/items/change.md")).unwrap(),
15582            new
15583        );
15584        assert!(!root.join("records/items/delete.md").exists());
15585        assert_eq!(
15586            std::fs::metadata(&untouched).unwrap().ino(),
15587            untouched_inode
15588        );
15589        assert!(root.join(V2_PULL_JOURNAL).is_file());
15590        assert_eq!(
15591            std::fs::metadata(root.join(V2_PULL_JOURNAL))
15592                .unwrap()
15593                .permissions()
15594                .mode()
15595                & 0o777,
15596            0o600
15597        );
15598        let journal = load_v2_pull_journal(&Store::open_strict(&root).unwrap())
15599            .unwrap()
15600            .unwrap();
15601        assert_eq!(
15602            std::fs::metadata(root.join(&journal.backup_dir))
15603                .unwrap()
15604                .permissions()
15605                .mode()
15606                & 0o777,
15607            0o700
15608        );
15609        for entry in &journal.entries {
15610            if let Some(backup) = &entry.backup {
15611                assert_eq!(
15612                    std::fs::metadata(root.join(&journal.backup_dir).join(backup))
15613                        .unwrap()
15614                        .permissions()
15615                        .mode()
15616                        & 0o777,
15617                    0o600
15618                );
15619            }
15620        }
15621
15622        let cfg = test_hub_config(
15623            "https://example.test".to_string(),
15624            sandbox.path().join("state"),
15625        );
15626        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
15627        assert_eq!(
15628            std::fs::read(root.join("records/items/change.md")).unwrap(),
15629            old
15630        );
15631        assert_eq!(
15632            std::fs::read(root.join("records/items/delete.md")).unwrap(),
15633            removed
15634        );
15635        assert_eq!(
15636            std::fs::metadata(&untouched).unwrap().ino(),
15637            untouched_inode
15638        );
15639        assert!(!root.join(V2_PULL_JOURNAL).exists());
15640    }
15641
15642    #[test]
15643    fn v2_bulk_stream_is_exact_ordered_and_tamper_evident() {
15644        let body = b"bounded bytes";
15645        let path = "records/example.md".to_string();
15646        let file = V2BaselineFile {
15647            sha256: content_sha256(body),
15648            bytes: body.len() as u64,
15649            proof: None,
15650        };
15651        let header = serde_json::to_vec(&json!({
15652            "bytes": body.len(),
15653            "path": path,
15654            "sha256": file.sha256,
15655            "v": 2,
15656        }))
15657        .unwrap();
15658        let mut stream = V2_BULK_STREAM_MAGIC.to_vec();
15659        stream.extend_from_slice(&(header.len() as u32).to_be_bytes());
15660        stream.extend_from_slice(&header);
15661        stream.extend_from_slice(body);
15662        stream.extend_from_slice(&0_u32.to_be_bytes());
15663        let parsed = parse_v2_bulk_stream(&stream, &[(&path, &file)]).unwrap();
15664        assert_eq!(parsed, vec![(path.clone(), body.to_vec())]);
15665
15666        let mut tampered = stream.clone();
15667        let body_offset = V2_BULK_STREAM_MAGIC.len() + 4 + header.len();
15668        tampered[body_offset] ^= 1;
15669        assert!(parse_v2_bulk_stream(&tampered, &[(&path, &file)]).is_err());
15670
15671        let mut trailing = stream;
15672        trailing.push(0);
15673        assert!(parse_v2_bulk_stream(&trailing, &[(&path, &file)]).is_err());
15674    }
15675
15676    #[test]
15677    fn conflict_cache_prunes_only_expired_or_incomplete_state_by_default() {
15678        let sandbox = tempfile::TempDir::new().unwrap();
15679        let root = sandbox.path().join("brain");
15680        std::fs::create_dir_all(&root).unwrap();
15681        std::fs::write(
15682            root.join("DB.md"),
15683            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
15684        )
15685        .unwrap();
15686        let store = Store::open_strict(&root).unwrap();
15687        let incomplete = crate::ulid::mint();
15688        store
15689            .create_dir_all(&v2_conflict_relative(&incomplete, "files"))
15690            .unwrap();
15691        let expired = crate::ulid::mint();
15692        store
15693            .create_dir_all(&v2_conflict_relative(&expired, "files"))
15694            .unwrap();
15695        let plan = V2ConflictPlan {
15696            v: 2,
15697            class: "content_resolution_required".to_string(),
15698            bundle: expired.clone(),
15699            brain: TEST_BRAIN_ID.to_string(),
15700            origin: "https://example.test".to_string(),
15701            created_unix: 0,
15702            expires_unix: 0,
15703            base_seq: None,
15704            base_commit: None,
15705            remote_seq: 0,
15706            remote_commit: None,
15707            remote_content_root: None,
15708            view_kind: "full".to_string(),
15709            view_revision: "a".repeat(64),
15710            files: vec![V2ConflictFile {
15711                path: "records/value.md".to_string(),
15712                base: V2ConflictCoordinate {
15713                    sha256: None,
15714                    bytes: None,
15715                    file: None,
15716                },
15717                local: V2ConflictCoordinate {
15718                    sha256: None,
15719                    bytes: None,
15720                    file: None,
15721                },
15722                remote: V2ConflictCoordinate {
15723                    sha256: None,
15724                    bytes: None,
15725                    file: None,
15726                },
15727            }],
15728        };
15729        let mut bytes = serde_json::to_vec(&plan).unwrap();
15730        bytes.push(b'\n');
15731        store
15732            .write_atomic_new(&v2_conflict_relative(&expired, "plan.json"), &bytes)
15733            .unwrap();
15734
15735        let listed = sync_conflicts(&root, false, false).unwrap();
15736        assert_eq!(listed["bundles"], 2);
15737        assert_eq!(listed["pruned"], 0);
15738        let pruned = sync_conflicts(&root, true, false).unwrap();
15739        assert_eq!(pruned["bundles"], 0);
15740        assert_eq!(pruned["pruned"], 2);
15741        assert!(!root.join(".dbmd/conflicts").join(incomplete).exists());
15742        assert!(!root.join(".dbmd/conflicts").join(expired).exists());
15743    }
15744
15745    #[test]
15746    fn corrupt_completed_conflict_state_requires_explicit_discard_all() {
15747        let sandbox = tempfile::TempDir::new().unwrap();
15748        let root = sandbox.path().join("brain");
15749        std::fs::create_dir_all(&root).unwrap();
15750        std::fs::write(
15751            root.join("DB.md"),
15752            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
15753        )
15754        .unwrap();
15755        let store = Store::open_strict(&root).unwrap();
15756        let bundle = crate::ulid::mint();
15757        store
15758            .create_dir_all(&v2_conflict_relative(&bundle, "files"))
15759            .unwrap();
15760        store
15761            .write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), b"not-json\n")
15762            .unwrap();
15763
15764        assert!(sync_conflicts(&root, true, false).is_err());
15765        assert!(sync_conflicts(&root, false, true).is_err());
15766        let pruned = sync_conflicts(&root, true, true).unwrap();
15767        assert_eq!(pruned["pruned"], 1);
15768        assert!(!root.join(".dbmd/conflicts").join(bundle).exists());
15769    }
15770
15771    #[test]
15772    fn ready_pull_journal_rolls_back_exact_preimages() {
15773        let sandbox = tempfile::TempDir::new().unwrap();
15774        let root = sandbox.path().join("brain");
15775        std::fs::create_dir_all(root.join("records")).unwrap();
15776        std::fs::write(
15777            root.join("DB.md"),
15778            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
15779        )
15780        .unwrap();
15781        let path = "records/value.md";
15782        let old = b"---\ntype: note\n---\n\nold\n";
15783        let new = b"---\ntype: note\n---\n\nnew\n";
15784        std::fs::write(root.join(path), old).unwrap();
15785        let store = Store::open_strict(&root).unwrap();
15786        let bundle = crate::ulid::mint();
15787        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
15788        store
15789            .create_private_dir_all(Path::new(&backup_dir))
15790            .unwrap();
15791        store
15792            .write_private_atomic_new(&Path::new(&backup_dir).join("00000000"), old)
15793            .unwrap();
15794        let journal = V2PullJournal {
15795            v: 1,
15796            phase: V2PullPhase::Ready,
15797            brain: TEST_BRAIN_ID.to_string(),
15798            previous: V2PullCoordinate {
15799                head_seq: None,
15800                commit_hash: None,
15801                view_kind: None,
15802                view_revision: None,
15803            },
15804            next: V2PullCoordinate {
15805                head_seq: Some(2),
15806                commit_hash: Some("c".repeat(64)),
15807                view_kind: Some("full".to_string()),
15808                view_revision: Some("d".repeat(64)),
15809            },
15810            backup_dir: backup_dir.clone(),
15811            entries: vec![V2PullJournalEntry {
15812                path: path.to_string(),
15813                old: Some(V2PullFileCoordinate {
15814                    sha256: content_sha256(old),
15815                    bytes: old.len() as u64,
15816                }),
15817                new: Some(V2PullFileCoordinate {
15818                    sha256: content_sha256(new),
15819                    bytes: new.len() as u64,
15820                }),
15821                backup: Some("00000000".to_string()),
15822            }],
15823        };
15824        validate_v2_pull_journal(&journal).unwrap();
15825        store
15826            .write_private_atomic_new(
15827                Path::new(V2_PULL_JOURNAL),
15828                &v2_pull_journal_bytes(&journal).unwrap(),
15829            )
15830            .unwrap();
15831        store.write_atomic(Path::new(path), new).unwrap();
15832
15833        let cfg = test_hub_config(
15834            "https://example.test".to_string(),
15835            sandbox.path().join("state"),
15836        );
15837        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
15838        assert_eq!(std::fs::read(root.join(path)).unwrap(), old);
15839        assert!(!root.join(V2_PULL_JOURNAL).exists());
15840        assert!(!root.join(backup_dir).exists());
15841    }
15842
15843    #[test]
15844    fn preparing_pull_journal_discards_only_private_staging() {
15845        let sandbox = tempfile::TempDir::new().unwrap();
15846        let root = sandbox.path().join("brain");
15847        std::fs::create_dir_all(root.join(".dbmd")).unwrap();
15848        std::fs::write(
15849            root.join("DB.md"),
15850            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
15851        )
15852        .unwrap();
15853        let store = Store::open_strict(&root).unwrap();
15854        let bundle = crate::ulid::mint();
15855        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
15856        store
15857            .create_private_dir_all(Path::new(&backup_dir))
15858            .unwrap();
15859        let journal = V2PullJournal {
15860            v: 1,
15861            phase: V2PullPhase::Preparing,
15862            brain: TEST_BRAIN_ID.to_string(),
15863            previous: V2PullCoordinate {
15864                head_seq: None,
15865                commit_hash: None,
15866                view_kind: None,
15867                view_revision: None,
15868            },
15869            next: V2PullCoordinate {
15870                head_seq: Some(1),
15871                commit_hash: Some("a".repeat(64)),
15872                view_kind: Some("full".to_string()),
15873                view_revision: Some("b".repeat(64)),
15874            },
15875            backup_dir: backup_dir.clone(),
15876            entries: vec![V2PullJournalEntry {
15877                path: "records/new.md".to_string(),
15878                old: None,
15879                new: Some(V2PullFileCoordinate {
15880                    sha256: "c".repeat(64),
15881                    bytes: 1,
15882                }),
15883                backup: None,
15884            }],
15885        };
15886        store
15887            .write_private_atomic_new(
15888                Path::new(V2_PULL_JOURNAL),
15889                &v2_pull_journal_bytes(&journal).unwrap(),
15890            )
15891            .unwrap();
15892        let cfg = test_hub_config(
15893            "https://example.test".to_string(),
15894            sandbox.path().join("state"),
15895        );
15896
15897        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
15898
15899        assert!(root.join("DB.md").is_file());
15900        assert!(!root.join(V2_PULL_JOURNAL).exists());
15901        assert!(!root.join(backup_dir).exists());
15902    }
15903
15904    #[test]
15905    fn committed_baseline_keeps_installed_bytes_and_prunes_orphans() {
15906        let sandbox = tempfile::TempDir::new().unwrap();
15907        let root = sandbox.path().join("brain");
15908        std::fs::create_dir_all(root.join("records")).unwrap();
15909        std::fs::write(
15910            root.join("DB.md"),
15911            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
15912        )
15913        .unwrap();
15914        let new = b"---\ntype: note\n---\n\nnew\n";
15915        std::fs::write(root.join("records/value.md"), new).unwrap();
15916        let store = Store::open_strict(&root).unwrap();
15917        let bundle = crate::ulid::mint();
15918        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
15919        store
15920            .create_private_dir_all(Path::new(&backup_dir))
15921            .unwrap();
15922        let orphan = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
15923        store.create_private_dir_all(Path::new(&orphan)).unwrap();
15924        let next = V2PullCoordinate {
15925            head_seq: Some(2),
15926            commit_hash: Some("c".repeat(64)),
15927            view_kind: Some("full".to_string()),
15928            view_revision: Some("d".repeat(64)),
15929        };
15930        let journal = V2PullJournal {
15931            v: 1,
15932            phase: V2PullPhase::Ready,
15933            brain: TEST_BRAIN_ID.to_string(),
15934            previous: V2PullCoordinate {
15935                head_seq: Some(1),
15936                commit_hash: Some("a".repeat(64)),
15937                view_kind: Some("full".to_string()),
15938                view_revision: Some("b".repeat(64)),
15939            },
15940            next: next.clone(),
15941            backup_dir: backup_dir.clone(),
15942            entries: vec![V2PullJournalEntry {
15943                path: "records/value.md".to_string(),
15944                old: Some(V2PullFileCoordinate {
15945                    sha256: "e".repeat(64),
15946                    bytes: new.len() as u64,
15947                }),
15948                new: Some(V2PullFileCoordinate {
15949                    sha256: content_sha256(new),
15950                    bytes: new.len() as u64,
15951                }),
15952                backup: Some("00000000".to_string()),
15953            }],
15954        };
15955        store
15956            .write_private_atomic_new(
15957                Path::new(V2_PULL_JOURNAL),
15958                &v2_pull_journal_bytes(&journal).unwrap(),
15959            )
15960            .unwrap();
15961        let cfg = test_hub_config(
15962            "https://example.test".to_string(),
15963            sandbox.path().join("state"),
15964        );
15965        save_v2_baseline(
15966            &cfg,
15967            TEST_BRAIN_ID,
15968            &root,
15969            &V2SyncBaseline {
15970                v: 2,
15971                origin: "https://example.test".to_string(),
15972                brain: TEST_BRAIN_ID.to_string(),
15973                head_seq: next.head_seq,
15974                commit_hash: next.commit_hash.clone(),
15975                content_root: Some("f".repeat(64)),
15976                asset_root: None,
15977                assets: Default::default(),
15978                view_kind: next.view_kind.clone(),
15979                view_revision: next.view_revision.clone(),
15980                projection_sha256: None,
15981                files: Default::default(),
15982                local_policy_digest: None,
15983                local_eligibility: Default::default(),
15984                remote_copy_remains: Default::default(),
15985            },
15986        )
15987        .unwrap();
15988
15989        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
15990
15991        assert_eq!(std::fs::read(root.join("records/value.md")).unwrap(), new);
15992        assert!(!root.join(V2_PULL_JOURNAL).exists());
15993        assert!(!root.join(backup_dir).exists());
15994        assert!(!root.join(orphan).exists());
15995    }
15996}