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    /// A mutable alias moved to another canonical brain. Ordinary sync fails
346    /// closed; only the exact old/new ids can authorize replacing that one
347    /// alias binding while preserving both canonical trust histories.
348    #[error(
349        "brain alias `{alias}` was pinned to `{from}` but now resolves to `{to}` — review both ids, then run `dbmd sync {alias} rebind --from {from} --to {to}`"
350    )]
351    AliasRebindRequired {
352        alias: String,
353        from: String,
354        to: String,
355    },
356
357    /// Local and remote both changed one or more coordinates since the last
358    /// verified sync baseline. No side was overwritten.
359    #[error("sync conflict on {paths:?} — resolve the named files and retry")]
360    Conflict {
361        /// Bounded, portable paths safe to show to the agent/operator.
362        paths: Vec<String>,
363    },
364
365    /// A readable v2 conflict was preserved as a private, exact-head bundle.
366    /// The bundle is local control state; none of its bytes ride with the
367    /// brain, and resolving it always creates a fresh explicit mutation.
368    #[error(
369        "sync conflict preserved in private bundle `{bundle}` for {paths:?} — run `dbmd sync resolve {bundle} --keep-local`, `--take-remote`, or `--from <safe-file>`"
370    )]
371    ConflictBundle {
372        /// Local ULID naming `.dbmd/conflicts/<bundle>/plan.json`.
373        bundle: String,
374        /// Bounded, readable conflict paths.
375        paths: Vec<String>,
376    },
377
378    /// `.sevralocal` newly made paths eligible to ride. Uploading them is an
379    /// explicit adoption boundary, never an accidental side effect of editing
380    /// or removing a local policy file.
381    #[error(
382        "local sync policy newly exposes {paths:?} — review and retry with --resume-local-policy"
383    )]
384    LocalPolicyTransition {
385        /// Bounded local-only paths. They are never sent in telemetry.
386        paths: Vec<String>,
387    },
388
389    /// The exact mutation crosses a permissioned bulk-impact boundary. The
390    /// hub has not committed it; `preview` is the bounded, permission-filtered
391    /// receipt an agent must inspect before explicitly confirming the same
392    /// request.
393    #[error(
394        "bulk change requires explicit confirmation — review the preview and retry the same sync with --confirm-bulk <id>:<digest>"
395    )]
396    BulkPreviewRequired {
397        /// Stable structured preview returned by the hub.
398        preview: Value,
399    },
400
401    /// A scoped checkout's generated store marker was edited or removed.
402    /// It is local projection metadata and is never accepted as brain data.
403    #[error(
404        "the generated DB.md for this scoped view was modified — clone a fresh scoped checkout"
405    )]
406    ScopedProjectionModified,
407
408    /// The effective permission slice changed after this checkout was pinned.
409    /// Reusing the directory could conceal removals or accidentally adopt files
410    /// revealed by a wider grant, so a new checkout is required.
411    #[error(
412        "the checkout's permission scope changed — clone into a new directory to accept the new view"
413    )]
414    ScopedViewChanged,
415
416    /// A v2 identity/head was previously accepted for this ref, but the hub
417    /// now hides it. Never reinterpret that as a v1 downgrade.
418    #[error("the previously verified brain is unavailable — access may have been revoked or the brain removed")]
419    BrainUnavailable,
420
421    /// The verified remote head advanced while a pull or post-commit barrier
422    /// was in flight. The old baseline is deliberately retained.
423    #[error(
424        "the remote brain advanced during sync — retry to converge from the new verified head"
425    )]
426    RemoteAdvancedDuringSync,
427
428    /// This build cannot provide the no-follow, directory-handle-relative
429    /// filesystem semantics required for trust/key/snapshot state.
430    #[error(
431        "{operation} is unavailable on this platform — use the official macOS/Linux build or WSL"
432    )]
433    UnsupportedPlatform {
434        /// The operation that requires hardened local filesystem primitives.
435        operation: &'static str,
436    },
437
438    /// Local filesystem failure while materializing a pull or reading a push.
439    #[error(transparent)]
440    Io(#[from] std::io::Error),
441
442    /// A store-level failure (walking the local store for a push).
443    #[error(transparent)]
444    Store(#[from] crate::StoreError),
445}
446
447/// Result alias for link.md client operations.
448pub type LinkResult<T> = std::result::Result<T, LinkError>;
449
450/// Exact approval token returned by a permissioned v2 bulk preview.
451#[derive(Debug, Clone, PartialEq, Eq)]
452pub struct V2BulkConfirmation {
453    /// Opaque preview receipt id.
454    pub id: String,
455    /// Digest binding the receipt to principal, head, control revision, and
456    /// the exact strict mutation.
457    pub digest: String,
458}
459
460impl V2BulkConfirmation {
461    /// Parse the CLI/wire handoff form `<id>:<digest>` and reject anything
462    /// outside the protocol's exact lowercase shapes before network I/O.
463    pub fn parse(value: &str) -> LinkResult<Self> {
464        let (id, digest) = value
465            .split_once(':')
466            .ok_or_else(|| LinkError::InvalidPack {
467                message: "bulk confirmation must be <id>:<digest>".to_string(),
468            })?;
469        if !crate::ulid::is_ulid(id) || !is_sha256(digest) {
470            return Err(LinkError::InvalidPack {
471                message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
472                    .to_string(),
473            });
474        }
475        Ok(Self {
476            id: id.to_string(),
477            digest: digest.to_string(),
478        })
479    }
480}
481
482/// Security-sensitive link.md state and destination writes require a hardened
483/// descriptor/handle-relative filesystem implementation. Unix uses
484/// `openat`/`O_NOFOLLOW`; Windows uses held directory handles, reparse-point
485/// refusal, exclusive share modes, and write-through replacement.
486fn require_hardened_filesystem(operation: &'static str) -> LinkResult<()> {
487    #[cfg(any(target_os = "linux", target_os = "macos", windows))]
488    {
489        let _ = operation;
490        Ok(())
491    }
492    #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
493    {
494        Err(LinkError::UnsupportedPlatform { operation })
495    }
496}
497
498// ─────────────────────────────────────────────────────────────────────────────
499// Addressing — `@brain[/id]`, the reserved shape (SPEC § Addressing)
500// ─────────────────────────────────────────────────────────────────────────────
501
502/// What the part after `@brain/` names.
503#[derive(Debug, Clone, PartialEq, Eq)]
504pub enum AddressTarget {
505    /// A record `id` — the db.md lowercase ULID (the reserved `@brain/id` shape).
506    Id(String),
507    /// A store-relative `.md` path — a client-side convenience the hub's
508    /// resolve endpoint also accepts (`?path=`). Not part of the reserved
509    /// shape; unambiguous because a ULID is never a path.
510    Path(String),
511}
512
513/// Why a brain reference failed [`is_safe_ref`] — shared by [`Address::parse`]
514/// and the per-verb entry gates so the two surfaces never drift.
515const BAD_BRAIN_REASON: &str =
516    "the brain reference must be a brain id (lowercase ULID) or a slug (lowercase letters, digits, hyphens)";
517
518/// Why an address target failed its shape check — shared by [`Address::parse`]
519/// and the [`resolve`] entry gate.
520const BAD_TARGET_REASON: &str =
521    "the part after `/` must be a record id (lowercase ULID) or a store-relative `.md` path";
522
523/// A parsed `@brain[/target]` address. `brain` is a hub brain reference — the
524/// brain's ULID id (works for any caller, including cross-party on a public
525/// brain) or a slug (which a hub resolves only against the caller's own
526/// brains; slugs are unique per owner, not globally).
527#[derive(Debug, Clone, PartialEq, Eq)]
528pub struct Address {
529    /// The brain reference (leading `@` stripped).
530    pub brain: String,
531    /// The record target, when the address names one.
532    pub target: Option<AddressTarget>,
533}
534
535impl Address {
536    /// Parse `@brain`, `@brain/<ulid>`, or `@brain/<store-path>.md`. The `@`
537    /// sigil is optional (an agent piping ids around should not have to quote
538    /// it back on). Whitespace and empty segments are malformed.
539    pub fn parse(raw: &str) -> LinkResult<Address> {
540        let bad = |reason: &str| LinkError::BadAddress {
541            given: raw.to_string(),
542            reason: reason.to_string(),
543        };
544
545        let trimmed = raw.trim();
546        let body = trimmed.strip_prefix('@').unwrap_or(trimmed);
547        if body.is_empty() {
548            return Err(bad("empty address"));
549        }
550
551        let (brain, rest) = match body.split_once('/') {
552            Some((b, r)) => (b, Some(r)),
553            None => (body, None),
554        };
555
556        if brain.is_empty() {
557            return Err(bad("missing brain reference before `/`"));
558        }
559        if !is_safe_ref(brain) {
560            return Err(bad(BAD_BRAIN_REASON));
561        }
562
563        let target = match rest {
564            None => None,
565            Some("") => return Err(bad("trailing `/` with no record id or path")),
566            Some(r) if crate::ulid::is_ulid(r) => Some(AddressTarget::Id(r.to_string())),
567            Some(r) => {
568                if !safe_store_rel_path(r) || !r.ends_with(".md") {
569                    return Err(bad(BAD_TARGET_REASON));
570                }
571                Some(AddressTarget::Path(r.to_string()))
572            }
573        };
574
575        Ok(Address {
576            brain: brain.to_string(),
577            target,
578        })
579    }
580}
581
582/// A brain reference safe to embed in a URL path segment: the shapes a hub
583/// accepts (ULID id or slug), which are also exactly URL-path-clean.
584fn is_safe_ref(s: &str) -> bool {
585    !s.is_empty()
586        && s.len() <= 64
587        && s.bytes()
588            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
589}
590
591/// A published-site handle (the `propose` target). Same lexical shape as a
592/// slug.
593pub fn is_valid_handle(s: &str) -> bool {
594    is_safe_ref(s)
595}
596
597/// True when `p` is a store-relative path this client will read from or write
598/// to disk: relative, no `..`, no empty or dot-leading segment (which shields
599/// `.dbmd/` and `.git/`), and only the hub-portable character set. Applied to
600/// every path an export hands us (the hub is not trusted with local layout)
601/// and to every path a push sends (mirroring the hub's own gate).
602pub fn safe_store_rel_path(p: &str) -> bool {
603    if p.is_empty() || p.len() > MAX_STORE_PATH_BYTES || p.starts_with('/') {
604        return false;
605    }
606    if !p
607        .bytes()
608        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/'))
609    {
610        return false;
611    }
612    p.split('/')
613        .all(|seg| !seg.is_empty() && seg != "." && seg != ".." && !seg.starts_with('.'))
614}
615
616/// Entry gate for every verb that embeds a caller-supplied brain reference in
617/// a URL path segment. `resolve` reaches the same check through
618/// [`Address::parse`]; the raw-ref verbs (`sync`, `grant`, `subscribe`) call
619/// this directly, so a ref carrying `/`, `..`, `?`, `#`, or any other
620/// URL-reshaping byte is refused before a request exists (the `url` crate
621/// normalizes dot segments, so an unvalidated ref would redirect the
622/// authenticated request to a different hub path).
623fn require_safe_ref(brain: &str) -> LinkResult<()> {
624    if is_safe_ref(brain) {
625        Ok(())
626    } else {
627        Err(LinkError::BadAddress {
628            given: brain.to_string(),
629            reason: BAD_BRAIN_REASON.to_string(),
630        })
631    }
632}
633
634/// Entry gate for the published-site handle `propose` embeds in its URL path.
635fn require_valid_handle(handle: &str) -> LinkResult<()> {
636    if is_valid_handle(handle) {
637        Ok(())
638    } else {
639        Err(LinkError::BadAddress {
640            given: handle.to_string(),
641            reason: "the site handle must be lowercase letters, digits, hyphens".to_string(),
642        })
643    }
644}
645
646/// Entry gate for the grant id `grant revoke` embeds in its URL path. Hub
647/// grant ids are lowercase ULIDs; the gate accepts the same URL-path-clean
648/// shape as a brain ref rather than pinning one mint scheme.
649fn require_safe_grant_id(id: &str) -> LinkResult<()> {
650    if is_safe_ref(id) {
651        Ok(())
652    } else {
653        Err(LinkError::BadGrantId {
654            given: id.to_string(),
655        })
656    }
657}
658
659// ─────────────────────────────────────────────────────────────────────────────
660// Configuration — flag > env > .dbmd/config; credential from env only
661// ─────────────────────────────────────────────────────────────────────────────
662
663/// The resolved client configuration for one invocation.
664#[derive(Debug, Clone)]
665pub struct HubConfig {
666    /// The hub base URL, trailing slash stripped, HTTPS-or-loopback enforced.
667    pub hub: String,
668    /// The bearer credential, when the environment carries one.
669    pub key: Option<String>,
670    /// The agent signing key, when [`AGENT_KEY_FILE_ENV`] names one. Wins
671    /// over the bearer for authenticated requests (link.md §8).
672    pub agent_key: Option<AgentSigningKey>,
673    /// The self-custodied brain signing key, when [`BRAIN_KEY_FILE_ENV`]
674    /// names one — `sync --push` then signs feed entries locally (§2.4).
675    pub brain_key: Option<AgentSigningKey>,
676    /// User-owned global toolkit state root. Identity pins and monotonic feed
677    /// checkpoints live below `<state_dir>/trust/`, never under a store.
678    pub state_dir: PathBuf,
679    /// True only when the origin came from untrusted store-local configuration.
680    /// Such origins are resolved and public-IP-pinned for every request.
681    store_selected: bool,
682}
683
684/// A loaded agent signing key: the PKCS#8 secret plus its derived public
685/// multikey. Debug never prints key material.
686#[derive(Clone)]
687pub struct AgentSigningKey {
688    pkcs8: Vec<u8>,
689    /// The key's public identity, `ed25519:<base64url sha256(SPKI)>`.
690    pub multikey: String,
691    /// The full public key, `base64url(SPKI DER)` — what feed entries carry.
692    pub public_key_spki: String,
693}
694
695impl std::fmt::Debug for AgentSigningKey {
696    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
697        f.debug_struct("AgentSigningKey")
698            .field("multikey", &self.multikey)
699            .field("pkcs8", &"<redacted>")
700            .finish()
701    }
702}
703
704impl HubConfig {
705    /// The credential, or the canonical "not configured" error. Verbs that
706    /// authenticate call this; `propose` never does.
707    pub fn require_key(&self) -> LinkResult<&str> {
708        self.key.as_deref().ok_or(LinkError::NoCredential)
709    }
710}
711
712/// Resolve the client configuration: `flag_hub` beats [`HUB_URL_ENV`] beats
713/// the `hub =` line in `<dir>/.dbmd/config`; no fallback default exists. The
714/// credential comes from [`HUB_KEY_ENV`] alone and is validated as a clean
715/// header token (never echoed on failure).
716pub fn hub_config(flag_hub: Option<&str>, dir: &Path) -> LinkResult<HubConfig> {
717    let explicit_hub = flag_hub
718        .map(str::to_string)
719        .or_else(|| env_nonempty(HUB_URL_ENV));
720    let selected_by_store = explicit_hub.is_none();
721    let hub = explicit_hub
722        .or_else(|| config_file_hub(&dir.join(CONFIG_REL_PATH)))
723        .ok_or(LinkError::NoHub)?;
724    let hub = hub.trim().trim_end_matches('/').to_string();
725    assert_safe_hub(&hub)?;
726    if selected_by_store {
727        let parsed =
728            url::Url::parse(&hub).map_err(|_| LinkError::UnsafeHub { hub: hub.clone() })?;
729        // A cloned store is not an operator opt-in to local-network access.
730        // Local/private development hubs must be selected explicitly by flag or
731        // environment, never by bytes inside the store.
732        if !parsed.scheme().eq_ignore_ascii_case("https")
733            || (parsed.path() != "/" && !parsed.path().is_empty())
734        {
735            return Err(LinkError::UnsafeHub { hub });
736        }
737    }
738
739    let key = match env_nonempty(HUB_KEY_ENV) {
740        Some(raw) => Some(clean_key(&raw)?),
741        None => None,
742    };
743
744    let agent_key = match env_nonempty(AGENT_KEY_FILE_ENV) {
745        Some(path) => Some(load_agent_key(Path::new(&path))?),
746        None => None,
747    };
748
749    let brain_key = match env_nonempty(BRAIN_KEY_FILE_ENV) {
750        Some(path) => Some(load_agent_key(Path::new(&path))?),
751        None => None,
752    };
753
754    // A cloned store controls `.dbmd/config`. It must never be able to point
755    // the process at an attacker origin and harvest the user's ambient account
756    // bearer, signed agent identity, or self-custodied brain signatures/content.
757    // Explicit --hub/DBMD_HUB_URL selection already pairs target + credential
758    // in the invocation environment; a store-selected target additionally
759    // needs an exact origin binding.
760    if selected_by_store && (key.is_some() || agent_key.is_some() || brain_key.is_some()) {
761        let bound = env_nonempty(HUB_CREDENTIAL_ORIGIN_ENV)
762            .and_then(|value| normalized_origin(&value).ok());
763        let selected_origin = normalized_origin(&hub)?;
764        if bound.as_deref() != Some(selected_origin.as_str()) {
765            return Err(LinkError::UnboundCredential);
766        }
767    }
768
769    Ok(HubConfig {
770        hub,
771        key,
772        agent_key,
773        brain_key,
774        state_dir: toolkit_state_dir()?,
775        store_selected: selected_by_store,
776    })
777}
778
779fn toolkit_state_dir() -> LinkResult<PathBuf> {
780    if let Some(path) = env_nonempty(STATE_DIR_ENV) {
781        let path = PathBuf::from(path);
782        if !path.is_absolute() {
783            return Err(LinkError::UnsafePath {
784                path: path.display().to_string(),
785            });
786        }
787        return Ok(path);
788    }
789    #[cfg(windows)]
790    if let Some(base) = env_nonempty("LOCALAPPDATA") {
791        let base = PathBuf::from(base);
792        if base.is_absolute() {
793            return Ok(base.join("dbmd").join("state"));
794        }
795    }
796    #[cfg(windows)]
797    {
798        Err(LinkError::Io(std::io::Error::new(
799            std::io::ErrorKind::NotFound,
800            format!("cannot locate user state; set {STATE_DIR_ENV} or LOCALAPPDATA"),
801        )))
802    }
803    #[cfg(not(windows))]
804    if let Some(base) = env_nonempty("XDG_STATE_HOME") {
805        let base = PathBuf::from(base);
806        if base.is_absolute() {
807            return Ok(base.join("dbmd"));
808        }
809    }
810    #[cfg(not(windows))]
811    let home = PathBuf::from(env_nonempty("HOME").ok_or_else(|| {
812        LinkError::Io(std::io::Error::new(
813            std::io::ErrorKind::NotFound,
814            format!("cannot locate user state; set {STATE_DIR_ENV}"),
815        ))
816    })?);
817    #[cfg(not(windows))]
818    if !home.is_absolute() {
819        return Err(LinkError::UnsafePath {
820            path: home.display().to_string(),
821        });
822    }
823    #[cfg(target_os = "macos")]
824    {
825        Ok(home
826            .join("Library")
827            .join("Application Support")
828            .join("dbmd")
829            .join("state"))
830    }
831    #[cfg(all(not(target_os = "macos"), not(windows)))]
832    {
833        Ok(home.join(".local").join("state").join("dbmd"))
834    }
835}
836
837fn normalized_origin(value: &str) -> LinkResult<String> {
838    let parsed = url::Url::parse(value).map_err(|_| LinkError::UnsafeHub {
839        hub: value.to_string(),
840    })?;
841    if !(parsed.scheme().eq_ignore_ascii_case("https")
842        || parsed.scheme().eq_ignore_ascii_case("http"))
843        || !parsed.username().is_empty()
844        || parsed.password().is_some()
845        || (parsed.path() != "/" && !parsed.path().is_empty())
846        || parsed.query().is_some()
847        || parsed.fragment().is_some()
848    {
849        return Err(LinkError::UnsafeHub {
850            hub: value.to_string(),
851        });
852    }
853    let host = parsed.host_str().ok_or_else(|| LinkError::UnsafeHub {
854        hub: value.to_string(),
855    })?;
856    let host = if host.contains(':') {
857        format!("[{host}]")
858    } else {
859        host.to_ascii_lowercase()
860    };
861    let port = parsed
862        .port_or_known_default()
863        .ok_or_else(|| LinkError::UnsafeHub {
864            hub: value.to_string(),
865        })?;
866    let default = (parsed.scheme().eq_ignore_ascii_case("https") && port == 443)
867        || (parsed.scheme().eq_ignore_ascii_case("http") && port == 80);
868    Ok(format!(
869        "{}://{}{}",
870        parsed.scheme().to_ascii_lowercase(),
871        host,
872        if default {
873            String::new()
874        } else {
875            format!(":{port}")
876        }
877    ))
878}
879
880// ─────────────────────────────────────────────────────────────────────────────
881// Agent signing keys — link.md §8 `LinkMD-Sig` proof of possession
882// ─────────────────────────────────────────────────────────────────────────────
883
884/// The DER prefix that wraps a raw Ed25519 public key into a
885/// SubjectPublicKeyInfo (RFC 8410).
886const ED25519_SPKI_PREFIX: [u8; 12] = [
887    0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
888];
889
890fn bad_agent_key(message: &str) -> LinkError {
891    LinkError::BadAgentKey {
892        message: message.to_string(),
893    }
894}
895
896fn agent_keypair(pkcs8: &[u8]) -> LinkResult<ring::signature::Ed25519KeyPair> {
897    // `from_pkcs8` wants ring's own v2 encoding (private + public); keys from
898    // other tools are often PKCS#8 v1, which `maybe_unchecked` accepts by
899    // deriving the public half itself.
900    ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8)
901        .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(pkcs8))
902        .map_err(|_| bad_agent_key("not an Ed25519 PKCS#8 key"))
903}
904
905/// Derive `(publicKeySpki b64u, multikey)` from a keypair.
906fn public_identity_for(pair: &ring::signature::Ed25519KeyPair) -> (String, String) {
907    use ring::signature::KeyPair as _;
908    let mut spki = Vec::with_capacity(44);
909    spki.extend_from_slice(&ED25519_SPKI_PREFIX);
910    spki.extend_from_slice(pair.public_key().as_ref());
911    (
912        URL_SAFE_NO_PAD.encode(&spki),
913        format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&spki))),
914    )
915}
916
917/// Load and validate a signing-key file (agent or brain — same format):
918/// one base64url line of PKCS#8. Public so `dbmd key rotate` can load the
919/// old key explicitly.
920pub fn load_signing_key(path: &Path) -> LinkResult<AgentSigningKey> {
921    load_agent_key(path)
922}
923
924/// Load and validate the agent key file: one base64url line of PKCS#8.
925fn load_agent_key(path: &Path) -> LinkResult<AgentSigningKey> {
926    #[cfg(unix)]
927    let file = {
928        use std::os::fd::{AsRawFd as _, FromRawFd as _};
929        use std::os::unix::ffi::OsStrExt as _;
930        let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
931            .map_err(|e| bad_agent_key(&format!("cannot open the key parent: {e}")))?;
932        let leaf = path
933            .file_name()
934            .ok_or_else(|| bad_agent_key("the key path has no file name"))?;
935        let leaf = c_name(leaf.as_bytes(), &path.display().to_string())?;
936        let fd = unsafe {
937            libc::openat(
938                parent.as_raw_fd(),
939                leaf.as_ptr(),
940                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
941            )
942        };
943        if fd < 0 {
944            return Err(bad_agent_key(
945                "the key path must be an existing regular file without symlink ancestors",
946            ));
947        }
948        unsafe { std::fs::File::from_raw_fd(fd) }
949    };
950    #[cfg(not(unix))]
951    let file = std::fs::File::open(path)
952        .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
953    let metadata = file
954        .metadata()
955        .map_err(|e| bad_agent_key(&format!("cannot inspect the key file: {e}")))?;
956    if !metadata.is_file() {
957        return Err(bad_agent_key("the key path must be a regular file"));
958    }
959    #[cfg(unix)]
960    {
961        use std::os::unix::fs::PermissionsExt as _;
962        if metadata.permissions().mode() & 0o077 != 0 {
963            return Err(bad_agent_key(
964                "the key file is accessible to group/other; set mode 0600",
965            ));
966        }
967    }
968    let mut text = String::new();
969    file.take(1024 * 1024 + 1)
970        .read_to_string(&mut text)
971        .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
972    if text.len() > 1024 * 1024 {
973        return Err(bad_agent_key("the key file exceeds the size limit"));
974    }
975    let pkcs8 = URL_SAFE_NO_PAD
976        .decode(text.trim())
977        .map_err(|_| bad_agent_key("the key file is not one base64url line"))?;
978    let (public_key_spki, multikey) = public_identity_for(&agent_keypair(&pkcs8)?);
979    Ok(AgentSigningKey {
980        pkcs8,
981        multikey,
982        public_key_spki,
983    })
984}
985
986/// Durably create a new secret file without an exists→write race and without
987/// ever exposing a default-mode (commonly 0644) key between write and chmod.
988/// `create_new` also refuses a planted symlink. The file and its parent
989/// directory are synced before the caller can publish the corresponding
990/// public identity remotely.
991fn write_secret_new(path: &Path, bytes: &[u8]) -> LinkResult<()> {
992    #[cfg(unix)]
993    let (mut file, parent, leaf) = {
994        use std::os::fd::{AsRawFd as _, FromRawFd as _};
995        use std::os::unix::ffi::OsStrExt as _;
996        let parent = open_or_create_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))?;
997        let leaf_name = path
998            .file_name()
999            .ok_or_else(|| bad_agent_key("the output key path has no file name"))?;
1000        let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
1001        let fd = unsafe {
1002            libc::openat(
1003                parent.as_raw_fd(),
1004                leaf.as_ptr(),
1005                libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1006                0o600,
1007            )
1008        };
1009        if fd < 0 {
1010            let error = std::io::Error::last_os_error();
1011            if error.kind() == std::io::ErrorKind::AlreadyExists {
1012                return Err(bad_agent_key(
1013                    "the output file already exists — refusing to overwrite a key",
1014                ));
1015            }
1016            return Err(error.into());
1017        }
1018        (unsafe { std::fs::File::from_raw_fd(fd) }, parent, leaf)
1019    };
1020    #[cfg(not(unix))]
1021    let mut file = std::fs::OpenOptions::new()
1022        .write(true)
1023        .create_new(true)
1024        .open(path)
1025        .map_err(|error| {
1026            if error.kind() == std::io::ErrorKind::AlreadyExists {
1027                bad_agent_key("the output file already exists — refusing to overwrite a key")
1028            } else {
1029                LinkError::Io(error)
1030            }
1031        })?;
1032    if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_all()) {
1033        drop(file);
1034        #[cfg(unix)]
1035        let _ =
1036            unsafe { libc::unlinkat(std::os::fd::AsRawFd::as_raw_fd(&parent), leaf.as_ptr(), 0) };
1037        #[cfg(not(unix))]
1038        let _ = std::fs::remove_file(path);
1039        return Err(LinkError::Io(error));
1040    }
1041    drop(file);
1042    #[cfg(unix)]
1043    parent.sync_all()?;
1044    Ok(())
1045}
1046
1047/// What `dbmd key generate` returns: the public identity to register plus
1048/// where the secret landed.
1049#[derive(Debug, Serialize)]
1050pub struct GeneratedAgentKey {
1051    /// `ed25519:<fingerprint>` — the grantable/registerable identity.
1052    pub multikey: String,
1053    /// base64url SPKI DER — what a hub's register endpoint takes.
1054    #[serde(rename = "publicKeySpki")]
1055    pub public_key_spki: String,
1056    /// Where the PKCS#8 secret was written (mode 0600).
1057    #[serde(rename = "keyFile")]
1058    pub key_file: String,
1059}
1060
1061/// Mint a fresh Ed25519 agent keypair. The secret is written to `out`
1062/// (base64url PKCS#8, one line, 0600, refusing to overwrite); only public
1063/// identity is returned. The private key never enters a store and never
1064/// travels — requests carry per-request signatures instead (link.md §8).
1065pub fn generate_agent_key(out: &Path) -> LinkResult<GeneratedAgentKey> {
1066    require_hardened_filesystem("key generation")?;
1067    let rng = ring::rand::SystemRandom::new();
1068    let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
1069        .map_err(|_| bad_agent_key("key generation failed"))?;
1070    let pair = agent_keypair(pkcs8.as_ref())?;
1071    let (spki_b64u, multikey) = public_identity_for(&pair);
1072
1073    write_secret_new(
1074        out,
1075        format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
1076    )?;
1077
1078    Ok(GeneratedAgentKey {
1079        multikey,
1080        public_key_spki: spki_b64u,
1081        key_file: out.display().to_string(),
1082    })
1083}
1084
1085/// Build the origin-bound `LinkMD-Sig` v2 header for one request:
1086/// `canonical = "v2" LF origin LF METHOD LF path+query LF ts LF
1087/// (sha256hex(body) | "-")`.
1088///
1089/// The origin is derived from the already-validated hub URL, never from
1090/// attacker-controlled request metadata. Binding it closes the v1 replay
1091/// class where a proof captured by one hub could be replayed to another hub
1092/// serving the same path inside the timestamp window.
1093fn linkmd_sig_header(
1094    key: &AgentSigningKey,
1095    origin: &str,
1096    method: &str,
1097    path: &str,
1098    body: Option<&str>,
1099) -> LinkResult<String> {
1100    let ts = std::time::SystemTime::now()
1101        .duration_since(std::time::UNIX_EPOCH)
1102        .map_err(|_| bad_agent_key("system clock is before the epoch"))?
1103        .as_secs();
1104    let body_hash = match body {
1105        Some(b) => format!("{:x}", Sha256::digest(b.as_bytes())),
1106        None => "-".to_string(),
1107    };
1108    let canonical = format!(
1109        "v2\n{}\n{}\n{}\n{}\n{}",
1110        origin,
1111        method.to_uppercase(),
1112        path,
1113        ts,
1114        body_hash
1115    );
1116    let pair = agent_keypair(&key.pkcs8)?;
1117    let sig = URL_SAFE_NO_PAD.encode(pair.sign(canonical.as_bytes()).as_ref());
1118    let fingerprint = key.multikey.trim_start_matches("ed25519:");
1119    Ok(format!(
1120        "LinkMD-Sig v2,key=ed25519:{fingerprint},ts={ts},sig={sig}"
1121    ))
1122}
1123
1124// ─────────────────────────────────────────────────────────────────────────────
1125// Self-custody feed entries — the client signs what the hub only verifies
1126// ─────────────────────────────────────────────────────────────────────────────
1127
1128/// One `files` element of a wire-profile-v1 feed entry (SPEC §5.1: fields in
1129/// exactly this order).
1130#[derive(Serialize)]
1131struct WireFeedFile {
1132    path: String,
1133    sha256: String,
1134    bytes: u64,
1135}
1136
1137/// The unsigned entry in the normative §5.1 field order — serde serializes
1138/// struct fields in declaration order, which IS the wire contract.
1139#[derive(Serialize)]
1140struct UnsignedWireEntry<'a> {
1141    v: u8,
1142    seq: u64,
1143    ts: String,
1144    brain: &'a str,
1145    public_key: &'a str,
1146    kind: &'a str,
1147    op: &'a str,
1148    pack_sha256: &'a str,
1149    files: &'a [WireFeedFile],
1150    removed: &'a [String],
1151    prev_entry_hash: Option<&'a str>,
1152}
1153
1154/// Build and sign a wire-profile-v1 `push` feed entry with a self-custodied
1155/// brain key: serialize the unsigned entry compactly in the normative order,
1156/// Ed25519-sign those exact bytes, splice `sig` on as the final field. The
1157/// returned string is the exact serialization the hub stores verbatim (plus
1158/// one trailing newline) and every independent reader re-derives.
1159fn self_custody_entry(
1160    key: &AgentSigningKey,
1161    seq: u64,
1162    ts: String,
1163    pack_sha256: &str,
1164    files: &[WireFeedFile],
1165    prev_entry_hash: Option<&str>,
1166) -> LinkResult<String> {
1167    let removed: [String; 0] = [];
1168    let unsigned = serde_json::to_string(&UnsignedWireEntry {
1169        v: 1,
1170        seq,
1171        ts,
1172        brain: &key.multikey,
1173        public_key: &key.public_key_spki,
1174        kind: "push",
1175        op: "snapshot",
1176        pack_sha256,
1177        files,
1178        removed: &removed,
1179        prev_entry_hash,
1180    })
1181    .expect("serialize feed entry");
1182    let pair = agent_keypair(&key.pkcs8)?;
1183    let sig = URL_SAFE_NO_PAD.encode(pair.sign(unsigned.as_bytes()).as_ref());
1184    Ok(format!(
1185        "{},\"sig\":\"{}\"}}",
1186        &unsigned[..unsigned.len() - 1],
1187        sig
1188    ))
1189}
1190
1191/// An env var, treated as absent when unset or empty (an empty
1192/// `DBMD_HUB_KEY=` falls through rather than becoming an empty credential).
1193fn env_nonempty(name: &str) -> Option<String> {
1194    std::env::var(name).ok().filter(|v| !v.trim().is_empty())
1195}
1196
1197/// Read the `hub = <URL>` line out of a `.dbmd/config` file. The format is
1198/// deliberately minimal: `key = value` lines, `#` comments, unknown keys
1199/// ignored (forward-compatible). A missing or unreadable file is simply "not
1200/// configured here".
1201fn config_file_hub(path: &Path) -> Option<String> {
1202    const MAX_CONFIG_BYTES: u64 = 64 * 1024;
1203    #[cfg(unix)]
1204    let file = {
1205        use std::os::fd::{AsRawFd as _, FromRawFd as _};
1206        use std::os::unix::ffi::OsStrExt as _;
1207        let parent =
1208            open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new("."))).ok()?;
1209        let leaf = c_name(path.file_name()?.as_bytes(), &path.display().to_string()).ok()?;
1210        let fd = unsafe {
1211            libc::openat(
1212                parent.as_raw_fd(),
1213                leaf.as_ptr(),
1214                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1215            )
1216        };
1217        if fd < 0 {
1218            return None;
1219        }
1220        unsafe { std::fs::File::from_raw_fd(fd) }
1221    };
1222    #[cfg(not(unix))]
1223    let file = std::fs::File::open(path).ok()?;
1224    let metadata = file.metadata().ok()?;
1225    if !metadata.is_file() || metadata.len() > MAX_CONFIG_BYTES {
1226        return None;
1227    }
1228    let mut bytes = Vec::with_capacity(metadata.len() as usize);
1229    file.take(MAX_CONFIG_BYTES + 1)
1230        .read_to_end(&mut bytes)
1231        .ok()?;
1232    if bytes.len() as u64 > MAX_CONFIG_BYTES {
1233        return None;
1234    }
1235    let text = String::from_utf8(bytes).ok()?;
1236    for line in text.lines() {
1237        let line = line.trim();
1238        if line.is_empty() || line.starts_with('#') {
1239            continue;
1240        }
1241        if let Some((k, v)) = line.split_once('=') {
1242            if k.trim() == "hub" {
1243                let v = v.trim();
1244                if !v.is_empty() {
1245                    return Some(v.to_string());
1246                }
1247            }
1248        }
1249    }
1250    None
1251}
1252
1253/// The bearer key must never travel in cleartext; only loopback hosts may
1254/// skip TLS (local development against a hub on localhost).
1255fn assert_safe_hub(hub: &str) -> LinkResult<()> {
1256    let parsed = url::Url::parse(hub).map_err(|_| LinkError::UnsafeHub {
1257        hub: hub.to_string(),
1258    })?;
1259    if !(parsed.scheme().eq_ignore_ascii_case("https")
1260        || parsed.scheme().eq_ignore_ascii_case("http"))
1261        || !parsed.username().is_empty()
1262        || parsed.password().is_some()
1263        || (parsed.path() != "/" && !parsed.path().is_empty())
1264        || parsed.query().is_some()
1265        || parsed.fragment().is_some()
1266    {
1267        return Err(LinkError::UnsafeHub {
1268            hub: hub.to_string(),
1269        });
1270    }
1271    let loopback = match parsed.host() {
1272        Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
1273        Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
1274        Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
1275        None => false,
1276    };
1277    if parsed.scheme().eq_ignore_ascii_case("https") || loopback {
1278        Ok(())
1279    } else {
1280        Err(LinkError::UnsafeHub {
1281            hub: hub.to_string(),
1282        })
1283    }
1284}
1285
1286/// Trim paste artifacts and refuse anything outside the printable-ASCII token
1287/// range WITHOUT echoing the key — an HTTP library rejecting a bad header
1288/// value tends to echo the whole header line, credential included, so the
1289/// gate sits here instead.
1290fn clean_key(raw: &str) -> LinkResult<String> {
1291    let k = raw.trim();
1292    if k.is_empty() || k.bytes().any(|b| !(0x21..=0x7e).contains(&b)) {
1293        return Err(LinkError::BadKey);
1294    }
1295    Ok(k.to_string())
1296}
1297
1298// ─────────────────────────────────────────────────────────────────────────────
1299// Transport — one blocking agent, capped reads, the JSON-or-refuse contract
1300// ─────────────────────────────────────────────────────────────────────────────
1301
1302/// One hub response: the status plus the parsed JSON body when there was one.
1303#[derive(Debug)]
1304pub struct HubResponse {
1305    /// The HTTP status code.
1306    pub status: u16,
1307    /// The parsed JSON body, `None` when the body was empty or not JSON.
1308    pub body: Option<Value>,
1309}
1310
1311struct RawHubResponse {
1312    status: u16,
1313    body: Vec<u8>,
1314}
1315
1316/// Whether a request carries the bearer credential.
1317#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1318enum Auth {
1319    /// Send `authorization: Bearer <key>`; error without a key.
1320    Required,
1321    /// Send no credential — the propose door is unauthenticated by design.
1322    None,
1323    /// Send the configured credential when one exists, otherwise nothing —
1324    /// brain-addressed propose works anonymously on public brains, and an
1325    /// authenticated caller earns a bigger actor-class budget.
1326    Optional,
1327}
1328
1329fn agent_builder_with_timeout(overall: std::time::Duration) -> ureq::AgentBuilder {
1330    ureq::AgentBuilder::new()
1331        .user_agent(concat!("dbmd/", env!("CARGO_PKG_VERSION")))
1332        // Never follow a redirect while a bearer, a store pack, or a signed
1333        // response is in flight. Callers see the 3xx as a non-success instead
1334        // of letting an origin steer sensitive material elsewhere.
1335        .redirects(0)
1336        .timeout_connect(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
1337        .timeout_read(std::time::Duration::from_secs(READ_TIMEOUT_SECS))
1338        .timeout_write(overall)
1339        .timeout(overall)
1340}
1341
1342fn agent_builder() -> ureq::AgentBuilder {
1343    agent_builder_with_timeout(std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS))
1344}
1345
1346fn agent() -> ureq::Agent {
1347    agent_builder().build()
1348}
1349
1350fn hub_agent(cfg: &HubConfig) -> LinkResult<ureq::Agent> {
1351    if !cfg.store_selected {
1352        return Ok(agent());
1353    }
1354    let parsed = url::Url::parse(&cfg.hub).map_err(|_| LinkError::UnsafeHub {
1355        hub: cfg.hub.clone(),
1356    })?;
1357    pinned_public_agent(&parsed, false, "store-selected hub")
1358}
1359
1360/// Perform one hub request. `path` is the binding path (starts with `/`);
1361/// `body` posts JSON. Transport failures, oversized bodies, and non-UTF-8 are
1362/// all surfaced as typed [`LinkError`]s; HTTP error statuses are returned in
1363/// the [`HubResponse`] for [`ensure_ok`] to shape.
1364fn request_raw(
1365    cfg: &HubConfig,
1366    method: &str,
1367    path: &str,
1368    body: Option<&Value>,
1369    auth: Auth,
1370    max_response_bytes: u64,
1371) -> LinkResult<RawHubResponse> {
1372    let http = hub_agent(cfg)?;
1373    request_raw_with_agent(cfg, &http, method, path, body, auth, max_response_bytes)
1374}
1375
1376fn request_raw_with_agent(
1377    cfg: &HubConfig,
1378    http: &ureq::Agent,
1379    method: &str,
1380    path: &str,
1381    body: Option<&Value>,
1382    auth: Auth,
1383    max_response_bytes: u64,
1384) -> LinkResult<RawHubResponse> {
1385    let url = format!("{}{}", cfg.hub, path);
1386    let encoded_body = body.map(Value::to_string);
1387    let origin = normalized_origin(&cfg.hub)?;
1388    // An agent signing key outranks the bearer: possession proofs put nothing
1389    // reusable on the wire, so when both are configured the stronger one wins.
1390    let credential = match auth {
1391        Auth::Required => Some(match &cfg.agent_key {
1392            Some(key) => linkmd_sig_header(key, &origin, method, path, encoded_body.as_deref())?,
1393            None => format!("Bearer {}", cfg.require_key()?),
1394        }),
1395        Auth::Optional => match &cfg.agent_key {
1396            Some(key) => Some(linkmd_sig_header(
1397                key,
1398                &origin,
1399                method,
1400                path,
1401                encoded_body.as_deref(),
1402            )?),
1403            None => cfg.key.as_deref().map(|k| format!("Bearer {k}")),
1404        },
1405        Auth::None => None,
1406    };
1407    let result = with_connect_retries(|| {
1408        let mut req = http.request(method, &url);
1409        if let Some(value) = &credential {
1410            req = req.set("authorization", value);
1411        }
1412        match &encoded_body {
1413            Some(value) => req
1414                .set("content-type", "application/json")
1415                .send_string(value)
1416                .map_err(Box::new),
1417            None => req.call().map_err(Box::new),
1418        }
1419    });
1420    let resp = match result {
1421        Ok(resp) => resp,
1422        Err(error) => match *error {
1423            ureq::Error::Status(_, resp) => resp,
1424            ureq::Error::Transport(error) => {
1425                return Err(LinkError::Transport {
1426                    hub: cfg.hub.clone(),
1427                    message: error.to_string(),
1428                });
1429            }
1430        },
1431    };
1432
1433    let status = resp.status();
1434    let mut buf = Vec::new();
1435    resp.into_reader()
1436        .take(max_response_bytes + 1)
1437        .read_to_end(&mut buf)?;
1438    if buf.len() as u64 > max_response_bytes {
1439        return Err(LinkError::ResponseTooLarge {
1440            limit_bytes: max_response_bytes,
1441        });
1442    }
1443    Ok(RawHubResponse { status, body: buf })
1444}
1445
1446fn request_capped(
1447    cfg: &HubConfig,
1448    method: &str,
1449    path: &str,
1450    body: Option<&Value>,
1451    auth: Auth,
1452    max_response_bytes: u64,
1453) -> LinkResult<HubResponse> {
1454    let raw = request_raw(cfg, method, path, body, auth, max_response_bytes)?;
1455    let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
1456    Ok(HubResponse {
1457        status: raw.status,
1458        body: parsed,
1459    })
1460}
1461
1462fn request(
1463    cfg: &HubConfig,
1464    method: &str,
1465    path: &str,
1466    body: Option<&Value>,
1467    auth: Auth,
1468) -> LinkResult<HubResponse> {
1469    request_capped(cfg, method, path, body, auth, MAX_RESPONSE_BYTES)
1470}
1471
1472fn ensure_raw_ok(r: RawHubResponse, what: &'static str) -> LinkResult<Vec<u8>> {
1473    if (200..300).contains(&r.status) {
1474        return Ok(r.body);
1475    }
1476    ensure_ok(
1477        HubResponse {
1478            status: r.status,
1479            body: serde_json::from_slice(&r.body).ok(),
1480        },
1481        what,
1482    )
1483    .and_then(|_| Err(invalid_feed("a non-2xx response was accepted unexpectedly")))
1484}
1485
1486/// These failures happen before any HTTP request reaches the hub, so retrying
1487/// cannot duplicate a mutation. Mid-stream I/O is deliberately excluded: once
1488/// bytes may have crossed the wire, the caller must rely on the verb's own
1489/// idempotency contract instead of guessing.
1490fn is_pre_request_transport(kind: ureq::ErrorKind) -> bool {
1491    matches!(
1492        kind,
1493        ureq::ErrorKind::Dns | ureq::ErrorKind::ConnectionFailed | ureq::ErrorKind::ProxyConnect
1494    )
1495}
1496
1497fn with_connect_retries(
1498    mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
1499) -> Result<ureq::Response, Box<ureq::Error>> {
1500    let mut attempt = 0;
1501    loop {
1502        match send() {
1503            Err(error)
1504                if matches!(
1505                    error.as_ref(),
1506                    ureq::Error::Transport(transport)
1507                        if is_pre_request_transport(transport.kind())
1508                ) && attempt + 1 < CONNECT_ATTEMPTS =>
1509            {
1510                std::thread::sleep(std::time::Duration::from_millis(
1511                    CONNECT_RETRY_BACKOFF_MS[attempt],
1512                ));
1513                attempt += 1;
1514            }
1515            result => return result,
1516        }
1517    }
1518}
1519
1520fn hub_is_loopback(hub: &str) -> bool {
1521    url::Url::parse(hub).ok().is_some_and(|parsed| {
1522        parsed.host().is_some_and(|host| match host {
1523            url::Host::Domain(host) => host.eq_ignore_ascii_case("localhost"),
1524            url::Host::Ipv4(ip) => ip.is_loopback(),
1525            url::Host::Ipv6(ip) => ip.is_loopback(),
1526        })
1527    })
1528}
1529
1530fn presigned_agent(cfg: &HubConfig, raw: &str) -> LinkResult<ureq::Agent> {
1531    let parsed = url::Url::parse(raw).map_err(|_| LinkError::InvalidPack {
1532        message: "the hub returned an invalid object-store URL".to_string(),
1533    })?;
1534    let allow_private = hub_is_loopback(&cfg.hub)
1535        || env_nonempty(ALLOW_PRIVATE_OBJECT_URL_ENV).as_deref() == Some("1");
1536    if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1537        || !parsed.username().is_empty()
1538        || parsed.password().is_some()
1539        || parsed.fragment().is_some()
1540    {
1541        return Err(LinkError::InvalidPack {
1542            message: "the hub returned an unsafe object-store URL".to_string(),
1543        });
1544    }
1545    pinned_public_agent(&parsed, allow_private, "object-store URL").map_err(|_| {
1546        LinkError::InvalidPack {
1547            message: "the hub returned an object-store URL with an unsafe network target"
1548                .to_string(),
1549        }
1550    })
1551}
1552
1553fn put_presigned(cfg: &HubConfig, raw: &str, headers: &Value, bytes: &[u8]) -> LinkResult<()> {
1554    let http = presigned_agent(cfg, raw)?;
1555    let result = with_connect_retries(|| {
1556        let mut req = http.put(raw);
1557        if let Some(map) = headers.as_object() {
1558            for (name, value) in map {
1559                if let Some(value) = value.as_str() {
1560                    req = req.set(name, value);
1561                }
1562            }
1563        }
1564        req.send_bytes(bytes).map_err(Box::new)
1565    });
1566    match result {
1567        Ok(resp) if (200..300).contains(&resp.status()) => Ok(()),
1568        Ok(resp) => Err(LinkError::Http {
1569            what: "pack upload",
1570            status: resp.status(),
1571            message: "object store rejected the upload".to_string(),
1572            code: None,
1573            details: None,
1574        }),
1575        Err(error) => match *error {
1576            // Immutable uploads use If-None-Match. A concurrent writer may
1577            // win the same content address; the commit/confirm endpoint reads
1578            // and hashes that object before accepting it, so 412 safely means
1579            // "continue to verification", never "trust the upload".
1580            ureq::Error::Status(412, _) => Ok(()),
1581            ureq::Error::Status(_, resp) => Err(LinkError::Http {
1582                what: "pack upload",
1583                status: resp.status(),
1584                message: "object store rejected the upload".to_string(),
1585                code: None,
1586                details: None,
1587            }),
1588            ureq::Error::Transport(err) => Err(LinkError::Transport {
1589                hub: "the object store".to_string(),
1590                message: err.to_string(),
1591            }),
1592        },
1593    }
1594}
1595
1596fn one_past_bounded_limit(max_bytes: u64) -> Option<u64> {
1597    max_bytes.checked_add(1)
1598}
1599
1600fn presigned_download_read_limit() -> u64 {
1601    one_past_bounded_limit(MAX_PACK_BYTES)
1602        .expect("the fixed presigned-download ceiling must leave room for the refusal byte")
1603}
1604
1605fn get_presigned(cfg: &HubConfig, raw: &str) -> LinkResult<Vec<u8>> {
1606    let http = presigned_agent(cfg, raw)?;
1607    let resp = match with_connect_retries(|| http.get(raw).call().map_err(Box::new)) {
1608        Ok(resp) => resp,
1609        Err(error) => match *error {
1610            ureq::Error::Status(_, resp) => {
1611                return Err(LinkError::Http {
1612                    what: "pack download",
1613                    status: resp.status(),
1614                    message: "object store rejected the download".to_string(),
1615                    code: None,
1616                    details: None,
1617                });
1618            }
1619            ureq::Error::Transport(err) => {
1620                return Err(LinkError::Transport {
1621                    hub: "the object store".to_string(),
1622                    message: err.to_string(),
1623                });
1624            }
1625        },
1626    };
1627    if !(200..300).contains(&resp.status()) {
1628        return Err(LinkError::Http {
1629            what: "pack download",
1630            status: resp.status(),
1631            message: "object store rejected the download".to_string(),
1632            code: None,
1633            details: None,
1634        });
1635    }
1636    let mut bytes = Vec::new();
1637    resp.into_reader()
1638        .take(presigned_download_read_limit())
1639        .read_to_end(&mut bytes)?;
1640    if bytes.len() as u64 > MAX_PACK_BYTES {
1641        return Err(LinkError::InvalidPack {
1642            message: "download exceeds the compressed-size limit".to_string(),
1643        });
1644    }
1645    Ok(bytes)
1646}
1647
1648/// Unwrap a successful JSON body, or shape the failure: a >=400 surfaces the
1649/// hub's own `error` + `code`; a 2xx without JSON is refused as not a hub
1650/// answer.
1651fn ensure_ok(r: HubResponse, what: &'static str) -> LinkResult<Value> {
1652    if !(200..300).contains(&r.status) {
1653        let message = r
1654            .body
1655            .as_ref()
1656            .and_then(|b| b.get("error"))
1657            .and_then(Value::as_str)
1658            .unwrap_or("unknown error")
1659            .to_string();
1660        let code = r
1661            .body
1662            .as_ref()
1663            .and_then(|b| b.get("code"))
1664            .and_then(Value::as_str)
1665            .map(str::to_string);
1666        let details = r.body.as_ref().and_then(|b| b.get("details")).cloned();
1667        return Err(LinkError::Http {
1668            what,
1669            status: r.status,
1670            message,
1671            code,
1672            details,
1673        });
1674    }
1675    r.body.ok_or(LinkError::NotJson {
1676        what,
1677        status: r.status,
1678    })
1679}
1680
1681// ─────────────────────────────────────────────────────────────────────────────
1682// resolve — handle → brain card; @brain/id → the record
1683// ─────────────────────────────────────────────────────────────────────────────
1684
1685/// Resolve an address. A bare `@brain` returns the brain card (metadata +
1686/// index stats — the v0 form of the card; keys arrive with the protocol's
1687/// signing layer). `@brain/<id>` and `@brain/<path>.md` return the full
1688/// record, frontmatter + body.
1689fn is_public_registry_ip(ip: std::net::IpAddr) -> bool {
1690    match ip {
1691        std::net::IpAddr::V4(ip) => {
1692            let [a, b, c, _] = ip.octets();
1693            !(a == 0
1694                || a == 10
1695                || a == 127
1696                || (a == 100 && (64..=127).contains(&b))
1697                || (a == 169 && b == 254)
1698                || (a == 172 && (16..=31).contains(&b))
1699                || (a == 192 && b == 0 && c == 0)
1700                || (a == 192 && b == 0 && c == 2)
1701                || (a == 192 && b == 88 && c == 99)
1702                || (a == 192 && b == 168)
1703                || (a == 198 && (b == 18 || b == 19))
1704                || (a == 198 && b == 51 && c == 100)
1705                || (a == 203 && b == 0 && c == 113)
1706                || a >= 224)
1707        }
1708        std::net::IpAddr::V6(ip) => {
1709            let segments = ip.segments();
1710            // Conservatively accept only global unicast 2000::/3, excluding
1711            // special-purpose/transition blocks. In particular, 6to4 embeds
1712            // an IPv4 destination and must not tunnel a validated public
1713            // connect to 127/8 or RFC1918.
1714            (segments[0] & 0xe000) == 0x2000
1715                && !(segments[0] == 0x2001 && (segments[1] & 0xfe00) == 0)
1716                && !(segments[0] == 0x2001 && segments[1] == 0x0db8)
1717                && segments[0] != 0x2002
1718                && !(segments[0] == 0x3fff && (segments[1] & 0xf000) == 0)
1719        }
1720    }
1721}
1722
1723#[derive(Clone)]
1724struct PinnedRegistryResolver {
1725    netloc: String,
1726    addresses: Vec<std::net::SocketAddr>,
1727}
1728
1729impl ureq::Resolver for PinnedRegistryResolver {
1730    fn resolve(&self, requested: &str) -> std::io::Result<Vec<std::net::SocketAddr>> {
1731        if requested == self.netloc {
1732            Ok(self.addresses.clone())
1733        } else {
1734            Err(std::io::Error::new(
1735                std::io::ErrorKind::PermissionDenied,
1736                "registry request attempted to resolve an unvalidated authority",
1737            ))
1738        }
1739    }
1740}
1741
1742fn pinned_public_agent(
1743    url: &url::Url,
1744    allow_private: bool,
1745    label: &str,
1746) -> LinkResult<ureq::Agent> {
1747    let host = url
1748        .host_str()
1749        .ok_or_else(|| invalid_feed(format!("{label} has no host")))?;
1750    let port = url
1751        .port_or_known_default()
1752        .ok_or_else(|| invalid_feed(format!("{label} has no port")))?;
1753    let addresses = resolve_addresses_with_deadline(
1754        host,
1755        port,
1756        std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS),
1757    )
1758    .map_err(|error| invalid_feed(format!("{label} DNS resolution failed: {error}")))?;
1759    if addresses.is_empty() {
1760        return Err(invalid_feed(format!("{label} DNS returned no addresses")));
1761    }
1762    if !allow_private
1763        && addresses
1764            .iter()
1765            .any(|address| !is_public_registry_ip(address.ip()))
1766    {
1767        return Err(invalid_feed(format!(
1768            "{label} resolves to a non-public address"
1769        )));
1770    }
1771    let netloc = if host.contains(':') {
1772        format!("[{host}]:{port}")
1773    } else {
1774        format!("{host}:{port}")
1775    };
1776    Ok(agent_builder()
1777        .resolver(PinnedRegistryResolver { netloc, addresses })
1778        .build())
1779}
1780
1781/// Resolve one authority without allowing libc DNS to hold the caller forever.
1782/// `ToSocketAddrs` itself has no timeout API, so resolution runs in a detached
1783/// worker and only its result channel is awaited. A late resolver result has no
1784/// side effects and is discarded after the deadline.
1785fn resolve_addresses_with_deadline(
1786    host: &str,
1787    port: u16,
1788    timeout: std::time::Duration,
1789) -> std::io::Result<Vec<std::net::SocketAddr>> {
1790    use std::net::ToSocketAddrs as _;
1791
1792    let host = host.to_string();
1793    let (send, receive) = std::sync::mpsc::sync_channel(1);
1794    std::thread::Builder::new()
1795        .name("dbmd-dns".to_string())
1796        .spawn(move || {
1797            let result = (host.as_str(), port)
1798                .to_socket_addrs()
1799                .map(|addresses| addresses.collect());
1800            let _ = send.send(result);
1801        })
1802        .map_err(|error| std::io::Error::other(format!("cannot start resolver: {error}")))?;
1803    match receive.recv_timeout(timeout) {
1804        Ok(result) => result,
1805        Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(std::io::Error::new(
1806            std::io::ErrorKind::TimedOut,
1807            "resolution exceeded its deadline",
1808        )),
1809        Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(std::io::Error::other(
1810            "resolver stopped without returning a result",
1811        )),
1812    }
1813}
1814
1815fn registry_agent(url: &url::Url) -> LinkResult<ureq::Agent> {
1816    let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
1817    pinned_public_agent(url, allow_private, "registry home")
1818}
1819
1820/// GET an absolute URL as JSON with NO credential — used to fetch a brain card
1821/// from a FOREIGN home during registry resolution. DNS is resolved once,
1822/// every answer is required to be public, and that exact answer set is pinned
1823/// into a no-redirect HTTP agent to close private-network SSRF and rebinding.
1824fn get_json_absolute(url: &str) -> LinkResult<Value> {
1825    let parsed = url::Url::parse(url).map_err(|_| invalid_feed("invalid registry home URL"))?;
1826    let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
1827    if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1828        || !parsed.username().is_empty()
1829        || parsed.password().is_some()
1830        || parsed.query().is_some()
1831        || parsed.fragment().is_some()
1832    {
1833        return Err(invalid_feed("unsafe registry home URL"));
1834    }
1835    let http = registry_agent(&parsed)?;
1836    let resp = match with_connect_retries(|| http.get(url).call().map_err(Box::new)) {
1837        Ok(resp) => resp,
1838        Err(error) => match *error {
1839            ureq::Error::Status(status, resp) => {
1840                let _ = resp;
1841                return Err(LinkError::Http {
1842                    what: "registry home fetch",
1843                    status,
1844                    message: "the home node rejected the card request".to_string(),
1845                    code: None,
1846                    details: None,
1847                });
1848            }
1849            ureq::Error::Transport(err) => {
1850                return Err(LinkError::Transport {
1851                    hub: url.to_string(),
1852                    message: err.to_string(),
1853                });
1854            }
1855        },
1856    };
1857    if !(200..300).contains(&resp.status()) {
1858        return Err(LinkError::Http {
1859            what: "registry home fetch",
1860            status: resp.status(),
1861            message: "the home node returned a redirect or error".to_string(),
1862            code: None,
1863            details: None,
1864        });
1865    }
1866    let mut buf = Vec::new();
1867    resp.into_reader()
1868        .take(MAX_REGISTRY_CARD_BYTES + 1)
1869        .read_to_end(&mut buf)?;
1870    if buf.len() as u64 > MAX_REGISTRY_CARD_BYTES {
1871        return Err(LinkError::ResponseTooLarge {
1872            limit_bytes: MAX_REGISTRY_CARD_BYTES,
1873        });
1874    }
1875    serde_json::from_slice(&buf).map_err(|_| LinkError::InvalidFeed {
1876        message: "the home node returned invalid JSON".to_string(),
1877    })
1878}
1879
1880/// Resolve a bare `@handle` through the federation registry (link.md §7.1,
1881/// E5): look the handle up in the hub's registry, fetch the brain card from
1882/// the returned HOME node, and PIN — the card's identity fingerprint must
1883/// equal the registry's, or resolution fails. Returns the card enriched with
1884/// the resolved `home`, or `Ok(None)` when the registry has no such handle
1885/// (so the caller can fall back to a direct lookup).
1886pub fn resolve_registry(cfg: &HubConfig, handle: &str) -> LinkResult<Option<Value>> {
1887    require_safe_ref(handle)?;
1888    // Hold the validated trust-directory capability before any network I/O.
1889    // Every lock/load/save below remains relative to this exact inode even if
1890    // an attacker swaps an ancestor while the registry or home is answering.
1891    let trust_directory = open_trust_dir(cfg)?;
1892    let reg = request_capped(
1893        cfg,
1894        "GET",
1895        &format!("/api/hub/registry/{handle}"),
1896        None,
1897        Auth::None,
1898        MAX_REGISTRY_CARD_BYTES,
1899    )?;
1900    if reg.status == 404 {
1901        return Ok(None);
1902    }
1903    let body = ensure_ok(reg, "registry resolve")?;
1904    let home = body
1905        .get("home")
1906        .and_then(Value::as_str)
1907        .ok_or_else(|| invalid_feed("registry entry has no home"))?;
1908    let brain = body
1909        .get("brain")
1910        .and_then(Value::as_str)
1911        .ok_or_else(|| invalid_feed("registry entry has no brain"))?;
1912    if !crate::ulid::is_ulid(brain) {
1913        return Err(invalid_feed(
1914            "registry entry brain is not a canonical lowercase ULID",
1915        ));
1916    }
1917    let want_fp = body
1918        .get("identity")
1919        .and_then(|i| i.get("fingerprint"))
1920        .and_then(Value::as_str)
1921        .ok_or_else(|| invalid_feed("registry entry has no identity fingerprint"))?;
1922
1923    let home = home.trim_end_matches('/');
1924    let origin = normalized_origin(home)?;
1925    if origin != home {
1926        return Err(invalid_feed(
1927            "registry home must be an origin without a path, query, or fragment",
1928        ));
1929    }
1930    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[handle, brain])?;
1931    let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, handle, brain)?;
1932    if let Some(binding) = &alias_binding {
1933        if binding
1934            .home
1935            .as_deref()
1936            .is_some_and(|pinned_home| pinned_home != home)
1937        {
1938            return Err(invalid_feed(
1939                "registry relocated a pinned handle to a different home",
1940            ));
1941        }
1942    }
1943    let card = get_json_absolute(&format!("{home}/api/hub/brains/{brain}"))?;
1944    if card.get("id").and_then(Value::as_str) != Some(brain) {
1945        return Err(invalid_feed(
1946            "the home node served a card for a different brain",
1947        ));
1948    }
1949    let identity: FeedIdentity = serde_json::from_value(
1950        card.get("identity")
1951            .cloned()
1952            .ok_or_else(|| invalid_feed("the home node served no identity"))?,
1953    )
1954    .map_err(|_| invalid_feed("the home node served an invalid identity"))?;
1955    let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
1956    let got_fp = card
1957        .get("identity")
1958        .and_then(|i| i.get("fingerprint"))
1959        .and_then(Value::as_str)
1960        .unwrap_or_default();
1961    if got_fp != want_fp {
1962        return Err(invalid_feed(
1963            "the home node served an identity that does not match the registry — refusing",
1964        ));
1965    }
1966    let current = format!("ed25519:{}", identity.fingerprint);
1967    let advertised_seq = card
1968        .get("headSeq")
1969        .and_then(Value::as_u64)
1970        .ok_or_else(|| invalid_feed("the home node served an invalid head sequence"))?;
1971    let advertised_hash = card.get("feedHash").and_then(Value::as_str);
1972    if (advertised_seq == 0 && !card.get("feedHash").is_some_and(Value::is_null))
1973        || (advertised_seq > 0 && advertised_hash.is_none_or(|hash| !is_sha256(hash)))
1974    {
1975        return Err(invalid_feed(
1976            "the home node served an invalid feed head boundary",
1977        ));
1978    }
1979    // This is required even on first contact. Otherwise a syntactically valid
1980    // identity may claim that a rotation happened after a head the home has
1981    // never reached, then become the permanent TOFU anchor.
1982    verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], advertised_seq)?;
1983    let registry_alias = AliasBinding {
1984        v: 1,
1985        origin: normalized_origin(&cfg.hub)?,
1986        requested: handle.to_string(),
1987        brain: brain.to_string(),
1988        home: Some(home.to_string()),
1989    };
1990    save_canonical_pin_and_alias(
1991        cfg,
1992        &trust_directory,
1993        handle,
1994        brain,
1995        TrustState {
1996            v: 2,
1997            origin: normalized_origin(&cfg.hub)?,
1998            requested: brain.to_string(),
1999            brain: brain.to_string(),
2000            home: None,
2001            anchor,
2002            current,
2003            head_seq: pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq),
2004            feed_hash: pinned
2005                .as_ref()
2006                .and_then(|checkpoint| checkpoint.feed_hash.clone()),
2007            rotations: identity.rotations.clone(),
2008            hub_signer: None,
2009            protocol_profile: None,
2010        },
2011        Some(&registry_alias),
2012    )?;
2013    let mut out = card;
2014    if let Value::Object(map) = &mut out {
2015        map.insert("home".to_string(), Value::String(home.to_string()));
2016        map.insert(
2017            "resolvedVia".to_string(),
2018            Value::String("registry".to_string()),
2019        );
2020    }
2021    Ok(Some(out))
2022}
2023
2024pub fn resolve(cfg: &HubConfig, addr: &Address) -> LinkResult<Value> {
2025    // `Address::parse` refuses these shapes already, but `Address` has public
2026    // fields — re-assert at the wire so a hand-built address can never
2027    // reshape the request path.
2028    require_safe_ref(&addr.brain)?;
2029    if let Some(target) = &addr.target {
2030        let (given, ok) = match target {
2031            AddressTarget::Id(id) => (id, crate::ulid::is_ulid(id)),
2032            AddressTarget::Path(p) => (p, safe_store_rel_path(p) && p.ends_with(".md")),
2033        };
2034        if !ok {
2035            return Err(LinkError::BadAddress {
2036                given: given.clone(),
2037                reason: BAD_TARGET_REASON.to_string(),
2038            });
2039        }
2040    }
2041
2042    // A record is never accepted from the hub's mutable query/index response:
2043    // that response was not covered by the brain's feed signature and could
2044    // return arbitrary frontmatter/body while an unrelated signed head still
2045    // verified. Materialize the record from the exact content-addressed pack
2046    // named by the verified signed head instead.
2047    if let Some(target) = &addr.target {
2048        let remote = verified_remote_head(cfg, &addr.brain, false)?;
2049        if !remote.head.verified {
2050            return Err(invalid_feed(
2051                "a path-scoped feed cannot prove a record against the full signed snapshot",
2052            ));
2053        }
2054        if remote.head.seq == 0 {
2055            return Err(LinkError::Http {
2056                what: "resolve",
2057                status: 404,
2058                message: "record not found".to_string(),
2059                code: Some("NOT_FOUND".to_string()),
2060                details: None,
2061            });
2062        }
2063        let brain = remote.head.brain.clone();
2064        let pack = download_verified_snapshot_pack(cfg, &brain, &remote)?;
2065        return resolve_from_verified_pack(&brain, target, pack);
2066    }
2067
2068    let path = format!("/api/hub/brains/{}", addr.brain);
2069    // Direct first: the caller's own slug and hub-hosted public handles resolve
2070    // here unchanged. Only a bare `@handle` the hub can't resolve directly
2071    // (404) falls through to the federation registry — how a handle reaches a
2072    // brain on ANOTHER node (link.md §7.1, E5).
2073    let direct = request(cfg, "GET", &path, None, Auth::Required)?;
2074    if direct.status == 404 && addr.target.is_none() && !crate::ulid::is_ulid(&addr.brain) {
2075        if let Some(card) = resolve_registry(cfg, &addr.brain)? {
2076            return Ok(card);
2077        }
2078    }
2079    let resolved = ensure_ok(direct, "resolve")?;
2080    // A successful direct response is accepted only after the same centralized
2081    // identity/rotation/feed checkpoint verification used by sync and
2082    // subscribe. No verb gets a weaker ad-hoc pinning path.
2083    let remote = verified_remote_head(cfg, &addr.brain, false)?;
2084    if resolved.get("id").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2085        || resolved.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2086        || resolved.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
2087    {
2088        return Err(invalid_feed(
2089            "resolve card is not bound to the exact verified feed checkpoint",
2090        ));
2091    }
2092    let card_identity: FeedIdentity = serde_json::from_value(
2093        resolved
2094            .get("identity")
2095            .cloned()
2096            .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2097    )
2098    .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2099    if remote.identity.as_ref() != Some(&card_identity) {
2100        return Err(invalid_feed(
2101            "resolve card identity differs from the verified feed identity",
2102        ));
2103    }
2104    Ok(resolved)
2105}
2106
2107/// Resolve one record strictly from the exact signed snapshot pack. The hub's
2108/// mutable query/index result is intentionally not consulted: a signed pack
2109/// digest is the only cryptographic binding between a feed checkpoint and
2110/// record bytes in wire profile v1.
2111fn resolve_from_verified_pack(
2112    brain: &str,
2113    target: &AddressTarget,
2114    pack: Vec<u8>,
2115) -> LinkResult<Value> {
2116    let entries = parse_store_pack(pack)?;
2117    let mut matched: Option<(String, Vec<u8>)> = None;
2118
2119    for (path, bytes) in entries {
2120        let is_candidate = match target {
2121            AddressTarget::Path(want) => &path == want,
2122            AddressTarget::Id(_) => {
2123                path.ends_with(".md")
2124                    && (path.starts_with("records/") || path.starts_with("sources/"))
2125            }
2126        };
2127        if !is_candidate {
2128            continue;
2129        }
2130        let text = std::str::from_utf8(&bytes)
2131            .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2132        let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2133            .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2134        if let AddressTarget::Id(want) = target {
2135            let frontmatter =
2136                crate::parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(&path))
2137                    .map_err(|_| {
2138                        invalid_feed(format!("signed snapshot record `{path}` is malformed"))
2139                    })?;
2140            if frontmatter.id.as_deref() != Some(want) {
2141                continue;
2142            }
2143        }
2144        if matched.is_some() {
2145            return Err(invalid_feed(
2146                "signed snapshot contains more than one record for the requested target",
2147            ));
2148        }
2149        matched = Some((path, bytes));
2150    }
2151
2152    let (path, bytes) = matched.ok_or_else(|| LinkError::Http {
2153        what: "resolve",
2154        status: 404,
2155        message: "record not found".to_string(),
2156        code: Some("NOT_FOUND".to_string()),
2157        details: None,
2158    })?;
2159    let text = std::str::from_utf8(&bytes)
2160        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2161    let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2162        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2163    let frontmatter: Value = serde_norway::from_str(&parsed.frontmatter_yaml)
2164        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2165    let Value::Object(fields) = frontmatter else {
2166        return Err(invalid_feed(format!(
2167            "signed snapshot record `{path}` frontmatter is not a mapping"
2168        )));
2169    };
2170    let mut document = serde_json::Map::new();
2171    document.insert("path".to_string(), Value::String(path));
2172    for (key, value) in fields {
2173        document.insert(key, value);
2174    }
2175    document.insert("body".to_string(), Value::String(parsed.body));
2176    document.insert(
2177        "contentSha".to_string(),
2178        Value::String(content_sha256(&bytes)),
2179    );
2180    Ok(json!({
2181        "brain": brain,
2182        "document": Value::Object(document),
2183    }))
2184}
2185
2186// ─────────────────────────────────────────────────────────────────────────────
2187// sync — pull the granted slice as files; push the local store as a snapshot
2188// ─────────────────────────────────────────────────────────────────────────────
2189
2190/// What a pull materialized.
2191#[derive(Debug, Clone, serde::Serialize)]
2192pub struct PullReport {
2193    /// The brain id the hub reported.
2194    pub brain: String,
2195    /// The brain's slug.
2196    pub slug: String,
2197    /// The hub's feed head at export time.
2198    #[serde(rename = "headSeq")]
2199    pub head_seq: u64,
2200    /// How many files were written.
2201    pub files: usize,
2202    /// Where they were written (as given or derived from the slug).
2203    pub dest: String,
2204    /// Local content files that the export did not carry — present so a
2205    /// caller sees divergence; nothing is ever deleted locally.
2206    #[serde(rename = "extraLocal")]
2207    pub extra_local: Vec<String>,
2208    /// Exact convergence result from the post-install barrier.
2209    #[serde(rename = "syncStatus")]
2210    pub sync_status: String,
2211}
2212
2213struct V2PulledSnapshot {
2214    report: PullReport,
2215    head: V2VerifiedHead,
2216    files: std::collections::BTreeMap<String, V2BaselineFile>,
2217    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
2218    local: V2LocalView,
2219    local_assets: std::collections::BTreeMap<String, crate::AssetRecord>,
2220}
2221
2222fn download_verified_snapshot_pack(
2223    cfg: &HubConfig,
2224    brain: &str,
2225    remote: &VerifiedRemote,
2226) -> LinkResult<Vec<u8>> {
2227    let feed_hash = remote
2228        .head
2229        .feed_hash
2230        .as_deref()
2231        .ok_or_else(|| invalid_feed("non-empty snapshot has no verified feed hash"))?;
2232    let signed_head = remote
2233        .head_entry
2234        .as_ref()
2235        .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
2236    let expected = &signed_head.entry.pack_sha256;
2237    if !is_sha256(expected) {
2238        return Err(invalid_feed(
2239            "signed head carries an invalid snapshot pack digest",
2240        ));
2241    }
2242    let path = format!(
2243        "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={feed_hash}",
2244        remote.head.seq
2245    );
2246    let body = ensure_ok(
2247        request(cfg, "GET", &path, None, Auth::Required)?,
2248        "sync pull",
2249    )?;
2250    if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2251        || body.get("feedHash").and_then(Value::as_str) != Some(feed_hash)
2252        || body.get("brain").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2253        || body.get("sha256").and_then(Value::as_str) != Some(expected.as_str())
2254    {
2255        return Err(invalid_feed(
2256            "export response is not bound to the exact verified snapshot",
2257        ));
2258    }
2259    let url = body
2260        .get("url")
2261        .and_then(Value::as_str)
2262        .ok_or_else(|| invalid_feed("verified snapshot export carried no exact pack URL"))?;
2263    let bytes = get_presigned(cfg, url)?;
2264    if content_sha256(&bytes) != *expected {
2265        return Err(LinkError::InvalidPack {
2266            message: "downloaded pack does not match the signed snapshot digest".to_string(),
2267        });
2268    }
2269    let entries = parse_store_pack(bytes.clone())?;
2270    if signed_head.entry.kind == "push" {
2271        verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
2272    }
2273    Ok(bytes)
2274}
2275
2276#[derive(Debug, Clone, Deserialize, Serialize)]
2277struct V2PointerBody {
2278    v: u8,
2279    brain: String,
2280    seq: u64,
2281    commit_hash: String,
2282    feed_hash: String,
2283    content_root: Option<String>,
2284    asset_root: Option<String>,
2285    materializer: String,
2286    signer_epoch: u64,
2287    control_revision: String,
2288    backup_preparation: String,
2289    prior_pointer_hash: Option<String>,
2290    signed_at: String,
2291}
2292
2293#[derive(Debug, Clone, Deserialize)]
2294struct V2SignedPointer {
2295    pointer: V2PointerBody,
2296    hub_public_key: String,
2297    hub_fingerprint: String,
2298    sig: String,
2299}
2300
2301#[derive(Debug, Clone, Deserialize)]
2302struct V2HeadIdentity {
2303    #[serde(default)]
2304    custody: String,
2305    fingerprint: String,
2306    public_key_spki: String,
2307    #[serde(default)]
2308    previous: Vec<V2PreviousIdentity>,
2309    #[serde(default)]
2310    rotations: Vec<String>,
2311}
2312
2313#[derive(Debug, Clone, Deserialize)]
2314struct V2PreviousIdentity {
2315    fingerprint: String,
2316    public_key_spki: String,
2317}
2318
2319#[derive(Debug, Deserialize)]
2320struct V2HeadResponse {
2321    v: u8,
2322    brain_id: String,
2323    profile: String,
2324    view: Option<V2HeadView>,
2325    pointer: Option<V2SignedPointer>,
2326    identity: Option<V2HeadIdentity>,
2327}
2328
2329#[derive(Debug, Clone, Deserialize)]
2330struct V2HeadView {
2331    kind: String,
2332    #[serde(default)]
2333    id: Option<String>,
2334    control_revision: String,
2335}
2336
2337#[derive(Debug, Clone)]
2338struct V2VerifiedHead {
2339    requested: String,
2340    brain_id: String,
2341    view_kind: String,
2342    /// Stable identity of the exact current-content path projection.
2343    view_revision: String,
2344    /// Use-time authority revision independently bound into writes/signatures.
2345    control_revision: String,
2346    identity: V2HeadIdentity,
2347    pointer: Option<V2PointerBody>,
2348    trust: TrustState,
2349    alias: Option<AliasBinding>,
2350}
2351
2352fn verify_v2_spki_signature(
2353    public_key: &str,
2354    message: &[u8],
2355    signature: &str,
2356) -> LinkResult<Vec<u8>> {
2357    let der = URL_SAFE_NO_PAD
2358        .decode(public_key)
2359        .map_err(|_| invalid_feed("v2 signer public key is not base64url"))?;
2360    if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
2361        return Err(invalid_feed("v2 signer public key is not Ed25519 SPKI"));
2362    }
2363    let sig = URL_SAFE_NO_PAD
2364        .decode(signature)
2365        .map_err(|_| invalid_feed("v2 signature is not base64url"))?;
2366    UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
2367        .verify(message, &sig)
2368        .map_err(|_| invalid_feed("v2 Ed25519 signature failed"))?;
2369    Ok(der)
2370}
2371
2372fn verify_v2_pointer(pointer: &V2SignedPointer, expected_brain: &str) -> LinkResult<String> {
2373    if pointer.pointer.v != 2
2374        || pointer.pointer.brain != expected_brain
2375        || pointer.pointer.seq == 0
2376        || !is_sha256(&pointer.pointer.commit_hash)
2377        || !is_sha256(&pointer.pointer.feed_hash)
2378        || pointer
2379            .pointer
2380            .content_root
2381            .as_deref()
2382            .is_some_and(|hash| !is_sha256(hash))
2383        || !is_sha256(&pointer.pointer.backup_preparation)
2384    {
2385        return Err(invalid_feed("v2 pointer fields are invalid"));
2386    }
2387    let value = serde_json::to_value(&pointer.pointer)
2388        .map_err(|_| invalid_feed("v2 pointer could not be canonicalized"))?;
2389    let message = crate::linkmd_v2::canonical_bytes(&value)
2390        .map_err(|error| invalid_feed(error.to_string()))?;
2391    let der = verify_v2_spki_signature(&pointer.hub_public_key, &message, &pointer.sig)?;
2392    let fingerprint = format!("{:x}", Sha256::digest(&der));
2393    if fingerprint != pointer.hub_fingerprint {
2394        return Err(invalid_feed("v2 hub signer fingerprint mismatch"));
2395    }
2396    Ok(format!(
2397        "{}:{}",
2398        pointer.hub_fingerprint, pointer.hub_public_key
2399    ))
2400}
2401
2402fn v2_identity(identity: &V2HeadIdentity) -> FeedIdentity {
2403    FeedIdentity {
2404        fingerprint: identity.fingerprint.clone(),
2405        public_key_spki: identity.public_key_spki.clone(),
2406        previous: identity
2407            .previous
2408            .iter()
2409            .map(|previous| PreviousIdentity {
2410                fingerprint: previous.fingerprint.clone(),
2411                public_key_spki: previous.public_key_spki.clone(),
2412            })
2413            .collect(),
2414        rotations: identity.rotations.clone(),
2415    }
2416}
2417
2418fn verified_v2_commit_object(
2419    raw: &[u8],
2420    identity: &V2HeadIdentity,
2421) -> LinkResult<serde_json::Map<String, Value>> {
2422    let mut value: Value =
2423        serde_json::from_slice(raw).map_err(|_| invalid_feed("v2 commit is not JSON"))?;
2424    let canonical = crate::linkmd_v2::canonical_bytes(&value)
2425        .map_err(|error| invalid_feed(error.to_string()))?;
2426    if canonical != raw {
2427        return Err(invalid_feed("v2 commit is not canonical JSON"));
2428    }
2429    let object = value
2430        .as_object_mut()
2431        .ok_or_else(|| invalid_feed("v2 commit is not an object"))?;
2432    let sig = object
2433        .remove("sig")
2434        .and_then(|value| value.as_str().map(str::to_string))
2435        .ok_or_else(|| invalid_feed("v2 commit has no signature"))?;
2436    const FIELDS: [&str; 18] = [
2437        "actor_ref",
2438        "asset_root",
2439        "brain",
2440        "changes_sha256",
2441        "control_revision",
2442        "materializer",
2443        "op",
2444        "parent_asset_root",
2445        "parent_commit",
2446        "parent_root",
2447        "prev_entry_hash",
2448        "public_key",
2449        "seq",
2450        "signer_epoch",
2451        "state_root",
2452        "ts",
2453        "v",
2454        "v1_bridge",
2455    ];
2456    if object.len() != FIELDS.len() || FIELDS.iter().any(|field| !object.contains_key(*field)) {
2457        return Err(invalid_feed("v2 commit has a non-normative field set"));
2458    }
2459    let seq = object
2460        .get("seq")
2461        .and_then(Value::as_u64)
2462        .filter(|seq| *seq > 0)
2463        .ok_or_else(|| invalid_feed("v2 commit has an invalid sequence"))?;
2464    let signer_epoch = object
2465        .get("signer_epoch")
2466        .and_then(Value::as_u64)
2467        .filter(|epoch| *epoch > 0)
2468        .ok_or_else(|| invalid_feed("v2 commit has an invalid signer epoch"))?;
2469    let hash_or_null = |field: &str| {
2470        object
2471            .get(field)
2472            .is_some_and(|value| value.is_null() || value.as_str().is_some_and(is_sha256))
2473    };
2474    if object.get("v").and_then(Value::as_u64) != Some(2)
2475        || object.get("op").and_then(Value::as_str) != Some("changeset")
2476        || !object
2477            .get("changes_sha256")
2478            .and_then(Value::as_str)
2479            .is_some_and(is_sha256)
2480        || !object
2481            .get("actor_ref")
2482            .and_then(Value::as_str)
2483            .is_some_and(is_sha256)
2484        || !object
2485            .get("control_revision")
2486            .and_then(Value::as_str)
2487            .is_some_and(is_sha256)
2488        || !object
2489            .get("state_root")
2490            .and_then(Value::as_str)
2491            .is_some_and(is_sha256)
2492        || !hash_or_null("parent_commit")
2493        || !hash_or_null("parent_root")
2494        || !hash_or_null("parent_asset_root")
2495        || !hash_or_null("asset_root")
2496        || !hash_or_null("prev_entry_hash")
2497        || !object
2498            .get("materializer")
2499            .and_then(Value::as_str)
2500            .is_some_and(|value| !value.is_empty() && value.len() <= 128)
2501        || !object
2502            .get("ts")
2503            .and_then(Value::as_str)
2504            .is_some_and(|value| value.len() == 24 && value.ends_with('Z'))
2505    {
2506        return Err(invalid_feed("v2 commit fields are invalid"));
2507    }
2508    if (seq == 1
2509        && [
2510            "parent_commit",
2511            "parent_root",
2512            "parent_asset_root",
2513            "prev_entry_hash",
2514        ]
2515        .iter()
2516        .any(|field| !object.get(*field).is_some_and(Value::is_null)))
2517        || (seq > 1
2518            && ["parent_commit", "parent_root", "prev_entry_hash"]
2519                .iter()
2520                .any(|field| object.get(*field).and_then(Value::as_str).is_none()))
2521    {
2522        return Err(invalid_feed("v2 commit parent shape is invalid"));
2523    }
2524    match object.get("v1_bridge") {
2525        Some(Value::Null) => {}
2526        Some(Value::Object(bridge))
2527            if seq == 1
2528                && bridge.len() == 3
2529                && bridge
2530                    .get("head_seq")
2531                    .and_then(Value::as_u64)
2532                    .is_some_and(|v| v > 0)
2533                && bridge
2534                    .get("feed_hash")
2535                    .and_then(Value::as_str)
2536                    .is_some_and(is_sha256)
2537                && bridge
2538                    .get("pack_sha256")
2539                    .and_then(Value::as_str)
2540                    .is_some_and(is_sha256) => {}
2541        _ => return Err(invalid_feed("v2 commit has an invalid v1 bridge")),
2542    }
2543    let public_key = object
2544        .get("public_key")
2545        .and_then(Value::as_str)
2546        .ok_or_else(|| invalid_feed("v2 commit has no public key"))?;
2547    let der = URL_SAFE_NO_PAD
2548        .decode(public_key)
2549        .map_err(|_| invalid_feed("v2 brain public key is not base64url"))?;
2550    let expected_multikey = format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&der)));
2551    if object.get("brain").and_then(Value::as_str) != Some(expected_multikey.as_str()) {
2552        return Err(invalid_feed("v2 commit brain identity mismatch"));
2553    }
2554    // Verify the old-key-signed chain before trusting any prior public key.
2555    verify_identity_chain(&v2_identity(identity), None)?;
2556    // Old commits remain verifiable after brain-key rotation. `previous` is
2557    // newest-first, while signer epochs and rotation statements are oldest-first.
2558    let mut chain: Vec<(&str, &str)> = identity
2559        .previous
2560        .iter()
2561        .rev()
2562        .map(|previous| {
2563            (
2564                previous.fingerprint.as_str(),
2565                previous.public_key_spki.as_str(),
2566            )
2567        })
2568        .collect();
2569    chain.push((&identity.fingerprint, &identity.public_key_spki));
2570    let signer_index = chain.iter().position(|(fingerprint, spki)| {
2571        *fingerprint == expected_multikey.trim_start_matches("ed25519:") && *spki == public_key
2572    });
2573    let Some(signer_index) = signer_index else {
2574        return Err(invalid_feed("v2 commit uses an unrecognized brain key"));
2575    };
2576    if signer_epoch != signer_index as u64 + 1 {
2577        return Err(invalid_feed("v2 commit signer epoch differs from its key"));
2578    }
2579    let lower_boundary = if signer_index == 0 {
2580        None
2581    } else {
2582        let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
2583            .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
2584        Some(prior.prior_head_seq)
2585    };
2586    let upper_boundary = if signer_index == identity.rotations.len() {
2587        None
2588    } else {
2589        let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
2590            .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
2591        Some(next.prior_head_seq)
2592    };
2593    if lower_boundary.is_some_and(|boundary| seq <= boundary)
2594        || upper_boundary.is_some_and(|boundary| seq > boundary)
2595    {
2596        return Err(invalid_feed(
2597            "v2 commit signer is outside its authenticated rotation epoch",
2598        ));
2599    }
2600    let unsigned = crate::linkmd_v2::canonical_bytes(&Value::Object(object.clone()))
2601        .map_err(|error| invalid_feed(error.to_string()))?;
2602    verify_v2_spki_signature(public_key, &unsigned, &sig)?;
2603    Ok(object.clone())
2604}
2605
2606#[derive(Debug, Deserialize)]
2607struct V2FeedWireEntry {
2608    seq: u64,
2609    commit_hash: String,
2610    feed_hash: String,
2611    bytes_base64: String,
2612}
2613
2614#[derive(Debug, Deserialize)]
2615struct V2FeedPage {
2616    v: u8,
2617    head_seq: u64,
2618    head_commit_hash: String,
2619    head_feed_hash: String,
2620    entries: Vec<V2FeedWireEntry>,
2621    next_after: u64,
2622    complete: bool,
2623}
2624
2625fn replay_v2_feed(
2626    cfg: &HubConfig,
2627    brain: &str,
2628    pointer: &V2PointerBody,
2629    identity: &V2HeadIdentity,
2630    start_after: u64,
2631    start_feed: Option<String>,
2632) -> LinkResult<()> {
2633    let mut after = start_after;
2634    let mut prior_feed = start_feed;
2635    let mut final_object = None;
2636    let mut replayed_entries = 0_u64;
2637    let mut replayed_bytes = 0_u64;
2638    while after < pointer.seq {
2639        let path = format!("/api/hub/brains/{brain}/v2/feed?after={after}&limit=100");
2640        let value = ensure_ok(
2641            request_capped(
2642                cfg,
2643                "GET",
2644                &path,
2645                None,
2646                Auth::Required,
2647                MAX_FEED_REPLAY_BYTES,
2648            )?,
2649            "v2 feed replay",
2650        )?;
2651        let page: V2FeedPage = serde_json::from_value(value)
2652            .map_err(|_| invalid_feed("v2 feed page has an invalid shape"))?;
2653        if page.v != 2
2654            || page.head_seq != pointer.seq
2655            || page.head_commit_hash != pointer.commit_hash
2656            || page.head_feed_hash != pointer.feed_hash
2657            || page.entries.is_empty()
2658            || page.entries.len() > FEED_PAGE_LIMIT
2659        {
2660            return Err(invalid_feed("v2 feed page differs from the signed head"));
2661        }
2662        for entry in page.entries {
2663            if entry.seq != after + 1
2664                || !is_sha256(&entry.commit_hash)
2665                || !is_sha256(&entry.feed_hash)
2666            {
2667                return Err(invalid_feed("v2 feed sequence is not contiguous"));
2668            }
2669            let raw = base64::engine::general_purpose::STANDARD
2670                .decode(&entry.bytes_base64)
2671                .map_err(|_| invalid_feed("v2 feed bytes are not canonical base64"))?;
2672            replayed_entries = replayed_entries
2673                .checked_add(1)
2674                .ok_or_else(|| invalid_feed("v2 feed replay count overflow"))?;
2675            replayed_bytes = replayed_bytes
2676                .checked_add(raw.len() as u64)
2677                .ok_or_else(|| invalid_feed("v2 feed replay byte count overflow"))?;
2678            if replayed_entries > MAX_FEED_REPLAY_ENTRIES || replayed_bytes > MAX_FEED_REPLAY_BYTES
2679            {
2680                return Err(invalid_feed("v2 feed replay exceeds its safety bound"));
2681            }
2682            if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
2683                .map_err(|error| invalid_feed(error.to_string()))?
2684                != entry.commit_hash
2685                || content_sha256(&raw) != entry.feed_hash
2686            {
2687                return Err(invalid_feed("v2 feed entry address mismatch"));
2688            }
2689            let object = verified_v2_commit_object(&raw, identity)?;
2690            if object.get("seq").and_then(Value::as_u64) != Some(entry.seq)
2691                || object.get("prev_entry_hash").and_then(Value::as_str) != prior_feed.as_deref()
2692            {
2693                return Err(invalid_feed(
2694                    "v2 feed entry does not extend its predecessor",
2695                ));
2696            }
2697            after = entry.seq;
2698            prior_feed = Some(entry.feed_hash);
2699            final_object = Some((entry.commit_hash, object));
2700        }
2701        if page.next_after != after || (page.complete != (after == pointer.seq)) {
2702            return Err(invalid_feed("v2 feed page cursor is inconsistent"));
2703        }
2704    }
2705    let (final_hash, object) =
2706        final_object.ok_or_else(|| invalid_feed("v2 feed replay made no progress"))?;
2707    if final_hash != pointer.commit_hash
2708        || prior_feed.as_deref() != Some(pointer.feed_hash.as_str())
2709        || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
2710        || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
2711        || object.get("control_revision").and_then(Value::as_str)
2712            != Some(pointer.control_revision.as_str())
2713        || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
2714    {
2715        return Err(invalid_feed(
2716            "v2 replay did not converge on the signed pointer",
2717        ));
2718    }
2719    Ok(())
2720}
2721
2722fn verify_v1_to_v2_bridge(
2723    cfg: &HubConfig,
2724    brain: &str,
2725    pointer: &V2PointerBody,
2726    identity: &V2HeadIdentity,
2727    checkpoint: &TrustState,
2728) -> LinkResult<()> {
2729    let value = ensure_ok(
2730        request_capped(
2731            cfg,
2732            "GET",
2733            &format!("/api/hub/brains/{brain}/v2/feed?after=0&limit=1"),
2734            None,
2735            Auth::Required,
2736            MAX_FEED_RESPONSE_BYTES,
2737        )?,
2738        "v2 genesis bridge",
2739    )?;
2740    let page: V2FeedPage = serde_json::from_value(value)
2741        .map_err(|_| invalid_feed("v2 genesis bridge page has an invalid shape"))?;
2742    if page.v != 2
2743        || page.head_seq != pointer.seq
2744        || page.head_commit_hash != pointer.commit_hash
2745        || page.head_feed_hash != pointer.feed_hash
2746        || page.entries.len() != 1
2747        || page.entries[0].seq != 1
2748        || !is_sha256(&page.entries[0].commit_hash)
2749        || !is_sha256(&page.entries[0].feed_hash)
2750    {
2751        return Err(invalid_feed(
2752            "v2 genesis bridge page differs from the signed head",
2753        ));
2754    }
2755    let first = &page.entries[0];
2756    let raw = STANDARD
2757        .decode(&first.bytes_base64)
2758        .map_err(|_| invalid_feed("v2 genesis bridge bytes are not canonical base64"))?;
2759    if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
2760        .map_err(|error| invalid_feed(error.to_string()))?
2761        != first.commit_hash
2762        || content_sha256(&raw) != first.feed_hash
2763    {
2764        return Err(invalid_feed("v2 genesis bridge address mismatch"));
2765    }
2766    let object = verified_v2_commit_object(&raw, identity)?;
2767    if checkpoint.head_seq == 0 {
2768        if checkpoint.feed_hash.is_some() || object.get("v1_bridge") != Some(&Value::Null) {
2769            return Err(invalid_feed(
2770                "empty v1 checkpoint did not transition through an empty v2 genesis",
2771            ));
2772        }
2773        return Ok(());
2774    }
2775    let bridge = object
2776        .get("v1_bridge")
2777        .and_then(Value::as_object)
2778        .ok_or_else(|| invalid_feed("v2 genesis omitted the pinned v1 boundary"))?;
2779    let checkpoint_feed = checkpoint
2780        .feed_hash
2781        .as_deref()
2782        .ok_or_else(|| invalid_feed("non-empty v1 checkpoint has no feed hash"))?;
2783    if bridge.get("head_seq").and_then(Value::as_u64) != Some(checkpoint.head_seq)
2784        || bridge.get("feed_hash").and_then(Value::as_str) != Some(checkpoint_feed)
2785    {
2786        return Err(invalid_feed(
2787            "v2 genesis bridge differs from the pinned v1 checkpoint",
2788        ));
2789    }
2790    let legacy_raw = ensure_raw_ok(
2791        request_raw(
2792            cfg,
2793            "GET",
2794            &format!(
2795                "/api/hub/brains/{brain}/feed?after={}&limit=1",
2796                checkpoint.head_seq - 1
2797            ),
2798            None,
2799            Auth::Required,
2800            MAX_FEED_RESPONSE_BYTES,
2801        )?,
2802        "v1 bridge boundary",
2803    )?;
2804    let legacy: FeedResponse = serde_json::from_slice(&legacy_raw)
2805        .map_err(|_| invalid_feed("v1 bridge boundary has an invalid feed shape"))?;
2806    let legacy_identity = legacy
2807        .identity
2808        .ok_or_else(|| invalid_feed("v1 bridge boundary has no identity"))?;
2809    let item = legacy
2810        .entries
2811        .first()
2812        .filter(|_| legacy.entries.len() == 1)
2813        .ok_or_else(|| invalid_feed("v1 bridge boundary did not return one exact entry"))?;
2814    if legacy.scope_limited
2815        || legacy.head_seq != checkpoint.head_seq
2816        || legacy.feed_hash.as_deref() != Some(checkpoint_feed)
2817        || item.entry.seq != checkpoint.head_seq
2818        || item.hash != checkpoint_feed
2819        || legacy_identity != v2_identity(identity)
2820        || bridge.get("pack_sha256").and_then(Value::as_str)
2821            != Some(item.entry.pack_sha256.as_str())
2822    {
2823        return Err(invalid_feed(
2824            "v1 bridge boundary differs from its signed legacy head",
2825        ));
2826    }
2827    let anchor = verify_identity_chain(&legacy_identity, Some(checkpoint))?;
2828    if anchor != checkpoint.anchor {
2829        return Err(invalid_feed("v1 bridge changed the pinned identity anchor"));
2830    }
2831    verify_feed_item(item, &legacy_identity)?;
2832    verify_rotation_feed_boundaries(
2833        &legacy_identity,
2834        Some(checkpoint),
2835        std::slice::from_ref(item),
2836        checkpoint.head_seq,
2837    )?;
2838    Ok(())
2839}
2840
2841fn verify_v2_commit(
2842    cfg: &HubConfig,
2843    brain: &str,
2844    pointer: &V2PointerBody,
2845    identity: &V2HeadIdentity,
2846    pinned: Option<&TrustState>,
2847) -> LinkResult<()> {
2848    let path = format!(
2849        "/api/hub/brains/{brain}/v2/commit?commit={}",
2850        pointer.commit_hash
2851    );
2852    let raw = ensure_raw_ok(
2853        request_raw(cfg, "GET", &path, None, Auth::Required, MAX_RESPONSE_BYTES)?,
2854        "v2 commit",
2855    )?;
2856    if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
2857        .map_err(|error| invalid_feed(error.to_string()))?
2858        != pointer.commit_hash
2859        || content_sha256(&raw) != pointer.feed_hash
2860    {
2861        return Err(invalid_feed("v2 commit address differs from the pointer"));
2862    }
2863    let object = verified_v2_commit_object(&raw, identity)?;
2864    if object.get("seq").and_then(Value::as_u64) != Some(pointer.seq)
2865        || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
2866        || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
2867        || object.get("control_revision").and_then(Value::as_str)
2868            != Some(pointer.control_revision.as_str())
2869        || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
2870    {
2871        return Err(invalid_feed("v2 commit fields differ from the pointer"));
2872    }
2873    if let Some(checkpoint) = pinned.filter(|checkpoint| accepted_as_v2(checkpoint)) {
2874        if pointer.seq == checkpoint.head_seq + 1
2875            && object.get("prev_entry_hash").and_then(Value::as_str)
2876                != checkpoint.feed_hash.as_deref()
2877        {
2878            return Err(invalid_feed(
2879                "v2 commit does not extend the pinned feed hash",
2880            ));
2881        }
2882        if pointer.seq > checkpoint.head_seq + 1 {
2883            return replay_v2_feed(
2884                cfg,
2885                brain,
2886                pointer,
2887                identity,
2888                checkpoint.head_seq,
2889                checkpoint.feed_hash.clone(),
2890            );
2891        }
2892    } else {
2893        if let Some(checkpoint) = pinned {
2894            verify_v1_to_v2_bridge(cfg, brain, pointer, identity, checkpoint)?;
2895        }
2896        if pointer.seq > 1 {
2897            return replay_v2_feed(cfg, brain, pointer, identity, 0, None);
2898        }
2899    }
2900    Ok(())
2901}
2902
2903fn v2_verified_head(cfg: &HubConfig, brain: &str) -> LinkResult<Option<V2VerifiedHead>> {
2904    require_hardened_filesystem("verified link.md v2 state")?;
2905    require_safe_ref(brain)?;
2906    let path = format!("/api/hub/brains/{brain}/v2/head");
2907    let response = request(cfg, "GET", &path, None, Auth::Required)?;
2908    if response.status == 404 {
2909        if has_accepted_v2_ref(cfg, brain)? {
2910            return Err(LinkError::BrainUnavailable);
2911        }
2912        return Ok(None);
2913    }
2914    let body = ensure_ok(response, "v2 head")?;
2915    let head: V2HeadResponse = serde_json::from_value(body)
2916        .map_err(|_| invalid_feed("v2 head response has an invalid shape"))?;
2917    if head.v != 2 || !crate::ulid::is_ulid(&head.brain_id) {
2918        return Err(invalid_feed("v2 head has no canonical brain id"));
2919    }
2920    if crate::ulid::is_ulid(brain) && head.brain_id != brain {
2921        return Err(invalid_feed("v2 head resolved a different brain id"));
2922    }
2923    if head.profile == "v1" {
2924        return Ok(None);
2925    }
2926    if head.profile != "v2" && head.profile != "v2-empty" {
2927        return Err(invalid_feed("v2 head advertised an unknown profile"));
2928    }
2929    let view = head
2930        .view
2931        .as_ref()
2932        .ok_or_else(|| invalid_feed("v2 head has no permission view"))?;
2933    if !matches!(view.kind.as_str(), "full" | "scoped")
2934        || !is_sha256(&view.control_revision)
2935        || view.id.as_deref().is_some_and(|id| !is_sha256(id))
2936    {
2937        return Err(invalid_feed("v2 head has an invalid permission view"));
2938    }
2939    let view_kind = view.kind.clone();
2940    // `id` is the stable materialized namespace. Older v2 hubs did not expose
2941    // it, so retain the fail-closed control-revision fallback for compatibility.
2942    let view_revision = view
2943        .id
2944        .clone()
2945        .unwrap_or_else(|| view.control_revision.clone());
2946    let control_revision = view.control_revision.clone();
2947    let identity = head
2948        .identity
2949        .as_ref()
2950        .ok_or_else(|| invalid_feed("v2 head has no brain identity"))?;
2951    let trust_directory = open_trust_dir(cfg)?;
2952    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &head.brain_id])?;
2953    let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, brain, &head.brain_id)?;
2954    let feed_identity = v2_identity(identity);
2955    let anchor = verify_identity_chain(&feed_identity, pinned.as_ref())?;
2956    let (seq, feed_hash, hub_signer) = match &head.pointer {
2957        None => {
2958            if head.profile != "v2-empty" {
2959                return Err(invalid_feed("initialized v2 head has no pointer"));
2960            }
2961            (
2962                0,
2963                None,
2964                pinned.as_ref().and_then(|state| state.hub_signer.clone()),
2965            )
2966        }
2967        Some(signed) => {
2968            let signer = verify_v2_pointer(signed, &head.brain_id)?;
2969            if pinned
2970                .as_ref()
2971                .and_then(|state| state.hub_signer.as_ref())
2972                .is_some_and(|known| known != &signer)
2973            {
2974                return Err(invalid_feed(
2975                    "v2 hub pointer signer changed without a trust transition",
2976                ));
2977            }
2978            if let Some(checkpoint) = pinned.as_ref().filter(|state| accepted_as_v2(state)) {
2979                if signed.pointer.seq < checkpoint.head_seq
2980                    || (signed.pointer.seq == checkpoint.head_seq
2981                        && checkpoint.feed_hash.as_deref()
2982                            != Some(signed.pointer.feed_hash.as_str()))
2983                {
2984                    return Err(invalid_feed("v2 pointer rolled back or equivocated"));
2985                }
2986            }
2987            verify_v2_commit(
2988                cfg,
2989                &head.brain_id,
2990                &signed.pointer,
2991                identity,
2992                pinned.as_ref(),
2993            )?;
2994            (
2995                signed.pointer.seq,
2996                Some(signed.pointer.feed_hash.clone()),
2997                Some(signer),
2998            )
2999        }
3000    };
3001    let trust = TrustState {
3002        v: 2,
3003        origin: normalized_origin(&cfg.hub)?,
3004        requested: head.brain_id.clone(),
3005        brain: head.brain_id.clone(),
3006        home: None,
3007        anchor,
3008        current: format!("ed25519:{}", identity.fingerprint),
3009        head_seq: seq,
3010        feed_hash,
3011        rotations: identity.rotations.clone(),
3012        hub_signer,
3013        protocol_profile: Some("link-v2".to_string()),
3014    };
3015    Ok(Some(V2VerifiedHead {
3016        requested: brain.to_string(),
3017        brain_id: head.brain_id,
3018        view_kind,
3019        view_revision,
3020        control_revision,
3021        identity: identity.clone(),
3022        pointer: head.pointer.map(|signed| signed.pointer),
3023        trust,
3024        alias: alias_binding,
3025    }))
3026}
3027
3028fn accept_v2_head(cfg: &HubConfig, head: &V2VerifiedHead) -> LinkResult<()> {
3029    let directory = open_trust_dir(cfg)?;
3030    let _locks = lock_trust_many(cfg, &directory, &[&head.requested, &head.brain_id])?;
3031    let (current, alias) = load_canonical_pin(cfg, &directory, &head.requested, &head.brain_id)?;
3032    if let Some(current) = current {
3033        let common_invalid = head.trust.anchor != current.anchor
3034            || !head.trust.rotations.starts_with(&current.rotations);
3035        let profile_invalid = if accepted_as_v2(&current) {
3036            head.trust.head_seq < current.head_seq
3037                || (head.trust.head_seq == current.head_seq
3038                    && head.trust.feed_hash != current.feed_hash)
3039                || current
3040                    .hub_signer
3041                    .as_ref()
3042                    .is_some_and(|known| head.trust.hub_signer.as_ref() != Some(known))
3043        } else {
3044            head.trust.protocol_profile.as_deref() != Some("link-v2")
3045                || head.trust.hub_signer.is_none()
3046        };
3047        if common_invalid || profile_invalid {
3048            return Err(invalid_feed(
3049                "v2 head cannot advance the currently accepted trust checkpoint",
3050            ));
3051        }
3052    }
3053    save_canonical_pin_and_alias(
3054        cfg,
3055        &directory,
3056        &head.requested,
3057        &head.brain_id,
3058        head.trust.clone(),
3059        alias.as_ref().or(head.alias.as_ref()),
3060    )
3061}
3062
3063#[derive(Debug, Clone, Deserialize, Serialize)]
3064struct V2BaselineFile {
3065    sha256: String,
3066    bytes: u64,
3067    #[serde(skip)]
3068    proof: Option<Vec<V2ProofStep>>,
3069}
3070
3071#[derive(Debug, Clone, Deserialize, Serialize)]
3072struct V2SyncBaseline {
3073    v: u8,
3074    origin: String,
3075    brain: String,
3076    #[serde(default)]
3077    checkout_id: Option<String>,
3078    #[serde(default)]
3079    head_seq: Option<u64>,
3080    commit_hash: Option<String>,
3081    content_root: Option<String>,
3082    #[serde(default)]
3083    asset_root: Option<String>,
3084    #[serde(default)]
3085    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
3086    #[serde(default)]
3087    view_kind: Option<String>,
3088    #[serde(default)]
3089    view_revision: Option<String>,
3090    #[serde(default)]
3091    projection_sha256: Option<String>,
3092    files: std::collections::BTreeMap<String, V2BaselineFile>,
3093    #[serde(default)]
3094    local_policy_digest: Option<String>,
3095    #[serde(default)]
3096    local_eligibility: std::collections::BTreeMap<String, bool>,
3097    #[serde(default)]
3098    remote_copy_remains: std::collections::BTreeMap<String, String>,
3099}
3100
3101struct V2LocalView {
3102    riding: std::collections::BTreeMap<String, (String, u64)>,
3103    eligibility: std::collections::BTreeMap<String, bool>,
3104    policy: crate::linkmd_sync_policy::SyncPolicy,
3105    withheld_links: Vec<V2WithheldLink>,
3106}
3107
3108#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
3109struct V2WithheldLink {
3110    source: String,
3111    target: String,
3112}
3113
3114#[derive(Debug, Clone, Deserialize, Serialize)]
3115struct V2ProofStep {
3116    directory_root: String,
3117    component: String,
3118    proof: crate::linkmd_v2::HamtProof,
3119}
3120
3121#[derive(Debug, Deserialize)]
3122struct V2ManifestFile {
3123    path: String,
3124    sha256: String,
3125    bytes: u64,
3126    proof: Vec<V2ProofStep>,
3127}
3128
3129#[derive(Debug, Deserialize)]
3130struct V2ManifestPage {
3131    v: u8,
3132    commit: String,
3133    content_root: Option<String>,
3134    files: Vec<V2ManifestFile>,
3135    next_cursor: Option<String>,
3136}
3137
3138#[derive(Debug, Clone, Deserialize, Serialize)]
3139struct V2BaselineAsset {
3140    blob_sha256: String,
3141    bytes: u64,
3142    media_type: String,
3143    wrappers: Vec<String>,
3144    required: bool,
3145    disposition: String,
3146    leaf_hash: String,
3147}
3148
3149#[derive(Debug, Deserialize)]
3150struct V2AssetManifestItem {
3151    path: String,
3152    blob_sha256: String,
3153    bytes: u64,
3154    media_type: String,
3155    wrappers: Vec<String>,
3156    required: bool,
3157    disposition: String,
3158    leaf_hash: String,
3159    proof: crate::linkmd_v2::HamtProof,
3160}
3161
3162#[derive(Debug, Deserialize)]
3163struct V2AssetManifestPage {
3164    v: u8,
3165    commit: String,
3166    asset_root: Option<String>,
3167    assets: Vec<V2AssetManifestItem>,
3168    next_cursor: Option<String>,
3169}
3170
3171#[derive(Debug, Deserialize)]
3172struct V2SigningCandidate {
3173    seq: u64,
3174    content_root: Option<String>,
3175    asset_root: Option<String>,
3176    signing_bytes_base64: String,
3177    changes_base64: String,
3178    actor_claim_base64: String,
3179}
3180
3181#[derive(Debug, Deserialize)]
3182struct V2SigningCandidatePage {
3183    v: u8,
3184    challenge_id: String,
3185    mutation_id: String,
3186    request_hash: String,
3187    parent: V2SigningParent,
3188    candidate: V2SigningCandidate,
3189    files: Vec<V2ManifestFile>,
3190    #[serde(default)]
3191    assets: Vec<V2AssetManifestItem>,
3192    next_cursor: Option<String>,
3193    expires_at: String,
3194}
3195
3196#[derive(Debug, Deserialize)]
3197struct V2SigningParent {
3198    seq: u64,
3199    commit_hash: Option<String>,
3200}
3201
3202fn verify_v2_file_proof(root: &str, file: &V2ManifestFile) -> LinkResult<()> {
3203    let normalized = crate::linkmd_v2::normalize_path(&file.path)
3204        .map_err(|error| invalid_feed(error.to_string()))?;
3205    let components = normalized.split('/').collect::<Vec<_>>();
3206    if components.len() != file.proof.len() || !is_sha256(&file.sha256) {
3207        return Err(invalid_feed("v2 file proof has the wrong shape"));
3208    }
3209    let mut directory_root = root.to_string();
3210    for (index, step) in file.proof.iter().enumerate() {
3211        if step.directory_root != directory_root || step.component != components[index] {
3212            return Err(invalid_feed(
3213                "v2 file proof path chain differs from its manifest",
3214            ));
3215        }
3216        if !crate::linkmd_v2::verify_proof(&directory_root, &step.component, &step.proof)
3217            .map_err(|error| invalid_feed(error.to_string()))?
3218        {
3219            return Err(invalid_feed("v2 file proof failed verification"));
3220        }
3221        let entry = match &step.proof {
3222            crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry,
3223            crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
3224                return Err(invalid_feed("v2 manifest carried a non-inclusion proof"));
3225            }
3226        };
3227        if index + 1 == components.len() {
3228            if entry.kind != crate::linkmd_v2::EntryKind::Blob
3229                || entry.child_hash != file.sha256
3230                || entry.bytes != Some(file.bytes)
3231            {
3232                return Err(invalid_feed("v2 file proof leaf differs from its manifest"));
3233            }
3234        } else if entry.kind != crate::linkmd_v2::EntryKind::Tree {
3235            return Err(invalid_feed("v2 file proof traversed a non-directory"));
3236        } else {
3237            directory_root = entry.child_hash.clone();
3238        }
3239    }
3240    Ok(())
3241}
3242
3243fn v2_manifest(
3244    cfg: &HubConfig,
3245    brain: &str,
3246    pointer: Option<&V2PointerBody>,
3247) -> LinkResult<std::collections::BTreeMap<String, V2BaselineFile>> {
3248    let Some(pointer) = pointer else {
3249        return Ok(std::collections::BTreeMap::new());
3250    };
3251    let Some(root) = pointer.content_root.as_deref() else {
3252        return Ok(std::collections::BTreeMap::new());
3253    };
3254    let mut files = std::collections::BTreeMap::new();
3255    let mut after = String::new();
3256    loop {
3257        let encoded_after: String =
3258            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3259        let path = format!(
3260            "/api/hub/brains/{brain}/v2/files?commit={}&limit=500&after={encoded_after}",
3261            pointer.commit_hash
3262        );
3263        let value = ensure_ok(
3264            request_capped(
3265                cfg,
3266                "GET",
3267                &path,
3268                None,
3269                Auth::Required,
3270                MAX_FEED_RESPONSE_BYTES,
3271            )?,
3272            "v2 file manifest",
3273        )?;
3274        let page: V2ManifestPage = serde_json::from_value(value)
3275            .map_err(|_| invalid_feed("v2 file manifest has an invalid shape"))?;
3276        if page.v != 2
3277            || page.commit != pointer.commit_hash
3278            || page.content_root.as_deref() != Some(root)
3279            || page.files.len() > 500
3280        {
3281            return Err(invalid_feed(
3282                "v2 file manifest is not bound to the verified head",
3283            ));
3284        }
3285        for file in page.files {
3286            verify_v2_file_proof(root, &file)?;
3287            if files
3288                .insert(
3289                    file.path.clone(),
3290                    V2BaselineFile {
3291                        sha256: file.sha256,
3292                        bytes: file.bytes,
3293                        proof: Some(file.proof),
3294                    },
3295                )
3296                .is_some()
3297            {
3298                return Err(invalid_feed("v2 file manifest repeats a path"));
3299            }
3300            if files.len() > MAX_PUSH_FILES {
3301                return Err(invalid_feed(
3302                    "v2 file manifest exceeds the file-count bound",
3303                ));
3304            }
3305        }
3306        match page.next_cursor {
3307            None => break,
3308            Some(next) if next > after => after = next,
3309            Some(_) => return Err(invalid_feed("v2 file manifest cursor did not advance")),
3310        }
3311    }
3312    Ok(files)
3313}
3314
3315fn verify_v2_asset_proof(root: &str, item: &V2AssetManifestItem) -> LinkResult<()> {
3316    crate::linkmd_v2::normalize_path(&item.path)
3317        .map_err(|error| invalid_feed(error.to_string()))?;
3318    if !is_sha256(&item.blob_sha256)
3319        || !is_sha256(&item.leaf_hash)
3320        || item.wrappers.is_empty()
3321        || !matches!(item.disposition.as_str(), "hosted" | "withheld")
3322        || item
3323            .wrappers
3324            .iter()
3325            .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
3326    {
3327        return Err(invalid_feed("v2 asset manifest item is invalid"));
3328    }
3329    let leaf = json!({
3330        "blob_sha256": item.blob_sha256,
3331        "bytes": item.bytes,
3332        "disposition": item.disposition,
3333        "media_type": item.media_type,
3334        "path": item.path,
3335        "required": item.required,
3336        "v": 2,
3337        "wrappers": item.wrappers,
3338    });
3339    if crate::linkmd_v2::domain_hash("v2/asset-leaf", &leaf)
3340        .map_err(|error| invalid_feed(error.to_string()))?
3341        != item.leaf_hash
3342        || !crate::linkmd_v2::verify_proof_with_domain(
3343            root,
3344            &item.path,
3345            &item.proof,
3346            crate::linkmd_v2::ASSET_TREE_HASH_DOMAIN,
3347        )
3348        .map_err(|error| invalid_feed(error.to_string()))?
3349    {
3350        return Err(invalid_feed("v2 asset inclusion proof failed"));
3351    }
3352    match &item.proof {
3353        crate::linkmd_v2::HamtProof::Inclusion { entry, .. }
3354            if entry.name == item.path
3355                && entry.kind == crate::linkmd_v2::EntryKind::Blob
3356                && entry.child_hash == item.leaf_hash
3357                && entry.bytes == Some(item.bytes) =>
3358        {
3359            Ok(())
3360        }
3361        _ => Err(invalid_feed(
3362            "v2 asset proof leaf differs from its manifest",
3363        )),
3364    }
3365}
3366
3367fn v2_asset_manifest(
3368    cfg: &HubConfig,
3369    brain: &str,
3370    pointer: Option<&V2PointerBody>,
3371) -> LinkResult<std::collections::BTreeMap<String, V2BaselineAsset>> {
3372    let Some(pointer) = pointer else {
3373        return Ok(std::collections::BTreeMap::new());
3374    };
3375    let Some(root) = pointer.asset_root.as_deref() else {
3376        return Ok(std::collections::BTreeMap::new());
3377    };
3378    let mut assets = std::collections::BTreeMap::new();
3379    let mut after = String::new();
3380    loop {
3381        let encoded_after: String =
3382            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3383        let path = format!(
3384            "/api/hub/brains/{brain}/v2/assets?commit={}&limit=500&after={encoded_after}",
3385            pointer.commit_hash
3386        );
3387        let value = ensure_ok(
3388            request_capped(
3389                cfg,
3390                "GET",
3391                &path,
3392                None,
3393                Auth::Required,
3394                MAX_FEED_RESPONSE_BYTES,
3395            )?,
3396            "v2 asset manifest",
3397        )?;
3398        let page: V2AssetManifestPage = serde_json::from_value(value)
3399            .map_err(|_| invalid_feed("v2 asset manifest has an invalid shape"))?;
3400        if page.v != 2
3401            || page.commit != pointer.commit_hash
3402            || page.asset_root.as_deref() != Some(root)
3403            || page.assets.len() > 500
3404        {
3405            return Err(invalid_feed(
3406                "v2 asset manifest is not bound to the verified head",
3407            ));
3408        }
3409        for item in page.assets {
3410            verify_v2_asset_proof(root, &item)?;
3411            let path = item.path.clone();
3412            if assets
3413                .insert(
3414                    path,
3415                    V2BaselineAsset {
3416                        blob_sha256: item.blob_sha256,
3417                        bytes: item.bytes,
3418                        media_type: item.media_type,
3419                        wrappers: item.wrappers,
3420                        required: item.required,
3421                        disposition: item.disposition,
3422                        leaf_hash: item.leaf_hash,
3423                    },
3424                )
3425                .is_some()
3426            {
3427                return Err(invalid_feed("v2 asset manifest repeats a path"));
3428            }
3429            if assets.len() > MAX_PUSH_FILES {
3430                return Err(invalid_feed(
3431                    "v2 asset manifest exceeds the item-count bound",
3432                ));
3433            }
3434        }
3435        match page.next_cursor {
3436            None => break,
3437            Some(next) if next > after => after = next,
3438            Some(_) => return Err(invalid_feed("v2 asset manifest cursor did not advance")),
3439        }
3440    }
3441    Ok(assets)
3442}
3443
3444fn v2_asset_record(asset: &V2BaselineAsset, path: &str) -> crate::AssetRecord {
3445    crate::AssetRecord {
3446        path: path.to_string(),
3447        sha256: asset.blob_sha256.clone(),
3448        bytes: asset.bytes,
3449        media_type: asset.media_type.clone(),
3450        wrappers: asset.wrappers.clone(),
3451        required: asset.required,
3452    }
3453}
3454
3455fn v2_asset_resumes_hosting(
3456    remote: Option<&V2BaselineAsset>,
3457    path: &str,
3458    record: &crate::AssetRecord,
3459    disposition: &str,
3460) -> bool {
3461    remote.is_some_and(|asset| {
3462        asset.disposition == "withheld"
3463            && disposition == "hosted"
3464            && v2_asset_record(asset, path) == *record
3465    })
3466}
3467
3468fn v2_asset_record_manifest_bytes(
3469    assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
3470) -> LinkResult<Vec<u8>> {
3471    let mut bytes = Vec::new();
3472    for (path, asset) in assets {
3473        if asset.path != *path {
3474            return Err(invalid_feed(
3475                "local asset manifest key differs from its record path",
3476            ));
3477        }
3478        serde_json::to_writer(&mut bytes, asset)
3479            .map_err(|_| invalid_feed("could not materialize merged v2 assets.jsonl"))?;
3480        bytes.push(b'\n');
3481    }
3482    Ok(bytes)
3483}
3484
3485fn v2_local_asset_records(
3486    store: &Store,
3487) -> LinkResult<std::collections::BTreeMap<String, crate::AssetRecord>> {
3488    Ok(crate::assets::read_manifest(store)
3489        .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
3490        .into_iter()
3491        .map(|asset| (asset.path.clone(), asset))
3492        .collect())
3493}
3494
3495fn v2_asset_records_match_remote(
3496    local: &std::collections::BTreeMap<String, crate::AssetRecord>,
3497    remote: &std::collections::BTreeMap<String, V2BaselineAsset>,
3498) -> bool {
3499    local.len() == remote.len()
3500        && remote
3501            .iter()
3502            .all(|(path, asset)| local.get(path) == Some(&v2_asset_record(asset, path)))
3503}
3504
3505#[derive(Debug, Clone, PartialEq, Eq)]
3506struct V2PulledMerge<T> {
3507    records: std::collections::BTreeMap<String, T>,
3508    accept_remote: std::collections::BTreeSet<String>,
3509    conflicts: Vec<String>,
3510}
3511
3512/// Compute the local checkout that a pull may install without discarding a
3513/// local-only edit. Remote-only changes are accepted, identical concurrent
3514/// changes converge, and divergent concurrent changes are reported. The
3515/// caller can exempt kept-home coordinates from both remote installation and
3516/// conflict disclosure.
3517fn merge_v2_pulled_records<Base, Remote, Record, BaseRecord, RemoteRecord, KeepLocal>(
3518    base: &std::collections::BTreeMap<String, Base>,
3519    remote: &std::collections::BTreeMap<String, Remote>,
3520    local: &std::collections::BTreeMap<String, Record>,
3521    base_record: BaseRecord,
3522    remote_record: RemoteRecord,
3523    keep_local: KeepLocal,
3524) -> V2PulledMerge<Record>
3525where
3526    Record: Clone + Eq,
3527    BaseRecord: Fn(&Base, &str) -> Record,
3528    RemoteRecord: Fn(&Remote, &str) -> Record,
3529    KeepLocal: Fn(&str) -> bool,
3530{
3531    let paths = base
3532        .keys()
3533        .chain(remote.keys())
3534        .chain(local.keys())
3535        .cloned()
3536        .collect::<std::collections::BTreeSet<_>>();
3537    let mut records = local.clone();
3538    let mut accept_remote = std::collections::BTreeSet::new();
3539    let mut conflicts = Vec::new();
3540    for path in paths {
3541        if keep_local(&path) {
3542            continue;
3543        }
3544        let base_value = base.get(&path).map(|value| base_record(value, &path));
3545        let remote_value = remote.get(&path).map(|value| remote_record(value, &path));
3546        let local_value = local.get(&path).cloned();
3547        if local_value != base_value && remote_value != base_value && local_value != remote_value {
3548            conflicts.push(path);
3549            continue;
3550        }
3551        if local_value == base_value || local_value == remote_value {
3552            accept_remote.insert(path.clone());
3553            match remote_value {
3554                Some(value) => {
3555                    records.insert(path, value);
3556                }
3557                None => {
3558                    records.remove(&path);
3559                }
3560            }
3561        }
3562    }
3563    V2PulledMerge {
3564        records,
3565        accept_remote,
3566        conflicts,
3567    }
3568}
3569
3570fn sign_verified_v2_candidate(
3571    cfg: &HubConfig,
3572    head: &V2VerifiedHead,
3573    expected: &std::collections::BTreeMap<String, V2BaselineFile>,
3574    expected_assets: &std::collections::BTreeMap<String, V2BaselineAsset>,
3575    mutation_id: &str,
3576    request_body: &Value,
3577    challenge_value: &Value,
3578) -> LinkResult<(String, String, String)> {
3579    if head.view_kind != "full" {
3580        return Err(invalid_feed(
3581            "a scoped self-custody writer must use the proposal workflow",
3582        ));
3583    }
3584    if head.identity.custody != "self" {
3585        return Err(invalid_feed(
3586            "a hub-custodied brain unexpectedly requested an external signature",
3587        ));
3588    }
3589    let key = cfg
3590        .brain_key
3591        .as_ref()
3592        .ok_or_else(|| bad_agent_key("this self-custodied brain requires DBMD_BRAIN_KEY_FILE"))?;
3593    if key.multikey != format!("ed25519:{}", head.identity.fingerprint)
3594        || key.public_key_spki != head.identity.public_key_spki
3595    {
3596        return Err(bad_agent_key(
3597            "DBMD_BRAIN_KEY_FILE does not match the verified brain identity",
3598        ));
3599    }
3600    let challenge_id = challenge_value
3601        .get("id")
3602        .and_then(Value::as_str)
3603        .filter(|id| crate::ulid::is_ulid(id))
3604        .ok_or_else(|| invalid_feed("self-custody challenge has no canonical id"))?;
3605    let expected_endpoint = format!(
3606        "/api/hub/brains/{}/v2/signing-challenges/{challenge_id}",
3607        head.brain_id
3608    );
3609    if challenge_value
3610        .get("candidate_endpoint")
3611        .and_then(Value::as_str)
3612        != Some(expected_endpoint.as_str())
3613    {
3614        return Err(invalid_feed(
3615            "self-custody challenge candidate endpoint is not origin-bound",
3616        ));
3617    }
3618
3619    let mut files = std::collections::BTreeMap::new();
3620    let mut after = String::new();
3621    type CandidateCoordinate = (
3622        String,
3623        String,
3624        String,
3625        String,
3626        Option<String>,
3627        Option<String>,
3628        u64,
3629        Option<String>,
3630    );
3631    let mut pinned: Option<CandidateCoordinate> = None;
3632    loop {
3633        let encoded_after: String =
3634            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3635        let path = format!("{expected_endpoint}?limit=500&after={encoded_after}");
3636        let value = ensure_ok(
3637            request_capped(
3638                cfg,
3639                "GET",
3640                &path,
3641                None,
3642                Auth::Required,
3643                MAX_FEED_RESPONSE_BYTES,
3644            )?,
3645            "v2 self-custody candidate",
3646        )?;
3647        let page: V2SigningCandidatePage = serde_json::from_value(value)
3648            .map_err(|_| invalid_feed("self-custody candidate has an invalid shape"))?;
3649        if page.v != 2
3650            || page.challenge_id != challenge_id
3651            || page.mutation_id != mutation_id
3652            || page.candidate.seq != page.parent.seq + 1
3653            || page.files.len() > 500
3654            || page.expires_at.is_empty()
3655        {
3656            return Err(invalid_feed(
3657                "self-custody candidate is not bound to this mutation",
3658            ));
3659        }
3660        let coordinate = (
3661            page.request_hash.clone(),
3662            page.candidate.signing_bytes_base64.clone(),
3663            page.candidate.changes_base64.clone(),
3664            page.candidate.actor_claim_base64.clone(),
3665            page.candidate.content_root.clone(),
3666            page.candidate.asset_root.clone(),
3667            page.parent.seq,
3668            page.parent.commit_hash.clone(),
3669        );
3670        if pinned.as_ref().is_some_and(|prior| prior != &coordinate) {
3671            return Err(invalid_feed(
3672                "self-custody candidate changed between manifest pages",
3673            ));
3674        }
3675        pinned = Some(coordinate);
3676        let root = page
3677            .candidate
3678            .content_root
3679            .as_deref()
3680            .ok_or_else(|| invalid_feed("self-custody candidate has no content root"))?;
3681        for file in page.files {
3682            verify_v2_file_proof(root, &file)?;
3683            if files
3684                .insert(
3685                    file.path.clone(),
3686                    V2BaselineFile {
3687                        sha256: file.sha256,
3688                        bytes: file.bytes,
3689                        proof: Some(file.proof),
3690                    },
3691                )
3692                .is_some()
3693            {
3694                return Err(invalid_feed(
3695                    "self-custody candidate repeats a manifest path",
3696                ));
3697            }
3698            if files.len() > MAX_PUSH_FILES {
3699                return Err(invalid_feed(
3700                    "self-custody candidate exceeds the file-count bound",
3701                ));
3702            }
3703        }
3704        match page.next_cursor {
3705            None => break,
3706            Some(next) if next > after => after = next,
3707            Some(_) => {
3708                return Err(invalid_feed(
3709                    "self-custody candidate cursor did not advance",
3710                ))
3711            }
3712        }
3713    }
3714    if files.len() != expected.len()
3715        || files.iter().any(|(path, file)| {
3716            expected.get(path).is_none_or(|expected| {
3717                expected.sha256 != file.sha256 || expected.bytes != file.bytes
3718            })
3719        })
3720    {
3721        return Err(invalid_feed(
3722            "self-custody candidate contains an unexpected file mutation",
3723        ));
3724    }
3725    let mut assets = std::collections::BTreeMap::new();
3726    after.clear();
3727    loop {
3728        let encoded_after: String =
3729            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3730        let path = format!("{expected_endpoint}?kind=assets&limit=500&after={encoded_after}");
3731        let value = ensure_ok(
3732            request_capped(
3733                cfg,
3734                "GET",
3735                &path,
3736                None,
3737                Auth::Required,
3738                MAX_FEED_RESPONSE_BYTES,
3739            )?,
3740            "v2 self-custody asset candidate",
3741        )?;
3742        let page: V2SigningCandidatePage = serde_json::from_value(value)
3743            .map_err(|_| invalid_feed("self-custody asset candidate has an invalid shape"))?;
3744        let coordinate = (
3745            page.request_hash.clone(),
3746            page.candidate.signing_bytes_base64.clone(),
3747            page.candidate.changes_base64.clone(),
3748            page.candidate.actor_claim_base64.clone(),
3749            page.candidate.content_root.clone(),
3750            page.candidate.asset_root.clone(),
3751            page.parent.seq,
3752            page.parent.commit_hash.clone(),
3753        );
3754        if page.v != 2
3755            || page.challenge_id != challenge_id
3756            || page.mutation_id != mutation_id
3757            || page.assets.len() > 500
3758            || pinned.as_ref() != Some(&coordinate)
3759        {
3760            return Err(invalid_feed(
3761                "self-custody asset candidate changed or is not bound",
3762            ));
3763        }
3764        let root = page.candidate.asset_root.as_deref();
3765        if !page.assets.is_empty() && root.is_none() {
3766            return Err(invalid_feed("asset candidate has no asset root"));
3767        }
3768        for item in page.assets {
3769            verify_v2_asset_proof(root.expect("non-empty assets checked"), &item)?;
3770            if assets
3771                .insert(
3772                    item.path.clone(),
3773                    V2BaselineAsset {
3774                        blob_sha256: item.blob_sha256,
3775                        bytes: item.bytes,
3776                        media_type: item.media_type,
3777                        wrappers: item.wrappers,
3778                        required: item.required,
3779                        disposition: item.disposition,
3780                        leaf_hash: item.leaf_hash,
3781                    },
3782                )
3783                .is_some()
3784            {
3785                return Err(invalid_feed("self-custody candidate repeats an asset"));
3786            }
3787        }
3788        match page.next_cursor {
3789            None => break,
3790            Some(next) if next > after => after = next,
3791            Some(_) => {
3792                return Err(invalid_feed(
3793                    "self-custody asset candidate cursor did not advance",
3794                ))
3795            }
3796        }
3797    }
3798    if assets.len() != expected_assets.len()
3799        || assets.iter().any(|(path, asset)| {
3800            expected_assets.get(path).is_none_or(|expected| {
3801                asset.blob_sha256 != expected.blob_sha256
3802                    || asset.bytes != expected.bytes
3803                    || asset.media_type != expected.media_type
3804                    || asset.wrappers != expected.wrappers
3805                    || asset.required != expected.required
3806                    || asset.disposition != expected.disposition
3807            })
3808        })
3809    {
3810        return Err(invalid_feed(
3811            "self-custody candidate contains an unexpected asset mutation",
3812        ));
3813    }
3814    let Some((
3815        request_hash,
3816        signing_b64,
3817        changes_b64,
3818        actor_b64,
3819        root,
3820        asset_root,
3821        parent_seq,
3822        parent,
3823    )) = pinned
3824    else {
3825        return Err(invalid_feed("self-custody candidate has no manifest"));
3826    };
3827    let current_seq = head.pointer.as_ref().map_or(0, |pointer| pointer.seq);
3828    let current_commit = head
3829        .pointer
3830        .as_ref()
3831        .map(|pointer| pointer.commit_hash.clone());
3832    if parent_seq != current_seq || parent != current_commit {
3833        return Err(LinkError::RemoteAdvancedDuringSync);
3834    }
3835    let changes = STANDARD
3836        .decode(changes_b64)
3837        .map_err(|_| invalid_feed("self-custody changeset is not base64"))?;
3838    let mut expected_changes = json!({
3839        "mutation_id": mutation_id,
3840        "operations": request_body.get("operations").cloned().unwrap_or(Value::Null),
3841        "reason": request_body.get("reason").cloned().unwrap_or(Value::Null),
3842        "v": 2,
3843    });
3844    if let Some(withheld_links) = request_body.get("withheld_links") {
3845        expected_changes["withheld_links"] = withheld_links.clone();
3846    }
3847    if let Some(checkout_id) = request_body.get("checkout_id") {
3848        expected_changes["checkout_id"] = checkout_id.clone();
3849    }
3850    let expected_changes_bytes = crate::linkmd_v2::canonical_bytes(&expected_changes)
3851        .map_err(|error| invalid_feed(error.to_string()))?;
3852    if changes != expected_changes_bytes {
3853        return Err(invalid_feed(
3854            "self-custody changeset differs from the requested mutation",
3855        ));
3856    }
3857    let changes_hash = crate::linkmd_v2::domain_hash_bytes("v2/changeset", &changes)
3858        .map_err(|error| invalid_feed(error.to_string()))?;
3859    let request_value = json!({
3860        "base": request_body.get("base").cloned().unwrap_or(Value::Null),
3861        "brain": head.brain_id,
3862        "changes_sha256": changes_hash,
3863        "rebase": request_body.get("rebase").cloned().unwrap_or(Value::Null),
3864        "v": 2,
3865        "v1_bridge": Value::Null,
3866    });
3867    let expected_request_hash = crate::linkmd_v2::domain_hash("v2/request", &request_value)
3868        .map_err(|error| invalid_feed(error.to_string()))?;
3869    if request_hash != expected_request_hash {
3870        return Err(invalid_feed(
3871            "self-custody request hash differs from the requested mutation",
3872        ));
3873    }
3874    let actor = STANDARD
3875        .decode(actor_b64)
3876        .map_err(|_| invalid_feed("self-custody actor claim is not base64"))?;
3877    let actor_value: Value = serde_json::from_slice(&actor)
3878        .map_err(|_| invalid_feed("self-custody actor claim is not JSON"))?;
3879    if crate::linkmd_v2::canonical_bytes(&actor_value)
3880        .map_err(|error| invalid_feed(error.to_string()))?
3881        != actor
3882    {
3883        return Err(invalid_feed("self-custody actor claim is not canonical"));
3884    }
3885    let actor_object = actor_value
3886        .as_object()
3887        .ok_or_else(|| invalid_feed("self-custody actor claim is not an object"))?;
3888    let actor_claim = actor_object
3889        .get("claim")
3890        .ok_or_else(|| invalid_feed("self-custody actor claim body is missing"))?;
3891    let actor_public_key = actor_object
3892        .get("public_key")
3893        .and_then(Value::as_str)
3894        .ok_or_else(|| invalid_feed("self-custody actor signer is missing"))?;
3895    let actor_fingerprint = actor_object
3896        .get("fingerprint")
3897        .and_then(Value::as_str)
3898        .ok_or_else(|| invalid_feed("self-custody actor fingerprint is missing"))?;
3899    let actor_signature = actor_object
3900        .get("sig")
3901        .and_then(Value::as_str)
3902        .ok_or_else(|| invalid_feed("self-custody actor signature is missing"))?;
3903    let actor_message = crate::linkmd_v2::canonical_bytes(actor_claim)
3904        .map_err(|error| invalid_feed(error.to_string()))?;
3905    let actor_der = verify_v2_spki_signature(actor_public_key, &actor_message, actor_signature)?;
3906    let expected_actor_signer = format!("{actor_fingerprint}:{actor_public_key}");
3907    let expected_actor_root = root.clone().map(Value::String).unwrap_or(Value::Null);
3908    let expected_actor_asset_root = asset_root.clone().map(Value::String).unwrap_or(Value::Null);
3909    let impact = actor_claim
3910        .get("result")
3911        .and_then(|result| result.get("impact"))
3912        .and_then(Value::as_object);
3913    let impact_fields = [
3914        "creates",
3915        "updates",
3916        "deletes",
3917        "withdrawals",
3918        "renames",
3919        "restores",
3920        "asset_changes",
3921        "public_expansions",
3922        "executable_activations",
3923    ];
3924    let impact_is_valid = impact.is_some_and(|impact| {
3925        impact.len() == impact_fields.len() + 1
3926            && impact.get("v").and_then(Value::as_u64) == Some(1)
3927            && impact_fields
3928                .iter()
3929                .all(|field| impact.get(*field).and_then(Value::as_u64).is_some())
3930    });
3931    if format!("{:x}", Sha256::digest(&actor_der)) != actor_fingerprint
3932        || head
3933            .trust
3934            .hub_signer
3935            .as_ref()
3936            .is_some_and(|known| known != &expected_actor_signer)
3937        || actor_claim.get("mutation_id").and_then(Value::as_str) != Some(mutation_id)
3938        || actor_claim.get("request_hash").and_then(Value::as_str) != Some(request_hash.as_str())
3939        || actor_claim
3940            .get("candidate")
3941            .and_then(|candidate| candidate.get("changes_sha256"))
3942            .and_then(Value::as_str)
3943            != Some(changes_hash.as_str())
3944        || actor_claim
3945            .get("candidate")
3946            .and_then(|candidate| candidate.get("state_root"))
3947            != Some(&expected_actor_root)
3948        || actor_claim
3949            .get("candidate")
3950            .and_then(|candidate| candidate.get("asset_root"))
3951            != Some(&expected_actor_asset_root)
3952        || actor_claim
3953            .get("candidate")
3954            .and_then(|candidate| candidate.get("control_revision"))
3955            .and_then(Value::as_str)
3956            != Some(head.control_revision.as_str())
3957        || !impact_is_valid
3958    {
3959        return Err(invalid_feed(
3960            "self-custody actor claim does not bind the verified authority",
3961        ));
3962    }
3963    let actor_hash = crate::linkmd_v2::domain_hash_bytes("v2/actor-claim", &actor)
3964        .map_err(|error| invalid_feed(error.to_string()))?;
3965    let signing = STANDARD
3966        .decode(signing_b64)
3967        .map_err(|_| invalid_feed("self-custody signing bytes are not base64"))?;
3968    let signing_value: Value = serde_json::from_slice(&signing)
3969        .map_err(|_| invalid_feed("self-custody signing bytes are not JSON"))?;
3970    if crate::linkmd_v2::canonical_bytes(&signing_value)
3971        .map_err(|error| invalid_feed(error.to_string()))?
3972        != signing
3973    {
3974        return Err(invalid_feed("self-custody signing bytes are not canonical"));
3975    }
3976    let pointer = head.pointer.as_ref();
3977    let expected_materializer = pointer
3978        .map(|value| value.materializer.as_str())
3979        .unwrap_or("dbmd-projection-v1");
3980    let expected_parent_commit = request_body
3981        .get("base")
3982        .and_then(|base| base.get("commit_hash"))
3983        .cloned()
3984        .unwrap_or(Value::Null);
3985    let expected_parent_root = request_body
3986        .get("base")
3987        .and_then(|base| base.get("content_root"))
3988        .cloned()
3989        .unwrap_or(Value::Null);
3990    let expected_state_root = root.clone().map(Value::String).unwrap_or(Value::Null);
3991    let expected_parent_asset_root = request_body
3992        .get("base")
3993        .and_then(|base| base.get("asset_root"))
3994        .cloned()
3995        .unwrap_or(Value::Null);
3996    let expected_asset_root = asset_root.map(Value::String).unwrap_or(Value::Null);
3997    let expected_prev_entry = pointer
3998        .map(|value| Value::String(value.feed_hash.clone()))
3999        .unwrap_or(Value::Null);
4000    let expected_signer_epoch = u64::try_from(head.identity.previous.len())
4001        .map_err(|_| invalid_feed("brain identity history is too large"))?
4002        + 1;
4003    if signing_value.get("v").and_then(Value::as_u64) != Some(2)
4004        || signing_value.get("seq").and_then(Value::as_u64) != Some(current_seq + 1)
4005        || signing_value.get("signer_epoch").and_then(Value::as_u64) != Some(expected_signer_epoch)
4006        || signing_value.get("brain").and_then(Value::as_str) != Some(key.multikey.as_str())
4007        || signing_value.get("public_key").and_then(Value::as_str)
4008            != Some(key.public_key_spki.as_str())
4009        || signing_value.get("parent_commit") != Some(&expected_parent_commit)
4010        || signing_value.get("parent_root") != Some(&expected_parent_root)
4011        || signing_value.get("state_root") != Some(&expected_state_root)
4012        || signing_value.get("parent_asset_root") != Some(&expected_parent_asset_root)
4013        || signing_value.get("asset_root") != Some(&expected_asset_root)
4014        || signing_value.get("materializer").and_then(Value::as_str) != Some(expected_materializer)
4015        || signing_value.get("changes_sha256").and_then(Value::as_str)
4016            != Some(changes_hash.as_str())
4017        || signing_value.get("actor_ref").and_then(Value::as_str) != Some(actor_hash.as_str())
4018        || signing_value
4019            .get("control_revision")
4020            .and_then(Value::as_str)
4021            != Some(head.control_revision.as_str())
4022        || signing_value.get("prev_entry_hash") != Some(&expected_prev_entry)
4023        || signing_value.get("v1_bridge") != Some(&Value::Null)
4024        || signing_value.get("op").and_then(Value::as_str) != Some("changeset")
4025    {
4026        return Err(invalid_feed(
4027            "self-custody signing bytes do not bind the verified candidate",
4028        ));
4029    }
4030    let pair = agent_keypair(&key.pkcs8)?;
4031    let signature = URL_SAFE_NO_PAD.encode(pair.sign(&signing).as_ref());
4032    Ok((challenge_id.to_string(), signature, expected_actor_signer))
4033}
4034
4035fn v2_baseline_name(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<String> {
4036    let origin = normalized_origin(&cfg.hub)?;
4037    let absolute = if checkout.is_absolute() {
4038        checkout.to_path_buf()
4039    } else {
4040        std::env::current_dir()?.join(checkout)
4041    };
4042    Ok(format!(
4043        "sync-{}.json",
4044        content_sha256(format!("{origin}\0{brain}\0{}", absolute.display()).as_bytes())
4045    ))
4046}
4047
4048fn v2_checkout_id(existing: Option<&str>) -> LinkResult<String> {
4049    if let Some(value) = existing {
4050        if !is_sha256(value) {
4051            return Err(invalid_feed("v2 checkout pseudonym is invalid"));
4052        }
4053        return Ok(value.to_string());
4054    }
4055    use ring::rand::SecureRandom as _;
4056    let mut random = [0_u8; 32];
4057    ring::rand::SystemRandom::new()
4058        .fill(&mut random)
4059        .map_err(|_| invalid_feed("could not generate a checkout pseudonym"))?;
4060    Ok(random.iter().map(|byte| format!("{byte:02x}")).collect())
4061}
4062
4063#[cfg(any(unix, windows))]
4064fn lock_v2_sync_operation(cfg: &HubConfig, brain: &str) -> LinkResult<TrustLock> {
4065    let directory = open_trust_dir(cfg)?;
4066    let origin = normalized_origin(&cfg.hub)?;
4067    let name = format!(
4068        "operation-{}.lock",
4069        content_sha256(format!("{origin}\0{brain}").as_bytes())
4070    );
4071    lock_trust_name(&directory, &name)
4072}
4073
4074#[cfg(not(any(unix, windows)))]
4075fn lock_v2_sync_operation(_cfg: &HubConfig, _brain: &str) -> LinkResult<()> {
4076    Err(LinkError::UnsupportedPlatform {
4077        operation: "serialized link.md v2 sync",
4078    })
4079}
4080
4081fn same_v2_head(left: &V2VerifiedHead, right: &V2VerifiedHead) -> bool {
4082    left.brain_id == right.brain_id
4083        && left.view_kind == right.view_kind
4084        && left.view_revision == right.view_revision
4085        && left.control_revision == right.control_revision
4086        && match (&left.pointer, &right.pointer) {
4087            (None, None) => true,
4088            (Some(left), Some(right)) => {
4089                left.seq == right.seq
4090                    && left.commit_hash == right.commit_hash
4091                    && left.content_root == right.content_root
4092                    && left.asset_root == right.asset_root
4093                    && left.feed_hash == right.feed_hash
4094            }
4095            _ => false,
4096        }
4097}
4098
4099fn scoped_projection_bytes(brain: &str) -> Vec<u8> {
4100    format!(
4101        "---\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"
4102    )
4103    .into_bytes()
4104}
4105
4106fn scoped_projection_sha256(brain: &str) -> String {
4107    content_sha256(&scoped_projection_bytes(brain))
4108}
4109
4110#[derive(Deserialize)]
4111struct LocalScopedViewMarker {
4112    v: u8,
4113    kind: String,
4114    authoritative: bool,
4115    brain: String,
4116    projection_sha256: String,
4117}
4118
4119/// True only when this store carries the exact generated marker for a local
4120/// link.md scoped view. This is presentation context, never authorization;
4121/// the hub remains the only authority for reads and writes.
4122pub fn has_verified_local_scoped_view(store: &Store) -> bool {
4123    let marker = store
4124        .read_bounded(Path::new(".dbmd/view.json"), 64 * 1024)
4125        .ok()
4126        .and_then(|bytes| serde_json::from_slice::<LocalScopedViewMarker>(&bytes).ok());
4127    let Some(marker) = marker else {
4128        return false;
4129    };
4130    if marker.v != 1
4131        || marker.kind != "link.md-scoped-view"
4132        || marker.authoritative
4133        || !crate::ulid::is_ulid(&marker.brain)
4134        || marker.projection_sha256 != scoped_projection_sha256(&marker.brain)
4135    {
4136        return false;
4137    }
4138    store
4139        .read_bounded(Path::new("DB.md"), crate::parser::MAX_DBMD_FILE_BYTES)
4140        .is_ok_and(|bytes| content_sha256(&bytes) == marker.projection_sha256)
4141}
4142
4143fn scoped_view_metadata(head: &V2VerifiedHead, files: usize) -> LinkResult<Vec<u8>> {
4144    let mut bytes = serde_json::to_vec_pretty(&json!({
4145        "v": 1,
4146        "kind": "link.md-scoped-view",
4147        "authoritative": false,
4148        "brain": head.brain_id,
4149        "view_revision": head.view_revision,
4150        "head_seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
4151        "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
4152        "content_root": head.pointer.as_ref().and_then(|pointer| pointer.content_root.as_ref()),
4153        "visible_files": files,
4154        "projection_sha256": scoped_projection_sha256(&head.brain_id),
4155    }))
4156    .map_err(|_| invalid_feed("could not serialize scoped view metadata"))?;
4157    bytes.push(b'\n');
4158    Ok(bytes)
4159}
4160
4161fn refresh_scoped_view_marker(
4162    store: &Store,
4163    head: &V2VerifiedHead,
4164    files: usize,
4165) -> LinkResult<()> {
4166    if head.view_kind == "scoped" {
4167        store.write_atomic(
4168            Path::new(".dbmd/view.json"),
4169            &scoped_view_metadata(head, files)?,
4170        )?;
4171    }
4172    Ok(())
4173}
4174
4175fn ensure_v2_view_compatible(
4176    head: &V2VerifiedHead,
4177    baseline: Option<&V2SyncBaseline>,
4178) -> LinkResult<()> {
4179    let Some(baseline) = baseline else {
4180        return Ok(());
4181    };
4182    match (
4183        baseline.view_kind.as_deref(),
4184        baseline.view_revision.as_deref(),
4185    ) {
4186        (None, None) if head.view_kind == "full" => Ok(()),
4187        (Some(kind), Some(revision))
4188            if kind == head.view_kind && revision == head.view_revision =>
4189        {
4190            Ok(())
4191        }
4192        _ => Err(LinkError::ScopedViewChanged),
4193    }
4194}
4195
4196fn ensure_established_v2_checkout_opened(
4197    head: &V2VerifiedHead,
4198    baseline: Option<&V2SyncBaseline>,
4199    opened: bool,
4200) -> LinkResult<()> {
4201    if baseline.is_none() || opened {
4202        return Ok(());
4203    }
4204    if head.view_kind == "scoped" {
4205        return Err(LinkError::ScopedProjectionModified);
4206    }
4207    Err(LinkError::InvalidPack {
4208        message: "the established v2 checkout is no longer a valid db.md store; repair its DB.md before syncing".to_string(),
4209    })
4210}
4211
4212fn remove_scoped_projection(
4213    head: &V2VerifiedHead,
4214    baseline: Option<&V2SyncBaseline>,
4215    view: &mut V2LocalView,
4216) -> LinkResult<()> {
4217    if head.view_kind != "scoped" {
4218        return Ok(());
4219    }
4220    let expected = scoped_projection_sha256(&head.brain_id);
4221    if baseline
4222        .and_then(|state| state.projection_sha256.as_deref())
4223        .is_some_and(|pinned| pinned != expected)
4224    {
4225        return Err(LinkError::ScopedViewChanged);
4226    }
4227    if view.riding.get("DB.md").map(|(sha256, _)| sha256.as_str()) != Some(expected.as_str()) {
4228        return Err(LinkError::ScopedProjectionModified);
4229    }
4230    view.riding.remove("DB.md");
4231    view.eligibility.remove("DB.md");
4232    Ok(())
4233}
4234
4235fn local_view_for_v2_push(
4236    store: &Store,
4237    head: &V2VerifiedHead,
4238    baseline: Option<&V2SyncBaseline>,
4239    carried: Option<V2LocalView>,
4240) -> LinkResult<V2LocalView> {
4241    match carried {
4242        // The pull half already verified and removed the generated scoped
4243        // projection before constructing its in-memory handoff. Requiring it
4244        // a second time would turn every scoped bidirectional write into a
4245        // false tamper refusal.
4246        Some(view) => Ok(view),
4247        None => {
4248            let mut view = v2_local_files(store)?;
4249            remove_scoped_projection(head, baseline, &mut view)?;
4250            Ok(view)
4251        }
4252    }
4253}
4254
4255fn files_for_v2_view(
4256    head: &V2VerifiedHead,
4257    mut files: std::collections::BTreeMap<String, V2BaselineFile>,
4258) -> std::collections::BTreeMap<String, V2BaselineFile> {
4259    if head.view_kind == "scoped" {
4260        // Even when a grant explicitly includes canonical DB.md, a partial
4261        // checkout uses a generated marker. Canonical validation remains a
4262        // server-side operation over the complete brain.
4263        files.remove("DB.md");
4264    }
4265    files
4266}
4267
4268fn parse_v2_baseline(cfg: &HubConfig, brain: &str, bytes: &[u8]) -> LinkResult<V2SyncBaseline> {
4269    let baseline: V2SyncBaseline =
4270        serde_json::from_slice(bytes).map_err(|_| invalid_feed("v2 sync baseline is corrupt"))?;
4271    if baseline.v != 2
4272        || baseline.origin != normalized_origin(&cfg.hub)?
4273        || baseline.brain != brain
4274        || baseline
4275            .commit_hash
4276            .as_deref()
4277            .is_some_and(|hash| !is_sha256(hash))
4278        || baseline
4279            .content_root
4280            .as_deref()
4281            .is_some_and(|hash| !is_sha256(hash))
4282        || baseline
4283            .asset_root
4284            .as_deref()
4285            .is_some_and(|hash| !is_sha256(hash))
4286        || baseline
4287            .local_policy_digest
4288            .as_deref()
4289            .is_some_and(|hash| !is_sha256(hash))
4290        || baseline
4291            .view_kind
4292            .as_deref()
4293            .is_some_and(|kind| !matches!(kind, "full" | "scoped"))
4294        || baseline
4295            .view_revision
4296            .as_deref()
4297            .is_some_and(|hash| !is_sha256(hash))
4298        || baseline
4299            .projection_sha256
4300            .as_deref()
4301            .is_some_and(|hash| !is_sha256(hash))
4302        || (baseline.view_kind.as_deref() == Some("scoped")
4303            && (baseline.view_revision.is_none() || baseline.projection_sha256.is_none()))
4304        || baseline.files.len() > MAX_PUSH_FILES
4305        || baseline.assets.len() > MAX_PUSH_FILES
4306        || baseline.local_eligibility.len() > MAX_PUSH_FILES
4307        || baseline.remote_copy_remains.len() > MAX_PUSH_FILES
4308        || baseline.files.iter().any(|(path, file)| {
4309            crate::linkmd_v2::normalize_path(path).is_err()
4310                || !is_sha256(&file.sha256)
4311                || file.bytes > MAX_STORE_BYTES
4312        })
4313        || baseline.assets.iter().any(|(path, asset)| {
4314            crate::linkmd_v2::normalize_path(path).is_err()
4315                || !is_sha256(&asset.blob_sha256)
4316                || !is_sha256(&asset.leaf_hash)
4317                || asset.bytes > MAX_STORE_BYTES
4318                || !matches!(asset.disposition.as_str(), "hosted" | "withheld")
4319                || asset.wrappers.is_empty()
4320                || asset
4321                    .wrappers
4322                    .iter()
4323                    .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
4324        })
4325        || baseline
4326            .local_eligibility
4327            .keys()
4328            .chain(baseline.remote_copy_remains.keys())
4329            .any(|path| crate::linkmd_v2::normalize_path(path).is_err())
4330        || baseline
4331            .remote_copy_remains
4332            .values()
4333            .any(|hash| !is_sha256(hash))
4334        || baseline
4335            .checkout_id
4336            .as_deref()
4337            .is_some_and(|checkout_id| !is_sha256(checkout_id))
4338    {
4339        return Err(invalid_feed("v2 sync baseline failed validation"));
4340    }
4341    Ok(baseline)
4342}
4343
4344#[cfg(unix)]
4345fn load_v2_baseline(
4346    cfg: &HubConfig,
4347    brain: &str,
4348    checkout: &Path,
4349) -> LinkResult<Option<V2SyncBaseline>> {
4350    use std::os::fd::{AsRawFd as _, FromRawFd as _};
4351    let directory = open_trust_dir(cfg)?;
4352    let name_string = v2_baseline_name(cfg, brain, checkout)?;
4353    let _lock = lock_trust_name(&directory, &name_string)?;
4354    let name = c_name(name_string.as_bytes(), &name_string)?;
4355    let fd = unsafe {
4356        libc::openat(
4357            directory.as_raw_fd(),
4358            name.as_ptr(),
4359            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4360        )
4361    };
4362    if fd < 0 {
4363        let error = std::io::Error::last_os_error();
4364        return if error.kind() == std::io::ErrorKind::NotFound {
4365            Ok(None)
4366        } else {
4367            Err(LinkError::UnsafePath { path: name_string })
4368        };
4369    }
4370    let file = unsafe { std::fs::File::from_raw_fd(fd) };
4371    let mut bytes = Vec::new();
4372    file.take(MAX_FEED_RESPONSE_BYTES + 1)
4373        .read_to_end(&mut bytes)?;
4374    if bytes.len() as u64 > MAX_FEED_RESPONSE_BYTES {
4375        return Err(invalid_feed("v2 sync baseline is oversized"));
4376    }
4377    Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?))
4378}
4379
4380#[cfg(windows)]
4381fn load_v2_baseline(
4382    cfg: &HubConfig,
4383    brain: &str,
4384    checkout: &Path,
4385) -> LinkResult<Option<V2SyncBaseline>> {
4386    let directory = open_trust_dir(cfg)?;
4387    let name = v2_baseline_name(cfg, brain, checkout)?;
4388    let _lock = lock_trust_name(&directory, &name)?;
4389    let mut reader = crate::fsx::BoundedDirReader::from_root(&directory)?;
4390    match reader.read(Path::new(&name), MAX_FEED_RESPONSE_BYTES) {
4391        Ok(bytes) => Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?)),
4392        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
4393        Err(_) => Err(LinkError::UnsafePath { path: name }),
4394    }
4395}
4396
4397#[cfg(not(any(unix, windows)))]
4398fn load_v2_baseline(
4399    _cfg: &HubConfig,
4400    _brain: &str,
4401    _checkout: &Path,
4402) -> LinkResult<Option<V2SyncBaseline>> {
4403    Err(LinkError::UnsupportedPlatform {
4404        operation: "verified link.md v2 baseline",
4405    })
4406}
4407
4408#[cfg(unix)]
4409fn save_v2_baseline(
4410    cfg: &HubConfig,
4411    brain: &str,
4412    checkout: &Path,
4413    baseline: &V2SyncBaseline,
4414) -> LinkResult<()> {
4415    use std::os::fd::{AsRawFd as _, FromRawFd as _};
4416    let directory = open_trust_dir(cfg)?;
4417    let name_string = v2_baseline_name(cfg, brain, checkout)?;
4418    let _lock = lock_trust_name(&directory, &name_string)?;
4419    let name = c_name(name_string.as_bytes(), &name_string)?;
4420    let mut bytes = serde_json::to_vec(baseline)
4421        .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
4422    bytes.push(b'\n');
4423    let temp_string = format!(
4424        ".{name_string}.tmp.{}-{}",
4425        std::process::id(),
4426        std::time::SystemTime::now()
4427            .duration_since(std::time::UNIX_EPOCH)
4428            .unwrap_or_default()
4429            .as_nanos()
4430    );
4431    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
4432    let fd = unsafe {
4433        libc::openat(
4434            directory.as_raw_fd(),
4435            temp.as_ptr(),
4436            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4437            0o600,
4438        )
4439    };
4440    if fd < 0 {
4441        return Err(std::io::Error::last_os_error().into());
4442    }
4443    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
4444    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
4445        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4446        return Err(error.into());
4447    }
4448    drop(file);
4449    if unsafe {
4450        libc::renameat(
4451            directory.as_raw_fd(),
4452            temp.as_ptr(),
4453            directory.as_raw_fd(),
4454            name.as_ptr(),
4455        )
4456    } != 0
4457    {
4458        let error = std::io::Error::last_os_error();
4459        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4460        return Err(error.into());
4461    }
4462    directory.sync_all()?;
4463    Ok(())
4464}
4465
4466#[cfg(windows)]
4467fn save_v2_baseline(
4468    cfg: &HubConfig,
4469    brain: &str,
4470    checkout: &Path,
4471    baseline: &V2SyncBaseline,
4472) -> LinkResult<()> {
4473    let directory = open_trust_dir(cfg)?;
4474    let name = v2_baseline_name(cfg, brain, checkout)?;
4475    let _lock = lock_trust_name(&directory, &name)?;
4476    let mut bytes = serde_json::to_vec(baseline)
4477        .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
4478    bytes.push(b'\n');
4479    crate::fsx::write_atomic_beneath(&directory, Path::new(&name), &bytes, false, true)?;
4480    Ok(())
4481}
4482
4483#[cfg(not(any(unix, windows)))]
4484fn save_v2_baseline(
4485    _cfg: &HubConfig,
4486    _brain: &str,
4487    _checkout: &Path,
4488    _baseline: &V2SyncBaseline,
4489) -> LinkResult<()> {
4490    Err(LinkError::UnsupportedPlatform {
4491        operation: "verified link.md v2 baseline",
4492    })
4493}
4494
4495fn v2_baseline_from_head(
4496    cfg: &HubConfig,
4497    head: &V2VerifiedHead,
4498    files: std::collections::BTreeMap<String, V2BaselineFile>,
4499    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
4500    local: Option<&V2LocalView>,
4501    checkout_id: Option<&str>,
4502) -> LinkResult<V2SyncBaseline> {
4503    let mut local_eligibility = local
4504        .map(|view| view.eligibility.clone())
4505        .unwrap_or_default();
4506    if let Some(view) = local {
4507        for path in files.keys() {
4508            local_eligibility
4509                .entry(path.clone())
4510                .or_insert_with(|| !view.policy.keeps_home(path));
4511        }
4512    }
4513    let remote_copy_remains = local_eligibility
4514        .iter()
4515        .filter(|(_, riding)| !**riding)
4516        .filter_map(|(path, _)| {
4517            files
4518                .get(path)
4519                .map(|file| (path.clone(), file.sha256.clone()))
4520        })
4521        .collect();
4522    Ok(V2SyncBaseline {
4523        v: 2,
4524        origin: normalized_origin(&cfg.hub)?,
4525        brain: head.brain_id.clone(),
4526        checkout_id: Some(v2_checkout_id(checkout_id)?),
4527        head_seq: Some(head.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
4528        commit_hash: head
4529            .pointer
4530            .as_ref()
4531            .map(|pointer| pointer.commit_hash.clone()),
4532        content_root: head
4533            .pointer
4534            .as_ref()
4535            .and_then(|pointer| pointer.content_root.clone()),
4536        asset_root: head
4537            .pointer
4538            .as_ref()
4539            .and_then(|pointer| pointer.asset_root.clone()),
4540        assets,
4541        view_kind: Some(head.view_kind.clone()),
4542        view_revision: Some(head.view_revision.clone()),
4543        projection_sha256: (head.view_kind == "scoped")
4544            .then(|| scoped_projection_sha256(&head.brain_id)),
4545        files,
4546        local_policy_digest: local.map(|view| view.policy.digest.clone()),
4547        local_eligibility,
4548        remote_copy_remains,
4549    })
4550}
4551
4552fn v2_local_files(store: &Store) -> LinkResult<V2LocalView> {
4553    let policy = crate::linkmd_sync_policy::load(store)
4554        .map_err(|message| LinkError::InvalidPack { message })?;
4555    let asset_paths = crate::assets::read_manifest(store)
4556        .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
4557        .into_iter()
4558        .map(|asset| asset.path)
4559        .collect::<std::collections::BTreeSet<_>>();
4560    let mut result = std::collections::BTreeMap::new();
4561    let mut eligibility = std::collections::BTreeMap::new();
4562    let mut riding_links = Vec::<(String, Vec<String>)>::new();
4563    let mut total = 0_u64;
4564    let mut paths = vec![PathBuf::from("DB.md")];
4565    paths.extend(store.walk()?);
4566    for relative in paths {
4567        let path = relative.to_string_lossy().replace('\\', "/");
4568        // v2 catalogs and asset inventory are materialized/signed separately.
4569        if matches!(path.as_str(), "assets.jsonl" | "index.md" | "index.jsonl") {
4570            continue;
4571        }
4572        if asset_paths.contains(&path) {
4573            continue;
4574        }
4575        crate::linkmd_v2::normalize_path(&path).map_err(|error| LinkError::UnsafePath {
4576            path: error.to_string(),
4577        })?;
4578        let riding = !policy.keeps_home(&path);
4579        eligibility.insert(path.clone(), riding);
4580        if !riding {
4581            continue;
4582        }
4583        let remaining = MAX_STORE_BYTES.saturating_sub(total);
4584        let bytes = store.read_bounded(&relative, remaining)?;
4585        total = total
4586            .checked_add(bytes.len() as u64)
4587            .ok_or_else(|| LinkError::PushTooLarge {
4588                detail: "v2 local byte count overflow".to_string(),
4589            })?;
4590        if total > MAX_STORE_BYTES {
4591            return Err(LinkError::PushTooLarge {
4592                detail: format!("{total} uncompressed bytes"),
4593            });
4594        }
4595        if std::str::from_utf8(&bytes).is_err() {
4596            return Err(LinkError::NotUtf8 { path });
4597        }
4598        let text = std::str::from_utf8(&bytes).expect("UTF-8 was checked");
4599        riding_links.push((path.clone(), crate::store::extract_edge_targets(text)));
4600        result.insert(path, (content_sha256(&bytes), bytes.len() as u64));
4601    }
4602    let kept_home = eligibility
4603        .iter()
4604        .filter(|(_, riding)| !**riding)
4605        .map(|(path, _)| path.clone())
4606        .collect::<std::collections::BTreeSet<_>>();
4607    let mut withheld_links = riding_links
4608        .into_iter()
4609        .flat_map(|(source, targets)| {
4610            let kept_home = &kept_home;
4611            targets.into_iter().filter_map(move |target| {
4612                let target = format!("{target}.md");
4613                kept_home.contains(&target).then_some(V2WithheldLink {
4614                    source: source.clone(),
4615                    target,
4616                })
4617            })
4618        })
4619        .collect::<Vec<_>>();
4620    withheld_links.sort();
4621    withheld_links.dedup();
4622    Ok(V2LocalView {
4623        riding: result,
4624        eligibility,
4625        policy,
4626        withheld_links,
4627    })
4628}
4629
4630#[derive(Debug, Deserialize)]
4631struct V2DownloadItem {
4632    path: String,
4633    sha256: String,
4634    bytes: u64,
4635    url: String,
4636    method: String,
4637}
4638
4639#[derive(Debug, Deserialize)]
4640struct V2DownloadWindow {
4641    v: u8,
4642    commit: String,
4643    downloads: Vec<V2DownloadItem>,
4644}
4645
4646#[derive(Debug, Deserialize)]
4647struct V2BulkStreamHeader {
4648    v: u8,
4649    path: String,
4650    sha256: String,
4651    bytes: u64,
4652}
4653
4654fn parse_v2_bulk_stream(
4655    bytes: &[u8],
4656    expected: &[(&String, &V2BaselineFile)],
4657) -> LinkResult<Vec<(String, Vec<u8>)>> {
4658    if !bytes.starts_with(V2_BULK_STREAM_MAGIC) {
4659        return Err(invalid_feed("v2 bulk stream has an invalid magic"));
4660    }
4661    let mut cursor = V2_BULK_STREAM_MAGIC.len();
4662    let mut result = Vec::with_capacity(expected.len());
4663    for (expected_path, expected_file) in expected {
4664        let length_bytes = bytes
4665            .get(cursor..cursor + 4)
4666            .ok_or_else(|| invalid_feed("v2 bulk stream ended before a frame header"))?;
4667        cursor += 4;
4668        let header_len = u32::from_be_bytes(length_bytes.try_into().expect("four bytes")) as usize;
4669        if header_len == 0 || header_len > 4 * 1024 {
4670            return Err(invalid_feed("v2 bulk stream has an invalid frame length"));
4671        }
4672        let header_bytes = bytes
4673            .get(cursor..cursor + header_len)
4674            .ok_or_else(|| invalid_feed("v2 bulk stream ended inside a frame header"))?;
4675        cursor += header_len;
4676        let header: V2BulkStreamHeader = serde_json::from_slice(header_bytes)
4677            .map_err(|_| invalid_feed("v2 bulk stream has an invalid frame header"))?;
4678        if header.v != 2
4679            || &header.path != *expected_path
4680            || header.sha256 != expected_file.sha256
4681            || header.bytes != expected_file.bytes
4682            || header.bytes > V2_BULK_STREAM_CONTENT_BYTES
4683        {
4684            return Err(invalid_feed(
4685                "v2 bulk stream frame differs from its proven manifest entry",
4686            ));
4687        }
4688        let body_len = usize::try_from(header.bytes)
4689            .map_err(|_| invalid_feed("v2 bulk stream frame length overflows this platform"))?;
4690        let body = bytes
4691            .get(cursor..cursor + body_len)
4692            .ok_or_else(|| invalid_feed("v2 bulk stream ended inside file bytes"))?;
4693        cursor += body_len;
4694        if content_sha256(body) != header.sha256 {
4695            return Err(invalid_feed(
4696                "v2 bulk stream file differs from its proven manifest entry",
4697            ));
4698        }
4699        result.push((header.path, body.to_vec()));
4700    }
4701    if bytes.get(cursor..cursor + 4) != Some(&[0, 0, 0, 0]) {
4702        return Err(invalid_feed("v2 bulk stream has no exact end marker"));
4703    }
4704    cursor += 4;
4705    if cursor != bytes.len() {
4706        return Err(invalid_feed("v2 bulk stream carries trailing data"));
4707    }
4708    Ok(result)
4709}
4710
4711fn download_v2_bulk_stream(
4712    cfg: &HubConfig,
4713    brain: &str,
4714    pointer: &V2PointerBody,
4715    pending: &[(&String, &V2BaselineFile)],
4716) -> LinkResult<Vec<(String, Vec<u8>)>> {
4717    let claims = pending
4718        .iter()
4719        .map(|(path, file)| {
4720            Ok(json!({
4721                "path": path,
4722                "sha256": file.sha256,
4723                "bytes": file.bytes,
4724                "proof": file.proof.as_ref().ok_or_else(|| {
4725                    invalid_feed("v2 manifest omitted a bulk-stream proof")
4726                })?,
4727            }))
4728        })
4729        .collect::<LinkResult<Vec<_>>>()?;
4730    let raw = request_raw(
4731        cfg,
4732        "POST",
4733        &format!("/api/hub/brains/{brain}/v2/stream"),
4734        Some(&json!({
4735            "commit": pointer.commit_hash,
4736            "files": claims,
4737        })),
4738        Auth::Required,
4739        V2_BULK_STREAM_RESPONSE_BYTES,
4740    )?;
4741    let body = ensure_raw_ok(raw, "download v2 bulk stream")?;
4742    parse_v2_bulk_stream(&body, pending)
4743}
4744
4745fn prepare_v2_downloads(
4746    cfg: &HubConfig,
4747    brain: &str,
4748    pointer: &V2PointerBody,
4749    pending: &[(&String, &V2BaselineFile)],
4750) -> LinkResult<Vec<V2DownloadItem>> {
4751    let mut result = Vec::with_capacity(pending.len());
4752    for chunk in pending.chunks(128) {
4753        let claims = chunk
4754            .iter()
4755            .map(|(path, file)| {
4756                Ok(json!({
4757                    "path": path,
4758                    "sha256": file.sha256,
4759                    "bytes": file.bytes,
4760                    "proof": file.proof.as_ref().ok_or_else(|| {
4761                        invalid_feed("v2 manifest omitted a download proof")
4762                    })?,
4763                }))
4764            })
4765            .collect::<LinkResult<Vec<_>>>()?;
4766        let value = ensure_ok(
4767            request_capped(
4768                cfg,
4769                "POST",
4770                &format!("/api/hub/brains/{brain}/v2/downloads"),
4771                Some(&json!({
4772                    "commit": pointer.commit_hash,
4773                    "files": claims,
4774                })),
4775                Auth::Required,
4776                MAX_FEED_RESPONSE_BYTES,
4777            )?,
4778            "prepare v2 blob downloads",
4779        )?;
4780        let window: V2DownloadWindow = serde_json::from_value(value)
4781            .map_err(|_| invalid_feed("v2 download window has an invalid shape"))?;
4782        if window.v != 2
4783            || window.commit != pointer.commit_hash
4784            || window.downloads.len() != chunk.len()
4785        {
4786            return Err(invalid_feed(
4787                "v2 download window is not bound to the requested files",
4788            ));
4789        }
4790        let mut by_path = window
4791            .downloads
4792            .into_iter()
4793            .map(|item| (item.path.clone(), item))
4794            .collect::<std::collections::BTreeMap<_, _>>();
4795        if by_path.len() != chunk.len() {
4796            return Err(invalid_feed("v2 download window repeats a path"));
4797        }
4798        for (path, file) in chunk {
4799            let item = by_path
4800                .remove(*path)
4801                .ok_or_else(|| invalid_feed("v2 download window omitted a path"))?;
4802            if item.method != "GET"
4803                || item.sha256 != file.sha256
4804                || item.bytes != file.bytes
4805                || item.url.is_empty()
4806            {
4807                return Err(invalid_feed(
4808                    "v2 download capability differs from its proven file",
4809                ));
4810            }
4811            result.push(item);
4812        }
4813    }
4814    Ok(result)
4815}
4816
4817fn prepare_v2_asset_downloads(
4818    cfg: &HubConfig,
4819    brain: &str,
4820    pointer: &V2PointerBody,
4821    pending: &[(&String, &V2BaselineAsset)],
4822) -> LinkResult<Vec<V2DownloadItem>> {
4823    let mut result = Vec::with_capacity(pending.len());
4824    for chunk in pending.chunks(128) {
4825        let claims = chunk
4826            .iter()
4827            .map(|(path, asset)| {
4828                json!({
4829                    "path": path,
4830                    "sha256": asset.blob_sha256,
4831                    "bytes": asset.bytes,
4832                    "leaf_hash": asset.leaf_hash,
4833                })
4834            })
4835            .collect::<Vec<_>>();
4836        let value = ensure_ok(
4837            request_capped(
4838                cfg,
4839                "POST",
4840                &format!("/api/hub/brains/{brain}/v2/assets/downloads"),
4841                Some(&json!({
4842                    "commit": pointer.commit_hash,
4843                    "assets": claims,
4844                })),
4845                Auth::Required,
4846                MAX_FEED_RESPONSE_BYTES,
4847            )?,
4848            "prepare v2 asset downloads",
4849        )?;
4850        let window: V2DownloadWindow = serde_json::from_value(value)
4851            .map_err(|_| invalid_feed("v2 asset download window has an invalid shape"))?;
4852        if window.v != 2
4853            || window.commit != pointer.commit_hash
4854            || window.downloads.len() != chunk.len()
4855        {
4856            return Err(invalid_feed(
4857                "v2 asset download window is not bound to the requested assets",
4858            ));
4859        }
4860        let mut by_path = window
4861            .downloads
4862            .into_iter()
4863            .map(|item| (item.path.clone(), item))
4864            .collect::<std::collections::BTreeMap<_, _>>();
4865        if by_path.len() != chunk.len() {
4866            return Err(invalid_feed("v2 asset download window repeats a path"));
4867        }
4868        for (path, asset) in chunk {
4869            let item = by_path
4870                .remove(*path)
4871                .ok_or_else(|| invalid_feed("v2 asset download window omitted a path"))?;
4872            if item.method != "GET"
4873                || item.sha256 != asset.blob_sha256
4874                || item.bytes != asset.bytes
4875                || item.url.is_empty()
4876            {
4877                return Err(invalid_feed(
4878                    "v2 asset download capability differs from its signed leaf",
4879                ));
4880            }
4881            result.push(item);
4882        }
4883    }
4884    Ok(result)
4885}
4886
4887fn download_v2_blob(cfg: &HubConfig, item: &V2DownloadItem) -> LinkResult<Vec<u8>> {
4888    let bytes = get_presigned(cfg, &item.url)?;
4889    if bytes.len() as u64 != item.bytes || content_sha256(&bytes) != item.sha256 {
4890        return Err(invalid_feed("v2 blob differs from its proven path entry"));
4891    }
4892    Ok(bytes)
4893}
4894
4895#[derive(Debug, Clone)]
4896struct V2StagedFile {
4897    path: String,
4898    source: PathBuf,
4899    sha256: String,
4900    bytes: u64,
4901}
4902
4903#[cfg(unix)]
4904fn v2_download_cache_dir(
4905    cfg: &HubConfig,
4906    brain: &str,
4907    pointer: &V2PointerBody,
4908) -> LinkResult<PathBuf> {
4909    v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
4910}
4911
4912#[cfg(unix)]
4913fn v2_download_cache_dir_for(
4914    cfg: &HubConfig,
4915    brain: &str,
4916    transaction: &str,
4917) -> LinkResult<PathBuf> {
4918    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
4919        return Err(invalid_feed("v2 download cache address is invalid"));
4920    }
4921    let path = cfg
4922        .state_dir
4923        .join("downloads")
4924        .join(brain)
4925        .join(transaction);
4926    let directory = open_or_create_dir_nofollow(&path)?;
4927    use std::os::fd::AsRawFd as _;
4928    if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
4929        return Err(std::io::Error::last_os_error().into());
4930    }
4931    directory.sync_all()?;
4932    Ok(path)
4933}
4934
4935#[cfg(windows)]
4936fn v2_download_cache_dir(
4937    cfg: &HubConfig,
4938    brain: &str,
4939    pointer: &V2PointerBody,
4940) -> LinkResult<PathBuf> {
4941    v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
4942}
4943
4944#[cfg(windows)]
4945fn v2_download_cache_dir_for(
4946    cfg: &HubConfig,
4947    brain: &str,
4948    transaction: &str,
4949) -> LinkResult<PathBuf> {
4950    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
4951        return Err(invalid_feed("v2 download cache address is invalid"));
4952    }
4953    let path = cfg
4954        .state_dir
4955        .join("downloads")
4956        .join(brain)
4957        .join(transaction);
4958    crate::fsx::write_atomic(&path.join(".directory"), b"v2 download cache\n")?;
4959    crate::fsx::open_directory_nofollow(&path)?;
4960    Ok(path)
4961}
4962
4963#[cfg(unix)]
4964fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
4965    use std::os::fd::AsRawFd as _;
4966    let parent = cfg.state_dir.join("downloads").join(brain);
4967    let Ok(directory) = open_existing_dir_nofollow(&parent) else {
4968        return;
4969    };
4970    let Ok(name) = c_name(transaction.as_bytes(), transaction) else {
4971        return;
4972    };
4973    let _ = remove_tree_at(directory.as_raw_fd(), &name, &parent.display().to_string());
4974    let _ = directory.sync_all();
4975}
4976
4977#[cfg(windows)]
4978fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
4979    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
4980        return;
4981    }
4982    let parent = cfg.state_dir.join("downloads").join(brain);
4983    let Ok(root) = crate::fsx::open_directory_nofollow(&parent) else {
4984        return;
4985    };
4986    let _ = crate::fsx::remove_tree_beneath(&root, Path::new(transaction));
4987}
4988
4989#[cfg(not(any(unix, windows)))]
4990fn cleanup_v2_download_cache(_cfg: &HubConfig, _brain: &str, _transaction: &str) {}
4991
4992#[cfg(not(any(unix, windows)))]
4993fn v2_download_cache_dir_for(
4994    _cfg: &HubConfig,
4995    _brain: &str,
4996    _transaction: &str,
4997) -> LinkResult<PathBuf> {
4998    Err(LinkError::UnsupportedPlatform {
4999        operation: "resumable v2 download staging",
5000    })
5001}
5002
5003#[cfg(any(unix, windows))]
5004fn cached_blob_is_exact(path: &Path, sha256: &str, bytes: u64) -> LinkResult<bool> {
5005    let file = match crate::fsx::open_regular_nofollow(path) {
5006        Ok(file) => file,
5007        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
5008        Err(error) => return Err(error.into()),
5009    };
5010    if file.metadata()?.len() != bytes {
5011        return Ok(false);
5012    }
5013    Ok(content_sha256_reader(file)? == sha256)
5014}
5015
5016#[cfg(any(unix, windows))]
5017fn cache_v2_blob_bytes(
5018    cache_dir: &Path,
5019    sha256: &str,
5020    expected_bytes: u64,
5021    bytes: &[u8],
5022) -> LinkResult<PathBuf> {
5023    if bytes.len() as u64 != expected_bytes || content_sha256(bytes) != sha256 {
5024        return Err(invalid_feed("v2 cached blob differs from its declaration"));
5025    }
5026    let path = cache_dir.join(sha256);
5027    if !cached_blob_is_exact(&path, sha256, expected_bytes)? {
5028        crate::fsx::write_atomic(&path, bytes)?;
5029    }
5030    Ok(path)
5031}
5032
5033#[cfg(not(any(unix, windows)))]
5034fn cache_v2_blob_bytes(
5035    _cache_dir: &Path,
5036    _sha256: &str,
5037    _expected_bytes: u64,
5038    _bytes: &[u8],
5039) -> LinkResult<PathBuf> {
5040    Err(LinkError::UnsupportedPlatform {
5041        operation: "resumable v2 download staging",
5042    })
5043}
5044
5045#[cfg(unix)]
5046fn download_presigned_to_cache(
5047    cfg: &HubConfig,
5048    url: &str,
5049    cache_dir: &Path,
5050    sha256: &str,
5051    expected_bytes: u64,
5052) -> LinkResult<PathBuf> {
5053    use std::os::fd::{AsRawFd as _, FromRawFd as _};
5054
5055    let target = cache_dir.join(sha256);
5056    if cached_blob_is_exact(&target, sha256, expected_bytes)? {
5057        return Ok(target);
5058    }
5059    let directory = open_existing_dir_nofollow(cache_dir)?;
5060    let mut nonce = [0_u8; 16];
5061    ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut nonce)
5062        .map_err(|_| invalid_feed("could not mint a download cache name"))?;
5063    let temp_string = format!(".download-{}", URL_SAFE_NO_PAD.encode(nonce));
5064    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
5065    let fd = unsafe {
5066        libc::openat(
5067            directory.as_raw_fd(),
5068            temp.as_ptr(),
5069            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5070            0o600,
5071        )
5072    };
5073    if fd < 0 {
5074        return Err(std::io::Error::last_os_error().into());
5075    }
5076    let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
5077    let response = match presigned_agent(cfg, url)?.get(url).call() {
5078        Ok(response) => response,
5079        Err(ureq::Error::Status(_, response)) => {
5080            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
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 _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
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 write_result = (|| -> 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) = write_result {
5116        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5117        return Err(error.into());
5118    }
5119    drop(output);
5120    if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
5121        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5122        return Err(invalid_feed(
5123            "v2 direct download failed integrity verification",
5124        ));
5125    }
5126    let target_name = c_name(sha256.as_bytes(), sha256)?;
5127    // Replace only a previously inspected cache leaf. Cache bytes are private
5128    // and content-addressed; final install re-verifies them again.
5129    if unsafe {
5130        libc::renameat(
5131            directory.as_raw_fd(),
5132            temp.as_ptr(),
5133            directory.as_raw_fd(),
5134            target_name.as_ptr(),
5135        )
5136    } != 0
5137    {
5138        let error = std::io::Error::last_os_error();
5139        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5140        return Err(error.into());
5141    }
5142    directory.sync_all()?;
5143    Ok(target)
5144}
5145
5146#[cfg(windows)]
5147fn download_presigned_to_cache(
5148    cfg: &HubConfig,
5149    url: &str,
5150    cache_dir: &Path,
5151    sha256: &str,
5152    expected_bytes: u64,
5153) -> LinkResult<PathBuf> {
5154    use std::fs::OpenOptions;
5155
5156    let target = cache_dir.join(sha256);
5157    if cached_blob_is_exact(&target, sha256, expected_bytes)? {
5158        return Ok(target);
5159    }
5160    // Keep the checked directory handle alive while the private cache path is
5161    // used. Windows opens omit FILE_SHARE_DELETE, so neither this directory nor
5162    // an ancestor can be swapped after validation.
5163    let _directory = crate::fsx::open_directory_nofollow(cache_dir)?;
5164    let temp = cache_dir.join(format!(".download-{}", crate::ulid::mint()));
5165    let mut output = OpenOptions::new()
5166        .write(true)
5167        .create_new(true)
5168        .open(&temp)?;
5169    let response = match presigned_agent(cfg, url)?.get(url).call() {
5170        Ok(response) => response,
5171        Err(ureq::Error::Status(_, response)) => {
5172            let _ = std::fs::remove_file(&temp);
5173            return Err(LinkError::Http {
5174                what: "v2 direct download",
5175                status: response.status(),
5176                message: "object store rejected the download".to_string(),
5177                code: None,
5178                details: None,
5179            });
5180        }
5181        Err(ureq::Error::Transport(error)) => {
5182            let _ = std::fs::remove_file(&temp);
5183            return Err(LinkError::Transport {
5184                hub: cfg.hub.clone(),
5185                message: error.to_string(),
5186            });
5187        }
5188    };
5189    let mut reader = response
5190        .into_reader()
5191        .take(expected_bytes.saturating_add(1));
5192    let mut digest = Sha256::new();
5193    let mut total = 0_u64;
5194    let mut buffer = [0_u8; 64 * 1024];
5195    let copied = (|| -> std::io::Result<()> {
5196        loop {
5197            let read = reader.read(&mut buffer)?;
5198            if read == 0 {
5199                break;
5200            }
5201            total = total.saturating_add(read as u64);
5202            digest.update(&buffer[..read]);
5203            output.write_all(&buffer[..read])?;
5204        }
5205        output.sync_all()
5206    })();
5207    if let Err(error) = copied {
5208        let _ = std::fs::remove_file(&temp);
5209        return Err(error.into());
5210    }
5211    drop(output);
5212    if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
5213        let _ = std::fs::remove_file(&temp);
5214        return Err(invalid_feed(
5215            "v2 direct download failed integrity verification",
5216        ));
5217    }
5218    if target.exists() {
5219        std::fs::remove_file(&target)?;
5220    }
5221    if let Err(error) = std::fs::rename(&temp, &target) {
5222        let _ = std::fs::remove_file(&temp);
5223        return Err(error.into());
5224    }
5225    Ok(target)
5226}
5227
5228#[cfg(not(any(unix, windows)))]
5229fn download_presigned_to_cache(
5230    _cfg: &HubConfig,
5231    _url: &str,
5232    _cache_dir: &Path,
5233    _sha256: &str,
5234    _expected_bytes: u64,
5235) -> LinkResult<PathBuf> {
5236    Err(LinkError::UnsupportedPlatform {
5237        operation: "resumable v2 download staging",
5238    })
5239}
5240
5241fn download_v2_blobs(
5242    cfg: &HubConfig,
5243    brain: &str,
5244    pointer: &V2PointerBody,
5245    pending: Vec<(&String, &V2BaselineFile)>,
5246) -> LinkResult<Vec<(String, Vec<u8>)>> {
5247    if pending.is_empty() {
5248        return Ok(Vec::new());
5249    }
5250    let expected_order = pending
5251        .iter()
5252        .map(|(path, _)| (*path).clone())
5253        .collect::<Vec<_>>();
5254    let mut streamed = std::collections::BTreeMap::new();
5255    let mut direct = Vec::new();
5256    let mut window = Vec::new();
5257    let mut window_bytes = 0_u64;
5258    let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
5259                 window_bytes: &mut u64,
5260                 streamed: &mut std::collections::BTreeMap<String, Vec<u8>>|
5261     -> LinkResult<()> {
5262        if window.is_empty() {
5263            return Ok(());
5264        }
5265        for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, window)? {
5266            if streamed.insert(path, bytes).is_some() {
5267                return Err(invalid_feed("v2 bulk streams repeated a path"));
5268            }
5269        }
5270        window.clear();
5271        *window_bytes = 0;
5272        Ok(())
5273    };
5274    for &(path, file) in &pending {
5275        if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
5276            flush(&mut window, &mut window_bytes, &mut streamed)?;
5277            direct.push((path, file));
5278            continue;
5279        }
5280        if window.len() == V2_BULK_STREAM_FILES
5281            || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
5282        {
5283            flush(&mut window, &mut window_bytes, &mut streamed)?;
5284        }
5285        window.push((path, file));
5286        window_bytes += file.bytes;
5287    }
5288    flush(&mut window, &mut window_bytes, &mut streamed)?;
5289
5290    let downloads = prepare_v2_downloads(cfg, brain, pointer, &direct)?;
5291    let next = std::sync::atomic::AtomicUsize::new(0);
5292    let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
5293    let mut results = std::iter::repeat_with(|| None)
5294        .take(downloads.len())
5295        .collect::<Vec<Option<LinkResult<(String, Vec<u8>)>>>>();
5296    std::thread::scope(|scope| {
5297        let (sender, receiver) = std::sync::mpsc::channel();
5298        for _ in 0..worker_count {
5299            let sender = sender.clone();
5300            let downloads = &downloads;
5301            let next = &next;
5302            scope.spawn(move || loop {
5303                let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5304                let Some(item) = downloads.get(index) else {
5305                    break;
5306                };
5307                let result = download_v2_blob(cfg, item).map(|bytes| (item.path.clone(), bytes));
5308                if sender.send((index, result)).is_err() {
5309                    break;
5310                }
5311            });
5312        }
5313        drop(sender);
5314        for (index, result) in receiver {
5315            results[index] = Some(result);
5316        }
5317    });
5318    for result in results.into_iter().map(|result| {
5319        result.ok_or_else(|| LinkError::Transport {
5320            hub: cfg.hub.clone(),
5321            message: "a bounded v2 blob worker stopped before reporting its result".to_string(),
5322        })?
5323    }) {
5324        let (path, bytes) = result?;
5325        if streamed.insert(path, bytes).is_some() {
5326            return Err(invalid_feed("v2 download lanes repeated a path"));
5327        }
5328    }
5329    expected_order
5330        .into_iter()
5331        .map(|path| {
5332            streamed
5333                .remove(&path)
5334                .map(|bytes| (path, bytes))
5335                .ok_or_else(|| invalid_feed("v2 download lanes omitted a proven path"))
5336        })
5337        .collect()
5338}
5339
5340/// Resume an exact-head materialization into the private global cache. Each
5341/// verified frame is durable before the next network window begins, so a
5342/// retry skips it and memory never grows with total changed bytes.
5343#[cfg(any(unix, windows))]
5344fn stage_v2_blobs(
5345    cfg: &HubConfig,
5346    brain: &str,
5347    pointer: &V2PointerBody,
5348    pending: Vec<(&String, &V2BaselineFile)>,
5349) -> LinkResult<Vec<V2StagedFile>> {
5350    let cache_dir = v2_download_cache_dir(cfg, brain, pointer)?;
5351    let mut staged = std::collections::BTreeMap::<String, V2StagedFile>::new();
5352    let mut direct = Vec::new();
5353    let mut window = Vec::new();
5354    let mut window_bytes = 0_u64;
5355    let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
5356                 window_bytes: &mut u64,
5357                 staged: &mut std::collections::BTreeMap<String, V2StagedFile>|
5358     -> LinkResult<()> {
5359        if window.is_empty() {
5360            return Ok(());
5361        }
5362        let missing = window
5363            .iter()
5364            .filter_map(|(path, file)| {
5365                let target = cache_dir.join(&file.sha256);
5366                match cached_blob_is_exact(&target, &file.sha256, file.bytes) {
5367                    Ok(true) => {
5368                        staged.insert(
5369                            (*path).clone(),
5370                            V2StagedFile {
5371                                path: (*path).clone(),
5372                                source: target,
5373                                sha256: file.sha256.clone(),
5374                                bytes: file.bytes,
5375                            },
5376                        );
5377                        None
5378                    }
5379                    Ok(false) => Some(Ok((*path, *file))),
5380                    Err(error) => Some(Err(error)),
5381                }
5382            })
5383            .collect::<LinkResult<Vec<_>>>()?;
5384        if !missing.is_empty() {
5385            for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, &missing)? {
5386                let file = missing
5387                    .iter()
5388                    .find_map(|(expected_path, file)| (*expected_path == &path).then_some(*file))
5389                    .ok_or_else(|| invalid_feed("v2 stream returned an unrequested cache path"))?;
5390                let source = cache_v2_blob_bytes(&cache_dir, &file.sha256, file.bytes, &bytes)?;
5391                staged.insert(
5392                    path.clone(),
5393                    V2StagedFile {
5394                        path,
5395                        source,
5396                        sha256: file.sha256.clone(),
5397                        bytes: file.bytes,
5398                    },
5399                );
5400            }
5401        }
5402        window.clear();
5403        *window_bytes = 0;
5404        Ok(())
5405    };
5406    for &(path, file) in &pending {
5407        if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
5408            flush(&mut window, &mut window_bytes, &mut staged)?;
5409            direct.push((path, file));
5410            continue;
5411        }
5412        if window.len() == V2_BULK_STREAM_FILES
5413            || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
5414        {
5415            flush(&mut window, &mut window_bytes, &mut staged)?;
5416        }
5417        window.push((path, file));
5418        window_bytes += file.bytes;
5419    }
5420    flush(&mut window, &mut window_bytes, &mut staged)?;
5421    for item in prepare_v2_downloads(cfg, brain, pointer, &direct)? {
5422        let source =
5423            download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
5424        staged.insert(
5425            item.path.clone(),
5426            V2StagedFile {
5427                path: item.path,
5428                source,
5429                sha256: item.sha256,
5430                bytes: item.bytes,
5431            },
5432        );
5433    }
5434    pending
5435        .into_iter()
5436        .map(|(path, _)| {
5437            staged
5438                .remove(path)
5439                .ok_or_else(|| invalid_feed("v2 download cache omitted a proven path"))
5440        })
5441        .collect()
5442}
5443
5444#[cfg(not(any(unix, windows)))]
5445fn stage_v2_blobs(
5446    _cfg: &HubConfig,
5447    _brain: &str,
5448    _pointer: &V2PointerBody,
5449    _pending: Vec<(&String, &V2BaselineFile)>,
5450) -> LinkResult<Vec<V2StagedFile>> {
5451    Err(LinkError::UnsupportedPlatform {
5452        operation: "resumable v2 download staging",
5453    })
5454}
5455
5456const V2_CONFLICT_BUNDLE_MAX: usize = 32;
5457const V2_CONFLICT_BUNDLE_TTL_SECS: u64 = 7 * 24 * 60 * 60;
5458const V2_CONFLICT_REMOTE_BYTES_MAX: u64 = 64 * 1024 * 1024;
5459
5460#[derive(Debug, Clone, Deserialize, Serialize)]
5461struct V2ConflictCoordinate {
5462    sha256: Option<String>,
5463    bytes: Option<u64>,
5464    file: Option<String>,
5465}
5466
5467#[derive(Debug, Clone, Deserialize, Serialize)]
5468struct V2ConflictFile {
5469    path: String,
5470    base: V2ConflictCoordinate,
5471    local: V2ConflictCoordinate,
5472    remote: V2ConflictCoordinate,
5473}
5474
5475#[derive(Debug, Clone, Deserialize, Serialize)]
5476struct V2ConflictPlan {
5477    v: u8,
5478    class: String,
5479    bundle: String,
5480    brain: String,
5481    origin: String,
5482    created_unix: u64,
5483    expires_unix: u64,
5484    base_seq: Option<u64>,
5485    base_commit: Option<String>,
5486    remote_seq: u64,
5487    remote_commit: Option<String>,
5488    remote_content_root: Option<String>,
5489    view_kind: String,
5490    view_revision: String,
5491    files: Vec<V2ConflictFile>,
5492}
5493
5494fn v2_take_remote_selection(
5495    files: &[V2ConflictFile],
5496    current: &std::collections::BTreeMap<String, V2BaselineFile>,
5497) -> LinkResult<(
5498    std::collections::BTreeMap<String, V2BaselineFile>,
5499    Vec<String>,
5500)> {
5501    let mut selected = std::collections::BTreeMap::new();
5502    let mut deleted = Vec::new();
5503    for file in files {
5504        match (&file.remote.sha256, file.remote.bytes) {
5505            (Some(sha256), Some(bytes)) => {
5506                let proven = current.get(&file.path).ok_or_else(|| {
5507                    invalid_feed("conflict remote coordinate disappeared from the exact head")
5508                })?;
5509                if proven.sha256 != *sha256 || proven.bytes != bytes {
5510                    return Err(invalid_feed(
5511                        "conflict remote coordinate differs from the exact head",
5512                    ));
5513                }
5514                if selected.insert(file.path.clone(), proven.clone()).is_some() {
5515                    return Err(invalid_feed("conflict plan repeats a remote coordinate"));
5516                }
5517            }
5518            (None, None) => {
5519                if current.contains_key(&file.path) {
5520                    return Err(invalid_feed(
5521                        "conflict remote deletion differs from the exact head",
5522                    ));
5523                }
5524                deleted.push(file.path.clone());
5525            }
5526            _ => return Err(invalid_feed("conflict remote coordinate is incomplete")),
5527        }
5528    }
5529    Ok((selected, deleted))
5530}
5531
5532fn v2_conflict_relative(bundle: &str, suffix: &str) -> PathBuf {
5533    PathBuf::from(".dbmd")
5534        .join("conflicts")
5535        .join(bundle)
5536        .join(suffix)
5537}
5538
5539fn read_historical_conflict_blob(
5540    cfg: &HubConfig,
5541    brain: &str,
5542    baseline: &V2SyncBaseline,
5543    path: &str,
5544    file: &V2BaselineFile,
5545) -> LinkResult<Option<Vec<u8>>> {
5546    let (Some(seq), Some(commit)) = (baseline.head_seq, baseline.commit_hash.as_deref()) else {
5547        return Ok(None);
5548    };
5549    if seq == 0 {
5550        return Ok(None);
5551    }
5552    let encoded_path: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
5553    let endpoint = format!(
5554        "/api/hub/brains/{brain}/v2/history/blob?seq={seq}&commit={commit}&path={encoded_path}&sha256={}",
5555        file.sha256
5556    );
5557    let raw = request_raw(cfg, "GET", &endpoint, None, Auth::Required, file.bytes)?;
5558    if raw.status == 404 || raw.status == 403 {
5559        return Ok(None);
5560    }
5561    let bytes = ensure_raw_ok(raw, "v2 conflict base")?;
5562    if bytes.len() as u64 != file.bytes || content_sha256(&bytes) != file.sha256 {
5563        return Err(invalid_feed(
5564            "v2 conflict base failed integrity verification",
5565        ));
5566    }
5567    Ok(Some(bytes))
5568}
5569
5570/// Persist a readable content conflict without modifying either side. The
5571/// plan is written last, so an interrupted bundle build is never actionable.
5572fn create_v2_conflict_bundle(
5573    cfg: &HubConfig,
5574    store: &Store,
5575    head: &V2VerifiedHead,
5576    baseline: Option<&V2SyncBaseline>,
5577    local: &std::collections::BTreeMap<String, (String, u64)>,
5578    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
5579    paths: &[String],
5580) -> LinkResult<(String, Vec<String>)> {
5581    let conflicts_root = Path::new(".dbmd/conflicts");
5582    store.create_dir_all(conflicts_root)?;
5583    let completed = store
5584        .directory_names(conflicts_root)?
5585        .into_iter()
5586        .filter(|name| name.to_str().is_some_and(crate::ulid::is_ulid))
5587        .count();
5588    if completed >= V2_CONFLICT_BUNDLE_MAX {
5589        return Err(LinkError::InvalidPack {
5590            message: format!(
5591                "private conflict cache has {completed} bundles; resolve or prune one before syncing"
5592            ),
5593        });
5594    }
5595
5596    // A conflict bundle is diagnostic state, not a second whole-brain cache.
5597    // Materialize a deterministic prefix whose remote artifacts fit one
5598    // bounded window. Resolving it exposes the next prefix on the next sync.
5599    let mut selected_paths = Vec::new();
5600    let mut selected_remote_bytes = 0_u64;
5601    for path in paths {
5602        let bytes = remote.get(path).map_or(0, |file| file.bytes);
5603        if !selected_paths.is_empty()
5604            && selected_remote_bytes.saturating_add(bytes) > V2_CONFLICT_REMOTE_BYTES_MAX
5605        {
5606            break;
5607        }
5608        selected_remote_bytes = selected_remote_bytes.saturating_add(bytes);
5609        selected_paths.push(path.clone());
5610        if selected_remote_bytes >= V2_CONFLICT_REMOTE_BYTES_MAX {
5611            break;
5612        }
5613    }
5614    if selected_paths.is_empty() {
5615        return Err(invalid_feed("content conflict set is empty"));
5616    }
5617    let bundle = crate::ulid::mint();
5618    let bundle_root = v2_conflict_relative(&bundle, "");
5619    store.create_dir_all(&bundle_root.join("files"))?;
5620    let pointer = head.pointer.as_ref();
5621    let remote_bytes = match pointer {
5622        Some(pointer) => download_v2_blobs(
5623            cfg,
5624            &head.brain_id,
5625            pointer,
5626            selected_paths
5627                .iter()
5628                .filter_map(|path| {
5629                    remote
5630                        .get(path)
5631                        .filter(|file| file.bytes <= V2_CONFLICT_REMOTE_BYTES_MAX)
5632                        .map(|file| (path, file))
5633                })
5634                .collect(),
5635        )?
5636        .into_iter()
5637        .collect::<std::collections::BTreeMap<_, _>>(),
5638        None => std::collections::BTreeMap::new(),
5639    };
5640
5641    let mut files = Vec::with_capacity(selected_paths.len());
5642    for (index, path) in selected_paths.iter().enumerate() {
5643        let base_file = baseline.and_then(|state| state.files.get(path));
5644        let base_bytes = match (baseline, base_file) {
5645            (Some(state), Some(file)) => {
5646                read_historical_conflict_blob(cfg, &head.brain_id, state, path, file)?
5647            }
5648            _ => None,
5649        };
5650        let local_file = local.get(path);
5651        let remote_file = remote.get(path);
5652        let remote_content = remote_bytes.get(path);
5653        let prefix = format!("files/{index:04}");
5654        let base_name = base_bytes.as_ref().map(|_| format!("{prefix}.base"));
5655        let local_name = local_file.as_ref().map(|_| format!("{prefix}.local"));
5656        let remote_name = remote_content.as_ref().map(|_| format!("{prefix}.remote"));
5657        if let (Some(name), Some(bytes)) = (&base_name, &base_bytes) {
5658            store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
5659        }
5660        if let (Some(name), Some((expected_hash, expected_bytes))) = (&local_name, local_file) {
5661            let bytes = store.read_bounded(Path::new(path), *expected_bytes)?;
5662            if bytes.len() as u64 != *expected_bytes || content_sha256(&bytes) != *expected_hash {
5663                return Err(LinkError::InvalidPack {
5664                    message: format!("local conflict path `{path}` changed while bundling"),
5665                });
5666            }
5667            store.write_atomic_new(&v2_conflict_relative(&bundle, name), &bytes)?;
5668        }
5669        if let (Some(name), Some(bytes)) = (&remote_name, remote_content) {
5670            store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
5671        }
5672        files.push(V2ConflictFile {
5673            path: path.clone(),
5674            base: V2ConflictCoordinate {
5675                sha256: base_file.map(|file| file.sha256.clone()),
5676                bytes: base_file.map(|file| file.bytes),
5677                file: base_name,
5678            },
5679            local: V2ConflictCoordinate {
5680                sha256: local_file.map(|(sha256, _)| sha256.clone()),
5681                bytes: local_file.map(|(_, bytes)| *bytes),
5682                file: local_name,
5683            },
5684            remote: V2ConflictCoordinate {
5685                sha256: remote_file.map(|file| file.sha256.clone()),
5686                bytes: remote_file.map(|file| file.bytes),
5687                file: remote_name,
5688            },
5689        });
5690    }
5691    let now = SystemTime::now()
5692        .duration_since(UNIX_EPOCH)
5693        .unwrap_or_default()
5694        .as_secs();
5695    let plan = V2ConflictPlan {
5696        v: 2,
5697        class: "content_resolution_required".to_string(),
5698        bundle: bundle.clone(),
5699        brain: head.brain_id.clone(),
5700        origin: normalized_origin(&cfg.hub)?,
5701        created_unix: now,
5702        expires_unix: now.saturating_add(V2_CONFLICT_BUNDLE_TTL_SECS),
5703        base_seq: baseline.and_then(|state| state.head_seq),
5704        base_commit: baseline.and_then(|state| state.commit_hash.clone()),
5705        remote_seq: pointer.map_or(0, |value| value.seq),
5706        remote_commit: pointer.map(|value| value.commit_hash.clone()),
5707        remote_content_root: pointer.and_then(|value| value.content_root.clone()),
5708        view_kind: head.view_kind.clone(),
5709        view_revision: head.view_revision.clone(),
5710        files,
5711    };
5712    let mut bytes = serde_json::to_vec_pretty(&plan)
5713        .map_err(|_| invalid_feed("could not serialize v2 conflict plan"))?;
5714    bytes.push(b'\n');
5715    store.write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), &bytes)?;
5716    Ok((bundle, selected_paths))
5717}
5718
5719fn v2_sync_pull_with_resolution(
5720    cfg: &HubConfig,
5721    requested_brain: &str,
5722    expected_head: V2VerifiedHead,
5723    out: Option<&Path>,
5724    take_remote: Option<&std::collections::BTreeSet<String>>,
5725) -> LinkResult<V2PulledSnapshot> {
5726    let dest = out
5727        .map(Path::to_path_buf)
5728        .unwrap_or_else(|| PathBuf::from(requested_brain));
5729    let _operation_lock = lock_v2_sync_operation(cfg, &expected_head.brain_id)?;
5730    recover_v2_pull(cfg, &expected_head.brain_id, &dest)?;
5731    let head = v2_verified_head(cfg, requested_brain)?
5732        .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
5733    if take_remote.is_some() && !same_v2_head(&expected_head, &head) {
5734        return Err(LinkError::RemoteAdvancedDuringSync);
5735    }
5736    let remote = files_for_v2_view(
5737        &head,
5738        v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
5739    );
5740    let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
5741    let baseline = load_v2_baseline(cfg, &head.brain_id, &dest)?;
5742    ensure_v2_view_compatible(&head, baseline.as_ref())?;
5743    let local_store = Store::open_strict(&dest).ok();
5744    // A baseline proves this path is an established checkout. Never reinterpret
5745    // a damaged/edited store marker as an empty clone destination: doing so
5746    // would let the pull half erase the local evidence before the push half can
5747    // reject it (most importantly for the generated scoped-view DB.md).
5748    ensure_established_v2_checkout_opened(&head, baseline.as_ref(), local_store.is_some())?;
5749    let mut local_view = local_store.as_ref().map(v2_local_files).transpose()?;
5750    if head.view_kind == "scoped" && baseline.is_none() && local_view.is_some() {
5751        return Err(LinkError::ScopedViewChanged);
5752    }
5753    if let Some(view) = local_view.as_mut() {
5754        remove_scoped_projection(&head, baseline.as_ref(), view)?;
5755    }
5756    let empty_local = std::collections::BTreeMap::new();
5757    let local = local_view
5758        .as_ref()
5759        .map_or(&empty_local, |view| &view.riding);
5760    let kept_home = |path: &str| {
5761        local_view
5762            .as_ref()
5763            .is_some_and(|view| view.policy.keeps_home(path))
5764    };
5765    let empty_base = std::collections::BTreeMap::new();
5766    let base = baseline.as_ref().map_or(&empty_base, |state| &state.files);
5767    let empty_base_assets = std::collections::BTreeMap::new();
5768    let base_assets = baseline
5769        .as_ref()
5770        .map_or(&empty_base_assets, |state| &state.assets);
5771    let mut local_assets = local_store
5772        .as_ref()
5773        .map(v2_local_asset_records)
5774        .transpose()?
5775        .unwrap_or_default();
5776    let mut content_merge = merge_v2_pulled_records(
5777        base,
5778        &remote,
5779        local,
5780        |file, _| (file.sha256.clone(), file.bytes),
5781        |file, _| (file.sha256.clone(), file.bytes),
5782        kept_home,
5783    );
5784    if let Some(selected) = take_remote {
5785        for path in selected {
5786            if let Some(position) = content_merge
5787                .conflicts
5788                .iter()
5789                .position(|conflict| conflict == path)
5790            {
5791                content_merge.conflicts.remove(position);
5792                content_merge.accept_remote.insert(path.clone());
5793                match remote.get(path) {
5794                    Some(file) => {
5795                        content_merge
5796                            .records
5797                            .insert(path.clone(), (file.sha256.clone(), file.bytes));
5798                    }
5799                    None => {
5800                        content_merge.records.remove(path);
5801                    }
5802                }
5803            } else if !content_merge.accept_remote.contains(path) {
5804                return Err(LinkError::InvalidPack {
5805                    message: format!(
5806                        "take-remote path `{path}` is no longer at its conflict coordinate"
5807                    ),
5808                });
5809            }
5810        }
5811    }
5812    if !content_merge.conflicts.is_empty() {
5813        let mut conflicts = content_merge.conflicts.clone();
5814        conflicts.truncate(100);
5815        if let Some(store) = local_store.as_ref() {
5816            let (bundle, paths) = create_v2_conflict_bundle(
5817                cfg,
5818                store,
5819                &head,
5820                baseline.as_ref(),
5821                local,
5822                &remote,
5823                &conflicts,
5824            )?;
5825            return Err(LinkError::ConflictBundle { bundle, paths });
5826        }
5827        return Err(LinkError::Conflict { paths: conflicts });
5828    }
5829    let asset_merge = merge_v2_pulled_records(
5830        base_assets,
5831        &remote_assets,
5832        &local_assets,
5833        v2_asset_record,
5834        v2_asset_record,
5835        |_| false,
5836    );
5837    if !asset_merge.conflicts.is_empty() {
5838        let mut conflicts = asset_merge.conflicts.clone();
5839        conflicts.truncate(100);
5840        return Err(LinkError::Conflict { paths: conflicts });
5841    }
5842    let pointer = head.pointer.as_ref();
5843    let cache_transaction = pointer.map_or_else(
5844        || content_sha256(format!("empty\0{}", head.view_revision).as_bytes()),
5845        |value| value.commit_hash.clone(),
5846    );
5847    let cache_dir = v2_download_cache_dir_for(cfg, &head.brain_id, &cache_transaction)?;
5848    let mut changed = match pointer {
5849        Some(pointer) => stage_v2_blobs(
5850            cfg,
5851            &head.brain_id,
5852            pointer,
5853            remote
5854                .iter()
5855                .filter(|(path, file)| {
5856                    content_merge.accept_remote.contains(*path)
5857                        && local.get(*path).map(|value| value.0.as_str())
5858                            != Some(file.sha256.as_str())
5859                })
5860                .collect(),
5861        )?,
5862        None => Vec::new(),
5863    };
5864    let mut deleted = content_merge
5865        .accept_remote
5866        .iter()
5867        .filter(|path| !remote.contains_key(*path) && local.contains_key(*path))
5868        .cloned()
5869        .collect::<Vec<_>>();
5870    if local_assets != asset_merge.records {
5871        if asset_merge.records.is_empty() {
5872            deleted.push("assets.jsonl".to_string());
5873        } else {
5874            let bytes = v2_asset_record_manifest_bytes(&asset_merge.records)?;
5875            let sha256 = content_sha256(&bytes);
5876            let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
5877            changed.push(V2StagedFile {
5878                path: "assets.jsonl".to_string(),
5879                source,
5880                sha256,
5881                bytes: bytes.len() as u64,
5882            });
5883        }
5884    }
5885    if let Some(pointer) = pointer {
5886        let mut pending_assets = Vec::new();
5887        for (path, asset) in &remote_assets {
5888            if asset.disposition != "hosted"
5889                || kept_home(path)
5890                || !asset_merge.accept_remote.contains(path)
5891            {
5892                continue;
5893            }
5894            let already_current = local_store.as_ref().is_some_and(|store| {
5895                matches!(store.regular_file_exists(Path::new(path)), Ok(true))
5896                    && store
5897                        .read_bounded(Path::new(path), asset.bytes)
5898                        .ok()
5899                        .is_some_and(|bytes| {
5900                            bytes.len() as u64 == asset.bytes
5901                                && content_sha256(&bytes) == asset.blob_sha256
5902                        })
5903            });
5904            if !already_current {
5905                pending_assets.push((path, asset));
5906            }
5907        }
5908        for item in prepare_v2_asset_downloads(cfg, &head.brain_id, pointer, &pending_assets)? {
5909            let source =
5910                download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
5911            changed.push(V2StagedFile {
5912                path: item.path,
5913                source,
5914                sha256: item.sha256,
5915                bytes: item.bytes,
5916            });
5917        }
5918    }
5919    for (path, prior) in base_assets {
5920        if remote_assets.contains_key(path)
5921            || kept_home(path)
5922            || !asset_merge.accept_remote.contains(path)
5923        {
5924            continue;
5925        }
5926        let unchanged = local_store.as_ref().is_some_and(|store| {
5927            matches!(store.regular_file_exists(Path::new(path)), Ok(true))
5928                && store
5929                    .read_bounded(Path::new(path), prior.bytes)
5930                    .ok()
5931                    .is_some_and(|bytes| content_sha256(&bytes) == prior.blob_sha256)
5932        });
5933        if unchanged {
5934            deleted.push(path.clone());
5935        }
5936    }
5937    let extra_local = content_merge
5938        .records
5939        .keys()
5940        .filter(|path| !remote.contains_key(*path))
5941        .cloned()
5942        .collect::<Vec<_>>();
5943    if head.view_kind == "scoped" {
5944        for (path, bytes) in [
5945            ("DB.md".to_string(), scoped_projection_bytes(&head.brain_id)),
5946            (
5947                ".dbmd/view.json".to_string(),
5948                scoped_view_metadata(&head, remote.len())?,
5949            ),
5950        ] {
5951            let sha256 = content_sha256(&bytes);
5952            let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
5953            changed.push(V2StagedFile {
5954                path,
5955                source,
5956                sha256,
5957                bytes: bytes.len() as u64,
5958            });
5959        }
5960    }
5961    let install_changed = !changed.is_empty() || !deleted.is_empty();
5962    install_pulled_delta_sources(&dest, &changed, &deleted, true, baseline.as_ref(), &head)?;
5963    let finalized = (|| -> LinkResult<(bool, V2LocalView, std::collections::BTreeMap<String, crate::AssetRecord>)> {
5964        let installed_store =
5965            Store::open_strict(&dest).map_err(|error| LinkError::InvalidPack {
5966                message: format!("installed v2 checkout is not a valid db.md store: {error}"),
5967            })?;
5968        let installed_local = if install_changed {
5969            let mut scanned = v2_local_files(&installed_store)?;
5970            remove_scoped_projection(&head, baseline.as_ref(), &mut scanned)?;
5971            scanned
5972        } else {
5973            local_view
5974                .take()
5975                .ok_or_else(|| invalid_feed("an unchanged pull has no installed local view"))?
5976        };
5977        if installed_local.riding != content_merge.records {
5978            return Err(LinkError::InvalidPack {
5979                message: "local content changed while installing the v2 pull".to_string(),
5980            });
5981        }
5982        let installed_assets = if install_changed {
5983            v2_local_asset_records(&installed_store)?
5984        } else {
5985            std::mem::take(&mut local_assets)
5986        };
5987        if installed_assets != asset_merge.records {
5988            return Err(LinkError::InvalidPack {
5989                message: "local assets changed while installing the v2 pull".to_string(),
5990            });
5991        }
5992        let local_dirty = !v2_riding_matches_remote(&installed_local.riding, &remote, |path| {
5993            installed_local.policy.keeps_home(path)
5994        })
5995            || !v2_asset_records_match_remote(&installed_assets, &remote_assets);
5996        let final_head = v2_verified_head(cfg, requested_brain)?
5997            .ok_or_else(|| invalid_feed("v2 head disappeared during pull"))?;
5998        if !same_v2_head(&head, &final_head) {
5999            return Err(LinkError::RemoteAdvancedDuringSync);
6000        }
6001        accept_v2_head(cfg, &final_head)?;
6002        save_v2_baseline(
6003            cfg,
6004            &head.brain_id,
6005            &dest,
6006            &v2_baseline_from_head(
6007                cfg,
6008                &head,
6009                remote.clone(),
6010                remote_assets.clone(),
6011                Some(&installed_local),
6012                baseline
6013                    .as_ref()
6014                    .and_then(|current| current.checkout_id.as_deref()),
6015            )?,
6016        )?;
6017        complete_v2_pull(&dest)?;
6018        Ok((local_dirty, installed_local, installed_assets))
6019    })();
6020    let (local_dirty, installed_local, installed_assets) = match finalized {
6021        Ok(value) => value,
6022        Err(error) => {
6023            if let Err(recovery) = recover_v2_pull(cfg, &head.brain_id, &dest) {
6024                return Err(LinkError::InvalidPack {
6025                    message: format!("{error}; durable pull recovery also failed: {recovery}"),
6026                });
6027            }
6028            return Err(error);
6029        }
6030    };
6031    cleanup_v2_download_cache(cfg, &head.brain_id, &cache_transaction);
6032    let report = PullReport {
6033        brain: head.brain_id.clone(),
6034        slug: requested_brain.to_string(),
6035        head_seq: pointer.map_or(0, |value| value.seq),
6036        files: remote.len() + remote_assets.len(),
6037        dest: dest.to_string_lossy().into_owned(),
6038        extra_local,
6039        sync_status: if local_dirty {
6040            "local_dirty_after_install".to_string()
6041        } else {
6042            "synced".to_string()
6043        },
6044    };
6045    Ok(V2PulledSnapshot {
6046        report,
6047        head,
6048        files: remote,
6049        assets: remote_assets,
6050        local: installed_local,
6051        local_assets: installed_assets,
6052    })
6053}
6054
6055fn v2_sync_pull(
6056    cfg: &HubConfig,
6057    requested_brain: &str,
6058    head: V2VerifiedHead,
6059    out: Option<&Path>,
6060) -> LinkResult<PullReport> {
6061    Ok(v2_sync_pull_with_resolution(cfg, requested_brain, head, out, None)?.report)
6062}
6063
6064fn v2_expected(remote: Option<&V2BaselineFile>) -> Value {
6065    match remote {
6066        Some(file) => json!({ "kind": "blob", "hash": file.sha256 }),
6067        None => json!({ "kind": "absent" }),
6068    }
6069}
6070
6071fn v2_asset_expected(remote: Option<&V2BaselineAsset>) -> Value {
6072    match remote {
6073        Some(asset) => json!({ "kind": "asset", "hash": asset.leaf_hash }),
6074        None => json!({ "kind": "absent" }),
6075    }
6076}
6077
6078fn v2_content_withdrawal_operation(
6079    store: &Store,
6080    local_view: &V2LocalView,
6081    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6082    path: &str,
6083    reason: &str,
6084) -> LinkResult<Value> {
6085    if (!(path.starts_with("records/") || path.starts_with("sources/")) || !path.ends_with(".md"))
6086        || path == "DB.md"
6087    {
6088        return Err(LinkError::InvalidPack {
6089            message: format!("content withdrawal path `{path}` is not a record or source"),
6090        });
6091    }
6092    if !local_view.policy.keeps_home(path)
6093        || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6094    {
6095        return Err(LinkError::InvalidPack {
6096            message: format!(
6097                "withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
6098            ),
6099        });
6100    }
6101    let current = remote.get(path).ok_or_else(|| LinkError::InvalidPack {
6102        message: format!("withdrawal path `{path}` has no readable hosted coordinate"),
6103    })?;
6104    verify_v2_upload_source(store, path, &current.sha256, current.bytes)?;
6105    Ok(json!({
6106        "op": "withdraw_from_hosting",
6107        "path": path,
6108        "expected": { "kind": "blob", "hash": current.sha256 },
6109        "reason": reason,
6110    }))
6111}
6112
6113fn v2_asset_withdrawal_operation(
6114    store: &Store,
6115    local_view: &V2LocalView,
6116    path: &str,
6117    local: &crate::AssetRecord,
6118    current: &V2BaselineAsset,
6119    reason: &str,
6120) -> LinkResult<Value> {
6121    if !local_view.policy.keeps_home(path)
6122        || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6123    {
6124        return Err(LinkError::InvalidPack {
6125            message: format!(
6126                "asset withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
6127            ),
6128        });
6129    }
6130    verify_v2_upload_source(store, path, &local.sha256, local.bytes)?;
6131    if current.disposition != "hosted" || v2_asset_record(current, path) != *local {
6132        return Err(LinkError::InvalidPack {
6133            message: format!(
6134                "asset withdrawal path `{path}` must exactly match its currently hosted signed leaf"
6135            ),
6136        });
6137    }
6138    Ok(json!({
6139        "op": "asset_withdraw",
6140        "path": path,
6141        "expected": v2_asset_expected(Some(current)),
6142        "reason": reason,
6143    }))
6144}
6145
6146fn v2_asset_value(record: &crate::AssetRecord, disposition: &str) -> Value {
6147    json!({
6148        "blob_sha256": record.sha256,
6149        "bytes": record.bytes,
6150        "media_type": record.media_type,
6151        "wrappers": record.wrappers,
6152        "required": record.required,
6153        "disposition": disposition,
6154    })
6155}
6156
6157/// Apply the exact operation subset generated by this client to an already
6158/// verified manifest. This avoids downloading the same 50k-path listing again
6159/// after an unrebased commit; a rebased response still re-reads the manifest.
6160fn apply_generated_v2_operations(
6161    operations: &[Value],
6162    local_assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
6163    candidate: &mut std::collections::BTreeMap<String, V2BaselineFile>,
6164    candidate_assets: &mut std::collections::BTreeMap<String, V2BaselineAsset>,
6165) -> LinkResult<bool> {
6166    let mut asset_changed = false;
6167    for operation in operations {
6168        match operation.get("op").and_then(Value::as_str) {
6169            Some("put") => {
6170                let path = operation
6171                    .get("path")
6172                    .and_then(Value::as_str)
6173                    .ok_or_else(|| invalid_feed("v2 put has no path"))?;
6174                let sha256 = operation
6175                    .get("blob")
6176                    .and_then(Value::as_str)
6177                    .ok_or_else(|| invalid_feed("v2 put has no blob"))?;
6178                let bytes = operation
6179                    .get("bytes")
6180                    .and_then(Value::as_u64)
6181                    .ok_or_else(|| invalid_feed("v2 put has no byte count"))?;
6182                candidate.insert(
6183                    path.to_string(),
6184                    V2BaselineFile {
6185                        sha256: sha256.to_string(),
6186                        bytes,
6187                        proof: None,
6188                    },
6189                );
6190            }
6191            Some("delete" | "withdraw_from_hosting") => {
6192                let path = operation
6193                    .get("path")
6194                    .and_then(Value::as_str)
6195                    .ok_or_else(|| invalid_feed("v2 delete has no path"))?;
6196                candidate.remove(path);
6197            }
6198            Some("asset_delete") => {
6199                let path = operation
6200                    .get("path")
6201                    .and_then(Value::as_str)
6202                    .ok_or_else(|| invalid_feed("v2 asset delete has no path"))?;
6203                candidate_assets.remove(path);
6204                asset_changed = true;
6205            }
6206            Some("asset_put" | "asset_resume" | "asset_withdraw") => {
6207                let path = operation
6208                    .get("path")
6209                    .and_then(Value::as_str)
6210                    .ok_or_else(|| invalid_feed("v2 asset write has no path"))?;
6211                let record = local_assets
6212                    .get(path)
6213                    .ok_or_else(|| invalid_feed("v2 asset write has no local record"))?;
6214                let disposition =
6215                    if operation.get("op").and_then(Value::as_str) == Some("asset_withdraw") {
6216                        "withheld"
6217                    } else {
6218                        operation
6219                            .get("asset")
6220                            .and_then(|asset| asset.get("disposition"))
6221                            .and_then(Value::as_str)
6222                            .ok_or_else(|| invalid_feed("v2 asset write has no disposition"))?
6223                    };
6224                candidate_assets.insert(
6225                    path.to_string(),
6226                    V2BaselineAsset {
6227                        blob_sha256: record.sha256.clone(),
6228                        bytes: record.bytes,
6229                        media_type: record.media_type.clone(),
6230                        wrappers: record.wrappers.clone(),
6231                        required: record.required,
6232                        disposition: disposition.to_string(),
6233                        // The server assigns the signed leaf address. Callers
6234                        // that need an exact asset manifest re-read it below.
6235                        leaf_hash: String::new(),
6236                    },
6237                );
6238                asset_changed = true;
6239            }
6240            _ => return Err(invalid_feed("dbmd generated an unsupported v2 operation")),
6241        }
6242    }
6243    Ok(asset_changed)
6244}
6245
6246fn v2_riding_matches_remote(
6247    local: &std::collections::BTreeMap<String, (String, u64)>,
6248    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6249    keeps_home: impl Fn(&str) -> bool,
6250) -> bool {
6251    remote.iter().all(|(path, file)| {
6252        keeps_home(path)
6253            || local.get(path).map(|value| value.0.as_str()) == Some(file.sha256.as_str())
6254    }) && local.iter().all(|(path, (hash, _))| {
6255        remote.get(path).map(|file| file.sha256.as_str()) == Some(hash.as_str())
6256    })
6257}
6258
6259#[derive(Debug, Clone)]
6260struct V2ResolutionOverride {
6261    expected_remote: Option<String>,
6262    selected_local: Option<String>,
6263}
6264
6265#[derive(Debug, Clone)]
6266struct V2UploadSource {
6267    path: String,
6268    bytes: u64,
6269}
6270
6271struct V2SyncPushOptions<'a> {
6272    resume_local_policy: bool,
6273    bulk_confirmation: Option<&'a V2BulkConfirmation>,
6274    resolution: Option<&'a std::collections::BTreeMap<String, V2ResolutionOverride>>,
6275    pulled: Option<V2PulledSnapshot>,
6276    withdrawal_paths: &'a [String],
6277    withdrawal_reason: Option<&'a str>,
6278}
6279
6280fn verify_v2_upload_source(
6281    store: &Store,
6282    path: &str,
6283    sha256: &str,
6284    expected_bytes: u64,
6285) -> LinkResult<()> {
6286    let file = store.open_regular(Path::new(path))?;
6287    if file.metadata()?.len() != expected_bytes || content_sha256_reader(file)? != sha256 {
6288        return Err(LinkError::InvalidPack {
6289            message: format!("local path `{path}` changed during sync planning"),
6290        });
6291    }
6292    Ok(())
6293}
6294
6295fn put_presigned_source(
6296    cfg: &HubConfig,
6297    raw: &str,
6298    headers: &Value,
6299    store: &Store,
6300    source: &V2UploadSource,
6301) -> LinkResult<()> {
6302    let http = presigned_agent(cfg, raw)?;
6303    let mut attempt = 0;
6304    let result = loop {
6305        let file = store.open_regular(Path::new(&source.path))?;
6306        if file.metadata()?.len() != source.bytes {
6307            return Err(LinkError::InvalidPack {
6308                message: format!("local path `{}` changed before upload", source.path),
6309            });
6310        }
6311        let mut req = http
6312            .put(raw)
6313            .set("Content-Length", &source.bytes.to_string());
6314        if let Some(map) = headers.as_object() {
6315            for (name, value) in map {
6316                if let Some(value) = value.as_str() {
6317                    req = req.set(name, value);
6318                }
6319            }
6320        }
6321        match req.send(file) {
6322            Err(ureq::Error::Transport(error))
6323                if is_pre_request_transport(error.kind()) && attempt + 1 < CONNECT_ATTEMPTS =>
6324            {
6325                std::thread::sleep(std::time::Duration::from_millis(
6326                    CONNECT_RETRY_BACKOFF_MS[attempt],
6327                ));
6328                attempt += 1;
6329            }
6330            result => break result,
6331        }
6332    };
6333    match result {
6334        Ok(response) if (200..300).contains(&response.status()) => Ok(()),
6335        Ok(response) => Err(LinkError::Http {
6336            what: "v2 changed-byte upload",
6337            status: response.status(),
6338            message: "object store rejected the upload".to_string(),
6339            code: None,
6340            details: None,
6341        }),
6342        Err(error) => match error {
6343            ureq::Error::Status(412, _) => Ok(()),
6344            ureq::Error::Status(_, response) => Err(LinkError::Http {
6345                what: "v2 changed-byte upload",
6346                status: response.status(),
6347                message: "object store rejected the upload".to_string(),
6348                code: None,
6349                details: None,
6350            }),
6351            ureq::Error::Transport(error) => Err(LinkError::Transport {
6352                hub: "the object store".to_string(),
6353                message: error.to_string(),
6354            }),
6355        },
6356    }
6357}
6358
6359fn v2_sync_push(
6360    cfg: &HubConfig,
6361    requested_brain: &str,
6362    store: &Store,
6363    head: V2VerifiedHead,
6364    options: V2SyncPushOptions<'_>,
6365) -> LinkResult<Value> {
6366    let V2SyncPushOptions {
6367        resume_local_policy,
6368        bulk_confirmation,
6369        resolution,
6370        pulled,
6371        withdrawal_paths,
6372        withdrawal_reason,
6373    } = options;
6374    let _operation_lock = lock_v2_sync_operation(cfg, &head.brain_id)?;
6375    let head = v2_verified_head(cfg, requested_brain)?
6376        .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
6377    let (mut remote, mut remote_assets, carried_local, carried_local_assets) =
6378        match pulled.filter(|snapshot| same_v2_head(&snapshot.head, &head)) {
6379            Some(snapshot) => (
6380                snapshot.files,
6381                snapshot.assets,
6382                Some(snapshot.local),
6383                Some(snapshot.local_assets),
6384            ),
6385            None => (
6386                files_for_v2_view(
6387                    &head,
6388                    v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6389                ),
6390                v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6391                None,
6392                None,
6393            ),
6394        };
6395    let baseline = load_v2_baseline(cfg, &head.brain_id, &store.root)?;
6396    ensure_v2_view_compatible(&head, baseline.as_ref())?;
6397    if head.view_kind == "scoped" && baseline.is_none() {
6398        return Err(LinkError::ScopedViewChanged);
6399    }
6400    let local_view = local_view_for_v2_push(store, &head, baseline.as_ref(), carried_local)?;
6401    let local = &local_view.riding;
6402    let local_assets = match carried_local_assets {
6403        Some(assets) => assets,
6404        None => v2_local_asset_records(store)?,
6405    };
6406    if withdrawal_paths.len() > MAX_PUSH_FILES {
6407        return Err(LinkError::PushTooLarge {
6408            detail: "too many explicit withdrawal paths".to_string(),
6409        });
6410    }
6411    let withdrawal_reason = if withdrawal_paths.is_empty() {
6412        None
6413    } else {
6414        let reason = withdrawal_reason
6415            .map(str::trim)
6416            .filter(|reason| !reason.is_empty() && reason.len() <= 1000)
6417            .ok_or_else(|| LinkError::InvalidPack {
6418                message: "explicit withdrawal requires a non-empty --withdraw-reason of at most 1000 bytes".to_string(),
6419            })?;
6420        Some(reason)
6421    };
6422    let mut withdrawals = withdrawal_paths
6423        .iter()
6424        .map(|path| {
6425            crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
6426                path: error.to_string(),
6427            })
6428        })
6429        .collect::<LinkResult<Vec<_>>>()?;
6430    withdrawals.sort();
6431    withdrawals.dedup();
6432    if withdrawals.len() != withdrawal_paths.len() {
6433        return Err(LinkError::InvalidPack {
6434            message: "explicit withdrawal paths must be unique".to_string(),
6435        });
6436    }
6437    let withdrawal_set = withdrawals.iter().cloned().collect::<BTreeSet<_>>();
6438    let mut consumed_withdrawals = BTreeSet::new();
6439    if let Some(previous) = baseline.as_ref() {
6440        if previous.local_policy_digest.as_deref() != Some(&local_view.policy.digest)
6441            && !resume_local_policy
6442        {
6443            let mut newly_eligible = previous
6444                .local_eligibility
6445                .iter()
6446                .filter(|(path, riding)| !**riding && !local_view.policy.keeps_home(path))
6447                .map(|(path, _)| path.clone())
6448                .collect::<Vec<_>>();
6449            if !newly_eligible.is_empty() {
6450                newly_eligible.truncate(100);
6451                return Err(LinkError::LocalPolicyTransition {
6452                    paths: newly_eligible,
6453                });
6454            }
6455        }
6456    }
6457    let base = match baseline.as_ref() {
6458        Some(state) => &state.files,
6459        None if remote.is_empty() => &remote,
6460        None => {
6461            let mut conflicts = remote
6462                .iter()
6463                .filter(|(path, file)| {
6464                    local.get(*path).map(|value| value.0.as_str()) != Some(file.sha256.as_str())
6465                })
6466                .map(|(path, _)| path.clone())
6467                .collect::<Vec<_>>();
6468            if !conflicts.is_empty() {
6469                conflicts.truncate(100);
6470                let (bundle, paths) =
6471                    create_v2_conflict_bundle(cfg, store, &head, None, local, &remote, &conflicts)?;
6472                return Err(LinkError::ConflictBundle { bundle, paths });
6473            }
6474            &remote
6475        }
6476    };
6477    let all_paths = base
6478        .keys()
6479        .chain(remote.keys())
6480        .chain(local.keys())
6481        .cloned()
6482        .collect::<std::collections::BTreeSet<_>>();
6483    let mut conflicts = Vec::new();
6484    let mut operations = Vec::new();
6485    let mut upload_sources = std::collections::BTreeMap::<String, V2UploadSource>::new();
6486    for path in all_paths {
6487        let base_hash = base.get(&path).map(|file| file.sha256.as_str());
6488        let remote_file = remote.get(&path);
6489        let remote_hash = remote_file.map(|file| file.sha256.as_str());
6490        let local_file = local.get(&path);
6491        let local_hash = local_file.map(|file| file.0.as_str());
6492        if local_hash == base_hash {
6493            continue;
6494        }
6495        if resolution.is_some_and(|allowed| !allowed.contains_key(&path)) {
6496            continue;
6497        }
6498        if local_view.policy.keeps_home(&path) {
6499            // Kept-home is a local transfer exclusion, never an implicit
6500            // delete of the company's already-hosted coordinate.
6501            continue;
6502        }
6503        if remote_hash != base_hash && local_hash != remote_hash {
6504            let explicitly_resolved = resolution
6505                .and_then(|allowed| allowed.get(&path))
6506                .is_some_and(|selected| {
6507                    selected.expected_remote.as_deref() == remote_hash
6508                        && selected.selected_local.as_deref() == local_hash
6509                });
6510            if !explicitly_resolved {
6511                conflicts.push(path);
6512                continue;
6513            }
6514        }
6515        match local_file {
6516            Some((sha256, byte_count)) => {
6517                verify_v2_upload_source(store, &path, sha256, *byte_count)?;
6518                operations.push(json!({
6519                    "op": "put",
6520                    "path": path,
6521                    "expected": v2_expected(remote_file),
6522                    "blob": sha256,
6523                    "bytes": byte_count,
6524                }));
6525                upload_sources
6526                    .entry(sha256.clone())
6527                    .or_insert_with(|| V2UploadSource {
6528                        path: path.clone(),
6529                        bytes: *byte_count,
6530                    });
6531            }
6532            None => {
6533                let Some(current) = remote_file else {
6534                    continue;
6535                };
6536                operations.push(json!({
6537                    "op": "delete",
6538                    "path": path,
6539                    "expected": { "kind": "blob", "hash": current.sha256 },
6540                }));
6541            }
6542        }
6543    }
6544    for path in &withdrawals {
6545        if local_assets.contains_key(path) {
6546            continue;
6547        }
6548        operations.push(v2_content_withdrawal_operation(
6549            store,
6550            &local_view,
6551            &remote,
6552            path,
6553            withdrawal_reason.expect("non-empty withdrawal set has a reason"),
6554        )?);
6555        consumed_withdrawals.insert(path.clone());
6556    }
6557    if !conflicts.is_empty() {
6558        conflicts.truncate(100);
6559        let (bundle, paths) = create_v2_conflict_bundle(
6560            cfg,
6561            store,
6562            &head,
6563            baseline.as_ref(),
6564            local,
6565            &remote,
6566            &conflicts,
6567        )?;
6568        return Err(LinkError::ConflictBundle { bundle, paths });
6569    }
6570    let base_assets = match baseline.as_ref() {
6571        Some(state) => &state.assets,
6572        None if remote_assets.is_empty() => &remote_assets,
6573        None => {
6574            let mismatched = remote_assets.iter().any(|(path, remote)| {
6575                local_assets.get(path) != Some(&v2_asset_record(remote, path))
6576            }) || local_assets.len() != remote_assets.len();
6577            if mismatched {
6578                return Err(LinkError::Conflict {
6579                    paths: vec!["assets.jsonl".to_string()],
6580                });
6581            }
6582            &remote_assets
6583        }
6584    };
6585    let asset_paths = base_assets
6586        .keys()
6587        .chain(remote_assets.keys())
6588        .chain(local_assets.keys())
6589        .cloned()
6590        .collect::<std::collections::BTreeSet<_>>();
6591    let mut asset_policy_transitions = Vec::new();
6592    for path in asset_paths {
6593        let base_record = base_assets
6594            .get(&path)
6595            .map(|asset| v2_asset_record(asset, &path));
6596        let remote = remote_assets.get(&path);
6597        let remote_record = remote.map(|asset| v2_asset_record(asset, &path));
6598        let local_record = local_assets.get(&path);
6599        if withdrawal_set.contains(&path) {
6600            let record = local_record.ok_or_else(|| LinkError::InvalidPack {
6601                message: format!("asset withdrawal path `{path}` is absent from assets.jsonl"),
6602            })?;
6603            let current = remote.ok_or_else(|| LinkError::InvalidPack {
6604                message: format!(
6605                    "asset withdrawal path `{path}` has no readable hosted coordinate"
6606                ),
6607            })?;
6608            operations.push(v2_asset_withdrawal_operation(
6609                store,
6610                &local_view,
6611                &path,
6612                record,
6613                current,
6614                withdrawal_reason.expect("non-empty withdrawal set has a reason"),
6615            )?);
6616            consumed_withdrawals.insert(path.clone());
6617            continue;
6618        }
6619        let mut raw_present = false;
6620        let mut disposition = "withheld";
6621        let mut resumes_hosting = false;
6622        if let Some(record) = local_record {
6623            crate::linkmd_v2::normalize_path(&record.path)
6624                .map_err(|error| invalid_feed(error.to_string()))?;
6625            let kept_home = local_view.policy.keeps_home(&path);
6626            raw_present = matches!(store.regular_file_exists(Path::new(&path)), Ok(true));
6627            disposition = if kept_home || !raw_present {
6628                "withheld"
6629            } else {
6630                "hosted"
6631            };
6632            if !raw_present && record.required && !kept_home {
6633                return Err(LinkError::InvalidPack {
6634                    message: format!("required asset {path} is missing"),
6635                });
6636            }
6637            resumes_hosting = v2_asset_resumes_hosting(remote, &path, record, disposition);
6638        }
6639        if local_record == base_record.as_ref() && !resumes_hosting {
6640            continue;
6641        }
6642        if remote_record != base_record && local_record != remote_record.as_ref() {
6643            conflicts.push(path);
6644            continue;
6645        }
6646        let Some(record) = local_record else {
6647            if let Some(remote) = remote {
6648                operations.push(json!({
6649                    "op": "asset_delete",
6650                    "path": path,
6651                    "expected": v2_asset_expected(Some(remote)),
6652                }));
6653            }
6654            continue;
6655        };
6656        let raw = if raw_present {
6657            verify_v2_upload_source(store, &path, &record.sha256, record.bytes)?;
6658            Some(())
6659        } else {
6660            None
6661        };
6662        let op = if resumes_hosting {
6663            if !resume_local_policy {
6664                asset_policy_transitions.push(path);
6665                continue;
6666            }
6667            "asset_resume"
6668        } else {
6669            "asset_put"
6670        };
6671        operations.push(json!({
6672            "op": op,
6673            "path": path,
6674            "expected": v2_asset_expected(remote),
6675            "asset": v2_asset_value(record, disposition),
6676        }));
6677        if disposition == "hosted" {
6678            raw.expect("hosted asset was checked present");
6679            upload_sources
6680                .entry(record.sha256.clone())
6681                .or_insert_with(|| V2UploadSource {
6682                    path: path.clone(),
6683                    bytes: record.bytes,
6684                });
6685        }
6686    }
6687    if consumed_withdrawals != withdrawal_set {
6688        let missing = withdrawal_set
6689            .difference(&consumed_withdrawals)
6690            .next()
6691            .expect("different withdrawal sets have one member");
6692        return Err(LinkError::InvalidPack {
6693            message: format!(
6694                "withdrawal path `{missing}` is not a readable content or asset coordinate"
6695            ),
6696        });
6697    }
6698    if !conflicts.is_empty() {
6699        conflicts.truncate(100);
6700        return Err(LinkError::Conflict { paths: conflicts });
6701    }
6702    if !asset_policy_transitions.is_empty() {
6703        asset_policy_transitions.truncate(100);
6704        return Err(LinkError::LocalPolicyTransition {
6705            paths: asset_policy_transitions,
6706        });
6707    }
6708    let touched_sources = operations
6709        .iter()
6710        .filter_map(
6711            |operation| match operation.get("op").and_then(Value::as_str) {
6712                Some("put" | "restore") => operation.get("path").and_then(Value::as_str),
6713                Some("rename") => operation.get("to").and_then(Value::as_str),
6714                _ => None,
6715            },
6716        )
6717        .collect::<std::collections::BTreeSet<_>>();
6718    let withheld_links = local_view
6719        .withheld_links
6720        .iter()
6721        .filter(|link| touched_sources.contains(link.source.as_str()))
6722        .collect::<Vec<_>>();
6723    let checkout_pseudonym = v2_checkout_id(
6724        baseline
6725            .as_ref()
6726            .and_then(|current| current.checkout_id.as_deref()),
6727    )?;
6728    let checkout_id = if withheld_links.is_empty() {
6729        None
6730    } else {
6731        Some(checkout_pseudonym.clone())
6732    };
6733    if operations.is_empty() {
6734        let final_head = v2_verified_head(cfg, requested_brain)?
6735            .ok_or_else(|| invalid_feed("v2 head disappeared during sync"))?;
6736        if !same_v2_head(&head, &final_head) {
6737            return Err(LinkError::RemoteAdvancedDuringSync);
6738        }
6739        let mut final_local = v2_local_files(store)?;
6740        remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
6741        let final_assets = v2_local_asset_records(store)?;
6742        let local_changed = final_local.riding != local_view.riding || final_assets != local_assets;
6743        let remote_ahead = !v2_riding_matches_remote(&final_local.riding, &remote, |path| {
6744            final_local.policy.keeps_home(path)
6745        }) || !v2_asset_records_match_remote(&final_assets, &remote_assets);
6746        let next = v2_baseline_from_head(
6747            cfg,
6748            &head,
6749            remote,
6750            remote_assets,
6751            Some(&final_local),
6752            Some(&checkout_pseudonym),
6753        )?;
6754        let split_count = next.remote_copy_remains.len();
6755        accept_v2_head(cfg, &final_head)?;
6756        if !local_changed && !remote_ahead {
6757            refresh_scoped_view_marker(store, &head, next.files.len())?;
6758            save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
6759        }
6760        return Ok(json!({
6761            "v": 2,
6762            "outcome": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "no_change" },
6763            "sync_status": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "synced" },
6764            "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
6765            "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
6766            "local_policy": {
6767                "remote_copy_remains": split_count,
6768            },
6769        }));
6770    }
6771    let includes_contract = operations
6772        .iter()
6773        .any(|operation| operation.get("path").and_then(Value::as_str) == Some("DB.md"));
6774    let rebase = if head.pointer.is_none() || includes_contract {
6775        "strict"
6776    } else {
6777        "disjoint"
6778    };
6779    let base_value = head.pointer.as_ref().map(|pointer| {
6780        json!({
6781            "seq": pointer.seq,
6782            "commit_hash": pointer.commit_hash,
6783            "content_root": pointer.content_root,
6784            "asset_root": pointer.asset_root,
6785        })
6786    });
6787    // The mutation coordinate is stable for the exact observed base and local
6788    // operation set. A lost response can therefore be retried without a
6789    // duplicate commit/proposal even across process restarts.
6790    let entropy = format!(
6791        "{}\0{}\0{}\0{}\0{}\0{}",
6792        normalized_origin(&cfg.hub)?,
6793        head.brain_id,
6794        serde_json::to_string(&base_value).unwrap_or_default(),
6795        serde_json::to_string(&operations).unwrap_or_default(),
6796        serde_json::to_string(&withheld_links).unwrap_or_default(),
6797        checkout_id.as_deref().unwrap_or("")
6798    );
6799    let mutation_id = format!("dbmd-{}", content_sha256(entropy.as_bytes()));
6800    let changed_bytes = upload_sources.values().try_fold(0_u64, |total, source| {
6801        total
6802            .checked_add(source.bytes)
6803            .ok_or_else(|| LinkError::PushTooLarge {
6804                detail: "v2 changed-byte total overflow".to_string(),
6805            })
6806    })?;
6807    let inline = changed_bytes <= 3 * 1024 * 1024;
6808    let inline_blobs = if inline {
6809        upload_sources
6810            .iter()
6811            .map(|(sha256, source)| {
6812                let bytes = store.read_bounded(Path::new(&source.path), source.bytes)?;
6813                if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != *sha256 {
6814                    return Err(LinkError::InvalidPack {
6815                        message: format!("local path `{}` changed before upload", source.path),
6816                    });
6817                }
6818                Ok(json!({
6819                    "sha256": sha256,
6820                    "bytes": source.bytes,
6821                    "content_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
6822                }))
6823            })
6824            .collect::<LinkResult<Vec<_>>>()?
6825    } else {
6826        Vec::new()
6827    };
6828    let mut body = json!({
6829        "mutation_id": mutation_id,
6830        "base": base_value,
6831        "rebase": rebase,
6832        "reason": "dbmd sync",
6833        "operations": operations,
6834        "blobs": inline_blobs,
6835    });
6836    if !withheld_links.is_empty() {
6837        body["withheld_links"] = serde_json::to_value(&withheld_links)
6838            .map_err(|_| invalid_feed("could not serialize withheld-link observations"))?;
6839        body["checkout_id"] =
6840            Value::String(checkout_id.expect("non-empty withheld links have a checkout pseudonym"));
6841    }
6842    if let Some(confirmation) = bulk_confirmation {
6843        if !crate::ulid::is_ulid(&confirmation.id) || !is_sha256(&confirmation.digest) {
6844            return Err(LinkError::InvalidPack {
6845                message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
6846                    .to_string(),
6847            });
6848        }
6849        // A preview is bound to the exact observed head. Confirmation can
6850        // therefore never use disjoint rebase semantics, even when the normal
6851        // small-mutation path could.
6852        body["rebase"] = Value::String("strict".to_string());
6853        body["bulk_preview_id"] = Value::String(confirmation.id.clone());
6854        body["bulk_preview_digest"] = Value::String(confirmation.digest.clone());
6855    }
6856    if !inline || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
6857        let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
6858        for operation in &operations {
6859            let Some(kind) = operation.get("op").and_then(Value::as_str) else {
6860                return Err(invalid_feed("v2 upload operation has no kind"));
6861            };
6862            let hash = match kind {
6863                "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
6864                "asset_put" | "asset_resume" => operation
6865                    .get("asset")
6866                    .and_then(|asset| asset.get("blob_sha256"))
6867                    .and_then(Value::as_str),
6868                _ => None,
6869            };
6870            let Some(hash) = hash else { continue };
6871            let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
6872            if kind == "rename" {
6873                for field in ["from", "to"] {
6874                    coordinates.insert(
6875                        operation
6876                            .get(field)
6877                            .and_then(Value::as_str)
6878                            .ok_or_else(|| invalid_feed("v2 rename upload has no coordinate"))?
6879                            .to_string(),
6880                    );
6881                }
6882            } else {
6883                let path = operation
6884                    .get("path")
6885                    .and_then(Value::as_str)
6886                    .ok_or_else(|| invalid_feed("v2 upload has no coordinate"))?;
6887                coordinates.insert(if kind.starts_with("asset_") {
6888                    format!("assets/{path}")
6889                } else {
6890                    path.to_string()
6891                });
6892            }
6893        }
6894        let declarations = upload_sources
6895            .iter()
6896            .map(|(sha256, source)| {
6897                json!({
6898                    "sha256": sha256,
6899                    "bytes": source.bytes,
6900                    "coordinates": coordinates_by_hash
6901                        .get(sha256)
6902                        .into_iter()
6903                        .flatten()
6904                        .collect::<Vec<_>>(),
6905                })
6906            })
6907            .collect::<Vec<_>>();
6908        let reserved = ensure_ok(
6909            request(
6910                cfg,
6911                "POST",
6912                &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
6913                Some(&json!({ "blobs": declarations })),
6914                Auth::Required,
6915            )?,
6916            "prepare v2 changed-byte uploads",
6917        )?;
6918        let items = reserved
6919            .get("uploads")
6920            .and_then(Value::as_array)
6921            .ok_or_else(|| invalid_feed("v2 upload reservation response has no items"))?;
6922        if items.len() != upload_sources.len() {
6923            return Err(invalid_feed(
6924                "v2 upload reservation response changed the requested set",
6925            ));
6926        }
6927        let mut references = Vec::with_capacity(items.len());
6928        let mut seen = std::collections::BTreeSet::new();
6929        for item in items {
6930            let sha256 = item
6931                .get("sha256")
6932                .and_then(Value::as_str)
6933                .ok_or_else(|| invalid_feed("v2 upload reservation has no hash"))?;
6934            let source = upload_sources
6935                .get(sha256)
6936                .ok_or_else(|| invalid_feed("v2 upload reservation introduced a blob"))?;
6937            let declared_bytes = item
6938                .get("bytes")
6939                .and_then(Value::as_u64)
6940                .ok_or_else(|| invalid_feed("v2 upload reservation has no byte length"))?;
6941            let reservation_id = item
6942                .get("reservation_id")
6943                .and_then(Value::as_str)
6944                .ok_or_else(|| invalid_feed("v2 upload reservation has no opaque id"))?;
6945            let expected_coordinates = coordinates_by_hash
6946                .get(sha256)
6947                .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinate binding"))?;
6948            let returned_coordinates = item
6949                .get("coordinates")
6950                .and_then(Value::as_array)
6951                .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinates"))?;
6952            if declared_bytes != source.bytes
6953                || !crate::ulid::is_ulid(reservation_id)
6954                || !seen.insert(sha256.to_string())
6955                || returned_coordinates.len() != expected_coordinates.len()
6956                || returned_coordinates
6957                    .iter()
6958                    .zip(expected_coordinates)
6959                    .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
6960            {
6961                return Err(invalid_feed("v2 upload reservation item is inconsistent"));
6962            }
6963            match item.get("status").and_then(Value::as_str) {
6964                Some("upload") => {
6965                    let url = item
6966                        .get("url")
6967                        .and_then(Value::as_str)
6968                        .ok_or_else(|| invalid_feed("v2 upload reservation has no URL"))?;
6969                    put_presigned_source(
6970                        cfg,
6971                        url,
6972                        item.get("headers").unwrap_or(&Value::Null),
6973                        store,
6974                        source,
6975                    )?;
6976                    verify_v2_upload_source(store, &source.path, sha256, source.bytes)?;
6977                }
6978                Some("already_present") => {}
6979                _ => return Err(invalid_feed("v2 upload reservation has an unknown status")),
6980            }
6981            references.push(json!({
6982                "sha256": sha256,
6983                "bytes": source.bytes,
6984                "reservation_id": reservation_id,
6985            }));
6986        }
6987        body["blobs"] = Value::Array(references);
6988    }
6989    if body.to_string().len() > MAX_PUSH_BYTES {
6990        return Err(LinkError::PushTooLarge {
6991            detail: "v2 operation metadata exceeds the bounded commit request".to_string(),
6992        });
6993    }
6994    let path = format!("/api/hub/brains/{requested_brain}/v2/commits");
6995    let mut candidate_hub_signer: Option<String> = None;
6996    let mut response = request(cfg, "POST", &path, Some(&body), Auth::Required)?;
6997    let bulk_preview_required = !(200..300).contains(&response.status)
6998        && response.body.as_ref().is_some_and(|value| {
6999            value.get("code").and_then(Value::as_str) == Some("bulk_preview_required")
7000                || value
7001                    .get("details")
7002                    .and_then(|details| details.get("code"))
7003                    .and_then(Value::as_str)
7004                    == Some("bulk_preview_required")
7005        });
7006    if bulk_preview_required && bulk_confirmation.is_none() {
7007        body["rebase"] = Value::String("strict".to_string());
7008        body["preview_only"] = Value::Bool(true);
7009        let preview = ensure_ok(
7010            request(cfg, "POST", &path, Some(&body), Auth::Required)?,
7011            "v2 bulk preview",
7012        )?;
7013        let preview_code = preview.get("code").and_then(Value::as_str);
7014        let required = preview.get("required").and_then(Value::as_bool);
7015        if preview.get("v").and_then(Value::as_u64) != Some(2)
7016            || preview.get("mutation_id").and_then(Value::as_str) != Some(mutation_id.as_str())
7017            || !matches!(
7018                preview_code,
7019                Some("bulk_preview_created" | "bulk_preview_not_required")
7020            )
7021            || required.is_none()
7022        {
7023            return Err(invalid_feed(
7024                "bulk preview response is not bound to the requested mutation",
7025            ));
7026        }
7027        if required == Some(true) {
7028            let preview_id = preview.get("bulk_preview_id").and_then(Value::as_str);
7029            let preview_digest = preview.get("bulk_preview_digest").and_then(Value::as_str);
7030            if preview_code != Some("bulk_preview_created")
7031                || preview_id.is_none_or(|value| !crate::ulid::is_ulid(value))
7032                || preview_digest.is_none_or(|value| !is_sha256(value))
7033                || preview.get("expires_at").and_then(Value::as_str).is_none()
7034                || !preview.get("impact").is_some_and(Value::is_object)
7035            {
7036                return Err(invalid_feed("bulk preview receipt is malformed"));
7037            }
7038            return Err(LinkError::BulkPreviewRequired { preview });
7039        }
7040        if preview_code != Some("bulk_preview_not_required") {
7041            return Err(invalid_feed("bulk preview requirement is inconsistent"));
7042        }
7043        // The rolling activity window can expire between the refusal and
7044        // preview. Retry once, still pinned strictly to the observed head.
7045        body.as_object_mut()
7046            .expect("v2 commit request is an object")
7047            .remove("preview_only");
7048        response = request(cfg, "POST", &path, Some(&body), Auth::Required)?;
7049    }
7050    let mut result = ensure_ok(response, "v2 sync push")?;
7051    if result.get("code").and_then(Value::as_str) == Some("proposal_queued") {
7052        if let Some(object) = result.as_object_mut() {
7053            object.insert(
7054                "sync_status".to_string(),
7055                Value::String("proposal_pending".to_string()),
7056            );
7057        }
7058        return Ok(result);
7059    }
7060    if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
7061        let challenge = result
7062            .get("signing_challenge")
7063            .ok_or_else(|| invalid_feed("self-custody response has no signing challenge"))?;
7064        let mut expected_candidate = remote.clone();
7065        let mut expected_candidate_assets = remote_assets.clone();
7066        apply_generated_v2_operations(
7067            &operations,
7068            &local_assets,
7069            &mut expected_candidate,
7070            &mut expected_candidate_assets,
7071        )?;
7072        let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
7073            cfg,
7074            &head,
7075            &expected_candidate,
7076            &expected_candidate_assets,
7077            &mutation_id,
7078            &body,
7079            challenge,
7080        )?;
7081        body["signing_challenge_id"] = Value::String(challenge_id);
7082        body["signature_base64url"] = Value::String(signature);
7083        candidate_hub_signer = Some(actor_signer);
7084        result = ensure_ok(
7085            request(cfg, "POST", &path, Some(&body), Auth::Required)?,
7086            "v2 self-custody commit",
7087        )?;
7088    }
7089    let refreshed = v2_verified_head(cfg, requested_brain)?
7090        .ok_or_else(|| invalid_feed("v2 head disappeared after commit"))?;
7091    if candidate_hub_signer
7092        .as_ref()
7093        .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
7094    {
7095        return Err(invalid_feed(
7096            "self-custody actor signer differs from the committed hub pointer signer",
7097        ));
7098    }
7099    let accepted_hash = result.get("commit_hash").and_then(Value::as_str);
7100    if refreshed
7101        .pointer
7102        .as_ref()
7103        .map(|pointer| pointer.commit_hash.as_str())
7104        != accepted_hash
7105    {
7106        return Err(LinkError::RemoteAdvancedDuringSync);
7107    }
7108    ensure_v2_view_compatible(&refreshed, baseline.as_ref())?;
7109    let rebased = result
7110        .get("rebased")
7111        .and_then(Value::as_bool)
7112        .ok_or_else(|| invalid_feed("v2 commit receipt has no rebase result"))?;
7113    let (refreshed_files, refreshed_assets) = if rebased {
7114        (
7115            files_for_v2_view(
7116                &refreshed,
7117                v2_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
7118            ),
7119            v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
7120        )
7121    } else {
7122        let asset_changed = apply_generated_v2_operations(
7123            &operations,
7124            &local_assets,
7125            &mut remote,
7126            &mut remote_assets,
7127        )?;
7128        let assets = if asset_changed {
7129            // Asset leaf hashes are server-generated signed coordinates; only
7130            // asset-changing commits need this usually tiny post-commit read.
7131            v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?
7132        } else {
7133            remote_assets
7134        };
7135        (remote, assets)
7136    };
7137    let mut final_local = v2_local_files(store)?;
7138    remove_scoped_projection(&refreshed, baseline.as_ref(), &mut final_local)?;
7139    let final_assets = v2_local_asset_records(store)?;
7140    let local_dirty = final_local.riding != local_view.riding
7141        || !v2_riding_matches_remote(&final_local.riding, &refreshed_files, |path| {
7142            final_local.policy.keeps_home(path)
7143        })
7144        || final_assets != local_assets
7145        || !v2_asset_records_match_remote(&final_assets, &refreshed_assets);
7146    let next = v2_baseline_from_head(
7147        cfg,
7148        &refreshed,
7149        refreshed_files,
7150        refreshed_assets,
7151        Some(&final_local),
7152        Some(&checkout_pseudonym),
7153    )?;
7154    let split_count = next.remote_copy_remains.len();
7155    accept_v2_head(cfg, &refreshed)?;
7156    if !local_dirty {
7157        refresh_scoped_view_marker(store, &refreshed, next.files.len())?;
7158        save_v2_baseline(cfg, &refreshed.brain_id, &store.root, &next)?;
7159    }
7160    if let Some(object) = result.as_object_mut() {
7161        object.insert(
7162            "local_policy".to_string(),
7163            json!({ "remote_copy_remains": split_count }),
7164        );
7165        object.insert(
7166            "sync_status".to_string(),
7167            Value::String(if local_dirty {
7168                "remote_committed_local_dirty".to_string()
7169            } else {
7170                "synced".to_string()
7171            }),
7172        );
7173    }
7174    Ok(result)
7175}
7176
7177/// Negotiate v2 and send only local changes. A v1 hub retains the existing
7178/// whole-snapshot behavior until its brain advertises the new profile.
7179pub fn sync_push_incremental(cfg: &HubConfig, brain: &str, store: &Store) -> LinkResult<Value> {
7180    sync_push_incremental_with_policy(cfg, brain, store, false)
7181}
7182
7183/// The explicit `.sevralocal` adoption form. Ordinary sync never uploads a
7184/// path that became eligible because local policy was weakened or removed.
7185pub fn sync_push_incremental_with_policy(
7186    cfg: &HubConfig,
7187    brain: &str,
7188    store: &Store,
7189    resume_local_policy: bool,
7190) -> LinkResult<Value> {
7191    sync_push_incremental_with_options(cfg, brain, store, resume_local_policy, None)
7192}
7193
7194/// Incremental push with both local-policy adoption and an optional exact
7195/// permissioned bulk-preview confirmation.
7196pub fn sync_push_incremental_with_options(
7197    cfg: &HubConfig,
7198    brain: &str,
7199    store: &Store,
7200    resume_local_policy: bool,
7201    bulk_confirmation: Option<&V2BulkConfirmation>,
7202) -> LinkResult<Value> {
7203    sync_push_incremental_with_controls(
7204        cfg,
7205        brain,
7206        store,
7207        resume_local_policy,
7208        bulk_confirmation,
7209        &[],
7210        None,
7211    )
7212}
7213
7214/// Incremental push with explicit permissioned current-host withdrawal.
7215pub fn sync_push_incremental_with_controls(
7216    cfg: &HubConfig,
7217    brain: &str,
7218    store: &Store,
7219    resume_local_policy: bool,
7220    bulk_confirmation: Option<&V2BulkConfirmation>,
7221    withdrawal_paths: &[String],
7222    withdrawal_reason: Option<&str>,
7223) -> LinkResult<Value> {
7224    require_safe_ref(brain)?;
7225    if let Some(head) = v2_verified_head(cfg, brain)? {
7226        return v2_sync_push(
7227            cfg,
7228            brain,
7229            store,
7230            head,
7231            V2SyncPushOptions {
7232                resume_local_policy,
7233                bulk_confirmation,
7234                resolution: None,
7235                pulled: None,
7236                withdrawal_paths,
7237                withdrawal_reason,
7238            },
7239        );
7240    }
7241    if !withdrawal_paths.is_empty() {
7242        return Err(LinkError::InvalidPack {
7243            message: "explicit withdrawal requires a link.md v2 brain".to_string(),
7244        });
7245    }
7246    legacy_sync_push_incremental(cfg, brain, store, resume_local_policy, bulk_confirmation)
7247}
7248
7249/// Whether this exact hub/brain/path coordinate has an accepted v2 sync
7250/// baseline. CLI dispatch uses this to keep checkout identity sticky even when
7251/// a damaged `DB.md` makes strict store-open fail.
7252pub fn has_v2_sync_baseline(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<bool> {
7253    require_safe_ref(brain)?;
7254    Ok(load_v2_baseline(cfg, brain, checkout)?.is_some())
7255}
7256
7257#[cfg(windows)]
7258fn legacy_sync_push_incremental(
7259    _cfg: &HubConfig,
7260    _brain: &str,
7261    _store: &Store,
7262    _resume_local_policy: bool,
7263    _bulk_confirmation: Option<&V2BulkConfirmation>,
7264) -> LinkResult<Value> {
7265    Err(LinkError::UnsupportedPlatform {
7266        operation: "legacy v1 whole-snapshot push on Windows; upgrade the brain to link.md v2",
7267    })
7268}
7269
7270#[cfg(not(windows))]
7271fn legacy_sync_push_incremental(
7272    cfg: &HubConfig,
7273    brain: &str,
7274    store: &Store,
7275    resume_local_policy: bool,
7276    bulk_confirmation: Option<&V2BulkConfirmation>,
7277) -> LinkResult<Value> {
7278    if resume_local_policy || bulk_confirmation.is_some() {
7279        return Err(LinkError::InvalidPack {
7280            message: "v2 sync options require a link.md v2 brain".to_string(),
7281        });
7282    }
7283    let files = collect_push_files(store)?;
7284    sync_push(cfg, brain, &files)
7285}
7286
7287/// One explicit resolution for a previously preserved private conflict bundle.
7288#[derive(Debug, Clone)]
7289pub enum V2ConflictChoice {
7290    KeepLocal,
7291    TakeRemote,
7292    From(PathBuf),
7293}
7294
7295fn load_v2_conflict_plan(store: &Store, bundle: &str) -> LinkResult<V2ConflictPlan> {
7296    if !crate::ulid::is_ulid(bundle) {
7297        return Err(LinkError::InvalidPack {
7298            message: "conflict bundle must be a lowercase ULID".to_string(),
7299        });
7300    }
7301    let bytes = store.read_bounded(&v2_conflict_relative(bundle, "plan.json"), 1024 * 1024)?;
7302    let plan: V2ConflictPlan = serde_json::from_slice(&bytes)
7303        .map_err(|_| invalid_feed("private conflict plan is corrupt"))?;
7304    if plan.v != 2
7305        || plan.class != "content_resolution_required"
7306        || plan.bundle != bundle
7307        || !crate::ulid::is_ulid(&plan.brain)
7308        || plan.files.is_empty()
7309        || plan.files.len() > 100
7310        || plan.files.iter().any(|file| {
7311            crate::linkmd_v2::normalize_path(&file.path).is_err()
7312                || [&file.base, &file.local, &file.remote]
7313                    .into_iter()
7314                    .any(|coordinate| {
7315                        coordinate
7316                            .sha256
7317                            .as_deref()
7318                            .is_some_and(|hash| !is_sha256(hash))
7319                            || coordinate.file.as_deref().is_some_and(|name| {
7320                                name.starts_with('/')
7321                                    || name
7322                                        .split('/')
7323                                        .any(|part| part.is_empty() || part == "." || part == "..")
7324                            })
7325                    })
7326        })
7327    {
7328        return Err(invalid_feed("private conflict plan failed validation"));
7329    }
7330    Ok(plan)
7331}
7332
7333/// Inspect or prune private conflict-control state. Ordinary pruning removes
7334/// only expired completed bundles and interrupted bundles with no `plan.json`;
7335/// corrupt completed plans fail closed. `all` is an explicit local discard of
7336/// every well-addressed bundle and never changes hosted brain data.
7337pub fn sync_conflicts(checkout: &Path, prune: bool, all: bool) -> LinkResult<Value> {
7338    require_hardened_filesystem("private conflict maintenance")?;
7339    if all && !prune {
7340        return Err(LinkError::InvalidPack {
7341            message: "discarding all conflict bundles requires prune=true".to_string(),
7342        });
7343    }
7344    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
7345        message: format!("conflict checkout is not a valid db.md store: {error}"),
7346    })?;
7347    let _transaction = store.transaction()?;
7348    let root = Path::new(".dbmd/conflicts");
7349    let names = match store.directory_names(root) {
7350        Ok(names) => names,
7351        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
7352        Err(error) => return Err(error.into()),
7353    };
7354    let now = SystemTime::now()
7355        .duration_since(UNIX_EPOCH)
7356        .unwrap_or_default()
7357        .as_secs();
7358    let mut bundles = Vec::new();
7359    let mut pruned = 0_u64;
7360    for name in names {
7361        let Some(bundle) = name.to_str().filter(|value| crate::ulid::is_ulid(value)) else {
7362            continue;
7363        };
7364        let plan_path = v2_conflict_relative(bundle, "plan.json");
7365        let plan_exists = store.regular_file_exists(&plan_path)?;
7366        let expired = if plan_exists {
7367            match load_v2_conflict_plan(&store, bundle) {
7368                Ok(plan) => plan.expires_unix < now,
7369                Err(error) if all => {
7370                    let _ = error;
7371                    true
7372                }
7373                Err(error) => return Err(error),
7374            }
7375        } else {
7376            true
7377        };
7378        if prune && (all || expired) {
7379            store.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
7380            pruned += 1;
7381            continue;
7382        }
7383        bundles.push(json!({
7384            "bundle": bundle,
7385            "complete": plan_exists,
7386            "expired": expired,
7387        }));
7388    }
7389    Ok(json!({
7390        "v": 2,
7391        "class": "private_conflict_state",
7392        "bundles": bundles.len(),
7393        "pruned": pruned,
7394        "items": bundles,
7395    }))
7396}
7397
7398/// Resolve one exact private conflict plan. Drift never lowers a precondition:
7399/// the remote head/view and every original local coordinate are rechecked
7400/// before either an explicit local install or a fresh normal commit.
7401pub fn sync_resolve_conflict(
7402    cfg: &HubConfig,
7403    checkout: &Path,
7404    bundle: &str,
7405    choice: V2ConflictChoice,
7406    bulk_confirmation: Option<&V2BulkConfirmation>,
7407) -> LinkResult<Value> {
7408    require_hardened_filesystem("conflict resolution")?;
7409    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
7410        message: format!("conflict checkout is not a valid db.md store: {error}"),
7411    })?;
7412    let plan = load_v2_conflict_plan(&store, bundle)?;
7413    if plan.origin != normalized_origin(&cfg.hub)? {
7414        return Err(invalid_feed(
7415            "conflict bundle belongs to another hub origin",
7416        ));
7417    }
7418    let now = SystemTime::now()
7419        .duration_since(UNIX_EPOCH)
7420        .unwrap_or_default()
7421        .as_secs();
7422    if now > plan.expires_unix {
7423        return Err(LinkError::InvalidPack {
7424            message: "conflict bundle expired; rerun sync to obtain current coordinates"
7425                .to_string(),
7426        });
7427    }
7428    let head = v2_verified_head(cfg, &plan.brain)?
7429        .ok_or_else(|| invalid_feed("conflict brain no longer advertises v2"))?;
7430    let pointer = head.pointer.as_ref();
7431    if pointer.map_or(0, |value| value.seq) != plan.remote_seq
7432        || pointer.map(|value| value.commit_hash.as_str()) != plan.remote_commit.as_deref()
7433        || pointer.and_then(|value| value.content_root.as_deref())
7434            != plan.remote_content_root.as_deref()
7435        || head.view_kind != plan.view_kind
7436        || head.view_revision != plan.view_revision
7437    {
7438        return Err(LinkError::RemoteAdvancedDuringSync);
7439    }
7440
7441    // A resolution never overwrites a post-bundle local edit.
7442    for file in &plan.files {
7443        let actual = match store.regular_file_exists(Path::new(&file.path))? {
7444            true => Some(content_sha256(&store.read_bounded(
7445                Path::new(&file.path),
7446                file.local.bytes.unwrap_or(MAX_STORE_BYTES),
7447            )?)),
7448            false => None,
7449        };
7450        if actual.as_deref() != file.local.sha256.as_deref() {
7451            return Err(LinkError::InvalidPack {
7452                message: format!(
7453                    "local conflict path `{}` changed after the bundle was created",
7454                    file.path
7455                ),
7456            });
7457        }
7458    }
7459
7460    let from_source = match &choice {
7461        V2ConflictChoice::From(source) => Some(source.clone()),
7462        _ => None,
7463    };
7464    let result = match choice {
7465        V2ConflictChoice::TakeRemote => {
7466            if bulk_confirmation.is_some() {
7467                return Err(LinkError::InvalidPack {
7468                    message: "bulk confirmation applies to keep-local/from commits, not a local take-remote install".to_string(),
7469                });
7470            }
7471            // Re-read the exact current manifest so every selected remote byte
7472            // retains its signed inclusion proof. The private bundle is useful
7473            // for inspection, but is never promoted into live state as trust.
7474            let current_remote =
7475                files_for_v2_view(&head, v2_manifest(cfg, &plan.brain, head.pointer.as_ref())?);
7476            let _ = v2_take_remote_selection(&plan.files, &current_remote)?;
7477            let selected = plan
7478                .files
7479                .iter()
7480                .map(|file| file.path.clone())
7481                .collect::<std::collections::BTreeSet<_>>();
7482            serde_json::to_value(
7483                v2_sync_pull_with_resolution(
7484                    cfg,
7485                    &plan.brain,
7486                    head,
7487                    Some(checkout),
7488                    Some(&selected),
7489                )?
7490                .report,
7491            )
7492            .map_err(|_| invalid_feed("could not serialize conflict pull receipt"))?
7493        }
7494        V2ConflictChoice::KeepLocal | V2ConflictChoice::From(_) => {
7495            if let Some(source) = from_source.as_ref() {
7496                if plan.files.len() != 1 {
7497                    return Err(LinkError::InvalidPack {
7498                        message: "--from requires a bundle with exactly one conflict".to_string(),
7499                    });
7500                }
7501                let candidate = crate::fsx::read_bounded_nofollow(source, MAX_STORE_BYTES)?;
7502                if std::str::from_utf8(&candidate).is_err() {
7503                    return Err(LinkError::NotUtf8 {
7504                        path: source.display().to_string(),
7505                    });
7506                }
7507                store.write_atomic(Path::new(&plan.files[0].path), &candidate)?;
7508            }
7509            let refreshed_store =
7510                Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
7511                    message: format!("resolved checkout is not a valid db.md store: {error}"),
7512                })?;
7513            let mut overrides = std::collections::BTreeMap::new();
7514            for file in &plan.files {
7515                let selected_local = match refreshed_store
7516                    .regular_file_exists(Path::new(&file.path))?
7517                {
7518                    true => Some(content_sha256(
7519                        &refreshed_store.read_bounded(Path::new(&file.path), MAX_STORE_BYTES)?,
7520                    )),
7521                    false => None,
7522                };
7523                overrides.insert(
7524                    file.path.clone(),
7525                    V2ResolutionOverride {
7526                        expected_remote: file.remote.sha256.clone(),
7527                        selected_local,
7528                    },
7529                );
7530            }
7531            v2_sync_push(
7532                cfg,
7533                &plan.brain,
7534                &refreshed_store,
7535                head,
7536                V2SyncPushOptions {
7537                    resume_local_policy: true,
7538                    bulk_confirmation,
7539                    resolution: Some(&overrides),
7540                    pulled: None,
7541                    withdrawal_paths: &[],
7542                    withdrawal_reason: None,
7543                },
7544            )?
7545        }
7546    };
7547
7548    if result.get("code").and_then(Value::as_str) != Some("proposal_queued") {
7549        let installed = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
7550            message: format!("resolved checkout is not a valid db.md store: {error}"),
7551        })?;
7552        installed.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
7553    }
7554    Ok(json!({
7555        "v": 2,
7556        "class": "auto_converged",
7557        "bundle": bundle,
7558        "receipt": result,
7559    }))
7560}
7561
7562/// Converge one established permissioned-v2 checkout in both directions.
7563///
7564/// The pull half first performs the ordinary three-way conflict check and
7565/// crash-safe install against the accepted baseline. The push half then opens
7566/// that exact installed checkout, takes its local transaction guard, computes
7567/// a fresh three-way delta, and submits it with mandatory preconditions. A
7568/// remote movement between halves is therefore a normal rebase/conflict input,
7569/// never a blind overwrite. Legacy v1 remains explicit pull-only/push-only so
7570/// its whole-pack replacement semantics are never presented as granular
7571/// convergence.
7572pub fn sync_converge(
7573    cfg: &HubConfig,
7574    brain: &str,
7575    checkout: &Path,
7576    resume_local_policy: bool,
7577) -> LinkResult<Value> {
7578    sync_converge_with_options(cfg, brain, checkout, resume_local_policy, None)
7579}
7580
7581/// Bidirectional convergence with optional exact bulk-preview confirmation.
7582pub fn sync_converge_with_options(
7583    cfg: &HubConfig,
7584    brain: &str,
7585    checkout: &Path,
7586    resume_local_policy: bool,
7587    bulk_confirmation: Option<&V2BulkConfirmation>,
7588) -> LinkResult<Value> {
7589    sync_converge_with_controls(
7590        cfg,
7591        brain,
7592        checkout,
7593        resume_local_policy,
7594        bulk_confirmation,
7595        &[],
7596        None,
7597    )
7598}
7599
7600/// Bidirectional convergence with optional exact withdrawal intents.
7601pub fn sync_converge_with_controls(
7602    cfg: &HubConfig,
7603    brain: &str,
7604    checkout: &Path,
7605    resume_local_policy: bool,
7606    bulk_confirmation: Option<&V2BulkConfirmation>,
7607    withdrawal_paths: &[String],
7608    withdrawal_reason: Option<&str>,
7609) -> LinkResult<Value> {
7610    require_hardened_filesystem("bidirectional sync")?;
7611    require_safe_ref(brain)?;
7612    let head = v2_verified_head(cfg, brain)?.ok_or_else(|| LinkError::InvalidPack {
7613        message:
7614            "bidirectional sync requires link.md v2; use --pull-only or --push-only for a legacy brain"
7615                .to_string(),
7616    })?;
7617    let pulled = v2_sync_pull_with_resolution(cfg, brain, head, Some(checkout), None)?;
7618    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
7619        message: format!("installed v2 checkout is not a valid db.md store: {error}"),
7620    })?;
7621    let _transaction = store.transaction()?;
7622    let pulled_report = pulled.report.clone();
7623    let pulled_head = pulled.head.clone();
7624    let mut result = v2_sync_push(
7625        cfg,
7626        brain,
7627        &store,
7628        pulled_head,
7629        V2SyncPushOptions {
7630            resume_local_policy,
7631            bulk_confirmation,
7632            resolution: None,
7633            pulled: Some(pulled),
7634            withdrawal_paths,
7635            withdrawal_reason,
7636        },
7637    )?;
7638    if let Some(object) = result.as_object_mut() {
7639        object.insert("pulled_files".to_string(), json!(pulled_report.files));
7640        object.insert("checkout".to_string(), Value::String(pulled_report.dest));
7641        object.insert(
7642            "mode".to_string(),
7643            Value::String("bidirectional".to_string()),
7644        );
7645    }
7646    Ok(result)
7647}
7648
7649/// Pull the granted slice of `brain` to `out` (default: `./<slug>`). Every
7650/// exported path is safety-gated before it touches disk; files are written
7651/// atomically; nothing local is ever deleted (locals the export lacks are
7652/// *reported* in `extra_local` instead). Returns the report; rebuilding the
7653/// local index catalog afterwards is the caller's (cheap, optional) step.
7654pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
7655    require_hardened_filesystem("sync pull")?;
7656    require_safe_ref(brain)?;
7657    if let Some(head) = v2_verified_head(cfg, brain)? {
7658        return v2_sync_pull(cfg, brain, head, out);
7659    }
7660    legacy_sync_pull(cfg, brain, out)
7661}
7662
7663#[cfg(windows)]
7664fn legacy_sync_pull(_cfg: &HubConfig, _brain: &str, _out: Option<&Path>) -> LinkResult<PullReport> {
7665    Err(LinkError::UnsupportedPlatform {
7666        operation: "legacy v1 whole-snapshot pull on Windows; upgrade the brain to link.md v2",
7667    })
7668}
7669
7670#[cfg(not(windows))]
7671fn legacy_sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
7672    let remote = verified_remote_head(cfg, brain, false)?;
7673    if !remote.head.verified {
7674        return Err(invalid_feed(
7675            "the grant exposes head movement but no signed snapshot; refusing an unverifiable pull",
7676        ));
7677    }
7678    let snapshot_hash = remote.head.feed_hash.as_deref().unwrap_or("none");
7679    let path = format!(
7680        "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={snapshot_hash}",
7681        remote.head.seq
7682    );
7683    let body = ensure_ok(
7684        request(cfg, "GET", &path, None, Auth::Required)?,
7685        "sync pull",
7686    )?;
7687    if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
7688        || body.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
7689    {
7690        return Err(invalid_feed(
7691            "export response is not bound to the verified snapshot token",
7692        ));
7693    }
7694
7695    let remote_slug = body
7696        .get("slug")
7697        .and_then(Value::as_str)
7698        .filter(|slug| is_safe_slug(slug));
7699    let slug = remote_slug
7700        .or_else(|| is_safe_slug(brain).then_some(brain))
7701        .unwrap_or("brain")
7702        .to_string();
7703    let brain_id = body
7704        .get("brain")
7705        .and_then(Value::as_str)
7706        .unwrap_or(&remote.head.brain)
7707        .to_string();
7708    if brain_id != remote.head.brain {
7709        return Err(invalid_feed(
7710            "export response names a different brain than the verified head",
7711        ));
7712    }
7713    let head_seq = remote.head.seq;
7714    let dest: PathBuf = match out {
7715        Some(p) => p.to_path_buf(),
7716        None => PathBuf::from(&slug),
7717    };
7718    let entries = if head_seq == 0 {
7719        let files = body
7720            .get("files")
7721            .and_then(Value::as_array)
7722            .ok_or_else(|| invalid_feed("empty snapshot export did not carry an empty manifest"))?;
7723        if !files.is_empty() || body.get("url").is_some() {
7724            return Err(invalid_feed(
7725                "empty signed feed cannot authorize non-empty exported content",
7726            ));
7727        }
7728        Vec::new()
7729    } else {
7730        let signed_head = remote
7731            .head_entry
7732            .as_ref()
7733            .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
7734        let expected = &signed_head.entry.pack_sha256;
7735        if !is_sha256(expected) {
7736            return Err(invalid_feed(
7737                "signed head carries an invalid snapshot pack digest",
7738            ));
7739        }
7740        if let Some(url) = body.get("url").and_then(Value::as_str) {
7741            if body.get("sha256").and_then(Value::as_str) != Some(expected.as_str()) {
7742                return Err(invalid_feed(
7743                    "export pack digest does not match the signed head entry",
7744                ));
7745            }
7746            let bytes = get_presigned(cfg, url)?;
7747            let actual = format!("{:x}", Sha256::digest(&bytes));
7748            if actual != *expected {
7749                return Err(LinkError::InvalidPack {
7750                    message: "downloaded pack does not match the signed snapshot digest"
7751                        .to_string(),
7752                });
7753            }
7754            let entries = parse_store_pack(bytes)?;
7755            if signed_head.entry.kind == "push" {
7756                verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
7757            }
7758            entries
7759        } else {
7760            if signed_head.entry.kind != "push" {
7761                return Err(invalid_feed(
7762                    "delta snapshots must export the exact signed pack",
7763                ));
7764            }
7765            let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
7766                invalid_feed("verified snapshot export carried neither a pack nor files")
7767            })?;
7768            let mut entries = Vec::with_capacity(files.len());
7769            for file in files {
7770                let path = file
7771                    .get("path")
7772                    .and_then(Value::as_str)
7773                    .ok_or_else(|| invalid_feed("exported file path is not a string"))?;
7774                let content = file
7775                    .get("content")
7776                    .and_then(Value::as_str)
7777                    .ok_or_else(|| invalid_feed("exported inline content is not UTF-8 text"))?;
7778                entries.push((path.to_string(), content.as_bytes().to_vec()));
7779            }
7780            verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
7781            entries
7782        }
7783    };
7784
7785    // Gate the complete manifest before the first filesystem mutation.
7786    let mut seen = std::collections::HashSet::new();
7787    for (path, _) in &entries {
7788        if !safe_store_rel_path(path) {
7789            return Err(LinkError::UnsafePath { path: path.clone() });
7790        }
7791        if !seen.insert(path) {
7792            return Err(LinkError::InvalidPack {
7793                message: format!("duplicate path `{path}`"),
7794            });
7795        }
7796    }
7797    // Compute divergence against the still-live tree. The staged clone keeps
7798    // these extra locals byte-for-byte while overlaying the signed snapshot.
7799    let pulled: std::collections::BTreeSet<&str> =
7800        entries.iter().map(|(p, _)| p.as_str()).collect();
7801    let mut extra_local = Vec::new();
7802    if let Ok(store) = Store::open(&dest) {
7803        if let Ok(walked) = store.walk() {
7804            for rel in walked {
7805                let rel_str = rel.to_string_lossy().replace('\\', "/");
7806                if !pulled.contains(rel_str.as_str()) {
7807                    extra_local.push(rel_str);
7808                }
7809            }
7810        }
7811    }
7812    #[cfg(unix)]
7813    install_pulled_snapshot(&dest, &entries)?;
7814
7815    Ok(PullReport {
7816        brain: brain_id,
7817        slug,
7818        head_seq,
7819        files: entries.len(),
7820        dest: dest.to_string_lossy().into_owned(),
7821        extra_local,
7822        sync_status: "synced".to_string(),
7823    })
7824}
7825
7826#[cfg(unix)]
7827fn c_name(bytes: &[u8], display: &str) -> LinkResult<std::ffi::CString> {
7828    std::ffi::CString::new(bytes).map_err(|_| LinkError::UnsafePath {
7829        path: display.to_string(),
7830    })
7831}
7832
7833#[cfg(unix)]
7834fn open_dir_at(
7835    parent: std::os::fd::RawFd,
7836    name: &std::ffi::CStr,
7837    display: &str,
7838) -> LinkResult<std::fs::File> {
7839    use std::os::fd::FromRawFd as _;
7840    let fd = unsafe {
7841        libc::openat(
7842            parent,
7843            name.as_ptr(),
7844            libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
7845        )
7846    };
7847    if fd < 0 {
7848        return Err(LinkError::UnsafePath {
7849            path: display.to_string(),
7850        });
7851    }
7852    Ok(unsafe { std::fs::File::from_raw_fd(fd) })
7853}
7854
7855/// Open (and, where absent, create) a directory path without following a
7856/// symlink in any component. The returned directory capability remains bound
7857/// to the opened inode even if an attacker renames or replaces an ancestor.
7858#[cfg(unix)]
7859fn open_dir_path_nofollow(path: &Path, create: bool) -> LinkResult<std::fs::File> {
7860    use std::os::fd::AsRawFd as _;
7861
7862    // macOS exposes a few system-owned compatibility symlinks at the root.
7863    // Normalize only those fixed OS aliases; never canonicalize an arbitrary
7864    // caller-controlled ancestor.
7865    #[cfg(target_os = "macos")]
7866    let normalized = [("/var", "/private/var"), ("/tmp", "/private/tmp")]
7867        .into_iter()
7868        .find_map(|(alias, real)| {
7869            path.strip_prefix(alias)
7870                .ok()
7871                .map(|rest| Path::new(real).join(rest))
7872        })
7873        .unwrap_or_else(|| path.to_path_buf());
7874    #[cfg(not(target_os = "macos"))]
7875    let normalized = path.to_path_buf();
7876
7877    let start = if normalized.is_absolute() {
7878        std::fs::File::open("/")?
7879    } else {
7880        std::fs::File::open(".")?
7881    };
7882    let mut directory = start;
7883    for component in normalized.components() {
7884        use std::path::Component;
7885        let name = match component {
7886            Component::RootDir | Component::CurDir => continue,
7887            Component::Normal(name) => name,
7888            Component::ParentDir | Component::Prefix(_) => {
7889                return Err(LinkError::UnsafePath {
7890                    path: path.display().to_string(),
7891                });
7892            }
7893        };
7894        use std::os::unix::ffi::OsStrExt as _;
7895        let name = c_name(name.as_bytes(), &path.display().to_string())?;
7896        if create {
7897            let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
7898            if made != 0 {
7899                let error = std::io::Error::last_os_error();
7900                if error.raw_os_error() != Some(libc::EEXIST) {
7901                    return Err(error.into());
7902                }
7903            }
7904        }
7905        directory = open_dir_at(directory.as_raw_fd(), &name, &path.display().to_string())?;
7906    }
7907    Ok(directory)
7908}
7909
7910#[cfg(unix)]
7911fn open_or_create_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
7912    open_dir_path_nofollow(path, true)
7913}
7914
7915#[cfg(unix)]
7916fn open_existing_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
7917    open_dir_path_nofollow(path, false)
7918}
7919
7920#[cfg(unix)]
7921fn entry_is_dir_at(parent: std::os::fd::RawFd, name: &std::ffi::CStr) -> LinkResult<Option<bool>> {
7922    let mut stat: libc::stat = unsafe { std::mem::zeroed() };
7923    let result =
7924        unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) };
7925    if result == 0 {
7926        return Ok(Some((stat.st_mode & libc::S_IFMT) == libc::S_IFDIR));
7927    }
7928    let error = std::io::Error::last_os_error();
7929    if error.kind() == std::io::ErrorKind::NotFound {
7930        Ok(None)
7931    } else {
7932        Err(error.into())
7933    }
7934}
7935
7936#[cfg(unix)]
7937fn create_dir_exclusive_at(
7938    parent: std::os::fd::RawFd,
7939    name: &std::ffi::CStr,
7940    display: &str,
7941) -> LinkResult<std::fs::File> {
7942    let made = unsafe { libc::mkdirat(parent, name.as_ptr(), 0o700) };
7943    if made != 0 {
7944        return Err(LinkError::UnsafePath {
7945            path: display.to_string(),
7946        });
7947    }
7948    open_dir_at(parent, name, display)
7949}
7950
7951#[cfg(unix)]
7952fn directory_entry_names(directory: &std::fs::File) -> LinkResult<Vec<std::ffi::CString>> {
7953    use std::os::fd::AsRawFd as _;
7954
7955    let duplicate = unsafe { libc::dup(directory.as_raw_fd()) };
7956    if duplicate < 0 {
7957        return Err(std::io::Error::last_os_error().into());
7958    }
7959    let stream = unsafe { libc::fdopendir(duplicate) };
7960    if stream.is_null() {
7961        let error = std::io::Error::last_os_error();
7962        unsafe {
7963            libc::close(duplicate);
7964        }
7965        return Err(error.into());
7966    }
7967    let mut names = Vec::new();
7968    loop {
7969        let entry = unsafe { libc::readdir(stream) };
7970        if entry.is_null() {
7971            break;
7972        }
7973        let raw = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) };
7974        if raw.to_bytes() != b"." && raw.to_bytes() != b".." {
7975            names.push(raw.to_owned());
7976        }
7977    }
7978    if unsafe { libc::closedir(stream) } != 0 {
7979        return Err(std::io::Error::last_os_error().into());
7980    }
7981    Ok(names)
7982}
7983
7984/// Remove an entry tree relative to a held directory capability. Symlinks are
7985/// unlinked, never traversed, even when an old mirror contains hostile names.
7986#[cfg(unix)]
7987fn remove_tree_at(
7988    parent: std::os::fd::RawFd,
7989    name: &std::ffi::CStr,
7990    display: &str,
7991) -> LinkResult<()> {
7992    use std::os::fd::AsRawFd as _;
7993
7994    match entry_is_dir_at(parent, name)? {
7995        None => return Ok(()),
7996        Some(false) => {
7997            if unsafe { libc::unlinkat(parent, name.as_ptr(), 0) } != 0 {
7998                return Err(std::io::Error::last_os_error().into());
7999            }
8000        }
8001        Some(true) => {
8002            let directory = open_dir_at(parent, name, display)?;
8003            for child in directory_entry_names(&directory)? {
8004                let child_display =
8005                    format!("{display}/{}", String::from_utf8_lossy(child.to_bytes()));
8006                remove_tree_at(directory.as_raw_fd(), &child, &child_display)?;
8007            }
8008            drop(directory);
8009            if unsafe { libc::unlinkat(parent, name.as_ptr(), libc::AT_REMOVEDIR) } != 0 {
8010                return Err(std::io::Error::last_os_error().into());
8011            }
8012        }
8013    }
8014    Ok(())
8015}
8016
8017/// Clone a live destination into a private sibling stage without following
8018/// any symlink. Regular files are streamed between held descriptors; symlinks
8019/// are reproduced as links, never opened. Special files are refused.
8020#[cfg(unix)]
8021fn clone_tree_contents(
8022    source: &std::fs::File,
8023    destination: &std::fs::File,
8024    display: &str,
8025) -> LinkResult<()> {
8026    use std::os::fd::{AsRawFd as _, FromRawFd as _};
8027
8028    for name in directory_entry_names(source)? {
8029        let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
8030        let mut stat: libc::stat = unsafe { std::mem::zeroed() };
8031        if unsafe {
8032            libc::fstatat(
8033                source.as_raw_fd(),
8034                name.as_ptr(),
8035                &mut stat,
8036                libc::AT_SYMLINK_NOFOLLOW,
8037            )
8038        } != 0
8039        {
8040            return Err(std::io::Error::last_os_error().into());
8041        }
8042        match stat.st_mode & libc::S_IFMT {
8043            libc::S_IFDIR => {
8044                if unsafe {
8045                    libc::mkdirat(destination.as_raw_fd(), name.as_ptr(), stat.st_mode & 0o777)
8046                } != 0
8047                {
8048                    return Err(std::io::Error::last_os_error().into());
8049                }
8050                let source_child = open_dir_at(source.as_raw_fd(), &name, &child_display)?;
8051                let destination_child =
8052                    open_dir_at(destination.as_raw_fd(), &name, &child_display)?;
8053                clone_tree_contents(&source_child, &destination_child, &child_display)?;
8054                destination_child.sync_all()?;
8055            }
8056            libc::S_IFREG => {
8057                let source_fd = unsafe {
8058                    libc::openat(
8059                        source.as_raw_fd(),
8060                        name.as_ptr(),
8061                        libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
8062                    )
8063                };
8064                if source_fd < 0 {
8065                    return Err(std::io::Error::last_os_error().into());
8066                }
8067                let destination_fd = unsafe {
8068                    libc::openat(
8069                        destination.as_raw_fd(),
8070                        name.as_ptr(),
8071                        libc::O_WRONLY
8072                            | libc::O_CREAT
8073                            | libc::O_EXCL
8074                            | libc::O_CLOEXEC
8075                            | libc::O_NOFOLLOW,
8076                        (stat.st_mode & 0o777) as libc::c_uint,
8077                    )
8078                };
8079                if destination_fd < 0 {
8080                    unsafe {
8081                        libc::close(source_fd);
8082                    }
8083                    return Err(std::io::Error::last_os_error().into());
8084                }
8085                let mut input = unsafe { std::fs::File::from_raw_fd(source_fd) };
8086                let mut output = unsafe { std::fs::File::from_raw_fd(destination_fd) };
8087                std::io::copy(&mut input, &mut output)?;
8088                output.sync_all()?;
8089            }
8090            libc::S_IFLNK => {
8091                let mut target = vec![0_u8; 4097];
8092                let length = unsafe {
8093                    libc::readlinkat(
8094                        source.as_raw_fd(),
8095                        name.as_ptr(),
8096                        target.as_mut_ptr().cast(),
8097                        target.len(),
8098                    )
8099                };
8100                if length < 0 || length as usize >= target.len() {
8101                    return Err(LinkError::UnsafePath {
8102                        path: child_display,
8103                    });
8104                }
8105                target.truncate(length as usize);
8106                let target = c_name(&target, &child_display)?;
8107                if unsafe {
8108                    libc::symlinkat(target.as_ptr(), destination.as_raw_fd(), name.as_ptr())
8109                } != 0
8110                {
8111                    return Err(std::io::Error::last_os_error().into());
8112                }
8113            }
8114            _ => {
8115                return Err(LinkError::UnsafePath {
8116                    path: child_display,
8117                });
8118            }
8119        }
8120    }
8121    destination.sync_all()?;
8122    Ok(())
8123}
8124
8125#[cfg(target_os = "linux")]
8126fn install_stage_at(
8127    parent: std::os::fd::RawFd,
8128    stage: &std::ffi::CStr,
8129    dest: &std::ffi::CStr,
8130    dest_exists: bool,
8131) -> LinkResult<()> {
8132    let flags = if dest_exists {
8133        libc::RENAME_EXCHANGE
8134    } else {
8135        libc::RENAME_NOREPLACE
8136    };
8137    // `libc::renameat2` is not exported for the musl targets we ship. Invoke
8138    // the kernel ABI directly, as `fsx::renameat_noreplace` does, so the same
8139    // atomic exchange/no-replace boundary compiles for glibc and musl.
8140    let result = unsafe {
8141        libc::syscall(
8142            libc::SYS_renameat2,
8143            parent,
8144            stage.as_ptr(),
8145            parent,
8146            dest.as_ptr(),
8147            flags,
8148        )
8149    };
8150    if result == 0 {
8151        Ok(())
8152    } else {
8153        Err(std::io::Error::last_os_error().into())
8154    }
8155}
8156
8157#[cfg(target_os = "macos")]
8158fn install_stage_at(
8159    parent: std::os::fd::RawFd,
8160    stage: &std::ffi::CStr,
8161    dest: &std::ffi::CStr,
8162    dest_exists: bool,
8163) -> LinkResult<()> {
8164    let flags = if dest_exists {
8165        libc::RENAME_SWAP
8166    } else {
8167        libc::RENAME_EXCL
8168    };
8169    let result =
8170        unsafe { libc::renameatx_np(parent, stage.as_ptr(), parent, dest.as_ptr(), flags) };
8171    if result == 0 {
8172        Ok(())
8173    } else {
8174        Err(std::io::Error::last_os_error().into())
8175    }
8176}
8177
8178#[cfg(unix)]
8179fn write_pull_entries_beneath_dir(
8180    root: &std::fs::File,
8181    entries: &[(String, Vec<u8>)],
8182) -> LinkResult<()> {
8183    use std::os::fd::{AsRawFd as _, FromRawFd as _};
8184
8185    for (path, content) in entries {
8186        let components: Vec<&str> = path.split('/').collect();
8187        let (leaf, parents) = components
8188            .split_last()
8189            .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
8190        let mut directory = root.try_clone()?;
8191        for component in parents {
8192            let name = c_name(component.as_bytes(), path)?;
8193            let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
8194            if made != 0 {
8195                let error = std::io::Error::last_os_error();
8196                if error.raw_os_error() != Some(libc::EEXIST) {
8197                    return Err(error.into());
8198                }
8199            }
8200            directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
8201        }
8202
8203        let leaf_name = c_name(leaf.as_bytes(), path)?;
8204        let mut existing: libc::stat = unsafe { std::mem::zeroed() };
8205        let inspected = unsafe {
8206            libc::fstatat(
8207                directory.as_raw_fd(),
8208                leaf_name.as_ptr(),
8209                &mut existing,
8210                libc::AT_SYMLINK_NOFOLLOW,
8211            )
8212        };
8213        if inspected == 0 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK {
8214            return Err(LinkError::UnsafePath { path: path.clone() });
8215        }
8216
8217        let nonce = std::time::SystemTime::now()
8218            .duration_since(std::time::UNIX_EPOCH)
8219            .unwrap_or_default()
8220            .as_nanos();
8221        let temp_name = format!(
8222            ".dbmd-pull-{}-{nonce}-{}",
8223            std::process::id(),
8224            content_sha256(format!("{path}\0{}", content.len()).as_bytes())
8225        );
8226        let temp = c_name(temp_name.as_bytes(), path)?;
8227        let fd = unsafe {
8228            libc::openat(
8229                directory.as_raw_fd(),
8230                temp.as_ptr(),
8231                libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
8232                0o600,
8233            )
8234        };
8235        if fd < 0 {
8236            return Err(std::io::Error::last_os_error().into());
8237        }
8238        let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
8239        if let Err(error) = file.write_all(content).and_then(|_| file.sync_all()) {
8240            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8241            return Err(error.into());
8242        }
8243        drop(file);
8244        let renamed = unsafe {
8245            libc::renameat(
8246                directory.as_raw_fd(),
8247                temp.as_ptr(),
8248                directory.as_raw_fd(),
8249                leaf_name.as_ptr(),
8250            )
8251        };
8252        if renamed != 0 {
8253            let error = std::io::Error::last_os_error();
8254            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8255            return Err(error.into());
8256        }
8257        directory.sync_all()?;
8258    }
8259    root.sync_all()?;
8260    Ok(())
8261}
8262
8263#[cfg(unix)]
8264fn write_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
8265    use std::os::fd::{AsRawFd as _, FromRawFd as _};
8266
8267    let path = &entry.path;
8268    let components: Vec<&str> = path.split('/').collect();
8269    let (leaf, parents) = components
8270        .split_last()
8271        .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
8272    let mut directory = root.try_clone()?;
8273    for component in parents {
8274        let name = c_name(component.as_bytes(), path)?;
8275        let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
8276        if made != 0 {
8277            let error = std::io::Error::last_os_error();
8278            if error.raw_os_error() != Some(libc::EEXIST) {
8279                return Err(error.into());
8280            }
8281        }
8282        directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
8283    }
8284    let leaf_name = c_name(leaf.as_bytes(), path)?;
8285    let mut existing: libc::stat = unsafe { std::mem::zeroed() };
8286    if unsafe {
8287        libc::fstatat(
8288            directory.as_raw_fd(),
8289            leaf_name.as_ptr(),
8290            &mut existing,
8291            libc::AT_SYMLINK_NOFOLLOW,
8292        )
8293    } == 0
8294        && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK
8295    {
8296        return Err(LinkError::UnsafePath { path: path.clone() });
8297    }
8298    let nonce = SystemTime::now()
8299        .duration_since(UNIX_EPOCH)
8300        .unwrap_or_default()
8301        .as_nanos();
8302    let temp_name = format!(
8303        ".dbmd-pull-{}-{nonce}-{}",
8304        std::process::id(),
8305        content_sha256(path.as_bytes())
8306    );
8307    let temp = c_name(temp_name.as_bytes(), path)?;
8308    let fd = unsafe {
8309        libc::openat(
8310            directory.as_raw_fd(),
8311            temp.as_ptr(),
8312            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
8313            0o600,
8314        )
8315    };
8316    if fd < 0 {
8317        return Err(std::io::Error::last_os_error().into());
8318    }
8319    let mut input = crate::fsx::open_regular_nofollow(&entry.source)?;
8320    let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
8321    let mut digest = Sha256::new();
8322    let mut total = 0_u64;
8323    let mut buffer = [0_u8; 64 * 1024];
8324    let copied = (|| -> std::io::Result<()> {
8325        loop {
8326            let read = input.read(&mut buffer)?;
8327            if read == 0 {
8328                break;
8329            }
8330            total = total.saturating_add(read as u64);
8331            if total > entry.bytes {
8332                return Err(std::io::Error::new(
8333                    std::io::ErrorKind::InvalidData,
8334                    "staged sync source grew beyond its verified length",
8335                ));
8336            }
8337            digest.update(&buffer[..read]);
8338            output.write_all(&buffer[..read])?;
8339        }
8340        Ok(())
8341    })();
8342    if let Err(error) = copied {
8343        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8344        return Err(error.into());
8345    }
8346    drop(output);
8347    if total != entry.bytes || format!("{:x}", digest.finalize()) != entry.sha256 {
8348        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8349        return Err(invalid_feed(
8350            "private staged sync source failed final integrity verification",
8351        ));
8352    }
8353    if unsafe {
8354        libc::renameat(
8355            directory.as_raw_fd(),
8356            temp.as_ptr(),
8357            directory.as_raw_fd(),
8358            leaf_name.as_ptr(),
8359        )
8360    } != 0
8361    {
8362        let error = std::io::Error::last_os_error();
8363        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8364        return Err(error.into());
8365    }
8366    Ok(())
8367}
8368
8369#[cfg(unix)]
8370fn sync_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
8371    use std::os::fd::{AsRawFd as _, FromRawFd as _};
8372
8373    let path = &entry.path;
8374    let components: Vec<&str> = path.split('/').collect();
8375    let (leaf, parents) = components
8376        .split_last()
8377        .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
8378    let mut directory = root.try_clone()?;
8379    for component in parents {
8380        directory = open_dir_at(
8381            directory.as_raw_fd(),
8382            &c_name(component.as_bytes(), path)?,
8383            path,
8384        )?;
8385    }
8386    let leaf = c_name(leaf.as_bytes(), path)?;
8387    let fd = unsafe {
8388        libc::openat(
8389            directory.as_raw_fd(),
8390            leaf.as_ptr(),
8391            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
8392        )
8393    };
8394    if fd < 0 {
8395        return Err(std::io::Error::last_os_error().into());
8396    }
8397    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
8398    if file.metadata()?.len() != entry.bytes || content_sha256_reader(&mut file)? != entry.sha256 {
8399        return Err(invalid_feed(
8400            "private pull stage changed before its durability barrier",
8401        ));
8402    }
8403    file.sync_all()?;
8404    Ok(())
8405}
8406
8407#[cfg(unix)]
8408fn run_pull_source_workers(
8409    root: &std::fs::File,
8410    entries: &[V2StagedFile],
8411    operation: fn(&std::fs::File, &V2StagedFile) -> LinkResult<()>,
8412) -> LinkResult<()> {
8413    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
8414
8415    let next = AtomicUsize::new(0);
8416    let failed = AtomicBool::new(false);
8417    let worker_count = entries.len().min(V2_PULL_INSTALL_WORKERS);
8418    let mut first_error = None;
8419    std::thread::scope(|scope| {
8420        let (sender, receiver) = std::sync::mpsc::channel();
8421        for _ in 0..worker_count {
8422            let sender = sender.clone();
8423            let next = &next;
8424            let failed = &failed;
8425            scope.spawn(move || {
8426                while !failed.load(Ordering::Acquire) {
8427                    let index = next.fetch_add(1, Ordering::Relaxed);
8428                    let Some(entry) = entries.get(index) else {
8429                        break;
8430                    };
8431                    let result = operation(root, entry);
8432                    if result.is_err() {
8433                        failed.store(true, Ordering::Release);
8434                    }
8435                    if sender.send(result).is_err() {
8436                        break;
8437                    }
8438                }
8439            });
8440        }
8441        drop(sender);
8442        for result in receiver {
8443            if let Err(error) = result {
8444                if first_error.is_none() {
8445                    first_error = Some(error);
8446                }
8447            }
8448        }
8449    });
8450    if let Some(error) = first_error {
8451        return Err(error);
8452    }
8453    if next.load(Ordering::Relaxed) < entries.len() {
8454        return Err(invalid_feed(
8455            "a bounded pull worker stopped before reporting every file",
8456        ));
8457    }
8458    Ok(())
8459}
8460
8461#[cfg(unix)]
8462fn sync_pull_directory_tree(root: &std::fs::File, display: &str) -> LinkResult<()> {
8463    use std::os::fd::AsRawFd as _;
8464
8465    for name in directory_entry_names(root)? {
8466        if entry_is_dir_at(root.as_raw_fd(), &name)? == Some(true) {
8467            let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
8468            let child = open_dir_at(root.as_raw_fd(), &name, &child_display)?;
8469            sync_pull_directory_tree(&child, &child_display)?;
8470        }
8471    }
8472    root.sync_all()?;
8473    Ok(())
8474}
8475
8476#[cfg(unix)]
8477fn write_pull_sources_beneath_dir(
8478    root: &std::fs::File,
8479    entries: &[V2StagedFile],
8480) -> LinkResult<()> {
8481    // The stage is still private and unreachable as the checkout here. Write
8482    // and verify every leaf first so the filesystem can coalesce dirty data,
8483    // then re-open/re-hash/fsync leaves through the held root capability. Only
8484    // after every file barrier succeeds do we flush each directory once. This
8485    // preserves the same crash durability as file-fsync + directory-fsync per
8486    // path without serializing 50,000 redundant directory barriers.
8487    run_pull_source_workers(root, entries, write_pull_source_beneath_dir)?;
8488    run_pull_source_workers(root, entries, sync_pull_source_beneath_dir)?;
8489    sync_pull_directory_tree(root, "v2 pull stage")
8490}
8491
8492#[cfg(unix)]
8493fn remove_pull_paths_beneath_dir(root: &std::fs::File, paths: &[String]) -> LinkResult<()> {
8494    use std::os::fd::AsRawFd as _;
8495    for path in paths {
8496        if !safe_store_rel_path(path) {
8497            return Err(LinkError::UnsafePath { path: path.clone() });
8498        }
8499        let components = path.split('/').collect::<Vec<_>>();
8500        let Some((leaf, parents)) = components.split_last() else {
8501            return Err(LinkError::UnsafePath { path: path.clone() });
8502        };
8503        let mut directory = root.try_clone()?;
8504        let mut missing = false;
8505        for component in parents {
8506            let name = c_name(component.as_bytes(), path)?;
8507            match entry_is_dir_at(directory.as_raw_fd(), &name)? {
8508                None => {
8509                    missing = true;
8510                    break;
8511                }
8512                Some(false) => return Err(LinkError::UnsafePath { path: path.clone() }),
8513                Some(true) => {
8514                    directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
8515                }
8516            }
8517        }
8518        if missing {
8519            continue;
8520        }
8521        let leaf = c_name(leaf.as_bytes(), path)?;
8522        match entry_is_dir_at(directory.as_raw_fd(), &leaf)? {
8523            None => {}
8524            Some(true) => return Err(LinkError::UnsafePath { path: path.clone() }),
8525            Some(false) => {
8526                if unsafe { libc::unlinkat(directory.as_raw_fd(), leaf.as_ptr(), 0) } != 0 {
8527                    return Err(std::io::Error::last_os_error().into());
8528                }
8529                directory.sync_all()?;
8530            }
8531        }
8532    }
8533    Ok(())
8534}
8535
8536#[cfg(unix)]
8537fn install_pulled_delta(
8538    dest: &Path,
8539    entries: &[(String, Vec<u8>)],
8540    deleted: &[String],
8541    rebuild_indexes: bool,
8542) -> LinkResult<()> {
8543    use ring::rand::SecureRandom as _;
8544    use std::os::fd::AsRawFd as _;
8545    use std::os::unix::ffi::OsStrExt as _;
8546
8547    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
8548    let name = dest
8549        .file_name()
8550        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
8551        .ok_or_else(|| LinkError::UnsafePath {
8552            path: dest.display().to_string(),
8553        })?;
8554    let parent_dir = open_or_create_dir_nofollow(parent)?;
8555    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
8556    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
8557        None => false,
8558        Some(true) => true,
8559        Some(false) => {
8560            return Err(LinkError::UnsafePath {
8561                path: dest.display().to_string(),
8562            });
8563        }
8564    };
8565
8566    let mut nonce = [0_u8; 16];
8567    ring::rand::SystemRandom::new()
8568        .fill(&mut nonce)
8569        .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
8570    let stage_label = format!(
8571        ".{}.dbmd-pull-stage-{}",
8572        name.to_string_lossy(),
8573        URL_SAFE_NO_PAD.encode(nonce)
8574    );
8575    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
8576    let stage_dir = create_dir_exclusive_at(
8577        parent_dir.as_raw_fd(),
8578        &stage_name,
8579        &dest.display().to_string(),
8580    )?;
8581
8582    let prepared = (|| -> LinkResult<()> {
8583        if dest_exists {
8584            let live = open_dir_at(
8585                parent_dir.as_raw_fd(),
8586                &dest_name,
8587                &dest.display().to_string(),
8588            )?;
8589            clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
8590        }
8591        remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
8592        write_pull_entries_beneath_dir(&stage_dir, entries)?;
8593        if rebuild_indexes {
8594            let stage_store =
8595                Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
8596                    .map_err(|error| LinkError::InvalidPack {
8597                        message: format!("v2 staging tree is not a valid db.md store: {error}"),
8598                    })?;
8599            crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
8600                LinkError::InvalidPack {
8601                    message: format!("could not materialize v2 local catalogs: {error}"),
8602                }
8603            })?;
8604        }
8605        stage_dir.sync_all()?;
8606        Ok(())
8607    })();
8608    if let Err(error) = prepared {
8609        let _ = remove_tree_at(
8610            parent_dir.as_raw_fd(),
8611            &stage_name,
8612            &dest.display().to_string(),
8613        );
8614        return Err(error);
8615    }
8616
8617    if let Err(error) =
8618        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
8619    {
8620        let _ = remove_tree_at(
8621            parent_dir.as_raw_fd(),
8622            &stage_name,
8623            &dest.display().to_string(),
8624        );
8625        return Err(error);
8626    }
8627    parent_dir.sync_all()?;
8628    if dest_exists {
8629        // Commit already happened atomically. Cleanup is best-effort so a
8630        // failure cannot be reported as a failed pull after the live tree
8631        // changed; a crash may leave only this private old-tree sibling.
8632        let _ = remove_tree_at(
8633            parent_dir.as_raw_fd(),
8634            &stage_name,
8635            &dest.display().to_string(),
8636        );
8637        let _ = parent_dir.sync_all();
8638    }
8639    Ok(())
8640}
8641
8642#[cfg(unix)]
8643fn install_pulled_delta_sources(
8644    dest: &Path,
8645    entries: &[V2StagedFile],
8646    deleted: &[String],
8647    rebuild_indexes: bool,
8648    _previous: Option<&V2SyncBaseline>,
8649    _next: &V2VerifiedHead,
8650) -> LinkResult<()> {
8651    use ring::rand::SecureRandom as _;
8652    use std::os::fd::AsRawFd as _;
8653    use std::os::unix::ffi::OsStrExt as _;
8654
8655    // An established checkout is updated only at the changed coordinates
8656    // through the durable pull journal shared with Windows. Initial clones
8657    // still use a private complete stage and one atomic directory install.
8658    if let Ok(store) = Store::open_strict(dest) {
8659        return install_established_v2_delta(
8660            store,
8661            entries,
8662            deleted,
8663            rebuild_indexes,
8664            _previous,
8665            _next,
8666        );
8667    }
8668
8669    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
8670    let name = dest
8671        .file_name()
8672        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
8673        .ok_or_else(|| LinkError::UnsafePath {
8674            path: dest.display().to_string(),
8675        })?;
8676    let parent_dir = open_or_create_dir_nofollow(parent)?;
8677    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
8678    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
8679        None => false,
8680        Some(true) => true,
8681        Some(false) => {
8682            return Err(LinkError::UnsafePath {
8683                path: dest.display().to_string(),
8684            })
8685        }
8686    };
8687    let mut nonce = [0_u8; 16];
8688    ring::rand::SystemRandom::new()
8689        .fill(&mut nonce)
8690        .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
8691    let stage_label = format!(
8692        ".{}.dbmd-pull-stage-{}",
8693        name.to_string_lossy(),
8694        URL_SAFE_NO_PAD.encode(nonce)
8695    );
8696    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
8697    let stage_dir = create_dir_exclusive_at(
8698        parent_dir.as_raw_fd(),
8699        &stage_name,
8700        &dest.display().to_string(),
8701    )?;
8702    let prepared = (|| -> LinkResult<()> {
8703        if dest_exists {
8704            let live = open_dir_at(
8705                parent_dir.as_raw_fd(),
8706                &dest_name,
8707                &dest.display().to_string(),
8708            )?;
8709            clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
8710        }
8711        remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
8712        write_pull_sources_beneath_dir(&stage_dir, entries)?;
8713        if rebuild_indexes {
8714            let stage_store =
8715                Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
8716                    .map_err(|error| LinkError::InvalidPack {
8717                        message: format!("v2 staging tree is not a valid db.md store: {error}"),
8718                    })?;
8719            crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
8720                LinkError::InvalidPack {
8721                    message: format!("could not materialize v2 local catalogs: {error}"),
8722                }
8723            })?;
8724        }
8725        stage_dir.sync_all()?;
8726        Ok(())
8727    })();
8728    if let Err(error) = prepared {
8729        let _ = remove_tree_at(
8730            parent_dir.as_raw_fd(),
8731            &stage_name,
8732            &dest.display().to_string(),
8733        );
8734        return Err(error);
8735    }
8736    if let Err(error) =
8737        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
8738    {
8739        let _ = remove_tree_at(
8740            parent_dir.as_raw_fd(),
8741            &stage_name,
8742            &dest.display().to_string(),
8743        );
8744        return Err(error);
8745    }
8746    parent_dir.sync_all()?;
8747    if dest_exists {
8748        let _ = remove_tree_at(
8749            parent_dir.as_raw_fd(),
8750            &stage_name,
8751            &dest.display().to_string(),
8752        );
8753        let _ = parent_dir.sync_all();
8754    }
8755    Ok(())
8756}
8757
8758#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
8759struct V2PullCoordinate {
8760    head_seq: Option<u64>,
8761    commit_hash: Option<String>,
8762    view_kind: Option<String>,
8763    view_revision: Option<String>,
8764}
8765
8766#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
8767struct V2PullFileCoordinate {
8768    sha256: String,
8769    bytes: u64,
8770}
8771
8772#[derive(Debug, Clone, Deserialize, Serialize)]
8773struct V2PullJournalEntry {
8774    path: String,
8775    old: Option<V2PullFileCoordinate>,
8776    new: Option<V2PullFileCoordinate>,
8777    backup: Option<String>,
8778}
8779
8780#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
8781#[serde(rename_all = "snake_case")]
8782enum V2PullPhase {
8783    Preparing,
8784    Ready,
8785}
8786
8787#[derive(Debug, Clone, Deserialize, Serialize)]
8788struct V2PullJournal {
8789    v: u8,
8790    phase: V2PullPhase,
8791    brain: String,
8792    previous: V2PullCoordinate,
8793    next: V2PullCoordinate,
8794    backup_dir: String,
8795    entries: Vec<V2PullJournalEntry>,
8796}
8797
8798const V2_PULL_JOURNAL: &str = ".dbmd/pull-journal.json";
8799
8800fn v2_pull_baseline_coordinate(baseline: Option<&V2SyncBaseline>) -> V2PullCoordinate {
8801    V2PullCoordinate {
8802        head_seq: baseline.and_then(|value| value.head_seq),
8803        commit_hash: baseline.and_then(|value| value.commit_hash.clone()),
8804        view_kind: baseline.and_then(|value| value.view_kind.clone()),
8805        view_revision: baseline.and_then(|value| value.view_revision.clone()),
8806    }
8807}
8808
8809fn v2_pull_head_coordinate(head: &V2VerifiedHead) -> V2PullCoordinate {
8810    V2PullCoordinate {
8811        head_seq: head.pointer.as_ref().map(|value| value.seq),
8812        commit_hash: head.pointer.as_ref().map(|value| value.commit_hash.clone()),
8813        view_kind: Some(head.view_kind.clone()),
8814        view_revision: Some(head.view_revision.clone()),
8815    }
8816}
8817
8818fn v2_pull_file_coordinate(
8819    store: &Store,
8820    path: &str,
8821    limit: u64,
8822) -> LinkResult<Option<V2PullFileCoordinate>> {
8823    let file = match store.open_regular(Path::new(path)) {
8824        Ok(file) => file,
8825        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
8826        Err(error) => return Err(error.into()),
8827    };
8828    let bytes = file.metadata()?.len();
8829    if bytes > limit || bytes > MAX_STORE_BYTES {
8830        return Err(invalid_feed(
8831            "pull transaction file exceeds its declared bound",
8832        ));
8833    }
8834    Ok(Some(V2PullFileCoordinate {
8835        sha256: content_sha256_reader(file)?,
8836        bytes,
8837    }))
8838}
8839
8840fn v2_pull_journal_bytes(journal: &V2PullJournal) -> LinkResult<Vec<u8>> {
8841    let mut bytes = serde_json::to_vec_pretty(journal)
8842        .map_err(|_| invalid_feed("could not serialize v2 pull journal"))?;
8843    bytes.push(b'\n');
8844    Ok(bytes)
8845}
8846
8847fn validate_v2_pull_journal(journal: &V2PullJournal) -> LinkResult<()> {
8848    let backup_prefix = ".dbmd/pull-backup-";
8849    let suffix = journal
8850        .backup_dir
8851        .strip_prefix(backup_prefix)
8852        .ok_or_else(|| invalid_feed("v2 pull journal backup address is invalid"))?;
8853    let mut paths = std::collections::BTreeSet::new();
8854    if journal.v != 1
8855        || !crate::ulid::is_ulid(&journal.brain)
8856        || !crate::ulid::is_ulid(suffix)
8857        || journal.entries.is_empty()
8858        || journal.entries.len() > MAX_PUSH_FILES + 4
8859        || journal.previous == journal.next
8860    {
8861        return Err(invalid_feed("v2 pull journal failed validation"));
8862    }
8863    for (index, entry) in journal.entries.iter().enumerate() {
8864        if !safe_store_rel_path(&entry.path)
8865            || entry.path == V2_PULL_JOURNAL
8866            || entry.path.starts_with(backup_prefix)
8867            || !paths.insert(entry.path.clone())
8868            || (entry.old.is_none() && entry.new.is_none())
8869            || entry
8870                .old
8871                .iter()
8872                .chain(entry.new.iter())
8873                .any(|value| !is_sha256(&value.sha256) || value.bytes > MAX_STORE_BYTES)
8874            || entry.backup.as_deref()
8875                != entry
8876                    .old
8877                    .as_ref()
8878                    .map(|_| format!("{index:08x}"))
8879                    .as_deref()
8880        {
8881            return Err(invalid_feed("v2 pull journal entry failed validation"));
8882        }
8883    }
8884    Ok(())
8885}
8886
8887fn load_v2_pull_journal(store: &Store) -> LinkResult<Option<V2PullJournal>> {
8888    #[cfg(unix)]
8889    {
8890        use std::os::unix::fs::PermissionsExt as _;
8891        match store.regular_metadata(Path::new(V2_PULL_JOURNAL)) {
8892            Ok(metadata) if metadata.permissions().mode() & 0o077 != 0 => {
8893                return Err(invalid_feed(
8894                    "v2 pull journal is accessible to group/other; set mode 0600",
8895                ));
8896            }
8897            Ok(_) => {}
8898            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
8899            Err(error) => return Err(error.into()),
8900        }
8901    }
8902    let bytes = match store.read_bounded(Path::new(V2_PULL_JOURNAL), 64 * 1024 * 1024) {
8903        Ok(bytes) => bytes,
8904        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
8905        Err(error) => return Err(error.into()),
8906    };
8907    let journal: V2PullJournal =
8908        serde_json::from_slice(&bytes).map_err(|_| invalid_feed("v2 pull journal is corrupt"))?;
8909    validate_v2_pull_journal(&journal)?;
8910    Ok(Some(journal))
8911}
8912
8913fn cleanup_v2_pull_journal(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
8914    // The journal is the only recovery authority. Remove it first only after
8915    // rollback or baseline commit is durable; an orphan private backup is safe
8916    // and can be pruned, while a journal without its backups is not recoverable.
8917    match store.remove_file(Path::new(V2_PULL_JOURNAL)) {
8918        Ok(()) => {}
8919        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
8920        Err(error) => return Err(error.into()),
8921    }
8922    match store.remove_private_tree(Path::new(&journal.backup_dir)) {
8923        Ok(()) => Ok(()),
8924        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
8925        Err(error) => Err(error.into()),
8926    }
8927}
8928
8929fn prune_orphan_v2_pull_backups(store: &Store) -> LinkResult<()> {
8930    let names = match store.directory_names(Path::new(".dbmd")) {
8931        Ok(names) => names,
8932        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
8933        Err(error) => return Err(error.into()),
8934    };
8935    for name in names {
8936        let Some(name) = name.to_str() else {
8937            continue;
8938        };
8939        let Some(suffix) = name.strip_prefix("pull-backup-") else {
8940            continue;
8941        };
8942        if crate::ulid::is_ulid(suffix) {
8943            store.remove_private_tree(&Path::new(".dbmd").join(name))?;
8944        }
8945    }
8946    Ok(())
8947}
8948
8949fn rollback_v2_pull(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
8950    // Validate every live coordinate and every backup before the first restore.
8951    for entry in &journal.entries {
8952        let limit = entry
8953            .old
8954            .as_ref()
8955            .into_iter()
8956            .chain(entry.new.iter())
8957            .map(|value| value.bytes)
8958            .max()
8959            .unwrap_or(0);
8960        let current = v2_pull_file_coordinate(store, &entry.path, limit)?;
8961        if current != entry.old && current != entry.new {
8962            return Err(LinkError::InvalidPack {
8963                message: format!(
8964                    "cannot recover interrupted pull because `{}` changed afterward",
8965                    entry.path
8966                ),
8967            });
8968        }
8969        if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
8970            let path = Path::new(&journal.backup_dir).join(backup);
8971            let file = store.open_regular(&path)?;
8972            if file.metadata()?.len() != old.bytes || content_sha256_reader(file)? != old.sha256 {
8973                return Err(invalid_feed("v2 pull recovery backup failed verification"));
8974            }
8975        }
8976    }
8977    for entry in journal.entries.iter().rev() {
8978        match (&entry.old, &entry.backup) {
8979            (Some(old), Some(backup)) => {
8980                let bytes =
8981                    store.read_bounded(&Path::new(&journal.backup_dir).join(backup), old.bytes)?;
8982                store.write_atomic(Path::new(&entry.path), &bytes)?;
8983            }
8984            (None, None) if store.regular_file_exists(Path::new(&entry.path))? => {
8985                store.remove_file(Path::new(&entry.path))?;
8986            }
8987            (None, None) => {}
8988            _ => return Err(invalid_feed("v2 pull recovery entry is inconsistent")),
8989        }
8990    }
8991    crate::index::Index::rebuild_all(store).map_err(|error| LinkError::InvalidPack {
8992        message: format!("could not rebuild catalogs after pull recovery: {error}"),
8993    })?;
8994    cleanup_v2_pull_journal(store, journal)
8995}
8996
8997fn recover_v2_pull(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<()> {
8998    let Ok(store) = Store::open_strict(dest) else {
8999        return Ok(());
9000    };
9001    if let Some(journal) = load_v2_pull_journal(&store)? {
9002        if journal.brain != brain {
9003            return Err(invalid_feed("v2 pull journal belongs to another brain"));
9004        }
9005        if journal.phase == V2PullPhase::Preparing {
9006            cleanup_v2_pull_journal(&store, &journal)?;
9007        } else {
9008            let baseline = load_v2_baseline(cfg, brain, dest)?;
9009            let current = v2_pull_baseline_coordinate(baseline.as_ref());
9010            if current == journal.next {
9011                cleanup_v2_pull_journal(&store, &journal)?;
9012            } else {
9013                if current != journal.previous {
9014                    return Err(invalid_feed(
9015                        "cannot recover interrupted pull because its baseline changed afterward",
9016                    ));
9017                }
9018                rollback_v2_pull(&store, &journal)?;
9019            }
9020        }
9021    }
9022    // Cleanup removes the journal first after the state is durably old or new.
9023    // A hard kill in the tiny interval before deleting its backup directory can
9024    // therefore leave only an inert, private orphan. The per-brain operation
9025    // lock is held by the caller, so no live pull can own one here.
9026    prune_orphan_v2_pull_backups(&store)
9027}
9028
9029fn complete_v2_pull(dest: &Path) -> LinkResult<()> {
9030    let store = Store::open_strict(dest).map_err(|error| LinkError::InvalidPack {
9031        message: format!("installed v2 checkout is not a valid db.md store: {error}"),
9032    })?;
9033    if let Some(journal) = load_v2_pull_journal(&store)? {
9034        cleanup_v2_pull_journal(&store, &journal)?;
9035    }
9036    Ok(())
9037}
9038
9039#[cfg(windows)]
9040fn install_windows_initial_sources(
9041    dest: &Path,
9042    entries: &[V2StagedFile],
9043    rebuild_indexes: bool,
9044) -> LinkResult<()> {
9045    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
9046    let name = dest.file_name().ok_or_else(|| LinkError::UnsafePath {
9047        path: dest.display().to_string(),
9048    })?;
9049    let parent_capability = crate::fsx::open_or_create_directory_nofollow(parent)?;
9050    if crate::fsx::directory_exists_beneath(&parent_capability, Path::new(name))? {
9051        return Err(LinkError::UnsafePath {
9052            path: dest.display().to_string(),
9053        });
9054    }
9055    let stage_name = format!(
9056        ".{}.dbmd-pull-stage-{}",
9057        name.to_string_lossy(),
9058        crate::ulid::mint()
9059    );
9060    let stage_path = parent.join(&stage_name);
9061    let stage_capability =
9062        crate::fsx::open_directory_beneath(&parent_capability, Path::new(&stage_name), true)?;
9063    let stage = Store::from_root_and_config(&stage_path, crate::Config::default())?;
9064    let prepared = (|| -> LinkResult<()> {
9065        for entry in entries {
9066            let bytes = crate::fsx::read_bounded_nofollow(&entry.source, entry.bytes)?;
9067            if bytes.len() as u64 != entry.bytes || content_sha256(&bytes) != entry.sha256 {
9068                return Err(invalid_feed(
9069                    "private staged sync source failed final integrity verification",
9070                ));
9071            }
9072            stage.write_atomic(Path::new(&entry.path), &bytes)?;
9073        }
9074        let strict = Store::from_held_root_strict(&stage_path, stage_capability.try_clone()?)
9075            .map_err(|error| LinkError::InvalidPack {
9076                message: format!("v2 staging tree is not a valid db.md store: {error}"),
9077            })?;
9078        if rebuild_indexes {
9079            crate::index::Index::rebuild_all(&strict).map_err(|error| LinkError::InvalidPack {
9080                message: format!("could not materialize v2 local catalogs: {error}"),
9081            })?;
9082        }
9083        Ok(())
9084    })();
9085    if let Err(error) = prepared {
9086        let _ = crate::fsx::remove_tree_beneath(&parent_capability, Path::new(&stage_name));
9087        return Err(error);
9088    }
9089    crate::fsx::rename_directory_beneath(
9090        &parent_capability,
9091        Path::new(&stage_name),
9092        Path::new(name),
9093    )?;
9094    Ok(())
9095}
9096
9097fn install_established_v2_delta(
9098    store: Store,
9099    entries: &[V2StagedFile],
9100    deleted: &[String],
9101    rebuild_indexes: bool,
9102    previous: Option<&V2SyncBaseline>,
9103    next: &V2VerifiedHead,
9104) -> LinkResult<()> {
9105    if load_v2_pull_journal(&store)?.is_some() {
9106        return Err(invalid_feed(
9107            "an interrupted pull must be recovered before installing",
9108        ));
9109    }
9110    let mut sources = std::collections::BTreeMap::new();
9111    for entry in entries {
9112        if sources.insert(entry.path.clone(), entry).is_some() || deleted.contains(&entry.path) {
9113            return Err(invalid_feed("pull mutation repeats a path"));
9114        }
9115    }
9116    let mut paths = sources.keys().cloned().collect::<Vec<_>>();
9117    paths.extend(deleted.iter().cloned());
9118    paths.sort();
9119    paths.dedup();
9120    if paths.is_empty() {
9121        return Ok(());
9122    }
9123    let backup_dir = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
9124    let mut journal = V2PullJournal {
9125        v: 1,
9126        phase: V2PullPhase::Preparing,
9127        brain: next.brain_id.clone(),
9128        previous: v2_pull_baseline_coordinate(previous),
9129        next: v2_pull_head_coordinate(next),
9130        backup_dir: backup_dir.clone(),
9131        entries: Vec::with_capacity(paths.len()),
9132    };
9133    for path in &paths {
9134        let old = v2_pull_file_coordinate(&store, path, MAX_STORE_BYTES)?;
9135        let new = sources.get(path).map(|entry| V2PullFileCoordinate {
9136            sha256: entry.sha256.clone(),
9137            bytes: entry.bytes,
9138        });
9139        if old == new {
9140            continue;
9141        }
9142        let index = journal.entries.len();
9143        journal.entries.push(V2PullJournalEntry {
9144            path: path.clone(),
9145            backup: old.as_ref().map(|_| format!("{index:08x}")),
9146            old,
9147            new,
9148        });
9149    }
9150    if journal.entries.is_empty() {
9151        return Ok(());
9152    }
9153    let backup_bytes = journal.entries.iter().try_fold(0_u64, |total, entry| {
9154        entry
9155            .old
9156            .as_ref()
9157            .map_or(Some(total), |old| total.checked_add(old.bytes))
9158    });
9159    if backup_bytes.is_none_or(|bytes| bytes > MAX_STORE_BYTES) {
9160        return Err(LinkError::InvalidPack {
9161            message: "pull recovery preimages exceed the 512 MB transaction limit".to_string(),
9162        });
9163    }
9164    validate_v2_pull_journal(&journal)?;
9165    store.write_private_atomic_new(
9166        Path::new(V2_PULL_JOURNAL),
9167        &v2_pull_journal_bytes(&journal)?,
9168    )?;
9169    let prepared = (|| -> LinkResult<()> {
9170        store.create_private_dir_all(Path::new(&backup_dir))?;
9171        for entry in &journal.entries {
9172            if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
9173                let bytes = store.read_bounded(Path::new(&entry.path), old.bytes)?;
9174                if content_sha256(&bytes) != old.sha256 {
9175                    return Err(invalid_feed("live pull source changed during backup"));
9176                }
9177                store.write_private_atomic_new(&Path::new(&backup_dir).join(backup), &bytes)?;
9178            }
9179        }
9180        journal.phase = V2PullPhase::Ready;
9181        store.write_private_atomic(
9182            Path::new(V2_PULL_JOURNAL),
9183            &v2_pull_journal_bytes(&journal)?,
9184        )?;
9185        Ok(())
9186    })();
9187    if let Err(error) = prepared {
9188        let cleanup = cleanup_v2_pull_journal(&store, &journal);
9189        return match cleanup {
9190            Ok(()) => Err(error),
9191            Err(cleanup) => Err(LinkError::InvalidPack {
9192                message: format!("{error}; recovery metadata cleanup also failed: {cleanup}"),
9193            }),
9194        };
9195    }
9196    let installed = (|| -> LinkResult<()> {
9197        for entry in &journal.entries {
9198            if v2_pull_file_coordinate(&store, &entry.path, MAX_STORE_BYTES)? != entry.old {
9199                return Err(LinkError::InvalidPack {
9200                    message: format!("local path `{}` changed during pull", entry.path),
9201                });
9202            }
9203            if let Some(source) = sources.get(&entry.path) {
9204                let bytes = crate::fsx::read_bounded_nofollow(&source.source, source.bytes)?;
9205                if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != source.sha256 {
9206                    return Err(invalid_feed(
9207                        "private staged sync source failed final integrity verification",
9208                    ));
9209                }
9210                store.write_atomic(Path::new(&entry.path), &bytes)?;
9211            } else if entry.old.is_some() {
9212                store.remove_file(Path::new(&entry.path))?;
9213            }
9214        }
9215        if rebuild_indexes {
9216            crate::index::Index::rebuild_all(&store).map_err(|error| LinkError::InvalidPack {
9217                message: format!("could not materialize v2 local catalogs: {error}"),
9218            })?;
9219        }
9220        Ok(())
9221    })();
9222    if let Err(error) = installed {
9223        return match rollback_v2_pull(&store, &journal) {
9224            Ok(()) => Err(error),
9225            Err(rollback) => Err(LinkError::InvalidPack {
9226                message: format!("{error}; durable pull rollback also failed: {rollback}"),
9227            }),
9228        };
9229    }
9230    Ok(())
9231}
9232
9233#[cfg(windows)]
9234fn install_pulled_delta_sources(
9235    dest: &Path,
9236    entries: &[V2StagedFile],
9237    deleted: &[String],
9238    rebuild_indexes: bool,
9239    previous: Option<&V2SyncBaseline>,
9240    next: &V2VerifiedHead,
9241) -> LinkResult<()> {
9242    match Store::open_strict(dest) {
9243        Ok(store) => {
9244            install_established_v2_delta(store, entries, deleted, rebuild_indexes, previous, next)
9245        }
9246        Err(_) => install_windows_initial_sources(dest, entries, rebuild_indexes),
9247    }
9248}
9249
9250#[cfg(not(any(unix, windows)))]
9251fn install_pulled_delta_sources(
9252    _dest: &Path,
9253    _entries: &[V2StagedFile],
9254    _deleted: &[String],
9255    _rebuild_indexes: bool,
9256    _previous: Option<&V2SyncBaseline>,
9257    _next: &V2VerifiedHead,
9258) -> LinkResult<()> {
9259    Err(LinkError::UnsupportedPlatform {
9260        operation: "atomic v2 pull install",
9261    })
9262}
9263
9264#[cfg(unix)]
9265fn install_pulled_snapshot(dest: &Path, entries: &[(String, Vec<u8>)]) -> LinkResult<()> {
9266    install_pulled_delta(dest, entries, &[], false)
9267}
9268
9269#[cfg(not(windows))]
9270fn is_safe_slug(slug: &str) -> bool {
9271    !slug.is_empty()
9272        && slug.len() <= 63
9273        && !slug.starts_with('-')
9274        && !slug.ends_with('-')
9275        && slug
9276            .bytes()
9277            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
9278}
9279
9280fn le_u16(bytes: &[u8], at: usize) -> Option<u16> {
9281    Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
9282}
9283
9284fn le_u32(bytes: &[u8], at: usize) -> Option<u32> {
9285    Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
9286}
9287
9288fn le_u64(bytes: &[u8], at: usize) -> Option<u64> {
9289    Some(u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?))
9290}
9291
9292fn preflight_zip_central_directory(
9293    bytes: &[u8],
9294    offset: usize,
9295    size: usize,
9296    count: u64,
9297) -> LinkResult<()> {
9298    const CENTRAL_ENTRY_SIG: &[u8; 4] = b"PK\x01\x02";
9299    let end = offset
9300        .checked_add(size)
9301        .filter(|end| *end <= bytes.len())
9302        .ok_or_else(|| LinkError::InvalidPack {
9303            message: "ZIP central directory is out of bounds".to_string(),
9304        })?;
9305    let mut cursor = offset;
9306    for _ in 0..count {
9307        if bytes.get(cursor..cursor + 4) != Some(CENTRAL_ENTRY_SIG.as_slice()) {
9308            return Err(LinkError::InvalidPack {
9309                message: "ZIP central directory entry count is inconsistent".to_string(),
9310            });
9311        }
9312        if le_u16(bytes, cursor + 34) != Some(0) {
9313            return Err(LinkError::InvalidPack {
9314                message: "multi-disk ZIP archives are not supported".to_string(),
9315            });
9316        }
9317        let variable = [28, 30, 32].into_iter().try_fold(0usize, |total, at| {
9318            total.checked_add(le_u16(bytes, cursor + at)? as usize)
9319        });
9320        cursor = cursor
9321            .checked_add(46)
9322            .and_then(|fixed| fixed.checked_add(variable?))
9323            .filter(|cursor| *cursor <= end)
9324            .ok_or_else(|| LinkError::InvalidPack {
9325                message: "ZIP central directory entry is truncated".to_string(),
9326            })?;
9327    }
9328    if cursor != end {
9329        return Err(LinkError::InvalidPack {
9330            message: "ZIP central directory size is inconsistent".to_string(),
9331        });
9332    }
9333    Ok(())
9334}
9335
9336/// Read only the bounded ZIP trailer before `ZipArchive::new` allocates one
9337/// metadata object per central-directory entry. Supports ordinary EOCD and the
9338/// Zip64 locator/record emitted for >65,535-entry archives.
9339fn preflight_zip_entry_count(bytes: &[u8], max_entries: usize) -> LinkResult<()> {
9340    const EOCD_SIG: &[u8; 4] = b"PK\x05\x06";
9341    const ZIP64_LOCATOR_SIG: &[u8; 4] = b"PK\x06\x07";
9342    const ZIP64_EOCD_SIG: &[u8; 4] = b"PK\x06\x06";
9343    let search_start = bytes.len().saturating_sub(22 + u16::MAX as usize);
9344    let eocd = bytes[search_start..]
9345        .windows(4)
9346        .rposition(|window| window == EOCD_SIG)
9347        .map(|offset| search_start + offset)
9348        .ok_or_else(|| LinkError::InvalidPack {
9349            message: "ZIP has no end-of-central-directory record".to_string(),
9350        })?;
9351    let invalid_end = || LinkError::InvalidPack {
9352        message: "ZIP has an invalid end-of-central-directory structure".to_string(),
9353    };
9354    let comment_len = le_u16(bytes, eocd + 20).ok_or_else(invalid_end)? as usize;
9355    if eocd
9356        .checked_add(22)
9357        .and_then(|end| end.checked_add(comment_len))
9358        != Some(bytes.len())
9359    {
9360        // Do not fall back to an earlier signature. ZipArchive does, and a
9361        // fake low-count EOCD appended after a real Zip64 entry-count bomb
9362        // would otherwise bypass this allocation preflight.
9363        return Err(invalid_end());
9364    }
9365    let disk = le_u16(bytes, eocd + 4);
9366    let central_disk = le_u16(bytes, eocd + 6);
9367    if disk != Some(0) || central_disk != Some(0) {
9368        return Err(LinkError::InvalidPack {
9369            message: "multi-disk ZIP archives are not supported".to_string(),
9370        });
9371    }
9372    let entries_on_disk = le_u16(bytes, eocd + 8).ok_or_else(invalid_end)?;
9373    let ordinary = le_u16(bytes, eocd + 10).ok_or_else(invalid_end)?;
9374    if entries_on_disk != ordinary {
9375        return Err(LinkError::InvalidPack {
9376            message: "multi-disk ZIP archives are not supported".to_string(),
9377        });
9378    }
9379    let zip64_locator = eocd
9380        .checked_sub(20)
9381        .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_LOCATOR_SIG.as_slice()));
9382    let (count, central_offset, central_size) = if ordinary != u16::MAX || zip64_locator.is_none() {
9383        let central_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)? as usize;
9384        let central_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)? as usize;
9385        if central_offset
9386            .checked_add(central_size)
9387            .filter(|end| *end == eocd)
9388            .is_none()
9389        {
9390            return Err(invalid_end());
9391        }
9392        (ordinary as u64, central_offset, central_size)
9393    } else {
9394        let Some(locator) = zip64_locator else {
9395            return Err(invalid_end());
9396        };
9397        if le_u32(bytes, locator + 4) != Some(0) || le_u32(bytes, locator + 16) != Some(1) {
9398            return Err(LinkError::InvalidPack {
9399                message: "multi-disk ZIP64 archives are not supported".to_string(),
9400            });
9401        }
9402        let record = le_u64(bytes, locator + 8)
9403            .and_then(|offset| usize::try_from(offset).ok())
9404            .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_EOCD_SIG.as_slice()))
9405            .ok_or_else(|| LinkError::InvalidPack {
9406                message: "ZIP64 archive has an invalid end record".to_string(),
9407            })?;
9408        let record_size = le_u64(bytes, record + 4)
9409            .and_then(|size| usize::try_from(size).ok())
9410            .filter(|size| *size >= 44)
9411            .ok_or_else(invalid_end)?;
9412        if record
9413            .checked_add(12)
9414            .and_then(|end| end.checked_add(record_size))
9415            != Some(locator)
9416            || le_u32(bytes, record + 16) != Some(0)
9417            || le_u32(bytes, record + 20) != Some(0)
9418        {
9419            return Err(invalid_end());
9420        }
9421        let zip64_on_disk = le_u64(bytes, record + 24).ok_or_else(invalid_end)?;
9422        let zip64_total = le_u64(bytes, record + 32).ok_or_else(invalid_end)?;
9423        let central_size = le_u64(bytes, record + 40)
9424            .and_then(|size| usize::try_from(size).ok())
9425            .ok_or_else(invalid_end)?;
9426        let central_offset = le_u64(bytes, record + 48)
9427            .and_then(|offset| usize::try_from(offset).ok())
9428            .ok_or_else(invalid_end)?;
9429        if zip64_on_disk != zip64_total
9430            || central_offset
9431                .checked_add(central_size)
9432                .filter(|end| *end == record)
9433                .is_none()
9434        {
9435            return Err(invalid_end());
9436        }
9437        let legacy_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)?;
9438        let legacy_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)?;
9439        if (legacy_size != u32::MAX && legacy_size as usize != central_size)
9440            || (legacy_offset != u32::MAX && legacy_offset as usize != central_offset)
9441        {
9442            return Err(invalid_end());
9443        }
9444        (zip64_total, central_offset, central_size)
9445    };
9446    if count == 0 || count > max_entries as u64 {
9447        return Err(LinkError::InvalidPack {
9448            message: format!("invalid file count {count}"),
9449        });
9450    }
9451    preflight_zip_central_directory(bytes, central_offset, central_size, count)?;
9452    Ok(())
9453}
9454
9455fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
9456    preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)?;
9457    let mut archive =
9458        zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
9459            message: format!("ZIP parse failed: {err}"),
9460        })?;
9461    if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
9462        return Err(LinkError::InvalidPack {
9463            message: format!("invalid file count {}", archive.len()),
9464        });
9465    }
9466    let mut total = 0u64;
9467    let mut seen = std::collections::HashSet::new();
9468    let mut entries = Vec::with_capacity(archive.len());
9469    for index in 0..archive.len() {
9470        let mut file = archive
9471            .by_index(index)
9472            .map_err(|err| LinkError::InvalidPack {
9473                message: format!("ZIP entry failed: {err}"),
9474            })?;
9475        if file.is_dir() {
9476            continue;
9477        }
9478        let path = file.name().to_string();
9479        if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
9480            return Err(LinkError::UnsafePath { path });
9481        }
9482        if file
9483            .unix_mode()
9484            .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
9485        {
9486            return Err(LinkError::InvalidPack {
9487                message: format!("non-file entry `{path}`"),
9488            });
9489        }
9490        if !seen.insert(path.clone()) {
9491            return Err(LinkError::InvalidPack {
9492                message: format!("duplicate path `{path}`"),
9493            });
9494        }
9495        let remaining = MAX_STORE_BYTES.saturating_sub(total);
9496        if file.size() > remaining {
9497            return Err(LinkError::InvalidPack {
9498                message: "expanded content exceeds the 512 MB limit".to_string(),
9499            });
9500        }
9501        let mut content = Vec::new();
9502        (&mut file)
9503            .take(remaining + 1)
9504            .read_to_end(&mut content)
9505            .map_err(|err| LinkError::InvalidPack {
9506                message: format!("could not decompress `{path}`: {err}"),
9507            })?;
9508        if content.len() as u64 > remaining {
9509            return Err(LinkError::InvalidPack {
9510                message: "expanded content exceeds the 512 MB limit".to_string(),
9511            });
9512        }
9513        if content.len() as u64 != file.size() {
9514            return Err(LinkError::InvalidPack {
9515                message: format!("length mismatch for `{path}`"),
9516            });
9517        }
9518        total += content.len() as u64;
9519        entries.push((path, content));
9520    }
9521    if entries.is_empty() {
9522        return Err(LinkError::InvalidPack {
9523            message: "pack contains no files".to_string(),
9524        });
9525    }
9526    Ok(entries)
9527}
9528
9529fn verify_snapshot_manifest(entries: &[(String, Vec<u8>)], signed: &[FeedFile]) -> LinkResult<()> {
9530    let mut expected = std::collections::BTreeMap::new();
9531    for file in signed {
9532        if !safe_store_rel_path(&file.path) {
9533            return Err(LinkError::UnsafePath {
9534                path: file.path.clone(),
9535            });
9536        }
9537        if !is_sha256(&file.sha256)
9538            || expected
9539                .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
9540                .is_some()
9541        {
9542            return Err(invalid_feed(
9543                "signed snapshot manifest contains an invalid or duplicate file",
9544            ));
9545        }
9546    }
9547    if expected.len() != entries.len() {
9548        return Err(invalid_feed(
9549            "downloaded pack file set differs from the signed snapshot manifest",
9550        ));
9551    }
9552    for (path, bytes) in entries {
9553        let Some((sha256, declared_bytes)) = expected.get(path.as_str()) else {
9554            return Err(invalid_feed(format!(
9555                "downloaded pack contains unsigned path `{path}`"
9556            )));
9557        };
9558        if *declared_bytes != bytes.len() as u64
9559            || *sha256 != format!("{:x}", Sha256::digest(bytes))
9560        {
9561            return Err(invalid_feed(format!(
9562                "downloaded file `{path}` differs from its signed manifest"
9563            )));
9564        }
9565    }
9566    Ok(())
9567}
9568
9569/// Collect the files a push sends: the store's owned text — `DB.md`,
9570/// `assets.jsonl` when present, and every content `.md` under `records/` and
9571/// `sources/` (the store walk, which already excludes hidden dirs like
9572/// `.dbmd/`, the `log/` archive, and derived `index.*` catalogs; the hub
9573/// derives its own index, and local history stays local). Returns
9574/// `(store-relative path, content)` pairs, path-sorted.
9575pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
9576    require_hardened_filesystem("sync push")?;
9577    preflight_push_ownership(store)?;
9578    let mut out: Vec<(String, String)> = Vec::new();
9579    let mut total = 0u64;
9580
9581    let mut read_text = |rel: &str| -> LinkResult<String> {
9582        let bytes = store.read_bounded(Path::new(rel), MAX_STORE_BYTES.saturating_sub(total))?;
9583        total = total
9584            .checked_add(bytes.len() as u64)
9585            .ok_or_else(|| LinkError::PushTooLarge {
9586                detail: "uncompressed byte count overflow".to_string(),
9587            })?;
9588        if total > MAX_STORE_BYTES {
9589            return Err(LinkError::PushTooLarge {
9590                detail: format!("{total} uncompressed bytes"),
9591            });
9592        }
9593        String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
9594            path: rel.to_string(),
9595        })
9596    };
9597
9598    out.push(("DB.md".to_string(), read_text("DB.md")?));
9599    if store
9600        .regular_file_exists(Path::new("assets.jsonl"))
9601        .unwrap_or(false)
9602    {
9603        out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
9604    }
9605
9606    for rel in store.walk()? {
9607        let rel_str = rel.to_string_lossy().replace('\\', "/");
9608        if !safe_store_rel_path(&rel_str) {
9609            // A locally-legal name outside the hub's portable charset cannot
9610            // travel this wire; refusing beats silently dropping it.
9611            return Err(LinkError::UnsafePath { path: rel_str });
9612        }
9613        let content = read_text(&rel_str)?;
9614        out.push((rel_str, content));
9615    }
9616
9617    out.sort_by(|a, b| a.0.cmp(&b.0));
9618    Ok(out)
9619}
9620
9621/// Refuse to build a destructive whole-store snapshot from an ambiguous local
9622/// tree. Ordinary read-only walks safely prune foreign paths, but a push that
9623/// silently omitted them could delete the hosted copies of those paths.
9624fn preflight_push_ownership(store: &Store) -> LinkResult<()> {
9625    if let Some(nested) = store.nested_store_roots()?.into_iter().next() {
9626        return Err(LinkError::from(std::io::Error::new(
9627            std::io::ErrorKind::PermissionDenied,
9628            format!("cannot push: nested db.md store at {}", nested.display()),
9629        )));
9630    }
9631
9632    if let Some(symlink) = store.unowned_symlinks()?.into_iter().next() {
9633        return Err(LinkError::from(std::io::Error::new(
9634            std::io::ErrorKind::PermissionDenied,
9635            format!(
9636                "cannot push: {} is a symlink outside the store ownership model",
9637                symlink.display()
9638            ),
9639        )));
9640    }
9641    Ok(())
9642}
9643
9644/// Push `files` to `brain` as a whole-store snapshot — the hub's push
9645/// semantics: the hosted copy becomes exactly this set (pull first if the
9646/// hosted side may have records the local copy lacks). Client-side caps
9647/// mirror the hub's JSON-path limits so an oversized push fails before the
9648/// upload.
9649pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
9650    require_safe_ref(brain)?;
9651    let remote = verified_remote_head(cfg, brain, false)?;
9652    if files.len() > MAX_PUSH_FILES {
9653        return Err(LinkError::PushTooLarge {
9654            detail: format!("{} files", files.len()),
9655        });
9656    }
9657    let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
9658    if raw_total > MAX_STORE_BYTES {
9659        return Err(LinkError::PushTooLarge {
9660            detail: format!("{raw_total} uncompressed bytes"),
9661        });
9662    }
9663
9664    // Self-custody (a brain key is configured): the JSON fast path is
9665    // hub-signed by construction, so every push goes through the pack flow
9666    // with a locally signed entry — the hub verifies and can never sign.
9667    if cfg.brain_key.is_none() {
9668        let body = json!({
9669            "files": files
9670                .iter()
9671                .map(|(p, c)| json!({ "path": p, "content": c }))
9672                .collect::<Vec<_>>(),
9673        });
9674        if body.to_string().len() <= MAX_PUSH_BYTES {
9675            let path = format!("/api/hub/brains/{brain}/push");
9676            let pushed = ensure_ok(
9677                request(cfg, "POST", &path, Some(&body), Auth::Required)?,
9678                "sync push",
9679            )?;
9680            return Ok(pushed);
9681        }
9682    }
9683
9684    let pack = build_store_pack(files)?;
9685    if pack.len() as u64 > MAX_PACK_BYTES {
9686        return Err(LinkError::PushTooLarge {
9687            detail: format!("{} pack bytes", pack.len()),
9688        });
9689    }
9690    let sha256 = format!("{:x}", Sha256::digest(&pack));
9691    let mut meta = json!({ "sha256": sha256, "bytes": pack.len() });
9692    if let Some(key) = &cfg.brain_key {
9693        if !remote.head.verified {
9694            return Err(invalid_feed(
9695                "self-custody push requires a fully verified, unscoped feed head",
9696            ));
9697        }
9698        let identity = remote
9699            .identity
9700            .as_ref()
9701            .ok_or_else(|| invalid_feed("verified brain has no current identity"))?;
9702        let current_multikey = format!("ed25519:{}", identity.fingerprint);
9703        if key.multikey != current_multikey || key.public_key_spki != identity.public_key_spki {
9704            return Err(invalid_feed(
9705                "configured brain key is not the verified current brain identity",
9706            ));
9707        }
9708        // The verified state pins seq + prev; a concurrent writer surfaces as
9709        // the hub's 422 on commit (re-run to retry against the new head).
9710        let next_seq = remote
9711            .head
9712            .seq
9713            .checked_add(1)
9714            .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
9715        let mut manifest: Vec<WireFeedFile> = files
9716            .iter()
9717            .map(|(path, content)| WireFeedFile {
9718                path: path.clone(),
9719                sha256: format!("{:x}", Sha256::digest(content.as_bytes())),
9720                bytes: content.len() as u64,
9721            })
9722            .collect();
9723        manifest.sort_by(|a, b| a.path.cmp(&b.path));
9724        let ts = crate::now()
9725            .with_timezone(&chrono::Utc)
9726            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
9727            .to_string();
9728        let entry = self_custody_entry(
9729            key,
9730            next_seq,
9731            ts,
9732            &sha256,
9733            &manifest,
9734            remote.head.feed_hash.as_deref(),
9735        )?;
9736        meta["entry"] = Value::String(entry);
9737    }
9738    let presigned = ensure_ok(
9739        request(
9740            cfg,
9741            "POST",
9742            &format!("/api/hub/brains/{brain}/packs/presign"),
9743            Some(&meta),
9744            Auth::Required,
9745        )?,
9746        "prepare pack upload",
9747    )?;
9748    let url = presigned
9749        .get("url")
9750        .and_then(Value::as_str)
9751        .ok_or_else(|| LinkError::InvalidPack {
9752            message: "the hub returned no upload URL".to_string(),
9753        })?;
9754    put_presigned(
9755        cfg,
9756        url,
9757        presigned.get("headers").unwrap_or(&Value::Null),
9758        &pack,
9759    )?;
9760    let committed = ensure_ok(
9761        request(
9762            cfg,
9763            "POST",
9764            &format!("/api/hub/brains/{brain}/packs/commit"),
9765            Some(&meta),
9766            Auth::Required,
9767        )?,
9768        "commit pack",
9769    )?;
9770    Ok(committed)
9771}
9772
9773fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
9774    const LOCAL_HEADER: u32 = 0x0403_4b50;
9775    const CENTRAL_HEADER: u32 = 0x0201_4b50;
9776    const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
9777    const VERSION_20: u16 = 20;
9778    const MADE_BY_UNIX_20: u16 = (3 << 8) | VERSION_20;
9779    const UTF8_FLAG: u16 = 1 << 11;
9780    const STORED: u16 = 0;
9781    const DOS_TIME_MIDNIGHT: u16 = 0;
9782    const DOS_DATE_1980_01_01: u16 = (1 << 5) | 1;
9783    const UNIX_REGULAR_0600: u32 = 0o100600 << 16;
9784
9785    struct CentralEntry<'a> {
9786        name: &'a [u8],
9787        crc32: u32,
9788        size: u32,
9789        local_offset: u32,
9790    }
9791
9792    fn push_u16(out: &mut Vec<u8>, value: u16) {
9793        out.extend_from_slice(&value.to_le_bytes());
9794    }
9795
9796    fn push_u32(out: &mut Vec<u8>, value: u32) {
9797        out.extend_from_slice(&value.to_le_bytes());
9798    }
9799
9800    if files.is_empty() {
9801        return Err(LinkError::InvalidPack {
9802            message: "cannot create an empty snapshot pack".to_string(),
9803        });
9804    }
9805    if files.len() > u16::MAX as usize {
9806        return Err(LinkError::PushTooLarge {
9807            detail: format!(
9808                "{} files (canonical ZIP32 packs cap at {})",
9809                files.len(),
9810                u16::MAX
9811            ),
9812        });
9813    }
9814
9815    let mut sorted: Vec<_> = files.iter().collect();
9816    sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
9817    let mut previous: Option<&str> = None;
9818    for (path, content) in &sorted {
9819        if !safe_store_rel_path(path) {
9820            return Err(LinkError::UnsafePath {
9821                path: (*path).clone(),
9822            });
9823        }
9824        if previous == Some(path.as_str()) {
9825            return Err(LinkError::InvalidPack {
9826                message: format!("duplicate path `{path}`"),
9827            });
9828        }
9829        previous = Some(path.as_str());
9830        u32::try_from(content.len()).map_err(|_| LinkError::PushTooLarge {
9831            detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
9832        })?;
9833    }
9834
9835    let mut out = Vec::new();
9836    let mut central = Vec::with_capacity(sorted.len());
9837    for (path, content) in sorted {
9838        let name = path.as_bytes();
9839        let name_len = u16::try_from(name.len()).map_err(|_| LinkError::InvalidPack {
9840            message: format!("ZIP entry name is too long: `{path}`"),
9841        })?;
9842        let bytes = content.as_bytes();
9843        let size = u32::try_from(bytes.len()).map_err(|_| LinkError::PushTooLarge {
9844            detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
9845        })?;
9846        let local_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
9847            detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
9848        })?;
9849        let crc32 = crc32fast::hash(bytes);
9850
9851        // Canonical local header: sizes and CRC are known up front. Bit 3 is
9852        // deliberately clear, so there is no trailing data descriptor.
9853        push_u32(&mut out, LOCAL_HEADER);
9854        push_u16(&mut out, VERSION_20);
9855        push_u16(&mut out, UTF8_FLAG);
9856        push_u16(&mut out, STORED);
9857        push_u16(&mut out, DOS_TIME_MIDNIGHT);
9858        push_u16(&mut out, DOS_DATE_1980_01_01);
9859        push_u32(&mut out, crc32);
9860        push_u32(&mut out, size);
9861        push_u32(&mut out, size);
9862        push_u16(&mut out, name_len);
9863        push_u16(&mut out, 0); // no local extra data
9864        out.extend_from_slice(name);
9865        out.extend_from_slice(bytes);
9866
9867        central.push(CentralEntry {
9868            name,
9869            crc32,
9870            size,
9871            local_offset,
9872        });
9873    }
9874
9875    let central_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
9876        detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
9877    })?;
9878    for entry in &central {
9879        push_u32(&mut out, CENTRAL_HEADER);
9880        push_u16(&mut out, MADE_BY_UNIX_20);
9881        push_u16(&mut out, VERSION_20);
9882        push_u16(&mut out, UTF8_FLAG);
9883        push_u16(&mut out, STORED);
9884        push_u16(&mut out, DOS_TIME_MIDNIGHT);
9885        push_u16(&mut out, DOS_DATE_1980_01_01);
9886        push_u32(&mut out, entry.crc32);
9887        push_u32(&mut out, entry.size);
9888        push_u32(&mut out, entry.size);
9889        push_u16(&mut out, entry.name.len() as u16);
9890        push_u16(&mut out, 0); // no central extra data
9891        push_u16(&mut out, 0); // no file comment
9892        push_u16(&mut out, 0); // disk number
9893        push_u16(&mut out, 0); // internal attributes
9894        push_u32(&mut out, UNIX_REGULAR_0600);
9895        push_u32(&mut out, entry.local_offset);
9896        out.extend_from_slice(entry.name);
9897    }
9898    let central_size = u32::try_from(out.len())
9899        .ok()
9900        .and_then(|end| end.checked_sub(central_offset))
9901        .ok_or_else(|| LinkError::PushTooLarge {
9902            detail: "canonical ZIP32 central directory exceeds its limit".to_string(),
9903        })?;
9904    let entry_count = central.len() as u16;
9905
9906    push_u32(&mut out, END_OF_CENTRAL_DIRECTORY);
9907    push_u16(&mut out, 0); // this disk
9908    push_u16(&mut out, 0); // central directory disk
9909    push_u16(&mut out, entry_count);
9910    push_u16(&mut out, entry_count);
9911    push_u32(&mut out, central_size);
9912    push_u32(&mut out, central_offset);
9913    push_u16(&mut out, 0); // no archive comment
9914
9915    if out.len() > u32::MAX as usize {
9916        return Err(LinkError::PushTooLarge {
9917            detail: "canonical ZIP32 pack exceeds 4 GiB".to_string(),
9918        });
9919    }
9920    Ok(out)
9921}
9922
9923// ─────────────────────────────────────────────────────────────────────────────
9924// grant — issue / list / revoke capabilities (owner-side)
9925// ─────────────────────────────────────────────────────────────────────────────
9926
9927/// The two capabilities a v0 hub enforces.
9928#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9929pub enum Capability {
9930    /// Read the granted slice.
9931    Read,
9932    /// Read and push (whole-store; a path-scoped grant is read-only).
9933    Write,
9934}
9935
9936impl Capability {
9937    /// The wire form.
9938    pub fn as_str(self) -> &'static str {
9939        match self {
9940            Capability::Read => "read",
9941            Capability::Write => "write",
9942        }
9943    }
9944}
9945
9946/// Issue (or refresh) a grant on `brain` to `grantee` — a hub principal named
9947/// by email in v0 (the protocol's near-term simplification; key-named
9948/// grantees arrive with the signing layer). `scope` is a store-path prefix
9949/// (the hub's enforcement unit); `until` an ISO 8601 expiry, absent = until
9950/// revoked.
9951pub fn grant_issue(
9952    cfg: &HubConfig,
9953    brain: &str,
9954    grantee: &str,
9955    can: Capability,
9956    scope: Option<&str>,
9957    until: Option<&str>,
9958) -> LinkResult<Value> {
9959    require_safe_ref(brain)?;
9960    let _ = verified_remote_head(cfg, brain, false)?;
9961    // Grantee shape decides the axis: a base64url Ed25519 SPKI is a bare
9962    // multikey holder (link.md §6 cross-party keys — no hub account; the
9963    // printed `publicKeySpki` from `dbmd key generate`); anything else is a
9964    // hub principal named by email.
9965    let is_key_grantee = URL_SAFE_NO_PAD
9966        .decode(grantee)
9967        .map(|der| der.len() == 44 && der.starts_with(&ED25519_SPKI_PREFIX))
9968        .unwrap_or(false);
9969    let mut body = if is_key_grantee {
9970        json!({ "keySpki": grantee, "capability": can.as_str() })
9971    } else {
9972        json!({ "email": grantee, "capability": can.as_str() })
9973    };
9974    if let Some(s) = scope {
9975        body["scopePrefix"] = json!(s);
9976    }
9977    if let Some(u) = until {
9978        body["expiresAt"] = json!(u);
9979    }
9980    let path = format!("/api/hub/brains/{brain}/grants");
9981    ensure_ok(
9982        request(cfg, "POST", &path, Some(&body), Auth::Required)?,
9983        "grant issue",
9984    )
9985}
9986
9987/// List the active grants (and pending invites) on `brain`. Owner-side.
9988pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
9989    require_safe_ref(brain)?;
9990    let _ = verified_remote_head(cfg, brain, false)?;
9991    let path = format!("/api/hub/brains/{brain}/grants");
9992    ensure_ok(
9993        request(cfg, "GET", &path, None, Auth::Required)?,
9994        "grant list",
9995    )
9996}
9997
9998/// Revoke a grant (or cancel a pending invite) by id. Owner-side; revocation
9999/// is soft on the hub (the audit trail survives).
10000pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
10001    require_safe_ref(brain)?;
10002    require_safe_grant_id(grant_id)?;
10003    let _ = verified_remote_head(cfg, brain, false)?;
10004    let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
10005    ensure_ok(
10006        request(cfg, "DELETE", &path, None, Auth::Required)?,
10007        "grant revoke",
10008    )
10009}
10010
10011// ─────────────────────────────────────────────────────────────────────────────
10012// permissioned v2 proposals — inspect, accept, reject
10013// ─────────────────────────────────────────────────────────────────────────────
10014
10015#[derive(Debug)]
10016struct VerifiedV2Proposal {
10017    value: Value,
10018    changes: Value,
10019    blobs: Vec<(String, u64, String)>,
10020}
10021
10022fn require_proposal_id(id: &str) -> LinkResult<()> {
10023    if crate::ulid::is_ulid(id) {
10024        Ok(())
10025    } else {
10026        Err(invalid_feed("proposal id is not a lowercase ULID"))
10027    }
10028}
10029
10030fn verified_v2_proposal(
10031    cfg: &HubConfig,
10032    head: &V2VerifiedHead,
10033    proposal_id: &str,
10034) -> LinkResult<VerifiedV2Proposal> {
10035    require_proposal_id(proposal_id)?;
10036    if head.view_kind != "full" {
10037        return Err(invalid_feed(
10038            "proposal review requires a full readable view",
10039        ));
10040    }
10041    let path = format!(
10042        "/api/hub/brains/{}/v2/proposals/{proposal_id}",
10043        head.brain_id
10044    );
10045    let value = ensure_ok(
10046        request_capped(
10047            cfg,
10048            "GET",
10049            &path,
10050            None,
10051            Auth::Required,
10052            MAX_FEED_RESPONSE_BYTES,
10053        )?,
10054        "v2 proposal",
10055    )?;
10056    verify_v2_proposal_value(head, proposal_id, value)
10057}
10058
10059fn verify_v2_proposal_value(
10060    head: &V2VerifiedHead,
10061    proposal_id: &str,
10062    value: Value,
10063) -> LinkResult<VerifiedV2Proposal> {
10064    if value.get("v").and_then(Value::as_u64) != Some(2) {
10065        return Err(invalid_feed("proposal response has an invalid version"));
10066    }
10067    let proposal = value
10068        .get("proposal")
10069        .and_then(Value::as_object)
10070        .ok_or_else(|| invalid_feed("proposal response has no proposal"))?;
10071    if proposal.get("id").and_then(Value::as_str) != Some(proposal_id) {
10072        return Err(invalid_feed("proposal response changed its id"));
10073    }
10074    let payload_hash = proposal
10075        .get("payload_sha256")
10076        .and_then(Value::as_str)
10077        .filter(|hash| is_sha256(hash))
10078        .ok_or_else(|| invalid_feed("proposal has no payload address"))?;
10079    let clear_hash = proposal
10080        .get("clear_sha256")
10081        .and_then(Value::as_str)
10082        .filter(|hash| is_sha256(hash))
10083        .ok_or_else(|| invalid_feed("proposal has no clear payload digest"))?;
10084    let submission_hash = proposal
10085        .get("submission_claim_sha256")
10086        .and_then(Value::as_str)
10087        .filter(|hash| is_sha256(hash))
10088        .ok_or_else(|| invalid_feed("proposal has no submission claim address"))?;
10089    let submission = STANDARD
10090        .decode(
10091            proposal
10092                .get("submission_claim_base64")
10093                .and_then(Value::as_str)
10094                .ok_or_else(|| invalid_feed("proposal has no submission claim"))?,
10095        )
10096        .map_err(|_| invalid_feed("proposal submission claim is not base64"))?;
10097    let submission_value: Value = serde_json::from_slice(&submission)
10098        .map_err(|_| invalid_feed("proposal submission claim is not JSON"))?;
10099    if crate::linkmd_v2::canonical_bytes(&submission_value)
10100        .map_err(|error| invalid_feed(error.to_string()))?
10101        != submission
10102        || crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &submission)
10103            .map_err(|error| invalid_feed(error.to_string()))?
10104            != submission_hash
10105    {
10106        return Err(invalid_feed(
10107            "proposal submission claim is not canonical or addressed",
10108        ));
10109    }
10110    let envelope = submission_value
10111        .as_object()
10112        .ok_or_else(|| invalid_feed("proposal submission claim is not an object"))?;
10113    let claim = envelope
10114        .get("claim")
10115        .ok_or_else(|| invalid_feed("proposal submission claim body is missing"))?;
10116    let claim_object = claim
10117        .as_object()
10118        .ok_or_else(|| invalid_feed("proposal submission claim body is not an object"))?;
10119    let actor_root = claim_object
10120        .get("actor_root")
10121        .and_then(Value::as_object)
10122        .ok_or_else(|| invalid_feed("proposal submission actor root is missing"))?;
10123    let public_key = envelope
10124        .get("public_key")
10125        .and_then(Value::as_str)
10126        .ok_or_else(|| invalid_feed("proposal submission signer is missing"))?;
10127    let fingerprint = envelope
10128        .get("fingerprint")
10129        .and_then(Value::as_str)
10130        .ok_or_else(|| invalid_feed("proposal submission fingerprint is missing"))?;
10131    let signature = envelope
10132        .get("sig")
10133        .and_then(Value::as_str)
10134        .ok_or_else(|| invalid_feed("proposal submission signature is missing"))?;
10135    let claim_bytes = crate::linkmd_v2::canonical_bytes(claim)
10136        .map_err(|error| invalid_feed(error.to_string()))?;
10137    let der = verify_v2_spki_signature(public_key, &claim_bytes, signature)?;
10138    let signer = format!("{fingerprint}:{public_key}");
10139    let actor_class = actor_root.get("actor_class").and_then(Value::as_str);
10140    let grants = actor_root.get("grants").and_then(Value::as_array);
10141    let grants_are_canonical = grants.is_some_and(|items| {
10142        let mut prior: Option<&str> = None;
10143        items.iter().all(|item| {
10144            let Some(grant) = item.as_str() else {
10145                return false;
10146            };
10147            if !crate::ulid::is_ulid(grant) || prior.is_some_and(|value| value >= grant) {
10148                return false;
10149            }
10150            prior = Some(grant);
10151            true
10152        })
10153    });
10154    let optional_actor_field = |name: &str| {
10155        actor_root.get(name).is_some_and(|value| {
10156            value.is_null() || value.as_str().is_some_and(|text| !text.is_empty())
10157        })
10158    };
10159    let submitted_at = claim_object.get("submitted_at").and_then(Value::as_str);
10160    if claim_object.get("v").and_then(Value::as_u64) != Some(2)
10161        || format!("{:x}", Sha256::digest(&der)) != fingerprint
10162        || head
10163            .trust
10164            .hub_signer
10165            .as_ref()
10166            .is_some_and(|known| known != &signer)
10167        || !matches!(
10168            actor_class,
10169            Some(
10170                "user"
10171                    | "owned_agent"
10172                    | "foreign_key"
10173                    | "curation"
10174                    | "inbox"
10175                    | "restore"
10176                    | "migration"
10177                    | "operator_recovery"
10178            )
10179        )
10180        || actor_root
10181            .get("principal")
10182            .and_then(Value::as_str)
10183            .is_none_or(|value| value.is_empty())
10184        || actor_root
10185            .get("credential")
10186            .and_then(Value::as_str)
10187            .is_none_or(|value| value.is_empty())
10188        || !optional_actor_field("organization")
10189        || !optional_actor_field("role")
10190        || !grants_are_canonical
10191        || claim.get("brain").and_then(Value::as_str) != Some(head.brain_id.as_str())
10192        || claim.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
10193        || !claim_object
10194            .get("mutation_id")
10195            .and_then(Value::as_str)
10196            .is_some_and(|value| {
10197                !value.is_empty()
10198                    && value.len() <= 128
10199                    && value.chars().enumerate().all(|(index, char)| {
10200                        char.is_ascii_alphanumeric()
10201                            || (index > 0 && matches!(char, '.' | '_' | ':' | '-'))
10202                    })
10203            })
10204        || claim.get("payload_sha256").and_then(Value::as_str) != Some(payload_hash)
10205        || claim.get("clear_sha256").and_then(Value::as_str) != Some(clear_hash)
10206        || !claim_object
10207            .get("control_revision")
10208            .and_then(Value::as_str)
10209            .is_some_and(is_sha256)
10210        || submitted_at.is_none_or(|value| {
10211            chrono::DateTime::parse_from_rfc3339(value).is_err()
10212                || proposal.get("submitted_at").and_then(Value::as_str) != Some(value)
10213        })
10214        || !proposal
10215            .get("state")
10216            .and_then(Value::as_str)
10217            .is_some_and(|value| matches!(value, "pending" | "accepted" | "rejected" | "expired"))
10218        || proposal
10219            .get("expires_at")
10220            .and_then(Value::as_str)
10221            .is_none_or(|value| chrono::DateTime::parse_from_rfc3339(value).is_err())
10222        || proposal
10223            .get("proposer")
10224            .and_then(Value::as_object)
10225            .and_then(|value| value.get("class"))
10226            .and_then(Value::as_str)
10227            != actor_class
10228    {
10229        return Err(invalid_feed(
10230            "proposal submission claim does not bind the verified proposal",
10231        ));
10232    }
10233    let changes_b64 = proposal
10234        .get("changes_base64")
10235        .and_then(Value::as_str)
10236        .ok_or_else(|| invalid_feed("proposal has no changeset"))?;
10237    let changes_bytes = STANDARD
10238        .decode(changes_b64)
10239        .map_err(|_| invalid_feed("proposal changeset is not base64"))?;
10240    let changes: Value = serde_json::from_slice(&changes_bytes)
10241        .map_err(|_| invalid_feed("proposal changeset is not JSON"))?;
10242    if crate::linkmd_v2::canonical_bytes(&changes)
10243        .map_err(|error| invalid_feed(error.to_string()))?
10244        != changes_bytes
10245        || changes.get("v").and_then(Value::as_u64) != Some(2)
10246        || !changes.get("operations").is_some_and(Value::is_array)
10247    {
10248        return Err(invalid_feed("proposal changeset is not canonical v2"));
10249    }
10250    let blob_values = proposal
10251        .get("blobs")
10252        .and_then(Value::as_array)
10253        .ok_or_else(|| invalid_feed("proposal has no blob declarations"))?;
10254    let mut blobs = Vec::with_capacity(blob_values.len());
10255    let mut descriptor_blobs = Vec::with_capacity(blob_values.len());
10256    let mut prior_hash: Option<String> = None;
10257    for item in blob_values {
10258        let hash = item
10259            .get("sha256")
10260            .and_then(Value::as_str)
10261            .filter(|hash| is_sha256(hash))
10262            .ok_or_else(|| invalid_feed("proposal blob has no address"))?;
10263        let bytes = item
10264            .get("bytes")
10265            .and_then(Value::as_u64)
10266            .filter(|bytes| *bytes <= MAX_STORE_BYTES)
10267            .ok_or_else(|| invalid_feed("proposal blob has an invalid size"))?;
10268        if prior_hash.as_deref().is_some_and(|prior| prior >= hash) {
10269            return Err(invalid_feed(
10270                "proposal blob declarations are not unique and sorted",
10271            ));
10272        }
10273        prior_hash = Some(hash.to_string());
10274        let endpoint = item
10275            .get("endpoint")
10276            .and_then(Value::as_str)
10277            .ok_or_else(|| invalid_feed("proposal blob has no endpoint"))?;
10278        let expected_endpoint = format!(
10279            "/api/hub/brains/{}/v2/proposals/{proposal_id}/blob?sha256={hash}",
10280            head.brain_id
10281        );
10282        if endpoint != expected_endpoint {
10283            return Err(invalid_feed("proposal blob endpoint is not origin-bound"));
10284        }
10285        descriptor_blobs.push(json!({ "bytes": bytes, "sha256": hash }));
10286        blobs.push((hash.to_string(), bytes, endpoint.to_string()));
10287    }
10288    let descriptor = json!({
10289        "base": proposal.get("base").cloned().unwrap_or(Value::Null),
10290        "blobs": descriptor_blobs,
10291        "changes_base64": changes_b64,
10292        "rebase": proposal.get("rebase").cloned().unwrap_or(Value::Null),
10293        "v": 2,
10294    });
10295    let descriptor_bytes = crate::linkmd_v2::canonical_bytes(&descriptor)
10296        .map_err(|error| invalid_feed(error.to_string()))?;
10297    if content_sha256(&descriptor_bytes) != clear_hash {
10298        return Err(invalid_feed(
10299            "proposal clear payload differs from its signed submission claim",
10300        ));
10301    }
10302    Ok(VerifiedV2Proposal {
10303        value,
10304        changes,
10305        blobs,
10306    })
10307}
10308
10309pub fn proposal_list(
10310    cfg: &HubConfig,
10311    brain: &str,
10312    state: &str,
10313    after: Option<&str>,
10314    limit: usize,
10315) -> LinkResult<Value> {
10316    require_safe_ref(brain)?;
10317    if !matches!(state, "pending" | "accepted" | "rejected" | "expired") {
10318        return Err(invalid_feed("proposal state is invalid"));
10319    }
10320    if after.is_some_and(|value| !crate::ulid::is_ulid(value)) {
10321        return Err(invalid_feed("proposal cursor is invalid"));
10322    }
10323    let head = v2_verified_head(cfg, brain)?
10324        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
10325    let path = format!(
10326        "/api/hub/brains/{}/v2/proposals?state={state}&limit={}{}",
10327        head.brain_id,
10328        limit.clamp(1, 100),
10329        after.map_or_else(String::new, |value| format!("&after={value}"))
10330    );
10331    ensure_ok(
10332        request_capped(
10333            cfg,
10334            "GET",
10335            &path,
10336            None,
10337            Auth::Required,
10338            MAX_FEED_RESPONSE_BYTES,
10339        )?,
10340        "v2 proposal list",
10341    )
10342}
10343
10344pub fn proposal_show(cfg: &HubConfig, brain: &str, proposal_id: &str) -> LinkResult<Value> {
10345    require_safe_ref(brain)?;
10346    let head = v2_verified_head(cfg, brain)?
10347        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
10348    Ok(verified_v2_proposal(cfg, &head, proposal_id)?.value)
10349}
10350
10351pub fn proposal_reject(
10352    cfg: &HubConfig,
10353    brain: &str,
10354    proposal_id: &str,
10355    mutation_id: &str,
10356    reason: &str,
10357) -> LinkResult<Value> {
10358    require_safe_ref(brain)?;
10359    require_proposal_id(proposal_id)?;
10360    let head = v2_verified_head(cfg, brain)?
10361        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
10362    let _ = verified_v2_proposal(cfg, &head, proposal_id)?;
10363    let body = json!({
10364        "mutation_id": mutation_id,
10365        "control_revision": head.control_revision,
10366        "reason": reason,
10367    });
10368    let path = format!(
10369        "/api/hub/brains/{}/v2/proposals/{proposal_id}",
10370        head.brain_id
10371    );
10372    ensure_ok(
10373        request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
10374        "v2 proposal rejection",
10375    )
10376}
10377
10378pub fn proposal_accept_exact(
10379    cfg: &HubConfig,
10380    brain: &str,
10381    proposal_id: &str,
10382    mutation_id: &str,
10383    reason: &str,
10384) -> LinkResult<Value> {
10385    require_safe_ref(brain)?;
10386    require_proposal_id(proposal_id)?;
10387    let head = v2_verified_head(cfg, brain)?
10388        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
10389    let proposal = verified_v2_proposal(cfg, &head, proposal_id)?;
10390    let operations = proposal
10391        .changes
10392        .get("operations")
10393        .and_then(Value::as_array)
10394        .cloned()
10395        .ok_or_else(|| invalid_feed("proposal changeset has no operations"))?;
10396    if operations.is_empty() || operations.len() > MAX_PUSH_FILES {
10397        return Err(invalid_feed("proposal operation count is invalid"));
10398    }
10399    let mut downloaded = std::collections::BTreeMap::new();
10400    for (hash, bytes, endpoint) in &proposal.blobs {
10401        let body = ensure_raw_ok(
10402            request_raw(cfg, "GET", endpoint, None, Auth::Required, *bytes)?,
10403            "v2 proposal blob",
10404        )?;
10405        if body.len() as u64 != *bytes || content_sha256(&body) != *hash {
10406            return Err(invalid_feed("proposal blob does not match its declaration"));
10407        }
10408        downloaded.insert(hash.clone(), body);
10409    }
10410    let remote = files_for_v2_view(
10411        &head,
10412        v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
10413    );
10414    let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
10415    let mut expected_candidate = remote.clone();
10416    let mut expected_candidate_assets = remote_assets;
10417    for operation in &operations {
10418        let op = operation
10419            .get("op")
10420            .and_then(Value::as_str)
10421            .ok_or_else(|| invalid_feed("proposal operation has no kind"))?;
10422        match op {
10423            "put" | "restore" => {
10424                let path = operation
10425                    .get("path")
10426                    .and_then(Value::as_str)
10427                    .ok_or_else(|| invalid_feed("proposal write has no path"))?;
10428                crate::linkmd_v2::normalize_path(path)
10429                    .map_err(|error| invalid_feed(error.to_string()))?;
10430                let hash = operation
10431                    .get("blob")
10432                    .and_then(Value::as_str)
10433                    .filter(|hash| is_sha256(hash))
10434                    .ok_or_else(|| invalid_feed("proposal write has no blob"))?;
10435                let bytes = operation
10436                    .get("bytes")
10437                    .and_then(Value::as_u64)
10438                    .ok_or_else(|| invalid_feed("proposal write has no size"))?;
10439                expected_candidate.insert(
10440                    path.to_string(),
10441                    V2BaselineFile {
10442                        sha256: hash.to_string(),
10443                        bytes,
10444                        proof: None,
10445                    },
10446                );
10447            }
10448            "delete" | "withdraw_from_hosting" => {
10449                let path = operation
10450                    .get("path")
10451                    .and_then(Value::as_str)
10452                    .ok_or_else(|| invalid_feed("proposal removal has no path"))?;
10453                crate::linkmd_v2::normalize_path(path)
10454                    .map_err(|error| invalid_feed(error.to_string()))?;
10455                expected_candidate.remove(path);
10456            }
10457            "rename" => {
10458                let from = operation
10459                    .get("from")
10460                    .and_then(Value::as_str)
10461                    .ok_or_else(|| invalid_feed("proposal rename has no source"))?;
10462                let to = operation
10463                    .get("to")
10464                    .and_then(Value::as_str)
10465                    .ok_or_else(|| invalid_feed("proposal rename has no destination"))?;
10466                crate::linkmd_v2::normalize_path(from)
10467                    .and_then(|_| crate::linkmd_v2::normalize_path(to))
10468                    .map_err(|error| invalid_feed(error.to_string()))?;
10469                let hash = operation
10470                    .get("blob")
10471                    .and_then(Value::as_str)
10472                    .filter(|hash| is_sha256(hash))
10473                    .ok_or_else(|| invalid_feed("proposal rename has no blob"))?;
10474                let bytes = operation
10475                    .get("bytes")
10476                    .and_then(Value::as_u64)
10477                    .ok_or_else(|| invalid_feed("proposal rename has no size"))?;
10478                expected_candidate.remove(from);
10479                expected_candidate.insert(
10480                    to.to_string(),
10481                    V2BaselineFile {
10482                        sha256: hash.to_string(),
10483                        bytes,
10484                        proof: None,
10485                    },
10486                );
10487            }
10488            "asset_delete" => {
10489                let path = operation
10490                    .get("path")
10491                    .and_then(Value::as_str)
10492                    .ok_or_else(|| invalid_feed("proposal asset delete has no path"))?;
10493                expected_candidate_assets.remove(path);
10494            }
10495            "asset_withdraw" => {
10496                let path = operation
10497                    .get("path")
10498                    .and_then(Value::as_str)
10499                    .ok_or_else(|| invalid_feed("proposal asset withdrawal has no path"))?;
10500                let asset = expected_candidate_assets
10501                    .get_mut(path)
10502                    .ok_or_else(|| invalid_feed("proposal withdraws an unknown asset"))?;
10503                asset.disposition = "withheld".to_string();
10504                asset.leaf_hash.clear();
10505            }
10506            "asset_put" | "asset_resume" => {
10507                let path = operation
10508                    .get("path")
10509                    .and_then(Value::as_str)
10510                    .ok_or_else(|| invalid_feed("proposal asset write has no path"))?;
10511                let asset = operation
10512                    .get("asset")
10513                    .and_then(Value::as_object)
10514                    .ok_or_else(|| invalid_feed("proposal asset write has no value"))?;
10515                let blob_sha256 = asset
10516                    .get("blob_sha256")
10517                    .and_then(Value::as_str)
10518                    .filter(|hash| is_sha256(hash))
10519                    .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
10520                let bytes = asset
10521                    .get("bytes")
10522                    .and_then(Value::as_u64)
10523                    .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
10524                let media_type = asset
10525                    .get("media_type")
10526                    .and_then(Value::as_str)
10527                    .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
10528                let wrappers = asset
10529                    .get("wrappers")
10530                    .and_then(Value::as_array)
10531                    .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
10532                    .iter()
10533                    .map(|wrapper| {
10534                        wrapper
10535                            .as_str()
10536                            .map(str::to_string)
10537                            .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
10538                    })
10539                    .collect::<LinkResult<Vec<_>>>()?;
10540                let required = asset
10541                    .get("required")
10542                    .and_then(Value::as_bool)
10543                    .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
10544                let disposition = asset
10545                    .get("disposition")
10546                    .and_then(Value::as_str)
10547                    .filter(|value| matches!(*value, "hosted" | "withheld"))
10548                    .ok_or_else(|| invalid_feed("proposal asset disposition is invalid"))?;
10549                expected_candidate_assets.insert(
10550                    path.to_string(),
10551                    V2BaselineAsset {
10552                        blob_sha256: blob_sha256.to_string(),
10553                        bytes,
10554                        media_type: media_type.to_string(),
10555                        wrappers,
10556                        required,
10557                        disposition: disposition.to_string(),
10558                        leaf_hash: String::new(),
10559                    },
10560                );
10561            }
10562            _ => return Err(invalid_feed("proposal operation kind is unsupported")),
10563        }
10564    }
10565    let base = head.pointer.as_ref().map(|pointer| {
10566        json!({
10567            "seq": pointer.seq,
10568            "commit_hash": pointer.commit_hash,
10569            "content_root": pointer.content_root,
10570            "asset_root": pointer.asset_root,
10571        })
10572    });
10573    let mut body = json!({
10574        "mutation_id": mutation_id,
10575        "base": base,
10576        "rebase": "strict",
10577        "reason": reason,
10578        "operations": operations,
10579        "blobs": downloaded
10580            .iter()
10581            .map(|(sha256, bytes)| json!({
10582                "sha256": sha256,
10583                "bytes": bytes.len(),
10584                "content_base64": STANDARD.encode(bytes),
10585            }))
10586            .collect::<Vec<_>>(),
10587        "proposal_id": proposal_id,
10588        "proposal_mode": "exact",
10589    });
10590    let changed_bytes = downloaded.values().try_fold(0_usize, |total, bytes| {
10591        total
10592            .checked_add(bytes.len())
10593            .ok_or_else(|| LinkError::PushTooLarge {
10594                detail: "proposal changed-byte total overflow".to_string(),
10595            })
10596    })?;
10597    if changed_bytes > 3 * 1024 * 1024 || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
10598        let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
10599        for operation in &operations {
10600            let Some(kind) = operation.get("op").and_then(Value::as_str) else {
10601                return Err(invalid_feed("proposal upload operation has no kind"));
10602            };
10603            let hash = match kind {
10604                "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
10605                "asset_put" | "asset_resume" => operation
10606                    .get("asset")
10607                    .and_then(|asset| asset.get("blob_sha256"))
10608                    .and_then(Value::as_str),
10609                _ => None,
10610            };
10611            let Some(hash) = hash else { continue };
10612            let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
10613            if kind == "rename" {
10614                for field in ["from", "to"] {
10615                    coordinates.insert(
10616                        operation
10617                            .get(field)
10618                            .and_then(Value::as_str)
10619                            .ok_or_else(|| invalid_feed("proposal rename has no coordinate"))?
10620                            .to_string(),
10621                    );
10622                }
10623            } else {
10624                let path = operation
10625                    .get("path")
10626                    .and_then(Value::as_str)
10627                    .ok_or_else(|| invalid_feed("proposal upload has no coordinate"))?;
10628                coordinates.insert(if kind.starts_with("asset_") {
10629                    format!("assets/{path}")
10630                } else {
10631                    path.to_string()
10632                });
10633            }
10634        }
10635        let declarations = downloaded
10636            .iter()
10637            .map(|(sha256, bytes)| {
10638                json!({
10639                    "sha256": sha256,
10640                    "bytes": bytes.len(),
10641                    "coordinates": coordinates_by_hash
10642                        .get(sha256)
10643                        .into_iter()
10644                        .flatten()
10645                        .collect::<Vec<_>>(),
10646                })
10647            })
10648            .collect::<Vec<_>>();
10649        let reserved = ensure_ok(
10650            request(
10651                cfg,
10652                "POST",
10653                &format!("/api/hub/brains/{}/v2/uploads", head.brain_id),
10654                Some(&json!({ "blobs": declarations })),
10655                Auth::Required,
10656            )?,
10657            "prepare proposal blob transport",
10658        )?;
10659        let items = reserved
10660            .get("uploads")
10661            .and_then(Value::as_array)
10662            .ok_or_else(|| invalid_feed("proposal upload reservation has no items"))?;
10663        if items.len() != downloaded.len() {
10664            return Err(invalid_feed("proposal upload reservation changed the set"));
10665        }
10666        let mut references = Vec::with_capacity(items.len());
10667        for item in items {
10668            let hash = item
10669                .get("sha256")
10670                .and_then(Value::as_str)
10671                .ok_or_else(|| invalid_feed("proposal upload reservation has no hash"))?;
10672            let bytes = downloaded
10673                .get(hash)
10674                .ok_or_else(|| invalid_feed("proposal upload reservation introduced a blob"))?;
10675            let reservation_id = item
10676                .get("reservation_id")
10677                .and_then(Value::as_str)
10678                .filter(|id| crate::ulid::is_ulid(id))
10679                .ok_or_else(|| invalid_feed("proposal upload reservation has no id"))?;
10680            let expected_coordinates = coordinates_by_hash
10681                .get(hash)
10682                .ok_or_else(|| invalid_feed("proposal upload has no coordinate binding"))?;
10683            let returned_coordinates = item
10684                .get("coordinates")
10685                .and_then(Value::as_array)
10686                .ok_or_else(|| invalid_feed("proposal upload reservation has no coordinates"))?;
10687            if returned_coordinates.len() != expected_coordinates.len()
10688                || returned_coordinates
10689                    .iter()
10690                    .zip(expected_coordinates)
10691                    .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
10692            {
10693                return Err(invalid_feed(
10694                    "proposal upload reservation changed its coordinates",
10695                ));
10696            }
10697            match item.get("status").and_then(Value::as_str) {
10698                Some("upload") => put_presigned(
10699                    cfg,
10700                    item.get("url")
10701                        .and_then(Value::as_str)
10702                        .ok_or_else(|| invalid_feed("proposal upload has no URL"))?,
10703                    item.get("headers").unwrap_or(&Value::Null),
10704                    bytes,
10705                )?,
10706                Some("already_present") => {}
10707                _ => return Err(invalid_feed("proposal upload status is invalid")),
10708            }
10709            references.push(json!({
10710                "sha256": hash,
10711                "bytes": bytes.len(),
10712                "reservation_id": reservation_id,
10713            }));
10714        }
10715        body["blobs"] = Value::Array(references);
10716    }
10717    if body.to_string().len() > MAX_PUSH_BYTES {
10718        return Err(LinkError::PushTooLarge {
10719            detail: "proposal operation metadata exceeds the commit request cap".to_string(),
10720        });
10721    }
10722    let path = format!("/api/hub/brains/{}/v2/commits", head.brain_id);
10723    let mut result = ensure_ok(
10724        request(cfg, "POST", &path, Some(&body), Auth::Required)?,
10725        "exact proposal acceptance",
10726    )?;
10727    let mut candidate_hub_signer = None;
10728    if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
10729        let challenge = result
10730            .get("signing_challenge")
10731            .ok_or_else(|| invalid_feed("proposal acceptance has no signing challenge"))?;
10732        let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
10733            cfg,
10734            &head,
10735            &expected_candidate,
10736            &expected_candidate_assets,
10737            mutation_id,
10738            &body,
10739            challenge,
10740        )?;
10741        body["signing_challenge_id"] = Value::String(challenge_id);
10742        body["signature_base64url"] = Value::String(signature);
10743        candidate_hub_signer = Some(actor_signer);
10744        result = ensure_ok(
10745            request(cfg, "POST", &path, Some(&body), Auth::Required)?,
10746            "signed exact proposal acceptance",
10747        )?;
10748    }
10749    let refreshed = v2_verified_head(cfg, brain)?
10750        .ok_or_else(|| invalid_feed("v2 head disappeared after proposal acceptance"))?;
10751    if candidate_hub_signer
10752        .as_ref()
10753        .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
10754        || refreshed
10755            .pointer
10756            .as_ref()
10757            .map(|pointer| pointer.commit_hash.as_str())
10758            != result.get("commit_hash").and_then(Value::as_str)
10759        || result.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
10760        || result.get("proposal_state").and_then(Value::as_str) != Some("accepted")
10761    {
10762        return Err(LinkError::RemoteAdvancedDuringSync);
10763    }
10764    accept_v2_head(cfg, &refreshed)?;
10765    Ok(result)
10766}
10767
10768// ─────────────────────────────────────────────────────────────────────────────
10769// propose — write without trust: evidence into the owner's inbox
10770// ─────────────────────────────────────────────────────────────────────────────
10771
10772/// Submit `body` to the published site `handle`, addressed to its app page
10773/// `app` (a page that declares the `write-inbox` capability). Deliberately
10774/// unauthenticated — this is the cross-party door; the submission lands as
10775/// *evidence* in the owner's `sources/inbox/`, never as truth, and the
10776/// owner's curator accepts or rejects it. Returns the hub's `{id, path}`
10777/// receipt.
10778pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
10779    require_valid_handle(handle)?;
10780    if body.len() as u64 > MAX_PROPOSE_BYTES {
10781        return Err(LinkError::ProposeTooLarge {
10782            bytes: body.len() as u64,
10783        });
10784    }
10785    let payload = json!({ "app": app, "body": body });
10786    // A ULID-shaped target is a bare brain address (link.md §7.4's
10787    // generalization): the brain inbox door, open on public brains, where a
10788    // configured credential earns a bigger actor-class budget. Anything else
10789    // is a published-site handle: that door is unauthenticated by design.
10790    let (path, auth) = if crate::ulid::is_ulid(handle) {
10791        (format!("/api/hub/brains/{handle}/inbox"), Auth::Optional)
10792    } else {
10793        (format!("/api/hub/sites/{handle}/inbox"), Auth::None)
10794    };
10795    ensure_ok(
10796        request(cfg, "POST", &path, Some(&payload), auth)?,
10797        "propose",
10798    )
10799}
10800
10801// ─────────────────────────────────────────────────────────────────────────────
10802// subscribe — follow feed-head movement
10803// ─────────────────────────────────────────────────────────────────────────────
10804
10805/// One observation of a brain's feed head.
10806#[derive(Debug, serde::Serialize)]
10807pub struct Head {
10808    /// The brain id.
10809    pub brain: String,
10810    /// The hub's durable feed cursor — advances on every accepted write.
10811    pub seq: u64,
10812    /// The hub's `updatedAt` for the brain, when present.
10813    #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
10814    pub updated_at: Option<String>,
10815    /// SHA-256 of the exact signed head entry.
10816    #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
10817    pub feed_hash: Option<String>,
10818    /// Whether the head entry's content hash, identity, and Ed25519 signature
10819    /// were verified locally. Path-scoped grants get head movement only.
10820    pub verified: bool,
10821}
10822
10823struct BoundedVecVisitor<T, const MAX: usize> {
10824    label: &'static str,
10825    marker: std::marker::PhantomData<T>,
10826}
10827
10828impl<'de, T, const MAX: usize> serde::de::Visitor<'de> for BoundedVecVisitor<T, MAX>
10829where
10830    T: Deserialize<'de>,
10831{
10832    type Value = Vec<T>;
10833
10834    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
10835        write!(formatter, "at most {MAX} {}", self.label)
10836    }
10837
10838    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
10839    where
10840        A: serde::de::SeqAccess<'de>,
10841    {
10842        if sequence.size_hint().is_some_and(|size| size > MAX) {
10843            return Err(serde::de::Error::custom(format!(
10844                "{} exceeds the {MAX}-item limit",
10845                self.label
10846            )));
10847        }
10848        let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX));
10849        while let Some(value) = sequence.next_element()? {
10850            if values.len() == MAX {
10851                return Err(serde::de::Error::custom(format!(
10852                    "{} exceeds the {MAX}-item limit",
10853                    self.label
10854                )));
10855            }
10856            values.push(value);
10857        }
10858        Ok(values)
10859    }
10860}
10861
10862fn deserialize_bounded_vec<'de, D, T, const MAX: usize>(
10863    deserializer: D,
10864    label: &'static str,
10865) -> Result<Vec<T>, D::Error>
10866where
10867    D: serde::Deserializer<'de>,
10868    T: Deserialize<'de>,
10869{
10870    deserializer.deserialize_seq(BoundedVecVisitor::<T, MAX> {
10871        label,
10872        marker: std::marker::PhantomData,
10873    })
10874}
10875
10876fn deserialize_feed_files<'de, D>(deserializer: D) -> Result<Vec<FeedFile>, D::Error>
10877where
10878    D: serde::Deserializer<'de>,
10879{
10880    deserialize_bounded_vec::<D, FeedFile, MAX_PUSH_FILES>(deserializer, "feed files")
10881}
10882
10883fn deserialize_removed_paths<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
10884where
10885    D: serde::Deserializer<'de>,
10886{
10887    deserialize_bounded_vec::<D, String, MAX_PUSH_FILES>(deserializer, "removed paths")
10888}
10889
10890fn deserialize_previous_identities<'de, D>(
10891    deserializer: D,
10892) -> Result<Vec<PreviousIdentity>, D::Error>
10893where
10894    D: serde::Deserializer<'de>,
10895{
10896    deserialize_bounded_vec::<D, PreviousIdentity, MAX_IDENTITY_ROTATIONS>(
10897        deserializer,
10898        "previous identities",
10899    )
10900}
10901
10902fn deserialize_rotations<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
10903where
10904    D: serde::Deserializer<'de>,
10905{
10906    deserialize_bounded_vec::<D, String, MAX_IDENTITY_ROTATIONS>(
10907        deserializer,
10908        "rotation statements",
10909    )
10910}
10911
10912fn deserialize_feed_items<'de, D>(deserializer: D) -> Result<Vec<FeedItem>, D::Error>
10913where
10914    D: serde::Deserializer<'de>,
10915{
10916    deserialize_bounded_vec::<D, FeedItem, FEED_PAGE_LIMIT>(deserializer, "feed entries")
10917}
10918
10919#[derive(Debug, Clone, Deserialize, Serialize)]
10920struct FeedFile {
10921    path: String,
10922    sha256: String,
10923    bytes: u64,
10924}
10925
10926#[cfg(test)]
10927#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10928enum V1DisclosureError {
10929    DuplicateFile,
10930    DuplicateRemoved,
10931    PushManifestMismatch,
10932    EditMissingChange,
10933    EditFalseFile,
10934    RemovedMismatch,
10935}
10936
10937/// Validate wire-profile-v1 `files`/`removed` disclosure semantics against
10938/// the exact previous and resulting pack manifests. `edit` accepts the
10939/// historical superset form while still requiring every actual changed path.
10940#[cfg(test)]
10941fn verify_v1_manifest_disclosure(
10942    kind: &str,
10943    previous: &[FeedFile],
10944    resulting: &[FeedFile],
10945    files: &[FeedFile],
10946    removed: &[String],
10947) -> Result<(), V1DisclosureError> {
10948    fn as_map(
10949        files: &[FeedFile],
10950    ) -> Result<std::collections::BTreeMap<&str, (&str, u64)>, V1DisclosureError> {
10951        let mut result = std::collections::BTreeMap::new();
10952        for file in files {
10953            if result
10954                .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
10955                .is_some()
10956            {
10957                return Err(V1DisclosureError::DuplicateFile);
10958            }
10959        }
10960        Ok(result)
10961    }
10962    let previous = as_map(previous)?;
10963    let resulting = as_map(resulting)?;
10964    let disclosed = as_map(files)?;
10965    let removed_set: std::collections::BTreeSet<&str> =
10966        removed.iter().map(String::as_str).collect();
10967    if removed_set.len() != removed.len() {
10968        return Err(V1DisclosureError::DuplicateRemoved);
10969    }
10970    let expected_removed: std::collections::BTreeSet<&str> = previous
10971        .keys()
10972        .copied()
10973        .filter(|path| !resulting.contains_key(path))
10974        .collect();
10975    if removed_set != expected_removed {
10976        return Err(V1DisclosureError::RemovedMismatch);
10977    }
10978    if kind == "push" {
10979        return if disclosed == resulting {
10980            Ok(())
10981        } else {
10982            Err(V1DisclosureError::PushManifestMismatch)
10983        };
10984    }
10985    if kind != "edit" {
10986        return Err(V1DisclosureError::EditFalseFile);
10987    }
10988    if disclosed
10989        .iter()
10990        .any(|(path, value)| resulting.get(path) != Some(value))
10991    {
10992        return Err(V1DisclosureError::EditFalseFile);
10993    }
10994    for (path, value) in &resulting {
10995        if previous.get(path) != Some(value) && !disclosed.contains_key(path) {
10996            return Err(V1DisclosureError::EditMissingChange);
10997        }
10998    }
10999    Ok(())
11000}
11001
11002#[derive(Debug, Clone, Deserialize, Serialize)]
11003struct FeedEntry {
11004    v: u8,
11005    seq: u64,
11006    ts: String,
11007    brain: String,
11008    public_key: String,
11009    kind: String,
11010    op: String,
11011    pack_sha256: String,
11012    #[serde(deserialize_with = "deserialize_feed_files")]
11013    files: Vec<FeedFile>,
11014    #[serde(deserialize_with = "deserialize_removed_paths")]
11015    removed: Vec<String>,
11016    prev_entry_hash: Option<String>,
11017    sig: String,
11018}
11019
11020#[derive(Serialize)]
11021struct UnsignedFeedEntry<'a> {
11022    v: u8,
11023    seq: u64,
11024    ts: &'a str,
11025    brain: &'a str,
11026    public_key: &'a str,
11027    kind: &'a str,
11028    op: &'a str,
11029    pack_sha256: &'a str,
11030    files: &'a [FeedFile],
11031    removed: &'a [String],
11032    prev_entry_hash: &'a Option<String>,
11033}
11034
11035#[derive(Debug, Clone, Deserialize, Serialize)]
11036struct FeedItem {
11037    hash: String,
11038    entry: FeedEntry,
11039}
11040
11041#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
11042struct FeedIdentity {
11043    fingerprint: String,
11044    #[serde(rename = "publicKeySpki")]
11045    public_key_spki: String,
11046    /// Rotation history (link.md §9.1): identities this brain previously
11047    /// signed as. Entries verify against current OR previous — rotation
11048    /// never invalidates history.
11049    #[serde(default, deserialize_with = "deserialize_previous_identities")]
11050    previous: Vec<PreviousIdentity>,
11051    /// Exact normative rotation statements, oldest first. A list of previous
11052    /// public keys without these old-key signatures is not a trust chain.
11053    #[serde(default, deserialize_with = "deserialize_rotations")]
11054    rotations: Vec<String>,
11055}
11056
11057#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
11058struct PreviousIdentity {
11059    fingerprint: String,
11060    #[serde(rename = "publicKeySpki")]
11061    public_key_spki: String,
11062}
11063
11064#[derive(Debug, Deserialize)]
11065struct FeedResponse {
11066    #[serde(rename = "headSeq")]
11067    head_seq: u64,
11068    #[serde(rename = "feedHash")]
11069    feed_hash: Option<String>,
11070    identity: Option<FeedIdentity>,
11071    #[serde(deserialize_with = "deserialize_feed_items")]
11072    entries: Vec<FeedItem>,
11073    #[serde(rename = "scopeLimited")]
11074    scope_limited: bool,
11075}
11076
11077#[derive(Debug, Deserialize, Serialize)]
11078#[serde(deny_unknown_fields)]
11079struct RotationStatement {
11080    v: u8,
11081    op: String,
11082    brain: String,
11083    public_key: String,
11084    new_brain: String,
11085    new_public_key: String,
11086    prior_head_seq: u64,
11087    prior_feed_hash: Option<String>,
11088    ts: String,
11089    sig: String,
11090}
11091
11092#[derive(Debug, Clone, Deserialize, Serialize)]
11093struct TrustState {
11094    v: u8,
11095    origin: String,
11096    /// The exact caller-visible ref whose resolution this checkpoint pins.
11097    /// Slugs/handles are mutable names at the hub; once observed, they may not
11098    /// silently resolve to a different canonical brain.
11099    #[serde(default)]
11100    requested: String,
11101    /// Canonical hub brain id returned for `requested`.
11102    brain: String,
11103    /// Federation home origin when this ref was learned from a registry.
11104    /// Once observed, the registry cannot silently relocate the same handle.
11105    #[serde(default, skip_serializing_if = "Option::is_none")]
11106    home: Option<String>,
11107    anchor: String,
11108    current: String,
11109    #[serde(rename = "headSeq")]
11110    head_seq: u64,
11111    #[serde(rename = "feedHash")]
11112    feed_hash: Option<String>,
11113    /// Exact accepted old-key-signed rotation statements, oldest first. v2
11114    /// checkpoints require this vector to be an immutable prefix of every
11115    /// subsequently served identity chain.
11116    #[serde(default)]
11117    rotations: Vec<String>,
11118    /// Hub pointer-signing identity pinned on first v2 observation. This is
11119    /// separate from the brain key, which signs commits.
11120    #[serde(default, skip_serializing_if = "Option::is_none")]
11121    hub_signer: Option<String>,
11122    /// Explicit transport profile. Older accepted non-empty v2 checkpoints
11123    /// are recognized by their pinned hub signer during one-way migration.
11124    #[serde(default, skip_serializing_if = "Option::is_none")]
11125    protocol_profile: Option<String>,
11126}
11127
11128fn accepted_as_v2(state: &TrustState) -> bool {
11129    state.protocol_profile.as_deref() == Some("link-v2") || state.hub_signer.is_some()
11130}
11131
11132fn has_accepted_v2_ref(cfg: &HubConfig, requested: &str) -> LinkResult<bool> {
11133    let directory = open_trust_dir(cfg)?;
11134    if load_trust_in(cfg, &directory, requested)?.is_some_and(|state| accepted_as_v2(&state)) {
11135        return Ok(true);
11136    }
11137    let Some(alias) = load_alias_in(cfg, &directory, requested)? else {
11138        return Ok(false);
11139    };
11140    Ok(load_trust_in(cfg, &directory, &alias.brain)?.is_some_and(|state| accepted_as_v2(&state)))
11141}
11142
11143#[derive(Debug, Clone, Deserialize, Serialize)]
11144struct AliasBinding {
11145    v: u8,
11146    origin: String,
11147    requested: String,
11148    brain: String,
11149    #[serde(default, skip_serializing_if = "Option::is_none")]
11150    home: Option<String>,
11151}
11152
11153struct VerifiedRemote {
11154    head: Head,
11155    identity: Option<FeedIdentity>,
11156    head_entry: Option<FeedItem>,
11157    /// Present when the caller requested and verified the complete chain.
11158    entries: Vec<FeedItem>,
11159    anchor: Option<String>,
11160}
11161
11162fn invalid_feed(message: impl Into<String>) -> LinkError {
11163    LinkError::InvalidFeed {
11164        message: message.into(),
11165    }
11166}
11167
11168fn is_sha256(value: &str) -> bool {
11169    value.len() == 64
11170        && value
11171            .bytes()
11172            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
11173}
11174
11175fn identity_fingerprint(public_key_spki: &str) -> LinkResult<String> {
11176    let der = URL_SAFE_NO_PAD
11177        .decode(public_key_spki)
11178        .map_err(|_| invalid_feed("identity public key is not base64url"))?;
11179    if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
11180        return Err(invalid_feed(
11181            "identity public key is not a valid Ed25519 SPKI",
11182        ));
11183    }
11184    Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(&der)))
11185}
11186
11187/// Verify the hub's current identity and every old-key-signed rotation that
11188/// leads from the original TOFU anchor to it. `previous` alone is metadata,
11189/// never authority.
11190fn verify_identity_chain(
11191    identity: &FeedIdentity,
11192    pinned: Option<&TrustState>,
11193) -> LinkResult<String> {
11194    if identity.previous.len() > MAX_IDENTITY_ROTATIONS
11195        || identity.rotations.len() > MAX_IDENTITY_ROTATIONS
11196    {
11197        return Err(invalid_feed(
11198            "identity rotation history exceeds the client cap",
11199        ));
11200    }
11201    if identity.fingerprint != identity_fingerprint(&identity.public_key_spki)? {
11202        return Err(invalid_feed(
11203            "current identity fingerprint does not match its public key",
11204        ));
11205    }
11206    for previous in &identity.previous {
11207        if previous.fingerprint != identity_fingerprint(&previous.public_key_spki)? {
11208            return Err(invalid_feed(
11209                "previous identity fingerprint does not match its public key",
11210            ));
11211        }
11212    }
11213    if identity.rotations.len() != identity.previous.len() {
11214        return Err(invalid_feed(
11215            "identity history is missing an old-key-signed rotation statement",
11216        ));
11217    }
11218
11219    // The HTTP identity lists prior identities newest first. Rotation
11220    // statements are chronological, so verification walks the reversed list
11221    // and ends at the current identity.
11222    let mut chain: Vec<(&str, &str)> = identity
11223        .previous
11224        .iter()
11225        .rev()
11226        .map(|p| (p.fingerprint.as_str(), p.public_key_spki.as_str()))
11227        .collect();
11228    chain.push((&identity.fingerprint, &identity.public_key_spki));
11229
11230    for (index, raw) in identity.rotations.iter().enumerate() {
11231        let statement: RotationStatement = serde_json::from_str(raw)
11232            .map_err(|_| invalid_feed("rotation statement did not parse exactly"))?;
11233        let (old_fingerprint, old_spki) = chain[index];
11234        let (new_fingerprint, new_spki) = chain[index + 1];
11235        if statement.v != 1
11236            || statement.op != "rotate"
11237            || statement.brain != format!("ed25519:{old_fingerprint}")
11238            || statement.public_key != old_spki
11239            || statement.new_brain != format!("ed25519:{new_fingerprint}")
11240            || statement.new_public_key != new_spki
11241            || (statement.prior_head_seq == 0 && statement.prior_feed_hash.is_some())
11242            || (statement.prior_head_seq > 0
11243                && statement
11244                    .prior_feed_hash
11245                    .as_deref()
11246                    .is_none_or(|hash| !is_sha256(hash)))
11247        {
11248            return Err(invalid_feed(
11249                "rotation statement does not connect adjacent identities",
11250            ));
11251        }
11252        let unsigned = serde_json::to_string(&UnsignedRotation {
11253            v: statement.v,
11254            op: &statement.op,
11255            brain: &statement.brain,
11256            public_key: &statement.public_key,
11257            new_brain: &statement.new_brain,
11258            new_public_key: &statement.new_public_key,
11259            prior_head_seq: statement.prior_head_seq,
11260            prior_feed_hash: statement.prior_feed_hash.as_deref(),
11261            ts: statement.ts.clone(),
11262        })
11263        .map_err(|_| invalid_feed("could not canonicalize rotation statement"))?;
11264        let exact = format!(
11265            "{},\"sig\":\"{}\"}}",
11266            &unsigned[..unsigned.len() - 1],
11267            statement.sig
11268        );
11269        if exact != *raw {
11270            return Err(invalid_feed(
11271                "rotation statement is not in normative serialization",
11272            ));
11273        }
11274        let der = URL_SAFE_NO_PAD
11275            .decode(old_spki)
11276            .map_err(|_| invalid_feed("rotation public key is not base64url"))?;
11277        let signature = URL_SAFE_NO_PAD
11278            .decode(&statement.sig)
11279            .map_err(|_| invalid_feed("rotation signature is not base64url"))?;
11280        UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
11281            .verify(unsigned.as_bytes(), &signature)
11282            .map_err(|_| invalid_feed("rotation signature verification failed"))?;
11283        if index > 0 {
11284            let prior: RotationStatement = serde_json::from_str(&identity.rotations[index - 1])
11285                .map_err(|_| invalid_feed("prior rotation statement did not parse"))?;
11286            if statement.prior_head_seq < prior.prior_head_seq {
11287                return Err(invalid_feed("rotation feed boundaries move backward"));
11288            }
11289        }
11290    }
11291
11292    let anchor = format!("ed25519:{}", chain[0].0);
11293    let current = format!("ed25519:{}", identity.fingerprint);
11294    if let Some(pin) = pinned {
11295        if pin.anchor != anchor {
11296            return Err(invalid_feed(
11297                "served identity chain does not descend from the pinned anchor",
11298            ));
11299        }
11300        if !chain
11301            .iter()
11302            .any(|(fingerprint, _)| pin.current == format!("ed25519:{fingerprint}"))
11303        {
11304            return Err(invalid_feed(
11305                "served identity chain forked away from the last pinned identity",
11306            ));
11307        }
11308        if pin.current == current && pin.anchor != current && identity.rotations.is_empty() {
11309            return Err(invalid_feed("served identity discarded its rotation chain"));
11310        }
11311        if pin.v >= 2
11312            && (identity.rotations.len() < pin.rotations.len()
11313                || identity.rotations[..pin.rotations.len()] != pin.rotations)
11314        {
11315            return Err(invalid_feed(
11316                "served identity rewrote the locally accepted rotation history",
11317            ));
11318        }
11319    }
11320    Ok(anchor)
11321}
11322
11323fn verify_rotation_feed_boundaries(
11324    identity: &FeedIdentity,
11325    pinned: Option<&TrustState>,
11326    observed: &[FeedItem],
11327    advertised_seq: u64,
11328) -> LinkResult<()> {
11329    let mut chain: Vec<String> = identity
11330        .previous
11331        .iter()
11332        .rev()
11333        .map(|previous| format!("ed25519:{}", previous.fingerprint))
11334        .collect();
11335    chain.push(format!("ed25519:{}", identity.fingerprint));
11336    let pinned_index = pinned.and_then(|pin| chain.iter().position(|key| key == &pin.current));
11337
11338    for (index, raw) in identity.rotations.iter().enumerate() {
11339        let rotation: RotationStatement = serde_json::from_str(raw)
11340            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
11341        if rotation.prior_head_seq > advertised_seq {
11342            return Err(invalid_feed(
11343                "rotation claims a feed boundary beyond the advertised head",
11344            ));
11345        }
11346        if let (Some(pin), Some(pin_index)) = (pinned, pinned_index) {
11347            if index >= pin_index && rotation.prior_head_seq < pin.head_seq {
11348                return Err(invalid_feed(
11349                    "newly disclosed rotation predates the local feed checkpoint",
11350                ));
11351            }
11352        }
11353        let actual = if rotation.prior_head_seq == 0 {
11354            None
11355        } else if pinned.is_some_and(|pin| pin.head_seq == rotation.prior_head_seq) {
11356            pinned.and_then(|pin| pin.feed_hash.as_deref())
11357        } else {
11358            observed
11359                .iter()
11360                .find(|item| item.entry.seq == rotation.prior_head_seq)
11361                .map(|item| item.hash.as_str())
11362        };
11363        if let Some(actual) = actual {
11364            if rotation.prior_feed_hash.as_deref() != Some(actual) {
11365                return Err(invalid_feed(
11366                    "rotation statement does not commit the verified feed boundary",
11367                ));
11368            }
11369        } else if rotation.prior_head_seq == 0 {
11370            // The empty-feed boundary is represented by (0, null); there is
11371            // no entry hash to find in `observed`.
11372        } else if pinned.is_some_and(|pin| {
11373            pinned_index.is_some_and(|pin_index| index >= pin_index)
11374                || rotation.prior_head_seq >= pin.head_seq
11375        }) {
11376            return Err(invalid_feed(
11377                "rotation feed boundary was not present in the verified chain",
11378            ));
11379        }
11380    }
11381    Ok(())
11382}
11383
11384/// Once a client has checkpointed identity K, identities retired before K may
11385/// verify old history but can never regain authority over a later sequence.
11386/// This is also the safe migration rule for legacy v1 checkpoints that did not
11387/// persist the exact accepted rotation statements.
11388fn reject_retired_signer_after_checkpoint(
11389    identity: &FeedIdentity,
11390    pinned: Option<&TrustState>,
11391    item: &FeedItem,
11392) -> LinkResult<()> {
11393    let Some(pin) = pinned else {
11394        return Ok(());
11395    };
11396    if item.entry.seq <= pin.head_seq {
11397        return Ok(());
11398    }
11399    let mut chain: Vec<String> = identity
11400        .previous
11401        .iter()
11402        .rev()
11403        .map(|previous| format!("ed25519:{}", previous.fingerprint))
11404        .collect();
11405    chain.push(format!("ed25519:{}", identity.fingerprint));
11406    let pinned_index = chain
11407        .iter()
11408        .position(|key| key == &pin.current)
11409        .ok_or_else(|| invalid_feed("pinned identity is absent from the served chain"))?;
11410    let signer_index = chain
11411        .iter()
11412        .position(|key| key == &item.entry.brain)
11413        .ok_or_else(|| invalid_feed("feed signer is absent from the served identity chain"))?;
11414    if signer_index < pinned_index {
11415        return Err(invalid_feed(
11416            "a retired identity attempted to sign after the local checkpoint",
11417        ));
11418    }
11419    Ok(())
11420}
11421
11422fn trust_file_name(cfg: &HubConfig, brain: &str) -> LinkResult<String> {
11423    let origin = normalized_origin(&cfg.hub)?;
11424    let key = format!(
11425        "{:x}",
11426        Sha256::digest(format!("{origin}\0{brain}").as_bytes())
11427    );
11428    Ok(format!("{key}.json"))
11429}
11430
11431fn alias_file_name(cfg: &HubConfig, alias: &str) -> LinkResult<String> {
11432    let origin = normalized_origin(&cfg.hub)?;
11433    let key = format!(
11434        "{:x}",
11435        Sha256::digest(format!("{origin}\0alias\0{alias}").as_bytes())
11436    );
11437    Ok(format!("alias-{key}.json"))
11438}
11439
11440#[cfg(any(unix, windows))]
11441struct TrustLock {
11442    _file: std::fs::File,
11443}
11444
11445#[cfg(unix)]
11446fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
11447    use std::os::fd::{AsRawFd as _, FromRawFd as _};
11448
11449    let lock_string = format!(".{state_name}.lock");
11450    let lock_name = c_name(lock_string.as_bytes(), &lock_string)?;
11451    let fd = unsafe {
11452        libc::openat(
11453            directory.as_raw_fd(),
11454            lock_name.as_ptr(),
11455            libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11456            0o600,
11457        )
11458    };
11459    if fd < 0 {
11460        return Err(std::io::Error::last_os_error().into());
11461    }
11462    let file = unsafe { std::fs::File::from_raw_fd(fd) };
11463    if !file.metadata()?.is_file() {
11464        return Err(LinkError::UnsafePath { path: lock_string });
11465    }
11466    if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
11467        return Err(std::io::Error::last_os_error().into());
11468    }
11469    Ok(TrustLock { _file: file })
11470}
11471
11472#[cfg(windows)]
11473fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
11474    let lock_name = format!(".{state_name}.lock");
11475    let file = crate::fsx::lock_exclusive_beneath(directory, Path::new(&lock_name))?;
11476    Ok(TrustLock { _file: file })
11477}
11478
11479#[cfg(any(unix, windows))]
11480fn lock_trust_many(
11481    cfg: &HubConfig,
11482    directory: &std::fs::File,
11483    refs: &[&str],
11484) -> LinkResult<Vec<TrustLock>> {
11485    let mut names = refs
11486        .iter()
11487        .map(|reference| trust_file_name(cfg, reference))
11488        .collect::<LinkResult<Vec<_>>>()?;
11489    names.sort();
11490    names.dedup();
11491    names
11492        .iter()
11493        .map(|name| lock_trust_name(directory, name))
11494        .collect()
11495}
11496
11497#[cfg(not(any(unix, windows)))]
11498fn lock_trust_many(
11499    _cfg: &HubConfig,
11500    _directory: &TrustDirectory,
11501    _refs: &[&str],
11502) -> LinkResult<Vec<()>> {
11503    Err(LinkError::UnsupportedPlatform {
11504        operation: "verified link.md state",
11505    })
11506}
11507
11508#[cfg(any(unix, windows))]
11509type TrustDirectory = std::fs::File;
11510
11511#[cfg(not(any(unix, windows)))]
11512struct TrustDirectory;
11513
11514#[cfg(unix)]
11515fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
11516    use std::os::fd::AsRawFd as _;
11517
11518    let directory = open_or_create_dir_nofollow(&cfg.state_dir.join("trust"))?;
11519    if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
11520        return Err(std::io::Error::last_os_error().into());
11521    }
11522    directory.sync_all()?;
11523    Ok(directory)
11524}
11525
11526#[cfg(windows)]
11527fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
11528    let marker = cfg.state_dir.join("trust").join(".directory");
11529    crate::fsx::write_atomic(&marker, b"link.md trust directory\n")?;
11530    Ok(crate::fsx::open_directory_nofollow(
11531        marker.parent().expect("trust marker has a parent"),
11532    )?)
11533}
11534
11535#[cfg(not(any(unix, windows)))]
11536fn open_trust_dir(_cfg: &HubConfig) -> LinkResult<TrustDirectory> {
11537    Err(LinkError::UnsupportedPlatform {
11538        operation: "verified link.md state",
11539    })
11540}
11541
11542#[cfg(unix)]
11543fn load_trust_in(
11544    cfg: &HubConfig,
11545    directory: &TrustDirectory,
11546    requested: &str,
11547) -> LinkResult<Option<TrustState>> {
11548    use std::os::fd::{AsRawFd as _, FromRawFd as _};
11549
11550    let name_string = trust_file_name(cfg, requested)?;
11551    let name = c_name(name_string.as_bytes(), &name_string)?;
11552    let fd = unsafe {
11553        libc::openat(
11554            directory.as_raw_fd(),
11555            name.as_ptr(),
11556            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11557        )
11558    };
11559    if fd < 0 {
11560        let error = std::io::Error::last_os_error();
11561        if error.kind() == std::io::ErrorKind::NotFound {
11562            return Ok(None);
11563        }
11564        return Err(LinkError::UnsafePath { path: name_string });
11565    }
11566    let file = unsafe { std::fs::File::from_raw_fd(fd) };
11567    if !file.metadata()?.is_file() {
11568        return Err(LinkError::UnsafePath { path: name_string });
11569    }
11570    let mut bytes = Vec::new();
11571    file.take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
11572    if bytes.len() > 1024 * 1024 {
11573        return Err(invalid_feed("local identity/feed checkpoint is oversized"));
11574    }
11575    let mut state: TrustState = serde_json::from_slice(&bytes)
11576        .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
11577    if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
11578        return Err(invalid_feed(
11579            "local identity/feed checkpoint does not match this hub and brain",
11580        ));
11581    }
11582    if state.v == 1 {
11583        // Legacy files were keyed by canonical brain id. They can migrate only
11584        // when the caller used that exact id; old slug lookups had no durable
11585        // alias binding and therefore cannot be guessed safely.
11586        if state.brain != requested {
11587            return Err(invalid_feed(
11588                "legacy checkpoint is not bound to the requested brain id",
11589            ));
11590        }
11591        state.requested = requested.to_string();
11592    } else if state.requested != requested {
11593        return Err(invalid_feed(
11594            "local identity/feed checkpoint is bound to a different requested ref",
11595        ));
11596    }
11597    Ok(Some(state))
11598}
11599
11600#[cfg(windows)]
11601fn load_trust_in(
11602    cfg: &HubConfig,
11603    directory: &TrustDirectory,
11604    requested: &str,
11605) -> LinkResult<Option<TrustState>> {
11606    let name = trust_file_name(cfg, requested)?;
11607    let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
11608    let bytes = match reader.read(Path::new(&name), 1024 * 1024) {
11609        Ok(bytes) => bytes,
11610        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
11611        Err(_) => return Err(LinkError::UnsafePath { path: name }),
11612    };
11613    let mut state: TrustState = serde_json::from_slice(&bytes)
11614        .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
11615    if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
11616        return Err(invalid_feed(
11617            "local identity/feed checkpoint does not match this hub and brain",
11618        ));
11619    }
11620    if state.v == 1 {
11621        if state.brain != requested {
11622            return Err(invalid_feed(
11623                "legacy checkpoint is not bound to the requested brain id",
11624            ));
11625        }
11626        state.requested = requested.to_string();
11627    } else if state.requested != requested {
11628        return Err(invalid_feed(
11629            "local identity/feed checkpoint is bound to a different requested ref",
11630        ));
11631    }
11632    Ok(Some(state))
11633}
11634
11635#[cfg(not(any(unix, windows)))]
11636fn load_trust_in(
11637    _cfg: &HubConfig,
11638    _directory: &TrustDirectory,
11639    _brain: &str,
11640) -> LinkResult<Option<TrustState>> {
11641    Err(LinkError::UnsupportedPlatform {
11642        operation: "verified link.md state",
11643    })
11644}
11645
11646#[cfg(all(test, any(unix, windows)))]
11647fn load_trust(cfg: &HubConfig, requested: &str) -> LinkResult<Option<TrustState>> {
11648    let directory = open_trust_dir(cfg)?;
11649    load_trust_in(cfg, &directory, requested)
11650}
11651
11652#[cfg(unix)]
11653fn save_trust_in(
11654    cfg: &HubConfig,
11655    directory: &TrustDirectory,
11656    state: &TrustState,
11657) -> LinkResult<()> {
11658    use std::os::fd::{AsRawFd as _, FromRawFd as _};
11659
11660    let name_string = trust_file_name(cfg, &state.requested)?;
11661    let name = c_name(name_string.as_bytes(), &name_string)?;
11662    let mut bytes = serde_json::to_vec(state)
11663        .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
11664    bytes.push(b'\n');
11665
11666    let nonce = std::time::SystemTime::now()
11667        .duration_since(std::time::UNIX_EPOCH)
11668        .unwrap_or_default()
11669        .as_nanos();
11670    let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
11671    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
11672    let fd = unsafe {
11673        libc::openat(
11674            directory.as_raw_fd(),
11675            temp.as_ptr(),
11676            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11677            0o600,
11678        )
11679    };
11680    if fd < 0 {
11681        return Err(std::io::Error::last_os_error().into());
11682    }
11683    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
11684    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
11685        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
11686        return Err(error.into());
11687    }
11688    drop(file);
11689    if unsafe {
11690        libc::renameat(
11691            directory.as_raw_fd(),
11692            temp.as_ptr(),
11693            directory.as_raw_fd(),
11694            name.as_ptr(),
11695        )
11696    } != 0
11697    {
11698        let error = std::io::Error::last_os_error();
11699        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
11700        return Err(error.into());
11701    }
11702    directory.sync_all()?;
11703    Ok(())
11704}
11705
11706#[cfg(windows)]
11707fn save_trust_in(
11708    cfg: &HubConfig,
11709    directory: &TrustDirectory,
11710    state: &TrustState,
11711) -> LinkResult<()> {
11712    let name = trust_file_name(cfg, &state.requested)?;
11713    let mut bytes = serde_json::to_vec(state)
11714        .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
11715    bytes.push(b'\n');
11716    crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
11717    Ok(())
11718}
11719
11720#[cfg(not(any(unix, windows)))]
11721fn save_trust_in(
11722    _cfg: &HubConfig,
11723    _directory: &TrustDirectory,
11724    _state: &TrustState,
11725) -> LinkResult<()> {
11726    Err(LinkError::UnsupportedPlatform {
11727        operation: "verified link.md state",
11728    })
11729}
11730
11731#[cfg(unix)]
11732fn load_alias_in(
11733    cfg: &HubConfig,
11734    directory: &TrustDirectory,
11735    requested: &str,
11736) -> LinkResult<Option<AliasBinding>> {
11737    use std::os::fd::{AsRawFd as _, FromRawFd as _};
11738
11739    let name_string = alias_file_name(cfg, requested)?;
11740    let name = c_name(name_string.as_bytes(), &name_string)?;
11741    let fd = unsafe {
11742        libc::openat(
11743            directory.as_raw_fd(),
11744            name.as_ptr(),
11745            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11746        )
11747    };
11748    if fd < 0 {
11749        let error = std::io::Error::last_os_error();
11750        if error.kind() == std::io::ErrorKind::NotFound {
11751            return Ok(None);
11752        }
11753        return Err(LinkError::UnsafePath { path: name_string });
11754    }
11755    let file = unsafe { std::fs::File::from_raw_fd(fd) };
11756    if !file.metadata()?.is_file() {
11757        return Err(LinkError::UnsafePath { path: name_string });
11758    }
11759    let mut bytes = Vec::new();
11760    file.take(64 * 1024 + 1).read_to_end(&mut bytes)?;
11761    if bytes.len() > 64 * 1024 {
11762        return Err(invalid_feed("local alias binding is oversized"));
11763    }
11764    let alias: AliasBinding = serde_json::from_slice(&bytes)
11765        .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
11766    if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
11767    {
11768        return Err(invalid_feed(
11769            "local alias binding does not match this hub and requested ref",
11770        ));
11771    }
11772    Ok(Some(alias))
11773}
11774
11775#[cfg(windows)]
11776fn load_alias_in(
11777    cfg: &HubConfig,
11778    directory: &TrustDirectory,
11779    requested: &str,
11780) -> LinkResult<Option<AliasBinding>> {
11781    let name = alias_file_name(cfg, requested)?;
11782    let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
11783    let bytes = match reader.read(Path::new(&name), 64 * 1024) {
11784        Ok(bytes) => bytes,
11785        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
11786        Err(_) => return Err(LinkError::UnsafePath { path: name }),
11787    };
11788    let alias: AliasBinding = serde_json::from_slice(&bytes)
11789        .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
11790    if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
11791    {
11792        return Err(invalid_feed(
11793            "local alias binding does not match this hub and requested ref",
11794        ));
11795    }
11796    Ok(Some(alias))
11797}
11798
11799#[cfg(not(any(unix, windows)))]
11800fn load_alias_in(
11801    _cfg: &HubConfig,
11802    _directory: &TrustDirectory,
11803    _requested: &str,
11804) -> LinkResult<Option<AliasBinding>> {
11805    Err(LinkError::UnsupportedPlatform {
11806        operation: "verified link.md state",
11807    })
11808}
11809
11810#[cfg(unix)]
11811fn save_alias_in(
11812    cfg: &HubConfig,
11813    directory: &TrustDirectory,
11814    alias: &AliasBinding,
11815) -> LinkResult<()> {
11816    use std::os::fd::{AsRawFd as _, FromRawFd as _};
11817
11818    let name_string = alias_file_name(cfg, &alias.requested)?;
11819    let name = c_name(name_string.as_bytes(), &name_string)?;
11820    let mut bytes = serde_json::to_vec(alias)
11821        .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
11822    bytes.push(b'\n');
11823    let nonce = std::time::SystemTime::now()
11824        .duration_since(std::time::UNIX_EPOCH)
11825        .unwrap_or_default()
11826        .as_nanos();
11827    let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
11828    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
11829    let fd = unsafe {
11830        libc::openat(
11831            directory.as_raw_fd(),
11832            temp.as_ptr(),
11833            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11834            0o600,
11835        )
11836    };
11837    if fd < 0 {
11838        return Err(std::io::Error::last_os_error().into());
11839    }
11840    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
11841    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
11842        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
11843        return Err(error.into());
11844    }
11845    drop(file);
11846    if unsafe {
11847        libc::renameat(
11848            directory.as_raw_fd(),
11849            temp.as_ptr(),
11850            directory.as_raw_fd(),
11851            name.as_ptr(),
11852        )
11853    } != 0
11854    {
11855        let error = std::io::Error::last_os_error();
11856        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
11857        return Err(error.into());
11858    }
11859    directory.sync_all()?;
11860    Ok(())
11861}
11862
11863#[cfg(windows)]
11864fn save_alias_in(
11865    cfg: &HubConfig,
11866    directory: &TrustDirectory,
11867    alias: &AliasBinding,
11868) -> LinkResult<()> {
11869    let name = alias_file_name(cfg, &alias.requested)?;
11870    let mut bytes = serde_json::to_vec(alias)
11871        .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
11872    bytes.push(b'\n');
11873    crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
11874    Ok(())
11875}
11876
11877#[cfg(not(any(unix, windows)))]
11878fn save_alias_in(
11879    _cfg: &HubConfig,
11880    _directory: &TrustDirectory,
11881    _alias: &AliasBinding,
11882) -> LinkResult<()> {
11883    Err(LinkError::UnsupportedPlatform {
11884        operation: "verified link.md state",
11885    })
11886}
11887
11888/// Load the canonical checkpoint shared by every spelling of one brain and
11889/// the separate alias binding. This also performs the one-way migration from
11890/// pre-v3 checkpoints that stored a full trust state under the alias itself.
11891/// Callers hold both alias and canonical locks before entering.
11892fn load_canonical_pin(
11893    cfg: &HubConfig,
11894    directory: &TrustDirectory,
11895    requested: &str,
11896    resolved_brain: &str,
11897) -> LinkResult<(Option<TrustState>, Option<AliasBinding>)> {
11898    let mut canonical = load_trust_in(cfg, directory, resolved_brain)?;
11899    if requested == resolved_brain {
11900        return Ok((canonical, None));
11901    }
11902
11903    let mut alias = load_alias_in(cfg, directory, requested)?;
11904    if let Some(binding) = &alias {
11905        if binding.brain != resolved_brain {
11906            return Err(LinkError::AliasRebindRequired {
11907                alias: requested.to_string(),
11908                from: binding.brain.clone(),
11909                to: resolved_brain.to_string(),
11910            });
11911        }
11912        return Ok((canonical, alias));
11913    }
11914
11915    // A v2 build stored the complete checkpoint under the requested slug.
11916    // Promote it to the canonical ULID key before creating the lightweight
11917    // alias binding. Never silently merge two independently advanced states.
11918    if let Some(legacy) = load_trust_in(cfg, directory, requested)? {
11919        if legacy.brain != resolved_brain {
11920            return Err(invalid_feed(
11921                "legacy alias checkpoint names a different canonical brain",
11922            ));
11923        }
11924        if let Some(existing) = &canonical {
11925            if existing.brain != legacy.brain
11926                || existing.anchor != legacy.anchor
11927                || existing.current != legacy.current
11928                || existing.head_seq != legacy.head_seq
11929                || existing.feed_hash != legacy.feed_hash
11930                || existing.rotations != legacy.rotations
11931            {
11932                return Err(invalid_feed(
11933                    "legacy alias checkpoint conflicts with the canonical checkpoint",
11934                ));
11935            }
11936        } else {
11937            let mut promoted = legacy.clone();
11938            promoted.requested = resolved_brain.to_string();
11939            promoted.home = None;
11940            save_trust_in(cfg, directory, &promoted)?;
11941            canonical = Some(promoted);
11942        }
11943        alias = Some(AliasBinding {
11944            v: 1,
11945            origin: normalized_origin(&cfg.hub)?,
11946            requested: requested.to_string(),
11947            brain: resolved_brain.to_string(),
11948            home: legacy.home,
11949        });
11950        save_alias_in(cfg, directory, alias.as_ref().expect("alias just created"))?;
11951    }
11952    Ok((canonical, alias))
11953}
11954
11955/// Explicitly replace one mutable alias binding after the operator reviews the
11956/// exact deleted/re-created brain ids. Canonical checkpoints are never removed
11957/// or rewritten, so old history remains pinned and the new brain establishes
11958/// its own independently verified trust chain.
11959pub fn rebind_v2_alias(cfg: &HubConfig, alias: &str, from: &str, to: &str) -> LinkResult<Value> {
11960    require_hardened_filesystem("verified alias rebind")?;
11961    require_safe_ref(alias)?;
11962    require_safe_ref(from)?;
11963    require_safe_ref(to)?;
11964    if crate::ulid::is_ulid(alias)
11965        || !crate::ulid::is_ulid(from)
11966        || !crate::ulid::is_ulid(to)
11967        || from == to
11968    {
11969        return Err(LinkError::InvalidPack {
11970            message:
11971                "alias rebind requires one non-ULID alias and two different exact canonical ULIDs"
11972                    .to_string(),
11973        });
11974    }
11975
11976    let verified = v2_verified_head(cfg, to)?.ok_or_else(|| LinkError::InvalidPack {
11977        message: "the proposed replacement is not a readable link.md v2 brain".to_string(),
11978    })?;
11979    accept_v2_head(cfg, &verified)?;
11980
11981    let alias_response = ensure_ok(
11982        request(
11983            cfg,
11984            "GET",
11985            &format!("/api/hub/brains/{alias}/v2/head"),
11986            None,
11987            Auth::Required,
11988        )?,
11989        "resolve alias for explicit rebind",
11990    )?;
11991    let resolved: V2HeadResponse = serde_json::from_value(alias_response)
11992        .map_err(|_| invalid_feed("alias rebind response has an invalid shape"))?;
11993    if resolved.v != 2 || resolved.brain_id != to {
11994        return Err(LinkError::RemoteAdvancedDuringSync);
11995    }
11996
11997    let directory = open_trust_dir(cfg)?;
11998    let _locks = lock_trust_many(cfg, &directory, &[alias, from, to])?;
11999    let binding = load_alias_in(cfg, &directory, alias)?.ok_or_else(|| LinkError::InvalidPack {
12000        message: "the requested alias has no existing local binding to replace".to_string(),
12001    })?;
12002    if binding.brain != from {
12003        return Err(LinkError::AliasRebindRequired {
12004            alias: alias.to_string(),
12005            from: binding.brain,
12006            to: to.to_string(),
12007        });
12008    }
12009    save_alias_in(
12010        cfg,
12011        &directory,
12012        &AliasBinding {
12013            v: 1,
12014            origin: normalized_origin(&cfg.hub)?,
12015            requested: alias.to_string(),
12016            brain: to.to_string(),
12017            home: binding.home,
12018        },
12019    )?;
12020    Ok(json!({
12021        "v": 2,
12022        "alias": alias,
12023        "from": from,
12024        "to": to,
12025        "outcome": "alias_rebound",
12026    }))
12027}
12028
12029fn save_canonical_pin_and_alias(
12030    cfg: &HubConfig,
12031    directory: &TrustDirectory,
12032    requested: &str,
12033    resolved_brain: &str,
12034    mut state: TrustState,
12035    existing_alias: Option<&AliasBinding>,
12036) -> LinkResult<()> {
12037    state.requested = resolved_brain.to_string();
12038    state.brain = resolved_brain.to_string();
12039    state.home = None;
12040    save_trust_in(cfg, directory, &state)?;
12041    if requested != resolved_brain {
12042        save_alias_in(
12043            cfg,
12044            directory,
12045            &AliasBinding {
12046                v: 1,
12047                origin: normalized_origin(&cfg.hub)?,
12048                requested: requested.to_string(),
12049                brain: resolved_brain.to_string(),
12050                home: existing_alias.and_then(|alias| alias.home.clone()),
12051            },
12052        )?;
12053    }
12054    Ok(())
12055}
12056
12057fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
12058    const ED25519_SPKI_PREFIX: &[u8] = &[
12059        0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
12060    ];
12061    let entry = &item.entry;
12062    let public_der = URL_SAFE_NO_PAD
12063        .decode(&entry.public_key)
12064        .map_err(|_| invalid_feed("public key is not base64url"))?;
12065    if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
12066        || !public_der.starts_with(ED25519_SPKI_PREFIX)
12067    {
12068        return Err(invalid_feed("entry public key is not a valid Ed25519 SPKI"));
12069    }
12070    let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
12071    if entry.brain != format!("ed25519:{fingerprint}") {
12072        return Err(invalid_feed(
12073            "brain fingerprint does not match its public key",
12074        ));
12075    }
12076    // Verify the complete rotation authority before using any previous key.
12077    let _ = verify_identity_chain(identity, None)?;
12078    let mut chain: Vec<(&str, &str)> = identity
12079        .previous
12080        .iter()
12081        .rev()
12082        .map(|previous| {
12083            (
12084                previous.fingerprint.as_str(),
12085                previous.public_key_spki.as_str(),
12086            )
12087        })
12088        .collect();
12089    chain.push((&identity.fingerprint, &identity.public_key_spki));
12090    let signer_index = chain.iter().position(|(known_fingerprint, spki)| {
12091        *known_fingerprint == fingerprint && *spki == entry.public_key
12092    });
12093    let Some(signer_index) = signer_index else {
12094        return Err(invalid_feed(
12095            "entry signer is not this brain's identity (current or rotated-from)",
12096        ));
12097    };
12098    let lower_boundary = if signer_index == 0 {
12099        None
12100    } else {
12101        let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
12102            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
12103        Some(prior.prior_head_seq)
12104    };
12105    let upper_boundary = if signer_index == identity.rotations.len() {
12106        None
12107    } else {
12108        let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
12109            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
12110        Some(next.prior_head_seq)
12111    };
12112    if lower_boundary.is_some_and(|boundary| entry.seq <= boundary)
12113        || upper_boundary.is_some_and(|boundary| entry.seq > boundary)
12114    {
12115        return Err(invalid_feed(
12116            "entry signer is outside its authenticated rotation epoch",
12117        ));
12118    }
12119    let unsigned = UnsignedFeedEntry {
12120        v: entry.v,
12121        seq: entry.seq,
12122        ts: &entry.ts,
12123        brain: &entry.brain,
12124        public_key: &entry.public_key,
12125        kind: &entry.kind,
12126        op: &entry.op,
12127        pack_sha256: &entry.pack_sha256,
12128        files: &entry.files,
12129        removed: &entry.removed,
12130        prev_entry_hash: &entry.prev_entry_hash,
12131    };
12132    let message =
12133        serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
12134    let signature = URL_SAFE_NO_PAD
12135        .decode(&entry.sig)
12136        .map_err(|_| invalid_feed("signature is not base64url"))?;
12137    UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
12138        .verify(&message, &signature)
12139        .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
12140
12141    let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
12142    exact.push(b'\n');
12143    let actual_hash = format!("{:x}", Sha256::digest(&exact));
12144    if actual_hash != item.hash {
12145        return Err(invalid_feed("entry SHA-256 does not match"));
12146    }
12147    Ok(())
12148}
12149
12150// ─────────────────────────────────────────────────────────────────────────────
12151// key rotation — link.md §9.1: the new key, signed by the old one
12152// ─────────────────────────────────────────────────────────────────────────────
12153
12154/// The unsigned rotation statement in its normative field order.
12155#[derive(Serialize)]
12156struct UnsignedRotation<'a> {
12157    v: u8,
12158    op: &'a str,
12159    brain: &'a str,
12160    public_key: &'a str,
12161    new_brain: &'a str,
12162    new_public_key: &'a str,
12163    prior_head_seq: u64,
12164    prior_feed_hash: Option<&'a str>,
12165    ts: String,
12166}
12167
12168/// Durable intent for an in-flight key rotation. The hub's recovery contract
12169/// identifies an ambiguous retry by the exact statement bytes, so the
12170/// statement cannot be reconstructed from the key and feed boundary later:
12171/// its timestamp and signature would differ.
12172#[derive(Debug, Deserialize, Serialize)]
12173#[serde(deny_unknown_fields)]
12174struct RotationJournal {
12175    v: u8,
12176    origin: String,
12177    brain: String,
12178    old_brain: String,
12179    new_brain: String,
12180    prior_head_seq: u64,
12181    prior_feed_hash: Option<String>,
12182    statement: String,
12183}
12184
12185fn rotation_journal_path(key_path: &Path) -> PathBuf {
12186    let mut path = key_path.as_os_str().to_os_string();
12187    path.push(".rotation.json");
12188    PathBuf::from(path)
12189}
12190
12191fn read_rotation_journal(path: &Path) -> LinkResult<RotationJournal> {
12192    #[cfg(unix)]
12193    let file = {
12194        use std::os::fd::{AsRawFd as _, FromRawFd as _};
12195        use std::os::unix::ffi::OsStrExt as _;
12196        let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
12197            .map_err(|error| {
12198                bad_agent_key(&format!("cannot open the rotation journal parent: {error}"))
12199            })?;
12200        let leaf_name = path
12201            .file_name()
12202            .ok_or_else(|| bad_agent_key("the rotation journal path has no file name"))?;
12203        let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
12204        let fd = unsafe {
12205            libc::openat(
12206                parent.as_raw_fd(),
12207                leaf.as_ptr(),
12208                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
12209            )
12210        };
12211        if fd < 0 {
12212            return Err(bad_agent_key(
12213                "the rotation journal must be an existing regular file without symlink ancestors",
12214            ));
12215        }
12216        unsafe { std::fs::File::from_raw_fd(fd) }
12217    };
12218    #[cfg(not(unix))]
12219    let file = std::fs::File::open(path)
12220        .map_err(|error| bad_agent_key(&format!("cannot read the rotation journal: {error}")))?;
12221    let metadata = file
12222        .metadata()
12223        .map_err(|error| bad_agent_key(&format!("cannot inspect the rotation journal: {error}")))?;
12224    if !metadata.is_file() || metadata.len() > MAX_REGISTRY_CARD_BYTES {
12225        return Err(bad_agent_key(
12226            "the rotation journal must be a bounded regular file",
12227        ));
12228    }
12229    #[cfg(unix)]
12230    {
12231        use std::os::unix::fs::PermissionsExt as _;
12232        if metadata.permissions().mode() & 0o077 != 0 {
12233            return Err(bad_agent_key(
12234                "the rotation journal is accessible to group/other; set mode 0600",
12235            ));
12236        }
12237    }
12238    serde_json::from_reader(file)
12239        .map_err(|_| bad_agent_key("the rotation journal is not valid exact JSON"))
12240}
12241
12242fn remove_rotation_journal(path: &Path) {
12243    #[cfg(unix)]
12244    {
12245        use std::os::fd::AsRawFd as _;
12246        use std::os::unix::ffi::OsStrExt as _;
12247        let Ok(parent) =
12248            open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
12249        else {
12250            return;
12251        };
12252        let Some(leaf_name) = path.file_name() else {
12253            return;
12254        };
12255        let Ok(leaf) = c_name(leaf_name.as_bytes(), &path.display().to_string()) else {
12256            return;
12257        };
12258        if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), 0) } == 0 {
12259            let _ = parent.sync_all();
12260        }
12261    }
12262    #[cfg(not(unix))]
12263    {
12264        let _ = std::fs::remove_file(path);
12265    }
12266}
12267
12268fn validate_rotation_journal(
12269    journal: &RotationJournal,
12270    cfg: &HubConfig,
12271    canonical_brain: &str,
12272    old_key: &AgentSigningKey,
12273    new_key: &AgentSigningKey,
12274    head: &Head,
12275) -> LinkResult<()> {
12276    if journal.v != 1
12277        || journal.origin != normalized_origin(&cfg.hub)?
12278        || journal.brain != canonical_brain
12279        || journal.old_brain != old_key.multikey
12280        || journal.new_brain != new_key.multikey
12281        || journal.prior_head_seq != head.seq
12282        || journal.prior_feed_hash != head.feed_hash
12283    {
12284        return Err(invalid_feed(
12285            "rotation journal does not match the verified key and feed boundary",
12286        ));
12287    }
12288    let statement: RotationStatement = serde_json::from_str(&journal.statement)
12289        .map_err(|_| invalid_feed("rotation journal statement did not parse exactly"))?;
12290    if statement.prior_head_seq != journal.prior_head_seq
12291        || statement.prior_feed_hash != journal.prior_feed_hash
12292        || statement.brain != old_key.multikey
12293        || statement.public_key != old_key.public_key_spki
12294        || statement.new_brain != new_key.multikey
12295        || statement.new_public_key != new_key.public_key_spki
12296    {
12297        return Err(invalid_feed(
12298            "rotation journal statement does not match its durable intent",
12299        ));
12300    }
12301    let identity = FeedIdentity {
12302        fingerprint: new_key.multikey.trim_start_matches("ed25519:").to_string(),
12303        public_key_spki: new_key.public_key_spki.clone(),
12304        previous: vec![PreviousIdentity {
12305            fingerprint: old_key.multikey.trim_start_matches("ed25519:").to_string(),
12306            public_key_spki: old_key.public_key_spki.clone(),
12307        }],
12308        rotations: vec![journal.statement.clone()],
12309    };
12310    verify_identity_chain(&identity, None)?;
12311    Ok(())
12312}
12313
12314/// What `dbmd key rotate` returns.
12315#[derive(Debug, Serialize)]
12316pub struct RotationReport {
12317    /// The brain id rotated.
12318    pub brain: String,
12319    /// The NEW identity the hub now serves.
12320    pub multikey: String,
12321    /// Where the new PKCS#8 secret landed (0600).
12322    #[serde(rename = "keyFile")]
12323    pub key_file: String,
12324    /// Prior identities (newest first) the feed still verifies against.
12325    pub previous: Vec<String>,
12326}
12327
12328/// Rotate a self-custodied brain's key: mint a fresh keypair, build the
12329/// §9.1 statement — the new key plus exact prior feed boundary, signed by the
12330/// OLD key in normative serialization — and send it to the hub. The new secret
12331/// is durably created at 0600 before the POST; an existing output is reused for
12332/// idempotent retry/reconciliation. The old key is left untouched.
12333pub fn rotate_brain_key(
12334    cfg: &HubConfig,
12335    brain: &str,
12336    old_key: &AgentSigningKey,
12337    out: &Path,
12338) -> LinkResult<RotationReport> {
12339    require_hardened_filesystem("key rotation")?;
12340    require_safe_ref(brain)?;
12341    // The new private key must be durable *before* the hub can accept its
12342    // public half. An existing file is the retry/reconciliation path after an
12343    // ambiguous network failure: reuse it, never generate another identity.
12344    let new_key = if out.exists() {
12345        load_signing_key(out)?
12346    } else {
12347        let rng = ring::rand::SystemRandom::new();
12348        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
12349            .map_err(|_| bad_agent_key("key generation failed"))?;
12350        let pair = agent_keypair(pkcs8.as_ref())?;
12351        let (public_key_spki, multikey) = public_identity_for(&pair);
12352        write_secret_new(
12353            out,
12354            format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
12355        )?;
12356        AgentSigningKey {
12357            pkcs8: pkcs8.as_ref().to_vec(),
12358            multikey,
12359            public_key_spki,
12360        }
12361    };
12362    let new_spki = new_key.public_key_spki.clone();
12363    let new_multikey = new_key.multikey.clone();
12364    let journal_path = rotation_journal_path(out);
12365    let before = verified_remote_head(cfg, brain, false)?;
12366    let served_identity = before
12367        .identity
12368        .as_ref()
12369        .ok_or_else(|| invalid_feed("cannot rotate a brain with no signed identity"))?;
12370    let served_multikey = format!("ed25519:{}", served_identity.fingerprint);
12371    if served_multikey == new_multikey {
12372        remove_rotation_journal(&journal_path);
12373        return Ok(RotationReport {
12374            brain: brain.to_string(),
12375            multikey: new_multikey,
12376            key_file: out.display().to_string(),
12377            previous: served_identity
12378                .previous
12379                .iter()
12380                .map(|identity| format!("ed25519:{}", identity.fingerprint))
12381                .collect(),
12382        });
12383    }
12384    if served_multikey != old_key.multikey {
12385        return Err(invalid_feed(
12386            "the supplied old key is not the brain's verified current identity",
12387        ));
12388    }
12389
12390    let journal = if journal_path.exists() {
12391        read_rotation_journal(&journal_path)?
12392    } else {
12393        let ts = crate::now()
12394            .with_timezone(&chrono::Utc)
12395            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
12396            .to_string();
12397        let unsigned = serde_json::to_string(&UnsignedRotation {
12398            v: 1,
12399            op: "rotate",
12400            brain: &old_key.multikey,
12401            public_key: &old_key.public_key_spki,
12402            new_brain: &new_multikey,
12403            new_public_key: &new_spki,
12404            prior_head_seq: before.head.seq,
12405            prior_feed_hash: before.head.feed_hash.as_deref(),
12406            ts,
12407        })
12408        .expect("serialize rotation");
12409        let old_pair = agent_keypair(&old_key.pkcs8)?;
12410        let sig = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
12411        let statement = format!("{},\"sig\":\"{}\"}}", &unsigned[..unsigned.len() - 1], sig);
12412        let journal = RotationJournal {
12413            v: 1,
12414            origin: normalized_origin(&cfg.hub)?,
12415            brain: before.head.brain.clone(),
12416            old_brain: old_key.multikey.clone(),
12417            new_brain: new_multikey.clone(),
12418            prior_head_seq: before.head.seq,
12419            prior_feed_hash: before.head.feed_hash.clone(),
12420            statement,
12421        };
12422        let mut exact = serde_json::to_vec(&journal)
12423            .map_err(|_| invalid_feed("could not serialize rotation journal"))?;
12424        exact.push(b'\n');
12425        if write_secret_new(&journal_path, &exact).is_err() {
12426            // A concurrent retry may have won the O_EXCL race. Only an exact,
12427            // fully validated journal is allowed to recover that race.
12428            read_rotation_journal(&journal_path)?
12429        } else {
12430            journal
12431        }
12432    };
12433    validate_rotation_journal(
12434        &journal,
12435        cfg,
12436        &before.head.brain,
12437        old_key,
12438        &new_key,
12439        &before.head,
12440    )?;
12441
12442    let body = json!({ "statement": journal.statement });
12443    let path = format!("/api/hub/brains/{brain}/rotate");
12444    let attempted = request(cfg, "POST", &path, Some(&body), Auth::Required);
12445    let attempted_failure = match attempted {
12446        Ok(response) if (200..300).contains(&response.status) => None,
12447        Ok(response) => Some(ensure_ok(response, "key rotate").unwrap_err()),
12448        Err(error) => Some(error),
12449    };
12450
12451    // A 2xx body is not authority, and a failed response may have followed a
12452    // committed mutation. In both cases success means the normal verifier sees
12453    // an append-only rotation chain ending at the durable new key.
12454    let after = match verified_remote_head(cfg, brain, false) {
12455        Ok(after) => after,
12456        Err(error) => return Err(attempted_failure.unwrap_or(error)),
12457    };
12458    let identity = after
12459        .identity
12460        .as_ref()
12461        .ok_or_else(|| invalid_feed("rotated brain has no verified identity"))?;
12462    if format!("ed25519:{}", identity.fingerprint) != new_multikey
12463        || identity.public_key_spki != new_spki
12464    {
12465        return Err(attempted_failure.unwrap_or_else(|| {
12466            invalid_feed("hub acknowledged rotation without committing the verified new identity")
12467        }));
12468    }
12469    let previous = identity
12470        .previous
12471        .iter()
12472        .map(|prior| format!("ed25519:{}", prior.fingerprint))
12473        .collect();
12474    remove_rotation_journal(&journal_path);
12475
12476    Ok(RotationReport {
12477        brain: brain.to_string(),
12478        multikey: new_multikey,
12479        key_file: out.display().to_string(),
12480        previous,
12481    })
12482}
12483
12484// ─────────────────────────────────────────────────────────────────────────────
12485// mirror — verified replication: the whole feed + files, re-servable
12486// ─────────────────────────────────────────────────────────────────────────────
12487
12488/// What `dbmd mirror` materialized.
12489#[derive(Debug, Serialize)]
12490pub struct MirrorReport {
12491    /// The brain id.
12492    pub brain: String,
12493    /// The mirrored feed head.
12494    #[serde(rename = "headSeq")]
12495    pub head_seq: u64,
12496    /// The head entry hash (the feed's advertised converged state).
12497    #[serde(rename = "feedHash")]
12498    pub feed_hash: Option<String>,
12499    /// Signed feed entries verified and stored.
12500    pub entries: u64,
12501    /// The brain's multikey, pinned in `.dbmd/config` (TOFU).
12502    pub pinned: String,
12503    /// Store files materialized by the pull.
12504    pub files: usize,
12505}
12506
12507/// The mirror state directory, relative to the mirror root.
12508pub const MIRROR_REL_DIR: &str = ".dbmd/mirror";
12509
12510/// Fully re-verified mirror material suitable for a read-only re-server.
12511#[derive(Debug)]
12512pub struct VerifiedMirrorMaterial {
12513    pub brain: String,
12514    pub head_seq: u64,
12515    pub feed_hash: Option<String>,
12516    pub identity: serde_json::Value,
12517    /// Sequence, exact normative entry JSON without newline, and entry hash.
12518    pub entries: Vec<(u64, String, String)>,
12519    pub pack_sha256: Option<String>,
12520}
12521
12522#[derive(Deserialize)]
12523#[serde(deny_unknown_fields)]
12524struct StoredMirrorHead {
12525    brain: String,
12526    #[serde(rename = "headSeq")]
12527    head_seq: u64,
12528    #[serde(rename = "feedHash")]
12529    feed_hash: Option<String>,
12530}
12531
12532/// Re-verify mirror metadata, every signature/hash/rotation boundary, and the
12533/// exact snapshot pack before `dbmd serve` exposes any bytes.
12534pub fn verify_mirror_material(
12535    head_bytes: &[u8],
12536    identity_bytes: &[u8],
12537    feed_bytes: &[Vec<u8>],
12538    snapshot_pack: Option<&[u8]>,
12539    expected_anchor: &str,
12540) -> LinkResult<VerifiedMirrorMaterial> {
12541    let snapshot_hash = snapshot_pack
12542        .filter(|pack| !pack.is_empty())
12543        .map(content_sha256);
12544    verify_mirror_material_with_pack_hash(
12545        head_bytes,
12546        identity_bytes,
12547        feed_bytes,
12548        snapshot_hash.as_deref(),
12549        expected_anchor,
12550    )
12551}
12552
12553/// Re-verify mirror metadata against a snapshot digest computed from a held
12554/// no-follow file capability. This lets `dbmd serve` authenticate and retain a
12555/// large pack without ever buffering the pack in process memory.
12556pub fn verify_mirror_material_with_pack_hash(
12557    head_bytes: &[u8],
12558    identity_bytes: &[u8],
12559    feed_bytes: &[Vec<u8>],
12560    snapshot_pack_sha256: Option<&str>,
12561    expected_anchor: &str,
12562) -> LinkResult<VerifiedMirrorMaterial> {
12563    let head: StoredMirrorHead = serde_json::from_slice(head_bytes)
12564        .map_err(|_| invalid_feed("stored mirror head did not parse exactly"))?;
12565    require_safe_ref(&head.brain)?;
12566    if head.head_seq > MAX_FEED_REPLAY_ENTRIES || feed_bytes.len() as u64 != head.head_seq {
12567        return Err(invalid_feed(
12568            "stored mirror feed count does not match its bounded head sequence",
12569        ));
12570    }
12571    let aggregate = feed_bytes
12572        .iter()
12573        .try_fold(0u64, |total, bytes| total.checked_add(bytes.len() as u64))
12574        .ok_or_else(|| invalid_feed("stored mirror feed size overflow"))?;
12575    if aggregate > MAX_FEED_REPLAY_BYTES {
12576        return Err(invalid_feed(
12577            "stored mirror feed metadata exceeds the aggregate limit",
12578        ));
12579    }
12580    let identity: FeedIdentity = serde_json::from_slice(identity_bytes)
12581        .map_err(|_| invalid_feed("stored mirror identity did not parse exactly"))?;
12582    let anchor = verify_identity_chain(&identity, None)?;
12583    if anchor != expected_anchor {
12584        return Err(invalid_feed(
12585            "stored mirror identity does not descend from the explicitly trusted anchor",
12586        ));
12587    }
12588
12589    let mut entries = Vec::with_capacity(feed_bytes.len());
12590    let mut items = Vec::with_capacity(feed_bytes.len());
12591    let mut previous_hash = None;
12592    let mut pack_sha256 = None;
12593    for (index, bytes) in feed_bytes.iter().enumerate() {
12594        let exact = bytes
12595            .strip_suffix(b"\n")
12596            .ok_or_else(|| invalid_feed("stored feed entry lacks its exact trailing newline"))?;
12597        if exact.ends_with(b"\n") {
12598            return Err(invalid_feed("stored feed entry has extra trailing bytes"));
12599        }
12600        let entry: FeedEntry = serde_json::from_slice(exact)
12601            .map_err(|_| invalid_feed("stored feed entry did not parse exactly"))?;
12602        let expected_seq = index as u64 + 1;
12603        if entry.seq != expected_seq || entry.prev_entry_hash != previous_hash {
12604            return Err(invalid_feed(
12605                "stored mirror feed is not contiguous and hash-chained",
12606            ));
12607        }
12608        let canonical = serde_json::to_vec(&entry)
12609            .map_err(|_| invalid_feed("could not canonicalize stored feed entry"))?;
12610        if canonical != exact {
12611            return Err(invalid_feed(
12612                "stored feed entry is not in normative serialization",
12613            ));
12614        }
12615        let hash = content_sha256(bytes);
12616        let item = FeedItem {
12617            hash: hash.clone(),
12618            entry,
12619        };
12620        verify_feed_item(&item, &identity)?;
12621        previous_hash = Some(hash.clone());
12622        if expected_seq == head.head_seq {
12623            pack_sha256 = Some(item.entry.pack_sha256.clone());
12624        }
12625        entries.push((
12626            expected_seq,
12627            std::str::from_utf8(exact)
12628                .map_err(|_| invalid_feed("stored feed entry is not UTF-8"))?
12629                .to_string(),
12630            hash,
12631        ));
12632        items.push(item);
12633    }
12634    if previous_hash != head.feed_hash {
12635        return Err(invalid_feed(
12636            "stored mirror feed does not converge on its advertised head",
12637        ));
12638    }
12639    verify_rotation_feed_boundaries(&identity, None, &items, head.head_seq)?;
12640    match (head.head_seq, snapshot_pack_sha256, pack_sha256.as_deref()) {
12641        (0, None, None) => {}
12642        (_, Some(actual), Some(expected)) if actual == expected => {}
12643        _ => {
12644            return Err(LinkError::InvalidPack {
12645                message: "stored snapshot pack does not match the signed head digest".to_string(),
12646            });
12647        }
12648    }
12649    let identity_value = serde_json::to_value(&identity)
12650        .map_err(|_| invalid_feed("could not serialize verified mirror identity"))?;
12651    Ok(VerifiedMirrorMaterial {
12652        brain: head.brain,
12653        head_seq: head.head_seq,
12654        feed_hash: head.feed_hash,
12655        identity: identity_value,
12656        entries,
12657        pack_sha256,
12658    })
12659}
12660
12661/// SHA-256 hex of one feed entry's stored bytes (`exact JSON + "\n"`) — the
12662/// entry hash every consumer recomputes (SPEC §5.3).
12663pub fn feed_entry_hash(exact_sans_newline: &str) -> String {
12664    format!(
12665        "{:x}",
12666        Sha256::digest(format!("{exact_sans_newline}\n").as_bytes())
12667    )
12668}
12669
12670/// SHA-256 hex for content a signed manifest names. Exposed for the thin
12671/// `dbmd serve` adapter; cryptographic verification remains centralized here.
12672pub fn content_sha256(bytes: &[u8]) -> String {
12673    format!("{:x}", Sha256::digest(bytes))
12674}
12675
12676/// SHA-256 a stream with a fixed-size working buffer.
12677pub fn content_sha256_reader(mut reader: impl Read) -> std::io::Result<String> {
12678    let mut digest = Sha256::new();
12679    let mut buffer = [0u8; 64 * 1024];
12680    loop {
12681        let read = reader.read(&mut buffer)?;
12682        if read == 0 {
12683            break;
12684        }
12685        digest.update(&buffer[..read]);
12686    }
12687    Ok(format!("{:x}", digest.finalize()))
12688}
12689
12690/// Replicate a brain with full verification (link.md §5.4 over the WHOLE
12691/// chain, not just the head): every entry's signature, hash, sequence
12692/// contiguity, prev-hash linkage, rotation chain, and exact signed pack are
12693/// checked in a sibling staging directory. Only then is the old mirror swapped
12694/// out through an atomic directory exchange. Every stage, install, and cleanup
12695/// operation is relative to one held no-follow parent-directory capability, so
12696/// renaming an ancestor cannot redirect any write or deletion.
12697#[cfg_attr(windows, allow(unreachable_code, unused_variables))]
12698pub fn mirror(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<MirrorReport> {
12699    require_hardened_filesystem("mirror")?;
12700    require_safe_ref(brain)?;
12701    #[cfg(windows)]
12702    {
12703        let _ = (cfg, dest);
12704        return Err(LinkError::UnsupportedPlatform {
12705            operation: "atomic whole-mirror replacement on Windows",
12706        });
12707    }
12708    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
12709    let name = dest
12710        .file_name()
12711        .and_then(|name| name.to_str())
12712        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
12713        .ok_or_else(|| LinkError::UnsafePath {
12714            path: dest.display().to_string(),
12715        })?;
12716    #[cfg(unix)]
12717    let parent_dir = open_or_create_dir_nofollow(parent)?;
12718    #[cfg(unix)]
12719    use std::os::fd::AsRawFd as _;
12720    #[cfg(unix)]
12721    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
12722    #[cfg(unix)]
12723    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
12724        None => false,
12725        Some(true) => true,
12726        Some(false) => {
12727            return Err(LinkError::UnsafePath {
12728                path: dest.display().to_string(),
12729            });
12730        }
12731    };
12732
12733    // A fixed backup was used by pre-hardening builds. Never interpret or
12734    // delete an attacker-planted entry at that name; require manual recovery.
12735    #[cfg(unix)]
12736    let legacy_backup_name = c_name(
12737        format!(".{name}.dbmd-backup").as_bytes(),
12738        &dest.display().to_string(),
12739    )?;
12740    #[cfg(unix)]
12741    if entry_is_dir_at(parent_dir.as_raw_fd(), &legacy_backup_name)?.is_some() {
12742        return Err(LinkError::UnsafePath {
12743            path: parent
12744                .join(format!(".{name}.dbmd-backup"))
12745                .display()
12746                .to_string(),
12747        });
12748    }
12749
12750    let nonce = std::time::SystemTime::now()
12751        .duration_since(std::time::UNIX_EPOCH)
12752        .unwrap_or_default()
12753        .as_nanos();
12754    let stage_label = format!(".{name}.dbmd-stage-{}-{nonce}", std::process::id());
12755    #[cfg(unix)]
12756    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
12757    #[cfg(unix)]
12758    let stage_dir = create_dir_exclusive_at(
12759        parent_dir.as_raw_fd(),
12760        &stage_name,
12761        &dest.display().to_string(),
12762    )?;
12763
12764    let assembled = (|| -> LinkResult<MirrorReport> {
12765        let remote = verified_remote_head(cfg, brain, true)?;
12766        let brain_id = remote.head.brain.clone();
12767        let identity = remote
12768            .identity
12769            .as_ref()
12770            .ok_or_else(|| invalid_feed("brain has no signed identity"))?;
12771        let anchor = remote
12772            .anchor
12773            .clone()
12774            .ok_or_else(|| invalid_feed("brain has no verified identity anchor"))?;
12775        let pack = download_verified_snapshot_pack(cfg, &brain_id, &remote)?;
12776        let snapshot_entries = parse_store_pack(pack.clone())?;
12777        let snapshot_count = snapshot_entries.len();
12778        let mut staged_entries = snapshot_entries;
12779        staged_entries.push((format!("{MIRROR_REL_DIR}/snapshot.pack"), pack));
12780        for item in &remote.entries {
12781            let mut exact = serde_json::to_vec(&item.entry)
12782                .map_err(|_| invalid_feed("could not serialize signed feed entry"))?;
12783            exact.push(b'\n');
12784            if format!("{:x}", Sha256::digest(&exact)) != item.hash {
12785                return Err(invalid_feed(
12786                    "serialized mirror entry differs from its verified hash",
12787                ));
12788            }
12789            staged_entries.push((
12790                format!("{MIRROR_REL_DIR}/feed/{}.json", item.entry.seq),
12791                exact,
12792            ));
12793        }
12794        let mut identity_bytes = serde_json::to_vec(identity)
12795            .map_err(|_| invalid_feed("could not serialize verified identity chain"))?;
12796        identity_bytes.push(b'\n');
12797        staged_entries.push((format!("{MIRROR_REL_DIR}/identity.json"), identity_bytes));
12798        let mut head_bytes = serde_json::to_vec(&json!({
12799            "brain": brain_id,
12800            "headSeq": remote.head.seq,
12801            "feedHash": remote.head.feed_hash,
12802        }))
12803        .map_err(|_| invalid_feed("could not serialize verified mirror head"))?;
12804        head_bytes.push(b'\n');
12805        staged_entries.push((format!("{MIRROR_REL_DIR}/head.json"), head_bytes));
12806        staged_entries.push((
12807            CONFIG_REL_PATH.to_string(),
12808            format!("hub = {}\npin = {anchor}\n", cfg.hub).into_bytes(),
12809        ));
12810        #[cfg(unix)]
12811        write_pull_entries_beneath_dir(&stage_dir, &staged_entries)?;
12812
12813        Ok(MirrorReport {
12814            brain: brain_id,
12815            head_seq: remote.head.seq,
12816            feed_hash: remote.head.feed_hash,
12817            entries: remote.entries.len() as u64,
12818            pinned: anchor,
12819            files: snapshot_count,
12820        })
12821    })();
12822
12823    let report = match assembled {
12824        Ok(report) => report,
12825        Err(error) => {
12826            #[cfg(unix)]
12827            let _ = remove_tree_at(
12828                parent_dir.as_raw_fd(),
12829                &stage_name,
12830                &dest.display().to_string(),
12831            );
12832            return Err(error);
12833        }
12834    };
12835
12836    #[cfg(unix)]
12837    if let Err(error) =
12838        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
12839    {
12840        let _ = remove_tree_at(
12841            parent_dir.as_raw_fd(),
12842            &stage_name,
12843            &dest.display().to_string(),
12844        );
12845        return Err(error);
12846    }
12847    // An exchange leaves the old mirror at the unique stage name. Cleanup is
12848    // capability-relative and never follows symlinks in hostile old content.
12849    #[cfg(unix)]
12850    if dest_exists {
12851        remove_tree_at(
12852            parent_dir.as_raw_fd(),
12853            &stage_name,
12854            &dest.display().to_string(),
12855        )?;
12856    }
12857    #[cfg(unix)]
12858    parent_dir.sync_all()?;
12859    Ok(report)
12860}
12861
12862fn verified_remote_head(
12863    cfg: &HubConfig,
12864    brain: &str,
12865    require_full_chain: bool,
12866) -> LinkResult<VerifiedRemote> {
12867    require_hardened_filesystem("verified link.md state")?;
12868    require_safe_ref(brain)?;
12869    // Refuse an unsafe state root before sending credentials or consulting an
12870    // untrusted card, and retain this exact directory inode for the complete
12871    // checkpoint transaction.
12872    let trust_directory = open_trust_dir(cfg)?;
12873    let path = format!("/api/hub/brains/{brain}");
12874    let body = ensure_ok(
12875        request(cfg, "GET", &path, None, Auth::Required)?,
12876        "subscribe",
12877    )?;
12878    let resolved_brain = body
12879        .get("id")
12880        .and_then(Value::as_str)
12881        .filter(|id| crate::ulid::is_ulid(id))
12882        .ok_or_else(|| invalid_feed("brain card has no canonical ULID id"))?
12883        .to_string();
12884    if crate::ulid::is_ulid(brain) && resolved_brain != brain {
12885        return Err(invalid_feed(
12886            "brain card id differs from the explicitly requested brain id",
12887        ));
12888    }
12889    // The card supplies the canonical ULID. Lock alias + canonical keys in
12890    // deterministic filename order, then hold both through verify + save.
12891    // Concurrent aliases for the same brain therefore converge on one
12892    // checkpoint instead of establishing independent TOFU universes.
12893    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &resolved_brain])?;
12894    let (pinned, alias_binding) =
12895        load_canonical_pin(cfg, &trust_directory, brain, &resolved_brain)?;
12896    let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
12897    let advertised_hash = body
12898        .get("feedHash")
12899        .and_then(Value::as_str)
12900        .map(str::to_string);
12901    let updated_at = body
12902        .get("updatedAt")
12903        .and_then(Value::as_str)
12904        .map(str::to_string);
12905    if let Some(pin) = &pinned {
12906        if seq < pin.head_seq {
12907            return Err(invalid_feed(format!(
12908                "feed rollback: hub advertised sequence {seq}, local checkpoint is {}",
12909                pin.head_seq
12910            )));
12911        }
12912        if seq == pin.head_seq && advertised_hash != pin.feed_hash {
12913            return Err(invalid_feed(
12914                "feed equivocation: the checkpoint sequence now has a different hash",
12915            ));
12916        }
12917    }
12918    if seq == 0 {
12919        if advertised_hash.is_some() {
12920            return Err(invalid_feed("an empty feed advertised a head hash"));
12921        }
12922        let identity: FeedIdentity = serde_json::from_value(
12923            body.get("identity")
12924                .cloned()
12925                .ok_or_else(|| invalid_feed("empty brain card has no signed identity"))?,
12926        )
12927        .map_err(|_| invalid_feed("empty brain card has an invalid identity"))?;
12928        let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
12929        // A valid old-key-signed rotation is still inconsistent if it commits
12930        // to history that the same card now claims never existed. Run the
12931        // identical feed-boundary proof used by non-empty heads before this
12932        // identity can become a durable TOFU checkpoint.
12933        verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], seq)?;
12934        save_canonical_pin_and_alias(
12935            cfg,
12936            &trust_directory,
12937            brain,
12938            &resolved_brain,
12939            TrustState {
12940                v: 2,
12941                origin: normalized_origin(&cfg.hub)?,
12942                requested: resolved_brain.clone(),
12943                brain: resolved_brain.clone(),
12944                home: None,
12945                anchor: anchor.clone(),
12946                current: format!("ed25519:{}", identity.fingerprint),
12947                head_seq: 0,
12948                feed_hash: None,
12949                rotations: identity.rotations.clone(),
12950                hub_signer: None,
12951                protocol_profile: None,
12952            },
12953            alias_binding.as_ref(),
12954        )?;
12955        return Ok(VerifiedRemote {
12956            head: Head {
12957                brain: resolved_brain,
12958                seq,
12959                updated_at,
12960                feed_hash: None,
12961                verified: true,
12962            },
12963            identity: Some(identity),
12964            head_entry: None,
12965            entries: Vec::new(),
12966            anchor: Some(anchor),
12967        });
12968    }
12969    if advertised_hash.as_ref().is_none_or(|hash| !is_sha256(hash)) {
12970        return Err(invalid_feed(
12971            "non-empty feed did not advertise a valid SHA-256 head",
12972        ));
12973    }
12974
12975    // On first contact the signed head itself is the TOFU checkpoint. A full
12976    // history replay cannot add authority before an anchor exists; mirrors
12977    // still request the complete chain because they promise an archival copy.
12978    let replay_head_only = !require_full_chain
12979        && pinned
12980            .as_ref()
12981            .is_none_or(|checkpoint| checkpoint.head_seq == seq);
12982    let mut after = if replay_head_only {
12983        seq - 1
12984    } else if require_full_chain || pinned.is_none() {
12985        0
12986    } else {
12987        pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq)
12988    };
12989    let mut expected_seq = after + 1;
12990    let mut previous_hash = if require_full_chain || pinned.is_none() || replay_head_only {
12991        None
12992    } else {
12993        pinned
12994            .as_ref()
12995            .and_then(|checkpoint| checkpoint.feed_hash.clone())
12996    };
12997    let mut identity: Option<FeedIdentity> = None;
12998    let mut anchor: Option<String> = None;
12999    let mut head_entry: Option<FeedItem> = None;
13000    let mut all_entries = Vec::new();
13001    let mut observed_entries = Vec::new();
13002    let replay_count = seq
13003        .checked_sub(after)
13004        .ok_or_else(|| invalid_feed("feed replay range moved backward"))?;
13005    if replay_count > MAX_FEED_REPLAY_ENTRIES {
13006        return Err(invalid_feed(format!(
13007            "feed replay requires {replay_count} entries, over the client cap"
13008        )));
13009    }
13010    let mut replay_bytes = 0u64;
13011
13012    loop {
13013        let feed_bytes = ensure_raw_ok(
13014            request_raw(
13015                cfg,
13016                "GET",
13017                &format!("/api/hub/brains/{brain}/feed?after={after}&limit=100"),
13018                None,
13019                Auth::Required,
13020                MAX_FEED_RESPONSE_BYTES,
13021            )?,
13022            "subscribe feed",
13023        )?;
13024        let feed: FeedResponse = serde_json::from_slice(&feed_bytes)
13025            .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
13026        if feed.head_seq != seq || feed.feed_hash != advertised_hash {
13027            return Err(invalid_feed("brain card and feed head disagree"));
13028        }
13029        if feed.entries.len() > FEED_PAGE_LIMIT {
13030            return Err(invalid_feed("feed page exceeds the requested entry limit"));
13031        }
13032        if feed.scope_limited {
13033            if require_full_chain {
13034                return Err(invalid_feed(
13035                    "path-scoped grants cannot verify a full snapshot chain",
13036                ));
13037            }
13038            return Ok(VerifiedRemote {
13039                head: Head {
13040                    brain: resolved_brain,
13041                    seq,
13042                    updated_at,
13043                    feed_hash: advertised_hash,
13044                    verified: false,
13045                },
13046                identity: None,
13047                head_entry: None,
13048                entries: Vec::new(),
13049                anchor: None,
13050            });
13051        }
13052        let page_identity = feed
13053            .identity
13054            .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
13055        let page_anchor = verify_identity_chain(&page_identity, pinned.as_ref())?;
13056        if identity
13057            .as_ref()
13058            .is_some_and(|existing| existing != &page_identity)
13059        {
13060            return Err(invalid_feed("identity changed while reading the feed"));
13061        }
13062        if anchor
13063            .as_ref()
13064            .is_some_and(|existing| existing != &page_anchor)
13065        {
13066            return Err(invalid_feed(
13067                "identity anchor changed while reading the feed",
13068            ));
13069        }
13070        identity = Some(page_identity.clone());
13071        if anchor.is_none() {
13072            anchor = Some(page_anchor);
13073        }
13074        if feed.entries.is_empty() {
13075            return Err(invalid_feed("feed page was empty before the signed head"));
13076        }
13077
13078        for item in feed.entries {
13079            if item.entry.seq != expected_seq {
13080                return Err(invalid_feed(format!(
13081                    "expected entry {expected_seq}, feed served {}",
13082                    item.entry.seq
13083                )));
13084            }
13085            if item.entry.seq > seq {
13086                return Err(invalid_feed("feed advanced past the card snapshot"));
13087            }
13088            if !replay_head_only && item.entry.prev_entry_hash != previous_hash {
13089                return Err(invalid_feed(format!(
13090                    "entry {} does not chain to the local checkpoint",
13091                    item.entry.seq
13092                )));
13093            }
13094            verify_feed_item(&item, &page_identity)?;
13095            reject_retired_signer_after_checkpoint(&page_identity, pinned.as_ref(), &item)?;
13096            replay_bytes = replay_bytes.saturating_add(
13097                serde_json::to_vec(&item)
13098                    .map_err(|_| invalid_feed("could not size feed entry"))?
13099                    .len() as u64,
13100            );
13101            if replay_bytes > MAX_FEED_REPLAY_BYTES {
13102                return Err(invalid_feed("feed replay metadata exceeds the client cap"));
13103            }
13104            previous_hash = Some(item.hash.clone());
13105            after = item.entry.seq;
13106            expected_seq = expected_seq
13107                .checked_add(1)
13108                .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
13109            if require_full_chain {
13110                all_entries.push(item.clone());
13111            }
13112            observed_entries.push(item.clone());
13113            head_entry = Some(item);
13114        }
13115        if after == seq {
13116            break;
13117        }
13118    }
13119
13120    if head_entry.as_ref().map(|item| &item.hash) != advertised_hash.as_ref() {
13121        return Err(invalid_feed(
13122            "verified chain does not converge on the advertised head",
13123        ));
13124    }
13125    let identity = identity.ok_or_else(|| invalid_feed("feed has no brain identity"))?;
13126    let anchor = anchor.ok_or_else(|| invalid_feed("feed has no identity anchor"))?;
13127    verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &observed_entries, seq)?;
13128    save_canonical_pin_and_alias(
13129        cfg,
13130        &trust_directory,
13131        brain,
13132        &resolved_brain,
13133        TrustState {
13134            v: 2,
13135            origin: normalized_origin(&cfg.hub)?,
13136            requested: resolved_brain.clone(),
13137            brain: resolved_brain.clone(),
13138            home: None,
13139            anchor: anchor.clone(),
13140            current: format!("ed25519:{}", identity.fingerprint),
13141            head_seq: seq,
13142            feed_hash: advertised_hash.clone(),
13143            rotations: identity.rotations.clone(),
13144            hub_signer: pinned.as_ref().and_then(|state| state.hub_signer.clone()),
13145            protocol_profile: pinned
13146                .as_ref()
13147                .and_then(|state| state.protocol_profile.clone()),
13148        },
13149        alias_binding.as_ref(),
13150    )?;
13151    Ok(VerifiedRemote {
13152        head: Head {
13153            brain: resolved_brain,
13154            seq,
13155            updated_at,
13156            feed_hash: advertised_hash,
13157            verified: true,
13158        },
13159        identity: Some(identity),
13160        head_entry,
13161        entries: all_entries,
13162        anchor: Some(anchor),
13163    })
13164}
13165
13166/// Read and locally verify the brain's current signed feed head. Identity
13167/// rotation is accepted only through an old-key-signed chain rooted at the
13168/// local TOFU anchor; sequence and hash checkpoints reject rollback and
13169/// equivocation across invocations.
13170pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
13171    Ok(verified_remote_head(cfg, brain, false)?.head)
13172}
13173
13174#[cfg(test)]
13175mod tests {
13176    use super::*;
13177
13178    const TEST_BRAIN_ID: &str = "01j5qc3v9k4ym8rwbn2tqe6f7d";
13179
13180    fn merge_fixture(
13181        base: Option<&str>,
13182        remote: Option<&str>,
13183        local: Option<&str>,
13184        keep_local: bool,
13185    ) -> V2PulledMerge<String> {
13186        let map = |value: Option<&str>| {
13187            value
13188                .map(|value| [("records/a.md".to_string(), value.to_string())])
13189                .into_iter()
13190                .flatten()
13191                .collect::<std::collections::BTreeMap<_, _>>()
13192        };
13193        merge_v2_pulled_records(
13194            &map(base),
13195            &map(remote),
13196            &map(local),
13197            |value, _| value.clone(),
13198            |value, _| value.clone(),
13199            |_| keep_local,
13200        )
13201    }
13202
13203    #[test]
13204    fn v2_pull_three_way_merge_never_discards_a_local_only_change() {
13205        let path = "records/a.md".to_string();
13206
13207        let local_add = merge_fixture(None, None, Some("local"), false);
13208        assert_eq!(
13209            local_add.records.get(&path).map(String::as_str),
13210            Some("local")
13211        );
13212        assert!(local_add.accept_remote.is_empty());
13213        assert!(local_add.conflicts.is_empty());
13214
13215        let local_edit = merge_fixture(Some("old"), Some("old"), Some("local"), false);
13216        assert_eq!(
13217            local_edit.records.get(&path).map(String::as_str),
13218            Some("local")
13219        );
13220        assert!(local_edit.accept_remote.is_empty());
13221        assert!(local_edit.conflicts.is_empty());
13222
13223        let local_delete = merge_fixture(Some("old"), Some("old"), None, false);
13224        assert!(!local_delete.records.contains_key(&path));
13225        assert!(local_delete.accept_remote.is_empty());
13226        assert!(local_delete.conflicts.is_empty());
13227
13228        let remote_edit = merge_fixture(Some("old"), Some("remote"), Some("old"), false);
13229        assert_eq!(
13230            remote_edit.records.get(&path).map(String::as_str),
13231            Some("remote")
13232        );
13233        assert!(remote_edit.accept_remote.contains(&path));
13234        assert!(remote_edit.conflicts.is_empty());
13235
13236        let remote_delete = merge_fixture(Some("old"), None, Some("old"), false);
13237        assert!(!remote_delete.records.contains_key(&path));
13238        assert!(remote_delete.accept_remote.contains(&path));
13239        assert!(remote_delete.conflicts.is_empty());
13240
13241        let same_edit = merge_fixture(Some("old"), Some("same"), Some("same"), false);
13242        assert_eq!(
13243            same_edit.records.get(&path).map(String::as_str),
13244            Some("same")
13245        );
13246        assert!(same_edit.accept_remote.contains(&path));
13247        assert!(same_edit.conflicts.is_empty());
13248
13249        let conflict = merge_fixture(Some("old"), Some("remote"), Some("local"), false);
13250        assert_eq!(conflict.conflicts, vec![path.clone()]);
13251        assert_eq!(
13252            conflict.records.get(&path).map(String::as_str),
13253            Some("local")
13254        );
13255        assert!(conflict.accept_remote.is_empty());
13256
13257        let kept_home = merge_fixture(Some("old"), Some("remote"), Some("local"), true);
13258        assert_eq!(
13259            kept_home.records.get(&path).map(String::as_str),
13260            Some("local")
13261        );
13262        assert!(kept_home.accept_remote.is_empty());
13263        assert!(kept_home.conflicts.is_empty());
13264    }
13265
13266    #[test]
13267    fn v2_asset_hosting_resume_is_an_explicit_local_policy_transition() {
13268        let path = "sources/report.pdf";
13269        let record = crate::AssetRecord {
13270            path: path.to_string(),
13271            sha256: "a".repeat(64),
13272            bytes: 42,
13273            media_type: "application/pdf".to_string(),
13274            wrappers: vec!["gzip".to_string()],
13275            required: true,
13276        };
13277        let mut remote = V2BaselineAsset {
13278            blob_sha256: record.sha256.clone(),
13279            bytes: record.bytes,
13280            media_type: record.media_type.clone(),
13281            wrappers: record.wrappers.clone(),
13282            required: record.required,
13283            disposition: "withheld".to_string(),
13284            leaf_hash: "b".repeat(64),
13285        };
13286
13287        assert!(v2_asset_resumes_hosting(
13288            Some(&remote),
13289            path,
13290            &record,
13291            "hosted"
13292        ));
13293        assert!(!v2_asset_resumes_hosting(
13294            Some(&remote),
13295            path,
13296            &record,
13297            "withheld"
13298        ));
13299
13300        remote.disposition = "hosted".to_string();
13301        assert!(!v2_asset_resumes_hosting(
13302            Some(&remote),
13303            path,
13304            &record,
13305            "hosted"
13306        ));
13307
13308        remote.disposition = "withheld".to_string();
13309        remote.blob_sha256 = "c".repeat(64);
13310        assert!(!v2_asset_resumes_hosting(
13311            Some(&remote),
13312            path,
13313            &record,
13314            "hosted"
13315        ));
13316        assert!(!v2_asset_resumes_hosting(None, path, &record, "hosted"));
13317    }
13318
13319    #[test]
13320    fn take_remote_resolution_rebinds_to_the_exact_current_manifest() {
13321        let path = "records/team/alpha.md".to_string();
13322        let deleted_path = "records/team/deleted.md".to_string();
13323        let coordinate = |sha256: Option<String>, bytes: Option<u64>| V2ConflictCoordinate {
13324            sha256,
13325            bytes,
13326            file: None,
13327        };
13328        let files = vec![
13329            V2ConflictFile {
13330                path: path.clone(),
13331                base: coordinate(None, None),
13332                local: coordinate(Some("b".repeat(64)), Some(7)),
13333                remote: coordinate(Some("a".repeat(64)), Some(5)),
13334            },
13335            V2ConflictFile {
13336                path: deleted_path.clone(),
13337                base: coordinate(Some("c".repeat(64)), Some(9)),
13338                local: coordinate(Some("d".repeat(64)), Some(11)),
13339                remote: coordinate(None, None),
13340            },
13341        ];
13342        let proven = V2BaselineFile {
13343            sha256: "a".repeat(64),
13344            bytes: 5,
13345            proof: None,
13346        };
13347        let current = [(path.clone(), proven.clone())]
13348            .into_iter()
13349            .collect::<std::collections::BTreeMap<_, _>>();
13350
13351        let (selected, deleted) = v2_take_remote_selection(&files, &current).unwrap();
13352        assert_eq!(selected.get(&path).unwrap().sha256, proven.sha256);
13353        assert_eq!(deleted, vec![deleted_path.clone()]);
13354
13355        let changed = [(
13356            path.clone(),
13357            V2BaselineFile {
13358                sha256: "e".repeat(64),
13359                bytes: 5,
13360                proof: None,
13361            },
13362        )]
13363        .into_iter()
13364        .collect::<std::collections::BTreeMap<_, _>>();
13365        assert!(v2_take_remote_selection(&files, &changed).is_err());
13366
13367        let resurrected = [
13368            (path, proven),
13369            (
13370                deleted_path,
13371                V2BaselineFile {
13372                    sha256: "f".repeat(64),
13373                    bytes: 13,
13374                    proof: None,
13375                },
13376            ),
13377        ]
13378        .into_iter()
13379        .collect::<std::collections::BTreeMap<_, _>>();
13380        assert!(v2_take_remote_selection(&files, &resurrected).is_err());
13381    }
13382
13383    #[cfg(target_os = "linux")]
13384    #[test]
13385    fn linux_snapshot_install_is_atomic_for_create_and_exchange() {
13386        use std::os::fd::AsRawFd as _;
13387
13388        let sandbox = tempfile::TempDir::new().unwrap();
13389        let parent = std::fs::File::open(sandbox.path()).unwrap();
13390        let stage = std::ffi::CString::new("stage").unwrap();
13391        let destination = std::ffi::CString::new("brain").unwrap();
13392
13393        std::fs::create_dir(sandbox.path().join("stage")).unwrap();
13394        std::fs::write(sandbox.path().join("stage/value"), b"created").unwrap();
13395        install_stage_at(
13396            parent.as_raw_fd(),
13397            stage.as_c_str(),
13398            destination.as_c_str(),
13399            false,
13400        )
13401        .unwrap();
13402        assert!(!sandbox.path().join("stage").exists());
13403        assert_eq!(
13404            std::fs::read(sandbox.path().join("brain/value")).unwrap(),
13405            b"created"
13406        );
13407
13408        std::fs::create_dir(sandbox.path().join("stage")).unwrap();
13409        std::fs::write(sandbox.path().join("stage/value"), b"replacement").unwrap();
13410        install_stage_at(
13411            parent.as_raw_fd(),
13412            stage.as_c_str(),
13413            destination.as_c_str(),
13414            true,
13415        )
13416        .unwrap();
13417        assert_eq!(
13418            std::fs::read(sandbox.path().join("brain/value")).unwrap(),
13419            b"replacement"
13420        );
13421        assert_eq!(
13422            std::fs::read(sandbox.path().join("stage/value")).unwrap(),
13423            b"created",
13424            "RENAME_EXCHANGE must leave the predecessor at the unique stage name"
13425        );
13426    }
13427
13428    struct SignedRemoteFixture {
13429        card: String,
13430        feed: String,
13431        key: AgentSigningKey,
13432        identity: FeedIdentity,
13433    }
13434
13435    fn signed_remote_fixture() -> SignedRemoteFixture {
13436        let rng = ring::rand::SystemRandom::new();
13437        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
13438        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
13439        let (public_key, multikey) = public_identity_for(&pair);
13440        let identity = FeedIdentity {
13441            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
13442            public_key_spki: public_key.clone(),
13443            previous: Vec::new(),
13444            rotations: Vec::new(),
13445        };
13446        let mut entry = FeedEntry {
13447            v: 1,
13448            seq: 1,
13449            ts: "2026-07-30T12:00:00.000Z".to_string(),
13450            brain: multikey.clone(),
13451            public_key: public_key.clone(),
13452            kind: "push".to_string(),
13453            op: "snapshot".to_string(),
13454            pack_sha256: "a".repeat(64),
13455            files: Vec::new(),
13456            removed: Vec::new(),
13457            prev_entry_hash: None,
13458            sig: String::new(),
13459        };
13460        let unsigned = UnsignedFeedEntry {
13461            v: entry.v,
13462            seq: entry.seq,
13463            ts: &entry.ts,
13464            brain: &entry.brain,
13465            public_key: &entry.public_key,
13466            kind: &entry.kind,
13467            op: &entry.op,
13468            pack_sha256: &entry.pack_sha256,
13469            files: &entry.files,
13470            removed: &entry.removed,
13471            prev_entry_hash: &entry.prev_entry_hash,
13472        };
13473        entry.sig =
13474            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
13475        let mut exact = serde_json::to_vec(&entry).unwrap();
13476        exact.push(b'\n');
13477        let hash = content_sha256(&exact);
13478        let card = json!({
13479            "id": TEST_BRAIN_ID,
13480            "headSeq": 1,
13481            "feedHash": hash,
13482            "identity": identity.clone(),
13483        })
13484        .to_string();
13485        let feed = json!({
13486            "headSeq": 1,
13487            "feedHash": hash,
13488            "identity": identity.clone(),
13489            "entries": [{"hash": hash, "entry": entry}],
13490            "scopeLimited": false,
13491        })
13492        .to_string();
13493        SignedRemoteFixture {
13494            card,
13495            feed,
13496            key: AgentSigningKey {
13497                pkcs8: pkcs8.as_ref().to_vec(),
13498                multikey,
13499                public_key_spki: public_key,
13500            },
13501            identity,
13502        }
13503    }
13504
13505    #[test]
13506    fn v1_edit_disclosure_accepts_minimal_and_superset_forms() {
13507        let file = |path: &str, byte: char| FeedFile {
13508            path: path.to_string(),
13509            sha256: byte.to_string().repeat(64),
13510            bytes: 1,
13511        };
13512        let a0 = file("records/a.md", 'a');
13513        let a1 = file("records/a.md", 'b');
13514        let stable = file("records/stable.md", 'c');
13515        let added = file("records/added.md", 'd');
13516        let removed_file = file("records/removed.md", 'e');
13517        let previous = vec![a0, stable.clone(), removed_file.clone()];
13518        let resulting = vec![a1.clone(), stable.clone(), added.clone()];
13519        let removed = vec![removed_file.path.clone()];
13520
13521        assert_eq!(
13522            verify_v1_manifest_disclosure(
13523                "edit",
13524                &previous,
13525                &resulting,
13526                &[a1.clone(), added.clone()],
13527                &removed,
13528            ),
13529            Ok(())
13530        );
13531        assert_eq!(
13532            verify_v1_manifest_disclosure(
13533                "edit",
13534                &previous,
13535                &resulting,
13536                &[stable.clone(), added.clone(), a1.clone()],
13537                &removed,
13538            ),
13539            Ok(())
13540        );
13541        assert_eq!(
13542            verify_v1_manifest_disclosure(
13543                "edit",
13544                &previous,
13545                &resulting,
13546                std::slice::from_ref(&added),
13547                &removed,
13548            ),
13549            Err(V1DisclosureError::EditMissingChange)
13550        );
13551        assert_eq!(
13552            verify_v1_manifest_disclosure(
13553                "edit",
13554                &previous,
13555                &resulting,
13556                &[file("records/a.md", 'f'), added.clone()],
13557                &removed,
13558            ),
13559            Err(V1DisclosureError::EditFalseFile)
13560        );
13561        assert_eq!(
13562            verify_v1_manifest_disclosure(
13563                "edit",
13564                &previous,
13565                &resulting,
13566                &[a1.clone(), added.clone()],
13567                &[],
13568            ),
13569            Err(V1DisclosureError::RemovedMismatch)
13570        );
13571        assert_eq!(
13572            verify_v1_manifest_disclosure(
13573                "push",
13574                &previous,
13575                &resulting,
13576                &[added.clone(), stable, a1],
13577                &removed,
13578            ),
13579            Ok(())
13580        );
13581        assert_eq!(
13582            verify_v1_manifest_disclosure("push", &previous, &resulting, &[added], &removed,),
13583            Err(V1DisclosureError::PushManifestMismatch)
13584        );
13585    }
13586
13587    #[test]
13588    fn wire_sequences_are_rejected_at_their_endpoint_count_limits() {
13589        let fixture = signed_remote_fixture();
13590        let feed: Value = serde_json::from_str(&fixture.feed).unwrap();
13591        let item = feed["entries"][0].to_string();
13592        let oversized_page = format!(
13593            "{{\"headSeq\":1,\"feedHash\":null,\"identity\":null,\"entries\":[{}],\"scopeLimited\":false}}",
13594            std::iter::repeat_n(item.as_str(), FEED_PAGE_LIMIT + 1)
13595                .collect::<Vec<_>>()
13596                .join(",")
13597        );
13598        assert!(serde_json::from_str::<FeedResponse>(&oversized_page).is_err());
13599
13600        let oversized_identity = format!(
13601            "{{\"fingerprint\":\"fp\",\"publicKeySpki\":\"spki\",\"previous\":[],\"rotations\":[{}]}}",
13602            std::iter::repeat_n("\"rotation\"", MAX_IDENTITY_ROTATIONS + 1)
13603                .collect::<Vec<_>>()
13604                .join(",")
13605        );
13606        assert!(serde_json::from_str::<FeedIdentity>(&oversized_identity).is_err());
13607
13608        let file = r#"{"path":"a","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bytes":1}"#;
13609        let oversized_entry = format!(
13610            "{{\"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\"}}",
13611            "a".repeat(64),
13612            std::iter::repeat_n(file, MAX_PUSH_FILES + 1)
13613                .collect::<Vec<_>>()
13614                .join(",")
13615        );
13616        assert!(serde_json::from_str::<FeedEntry>(&oversized_entry).is_err());
13617    }
13618
13619    #[test]
13620    fn bulk_confirmation_parser_accepts_only_the_exact_wire_shape() {
13621        let id = "01arz3ndektsv4rrffq69g5fav";
13622        let digest = "a".repeat(64);
13623        assert_eq!(
13624            V2BulkConfirmation::parse(&format!("{id}:{digest}")).unwrap(),
13625            V2BulkConfirmation {
13626                id: id.to_string(),
13627                digest,
13628            }
13629        );
13630        for invalid in [
13631            "",
13632            "01arz3ndektsv4rrffq69g5fav",
13633            "01ARZ3NDEKTSV4RRFFQ69G5FAV:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
13634            "01arz3ndektsv4rrffq69g5fav:ABCDEF",
13635            "01arz3ndektsv4rrffq69g5fav:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
13636        ] {
13637            assert!(matches!(
13638                V2BulkConfirmation::parse(invalid),
13639                Err(LinkError::InvalidPack { .. })
13640            ));
13641        }
13642    }
13643
13644    fn scripted_json_hub(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle<()>) {
13645        use std::io::{BufRead as _, BufReader, Read as _, Write as _};
13646        use std::net::TcpListener;
13647
13648        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
13649        let url = format!("http://{}", listener.local_addr().unwrap());
13650        let handle = std::thread::spawn(move || {
13651            for (status, body) in responses {
13652                let (stream, _) = listener.accept().unwrap();
13653                let mut reader = BufReader::new(stream);
13654                let mut line = String::new();
13655                reader.read_line(&mut line).unwrap();
13656                let mut content_length = 0usize;
13657                loop {
13658                    line.clear();
13659                    reader.read_line(&mut line).unwrap();
13660                    if line == "\r\n" || line == "\n" || line.is_empty() {
13661                        break;
13662                    }
13663                    if let Some((name, value)) = line.split_once(':') {
13664                        if name.eq_ignore_ascii_case("content-length") {
13665                            content_length = value.trim().parse().unwrap();
13666                        }
13667                    }
13668                }
13669                let mut request_body = vec![0_u8; content_length];
13670                reader.read_exact(&mut request_body).unwrap();
13671                let response = format!(
13672                    "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
13673                    body.len()
13674                );
13675                reader.get_mut().write_all(response.as_bytes()).unwrap();
13676            }
13677        });
13678        (url, handle)
13679    }
13680
13681    fn routed_json_hub(
13682        requests: usize,
13683        mut respond: impl FnMut(&str) -> (u16, String) + Send + 'static,
13684    ) -> (String, std::thread::JoinHandle<()>) {
13685        use std::io::{BufRead as _, BufReader, Read as _, Write as _};
13686        use std::net::TcpListener;
13687
13688        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
13689        let url = format!("http://{}", listener.local_addr().unwrap());
13690        let handle = std::thread::spawn(move || {
13691            for _ in 0..requests {
13692                let (stream, _) = listener.accept().unwrap();
13693                let mut reader = BufReader::new(stream);
13694                let mut line = String::new();
13695                reader.read_line(&mut line).unwrap();
13696                let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
13697                let mut content_length = 0usize;
13698                loop {
13699                    line.clear();
13700                    reader.read_line(&mut line).unwrap();
13701                    if line == "\r\n" || line == "\n" || line.is_empty() {
13702                        break;
13703                    }
13704                    if let Some((name, value)) = line.split_once(':') {
13705                        if name.eq_ignore_ascii_case("content-length") {
13706                            content_length = value.trim().parse().unwrap();
13707                        }
13708                    }
13709                }
13710                let mut request_body = vec![0_u8; content_length];
13711                reader.read_exact(&mut request_body).unwrap();
13712                let (status, body) = respond(&path);
13713                let response = format!(
13714                    "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
13715                    body.len()
13716                );
13717                reader.get_mut().write_all(response.as_bytes()).unwrap();
13718            }
13719        });
13720        (url, handle)
13721    }
13722
13723    fn test_hub_config(hub: String, state_dir: PathBuf) -> HubConfig {
13724        HubConfig {
13725            hub,
13726            key: Some("test-key".to_string()),
13727            agent_key: None,
13728            brain_key: None,
13729            state_dir,
13730            store_selected: false,
13731        }
13732    }
13733
13734    #[test]
13735    fn linkmd_sig_v2_is_bound_to_the_exact_hub_origin() {
13736        use ring::signature::KeyPair as _;
13737
13738        let rng = ring::rand::SystemRandom::new();
13739        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
13740        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
13741        let (spki, multikey) = public_identity_for(&pair);
13742        let key = AgentSigningKey {
13743            pkcs8: pkcs8.as_ref().to_vec(),
13744            multikey,
13745            public_key_spki: spki,
13746        };
13747        let header = linkmd_sig_header(
13748            &key,
13749            "https://hub-a.example",
13750            "post",
13751            "/api/hub/brains/brain/push?mode=exact",
13752            Some("{\"ok\":true}"),
13753        )
13754        .unwrap();
13755        assert!(header.starts_with("LinkMD-Sig v2,"));
13756        let ts = header
13757            .split(",ts=")
13758            .nth(1)
13759            .unwrap()
13760            .split(',')
13761            .next()
13762            .unwrap();
13763        let signature = URL_SAFE_NO_PAD
13764            .decode(header.rsplit(",sig=").next().unwrap())
13765            .unwrap();
13766        let body_hash = format!("{:x}", Sha256::digest(b"{\"ok\":true}"));
13767        let accepted = format!(
13768            "v2\nhttps://hub-a.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
13769        );
13770        let replayed = format!(
13771            "v2\nhttps://hub-b.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
13772        );
13773        let public = pair.public_key().as_ref();
13774        assert!(UnparsedPublicKey::new(&ED25519, public)
13775            .verify(accepted.as_bytes(), &signature)
13776            .is_ok());
13777        assert!(
13778            UnparsedPublicKey::new(&ED25519, public)
13779                .verify(replayed.as_bytes(), &signature)
13780                .is_err(),
13781            "a proof captured at hub A must not authenticate at hub B"
13782        );
13783    }
13784
13785    #[test]
13786    fn explicit_brain_id_rejects_a_substituted_card_before_feed_trust() {
13787        let other = "01j5qc3v9k4ym8rwbn2tqe6f7e";
13788        let card = json!({
13789            "id": other,
13790            "headSeq": 0,
13791            "identity": signed_remote_fixture().identity,
13792        })
13793        .to_string();
13794        let (hub, server) = scripted_json_hub(vec![(200, card)]);
13795        let state = tempfile::tempdir().unwrap();
13796        let cfg = test_hub_config(hub, state.path().to_path_buf());
13797        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
13798        assert!(
13799            error.contains("differs from the explicitly requested"),
13800            "{error}"
13801        );
13802        server.join().unwrap();
13803    }
13804
13805    #[test]
13806    fn empty_brain_identity_is_pinned_and_cannot_be_substituted_later() {
13807        let first = signed_remote_fixture().identity;
13808        let second = signed_remote_fixture().identity;
13809        let card = |identity: FeedIdentity| {
13810            json!({
13811                "id": TEST_BRAIN_ID,
13812                "headSeq": 0,
13813                "identity": identity,
13814            })
13815            .to_string()
13816        };
13817        let (hub, server) = scripted_json_hub(vec![(200, card(first)), (200, card(second))]);
13818        let state = tempfile::tempdir().unwrap();
13819        let cfg = test_hub_config(hub, state.path().to_path_buf());
13820        assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
13821        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
13822        assert!(
13823            error.contains("pinned anchor") || error.contains("forked away"),
13824            "{error}"
13825        );
13826        server.join().unwrap();
13827    }
13828
13829    #[test]
13830    fn empty_head_rejects_a_rotation_that_commits_to_hidden_history() {
13831        let old = signed_remote_fixture();
13832        let new = signed_remote_fixture();
13833        let old_pair = ring::signature::Ed25519KeyPair::from_pkcs8(&old.key.pkcs8).unwrap();
13834        let unsigned = serde_json::to_string(&UnsignedRotation {
13835            v: 1,
13836            op: "rotate",
13837            brain: &old.key.multikey,
13838            public_key: &old.key.public_key_spki,
13839            new_brain: &new.key.multikey,
13840            new_public_key: &new.key.public_key_spki,
13841            prior_head_seq: 1,
13842            prior_feed_hash: Some(&"a".repeat(64)),
13843            ts: "2026-07-30T12:00:00.000Z".to_string(),
13844        })
13845        .unwrap();
13846        let signature = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
13847        let rotation = format!(
13848            "{},\"sig\":\"{}\"}}",
13849            &unsigned[..unsigned.len() - 1],
13850            signature
13851        );
13852        let identity = FeedIdentity {
13853            fingerprint: new.key.multikey.trim_start_matches("ed25519:").to_string(),
13854            public_key_spki: new.key.public_key_spki,
13855            previous: vec![PreviousIdentity {
13856                fingerprint: old.key.multikey.trim_start_matches("ed25519:").to_string(),
13857                public_key_spki: old.key.public_key_spki,
13858            }],
13859            rotations: vec![rotation],
13860        };
13861        let card = json!({
13862            "id": TEST_BRAIN_ID,
13863            "headSeq": 0,
13864            "feedHash": null,
13865            "identity": identity,
13866        })
13867        .to_string();
13868        let (hub, server) = scripted_json_hub(vec![(200, card)]);
13869        let state = tempfile::tempdir().unwrap();
13870        let cfg = test_hub_config(hub, state.path().to_path_buf());
13871        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
13872        assert!(
13873            error.contains("rotation claims a feed boundary beyond the advertised head"),
13874            "{error}"
13875        );
13876        assert!(
13877            load_trust(&cfg, TEST_BRAIN_ID).unwrap().is_none(),
13878            "an inconsistent empty-head identity must not become the TOFU checkpoint"
13879        );
13880        server.join().unwrap();
13881    }
13882
13883    #[test]
13884    fn trust_checkpoint_rejects_a_later_fork() {
13885        let fixture = signed_remote_fixture();
13886        let mut fork: Value = serde_json::from_str(&fixture.card).unwrap();
13887        fork["feedHash"] = Value::String("b".repeat(64));
13888        let (hub, server) = scripted_json_hub(vec![
13889            (200, fixture.card),
13890            (200, fixture.feed),
13891            (200, fork.to_string()),
13892        ]);
13893        let state = tempfile::tempdir().unwrap();
13894        let cfg = test_hub_config(hub, state.path().to_path_buf());
13895        assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
13896        assert!(head(&cfg, TEST_BRAIN_ID).is_err());
13897        server.join().unwrap();
13898    }
13899
13900    #[test]
13901    fn alias_and_canonical_id_share_one_identity_checkpoint() {
13902        let trusted = signed_remote_fixture();
13903        let attacker = signed_remote_fixture();
13904        let (hub, server) = scripted_json_hub(vec![
13905            (200, trusted.card),
13906            (200, trusted.feed),
13907            (200, attacker.card),
13908        ]);
13909        let state = tempfile::tempdir().unwrap();
13910        let cfg = test_hub_config(hub, state.path().to_path_buf());
13911        assert!(head(&cfg, "trusted-slug").unwrap().verified);
13912        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
13913        assert!(
13914            error.contains("equivocation")
13915                || error.contains("pinned")
13916                || error.contains("identity"),
13917            "{error}"
13918        );
13919        server.join().unwrap();
13920    }
13921
13922    #[test]
13923    fn moved_alias_requires_exact_explicit_rebind_without_rewriting_history() {
13924        let state = tempfile::tempdir().unwrap();
13925        let cfg = test_hub_config(
13926            "https://hub.example".to_string(),
13927            state.path().to_path_buf(),
13928        );
13929        let directory = open_trust_dir(&cfg).unwrap();
13930        let old = TEST_BRAIN_ID;
13931        let new = "01j5qc3v9k4ym8rwbn2tqe6f7e";
13932        save_alias_in(
13933            &cfg,
13934            &directory,
13935            &AliasBinding {
13936                v: 1,
13937                origin: normalized_origin(&cfg.hub).unwrap(),
13938                requested: "company-brain".to_string(),
13939                brain: old.to_string(),
13940                home: Some("company-brain".to_string()),
13941            },
13942        )
13943        .unwrap();
13944
13945        let error = load_canonical_pin(&cfg, &directory, "company-brain", new).unwrap_err();
13946        assert!(matches!(
13947            error,
13948            LinkError::AliasRebindRequired {
13949                alias,
13950                from,
13951                to
13952            } if alias == "company-brain" && from == old && to == new
13953        ));
13954        let unchanged = load_alias_in(&cfg, &directory, "company-brain")
13955            .unwrap()
13956            .unwrap();
13957        assert_eq!(unchanged.brain, old);
13958        assert_eq!(unchanged.home.as_deref(), Some("company-brain"));
13959    }
13960
13961    #[test]
13962    fn concurrent_aliases_cannot_establish_conflicting_tofu_pins() {
13963        let alpha = signed_remote_fixture();
13964        let beta = signed_remote_fixture();
13965        let alpha_card = alpha.card.clone();
13966        let alpha_feed = alpha.feed.clone();
13967        let beta_card = beta.card.clone();
13968        let beta_feed = beta.feed.clone();
13969        let (hub, server) = routed_json_hub(3, move |path| {
13970            if path.contains("/alpha/feed?") {
13971                (200, alpha_feed.clone())
13972            } else if path.contains("/beta/feed?") {
13973                (200, beta_feed.clone())
13974            } else if path.ends_with("/alpha") {
13975                (200, alpha_card.clone())
13976            } else if path.ends_with("/beta") {
13977                (200, beta_card.clone())
13978            } else {
13979                (500, r#"{"error":"unexpected path"}"#.to_string())
13980            }
13981        });
13982        let state = tempfile::tempdir().unwrap();
13983        let cfg = test_hub_config(hub, state.path().to_path_buf());
13984        let alpha_cfg = cfg.clone();
13985        let beta_cfg = cfg;
13986        let first = std::thread::spawn(move || head(&alpha_cfg, "alpha"));
13987        let second = std::thread::spawn(move || head(&beta_cfg, "beta"));
13988        let results = [first.join().unwrap(), second.join().unwrap()];
13989        assert_eq!(
13990            results.iter().filter(|result| result.is_ok()).count(),
13991            1,
13992            "only one alias identity may establish canonical TOFU: {results:?}"
13993        );
13994        assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
13995        server.join().unwrap();
13996    }
13997
13998    #[cfg(unix)]
13999    #[test]
14000    fn trust_transaction_survives_an_ancestor_swap_without_writing_outside() {
14001        use std::os::unix::fs::symlink;
14002
14003        let fixture = signed_remote_fixture();
14004        let card = json!({
14005            "id": TEST_BRAIN_ID,
14006            "headSeq": 0,
14007            "feedHash": Value::Null,
14008            "identity": fixture.identity,
14009        })
14010        .to_string();
14011        let work = tempfile::tempdir().unwrap();
14012        let outside = tempfile::tempdir().unwrap();
14013        let state = work.path().join("state");
14014        let moved = work.path().join("state-held");
14015        let swap_state = state.clone();
14016        let swap_moved = moved.clone();
14017        let outside_path = outside.path().to_path_buf();
14018        let (hub, server) = routed_json_hub(1, move |_| {
14019            // The client has already opened state/trust before this response.
14020            std::fs::rename(&swap_state, &swap_moved).unwrap();
14021            symlink(&outside_path, &swap_state).unwrap();
14022            (200, card.clone())
14023        });
14024        let cfg = test_hub_config(hub, state);
14025
14026        let verified = verified_remote_head(&cfg, TEST_BRAIN_ID, false).unwrap();
14027        assert_eq!(verified.head.seq, 0);
14028        assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
14029        assert!(std::fs::read_dir(moved.join("trust"))
14030            .unwrap()
14031            .flatten()
14032            .any(|entry| entry.path().extension().is_some_and(|ext| ext == "json")));
14033        server.join().unwrap();
14034    }
14035
14036    #[test]
14037    fn self_custody_push_refuses_an_unrelated_key_before_requesting_an_upload() {
14038        let remote = signed_remote_fixture();
14039        let unrelated = signed_remote_fixture().key;
14040        let (hub, server) = scripted_json_hub(vec![(200, remote.card), (200, remote.feed)]);
14041        let state = tempfile::tempdir().unwrap();
14042        let mut cfg = test_hub_config(hub, state.path().to_path_buf());
14043        cfg.brain_key = Some(unrelated);
14044        let error = sync_push(
14045            &cfg,
14046            TEST_BRAIN_ID,
14047            &[("DB.md".to_string(), "signed local content".to_string())],
14048        )
14049        .unwrap_err()
14050        .to_string();
14051        assert!(
14052            error.contains("not the verified current brain identity"),
14053            "{error}"
14054        );
14055        server.join().unwrap();
14056    }
14057
14058    #[test]
14059    fn rotation_ignores_a_forged_2xx_body_and_requires_verified_postcondition() {
14060        let remote = signed_remote_fixture();
14061        let new = signed_remote_fixture().key;
14062        let state = tempfile::tempdir().unwrap();
14063        let new_file = state.path().join("new.key");
14064        std::fs::write(
14065            &new_file,
14066            format!("{}\n", URL_SAFE_NO_PAD.encode(&new.pkcs8)),
14067        )
14068        .unwrap();
14069        #[cfg(unix)]
14070        {
14071            use std::os::unix::fs::PermissionsExt as _;
14072            std::fs::set_permissions(&new_file, std::fs::Permissions::from_mode(0o600)).unwrap();
14073        }
14074        let forged = json!({
14075            "brain": TEST_BRAIN_ID,
14076            "identity": {
14077                "fingerprint": new.multikey.trim_start_matches("ed25519:"),
14078                "publicKeySpki": new.public_key_spki,
14079            }
14080        })
14081        .to_string();
14082        let (hub, server) = scripted_json_hub(vec![
14083            (200, remote.card.clone()),
14084            (200, remote.feed.clone()),
14085            (200, forged),
14086            (200, remote.card),
14087            (200, remote.feed),
14088        ]);
14089        let cfg = test_hub_config(hub, state.path().to_path_buf());
14090        let error = rotate_brain_key(&cfg, TEST_BRAIN_ID, &remote.key, &new_file)
14091            .unwrap_err()
14092            .to_string();
14093        assert!(
14094            error.contains("without committing the verified new identity"),
14095            "{error}"
14096        );
14097        server.join().unwrap();
14098    }
14099
14100    #[test]
14101    fn resolve_record_is_derived_from_signed_pack_not_a_query_response() {
14102        let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
14103        let raw = format!(
14104            "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
14105        );
14106        let pack = build_store_pack(&[
14107            (
14108                "DB.md".to_string(),
14109                "---\ntype: db-md\nscope: company\nowner: Test\n---\n".to_string(),
14110            ),
14111            ("records/clients/truth.md".to_string(), raw.clone()),
14112        ])
14113        .unwrap();
14114        let by_id = resolve_from_verified_pack(
14115            "01j5qc3v9k4ym8rwbn2tqe6f7d",
14116            &AddressTarget::Id(record_id.to_string()),
14117            pack.clone(),
14118        )
14119        .unwrap();
14120        assert_eq!(by_id["document"]["summary"], "Signed truth");
14121        assert_eq!(by_id["document"]["body"], "# Signed truth\n");
14122        assert_eq!(
14123            by_id["document"]["contentSha"],
14124            content_sha256(raw.as_bytes())
14125        );
14126
14127        let by_path = resolve_from_verified_pack(
14128            "01j5qc3v9k4ym8rwbn2tqe6f7d",
14129            &AddressTarget::Path("records/clients/truth.md".to_string()),
14130            pack,
14131        )
14132        .unwrap();
14133        assert_eq!(by_path["document"]["id"], record_id);
14134        assert_eq!(by_path["document"]["path"], "records/clients/truth.md");
14135    }
14136
14137    #[test]
14138    fn canonical_store_pack_matches_the_cross_language_zip32_golden() {
14139        let unsorted = vec![
14140            ("records/a.md".to_string(), "alpha\n".to_string()),
14141            ("DB.md".to_string(), "# db\n".to_string()),
14142        ];
14143        let sorted = vec![
14144            ("DB.md".to_string(), "# db\n".to_string()),
14145            ("records/a.md".to_string(), "alpha\n".to_string()),
14146        ];
14147        let pack = build_store_pack(&unsorted).unwrap();
14148
14149        // This digest is shared with the hub implementation. It locks every
14150        // byte of the wire profile: raw UTF-8 order, STORED payloads, fixed DOS
14151        // epoch, explicit CRC/sizes, Unix regular-0600 attributes, and the
14152        // absence of descriptors/extras/comments/ZIP64.
14153        assert_eq!(pack.len(), 219);
14154        assert_eq!(
14155            content_sha256(&pack),
14156            "972fb2045becaa21588baaf4b349e62a430687fa2c21167b53f4ca0efa6c9408"
14157        );
14158        assert_eq!(pack, build_store_pack(&sorted).unwrap());
14159        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x07\x08"));
14160        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x06"));
14161        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x07"));
14162
14163        assert_eq!(
14164            parse_store_pack(pack).unwrap(),
14165            vec![
14166                ("DB.md".to_string(), b"# db\n".to_vec()),
14167                ("records/a.md".to_string(), b"alpha\n".to_vec()),
14168            ]
14169        );
14170    }
14171
14172    #[test]
14173    fn canonical_store_pack_validates_every_path_before_writing() {
14174        let duplicate = vec![
14175            ("DB.md".to_string(), "first".to_string()),
14176            ("DB.md".to_string(), "second".to_string()),
14177        ];
14178        assert!(build_store_pack(&duplicate)
14179            .unwrap_err()
14180            .to_string()
14181            .contains("duplicate path"));
14182        assert!(matches!(
14183            build_store_pack(&[("../escape.md".to_string(), "no".to_string())]),
14184            Err(LinkError::UnsafePath { .. })
14185        ));
14186    }
14187
14188    #[test]
14189    fn zip64_preflight_rejects_an_entry_count_bomb_before_zip_parsing() {
14190        const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
14191        // One byte stands in for the central directory. The trailer itself is
14192        // structurally valid; the count is the sole reason for refusal.
14193        let mut bytes = vec![0_u8];
14194        let zip64_offset = bytes.len() as u64;
14195        bytes.extend_from_slice(b"PK\x06\x06");
14196        bytes.extend_from_slice(&44_u64.to_le_bytes());
14197        bytes.extend_from_slice(&[0_u8; 12]);
14198        bytes.extend_from_slice(&COUNT.to_le_bytes());
14199        bytes.extend_from_slice(&COUNT.to_le_bytes());
14200        bytes.extend_from_slice(&1_u64.to_le_bytes());
14201        bytes.extend_from_slice(&0_u64.to_le_bytes());
14202        bytes.extend_from_slice(b"PK\x06\x07");
14203        bytes.extend_from_slice(&0_u32.to_le_bytes());
14204        bytes.extend_from_slice(&zip64_offset.to_le_bytes());
14205        bytes.extend_from_slice(&1_u32.to_le_bytes());
14206        bytes.extend_from_slice(b"PK\x05\x06");
14207        bytes.extend_from_slice(&0_u16.to_le_bytes());
14208        bytes.extend_from_slice(&0_u16.to_le_bytes());
14209        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
14210        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
14211        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
14212        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
14213        bytes.extend_from_slice(&0_u16.to_le_bytes());
14214
14215        let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
14216            .unwrap_err()
14217            .to_string();
14218        assert!(error.contains("invalid file count"), "{error}");
14219    }
14220
14221    #[test]
14222    fn zip_preflight_rejects_a_fake_trailing_eocd_before_zip_parsing() {
14223        const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
14224        let mut bytes = vec![0_u8];
14225        let zip64_offset = bytes.len() as u64;
14226        bytes.extend_from_slice(b"PK\x06\x06");
14227        bytes.extend_from_slice(&44_u64.to_le_bytes());
14228        bytes.extend_from_slice(&[0_u8; 12]);
14229        bytes.extend_from_slice(&COUNT.to_le_bytes());
14230        bytes.extend_from_slice(&COUNT.to_le_bytes());
14231        bytes.extend_from_slice(&1_u64.to_le_bytes());
14232        bytes.extend_from_slice(&0_u64.to_le_bytes());
14233        bytes.extend_from_slice(b"PK\x06\x07");
14234        bytes.extend_from_slice(&0_u32.to_le_bytes());
14235        bytes.extend_from_slice(&zip64_offset.to_le_bytes());
14236        bytes.extend_from_slice(&1_u32.to_le_bytes());
14237        bytes.extend_from_slice(b"PK\x05\x06");
14238        bytes.extend_from_slice(&0_u16.to_le_bytes());
14239        bytes.extend_from_slice(&0_u16.to_le_bytes());
14240        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
14241        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
14242        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
14243        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
14244        bytes.extend_from_slice(&0_u16.to_le_bytes());
14245        // The old parser trusted this last low-count signature, while
14246        // ZipArchive fell back to the real Zip64 directory and allocated for
14247        // COUNT entries. Its central-directory offsets are deliberately fake.
14248        let fake_eocd = bytes.len() as u32;
14249        bytes.extend_from_slice(b"PK\x05\x06");
14250        bytes.extend_from_slice(&0_u16.to_le_bytes());
14251        bytes.extend_from_slice(&0_u16.to_le_bytes());
14252        bytes.extend_from_slice(&1_u16.to_le_bytes());
14253        bytes.extend_from_slice(&1_u16.to_le_bytes());
14254        bytes.extend_from_slice(&0_u32.to_le_bytes());
14255        bytes.extend_from_slice(&fake_eocd.to_le_bytes());
14256        bytes.extend_from_slice(&0_u16.to_le_bytes());
14257
14258        let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
14259            .unwrap_err()
14260            .to_string();
14261        assert!(error.contains("central directory"), "{error}");
14262    }
14263
14264    #[test]
14265    fn strict_http_status_handling_rejects_redirects_without_panicking() {
14266        let error = ensure_ok(
14267            HubResponse {
14268                status: 302,
14269                body: Some(json!({"redirect": "/elsewhere"})),
14270            },
14271            "mutation",
14272        )
14273        .unwrap_err();
14274        assert!(matches!(error, LinkError::Http { status: 302, .. }));
14275
14276        let error = ensure_raw_ok(
14277            RawHubResponse {
14278                status: 302,
14279                body: br#"{"redirect":"/elsewhere"}"#.to_vec(),
14280            },
14281            "feed",
14282        )
14283        .unwrap_err();
14284        assert!(matches!(error, LinkError::Http { status: 302, .. }));
14285    }
14286
14287    #[cfg(unix)]
14288    #[test]
14289    fn collect_push_files_refuses_external_symlink_and_nested_store() {
14290        use std::os::unix::fs::symlink;
14291
14292        let root = tempfile::tempdir().unwrap();
14293        std::fs::write(
14294            root.path().join("DB.md"),
14295            "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
14296        )
14297        .unwrap();
14298        std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
14299
14300        let external = tempfile::tempdir().unwrap();
14301        let secret = external.path().join("secret.md");
14302        std::fs::write(&secret, "TOP SECRET").unwrap();
14303        symlink(&secret, root.path().join("records/notes/secret.md")).unwrap();
14304
14305        let store = Store::open_strict(root.path()).unwrap();
14306        let err = collect_push_files(&store).unwrap_err().to_string();
14307        assert!(err.contains("cannot push"), "{err}");
14308        assert!(
14309            !err.contains("TOP SECRET"),
14310            "external bytes must never leak"
14311        );
14312
14313        std::fs::remove_file(root.path().join("records/notes/secret.md")).unwrap();
14314        let nested = root.path().join("records/nested");
14315        std::fs::create_dir_all(&nested).unwrap();
14316        std::fs::write(
14317            nested.join("DB.md"),
14318            "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
14319        )
14320        .unwrap();
14321        let err = collect_push_files(&store).unwrap_err().to_string();
14322        assert!(err.contains("nested db.md store"), "{err}");
14323    }
14324
14325    #[cfg(unix)]
14326    #[test]
14327    fn remote_push_uses_opened_root_after_path_replacement() {
14328        use std::os::unix::fs::symlink;
14329
14330        let sandbox = tempfile::tempdir().unwrap();
14331        let root = sandbox.path().join("store");
14332        std::fs::create_dir_all(root.join("records/notes")).unwrap();
14333        std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
14334        std::fs::write(
14335            root.join("records/notes/owned.md"),
14336            "---\ntype: note\nsummary: owned\n---\nowned upload\n",
14337        )
14338        .unwrap();
14339        let store = Store::open_strict(&root).unwrap();
14340        let detached = sandbox.path().join("detached");
14341        std::fs::rename(&root, &detached).unwrap();
14342
14343        let replacement = sandbox.path().join("replacement");
14344        std::fs::create_dir_all(replacement.join("records/notes")).unwrap();
14345        std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
14346        std::fs::write(
14347            replacement.join("records/notes/secret.md"),
14348            "---\ntype: note\nsummary: secret\n---\nreplacement sentinel\n",
14349        )
14350        .unwrap();
14351        symlink(&replacement, &root).unwrap();
14352
14353        let files = collect_push_files(&store).unwrap();
14354        let wire_text = files
14355            .iter()
14356            .map(|(path, content)| format!("{path}\n{content}"))
14357            .collect::<Vec<_>>()
14358            .join("\n");
14359        assert!(wire_text.contains("owned upload"));
14360        assert!(!wire_text.contains("replacement sentinel"));
14361        assert!(!wire_text.contains("records/notes/secret.md"));
14362
14363        let remote = signed_remote_fixture();
14364        let (hub, server) = scripted_json_hub(vec![
14365            (200, remote.card),
14366            (200, remote.feed),
14367            (200, json!({"ok": true}).to_string()),
14368        ]);
14369        let state = tempfile::tempdir().unwrap();
14370        let cfg = test_hub_config(hub, state.path().to_path_buf());
14371        let pushed = sync_push(&cfg, TEST_BRAIN_ID, &files).unwrap();
14372        assert_eq!(pushed, json!({"ok": true}));
14373        server.join().unwrap();
14374    }
14375
14376    #[test]
14377    fn signed_feed_item_verifies_identity_hash_and_signature() {
14378        use ring::rand::SystemRandom;
14379        use ring::signature::{Ed25519KeyPair, KeyPair};
14380
14381        const PREFIX: &[u8] = &[
14382            0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
14383        ];
14384        let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
14385        let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
14386        let mut spki = PREFIX.to_vec();
14387        spki.extend_from_slice(pair.public_key().as_ref());
14388        let public_key = URL_SAFE_NO_PAD.encode(&spki);
14389        let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
14390        let mut entry = FeedEntry {
14391            v: 1,
14392            seq: 1,
14393            ts: "2026-07-14T00:00:00.000Z".to_string(),
14394            brain: format!("ed25519:{fingerprint}"),
14395            public_key: public_key.clone(),
14396            kind: "push".to_string(),
14397            op: "snapshot".to_string(),
14398            pack_sha256: "a".repeat(64),
14399            files: vec![FeedFile {
14400                path: "DB.md".to_string(),
14401                sha256: "b".repeat(64),
14402                bytes: 3,
14403            }],
14404            removed: vec![],
14405            prev_entry_hash: None,
14406            sig: String::new(),
14407        };
14408        let unsigned = UnsignedFeedEntry {
14409            v: entry.v,
14410            seq: entry.seq,
14411            ts: &entry.ts,
14412            brain: &entry.brain,
14413            public_key: &entry.public_key,
14414            kind: &entry.kind,
14415            op: &entry.op,
14416            pack_sha256: &entry.pack_sha256,
14417            files: &entry.files,
14418            removed: &entry.removed,
14419            prev_entry_hash: &entry.prev_entry_hash,
14420        };
14421        entry.sig =
14422            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
14423        let mut exact = serde_json::to_vec(&entry).unwrap();
14424        exact.push(b'\n');
14425        let item = FeedItem {
14426            hash: format!("{:x}", Sha256::digest(&exact)),
14427            entry,
14428        };
14429        let identity = FeedIdentity {
14430            fingerprint,
14431            public_key_spki: public_key,
14432            previous: Vec::new(),
14433            rotations: Vec::new(),
14434        };
14435        assert!(verify_feed_item(&item, &identity).is_ok());
14436        let mut tampered = item;
14437        tampered.entry.pack_sha256 = "c".repeat(64);
14438        assert!(verify_feed_item(&tampered, &identity).is_err());
14439    }
14440
14441    #[test]
14442    fn v2_commit_requires_exact_fields_and_a_valid_genesis_bridge() {
14443        let rng = ring::rand::SystemRandom::new();
14444        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
14445        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
14446        let (spki, multikey) = public_identity_for(&pair);
14447        let identity = V2HeadIdentity {
14448            custody: "self".to_string(),
14449            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
14450            public_key_spki: spki.clone(),
14451            previous: Vec::new(),
14452            rotations: Vec::new(),
14453        };
14454        let unsigned = json!({
14455            "actor_ref": "a".repeat(64),
14456            "asset_root": Value::Null,
14457            "brain": multikey,
14458            "changes_sha256": "b".repeat(64),
14459            "control_revision": "c".repeat(64),
14460            "materializer": "dbmd-projection-v1",
14461            "op": "changeset",
14462            "parent_asset_root": Value::Null,
14463            "parent_commit": Value::Null,
14464            "parent_root": Value::Null,
14465            "prev_entry_hash": Value::Null,
14466            "public_key": spki,
14467            "seq": 1,
14468            "signer_epoch": 1,
14469            "state_root": "d".repeat(64),
14470            "ts": "2026-08-19T12:00:00.000Z",
14471            "v": 2,
14472            "v1_bridge": {
14473                "feed_hash": "e".repeat(64),
14474                "head_seq": 7,
14475                "pack_sha256": "f".repeat(64),
14476            },
14477        });
14478        let sign_value = |value: Value| {
14479            let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
14480            let mut object = value.as_object().unwrap().clone();
14481            object.insert(
14482                "sig".to_string(),
14483                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
14484            );
14485            crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
14486        };
14487        assert!(verified_v2_commit_object(&sign_value(unsigned.clone()), &identity).is_ok());
14488
14489        let mut extra = unsigned.clone();
14490        extra
14491            .as_object_mut()
14492            .unwrap()
14493            .insert("future".to_string(), Value::Bool(true));
14494        assert!(verified_v2_commit_object(&sign_value(extra), &identity).is_err());
14495
14496        let mut missing = unsigned.clone();
14497        missing.as_object_mut().unwrap().remove("v1_bridge");
14498        assert!(verified_v2_commit_object(&sign_value(missing), &identity).is_err());
14499
14500        let mut invalid_bridge = unsigned;
14501        invalid_bridge.as_object_mut().unwrap().insert(
14502            "v1_bridge".to_string(),
14503            json!({"feed_hash": "e".repeat(64), "head_seq": 0, "pack_sha256": "f".repeat(64)}),
14504        );
14505        assert!(verified_v2_commit_object(&sign_value(invalid_bridge), &identity).is_err());
14506    }
14507
14508    #[test]
14509    fn shared_v2_commit_bridge_vector_matches_the_typescript_signer() {
14510        let vector: Value = serde_json::from_str(include_str!(
14511            "../tests/vectors/linkmd-v2-commit-bridge.json"
14512        ))
14513        .unwrap();
14514        let identity_value = vector.get("identity").unwrap();
14515        let identity = V2HeadIdentity {
14516            custody: "self".to_string(),
14517            fingerprint: identity_value
14518                .get("fingerprint")
14519                .and_then(Value::as_str)
14520                .unwrap()
14521                .to_string(),
14522            public_key_spki: identity_value
14523                .get("public_key_spki")
14524                .and_then(Value::as_str)
14525                .unwrap()
14526                .to_string(),
14527            previous: Vec::new(),
14528            rotations: Vec::new(),
14529        };
14530        let private = URL_SAFE_NO_PAD
14531            .decode(
14532                identity_value
14533                    .get("private_key_pkcs8")
14534                    .and_then(Value::as_str)
14535                    .unwrap(),
14536            )
14537            .unwrap();
14538        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&private)
14539            .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(&private))
14540            .unwrap();
14541        let base = vector.get("body").unwrap().as_object().unwrap();
14542
14543        for item in vector.get("valid").unwrap().as_array().unwrap() {
14544            let mut body = base.clone();
14545            body.insert(
14546                "v1_bridge".to_string(),
14547                item.get("v1_bridge").unwrap().clone(),
14548            );
14549            body.insert(
14550                "sig".to_string(),
14551                item.get("signature_base64url").unwrap().clone(),
14552            );
14553            let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
14554            assert!(verified_v2_commit_object(&signed, &identity).is_ok());
14555            assert_eq!(
14556                crate::linkmd_v2::domain_hash_bytes("v2/commit", &signed).unwrap(),
14557                item.get("commit_hash").and_then(Value::as_str).unwrap()
14558            );
14559            assert_eq!(
14560                format!("{:x}", Sha256::digest(&signed)),
14561                item.get("feed_hash").and_then(Value::as_str).unwrap()
14562            );
14563        }
14564
14565        for item in vector.get("invalid").unwrap().as_array().unwrap() {
14566            let mut body = base.clone();
14567            if let Some(remove) = item.get("remove").and_then(Value::as_array) {
14568                for field in remove {
14569                    body.remove(field.as_str().unwrap());
14570                }
14571            }
14572            if let Some(set) = item.get("set").and_then(Value::as_object) {
14573                for (field, value) in set {
14574                    body.insert(field.clone(), value.clone());
14575                }
14576            }
14577            let message = crate::linkmd_v2::canonical_bytes(&Value::Object(body.clone())).unwrap();
14578            body.insert(
14579                "sig".to_string(),
14580                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
14581            );
14582            let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
14583            assert!(
14584                verified_v2_commit_object(&signed, &identity).is_err(),
14585                "accepted invalid shared vector {}",
14586                item.get("reason").and_then(Value::as_str).unwrap()
14587            );
14588        }
14589    }
14590
14591    #[test]
14592    fn shared_v2_kept_home_changeset_vector_matches_the_typescript_hub() {
14593        let vector: Value = serde_json::from_str(include_str!(
14594            "../tests/vectors/linkmd-v2-changeset-withheld.json"
14595        ))
14596        .unwrap();
14597        assert_eq!(
14598            vector.get("profile").and_then(Value::as_str),
14599            Some("link.md-v2-changeset-withheld")
14600        );
14601        let canonical =
14602            crate::linkmd_v2::canonical_bytes(vector.get("changeset").unwrap()).unwrap();
14603        let expected = STANDARD
14604            .decode(
14605                vector
14606                    .get("canonical_base64")
14607                    .and_then(Value::as_str)
14608                    .unwrap(),
14609            )
14610            .unwrap();
14611        assert_eq!(canonical, expected);
14612        assert_eq!(
14613            crate::linkmd_v2::domain_hash_bytes("v2/changeset", &canonical).unwrap(),
14614            vector.get("domain_hash").and_then(Value::as_str).unwrap()
14615        );
14616    }
14617
14618    #[test]
14619    fn v2_profile_transition_reverifies_the_exact_signed_v1_bridge() {
14620        let remote = signed_remote_fixture();
14621        let legacy: FeedResponse = serde_json::from_str(&remote.feed).unwrap();
14622        let legacy_item = legacy.entries.first().unwrap();
14623        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&remote.key.pkcs8).unwrap();
14624        let body = json!({
14625            "actor_ref": "a".repeat(64),
14626            "asset_root": Value::Null,
14627            "brain": remote.key.multikey,
14628            "changes_sha256": "b".repeat(64),
14629            "control_revision": "c".repeat(64),
14630            "materializer": "dbmd-projection-v1",
14631            "op": "changeset",
14632            "parent_asset_root": Value::Null,
14633            "parent_commit": Value::Null,
14634            "parent_root": Value::Null,
14635            "prev_entry_hash": Value::Null,
14636            "public_key": remote.key.public_key_spki,
14637            "seq": 1,
14638            "signer_epoch": 1,
14639            "state_root": "d".repeat(64),
14640            "ts": "2026-08-19T12:00:00.000Z",
14641            "v": 2,
14642            "v1_bridge": {
14643                "feed_hash": legacy_item.hash,
14644                "head_seq": legacy_item.entry.seq,
14645                "pack_sha256": legacy_item.entry.pack_sha256,
14646            },
14647        });
14648        let message = crate::linkmd_v2::canonical_bytes(&body).unwrap();
14649        let mut signed = body.as_object().unwrap().clone();
14650        signed.insert(
14651            "sig".to_string(),
14652            Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
14653        );
14654        let raw = crate::linkmd_v2::canonical_bytes(&Value::Object(signed)).unwrap();
14655        let commit_hash = crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw).unwrap();
14656        let feed_hash = content_sha256(&raw);
14657        let pointer = V2PointerBody {
14658            v: 2,
14659            brain: TEST_BRAIN_ID.to_string(),
14660            seq: 1,
14661            commit_hash: commit_hash.clone(),
14662            feed_hash: feed_hash.clone(),
14663            content_root: Some("d".repeat(64)),
14664            asset_root: None,
14665            materializer: "dbmd-projection-v1".to_string(),
14666            signer_epoch: 1,
14667            control_revision: "c".repeat(64),
14668            backup_preparation: "e".repeat(64),
14669            prior_pointer_hash: None,
14670            signed_at: "2026-08-19T12:00:00.000Z".to_string(),
14671        };
14672        let v2_page = json!({
14673            "v": 2,
14674            "head_seq": 1,
14675            "head_commit_hash": commit_hash,
14676            "head_feed_hash": feed_hash,
14677            "entries": [{
14678                "seq": 1,
14679                "commit_hash": pointer.commit_hash,
14680                "feed_hash": pointer.feed_hash,
14681                "bytes_base64": STANDARD.encode(&raw),
14682            }],
14683            "next_after": 1,
14684            "complete": true,
14685        })
14686        .to_string();
14687        let identity = V2HeadIdentity {
14688            custody: "self".to_string(),
14689            fingerprint: remote.identity.fingerprint.clone(),
14690            public_key_spki: remote.identity.public_key_spki.clone(),
14691            previous: Vec::new(),
14692            rotations: Vec::new(),
14693        };
14694        let checkpoint = TrustState {
14695            v: 2,
14696            origin: "unused".to_string(),
14697            requested: TEST_BRAIN_ID.to_string(),
14698            brain: TEST_BRAIN_ID.to_string(),
14699            home: None,
14700            anchor: remote.key.multikey.clone(),
14701            current: remote.key.multikey,
14702            head_seq: legacy_item.entry.seq,
14703            feed_hash: Some(legacy_item.hash.clone()),
14704            rotations: Vec::new(),
14705            hub_signer: None,
14706            protocol_profile: None,
14707        };
14708        let (hub, server) = scripted_json_hub(vec![(200, v2_page), (200, remote.feed)]);
14709        let state = tempfile::tempdir().unwrap();
14710        let cfg = test_hub_config(hub, state.path().to_path_buf());
14711        verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &checkpoint).unwrap();
14712        server.join().unwrap();
14713
14714        let mut wrong = checkpoint;
14715        wrong.feed_hash = Some("0".repeat(64));
14716        let (hub, server) = scripted_json_hub(vec![(
14717            200,
14718            json!({
14719                "v": 2,
14720                "head_seq": 1,
14721                "head_commit_hash": pointer.commit_hash,
14722                "head_feed_hash": pointer.feed_hash,
14723                "entries": [{
14724                    "seq": 1,
14725                    "commit_hash": pointer.commit_hash,
14726                    "feed_hash": pointer.feed_hash,
14727                    "bytes_base64": STANDARD.encode(&raw),
14728                }],
14729                "next_after": 1,
14730                "complete": true,
14731            })
14732            .to_string(),
14733        )]);
14734        let state = tempfile::tempdir().unwrap();
14735        let cfg = test_hub_config(hub, state.path().to_path_buf());
14736        assert!(verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &wrong,).is_err());
14737        server.join().unwrap();
14738    }
14739
14740    #[test]
14741    fn v2_commit_signer_epoch_follows_the_authenticated_rotation_boundary() {
14742        let rng = ring::rand::SystemRandom::new();
14743        let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
14744        let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
14745        let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
14746        let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
14747        let (old_spki, old_multikey) = public_identity_for(&old);
14748        let (new_spki, new_multikey) = public_identity_for(&new);
14749        let rotation_unsigned = serde_json::to_string(&UnsignedRotation {
14750            v: 1,
14751            op: "rotate",
14752            brain: &old_multikey,
14753            public_key: &old_spki,
14754            new_brain: &new_multikey,
14755            new_public_key: &new_spki,
14756            prior_head_seq: 1,
14757            prior_feed_hash: Some(&"9".repeat(64)),
14758            ts: "2026-08-19T12:01:00.000Z".to_string(),
14759        })
14760        .unwrap();
14761        let rotation_sig = URL_SAFE_NO_PAD.encode(old.sign(rotation_unsigned.as_bytes()).as_ref());
14762        let rotation = format!(
14763            "{},\"sig\":\"{}\"}}",
14764            &rotation_unsigned[..rotation_unsigned.len() - 1],
14765            rotation_sig
14766        );
14767        let identity = V2HeadIdentity {
14768            custody: "self".to_string(),
14769            fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
14770            public_key_spki: new_spki.clone(),
14771            previous: vec![V2PreviousIdentity {
14772                fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
14773                public_key_spki: old_spki.clone(),
14774            }],
14775            rotations: vec![rotation],
14776        };
14777        let commit = |seq: u64,
14778                      epoch: u64,
14779                      multikey: &str,
14780                      spki: &str,
14781                      pair: &ring::signature::Ed25519KeyPair| {
14782            let value = json!({
14783                "actor_ref": "a".repeat(64),
14784                "asset_root": Value::Null,
14785                "brain": multikey,
14786                "changes_sha256": "b".repeat(64),
14787                "control_revision": "c".repeat(64),
14788                "materializer": "dbmd-projection-v1",
14789                "op": "changeset",
14790                "parent_asset_root": Value::Null,
14791                "parent_commit": if seq == 1 { Value::Null } else { Value::String("d".repeat(64)) },
14792                "parent_root": if seq == 1 { Value::Null } else { Value::String("e".repeat(64)) },
14793                "prev_entry_hash": if seq == 1 { Value::Null } else { Value::String("f".repeat(64)) },
14794                "public_key": spki,
14795                "seq": seq,
14796                "signer_epoch": epoch,
14797                "state_root": "1".repeat(64),
14798                "ts": "2026-08-19T12:00:00.000Z",
14799                "v": 2,
14800                "v1_bridge": Value::Null,
14801            });
14802            let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
14803            let mut object = value.as_object().unwrap().clone();
14804            object.insert(
14805                "sig".to_string(),
14806                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
14807            );
14808            crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
14809        };
14810
14811        assert!(verified_v2_commit_object(
14812            &commit(1, 1, &old_multikey, &old_spki, &old),
14813            &identity,
14814        )
14815        .is_ok());
14816        assert!(verified_v2_commit_object(
14817            &commit(2, 2, &new_multikey, &new_spki, &new),
14818            &identity,
14819        )
14820        .is_ok());
14821        assert!(verified_v2_commit_object(
14822            &commit(2, 1, &old_multikey, &old_spki, &old),
14823            &identity,
14824        )
14825        .is_err());
14826        assert!(verified_v2_commit_object(
14827            &commit(1, 2, &new_multikey, &new_spki, &new),
14828            &identity,
14829        )
14830        .is_err());
14831    }
14832
14833    #[test]
14834    fn a_self_custody_entry_verifies_like_any_hub_entry() {
14835        let rng = ring::rand::SystemRandom::new();
14836        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
14837        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
14838        let (spki, multikey) = public_identity_for(&pair);
14839        let key = AgentSigningKey {
14840            pkcs8: pkcs8.as_ref().to_vec(),
14841            multikey: multikey.clone(),
14842            public_key_spki: spki.clone(),
14843        };
14844        let files = vec![WireFeedFile {
14845            path: "DB.md".to_string(),
14846            sha256: "a".repeat(64),
14847            bytes: 3,
14848        }];
14849        let raw = self_custody_entry(
14850            &key,
14851            1,
14852            "2026-07-23T12:00:00.000Z".to_string(),
14853            &"c".repeat(64),
14854            &files,
14855            None,
14856        )
14857        .unwrap();
14858        // The exact client serialization parses as a feed entry and passes the
14859        // SAME verifier every subscribe read runs — the self-custody path
14860        // produces first-class wire-profile-v1 entries.
14861        let entry: FeedEntry = serde_json::from_str(&raw).unwrap();
14862        let hash = format!("{:x}", Sha256::digest(format!("{raw}\n").as_bytes()));
14863        let item = FeedItem { hash, entry };
14864        let identity = FeedIdentity {
14865            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
14866            public_key_spki: spki,
14867            previous: Vec::new(),
14868            rotations: Vec::new(),
14869        };
14870        assert!(verify_feed_item(&item, &identity).is_ok());
14871    }
14872
14873    #[test]
14874    fn identity_rotation_requires_old_key_signature_and_preserves_the_pin() {
14875        let rng = ring::rand::SystemRandom::new();
14876        let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
14877        let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
14878        let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
14879        let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
14880        let (old_spki, old_multikey) = public_identity_for(&old);
14881        let (new_spki, new_multikey) = public_identity_for(&new);
14882        let unsigned = serde_json::to_string(&UnsignedRotation {
14883            v: 1,
14884            op: "rotate",
14885            brain: &old_multikey,
14886            public_key: &old_spki,
14887            new_brain: &new_multikey,
14888            new_public_key: &new_spki,
14889            prior_head_seq: 1,
14890            prior_feed_hash: Some(&"a".repeat(64)),
14891            ts: "2026-07-30T12:00:00.000Z".to_string(),
14892        })
14893        .unwrap();
14894        let signature = URL_SAFE_NO_PAD.encode(old.sign(unsigned.as_bytes()).as_ref());
14895        let rotation = format!(
14896            "{},\"sig\":\"{}\"}}",
14897            &unsigned[..unsigned.len() - 1],
14898            signature
14899        );
14900        let identity = FeedIdentity {
14901            fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
14902            public_key_spki: new_spki,
14903            previous: vec![PreviousIdentity {
14904                fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
14905                public_key_spki: old_spki,
14906            }],
14907            rotations: vec![rotation],
14908        };
14909        let pin = TrustState {
14910            v: 2,
14911            origin: "https://hub.example".to_string(),
14912            requested: "brain".to_string(),
14913            brain: "brain".to_string(),
14914            home: None,
14915            anchor: old_multikey.clone(),
14916            current: old_multikey.clone(),
14917            head_seq: 1,
14918            feed_hash: Some("a".repeat(64)),
14919            rotations: Vec::new(),
14920            hub_signer: None,
14921            protocol_profile: None,
14922        };
14923        assert_eq!(
14924            verify_identity_chain(&identity, Some(&pin)).unwrap(),
14925            old_multikey
14926        );
14927        let mut accepted = pin.clone();
14928        accepted.current = new_multikey.clone();
14929        accepted.rotations = identity.rotations.clone();
14930        let alternate_unsigned = serde_json::to_string(&UnsignedRotation {
14931            v: 1,
14932            op: "rotate",
14933            brain: &old_multikey,
14934            public_key: &identity.previous[0].public_key_spki,
14935            new_brain: &new_multikey,
14936            new_public_key: &identity.public_key_spki,
14937            prior_head_seq: 1,
14938            prior_feed_hash: Some(&"a".repeat(64)),
14939            ts: "2026-07-30T12:00:01.000Z".to_string(),
14940        })
14941        .unwrap();
14942        let alternate_signature =
14943            URL_SAFE_NO_PAD.encode(old.sign(alternate_unsigned.as_bytes()).as_ref());
14944        let mut rewritten = identity.clone();
14945        rewritten.rotations[0] = format!(
14946            "{},\"sig\":\"{}\"}}",
14947            &alternate_unsigned[..alternate_unsigned.len() - 1],
14948            alternate_signature
14949        );
14950        assert!(
14951            verify_identity_chain(&rewritten, Some(&accepted)).is_err(),
14952            "an alternate valid statement must not rewrite accepted history"
14953        );
14954
14955        let mut stale_entry = FeedEntry {
14956            v: 1,
14957            seq: 2,
14958            ts: "2026-07-30T12:01:00.000Z".to_string(),
14959            brain: pin.current.clone(),
14960            public_key: identity.previous[0].public_key_spki.clone(),
14961            kind: "push".to_string(),
14962            op: "snapshot".to_string(),
14963            pack_sha256: "b".repeat(64),
14964            files: Vec::new(),
14965            removed: Vec::new(),
14966            prev_entry_hash: pin.feed_hash.clone(),
14967            sig: String::new(),
14968        };
14969        let stale_unsigned = UnsignedFeedEntry {
14970            v: stale_entry.v,
14971            seq: stale_entry.seq,
14972            ts: &stale_entry.ts,
14973            brain: &stale_entry.brain,
14974            public_key: &stale_entry.public_key,
14975            kind: &stale_entry.kind,
14976            op: &stale_entry.op,
14977            pack_sha256: &stale_entry.pack_sha256,
14978            files: &stale_entry.files,
14979            removed: &stale_entry.removed,
14980            prev_entry_hash: &stale_entry.prev_entry_hash,
14981        };
14982        stale_entry.sig = URL_SAFE_NO_PAD.encode(
14983            old.sign(&serde_json::to_vec(&stale_unsigned).unwrap())
14984                .as_ref(),
14985        );
14986        let mut stale_exact = serde_json::to_vec(&stale_entry).unwrap();
14987        stale_exact.push(b'\n');
14988        let stale_item = FeedItem {
14989            hash: content_sha256(&stale_exact),
14990            entry: stale_entry,
14991        };
14992        assert!(
14993            reject_retired_signer_after_checkpoint(&identity, Some(&accepted), &stale_item)
14994                .is_err(),
14995            "a key retired before the checkpoint must never append after it"
14996        );
14997        assert!(
14998            verify_feed_item(&stale_item, &identity).is_err(),
14999            "an old key must never append after its signed rotation boundary"
15000        );
15001
15002        let mut missing = identity.clone();
15003        missing.rotations.clear();
15004        assert!(verify_identity_chain(&missing, Some(&pin)).is_err());
15005
15006        let mut tampered = identity;
15007        tampered.rotations[0] = tampered.rotations[0].replace("\"sig\":\"", "\"sig\":\"A");
15008        assert!(verify_identity_chain(&tampered, Some(&pin)).is_err());
15009    }
15010
15011    #[cfg(unix)]
15012    #[test]
15013    fn key_creation_refuses_a_planted_symlink_without_touching_its_target() {
15014        use std::os::unix::fs::symlink;
15015
15016        let dir = tempfile::tempdir().unwrap();
15017        let target = dir.path().join("valuable.txt");
15018        let planted = dir.path().join("agent.key");
15019        std::fs::write(&target, "do not overwrite").unwrap();
15020        symlink(&target, &planted).unwrap();
15021
15022        assert!(matches!(
15023            generate_agent_key(&planted),
15024            Err(LinkError::BadAgentKey { .. })
15025        ));
15026        assert_eq!(std::fs::read_to_string(target).unwrap(), "do not overwrite");
15027    }
15028
15029    #[cfg(unix)]
15030    #[test]
15031    fn key_creation_refuses_a_symlinked_parent_without_writing_through_it() {
15032        use std::os::unix::fs::symlink;
15033
15034        let root = tempfile::tempdir().unwrap();
15035        let outside = tempfile::tempdir().unwrap();
15036        symlink(outside.path(), root.path().join("redirect")).unwrap();
15037
15038        assert!(generate_agent_key(&root.path().join("redirect/agent.key")).is_err());
15039        assert!(!outside.path().join("agent.key").exists());
15040    }
15041
15042    // ── Address parsing ─────────────────────────────────────────────────────
15043
15044    #[test]
15045    fn address_bare_brain_with_and_without_sigil() {
15046        for raw in ["@acme-ops", "acme-ops"] {
15047            let a = Address::parse(raw).expect(raw);
15048            assert_eq!(a.brain, "acme-ops");
15049            assert_eq!(a.target, None);
15050        }
15051    }
15052
15053    #[test]
15054    fn address_ulid_target_parses_as_id() {
15055        let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
15056        assert_eq!(a.brain, "acme");
15057        assert_eq!(
15058            a.target,
15059            Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
15060        );
15061    }
15062
15063    #[test]
15064    fn address_md_path_target_parses_as_path() {
15065        let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
15066        assert_eq!(
15067            a.target,
15068            Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
15069        );
15070    }
15071
15072    #[test]
15073    fn address_rejects_malformed_forms() {
15074        for raw in [
15075            "",
15076            "@",
15077            "@/x",
15078            "@acme/",
15079            "@acme/../etc/passwd",
15080            "@acme/records/.hidden.md",
15081            "@ACME",             // uppercase is not a hub ref shape
15082            "@acme/notes/x.txt", // target is neither ULID nor .md path
15083            "@a b",              // whitespace
15084        ] {
15085            assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
15086        }
15087    }
15088
15089    // ── Path safety ─────────────────────────────────────────────────────────
15090
15091    #[test]
15092    fn safe_paths_accept_store_shapes_and_reject_escapes() {
15093        for ok in [
15094            "DB.md",
15095            "assets.jsonl",
15096            "records/clients/lumio.md",
15097            "sources/emails/2026/07/x.md",
15098        ] {
15099            assert!(safe_store_rel_path(ok), "should accept {ok:?}");
15100        }
15101        for bad in [
15102            "",
15103            "/etc/passwd",
15104            "../up.md",
15105            "records/../../up.md",
15106            "records//x.md",
15107            ".dbmd/config",
15108            "records/.hidden/x.md",
15109            "records/a b.md",
15110            "records\\win.md",
15111        ] {
15112            assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
15113        }
15114    }
15115
15116    #[cfg(unix)]
15117    #[test]
15118    fn opened_destination_capability_survives_an_ancestor_path_swap() {
15119        use std::os::unix::fs::symlink;
15120
15121        let work = tempfile::tempdir().unwrap();
15122        let outside = tempfile::tempdir().unwrap();
15123        let original = work.path().join("destination");
15124        let moved = work.path().join("destination-moved");
15125        let directory = open_or_create_dir_nofollow(&original).unwrap();
15126
15127        std::fs::rename(&original, &moved).unwrap();
15128        symlink(outside.path(), &original).unwrap();
15129        write_pull_entries_beneath_dir(
15130            &directory,
15131            &[("records/note.md".to_string(), b"held inode".to_vec())],
15132        )
15133        .unwrap();
15134
15135        assert_eq!(
15136            std::fs::read(moved.join("records/note.md")).unwrap(),
15137            b"held inode"
15138        );
15139        assert!(!outside.path().join("records/note.md").exists());
15140    }
15141
15142    // ── Config resolution (flag + file precedence; env is covered by the CLI
15143    //    integration tests, where a child process isolates it) ───────────────
15144
15145    #[test]
15146    fn hub_config_flag_beats_file_and_requires_some_source() {
15147        let dir = tempfile::tempdir().unwrap();
15148        std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
15149        std::fs::write(
15150            dir.path().join(CONFIG_REL_PATH),
15151            "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
15152        )
15153        .unwrap();
15154
15155        let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
15156        assert_eq!(from_flag.hub, "https://flag.example.com");
15157
15158        let from_file = hub_config(None, dir.path()).unwrap();
15159        assert_eq!(from_file.hub, "https://file.example.com");
15160
15161        let none = hub_config(None, tempfile::tempdir().unwrap().path());
15162        assert!(matches!(none, Err(LinkError::NoHub)));
15163    }
15164
15165    #[test]
15166    fn https_guard_allows_loopback_only_for_plain_http() {
15167        assert!(assert_safe_hub("https://hub.example.com").is_ok());
15168        assert!(assert_safe_hub("http://localhost:3000").is_ok());
15169        assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
15170        assert!(assert_safe_hub("http://[::1]:3000").is_ok());
15171        assert!(matches!(
15172            assert_safe_hub("http://hub.example.com"),
15173            Err(LinkError::UnsafeHub { .. })
15174        ));
15175        assert!(matches!(
15176            assert_safe_hub("hub.example.com"),
15177            Err(LinkError::UnsafeHub { .. })
15178        ));
15179        assert!(matches!(
15180            assert_safe_hub("http://localhost:80@127.0.0.1:1"),
15181            Err(LinkError::UnsafeHub { .. })
15182        ));
15183        assert!(matches!(
15184            assert_safe_hub("https://hub.example.com@attacker.example"),
15185            Err(LinkError::UnsafeHub { .. })
15186        ));
15187        assert!(matches!(
15188            assert_safe_hub("https://hub.example.com/base"),
15189            Err(LinkError::UnsafeHub { .. })
15190        ));
15191    }
15192
15193    #[test]
15194    fn registry_ssrf_classifier_rejects_local_private_and_documentation_ranges() {
15195        for blocked in [
15196            "127.0.0.1",
15197            "10.0.0.1",
15198            "100.64.0.1",
15199            "169.254.169.254",
15200            "172.16.0.1",
15201            "192.168.0.1",
15202            "192.88.99.1",
15203            "198.18.0.1",
15204            "203.0.113.1",
15205            "::1",
15206            "fe80::1",
15207            "fd00::1",
15208            "2001:db8::1",
15209            "2001:1::1",
15210            "2002:7f00:1::",
15211            "3fff::1",
15212        ] {
15213            assert!(
15214                !is_public_registry_ip(blocked.parse().unwrap()),
15215                "must block {blocked}"
15216            );
15217        }
15218        assert!(is_public_registry_ip("1.1.1.1".parse().unwrap()));
15219        assert!(is_public_registry_ip(
15220            "2606:4700:4700::1111".parse().unwrap()
15221        ));
15222        assert!(is_public_registry_ip("3fff:1000::1".parse().unwrap()));
15223    }
15224
15225    #[test]
15226    fn registry_dns_answer_is_pinned_and_cannot_rebind_or_change_authority() {
15227        use ureq::Resolver as _;
15228
15229        let pinned: std::net::SocketAddr = "1.1.1.1:443".parse().unwrap();
15230        let resolver = PinnedRegistryResolver {
15231            netloc: "home.example:443".to_string(),
15232            addresses: vec![pinned],
15233        };
15234        assert_eq!(resolver.resolve("home.example:443").unwrap(), vec![pinned]);
15235        assert!(resolver.resolve("127.0.0.1:443").is_err());
15236        assert_eq!(
15237            resolver.resolve("home.example:443").unwrap(),
15238            vec![pinned],
15239            "subsequent connects reuse the validated answer instead of DNS"
15240        );
15241    }
15242
15243    #[test]
15244    fn production_object_urls_and_store_selected_hubs_cannot_reach_private_ips() {
15245        let cfg = HubConfig {
15246            hub: "https://hub.example".to_string(),
15247            key: None,
15248            agent_key: None,
15249            brain_key: None,
15250            state_dir: tempfile::tempdir().unwrap().keep(),
15251            store_selected: false,
15252        };
15253        assert!(
15254            presigned_agent(&cfg, "https://127.0.0.1/private").is_err(),
15255            "a production hub must not turn its presigned URL into an SSRF primitive"
15256        );
15257
15258        let store_selected = HubConfig {
15259            hub: "https://127.0.0.1".to_string(),
15260            store_selected: true,
15261            ..cfg
15262        };
15263        assert!(
15264            hub_agent(&store_selected).is_err(),
15265            "bytes in a cloned store must not select a private-network hub"
15266        );
15267    }
15268
15269    #[test]
15270    fn presigned_download_ceiling_cannot_overflow_at_u64_max() {
15271        assert_eq!(
15272            one_past_bounded_limit(MAX_PACK_BYTES),
15273            Some(MAX_PACK_BYTES + 1),
15274            "the presigned reader consumes exactly one refusal byte beyond its fixed pack cap"
15275        );
15276        assert_eq!(
15277            presigned_download_read_limit(),
15278            MAX_PACK_BYTES + 1,
15279            "the presigned reader is capped by the client constant, not a hub response"
15280        );
15281        assert_eq!(
15282            one_past_bounded_limit(u64::MAX),
15283            None,
15284            "the former attacker-controlled u64::MAX + 1 shape must fail without overflow"
15285        );
15286    }
15287
15288    #[test]
15289    fn https_guard_matches_the_scheme_case_insensitively() {
15290        // RFC 3986 schemes are case-insensitive: an uppercase-scheme HTTPS
15291        // hub is still HTTPS, never a misleading non-HTTPS refusal.
15292        assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
15293        assert!(assert_safe_hub("Https://hub.example.com").is_ok());
15294        // And an uppercase plain-HTTP hub is still refused outside loopback.
15295        assert!(matches!(
15296            assert_safe_hub("HTTP://hub.example.com"),
15297            Err(LinkError::UnsafeHub { .. })
15298        ));
15299    }
15300
15301    #[test]
15302    fn clean_key_refuses_paste_artifacts_without_echoing() {
15303        assert_eq!(clean_key("  vc_account_abc  ").unwrap(), "vc_account_abc");
15304        for bad in ["vc account", "vc\naccount", "ключ", ""] {
15305            let err = clean_key(bad).unwrap_err();
15306            assert!(matches!(err, LinkError::BadKey));
15307            assert!(
15308                !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
15309                "error must not echo the key"
15310            );
15311        }
15312    }
15313
15314    // ── Verb entry gates: refs must never reshape the request path ──────────
15315
15316    /// A config whose hub passes the loopback guard but is never listened on:
15317    /// every refusal below must come from the entry gate BEFORE a request
15318    /// exists — a dial on this dead port would surface `Transport` instead.
15319    fn dead_hub() -> HubConfig {
15320        HubConfig {
15321            hub: "http://127.0.0.1:9".to_string(),
15322            key: Some("k".to_string()),
15323            agent_key: None,
15324            brain_key: None,
15325            state_dir: PathBuf::from("."),
15326            store_selected: false,
15327        }
15328    }
15329
15330    #[test]
15331    fn request_retries_a_connection_failure_before_sending() {
15332        use std::io::{Read as _, Write as _};
15333        use std::net::TcpListener;
15334        use std::thread;
15335        use std::time::Duration;
15336
15337        let probe = TcpListener::bind("127.0.0.1:0").unwrap();
15338        let address = probe.local_addr().unwrap();
15339        drop(probe);
15340        let server = thread::spawn(move || {
15341            thread::sleep(Duration::from_millis(40));
15342            let listener = TcpListener::bind(address).unwrap();
15343            let (mut stream, _) = listener.accept().unwrap();
15344            let mut request_bytes = [0_u8; 1024];
15345            let _ = stream.read(&mut request_bytes).unwrap();
15346            stream
15347                .write_all(
15348                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
15349                )
15350                .unwrap();
15351        });
15352        let cfg = HubConfig {
15353            hub: format!("http://{address}"),
15354            key: None,
15355            agent_key: None,
15356            brain_key: None,
15357            state_dir: tempfile::tempdir().unwrap().keep(),
15358            store_selected: false,
15359        };
15360
15361        let response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
15362        assert_eq!(response.status, 200);
15363        assert_eq!(response.body, Some(json!({ "ok": true })));
15364        server.join().unwrap();
15365    }
15366
15367    #[test]
15368    fn endpoint_cap_refuses_a_body_before_json_parsing() {
15369        let (hub, server) = scripted_json_hub(vec![(200, "x".repeat(2_048))]);
15370        let cfg = HubConfig {
15371            hub,
15372            key: None,
15373            agent_key: None,
15374            brain_key: None,
15375            state_dir: tempfile::tempdir().unwrap().keep(),
15376            store_selected: false,
15377        };
15378
15379        assert!(matches!(
15380            request_capped(&cfg, "GET", "/bounded", None, Auth::None, 1_024),
15381            Err(LinkError::ResponseTooLarge { .. })
15382        ));
15383        server.join().unwrap();
15384    }
15385
15386    #[test]
15387    fn overall_deadline_stops_a_dribbled_response_body() {
15388        use std::io::{Read as _, Write as _};
15389        use std::net::TcpListener;
15390        use std::time::{Duration, Instant};
15391
15392        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15393        let url = format!("http://{}/dribble", listener.local_addr().unwrap());
15394        let server = std::thread::spawn(move || {
15395            let (mut stream, _) = listener.accept().unwrap();
15396            let mut request = [0_u8; 1024];
15397            let _ = stream.read(&mut request);
15398            stream
15399                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 32\r\nConnection: close\r\n\r\n")
15400                .unwrap();
15401            for byte in [b'x'; 32] {
15402                if stream.write_all(&[byte]).is_err() {
15403                    break;
15404                }
15405                std::thread::sleep(Duration::from_millis(40));
15406            }
15407        });
15408        let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
15409        let started = Instant::now();
15410        let response = http.get(&url).call().unwrap();
15411        let mut body = Vec::new();
15412        let error = response
15413            .into_reader()
15414            .read_to_end(&mut body)
15415            .expect_err("per-read progress must not reset the overall deadline");
15416        assert!(
15417            started.elapsed() < Duration::from_millis(700),
15418            "dribbled body exceeded the wall-clock budget: {error}"
15419        );
15420        server.join().unwrap();
15421    }
15422
15423    #[test]
15424    fn overall_deadline_stops_a_stalled_upload() {
15425        use std::net::TcpListener;
15426        use std::time::{Duration, Instant};
15427
15428        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15429        let url = format!("http://{}/upload", listener.local_addr().unwrap());
15430        let server = std::thread::spawn(move || {
15431            let (_stream, _) = listener.accept().unwrap();
15432            // Never consume the request body. Once the kernel send buffer fills,
15433            // the client must leave on its absolute write deadline.
15434            std::thread::sleep(Duration::from_millis(600));
15435        });
15436        let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
15437        let body = vec![0x5a; 32 * 1024 * 1024];
15438        let started = Instant::now();
15439        let error = http
15440            .put(&url)
15441            .send_bytes(&body)
15442            .expect_err("stalled request-body writes must time out");
15443        assert!(
15444            started.elapsed() < Duration::from_millis(700),
15445            "stalled upload exceeded the wall-clock budget: {error}"
15446        );
15447        server.join().unwrap();
15448    }
15449
15450    #[test]
15451    fn verb_entry_gates_accept_the_hub_ref_shapes() {
15452        for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
15453            assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
15454            assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
15455            assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
15456        }
15457    }
15458
15459    #[test]
15460    fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
15461        let cfg = dead_hub();
15462        for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
15463            assert!(
15464                matches!(
15465                    sync_pull(&cfg, bad, None),
15466                    Err(LinkError::BadAddress { .. })
15467                ),
15468                "sync_pull must refuse {bad:?}"
15469            );
15470            assert!(
15471                matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
15472                "sync_push must refuse {bad:?}"
15473            );
15474            assert!(
15475                matches!(
15476                    grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
15477                    Err(LinkError::BadAddress { .. })
15478                ),
15479                "grant_issue must refuse {bad:?}"
15480            );
15481            assert!(
15482                matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
15483                "grant_list must refuse {bad:?}"
15484            );
15485            assert!(
15486                matches!(
15487                    grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
15488                    Err(LinkError::BadAddress { .. })
15489                ),
15490                "grant_revoke must refuse brain {bad:?}"
15491            );
15492            assert!(
15493                matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
15494                "head must refuse {bad:?}"
15495            );
15496        }
15497    }
15498
15499    #[test]
15500    fn grant_revoke_refuses_url_reshaping_grant_ids() {
15501        let cfg = dead_hub();
15502        for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
15503            assert!(
15504                matches!(
15505                    grant_revoke(&cfg, "acme", bad),
15506                    Err(LinkError::BadGrantId { .. })
15507                ),
15508                "grant_revoke must refuse grant id {bad:?}"
15509            );
15510        }
15511    }
15512
15513    #[test]
15514    fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
15515        let cfg = dead_hub();
15516        for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
15517            assert!(
15518                matches!(
15519                    propose(&cfg, bad, "intake", "hi"),
15520                    Err(LinkError::BadAddress { .. })
15521                ),
15522                "propose must refuse handle {bad:?}"
15523            );
15524        }
15525        let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
15526        assert!(matches!(
15527            propose(&cfg, "acme-site", "intake", &oversize),
15528            Err(LinkError::ProposeTooLarge { .. })
15529        ));
15530        // A clean handle + in-cap body passes both gates: the failure is now
15531        // the (dead) wire, proving the gates refuse shape, not the verb.
15532        assert!(matches!(
15533            propose(&cfg, "acme-site", "intake", "hi"),
15534            Err(LinkError::Transport { .. })
15535        ));
15536    }
15537
15538    #[test]
15539    fn resolve_refuses_a_hand_built_unsafe_address() {
15540        let cfg = dead_hub();
15541        for brain in ["../up", "a/b", "a?x", "a#f"] {
15542            let addr = Address {
15543                brain: brain.to_string(),
15544                target: None,
15545            };
15546            assert!(
15547                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
15548                "resolve must refuse brain {brain:?}"
15549            );
15550        }
15551        for target in [
15552            AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
15553            AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), // not the minted shape
15554            AddressTarget::Path("../up.md".to_string()),
15555            AddressTarget::Path("records/x.md#frag".to_string()),
15556        ] {
15557            let addr = Address {
15558                brain: "acme".to_string(),
15559                target: Some(target.clone()),
15560            };
15561            assert!(
15562                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
15563                "resolve must refuse target {target:?}"
15564            );
15565        }
15566    }
15567
15568    #[test]
15569    fn v2_final_barrier_refuses_to_advance_a_remote_ahead_checkout() {
15570        let mut local = std::collections::BTreeMap::new();
15571        local.insert("records/a.md".to_string(), ("a".repeat(64), 0));
15572        local.insert("records/b.md".to_string(), ("b".repeat(64), 0));
15573        let mut remote = std::collections::BTreeMap::new();
15574        remote.insert(
15575            "records/a.md".to_string(),
15576            V2BaselineFile {
15577                sha256: "c".repeat(64),
15578                bytes: 1,
15579                proof: None,
15580            },
15581        );
15582        remote.insert(
15583            "records/b.md".to_string(),
15584            V2BaselineFile {
15585                sha256: "b".repeat(64),
15586                bytes: 1,
15587                proof: None,
15588            },
15589        );
15590        assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
15591    }
15592
15593    #[test]
15594    fn v2_final_barrier_ignores_only_explicit_kept_home_paths() {
15595        let local = std::collections::BTreeMap::new();
15596        let mut remote = std::collections::BTreeMap::new();
15597        remote.insert(
15598            "private/local.md".to_string(),
15599            V2BaselineFile {
15600                sha256: "d".repeat(64),
15601                bytes: 1,
15602                proof: None,
15603            },
15604        );
15605        assert!(v2_riding_matches_remote(&local, &remote, |path| path == "private/local.md"));
15606        assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
15607    }
15608
15609    fn scoped_test_head(revision: &str) -> V2VerifiedHead {
15610        V2VerifiedHead {
15611            requested: TEST_BRAIN_ID.to_string(),
15612            brain_id: TEST_BRAIN_ID.to_string(),
15613            view_kind: "scoped".to_string(),
15614            view_revision: revision.to_string(),
15615            control_revision: revision.to_string(),
15616            identity: V2HeadIdentity {
15617                custody: "hub".to_string(),
15618                fingerprint: "test".to_string(),
15619                public_key_spki: "test".to_string(),
15620                previous: Vec::new(),
15621                rotations: Vec::new(),
15622            },
15623            pointer: None,
15624            trust: TrustState {
15625                v: 2,
15626                origin: "https://hub.example".to_string(),
15627                requested: TEST_BRAIN_ID.to_string(),
15628                brain: TEST_BRAIN_ID.to_string(),
15629                home: None,
15630                anchor: "ed25519:test".to_string(),
15631                current: "ed25519:test".to_string(),
15632                head_seq: 0,
15633                feed_hash: None,
15634                rotations: Vec::new(),
15635                hub_signer: None,
15636                protocol_profile: Some("link-v2".to_string()),
15637            },
15638            alias: None,
15639        }
15640    }
15641
15642    #[test]
15643    fn accepted_v2_checkpoint_never_downgrades_after_profile_migration() {
15644        let mut trust = scoped_test_head(&"a".repeat(64)).trust;
15645        assert!(accepted_as_v2(&trust));
15646
15647        trust.protocol_profile = None;
15648        trust.hub_signer = Some("ed25519:hub".to_string());
15649        assert!(accepted_as_v2(&trust));
15650
15651        trust.hub_signer = None;
15652        assert!(!accepted_as_v2(&trust));
15653    }
15654
15655    fn scoped_test_baseline(revision: &str) -> V2SyncBaseline {
15656        V2SyncBaseline {
15657            v: 2,
15658            origin: "https://hub.example".to_string(),
15659            brain: TEST_BRAIN_ID.to_string(),
15660            checkout_id: Some("c".repeat(64)),
15661            head_seq: Some(0),
15662            commit_hash: None,
15663            content_root: None,
15664            asset_root: None,
15665            assets: std::collections::BTreeMap::new(),
15666            view_kind: Some("scoped".to_string()),
15667            view_revision: Some(revision.to_string()),
15668            projection_sha256: Some(scoped_projection_sha256(TEST_BRAIN_ID)),
15669            files: std::collections::BTreeMap::new(),
15670            local_policy_digest: None,
15671            local_eligibility: std::collections::BTreeMap::new(),
15672            remote_copy_remains: std::collections::BTreeMap::new(),
15673        }
15674    }
15675
15676    #[test]
15677    fn scoped_projection_is_a_valid_local_store_marker_but_never_rides() {
15678        let directory = tempfile::tempdir().unwrap();
15679        std::fs::write(
15680            directory.path().join("DB.md"),
15681            scoped_projection_bytes(TEST_BRAIN_ID),
15682        )
15683        .unwrap();
15684        let store = Store::open_strict(directory.path()).unwrap();
15685        let head = scoped_test_head(&"a".repeat(64));
15686        let baseline = scoped_test_baseline(&"a".repeat(64));
15687        let mut view = v2_local_files(&store).unwrap();
15688        remove_scoped_projection(&head, Some(&baseline), &mut view).unwrap();
15689        assert!(!view.riding.contains_key("DB.md"));
15690        assert!(!view.eligibility.contains_key("DB.md"));
15691    }
15692
15693    #[test]
15694    fn scoped_push_accepts_a_pull_verified_handoff_without_double_checking_projection() {
15695        let directory = tempfile::tempdir().unwrap();
15696        std::fs::write(
15697            directory.path().join("DB.md"),
15698            scoped_projection_bytes(TEST_BRAIN_ID),
15699        )
15700        .unwrap();
15701        let store = Store::open_strict(directory.path()).unwrap();
15702        let head = scoped_test_head(&"a".repeat(64));
15703        let baseline = scoped_test_baseline(&"a".repeat(64));
15704
15705        let mut carried = v2_local_files(&store).unwrap();
15706        remove_scoped_projection(&head, Some(&baseline), &mut carried).unwrap();
15707        let handed_off =
15708            local_view_for_v2_push(&store, &head, Some(&baseline), Some(carried)).unwrap();
15709        assert!(!handed_off.riding.contains_key("DB.md"));
15710
15711        let freshly_scanned = local_view_for_v2_push(&store, &head, Some(&baseline), None).unwrap();
15712        assert!(!freshly_scanned.riding.contains_key("DB.md"));
15713
15714        std::fs::write(
15715            directory.path().join("DB.md"),
15716            b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
15717        )
15718        .unwrap();
15719        let tampered = Store::open_strict(directory.path()).unwrap();
15720        assert!(matches!(
15721            local_view_for_v2_push(&tampered, &head, Some(&baseline), None),
15722            Err(LinkError::ScopedProjectionModified)
15723        ));
15724    }
15725
15726    #[test]
15727    fn v2_local_view_reports_only_exact_linked_kept_home_markdown() {
15728        let directory = tempfile::tempdir().unwrap();
15729        std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
15730        std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
15731        std::fs::write(
15732            directory.path().join("DB.md"),
15733            b"---\nname: Kept home test\n---\n",
15734        )
15735        .unwrap();
15736        std::fs::write(
15737            directory.path().join("records/notes/a.md"),
15738            b"---\ntype: note\n---\nSee [[sources/private/secret]].\n",
15739        )
15740        .unwrap();
15741        std::fs::write(
15742            directory.path().join("sources/private/secret.md"),
15743            b"---\ntype: note\n---\nlocal only\n",
15744        )
15745        .unwrap();
15746        std::fs::write(
15747            directory.path().join("sources/private/unlinked.md"),
15748            b"---\ntype: note\n---\nnot disclosed\n",
15749        )
15750        .unwrap();
15751        std::fs::write(
15752            directory.path().join(".sevralocal"),
15753            b"sources/private/**\n",
15754        )
15755        .unwrap();
15756
15757        let store = Store::open_strict(directory.path()).unwrap();
15758        let view = v2_local_files(&store).unwrap();
15759        assert!(!view.riding.contains_key("sources/private/secret.md"));
15760        assert_eq!(
15761            view.withheld_links,
15762            vec![V2WithheldLink {
15763                source: "records/notes/a.md".to_string(),
15764                target: "sources/private/secret.md".to_string(),
15765            }]
15766        );
15767    }
15768
15769    #[test]
15770    fn explicit_content_withdrawal_requires_exact_current_kept_home_bytes() {
15771        let directory = tempfile::tempdir().unwrap();
15772        std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
15773        std::fs::write(
15774            directory.path().join("DB.md"),
15775            b"---\nname: Withdrawal test\n---\n",
15776        )
15777        .unwrap();
15778        let source = b"---\ntype: note\n---\nlocal evidence\n";
15779        std::fs::write(directory.path().join("sources/private/evidence.md"), source).unwrap();
15780        std::fs::write(
15781            directory.path().join(".sevralocal"),
15782            b"sources/private/**\n",
15783        )
15784        .unwrap();
15785        let store = Store::open_strict(directory.path()).unwrap();
15786        let view = v2_local_files(&store).unwrap();
15787        let mut remote = std::collections::BTreeMap::new();
15788        remote.insert(
15789            "sources/private/evidence.md".to_string(),
15790            V2BaselineFile {
15791                sha256: content_sha256(source),
15792                bytes: source.len() as u64,
15793                proof: None,
15794            },
15795        );
15796        assert_eq!(
15797            v2_content_withdrawal_operation(
15798                &store,
15799                &view,
15800                &remote,
15801                "sources/private/evidence.md",
15802                "approved retention change",
15803            )
15804            .unwrap(),
15805            json!({
15806                "op": "withdraw_from_hosting",
15807                "path": "sources/private/evidence.md",
15808                "expected": { "kind": "blob", "hash": content_sha256(source) },
15809                "reason": "approved retention change",
15810            })
15811        );
15812
15813        std::fs::write(
15814            directory.path().join("sources/private/evidence.md"),
15815            b"changed after review",
15816        )
15817        .unwrap();
15818        assert!(matches!(
15819            v2_content_withdrawal_operation(
15820                &store,
15821                &view,
15822                &remote,
15823                "sources/private/evidence.md",
15824                "approved retention change",
15825            ),
15826            Err(LinkError::InvalidPack { .. })
15827        ));
15828    }
15829
15830    #[test]
15831    fn explicit_asset_withdrawal_requires_the_exact_hosted_leaf_and_local_bytes() {
15832        let directory = tempfile::tempdir().unwrap();
15833        std::fs::create_dir_all(directory.path().join("sources/files")).unwrap();
15834        std::fs::write(
15835            directory.path().join("DB.md"),
15836            b"---\nname: Asset withdrawal test\n---\n",
15837        )
15838        .unwrap();
15839        let bytes = b"private binary";
15840        std::fs::write(directory.path().join("sources/files/private.pdf"), bytes).unwrap();
15841        std::fs::write(
15842            directory.path().join(".sevralocal"),
15843            b"sources/files/private.pdf\n",
15844        )
15845        .unwrap();
15846        let store = Store::open_strict(directory.path()).unwrap();
15847        let view = v2_local_files(&store).unwrap();
15848        let local = crate::AssetRecord {
15849            path: "sources/files/private.pdf".to_string(),
15850            sha256: content_sha256(bytes),
15851            bytes: bytes.len() as u64,
15852            media_type: "application/pdf".to_string(),
15853            wrappers: vec!["sources/files/private.md".to_string()],
15854            required: true,
15855        };
15856        let current = V2BaselineAsset {
15857            blob_sha256: local.sha256.clone(),
15858            bytes: local.bytes,
15859            media_type: local.media_type.clone(),
15860            wrappers: local.wrappers.clone(),
15861            required: local.required,
15862            disposition: "hosted".to_string(),
15863            leaf_hash: "d".repeat(64),
15864        };
15865        assert_eq!(
15866            v2_asset_withdrawal_operation(
15867                &store,
15868                &view,
15869                &local.path,
15870                &local,
15871                &current,
15872                "approved retention change",
15873            )
15874            .unwrap(),
15875            json!({
15876                "op": "asset_withdraw",
15877                "path": local.path,
15878                "expected": { "kind": "asset", "hash": "d".repeat(64) },
15879                "reason": "approved retention change",
15880            })
15881        );
15882
15883        let mut mismatched = current.clone();
15884        mismatched.required = false;
15885        assert!(matches!(
15886            v2_asset_withdrawal_operation(
15887                &store,
15888                &view,
15889                &local.path,
15890                &local,
15891                &mismatched,
15892                "approved retention change",
15893            ),
15894            Err(LinkError::InvalidPack { .. })
15895        ));
15896    }
15897
15898    #[test]
15899    fn checkout_pseudonym_is_random_then_stable_from_private_baseline() {
15900        let first = v2_checkout_id(None).unwrap();
15901        assert_eq!(first, v2_checkout_id(Some(&first)).unwrap());
15902        assert_ne!(first, v2_checkout_id(None).unwrap());
15903        assert!(is_sha256(&first));
15904    }
15905
15906    #[test]
15907    fn scoped_projection_edit_and_scope_transition_fail_closed() {
15908        let directory = tempfile::tempdir().unwrap();
15909        std::fs::write(
15910            directory.path().join("DB.md"),
15911            b"---\ntype: db-md\nscope: company\nowner: someone\n---\n",
15912        )
15913        .unwrap();
15914        let store = Store::open_strict(directory.path()).unwrap();
15915        let head = scoped_test_head(&"a".repeat(64));
15916        let baseline = scoped_test_baseline(&"a".repeat(64));
15917        let mut view = v2_local_files(&store).unwrap();
15918        assert!(matches!(
15919            remove_scoped_projection(&head, Some(&baseline), &mut view),
15920            Err(LinkError::ScopedProjectionModified)
15921        ));
15922
15923        let changed = scoped_test_head(&"b".repeat(64));
15924        assert!(matches!(
15925            ensure_v2_view_compatible(&changed, Some(&baseline)),
15926            Err(LinkError::ScopedViewChanged)
15927        ));
15928
15929        let mut same_view_new_control = head.clone();
15930        same_view_new_control.control_revision = "c".repeat(64);
15931        assert!(ensure_v2_view_compatible(&same_view_new_control, Some(&baseline)).is_ok());
15932        assert!(!same_v2_head(&head, &same_view_new_control));
15933    }
15934
15935    #[test]
15936    fn established_checkout_never_becomes_an_empty_clone_when_db_md_is_invalid() {
15937        let scoped = scoped_test_head(&"a".repeat(64));
15938        let scoped_baseline = scoped_test_baseline(&"a".repeat(64));
15939        assert!(matches!(
15940            ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), false),
15941            Err(LinkError::ScopedProjectionModified)
15942        ));
15943
15944        let mut full = scoped.clone();
15945        full.view_kind = "full".to_string();
15946        let mut full_baseline = scoped_baseline.clone();
15947        full_baseline.view_kind = Some("full".to_string());
15948        full_baseline.projection_sha256 = None;
15949        assert!(matches!(
15950            ensure_established_v2_checkout_opened(&full, Some(&full_baseline), false),
15951            Err(LinkError::InvalidPack { .. })
15952        ));
15953
15954        assert!(ensure_established_v2_checkout_opened(&scoped, None, false).is_ok());
15955        assert!(
15956            ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), true).is_ok()
15957        );
15958    }
15959
15960    #[test]
15961    fn scoped_view_metadata_is_explicitly_non_authoritative() {
15962        let head = scoped_test_head(&"a".repeat(64));
15963        let value: Value =
15964            serde_json::from_slice(&scoped_view_metadata(&head, 7).unwrap()).unwrap();
15965        assert_eq!(value["kind"], "link.md-scoped-view");
15966        assert_eq!(value["authoritative"], false);
15967        assert_eq!(value["visible_files"], 7);
15968        assert_eq!(value["brain"], TEST_BRAIN_ID);
15969    }
15970
15971    #[test]
15972    fn local_scoped_marker_requires_the_exact_generated_projection() {
15973        let directory = tempfile::tempdir().unwrap();
15974        std::fs::create_dir(directory.path().join(".dbmd")).unwrap();
15975        std::fs::write(
15976            directory.path().join("DB.md"),
15977            scoped_projection_bytes(TEST_BRAIN_ID),
15978        )
15979        .unwrap();
15980        let head = scoped_test_head(&"a".repeat(64));
15981        std::fs::write(
15982            directory.path().join(".dbmd/view.json"),
15983            scoped_view_metadata(&head, 0).unwrap(),
15984        )
15985        .unwrap();
15986        let store = Store::open_strict(directory.path()).unwrap();
15987        assert!(has_verified_local_scoped_view(&store));
15988
15989        std::fs::write(
15990            directory.path().join("DB.md"),
15991            b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
15992        )
15993        .unwrap();
15994        let altered = Store::open_strict(directory.path()).unwrap();
15995        assert!(!has_verified_local_scoped_view(&altered));
15996    }
15997
15998    fn signed_proposal_fixture() -> (V2VerifiedHead, String, Value) {
15999        use ring::signature::KeyPair as _;
16000
16001        let proposal_id = "01k2r7bm9w5x6e8nq3tjhv4cya".to_string();
16002        let rng = ring::rand::SystemRandom::new();
16003        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16004        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16005        let public_der = [ED25519_SPKI_PREFIX.as_slice(), pair.public_key().as_ref()].concat();
16006        let public_key = URL_SAFE_NO_PAD.encode(&public_der);
16007        let fingerprint = format!("{:x}", Sha256::digest(&public_der));
16008        let blob = b"new";
16009        let blob_hash = content_sha256(blob);
16010        let changes = json!({
16011            "mutation_id": "sync:proposal-fixture",
16012            "operations": [{
16013                "blob": blob_hash,
16014                "bytes": blob.len(),
16015                "expected": null,
16016                "op": "put",
16017                "path": "records/new.md",
16018            }],
16019            "reason": "fixture",
16020            "v": 2,
16021        });
16022        let changes_bytes = crate::linkmd_v2::canonical_bytes(&changes).unwrap();
16023        let changes_base64 = STANDARD.encode(&changes_bytes);
16024        let descriptor = json!({
16025            "base": null,
16026            "blobs": [{ "bytes": blob.len(), "sha256": blob_hash }],
16027            "changes_base64": changes_base64,
16028            "rebase": "strict",
16029            "v": 2,
16030        });
16031        let clear_hash = content_sha256(&crate::linkmd_v2::canonical_bytes(&descriptor).unwrap());
16032        let payload_hash = "b".repeat(64);
16033        let submitted_at = "2026-08-19T12:00:00.000Z";
16034        let claim = json!({
16035            "actor_root": {
16036                "actor_class": "foreign_key",
16037                "credential": "ed25519:fixture",
16038                "grants": ["01k2r7bm9w5x6e8nq3tjhv4cyb"],
16039                "organization": "01k2r7bm9w5x6e8nq3tjhv4cyc",
16040                "principal": "key:fixture",
16041                "role": null,
16042            },
16043            "brain": TEST_BRAIN_ID,
16044            "clear_sha256": clear_hash,
16045            "control_revision": "c".repeat(64),
16046            "mutation_id": "sync:proposal-fixture",
16047            "payload_sha256": payload_hash,
16048            "proposal_id": proposal_id,
16049            "submitted_at": submitted_at,
16050            "v": 2,
16051        });
16052        let claim_bytes = crate::linkmd_v2::canonical_bytes(&claim).unwrap();
16053        let envelope = json!({
16054            "claim": claim,
16055            "fingerprint": fingerprint,
16056            "public_key": public_key,
16057            "sig": URL_SAFE_NO_PAD.encode(pair.sign(&claim_bytes).as_ref()),
16058        });
16059        let envelope_bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
16060        let submission_hash =
16061            crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &envelope_bytes).unwrap();
16062        let mut head = scoped_test_head(&"c".repeat(64));
16063        head.view_kind = "full".to_string();
16064        head.trust.hub_signer = Some(format!("{fingerprint}:{public_key}"));
16065        let value = json!({
16066            "proposal": {
16067                "base": null,
16068                "blobs": [{
16069                    "bytes": blob.len(),
16070                    "endpoint": format!(
16071                        "/api/hub/brains/{TEST_BRAIN_ID}/v2/proposals/{proposal_id}/blob?sha256={blob_hash}"
16072                    ),
16073                    "sha256": blob_hash,
16074                }],
16075                "changes_base64": changes_base64,
16076                "clear_sha256": clear_hash,
16077                "expires_at": "2026-08-26T12:00:00.000Z",
16078                "id": proposal_id,
16079                "payload_sha256": payload_hash,
16080                "proposer": { "class": "foreign_key" },
16081                "rebase": "strict",
16082                "state": "pending",
16083                "submission_claim_base64": STANDARD.encode(envelope_bytes),
16084                "submission_claim_sha256": submission_hash,
16085                "submitted_at": submitted_at,
16086            },
16087            "v": 2,
16088        });
16089        (head, proposal_id, value)
16090    }
16091
16092    #[test]
16093    fn v2_proposal_verifier_accepts_exact_signed_payload() {
16094        let (head, proposal_id, value) = signed_proposal_fixture();
16095        let verified = verify_v2_proposal_value(&head, &proposal_id, value).unwrap();
16096        assert_eq!(verified.blobs.len(), 1);
16097        assert_eq!(verified.changes["operations"][0]["path"], "records/new.md");
16098    }
16099
16100    #[test]
16101    fn v2_proposal_verifier_refuses_payload_endpoint_and_signature_tampering() {
16102        let (head, proposal_id, value) = signed_proposal_fixture();
16103
16104        let mut changed = value.clone();
16105        changed["proposal"]["changes_base64"] = Value::String(STANDARD.encode(b"{}"));
16106        assert!(verify_v2_proposal_value(&head, &proposal_id, changed).is_err());
16107
16108        let mut redirected = value.clone();
16109        redirected["proposal"]["blobs"][0]["endpoint"] =
16110            Value::String("https://attacker.example/blob".to_string());
16111        assert!(verify_v2_proposal_value(&head, &proposal_id, redirected).is_err());
16112
16113        let mut forged = value;
16114        let encoded = forged["proposal"]["submission_claim_base64"]
16115            .as_str()
16116            .unwrap();
16117        let mut envelope: Value =
16118            serde_json::from_slice(&STANDARD.decode(encoded).unwrap()).unwrap();
16119        envelope["sig"] = Value::String(URL_SAFE_NO_PAD.encode([0_u8; 64]));
16120        let bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
16121        forged["proposal"]["submission_claim_base64"] = Value::String(STANDARD.encode(&bytes));
16122        forged["proposal"]["submission_claim_sha256"] = Value::String(
16123            crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &bytes).unwrap(),
16124        );
16125        assert!(verify_v2_proposal_value(&head, &proposal_id, forged).is_err());
16126    }
16127
16128    #[cfg(unix)]
16129    #[test]
16130    fn v2_atomic_install_materializes_only_local_catalogs_before_swap() {
16131        let sandbox = tempfile::tempdir().unwrap();
16132        let destination = sandbox.path().join("brain");
16133        let entries = vec![
16134            (
16135                "DB.md".to_string(),
16136                scoped_projection_bytes(TEST_BRAIN_ID),
16137            ),
16138            (
16139                "records/contacts/a.md".to_string(),
16140                b"---\ntype: contact\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Visible contact\n---\n\n# A\n"
16141                    .to_vec(),
16142            ),
16143        ];
16144        install_pulled_delta(&destination, &entries, &[], true).unwrap();
16145        assert!(destination.join("index.md").is_file());
16146        assert!(destination.join("records/index.md").is_file());
16147        assert!(destination.join("records/contacts/index.md").is_file());
16148        assert!(destination.join("records/contacts/index.jsonl").is_file());
16149    }
16150
16151    #[cfg(unix)]
16152    #[test]
16153    fn v2_bounded_parallel_source_install_is_exact_and_atomic() {
16154        let sandbox = tempfile::tempdir().unwrap();
16155        let destination = sandbox.path().join("brain");
16156        let cache = sandbox.path().join("cache");
16157        std::fs::create_dir(&cache).unwrap();
16158        let db = scoped_projection_bytes(TEST_BRAIN_ID);
16159        let shared = b"---\ntype: note\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Shared\n---\n\n# Shared\n";
16160        let db_source = cache.join("db");
16161        let shared_source = cache.join("shared");
16162        crate::fsx::write_atomic(&db_source, &db).unwrap();
16163        crate::fsx::write_atomic(&shared_source, shared).unwrap();
16164        let mut entries = vec![V2StagedFile {
16165            path: "DB.md".to_string(),
16166            source: db_source,
16167            sha256: content_sha256(&db),
16168            bytes: db.len() as u64,
16169        }];
16170        for index in 0..512 {
16171            entries.push(V2StagedFile {
16172                path: format!("records/items/{index:05}.md"),
16173                source: shared_source.clone(),
16174                sha256: content_sha256(shared),
16175                bytes: shared.len() as u64,
16176            });
16177        }
16178        install_pulled_delta_sources(
16179            &destination,
16180            &entries,
16181            &[],
16182            false,
16183            None,
16184            &scoped_test_head(&"c".repeat(64)),
16185        )
16186        .unwrap();
16187        assert_eq!(std::fs::read(destination.join("DB.md")).unwrap(), db);
16188        for index in 0..512 {
16189            assert_eq!(
16190                std::fs::read(destination.join(format!("records/items/{index:05}.md"))).unwrap(),
16191                shared
16192            );
16193        }
16194        assert!(
16195            std::fs::read_dir(sandbox.path())
16196                .unwrap()
16197                .all(|entry| !entry
16198                    .unwrap()
16199                    .file_name()
16200                    .to_string_lossy()
16201                    .contains("pull-stage")),
16202            "the private stage must be atomically installed or removed"
16203        );
16204    }
16205
16206    #[cfg(unix)]
16207    #[test]
16208    fn v2_established_pull_touches_only_delta_and_rolls_back_exactly() {
16209        use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
16210
16211        let sandbox = tempfile::tempdir().unwrap();
16212        let root = sandbox.path().join("brain");
16213        std::fs::create_dir_all(root.join("records/items")).unwrap();
16214        let db = scoped_projection_bytes(TEST_BRAIN_ID);
16215        let old = b"---\ntype: note\n---\n\nold\n";
16216        let new = b"---\ntype: note\n---\n\nnew\n";
16217        let removed = b"---\ntype: note\n---\n\nremove me\n";
16218        std::fs::write(root.join("DB.md"), &db).unwrap();
16219        std::fs::write(root.join("records/items/change.md"), old).unwrap();
16220        std::fs::write(root.join("records/items/delete.md"), removed).unwrap();
16221        for index in 0..512 {
16222            std::fs::write(
16223                root.join(format!("records/items/untouched-{index:04}.md")),
16224                old,
16225            )
16226            .unwrap();
16227        }
16228        let untouched = root.join("records/items/untouched-0256.md");
16229        let untouched_inode = std::fs::metadata(&untouched).unwrap().ino();
16230        let source = sandbox.path().join("changed-source");
16231        crate::fsx::write_atomic(&source, new).unwrap();
16232        let same_source = sandbox.path().join("unchanged-source");
16233        crate::fsx::write_atomic(&same_source, old).unwrap();
16234        let same_entry = V2StagedFile {
16235            path: "records/items/change.md".to_string(),
16236            source: same_source,
16237            sha256: content_sha256(old),
16238            bytes: old.len() as u64,
16239        };
16240        let entry = V2StagedFile {
16241            path: "records/items/change.md".to_string(),
16242            source,
16243            sha256: content_sha256(new),
16244            bytes: new.len() as u64,
16245        };
16246        let head = scoped_test_head(&"c".repeat(64));
16247
16248        // A no-op established pull neither clones the checkout nor arms a
16249        // journal. Inode identity is a direct regression guard against the
16250        // former brain-wide private-copy implementation.
16251        install_established_v2_delta(
16252            Store::open_strict(&root).unwrap(),
16253            &[same_entry],
16254            &["records/items/already-absent.md".to_string()],
16255            true,
16256            None,
16257            &head,
16258        )
16259        .unwrap();
16260        assert_eq!(
16261            std::fs::metadata(&untouched).unwrap().ino(),
16262            untouched_inode
16263        );
16264        assert!(!root.join(V2_PULL_JOURNAL).exists());
16265
16266        install_established_v2_delta(
16267            Store::open_strict(&root).unwrap(),
16268            &[entry],
16269            &["records/items/delete.md".to_string()],
16270            false,
16271            None,
16272            &head,
16273        )
16274        .unwrap();
16275        assert_eq!(
16276            std::fs::read(root.join("records/items/change.md")).unwrap(),
16277            new
16278        );
16279        assert!(!root.join("records/items/delete.md").exists());
16280        assert_eq!(
16281            std::fs::metadata(&untouched).unwrap().ino(),
16282            untouched_inode
16283        );
16284        assert!(root.join(V2_PULL_JOURNAL).is_file());
16285        assert_eq!(
16286            std::fs::metadata(root.join(V2_PULL_JOURNAL))
16287                .unwrap()
16288                .permissions()
16289                .mode()
16290                & 0o777,
16291            0o600
16292        );
16293        let journal = load_v2_pull_journal(&Store::open_strict(&root).unwrap())
16294            .unwrap()
16295            .unwrap();
16296        assert_eq!(
16297            std::fs::metadata(root.join(&journal.backup_dir))
16298                .unwrap()
16299                .permissions()
16300                .mode()
16301                & 0o777,
16302            0o700
16303        );
16304        for entry in &journal.entries {
16305            if let Some(backup) = &entry.backup {
16306                assert_eq!(
16307                    std::fs::metadata(root.join(&journal.backup_dir).join(backup))
16308                        .unwrap()
16309                        .permissions()
16310                        .mode()
16311                        & 0o777,
16312                    0o600
16313                );
16314            }
16315        }
16316
16317        let cfg = test_hub_config(
16318            "https://example.test".to_string(),
16319            sandbox.path().join("state"),
16320        );
16321        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
16322        assert_eq!(
16323            std::fs::read(root.join("records/items/change.md")).unwrap(),
16324            old
16325        );
16326        assert_eq!(
16327            std::fs::read(root.join("records/items/delete.md")).unwrap(),
16328            removed
16329        );
16330        assert_eq!(
16331            std::fs::metadata(&untouched).unwrap().ino(),
16332            untouched_inode
16333        );
16334        assert!(!root.join(V2_PULL_JOURNAL).exists());
16335    }
16336
16337    #[test]
16338    fn v2_bulk_stream_is_exact_ordered_and_tamper_evident() {
16339        let body = b"bounded bytes";
16340        let path = "records/example.md".to_string();
16341        let file = V2BaselineFile {
16342            sha256: content_sha256(body),
16343            bytes: body.len() as u64,
16344            proof: None,
16345        };
16346        let header = serde_json::to_vec(&json!({
16347            "bytes": body.len(),
16348            "path": path,
16349            "sha256": file.sha256,
16350            "v": 2,
16351        }))
16352        .unwrap();
16353        let mut stream = V2_BULK_STREAM_MAGIC.to_vec();
16354        stream.extend_from_slice(&(header.len() as u32).to_be_bytes());
16355        stream.extend_from_slice(&header);
16356        stream.extend_from_slice(body);
16357        stream.extend_from_slice(&0_u32.to_be_bytes());
16358        let parsed = parse_v2_bulk_stream(&stream, &[(&path, &file)]).unwrap();
16359        assert_eq!(parsed, vec![(path.clone(), body.to_vec())]);
16360
16361        let mut tampered = stream.clone();
16362        let body_offset = V2_BULK_STREAM_MAGIC.len() + 4 + header.len();
16363        tampered[body_offset] ^= 1;
16364        assert!(parse_v2_bulk_stream(&tampered, &[(&path, &file)]).is_err());
16365
16366        let mut trailing = stream;
16367        trailing.push(0);
16368        assert!(parse_v2_bulk_stream(&trailing, &[(&path, &file)]).is_err());
16369    }
16370
16371    #[test]
16372    fn conflict_cache_prunes_only_expired_or_incomplete_state_by_default() {
16373        let sandbox = tempfile::TempDir::new().unwrap();
16374        let root = sandbox.path().join("brain");
16375        std::fs::create_dir_all(&root).unwrap();
16376        std::fs::write(
16377            root.join("DB.md"),
16378            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
16379        )
16380        .unwrap();
16381        let store = Store::open_strict(&root).unwrap();
16382        let incomplete = crate::ulid::mint();
16383        store
16384            .create_dir_all(&v2_conflict_relative(&incomplete, "files"))
16385            .unwrap();
16386        let expired = crate::ulid::mint();
16387        store
16388            .create_dir_all(&v2_conflict_relative(&expired, "files"))
16389            .unwrap();
16390        let plan = V2ConflictPlan {
16391            v: 2,
16392            class: "content_resolution_required".to_string(),
16393            bundle: expired.clone(),
16394            brain: TEST_BRAIN_ID.to_string(),
16395            origin: "https://example.test".to_string(),
16396            created_unix: 0,
16397            expires_unix: 0,
16398            base_seq: None,
16399            base_commit: None,
16400            remote_seq: 0,
16401            remote_commit: None,
16402            remote_content_root: None,
16403            view_kind: "full".to_string(),
16404            view_revision: "a".repeat(64),
16405            files: vec![V2ConflictFile {
16406                path: "records/value.md".to_string(),
16407                base: V2ConflictCoordinate {
16408                    sha256: None,
16409                    bytes: None,
16410                    file: None,
16411                },
16412                local: V2ConflictCoordinate {
16413                    sha256: None,
16414                    bytes: None,
16415                    file: None,
16416                },
16417                remote: V2ConflictCoordinate {
16418                    sha256: None,
16419                    bytes: None,
16420                    file: None,
16421                },
16422            }],
16423        };
16424        let mut bytes = serde_json::to_vec(&plan).unwrap();
16425        bytes.push(b'\n');
16426        store
16427            .write_atomic_new(&v2_conflict_relative(&expired, "plan.json"), &bytes)
16428            .unwrap();
16429
16430        let listed = sync_conflicts(&root, false, false).unwrap();
16431        assert_eq!(listed["bundles"], 2);
16432        assert_eq!(listed["pruned"], 0);
16433        let pruned = sync_conflicts(&root, true, false).unwrap();
16434        assert_eq!(pruned["bundles"], 0);
16435        assert_eq!(pruned["pruned"], 2);
16436        assert!(!root.join(".dbmd/conflicts").join(incomplete).exists());
16437        assert!(!root.join(".dbmd/conflicts").join(expired).exists());
16438    }
16439
16440    #[test]
16441    fn corrupt_completed_conflict_state_requires_explicit_discard_all() {
16442        let sandbox = tempfile::TempDir::new().unwrap();
16443        let root = sandbox.path().join("brain");
16444        std::fs::create_dir_all(&root).unwrap();
16445        std::fs::write(
16446            root.join("DB.md"),
16447            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
16448        )
16449        .unwrap();
16450        let store = Store::open_strict(&root).unwrap();
16451        let bundle = crate::ulid::mint();
16452        store
16453            .create_dir_all(&v2_conflict_relative(&bundle, "files"))
16454            .unwrap();
16455        store
16456            .write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), b"not-json\n")
16457            .unwrap();
16458
16459        assert!(sync_conflicts(&root, true, false).is_err());
16460        assert!(sync_conflicts(&root, false, true).is_err());
16461        let pruned = sync_conflicts(&root, true, true).unwrap();
16462        assert_eq!(pruned["pruned"], 1);
16463        assert!(!root.join(".dbmd/conflicts").join(bundle).exists());
16464    }
16465
16466    #[test]
16467    fn ready_pull_journal_rolls_back_exact_preimages() {
16468        let sandbox = tempfile::TempDir::new().unwrap();
16469        let root = sandbox.path().join("brain");
16470        std::fs::create_dir_all(root.join("records")).unwrap();
16471        std::fs::write(
16472            root.join("DB.md"),
16473            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
16474        )
16475        .unwrap();
16476        let path = "records/value.md";
16477        let old = b"---\ntype: note\n---\n\nold\n";
16478        let new = b"---\ntype: note\n---\n\nnew\n";
16479        std::fs::write(root.join(path), old).unwrap();
16480        let store = Store::open_strict(&root).unwrap();
16481        let bundle = crate::ulid::mint();
16482        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
16483        store
16484            .create_private_dir_all(Path::new(&backup_dir))
16485            .unwrap();
16486        store
16487            .write_private_atomic_new(&Path::new(&backup_dir).join("00000000"), old)
16488            .unwrap();
16489        let journal = V2PullJournal {
16490            v: 1,
16491            phase: V2PullPhase::Ready,
16492            brain: TEST_BRAIN_ID.to_string(),
16493            previous: V2PullCoordinate {
16494                head_seq: None,
16495                commit_hash: None,
16496                view_kind: None,
16497                view_revision: None,
16498            },
16499            next: V2PullCoordinate {
16500                head_seq: Some(2),
16501                commit_hash: Some("c".repeat(64)),
16502                view_kind: Some("full".to_string()),
16503                view_revision: Some("d".repeat(64)),
16504            },
16505            backup_dir: backup_dir.clone(),
16506            entries: vec![V2PullJournalEntry {
16507                path: path.to_string(),
16508                old: Some(V2PullFileCoordinate {
16509                    sha256: content_sha256(old),
16510                    bytes: old.len() as u64,
16511                }),
16512                new: Some(V2PullFileCoordinate {
16513                    sha256: content_sha256(new),
16514                    bytes: new.len() as u64,
16515                }),
16516                backup: Some("00000000".to_string()),
16517            }],
16518        };
16519        validate_v2_pull_journal(&journal).unwrap();
16520        store
16521            .write_private_atomic_new(
16522                Path::new(V2_PULL_JOURNAL),
16523                &v2_pull_journal_bytes(&journal).unwrap(),
16524            )
16525            .unwrap();
16526        store.write_atomic(Path::new(path), new).unwrap();
16527
16528        let cfg = test_hub_config(
16529            "https://example.test".to_string(),
16530            sandbox.path().join("state"),
16531        );
16532        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
16533        assert_eq!(std::fs::read(root.join(path)).unwrap(), old);
16534        assert!(!root.join(V2_PULL_JOURNAL).exists());
16535        assert!(!root.join(backup_dir).exists());
16536    }
16537
16538    #[test]
16539    fn preparing_pull_journal_discards_only_private_staging() {
16540        let sandbox = tempfile::TempDir::new().unwrap();
16541        let root = sandbox.path().join("brain");
16542        std::fs::create_dir_all(root.join(".dbmd")).unwrap();
16543        std::fs::write(
16544            root.join("DB.md"),
16545            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
16546        )
16547        .unwrap();
16548        let store = Store::open_strict(&root).unwrap();
16549        let bundle = crate::ulid::mint();
16550        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
16551        store
16552            .create_private_dir_all(Path::new(&backup_dir))
16553            .unwrap();
16554        let journal = V2PullJournal {
16555            v: 1,
16556            phase: V2PullPhase::Preparing,
16557            brain: TEST_BRAIN_ID.to_string(),
16558            previous: V2PullCoordinate {
16559                head_seq: None,
16560                commit_hash: None,
16561                view_kind: None,
16562                view_revision: None,
16563            },
16564            next: V2PullCoordinate {
16565                head_seq: Some(1),
16566                commit_hash: Some("a".repeat(64)),
16567                view_kind: Some("full".to_string()),
16568                view_revision: Some("b".repeat(64)),
16569            },
16570            backup_dir: backup_dir.clone(),
16571            entries: vec![V2PullJournalEntry {
16572                path: "records/new.md".to_string(),
16573                old: None,
16574                new: Some(V2PullFileCoordinate {
16575                    sha256: "c".repeat(64),
16576                    bytes: 1,
16577                }),
16578                backup: None,
16579            }],
16580        };
16581        store
16582            .write_private_atomic_new(
16583                Path::new(V2_PULL_JOURNAL),
16584                &v2_pull_journal_bytes(&journal).unwrap(),
16585            )
16586            .unwrap();
16587        let cfg = test_hub_config(
16588            "https://example.test".to_string(),
16589            sandbox.path().join("state"),
16590        );
16591
16592        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
16593
16594        assert!(root.join("DB.md").is_file());
16595        assert!(!root.join(V2_PULL_JOURNAL).exists());
16596        assert!(!root.join(backup_dir).exists());
16597    }
16598
16599    #[test]
16600    fn committed_baseline_keeps_installed_bytes_and_prunes_orphans() {
16601        let sandbox = tempfile::TempDir::new().unwrap();
16602        let root = sandbox.path().join("brain");
16603        std::fs::create_dir_all(root.join("records")).unwrap();
16604        std::fs::write(
16605            root.join("DB.md"),
16606            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
16607        )
16608        .unwrap();
16609        let new = b"---\ntype: note\n---\n\nnew\n";
16610        std::fs::write(root.join("records/value.md"), new).unwrap();
16611        let store = Store::open_strict(&root).unwrap();
16612        let bundle = crate::ulid::mint();
16613        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
16614        store
16615            .create_private_dir_all(Path::new(&backup_dir))
16616            .unwrap();
16617        let orphan = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
16618        store.create_private_dir_all(Path::new(&orphan)).unwrap();
16619        let next = V2PullCoordinate {
16620            head_seq: Some(2),
16621            commit_hash: Some("c".repeat(64)),
16622            view_kind: Some("full".to_string()),
16623            view_revision: Some("d".repeat(64)),
16624        };
16625        let journal = V2PullJournal {
16626            v: 1,
16627            phase: V2PullPhase::Ready,
16628            brain: TEST_BRAIN_ID.to_string(),
16629            previous: V2PullCoordinate {
16630                head_seq: Some(1),
16631                commit_hash: Some("a".repeat(64)),
16632                view_kind: Some("full".to_string()),
16633                view_revision: Some("b".repeat(64)),
16634            },
16635            next: next.clone(),
16636            backup_dir: backup_dir.clone(),
16637            entries: vec![V2PullJournalEntry {
16638                path: "records/value.md".to_string(),
16639                old: Some(V2PullFileCoordinate {
16640                    sha256: "e".repeat(64),
16641                    bytes: new.len() as u64,
16642                }),
16643                new: Some(V2PullFileCoordinate {
16644                    sha256: content_sha256(new),
16645                    bytes: new.len() as u64,
16646                }),
16647                backup: Some("00000000".to_string()),
16648            }],
16649        };
16650        store
16651            .write_private_atomic_new(
16652                Path::new(V2_PULL_JOURNAL),
16653                &v2_pull_journal_bytes(&journal).unwrap(),
16654            )
16655            .unwrap();
16656        let cfg = test_hub_config(
16657            "https://example.test".to_string(),
16658            sandbox.path().join("state"),
16659        );
16660        save_v2_baseline(
16661            &cfg,
16662            TEST_BRAIN_ID,
16663            &root,
16664            &V2SyncBaseline {
16665                v: 2,
16666                origin: "https://example.test".to_string(),
16667                brain: TEST_BRAIN_ID.to_string(),
16668                checkout_id: Some("c".repeat(64)),
16669                head_seq: next.head_seq,
16670                commit_hash: next.commit_hash.clone(),
16671                content_root: Some("f".repeat(64)),
16672                asset_root: None,
16673                assets: Default::default(),
16674                view_kind: next.view_kind.clone(),
16675                view_revision: next.view_revision.clone(),
16676                projection_sha256: None,
16677                files: Default::default(),
16678                local_policy_digest: None,
16679                local_eligibility: Default::default(),
16680                remote_copy_remains: Default::default(),
16681            },
16682        )
16683        .unwrap();
16684
16685        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
16686
16687        assert_eq!(std::fs::read(root.join("records/value.md")).unwrap(), new);
16688        assert!(!root.join(V2_PULL_JOURNAL).exists());
16689        assert!(!root.join(backup_dir).exists());
16690        assert!(!root.join(orphan).exists());
16691    }
16692}