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
6146// A source is immutable, but its exact bytes may move from an intake namespace
6147// to an archive namespace under the distinct `promote_source` capability. A
6148// plain filesystem move appears to sync as one absent old path plus one new
6149// path. Preserve that intent as a protocol rename only when the hash has one
6150// unambiguous source delete and one unambiguous absent-destination source put.
6151// Ambiguous duplicate bytes stay as put/delete and fail closed at the hub.
6152fn infer_exact_source_promotions(operations: Vec<Value>) -> Vec<Value> {
6153    let mut deletes = std::collections::BTreeMap::<String, Vec<(usize, String)>>::new();
6154    let mut puts = std::collections::BTreeMap::<String, Vec<(usize, String, u64)>>::new();
6155    for (index, operation) in operations.iter().enumerate() {
6156        match operation.get("op").and_then(Value::as_str) {
6157            Some("delete") => {
6158                let Some(path) = operation.get("path").and_then(Value::as_str) else {
6159                    continue;
6160                };
6161                let Some(hash) = operation
6162                    .get("expected")
6163                    .and_then(|value| value.get("hash"))
6164                    .and_then(Value::as_str)
6165                else {
6166                    continue;
6167                };
6168                if path.starts_with("sources/") {
6169                    deletes
6170                        .entry(hash.to_string())
6171                        .or_default()
6172                        .push((index, path.to_string()));
6173                }
6174            }
6175            Some("put") => {
6176                let Some(path) = operation.get("path").and_then(Value::as_str) else {
6177                    continue;
6178                };
6179                let Some(hash) = operation.get("blob").and_then(Value::as_str) else {
6180                    continue;
6181                };
6182                let Some(bytes) = operation.get("bytes").and_then(Value::as_u64) else {
6183                    continue;
6184                };
6185                let destination_absent = operation
6186                    .get("expected")
6187                    .and_then(|value| value.get("kind"))
6188                    .and_then(Value::as_str)
6189                    == Some("absent");
6190                if path.starts_with("sources/") && destination_absent {
6191                    puts.entry(hash.to_string()).or_default().push((
6192                        index,
6193                        path.to_string(),
6194                        bytes,
6195                    ));
6196                }
6197            }
6198            _ => {}
6199        }
6200    }
6201    let mut rename_at = std::collections::BTreeMap::<usize, Value>::new();
6202    let mut consumed_puts = std::collections::BTreeSet::<usize>::new();
6203    for (hash, source) in deletes {
6204        let Some(destination) = puts.get(&hash) else {
6205            continue;
6206        };
6207        if source.len() != 1 || destination.len() != 1 {
6208            continue;
6209        }
6210        let (delete_index, from) = &source[0];
6211        let (put_index, to, bytes) = &destination[0];
6212        if from == to {
6213            continue;
6214        }
6215        rename_at.insert(
6216            *delete_index,
6217            json!({
6218                "op": "rename",
6219                "from": from,
6220                "to": to,
6221                "expected_from": { "kind": "blob", "hash": hash },
6222                "expected_to": { "kind": "absent" },
6223                "blob": hash,
6224                "bytes": bytes,
6225            }),
6226        );
6227        consumed_puts.insert(*put_index);
6228    }
6229    operations
6230        .into_iter()
6231        .enumerate()
6232        .filter_map(|(index, operation)| {
6233            if let Some(rename) = rename_at.remove(&index) {
6234                Some(rename)
6235            } else if consumed_puts.contains(&index) {
6236                None
6237            } else {
6238                Some(operation)
6239            }
6240        })
6241        .collect()
6242}
6243
6244fn v2_asset_value(record: &crate::AssetRecord, disposition: &str) -> Value {
6245    json!({
6246        "blob_sha256": record.sha256,
6247        "bytes": record.bytes,
6248        "media_type": record.media_type,
6249        "wrappers": record.wrappers,
6250        "required": record.required,
6251        "disposition": disposition,
6252    })
6253}
6254
6255/// Apply the exact operation subset generated by this client to an already
6256/// verified manifest. This avoids downloading the same 50k-path listing again
6257/// after an unrebased commit; a rebased response still re-reads the manifest.
6258fn apply_generated_v2_operations(
6259    operations: &[Value],
6260    local_assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
6261    candidate: &mut std::collections::BTreeMap<String, V2BaselineFile>,
6262    candidate_assets: &mut std::collections::BTreeMap<String, V2BaselineAsset>,
6263) -> LinkResult<bool> {
6264    let mut asset_changed = false;
6265    for operation in operations {
6266        match operation.get("op").and_then(Value::as_str) {
6267            Some("put") => {
6268                let path = operation
6269                    .get("path")
6270                    .and_then(Value::as_str)
6271                    .ok_or_else(|| invalid_feed("v2 put has no path"))?;
6272                let sha256 = operation
6273                    .get("blob")
6274                    .and_then(Value::as_str)
6275                    .ok_or_else(|| invalid_feed("v2 put has no blob"))?;
6276                let bytes = operation
6277                    .get("bytes")
6278                    .and_then(Value::as_u64)
6279                    .ok_or_else(|| invalid_feed("v2 put has no byte count"))?;
6280                candidate.insert(
6281                    path.to_string(),
6282                    V2BaselineFile {
6283                        sha256: sha256.to_string(),
6284                        bytes,
6285                        proof: None,
6286                    },
6287                );
6288            }
6289            Some("rename") => {
6290                let from = operation
6291                    .get("from")
6292                    .and_then(Value::as_str)
6293                    .ok_or_else(|| invalid_feed("v2 rename has no source path"))?;
6294                let to = operation
6295                    .get("to")
6296                    .and_then(Value::as_str)
6297                    .ok_or_else(|| invalid_feed("v2 rename has no destination path"))?;
6298                let sha256 = operation
6299                    .get("blob")
6300                    .and_then(Value::as_str)
6301                    .ok_or_else(|| invalid_feed("v2 rename has no blob"))?;
6302                let bytes = operation
6303                    .get("bytes")
6304                    .and_then(Value::as_u64)
6305                    .ok_or_else(|| invalid_feed("v2 rename has no byte count"))?;
6306                let expected_from = operation
6307                    .get("expected_from")
6308                    .and_then(|expected| expected.get("hash"))
6309                    .and_then(Value::as_str);
6310                let expected_to_absent = operation
6311                    .get("expected_to")
6312                    .and_then(|expected| expected.get("kind"))
6313                    .and_then(Value::as_str)
6314                    == Some("absent");
6315                if from == to
6316                    || !from.starts_with("sources/")
6317                    || !to.starts_with("sources/")
6318                    || expected_from != Some(sha256)
6319                    || !expected_to_absent
6320                    || candidate.contains_key(to)
6321                {
6322                    return Err(invalid_feed("generated v2 source rename is malformed"));
6323                }
6324                let source = candidate
6325                    .remove(from)
6326                    .ok_or_else(|| invalid_feed("v2 rename source is absent"))?;
6327                if source.sha256 != sha256 || source.bytes != bytes {
6328                    return Err(invalid_feed(
6329                        "v2 rename source differs from its exact-byte claim",
6330                    ));
6331                }
6332                candidate.insert(
6333                    to.to_string(),
6334                    V2BaselineFile {
6335                        sha256: sha256.to_string(),
6336                        bytes,
6337                        proof: None,
6338                    },
6339                );
6340            }
6341            Some("delete" | "withdraw_from_hosting") => {
6342                let path = operation
6343                    .get("path")
6344                    .and_then(Value::as_str)
6345                    .ok_or_else(|| invalid_feed("v2 delete has no path"))?;
6346                candidate.remove(path);
6347            }
6348            Some("asset_delete") => {
6349                let path = operation
6350                    .get("path")
6351                    .and_then(Value::as_str)
6352                    .ok_or_else(|| invalid_feed("v2 asset delete has no path"))?;
6353                candidate_assets.remove(path);
6354                asset_changed = true;
6355            }
6356            Some("asset_put" | "asset_resume" | "asset_withdraw") => {
6357                let path = operation
6358                    .get("path")
6359                    .and_then(Value::as_str)
6360                    .ok_or_else(|| invalid_feed("v2 asset write has no path"))?;
6361                let record = local_assets
6362                    .get(path)
6363                    .ok_or_else(|| invalid_feed("v2 asset write has no local record"))?;
6364                let disposition =
6365                    if operation.get("op").and_then(Value::as_str) == Some("asset_withdraw") {
6366                        "withheld"
6367                    } else {
6368                        operation
6369                            .get("asset")
6370                            .and_then(|asset| asset.get("disposition"))
6371                            .and_then(Value::as_str)
6372                            .ok_or_else(|| invalid_feed("v2 asset write has no disposition"))?
6373                    };
6374                candidate_assets.insert(
6375                    path.to_string(),
6376                    V2BaselineAsset {
6377                        blob_sha256: record.sha256.clone(),
6378                        bytes: record.bytes,
6379                        media_type: record.media_type.clone(),
6380                        wrappers: record.wrappers.clone(),
6381                        required: record.required,
6382                        disposition: disposition.to_string(),
6383                        // The server assigns the signed leaf address. Callers
6384                        // that need an exact asset manifest re-read it below.
6385                        leaf_hash: String::new(),
6386                    },
6387                );
6388                asset_changed = true;
6389            }
6390            _ => return Err(invalid_feed("dbmd generated an unsupported v2 operation")),
6391        }
6392    }
6393    Ok(asset_changed)
6394}
6395
6396fn v2_riding_matches_remote(
6397    local: &std::collections::BTreeMap<String, (String, u64)>,
6398    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6399    keeps_home: impl Fn(&str) -> bool,
6400) -> bool {
6401    remote.iter().all(|(path, file)| {
6402        keeps_home(path)
6403            || local.get(path).map(|value| value.0.as_str()) == Some(file.sha256.as_str())
6404    }) && local.iter().all(|(path, (hash, _))| {
6405        remote.get(path).map(|file| file.sha256.as_str()) == Some(hash.as_str())
6406    })
6407}
6408
6409#[derive(Debug, Clone)]
6410struct V2ResolutionOverride {
6411    expected_remote: Option<String>,
6412    selected_local: Option<String>,
6413}
6414
6415#[derive(Debug, Clone)]
6416struct V2UploadSource {
6417    path: String,
6418    bytes: u64,
6419}
6420
6421struct V2SyncPushOptions<'a> {
6422    resume_local_policy: bool,
6423    bulk_confirmation: Option<&'a V2BulkConfirmation>,
6424    resolution: Option<&'a std::collections::BTreeMap<String, V2ResolutionOverride>>,
6425    pulled: Option<V2PulledSnapshot>,
6426    withdrawal_paths: &'a [String],
6427    withdrawal_reason: Option<&'a str>,
6428}
6429
6430fn verify_v2_upload_source(
6431    store: &Store,
6432    path: &str,
6433    sha256: &str,
6434    expected_bytes: u64,
6435) -> LinkResult<()> {
6436    let file = store.open_regular(Path::new(path))?;
6437    if file.metadata()?.len() != expected_bytes || content_sha256_reader(file)? != sha256 {
6438        return Err(LinkError::InvalidPack {
6439            message: format!("local path `{path}` changed during sync planning"),
6440        });
6441    }
6442    Ok(())
6443}
6444
6445fn put_presigned_source(
6446    cfg: &HubConfig,
6447    raw: &str,
6448    headers: &Value,
6449    store: &Store,
6450    source: &V2UploadSource,
6451) -> LinkResult<()> {
6452    let http = presigned_agent(cfg, raw)?;
6453    let mut attempt = 0;
6454    let result = loop {
6455        let file = store.open_regular(Path::new(&source.path))?;
6456        if file.metadata()?.len() != source.bytes {
6457            return Err(LinkError::InvalidPack {
6458                message: format!("local path `{}` changed before upload", source.path),
6459            });
6460        }
6461        let mut req = http
6462            .put(raw)
6463            .set("Content-Length", &source.bytes.to_string());
6464        if let Some(map) = headers.as_object() {
6465            for (name, value) in map {
6466                if let Some(value) = value.as_str() {
6467                    req = req.set(name, value);
6468                }
6469            }
6470        }
6471        match req.send(file) {
6472            Err(ureq::Error::Transport(error))
6473                if is_pre_request_transport(error.kind()) && attempt + 1 < CONNECT_ATTEMPTS =>
6474            {
6475                std::thread::sleep(std::time::Duration::from_millis(
6476                    CONNECT_RETRY_BACKOFF_MS[attempt],
6477                ));
6478                attempt += 1;
6479            }
6480            result => break result,
6481        }
6482    };
6483    match result {
6484        Ok(response) if (200..300).contains(&response.status()) => Ok(()),
6485        Ok(response) => Err(LinkError::Http {
6486            what: "v2 changed-byte upload",
6487            status: response.status(),
6488            message: "object store rejected the upload".to_string(),
6489            code: None,
6490            details: None,
6491        }),
6492        Err(error) => match error {
6493            ureq::Error::Status(412, _) => Ok(()),
6494            ureq::Error::Status(_, response) => Err(LinkError::Http {
6495                what: "v2 changed-byte upload",
6496                status: response.status(),
6497                message: "object store rejected the upload".to_string(),
6498                code: None,
6499                details: None,
6500            }),
6501            ureq::Error::Transport(error) => Err(LinkError::Transport {
6502                hub: "the object store".to_string(),
6503                message: error.to_string(),
6504            }),
6505        },
6506    }
6507}
6508
6509fn v2_sync_push(
6510    cfg: &HubConfig,
6511    requested_brain: &str,
6512    store: &Store,
6513    head: V2VerifiedHead,
6514    options: V2SyncPushOptions<'_>,
6515) -> LinkResult<Value> {
6516    let V2SyncPushOptions {
6517        resume_local_policy,
6518        bulk_confirmation,
6519        resolution,
6520        pulled,
6521        withdrawal_paths,
6522        withdrawal_reason,
6523    } = options;
6524    let _operation_lock = lock_v2_sync_operation(cfg, &head.brain_id)?;
6525    let head = v2_verified_head(cfg, requested_brain)?
6526        .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
6527    let (mut remote, mut remote_assets, carried_local, carried_local_assets) =
6528        match pulled.filter(|snapshot| same_v2_head(&snapshot.head, &head)) {
6529            Some(snapshot) => (
6530                snapshot.files,
6531                snapshot.assets,
6532                Some(snapshot.local),
6533                Some(snapshot.local_assets),
6534            ),
6535            None => (
6536                files_for_v2_view(
6537                    &head,
6538                    v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6539                ),
6540                v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6541                None,
6542                None,
6543            ),
6544        };
6545    let baseline = load_v2_baseline(cfg, &head.brain_id, &store.root)?;
6546    ensure_v2_view_compatible(&head, baseline.as_ref())?;
6547    if head.view_kind == "scoped" && baseline.is_none() {
6548        return Err(LinkError::ScopedViewChanged);
6549    }
6550    let local_view = local_view_for_v2_push(store, &head, baseline.as_ref(), carried_local)?;
6551    let local = &local_view.riding;
6552    let local_assets = match carried_local_assets {
6553        Some(assets) => assets,
6554        None => v2_local_asset_records(store)?,
6555    };
6556    if withdrawal_paths.len() > MAX_PUSH_FILES {
6557        return Err(LinkError::PushTooLarge {
6558            detail: "too many explicit withdrawal paths".to_string(),
6559        });
6560    }
6561    let withdrawal_reason = if withdrawal_paths.is_empty() {
6562        None
6563    } else {
6564        let reason = withdrawal_reason
6565            .map(str::trim)
6566            .filter(|reason| !reason.is_empty() && reason.len() <= 1000)
6567            .ok_or_else(|| LinkError::InvalidPack {
6568                message: "explicit withdrawal requires a non-empty --withdraw-reason of at most 1000 bytes".to_string(),
6569            })?;
6570        Some(reason)
6571    };
6572    let mut withdrawals = withdrawal_paths
6573        .iter()
6574        .map(|path| {
6575            crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
6576                path: error.to_string(),
6577            })
6578        })
6579        .collect::<LinkResult<Vec<_>>>()?;
6580    withdrawals.sort();
6581    withdrawals.dedup();
6582    if withdrawals.len() != withdrawal_paths.len() {
6583        return Err(LinkError::InvalidPack {
6584            message: "explicit withdrawal paths must be unique".to_string(),
6585        });
6586    }
6587    let withdrawal_set = withdrawals.iter().cloned().collect::<BTreeSet<_>>();
6588    let mut consumed_withdrawals = BTreeSet::new();
6589    if let Some(previous) = baseline.as_ref() {
6590        if previous.local_policy_digest.as_deref() != Some(&local_view.policy.digest)
6591            && !resume_local_policy
6592        {
6593            let mut newly_eligible = previous
6594                .local_eligibility
6595                .iter()
6596                .filter(|(path, riding)| !**riding && !local_view.policy.keeps_home(path))
6597                .map(|(path, _)| path.clone())
6598                .collect::<Vec<_>>();
6599            if !newly_eligible.is_empty() {
6600                newly_eligible.truncate(100);
6601                return Err(LinkError::LocalPolicyTransition {
6602                    paths: newly_eligible,
6603                });
6604            }
6605        }
6606    }
6607    let base = match baseline.as_ref() {
6608        Some(state) => &state.files,
6609        None if remote.is_empty() => &remote,
6610        None => {
6611            let mut conflicts = remote
6612                .iter()
6613                .filter(|(path, file)| {
6614                    local.get(*path).map(|value| value.0.as_str()) != Some(file.sha256.as_str())
6615                })
6616                .map(|(path, _)| path.clone())
6617                .collect::<Vec<_>>();
6618            if !conflicts.is_empty() {
6619                conflicts.truncate(100);
6620                let (bundle, paths) =
6621                    create_v2_conflict_bundle(cfg, store, &head, None, local, &remote, &conflicts)?;
6622                return Err(LinkError::ConflictBundle { bundle, paths });
6623            }
6624            &remote
6625        }
6626    };
6627    let all_paths = base
6628        .keys()
6629        .chain(remote.keys())
6630        .chain(local.keys())
6631        .cloned()
6632        .collect::<std::collections::BTreeSet<_>>();
6633    let mut conflicts = Vec::new();
6634    let mut operations = Vec::new();
6635    let mut upload_sources = std::collections::BTreeMap::<String, V2UploadSource>::new();
6636    for path in all_paths {
6637        let base_hash = base.get(&path).map(|file| file.sha256.as_str());
6638        let remote_file = remote.get(&path);
6639        let remote_hash = remote_file.map(|file| file.sha256.as_str());
6640        let local_file = local.get(&path);
6641        let local_hash = local_file.map(|file| file.0.as_str());
6642        if local_hash == base_hash {
6643            continue;
6644        }
6645        if resolution.is_some_and(|allowed| !allowed.contains_key(&path)) {
6646            continue;
6647        }
6648        if local_view.policy.keeps_home(&path) {
6649            // Kept-home is a local transfer exclusion, never an implicit
6650            // delete of the company's already-hosted coordinate.
6651            continue;
6652        }
6653        if remote_hash != base_hash && local_hash != remote_hash {
6654            let explicitly_resolved = resolution
6655                .and_then(|allowed| allowed.get(&path))
6656                .is_some_and(|selected| {
6657                    selected.expected_remote.as_deref() == remote_hash
6658                        && selected.selected_local.as_deref() == local_hash
6659                });
6660            if !explicitly_resolved {
6661                conflicts.push(path);
6662                continue;
6663            }
6664        }
6665        match local_file {
6666            Some((sha256, byte_count)) => {
6667                verify_v2_upload_source(store, &path, sha256, *byte_count)?;
6668                operations.push(json!({
6669                    "op": "put",
6670                    "path": path,
6671                    "expected": v2_expected(remote_file),
6672                    "blob": sha256,
6673                    "bytes": byte_count,
6674                }));
6675                upload_sources
6676                    .entry(sha256.clone())
6677                    .or_insert_with(|| V2UploadSource {
6678                        path: path.clone(),
6679                        bytes: *byte_count,
6680                    });
6681            }
6682            None => {
6683                let Some(current) = remote_file else {
6684                    continue;
6685                };
6686                operations.push(json!({
6687                    "op": "delete",
6688                    "path": path,
6689                    "expected": { "kind": "blob", "hash": current.sha256 },
6690                }));
6691            }
6692        }
6693    }
6694    operations = infer_exact_source_promotions(operations);
6695    for path in &withdrawals {
6696        if local_assets.contains_key(path) {
6697            continue;
6698        }
6699        operations.push(v2_content_withdrawal_operation(
6700            store,
6701            &local_view,
6702            &remote,
6703            path,
6704            withdrawal_reason.expect("non-empty withdrawal set has a reason"),
6705        )?);
6706        consumed_withdrawals.insert(path.clone());
6707    }
6708    if !conflicts.is_empty() {
6709        conflicts.truncate(100);
6710        let (bundle, paths) = create_v2_conflict_bundle(
6711            cfg,
6712            store,
6713            &head,
6714            baseline.as_ref(),
6715            local,
6716            &remote,
6717            &conflicts,
6718        )?;
6719        return Err(LinkError::ConflictBundle { bundle, paths });
6720    }
6721    let base_assets = match baseline.as_ref() {
6722        Some(state) => &state.assets,
6723        None if remote_assets.is_empty() => &remote_assets,
6724        None => {
6725            let mismatched = remote_assets.iter().any(|(path, remote)| {
6726                local_assets.get(path) != Some(&v2_asset_record(remote, path))
6727            }) || local_assets.len() != remote_assets.len();
6728            if mismatched {
6729                return Err(LinkError::Conflict {
6730                    paths: vec!["assets.jsonl".to_string()],
6731                });
6732            }
6733            &remote_assets
6734        }
6735    };
6736    let asset_paths = base_assets
6737        .keys()
6738        .chain(remote_assets.keys())
6739        .chain(local_assets.keys())
6740        .cloned()
6741        .collect::<std::collections::BTreeSet<_>>();
6742    let mut asset_policy_transitions = Vec::new();
6743    for path in asset_paths {
6744        let base_record = base_assets
6745            .get(&path)
6746            .map(|asset| v2_asset_record(asset, &path));
6747        let remote = remote_assets.get(&path);
6748        let remote_record = remote.map(|asset| v2_asset_record(asset, &path));
6749        let local_record = local_assets.get(&path);
6750        if withdrawal_set.contains(&path) {
6751            let record = local_record.ok_or_else(|| LinkError::InvalidPack {
6752                message: format!("asset withdrawal path `{path}` is absent from assets.jsonl"),
6753            })?;
6754            let current = remote.ok_or_else(|| LinkError::InvalidPack {
6755                message: format!(
6756                    "asset withdrawal path `{path}` has no readable hosted coordinate"
6757                ),
6758            })?;
6759            operations.push(v2_asset_withdrawal_operation(
6760                store,
6761                &local_view,
6762                &path,
6763                record,
6764                current,
6765                withdrawal_reason.expect("non-empty withdrawal set has a reason"),
6766            )?);
6767            consumed_withdrawals.insert(path.clone());
6768            continue;
6769        }
6770        let mut raw_present = false;
6771        let mut disposition = "withheld";
6772        let mut resumes_hosting = false;
6773        if let Some(record) = local_record {
6774            crate::linkmd_v2::normalize_path(&record.path)
6775                .map_err(|error| invalid_feed(error.to_string()))?;
6776            let kept_home = local_view.policy.keeps_home(&path);
6777            raw_present = matches!(store.regular_file_exists(Path::new(&path)), Ok(true));
6778            disposition = if kept_home || !raw_present {
6779                "withheld"
6780            } else {
6781                "hosted"
6782            };
6783            if !raw_present && record.required && !kept_home {
6784                return Err(LinkError::InvalidPack {
6785                    message: format!("required asset {path} is missing"),
6786                });
6787            }
6788            resumes_hosting = v2_asset_resumes_hosting(remote, &path, record, disposition);
6789        }
6790        if local_record == base_record.as_ref() && !resumes_hosting {
6791            continue;
6792        }
6793        if remote_record != base_record && local_record != remote_record.as_ref() {
6794            conflicts.push(path);
6795            continue;
6796        }
6797        let Some(record) = local_record else {
6798            if let Some(remote) = remote {
6799                operations.push(json!({
6800                    "op": "asset_delete",
6801                    "path": path,
6802                    "expected": v2_asset_expected(Some(remote)),
6803                }));
6804            }
6805            continue;
6806        };
6807        let raw = if raw_present {
6808            verify_v2_upload_source(store, &path, &record.sha256, record.bytes)?;
6809            Some(())
6810        } else {
6811            None
6812        };
6813        let op = if resumes_hosting {
6814            if !resume_local_policy {
6815                asset_policy_transitions.push(path);
6816                continue;
6817            }
6818            "asset_resume"
6819        } else {
6820            "asset_put"
6821        };
6822        operations.push(json!({
6823            "op": op,
6824            "path": path,
6825            "expected": v2_asset_expected(remote),
6826            "asset": v2_asset_value(record, disposition),
6827        }));
6828        if disposition == "hosted" {
6829            raw.expect("hosted asset was checked present");
6830            upload_sources
6831                .entry(record.sha256.clone())
6832                .or_insert_with(|| V2UploadSource {
6833                    path: path.clone(),
6834                    bytes: record.bytes,
6835                });
6836        }
6837    }
6838    if consumed_withdrawals != withdrawal_set {
6839        let missing = withdrawal_set
6840            .difference(&consumed_withdrawals)
6841            .next()
6842            .expect("different withdrawal sets have one member");
6843        return Err(LinkError::InvalidPack {
6844            message: format!(
6845                "withdrawal path `{missing}` is not a readable content or asset coordinate"
6846            ),
6847        });
6848    }
6849    if !conflicts.is_empty() {
6850        conflicts.truncate(100);
6851        return Err(LinkError::Conflict { paths: conflicts });
6852    }
6853    if !asset_policy_transitions.is_empty() {
6854        asset_policy_transitions.truncate(100);
6855        return Err(LinkError::LocalPolicyTransition {
6856            paths: asset_policy_transitions,
6857        });
6858    }
6859    let touched_sources = operations
6860        .iter()
6861        .filter_map(
6862            |operation| match operation.get("op").and_then(Value::as_str) {
6863                Some("put" | "restore") => operation.get("path").and_then(Value::as_str),
6864                Some("rename") => operation.get("to").and_then(Value::as_str),
6865                _ => None,
6866            },
6867        )
6868        .collect::<std::collections::BTreeSet<_>>();
6869    let withheld_links = local_view
6870        .withheld_links
6871        .iter()
6872        .filter(|link| touched_sources.contains(link.source.as_str()))
6873        .collect::<Vec<_>>();
6874    let checkout_pseudonym = v2_checkout_id(
6875        baseline
6876            .as_ref()
6877            .and_then(|current| current.checkout_id.as_deref()),
6878    )?;
6879    let checkout_id = if withheld_links.is_empty() {
6880        None
6881    } else {
6882        Some(checkout_pseudonym.clone())
6883    };
6884    if operations.is_empty() {
6885        let final_head = v2_verified_head(cfg, requested_brain)?
6886            .ok_or_else(|| invalid_feed("v2 head disappeared during sync"))?;
6887        if !same_v2_head(&head, &final_head) {
6888            return Err(LinkError::RemoteAdvancedDuringSync);
6889        }
6890        let mut final_local = v2_local_files(store)?;
6891        remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
6892        let final_assets = v2_local_asset_records(store)?;
6893        let local_changed = final_local.riding != local_view.riding || final_assets != local_assets;
6894        let remote_ahead = !v2_riding_matches_remote(&final_local.riding, &remote, |path| {
6895            final_local.policy.keeps_home(path)
6896        }) || !v2_asset_records_match_remote(&final_assets, &remote_assets);
6897        let next = v2_baseline_from_head(
6898            cfg,
6899            &head,
6900            remote,
6901            remote_assets,
6902            Some(&final_local),
6903            Some(&checkout_pseudonym),
6904        )?;
6905        let split_count = next.remote_copy_remains.len();
6906        accept_v2_head(cfg, &final_head)?;
6907        if !local_changed && !remote_ahead {
6908            refresh_scoped_view_marker(store, &head, next.files.len())?;
6909            save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
6910        }
6911        return Ok(json!({
6912            "v": 2,
6913            "outcome": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "no_change" },
6914            "sync_status": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "synced" },
6915            "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
6916            "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
6917            "local_policy": {
6918                "remote_copy_remains": split_count,
6919            },
6920        }));
6921    }
6922    let includes_contract = operations
6923        .iter()
6924        .any(|operation| operation.get("path").and_then(Value::as_str) == Some("DB.md"));
6925    let rebase = if head.pointer.is_none() || includes_contract {
6926        "strict"
6927    } else {
6928        "disjoint"
6929    };
6930    let base_value = head.pointer.as_ref().map(|pointer| {
6931        json!({
6932            "seq": pointer.seq,
6933            "commit_hash": pointer.commit_hash,
6934            "content_root": pointer.content_root,
6935            "asset_root": pointer.asset_root,
6936        })
6937    });
6938    // The mutation coordinate is stable for the exact observed base and local
6939    // operation set. A lost response can therefore be retried without a
6940    // duplicate commit/proposal even across process restarts.
6941    let entropy = format!(
6942        "{}\0{}\0{}\0{}\0{}\0{}",
6943        normalized_origin(&cfg.hub)?,
6944        head.brain_id,
6945        serde_json::to_string(&base_value).unwrap_or_default(),
6946        serde_json::to_string(&operations).unwrap_or_default(),
6947        serde_json::to_string(&withheld_links).unwrap_or_default(),
6948        checkout_id.as_deref().unwrap_or("")
6949    );
6950    let mutation_id = format!("dbmd-{}", content_sha256(entropy.as_bytes()));
6951    let changed_bytes = upload_sources.values().try_fold(0_u64, |total, source| {
6952        total
6953            .checked_add(source.bytes)
6954            .ok_or_else(|| LinkError::PushTooLarge {
6955                detail: "v2 changed-byte total overflow".to_string(),
6956            })
6957    })?;
6958    let inline = changed_bytes <= 3 * 1024 * 1024;
6959    let inline_blobs = if inline {
6960        upload_sources
6961            .iter()
6962            .map(|(sha256, source)| {
6963                let bytes = store.read_bounded(Path::new(&source.path), source.bytes)?;
6964                if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != *sha256 {
6965                    return Err(LinkError::InvalidPack {
6966                        message: format!("local path `{}` changed before upload", source.path),
6967                    });
6968                }
6969                Ok(json!({
6970                    "sha256": sha256,
6971                    "bytes": source.bytes,
6972                    "content_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
6973                }))
6974            })
6975            .collect::<LinkResult<Vec<_>>>()?
6976    } else {
6977        Vec::new()
6978    };
6979    let mut body = json!({
6980        "mutation_id": mutation_id,
6981        "base": base_value,
6982        "rebase": rebase,
6983        "reason": "dbmd sync",
6984        "operations": operations,
6985        "blobs": inline_blobs,
6986    });
6987    if !withheld_links.is_empty() {
6988        body["withheld_links"] = serde_json::to_value(&withheld_links)
6989            .map_err(|_| invalid_feed("could not serialize withheld-link observations"))?;
6990        body["checkout_id"] =
6991            Value::String(checkout_id.expect("non-empty withheld links have a checkout pseudonym"));
6992    }
6993    if let Some(confirmation) = bulk_confirmation {
6994        if !crate::ulid::is_ulid(&confirmation.id) || !is_sha256(&confirmation.digest) {
6995            return Err(LinkError::InvalidPack {
6996                message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
6997                    .to_string(),
6998            });
6999        }
7000        // A preview is bound to the exact observed head. Confirmation can
7001        // therefore never use disjoint rebase semantics, even when the normal
7002        // small-mutation path could.
7003        body["rebase"] = Value::String("strict".to_string());
7004        body["bulk_preview_id"] = Value::String(confirmation.id.clone());
7005        body["bulk_preview_digest"] = Value::String(confirmation.digest.clone());
7006    }
7007    if !inline || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
7008        let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
7009        for operation in &operations {
7010            let Some(kind) = operation.get("op").and_then(Value::as_str) else {
7011                return Err(invalid_feed("v2 upload operation has no kind"));
7012            };
7013            let hash = match kind {
7014                "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
7015                "asset_put" | "asset_resume" => operation
7016                    .get("asset")
7017                    .and_then(|asset| asset.get("blob_sha256"))
7018                    .and_then(Value::as_str),
7019                _ => None,
7020            };
7021            let Some(hash) = hash else { continue };
7022            let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
7023            if kind == "rename" {
7024                for field in ["from", "to"] {
7025                    coordinates.insert(
7026                        operation
7027                            .get(field)
7028                            .and_then(Value::as_str)
7029                            .ok_or_else(|| invalid_feed("v2 rename upload has no coordinate"))?
7030                            .to_string(),
7031                    );
7032                }
7033            } else {
7034                let path = operation
7035                    .get("path")
7036                    .and_then(Value::as_str)
7037                    .ok_or_else(|| invalid_feed("v2 upload has no coordinate"))?;
7038                coordinates.insert(if kind.starts_with("asset_") {
7039                    format!("assets/{path}")
7040                } else {
7041                    path.to_string()
7042                });
7043            }
7044        }
7045        let declarations = upload_sources
7046            .iter()
7047            .map(|(sha256, source)| {
7048                json!({
7049                    "sha256": sha256,
7050                    "bytes": source.bytes,
7051                    "coordinates": coordinates_by_hash
7052                        .get(sha256)
7053                        .into_iter()
7054                        .flatten()
7055                        .collect::<Vec<_>>(),
7056                })
7057            })
7058            .collect::<Vec<_>>();
7059        let reserved = ensure_ok(
7060            request(
7061                cfg,
7062                "POST",
7063                &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
7064                Some(&json!({ "blobs": declarations })),
7065                Auth::Required,
7066            )?,
7067            "prepare v2 changed-byte uploads",
7068        )?;
7069        let items = reserved
7070            .get("uploads")
7071            .and_then(Value::as_array)
7072            .ok_or_else(|| invalid_feed("v2 upload reservation response has no items"))?;
7073        if items.len() != upload_sources.len() {
7074            return Err(invalid_feed(
7075                "v2 upload reservation response changed the requested set",
7076            ));
7077        }
7078        let mut references = Vec::with_capacity(items.len());
7079        let mut seen = std::collections::BTreeSet::new();
7080        for item in items {
7081            let sha256 = item
7082                .get("sha256")
7083                .and_then(Value::as_str)
7084                .ok_or_else(|| invalid_feed("v2 upload reservation has no hash"))?;
7085            let source = upload_sources
7086                .get(sha256)
7087                .ok_or_else(|| invalid_feed("v2 upload reservation introduced a blob"))?;
7088            let declared_bytes = item
7089                .get("bytes")
7090                .and_then(Value::as_u64)
7091                .ok_or_else(|| invalid_feed("v2 upload reservation has no byte length"))?;
7092            let reservation_id = item
7093                .get("reservation_id")
7094                .and_then(Value::as_str)
7095                .ok_or_else(|| invalid_feed("v2 upload reservation has no opaque id"))?;
7096            let expected_coordinates = coordinates_by_hash
7097                .get(sha256)
7098                .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinate binding"))?;
7099            let returned_coordinates = item
7100                .get("coordinates")
7101                .and_then(Value::as_array)
7102                .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinates"))?;
7103            if declared_bytes != source.bytes
7104                || !crate::ulid::is_ulid(reservation_id)
7105                || !seen.insert(sha256.to_string())
7106                || returned_coordinates.len() != expected_coordinates.len()
7107                || returned_coordinates
7108                    .iter()
7109                    .zip(expected_coordinates)
7110                    .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
7111            {
7112                return Err(invalid_feed("v2 upload reservation item is inconsistent"));
7113            }
7114            match item.get("status").and_then(Value::as_str) {
7115                Some("upload") => {
7116                    let url = item
7117                        .get("url")
7118                        .and_then(Value::as_str)
7119                        .ok_or_else(|| invalid_feed("v2 upload reservation has no URL"))?;
7120                    put_presigned_source(
7121                        cfg,
7122                        url,
7123                        item.get("headers").unwrap_or(&Value::Null),
7124                        store,
7125                        source,
7126                    )?;
7127                    verify_v2_upload_source(store, &source.path, sha256, source.bytes)?;
7128                }
7129                Some("already_present") => {}
7130                _ => return Err(invalid_feed("v2 upload reservation has an unknown status")),
7131            }
7132            references.push(json!({
7133                "sha256": sha256,
7134                "bytes": source.bytes,
7135                "reservation_id": reservation_id,
7136            }));
7137        }
7138        body["blobs"] = Value::Array(references);
7139    }
7140    if body.to_string().len() > MAX_PUSH_BYTES {
7141        return Err(LinkError::PushTooLarge {
7142            detail: "v2 operation metadata exceeds the bounded commit request".to_string(),
7143        });
7144    }
7145    let path = format!("/api/hub/brains/{requested_brain}/v2/commits");
7146    let mut candidate_hub_signer: Option<String> = None;
7147    let mut response = request(cfg, "POST", &path, Some(&body), Auth::Required)?;
7148    let bulk_preview_required = !(200..300).contains(&response.status)
7149        && response.body.as_ref().is_some_and(|value| {
7150            value.get("code").and_then(Value::as_str) == Some("bulk_preview_required")
7151                || value
7152                    .get("details")
7153                    .and_then(|details| details.get("code"))
7154                    .and_then(Value::as_str)
7155                    == Some("bulk_preview_required")
7156        });
7157    if bulk_preview_required && bulk_confirmation.is_none() {
7158        body["rebase"] = Value::String("strict".to_string());
7159        body["preview_only"] = Value::Bool(true);
7160        let preview = ensure_ok(
7161            request(cfg, "POST", &path, Some(&body), Auth::Required)?,
7162            "v2 bulk preview",
7163        )?;
7164        let preview_code = preview.get("code").and_then(Value::as_str);
7165        let required = preview.get("required").and_then(Value::as_bool);
7166        if preview.get("v").and_then(Value::as_u64) != Some(2)
7167            || preview.get("mutation_id").and_then(Value::as_str) != Some(mutation_id.as_str())
7168            || !matches!(
7169                preview_code,
7170                Some("bulk_preview_created" | "bulk_preview_not_required")
7171            )
7172            || required.is_none()
7173        {
7174            return Err(invalid_feed(
7175                "bulk preview response is not bound to the requested mutation",
7176            ));
7177        }
7178        if required == Some(true) {
7179            let preview_id = preview.get("bulk_preview_id").and_then(Value::as_str);
7180            let preview_digest = preview.get("bulk_preview_digest").and_then(Value::as_str);
7181            if preview_code != Some("bulk_preview_created")
7182                || preview_id.is_none_or(|value| !crate::ulid::is_ulid(value))
7183                || preview_digest.is_none_or(|value| !is_sha256(value))
7184                || preview.get("expires_at").and_then(Value::as_str).is_none()
7185                || !preview.get("impact").is_some_and(Value::is_object)
7186            {
7187                return Err(invalid_feed("bulk preview receipt is malformed"));
7188            }
7189            return Err(LinkError::BulkPreviewRequired { preview });
7190        }
7191        if preview_code != Some("bulk_preview_not_required") {
7192            return Err(invalid_feed("bulk preview requirement is inconsistent"));
7193        }
7194        // The rolling activity window can expire between the refusal and
7195        // preview. Retry once, still pinned strictly to the observed head.
7196        body.as_object_mut()
7197            .expect("v2 commit request is an object")
7198            .remove("preview_only");
7199        response = request(cfg, "POST", &path, Some(&body), Auth::Required)?;
7200    }
7201    let mut result = ensure_ok(response, "v2 sync push")?;
7202    if result.get("code").and_then(Value::as_str) == Some("proposal_queued") {
7203        if let Some(object) = result.as_object_mut() {
7204            object.insert(
7205                "sync_status".to_string(),
7206                Value::String("proposal_pending".to_string()),
7207            );
7208        }
7209        return Ok(result);
7210    }
7211    if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
7212        let challenge = result
7213            .get("signing_challenge")
7214            .ok_or_else(|| invalid_feed("self-custody response has no signing challenge"))?;
7215        let mut expected_candidate = remote.clone();
7216        let mut expected_candidate_assets = remote_assets.clone();
7217        apply_generated_v2_operations(
7218            &operations,
7219            &local_assets,
7220            &mut expected_candidate,
7221            &mut expected_candidate_assets,
7222        )?;
7223        let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
7224            cfg,
7225            &head,
7226            &expected_candidate,
7227            &expected_candidate_assets,
7228            &mutation_id,
7229            &body,
7230            challenge,
7231        )?;
7232        body["signing_challenge_id"] = Value::String(challenge_id);
7233        body["signature_base64url"] = Value::String(signature);
7234        candidate_hub_signer = Some(actor_signer);
7235        result = ensure_ok(
7236            request(cfg, "POST", &path, Some(&body), Auth::Required)?,
7237            "v2 self-custody commit",
7238        )?;
7239    }
7240    let refreshed = v2_verified_head(cfg, requested_brain)?
7241        .ok_or_else(|| invalid_feed("v2 head disappeared after commit"))?;
7242    if candidate_hub_signer
7243        .as_ref()
7244        .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
7245    {
7246        return Err(invalid_feed(
7247            "self-custody actor signer differs from the committed hub pointer signer",
7248        ));
7249    }
7250    let accepted_hash = result.get("commit_hash").and_then(Value::as_str);
7251    if refreshed
7252        .pointer
7253        .as_ref()
7254        .map(|pointer| pointer.commit_hash.as_str())
7255        != accepted_hash
7256    {
7257        return Err(LinkError::RemoteAdvancedDuringSync);
7258    }
7259    ensure_v2_view_compatible(&refreshed, baseline.as_ref())?;
7260    let rebased = result
7261        .get("rebased")
7262        .and_then(Value::as_bool)
7263        .ok_or_else(|| invalid_feed("v2 commit receipt has no rebase result"))?;
7264    let (refreshed_files, refreshed_assets) = if rebased {
7265        (
7266            files_for_v2_view(
7267                &refreshed,
7268                v2_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
7269            ),
7270            v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
7271        )
7272    } else {
7273        let asset_changed = apply_generated_v2_operations(
7274            &operations,
7275            &local_assets,
7276            &mut remote,
7277            &mut remote_assets,
7278        )?;
7279        let assets = if asset_changed {
7280            // Asset leaf hashes are server-generated signed coordinates; only
7281            // asset-changing commits need this usually tiny post-commit read.
7282            v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?
7283        } else {
7284            remote_assets
7285        };
7286        (remote, assets)
7287    };
7288    let mut final_local = v2_local_files(store)?;
7289    remove_scoped_projection(&refreshed, baseline.as_ref(), &mut final_local)?;
7290    let final_assets = v2_local_asset_records(store)?;
7291    let local_dirty = final_local.riding != local_view.riding
7292        || !v2_riding_matches_remote(&final_local.riding, &refreshed_files, |path| {
7293            final_local.policy.keeps_home(path)
7294        })
7295        || final_assets != local_assets
7296        || !v2_asset_records_match_remote(&final_assets, &refreshed_assets);
7297    let next = v2_baseline_from_head(
7298        cfg,
7299        &refreshed,
7300        refreshed_files,
7301        refreshed_assets,
7302        Some(&final_local),
7303        Some(&checkout_pseudonym),
7304    )?;
7305    let split_count = next.remote_copy_remains.len();
7306    accept_v2_head(cfg, &refreshed)?;
7307    if !local_dirty {
7308        refresh_scoped_view_marker(store, &refreshed, next.files.len())?;
7309        save_v2_baseline(cfg, &refreshed.brain_id, &store.root, &next)?;
7310    }
7311    if let Some(object) = result.as_object_mut() {
7312        object.insert(
7313            "local_policy".to_string(),
7314            json!({ "remote_copy_remains": split_count }),
7315        );
7316        object.insert(
7317            "sync_status".to_string(),
7318            Value::String(if local_dirty {
7319                "remote_committed_local_dirty".to_string()
7320            } else {
7321                "synced".to_string()
7322            }),
7323        );
7324    }
7325    Ok(result)
7326}
7327
7328/// Negotiate v2 and send only local changes. A v1 hub retains the existing
7329/// whole-snapshot behavior until its brain advertises the new profile.
7330pub fn sync_push_incremental(cfg: &HubConfig, brain: &str, store: &Store) -> LinkResult<Value> {
7331    sync_push_incremental_with_policy(cfg, brain, store, false)
7332}
7333
7334/// The explicit `.sevralocal` adoption form. Ordinary sync never uploads a
7335/// path that became eligible because local policy was weakened or removed.
7336pub fn sync_push_incremental_with_policy(
7337    cfg: &HubConfig,
7338    brain: &str,
7339    store: &Store,
7340    resume_local_policy: bool,
7341) -> LinkResult<Value> {
7342    sync_push_incremental_with_options(cfg, brain, store, resume_local_policy, None)
7343}
7344
7345/// Incremental push with both local-policy adoption and an optional exact
7346/// permissioned bulk-preview confirmation.
7347pub fn sync_push_incremental_with_options(
7348    cfg: &HubConfig,
7349    brain: &str,
7350    store: &Store,
7351    resume_local_policy: bool,
7352    bulk_confirmation: Option<&V2BulkConfirmation>,
7353) -> LinkResult<Value> {
7354    sync_push_incremental_with_controls(
7355        cfg,
7356        brain,
7357        store,
7358        resume_local_policy,
7359        bulk_confirmation,
7360        &[],
7361        None,
7362    )
7363}
7364
7365/// Incremental push with explicit permissioned current-host withdrawal.
7366pub fn sync_push_incremental_with_controls(
7367    cfg: &HubConfig,
7368    brain: &str,
7369    store: &Store,
7370    resume_local_policy: bool,
7371    bulk_confirmation: Option<&V2BulkConfirmation>,
7372    withdrawal_paths: &[String],
7373    withdrawal_reason: Option<&str>,
7374) -> LinkResult<Value> {
7375    require_safe_ref(brain)?;
7376    if let Some(head) = v2_verified_head(cfg, brain)? {
7377        return v2_sync_push(
7378            cfg,
7379            brain,
7380            store,
7381            head,
7382            V2SyncPushOptions {
7383                resume_local_policy,
7384                bulk_confirmation,
7385                resolution: None,
7386                pulled: None,
7387                withdrawal_paths,
7388                withdrawal_reason,
7389            },
7390        );
7391    }
7392    if !withdrawal_paths.is_empty() {
7393        return Err(LinkError::InvalidPack {
7394            message: "explicit withdrawal requires a link.md v2 brain".to_string(),
7395        });
7396    }
7397    legacy_sync_push_incremental(cfg, brain, store, resume_local_policy, bulk_confirmation)
7398}
7399
7400/// Whether this exact hub/brain/path coordinate has an accepted v2 sync
7401/// baseline. CLI dispatch uses this to keep checkout identity sticky even when
7402/// a damaged `DB.md` makes strict store-open fail.
7403pub fn has_v2_sync_baseline(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<bool> {
7404    require_safe_ref(brain)?;
7405    Ok(load_v2_baseline(cfg, brain, checkout)?.is_some())
7406}
7407
7408#[cfg(windows)]
7409fn legacy_sync_push_incremental(
7410    _cfg: &HubConfig,
7411    _brain: &str,
7412    _store: &Store,
7413    _resume_local_policy: bool,
7414    _bulk_confirmation: Option<&V2BulkConfirmation>,
7415) -> LinkResult<Value> {
7416    Err(LinkError::UnsupportedPlatform {
7417        operation: "legacy v1 whole-snapshot push on Windows; upgrade the brain to link.md v2",
7418    })
7419}
7420
7421#[cfg(not(windows))]
7422fn legacy_sync_push_incremental(
7423    cfg: &HubConfig,
7424    brain: &str,
7425    store: &Store,
7426    resume_local_policy: bool,
7427    bulk_confirmation: Option<&V2BulkConfirmation>,
7428) -> LinkResult<Value> {
7429    if resume_local_policy || bulk_confirmation.is_some() {
7430        return Err(LinkError::InvalidPack {
7431            message: "v2 sync options require a link.md v2 brain".to_string(),
7432        });
7433    }
7434    let files = collect_push_files(store)?;
7435    sync_push(cfg, brain, &files)
7436}
7437
7438/// One explicit resolution for a previously preserved private conflict bundle.
7439#[derive(Debug, Clone)]
7440pub enum V2ConflictChoice {
7441    KeepLocal,
7442    TakeRemote,
7443    From(PathBuf),
7444}
7445
7446fn load_v2_conflict_plan(store: &Store, bundle: &str) -> LinkResult<V2ConflictPlan> {
7447    if !crate::ulid::is_ulid(bundle) {
7448        return Err(LinkError::InvalidPack {
7449            message: "conflict bundle must be a lowercase ULID".to_string(),
7450        });
7451    }
7452    let bytes = store.read_bounded(&v2_conflict_relative(bundle, "plan.json"), 1024 * 1024)?;
7453    let plan: V2ConflictPlan = serde_json::from_slice(&bytes)
7454        .map_err(|_| invalid_feed("private conflict plan is corrupt"))?;
7455    if plan.v != 2
7456        || plan.class != "content_resolution_required"
7457        || plan.bundle != bundle
7458        || !crate::ulid::is_ulid(&plan.brain)
7459        || plan.files.is_empty()
7460        || plan.files.len() > 100
7461        || plan.files.iter().any(|file| {
7462            crate::linkmd_v2::normalize_path(&file.path).is_err()
7463                || [&file.base, &file.local, &file.remote]
7464                    .into_iter()
7465                    .any(|coordinate| {
7466                        coordinate
7467                            .sha256
7468                            .as_deref()
7469                            .is_some_and(|hash| !is_sha256(hash))
7470                            || coordinate.file.as_deref().is_some_and(|name| {
7471                                name.starts_with('/')
7472                                    || name
7473                                        .split('/')
7474                                        .any(|part| part.is_empty() || part == "." || part == "..")
7475                            })
7476                    })
7477        })
7478    {
7479        return Err(invalid_feed("private conflict plan failed validation"));
7480    }
7481    Ok(plan)
7482}
7483
7484/// Inspect or prune private conflict-control state. Ordinary pruning removes
7485/// only expired completed bundles and interrupted bundles with no `plan.json`;
7486/// corrupt completed plans fail closed. `all` is an explicit local discard of
7487/// every well-addressed bundle and never changes hosted brain data.
7488pub fn sync_conflicts(checkout: &Path, prune: bool, all: bool) -> LinkResult<Value> {
7489    require_hardened_filesystem("private conflict maintenance")?;
7490    if all && !prune {
7491        return Err(LinkError::InvalidPack {
7492            message: "discarding all conflict bundles requires prune=true".to_string(),
7493        });
7494    }
7495    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
7496        message: format!("conflict checkout is not a valid db.md store: {error}"),
7497    })?;
7498    let _transaction = store.transaction()?;
7499    let root = Path::new(".dbmd/conflicts");
7500    let names = match store.directory_names(root) {
7501        Ok(names) => names,
7502        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
7503        Err(error) => return Err(error.into()),
7504    };
7505    let now = SystemTime::now()
7506        .duration_since(UNIX_EPOCH)
7507        .unwrap_or_default()
7508        .as_secs();
7509    let mut bundles = Vec::new();
7510    let mut pruned = 0_u64;
7511    for name in names {
7512        let Some(bundle) = name.to_str().filter(|value| crate::ulid::is_ulid(value)) else {
7513            continue;
7514        };
7515        let plan_path = v2_conflict_relative(bundle, "plan.json");
7516        let plan_exists = store.regular_file_exists(&plan_path)?;
7517        let expired = if plan_exists {
7518            match load_v2_conflict_plan(&store, bundle) {
7519                Ok(plan) => plan.expires_unix < now,
7520                Err(error) if all => {
7521                    let _ = error;
7522                    true
7523                }
7524                Err(error) => return Err(error),
7525            }
7526        } else {
7527            true
7528        };
7529        if prune && (all || expired) {
7530            store.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
7531            pruned += 1;
7532            continue;
7533        }
7534        bundles.push(json!({
7535            "bundle": bundle,
7536            "complete": plan_exists,
7537            "expired": expired,
7538        }));
7539    }
7540    Ok(json!({
7541        "v": 2,
7542        "class": "private_conflict_state",
7543        "bundles": bundles.len(),
7544        "pruned": pruned,
7545        "items": bundles,
7546    }))
7547}
7548
7549/// Resolve one exact private conflict plan. Drift never lowers a precondition:
7550/// the remote head/view and every original local coordinate are rechecked
7551/// before either an explicit local install or a fresh normal commit.
7552pub fn sync_resolve_conflict(
7553    cfg: &HubConfig,
7554    checkout: &Path,
7555    bundle: &str,
7556    choice: V2ConflictChoice,
7557    bulk_confirmation: Option<&V2BulkConfirmation>,
7558) -> LinkResult<Value> {
7559    require_hardened_filesystem("conflict resolution")?;
7560    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
7561        message: format!("conflict checkout is not a valid db.md store: {error}"),
7562    })?;
7563    let plan = load_v2_conflict_plan(&store, bundle)?;
7564    if plan.origin != normalized_origin(&cfg.hub)? {
7565        return Err(invalid_feed(
7566            "conflict bundle belongs to another hub origin",
7567        ));
7568    }
7569    let now = SystemTime::now()
7570        .duration_since(UNIX_EPOCH)
7571        .unwrap_or_default()
7572        .as_secs();
7573    if now > plan.expires_unix {
7574        return Err(LinkError::InvalidPack {
7575            message: "conflict bundle expired; rerun sync to obtain current coordinates"
7576                .to_string(),
7577        });
7578    }
7579    let head = v2_verified_head(cfg, &plan.brain)?
7580        .ok_or_else(|| invalid_feed("conflict brain no longer advertises v2"))?;
7581    let pointer = head.pointer.as_ref();
7582    if pointer.map_or(0, |value| value.seq) != plan.remote_seq
7583        || pointer.map(|value| value.commit_hash.as_str()) != plan.remote_commit.as_deref()
7584        || pointer.and_then(|value| value.content_root.as_deref())
7585            != plan.remote_content_root.as_deref()
7586        || head.view_kind != plan.view_kind
7587        || head.view_revision != plan.view_revision
7588    {
7589        return Err(LinkError::RemoteAdvancedDuringSync);
7590    }
7591
7592    // A resolution never overwrites a post-bundle local edit.
7593    for file in &plan.files {
7594        let actual = match store.regular_file_exists(Path::new(&file.path))? {
7595            true => Some(content_sha256(&store.read_bounded(
7596                Path::new(&file.path),
7597                file.local.bytes.unwrap_or(MAX_STORE_BYTES),
7598            )?)),
7599            false => None,
7600        };
7601        if actual.as_deref() != file.local.sha256.as_deref() {
7602            return Err(LinkError::InvalidPack {
7603                message: format!(
7604                    "local conflict path `{}` changed after the bundle was created",
7605                    file.path
7606                ),
7607            });
7608        }
7609    }
7610
7611    let from_source = match &choice {
7612        V2ConflictChoice::From(source) => Some(source.clone()),
7613        _ => None,
7614    };
7615    let result = match choice {
7616        V2ConflictChoice::TakeRemote => {
7617            if bulk_confirmation.is_some() {
7618                return Err(LinkError::InvalidPack {
7619                    message: "bulk confirmation applies to keep-local/from commits, not a local take-remote install".to_string(),
7620                });
7621            }
7622            // Re-read the exact current manifest so every selected remote byte
7623            // retains its signed inclusion proof. The private bundle is useful
7624            // for inspection, but is never promoted into live state as trust.
7625            let current_remote =
7626                files_for_v2_view(&head, v2_manifest(cfg, &plan.brain, head.pointer.as_ref())?);
7627            let _ = v2_take_remote_selection(&plan.files, &current_remote)?;
7628            let selected = plan
7629                .files
7630                .iter()
7631                .map(|file| file.path.clone())
7632                .collect::<std::collections::BTreeSet<_>>();
7633            serde_json::to_value(
7634                v2_sync_pull_with_resolution(
7635                    cfg,
7636                    &plan.brain,
7637                    head,
7638                    Some(checkout),
7639                    Some(&selected),
7640                )?
7641                .report,
7642            )
7643            .map_err(|_| invalid_feed("could not serialize conflict pull receipt"))?
7644        }
7645        V2ConflictChoice::KeepLocal | V2ConflictChoice::From(_) => {
7646            if let Some(source) = from_source.as_ref() {
7647                if plan.files.len() != 1 {
7648                    return Err(LinkError::InvalidPack {
7649                        message: "--from requires a bundle with exactly one conflict".to_string(),
7650                    });
7651                }
7652                let candidate = crate::fsx::read_bounded_nofollow(source, MAX_STORE_BYTES)?;
7653                if std::str::from_utf8(&candidate).is_err() {
7654                    return Err(LinkError::NotUtf8 {
7655                        path: source.display().to_string(),
7656                    });
7657                }
7658                store.write_atomic(Path::new(&plan.files[0].path), &candidate)?;
7659            }
7660            let refreshed_store =
7661                Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
7662                    message: format!("resolved checkout is not a valid db.md store: {error}"),
7663                })?;
7664            let mut overrides = std::collections::BTreeMap::new();
7665            for file in &plan.files {
7666                let selected_local = match refreshed_store
7667                    .regular_file_exists(Path::new(&file.path))?
7668                {
7669                    true => Some(content_sha256(
7670                        &refreshed_store.read_bounded(Path::new(&file.path), MAX_STORE_BYTES)?,
7671                    )),
7672                    false => None,
7673                };
7674                overrides.insert(
7675                    file.path.clone(),
7676                    V2ResolutionOverride {
7677                        expected_remote: file.remote.sha256.clone(),
7678                        selected_local,
7679                    },
7680                );
7681            }
7682            v2_sync_push(
7683                cfg,
7684                &plan.brain,
7685                &refreshed_store,
7686                head,
7687                V2SyncPushOptions {
7688                    resume_local_policy: true,
7689                    bulk_confirmation,
7690                    resolution: Some(&overrides),
7691                    pulled: None,
7692                    withdrawal_paths: &[],
7693                    withdrawal_reason: None,
7694                },
7695            )?
7696        }
7697    };
7698
7699    if result.get("code").and_then(Value::as_str) != Some("proposal_queued") {
7700        let installed = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
7701            message: format!("resolved checkout is not a valid db.md store: {error}"),
7702        })?;
7703        installed.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
7704    }
7705    Ok(json!({
7706        "v": 2,
7707        "class": "auto_converged",
7708        "bundle": bundle,
7709        "receipt": result,
7710    }))
7711}
7712
7713/// Converge one established permissioned-v2 checkout in both directions.
7714///
7715/// The pull half first performs the ordinary three-way conflict check and
7716/// crash-safe install against the accepted baseline. The push half then opens
7717/// that exact installed checkout, takes its local transaction guard, computes
7718/// a fresh three-way delta, and submits it with mandatory preconditions. A
7719/// remote movement between halves is therefore a normal rebase/conflict input,
7720/// never a blind overwrite. Legacy v1 remains explicit pull-only/push-only so
7721/// its whole-pack replacement semantics are never presented as granular
7722/// convergence.
7723pub fn sync_converge(
7724    cfg: &HubConfig,
7725    brain: &str,
7726    checkout: &Path,
7727    resume_local_policy: bool,
7728) -> LinkResult<Value> {
7729    sync_converge_with_options(cfg, brain, checkout, resume_local_policy, None)
7730}
7731
7732/// Bidirectional convergence with optional exact bulk-preview confirmation.
7733pub fn sync_converge_with_options(
7734    cfg: &HubConfig,
7735    brain: &str,
7736    checkout: &Path,
7737    resume_local_policy: bool,
7738    bulk_confirmation: Option<&V2BulkConfirmation>,
7739) -> LinkResult<Value> {
7740    sync_converge_with_controls(
7741        cfg,
7742        brain,
7743        checkout,
7744        resume_local_policy,
7745        bulk_confirmation,
7746        &[],
7747        None,
7748    )
7749}
7750
7751/// Bidirectional convergence with optional exact withdrawal intents.
7752pub fn sync_converge_with_controls(
7753    cfg: &HubConfig,
7754    brain: &str,
7755    checkout: &Path,
7756    resume_local_policy: bool,
7757    bulk_confirmation: Option<&V2BulkConfirmation>,
7758    withdrawal_paths: &[String],
7759    withdrawal_reason: Option<&str>,
7760) -> LinkResult<Value> {
7761    require_hardened_filesystem("bidirectional sync")?;
7762    require_safe_ref(brain)?;
7763    let head = v2_verified_head(cfg, brain)?.ok_or_else(|| LinkError::InvalidPack {
7764        message:
7765            "bidirectional sync requires link.md v2; use --pull-only or --push-only for a legacy brain"
7766                .to_string(),
7767    })?;
7768    let pulled = v2_sync_pull_with_resolution(cfg, brain, head, Some(checkout), None)?;
7769    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
7770        message: format!("installed v2 checkout is not a valid db.md store: {error}"),
7771    })?;
7772    let _transaction = store.transaction()?;
7773    let pulled_report = pulled.report.clone();
7774    let pulled_head = pulled.head.clone();
7775    let mut result = v2_sync_push(
7776        cfg,
7777        brain,
7778        &store,
7779        pulled_head,
7780        V2SyncPushOptions {
7781            resume_local_policy,
7782            bulk_confirmation,
7783            resolution: None,
7784            pulled: Some(pulled),
7785            withdrawal_paths,
7786            withdrawal_reason,
7787        },
7788    )?;
7789    if let Some(object) = result.as_object_mut() {
7790        object.insert("pulled_files".to_string(), json!(pulled_report.files));
7791        object.insert("checkout".to_string(), Value::String(pulled_report.dest));
7792        object.insert(
7793            "mode".to_string(),
7794            Value::String("bidirectional".to_string()),
7795        );
7796    }
7797    Ok(result)
7798}
7799
7800/// Pull the granted slice of `brain` to `out` (default: `./<slug>`). Every
7801/// exported path is safety-gated before it touches disk; files are written
7802/// atomically; nothing local is ever deleted (locals the export lacks are
7803/// *reported* in `extra_local` instead). Returns the report; rebuilding the
7804/// local index catalog afterwards is the caller's (cheap, optional) step.
7805pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
7806    require_hardened_filesystem("sync pull")?;
7807    require_safe_ref(brain)?;
7808    if let Some(head) = v2_verified_head(cfg, brain)? {
7809        return v2_sync_pull(cfg, brain, head, out);
7810    }
7811    legacy_sync_pull(cfg, brain, out)
7812}
7813
7814#[cfg(windows)]
7815fn legacy_sync_pull(_cfg: &HubConfig, _brain: &str, _out: Option<&Path>) -> LinkResult<PullReport> {
7816    Err(LinkError::UnsupportedPlatform {
7817        operation: "legacy v1 whole-snapshot pull on Windows; upgrade the brain to link.md v2",
7818    })
7819}
7820
7821#[cfg(not(windows))]
7822fn legacy_sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
7823    let remote = verified_remote_head(cfg, brain, false)?;
7824    if !remote.head.verified {
7825        return Err(invalid_feed(
7826            "the grant exposes head movement but no signed snapshot; refusing an unverifiable pull",
7827        ));
7828    }
7829    let snapshot_hash = remote.head.feed_hash.as_deref().unwrap_or("none");
7830    let path = format!(
7831        "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={snapshot_hash}",
7832        remote.head.seq
7833    );
7834    let body = ensure_ok(
7835        request(cfg, "GET", &path, None, Auth::Required)?,
7836        "sync pull",
7837    )?;
7838    if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
7839        || body.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
7840    {
7841        return Err(invalid_feed(
7842            "export response is not bound to the verified snapshot token",
7843        ));
7844    }
7845
7846    let remote_slug = body
7847        .get("slug")
7848        .and_then(Value::as_str)
7849        .filter(|slug| is_safe_slug(slug));
7850    let slug = remote_slug
7851        .or_else(|| is_safe_slug(brain).then_some(brain))
7852        .unwrap_or("brain")
7853        .to_string();
7854    let brain_id = body
7855        .get("brain")
7856        .and_then(Value::as_str)
7857        .unwrap_or(&remote.head.brain)
7858        .to_string();
7859    if brain_id != remote.head.brain {
7860        return Err(invalid_feed(
7861            "export response names a different brain than the verified head",
7862        ));
7863    }
7864    let head_seq = remote.head.seq;
7865    let dest: PathBuf = match out {
7866        Some(p) => p.to_path_buf(),
7867        None => PathBuf::from(&slug),
7868    };
7869    let entries = if head_seq == 0 {
7870        let files = body
7871            .get("files")
7872            .and_then(Value::as_array)
7873            .ok_or_else(|| invalid_feed("empty snapshot export did not carry an empty manifest"))?;
7874        if !files.is_empty() || body.get("url").is_some() {
7875            return Err(invalid_feed(
7876                "empty signed feed cannot authorize non-empty exported content",
7877            ));
7878        }
7879        Vec::new()
7880    } else {
7881        let signed_head = remote
7882            .head_entry
7883            .as_ref()
7884            .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
7885        let expected = &signed_head.entry.pack_sha256;
7886        if !is_sha256(expected) {
7887            return Err(invalid_feed(
7888                "signed head carries an invalid snapshot pack digest",
7889            ));
7890        }
7891        if let Some(url) = body.get("url").and_then(Value::as_str) {
7892            if body.get("sha256").and_then(Value::as_str) != Some(expected.as_str()) {
7893                return Err(invalid_feed(
7894                    "export pack digest does not match the signed head entry",
7895                ));
7896            }
7897            let bytes = get_presigned(cfg, url)?;
7898            let actual = format!("{:x}", Sha256::digest(&bytes));
7899            if actual != *expected {
7900                return Err(LinkError::InvalidPack {
7901                    message: "downloaded pack does not match the signed snapshot digest"
7902                        .to_string(),
7903                });
7904            }
7905            let entries = parse_store_pack(bytes)?;
7906            if signed_head.entry.kind == "push" {
7907                verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
7908            }
7909            entries
7910        } else {
7911            if signed_head.entry.kind != "push" {
7912                return Err(invalid_feed(
7913                    "delta snapshots must export the exact signed pack",
7914                ));
7915            }
7916            let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
7917                invalid_feed("verified snapshot export carried neither a pack nor files")
7918            })?;
7919            let mut entries = Vec::with_capacity(files.len());
7920            for file in files {
7921                let path = file
7922                    .get("path")
7923                    .and_then(Value::as_str)
7924                    .ok_or_else(|| invalid_feed("exported file path is not a string"))?;
7925                let content = file
7926                    .get("content")
7927                    .and_then(Value::as_str)
7928                    .ok_or_else(|| invalid_feed("exported inline content is not UTF-8 text"))?;
7929                entries.push((path.to_string(), content.as_bytes().to_vec()));
7930            }
7931            verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
7932            entries
7933        }
7934    };
7935
7936    // Gate the complete manifest before the first filesystem mutation.
7937    let mut seen = std::collections::HashSet::new();
7938    for (path, _) in &entries {
7939        if !safe_store_rel_path(path) {
7940            return Err(LinkError::UnsafePath { path: path.clone() });
7941        }
7942        if !seen.insert(path) {
7943            return Err(LinkError::InvalidPack {
7944                message: format!("duplicate path `{path}`"),
7945            });
7946        }
7947    }
7948    // Compute divergence against the still-live tree. The staged clone keeps
7949    // these extra locals byte-for-byte while overlaying the signed snapshot.
7950    let pulled: std::collections::BTreeSet<&str> =
7951        entries.iter().map(|(p, _)| p.as_str()).collect();
7952    let mut extra_local = Vec::new();
7953    if let Ok(store) = Store::open(&dest) {
7954        if let Ok(walked) = store.walk() {
7955            for rel in walked {
7956                let rel_str = rel.to_string_lossy().replace('\\', "/");
7957                if !pulled.contains(rel_str.as_str()) {
7958                    extra_local.push(rel_str);
7959                }
7960            }
7961        }
7962    }
7963    #[cfg(unix)]
7964    install_pulled_snapshot(&dest, &entries)?;
7965
7966    Ok(PullReport {
7967        brain: brain_id,
7968        slug,
7969        head_seq,
7970        files: entries.len(),
7971        dest: dest.to_string_lossy().into_owned(),
7972        extra_local,
7973        sync_status: "synced".to_string(),
7974    })
7975}
7976
7977#[cfg(unix)]
7978fn c_name(bytes: &[u8], display: &str) -> LinkResult<std::ffi::CString> {
7979    std::ffi::CString::new(bytes).map_err(|_| LinkError::UnsafePath {
7980        path: display.to_string(),
7981    })
7982}
7983
7984#[cfg(unix)]
7985fn open_dir_at(
7986    parent: std::os::fd::RawFd,
7987    name: &std::ffi::CStr,
7988    display: &str,
7989) -> LinkResult<std::fs::File> {
7990    use std::os::fd::FromRawFd as _;
7991    let fd = unsafe {
7992        libc::openat(
7993            parent,
7994            name.as_ptr(),
7995            libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
7996        )
7997    };
7998    if fd < 0 {
7999        return Err(LinkError::UnsafePath {
8000            path: display.to_string(),
8001        });
8002    }
8003    Ok(unsafe { std::fs::File::from_raw_fd(fd) })
8004}
8005
8006/// Open (and, where absent, create) a directory path without following a
8007/// symlink in any component. The returned directory capability remains bound
8008/// to the opened inode even if an attacker renames or replaces an ancestor.
8009#[cfg(unix)]
8010fn open_dir_path_nofollow(path: &Path, create: bool) -> LinkResult<std::fs::File> {
8011    use std::os::fd::AsRawFd as _;
8012
8013    // macOS exposes a few system-owned compatibility symlinks at the root.
8014    // Normalize only those fixed OS aliases; never canonicalize an arbitrary
8015    // caller-controlled ancestor.
8016    #[cfg(target_os = "macos")]
8017    let normalized = [("/var", "/private/var"), ("/tmp", "/private/tmp")]
8018        .into_iter()
8019        .find_map(|(alias, real)| {
8020            path.strip_prefix(alias)
8021                .ok()
8022                .map(|rest| Path::new(real).join(rest))
8023        })
8024        .unwrap_or_else(|| path.to_path_buf());
8025    #[cfg(not(target_os = "macos"))]
8026    let normalized = path.to_path_buf();
8027
8028    let start = if normalized.is_absolute() {
8029        std::fs::File::open("/")?
8030    } else {
8031        std::fs::File::open(".")?
8032    };
8033    let mut directory = start;
8034    for component in normalized.components() {
8035        use std::path::Component;
8036        let name = match component {
8037            Component::RootDir | Component::CurDir => continue,
8038            Component::Normal(name) => name,
8039            Component::ParentDir | Component::Prefix(_) => {
8040                return Err(LinkError::UnsafePath {
8041                    path: path.display().to_string(),
8042                });
8043            }
8044        };
8045        use std::os::unix::ffi::OsStrExt as _;
8046        let name = c_name(name.as_bytes(), &path.display().to_string())?;
8047        if create {
8048            let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
8049            if made != 0 {
8050                let error = std::io::Error::last_os_error();
8051                if error.raw_os_error() != Some(libc::EEXIST) {
8052                    return Err(error.into());
8053                }
8054            }
8055        }
8056        directory = open_dir_at(directory.as_raw_fd(), &name, &path.display().to_string())?;
8057    }
8058    Ok(directory)
8059}
8060
8061#[cfg(unix)]
8062fn open_or_create_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
8063    open_dir_path_nofollow(path, true)
8064}
8065
8066#[cfg(unix)]
8067fn open_existing_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
8068    open_dir_path_nofollow(path, false)
8069}
8070
8071#[cfg(unix)]
8072fn entry_is_dir_at(parent: std::os::fd::RawFd, name: &std::ffi::CStr) -> LinkResult<Option<bool>> {
8073    let mut stat: libc::stat = unsafe { std::mem::zeroed() };
8074    let result =
8075        unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) };
8076    if result == 0 {
8077        return Ok(Some((stat.st_mode & libc::S_IFMT) == libc::S_IFDIR));
8078    }
8079    let error = std::io::Error::last_os_error();
8080    if error.kind() == std::io::ErrorKind::NotFound {
8081        Ok(None)
8082    } else {
8083        Err(error.into())
8084    }
8085}
8086
8087#[cfg(unix)]
8088fn create_dir_exclusive_at(
8089    parent: std::os::fd::RawFd,
8090    name: &std::ffi::CStr,
8091    display: &str,
8092) -> LinkResult<std::fs::File> {
8093    let made = unsafe { libc::mkdirat(parent, name.as_ptr(), 0o700) };
8094    if made != 0 {
8095        return Err(LinkError::UnsafePath {
8096            path: display.to_string(),
8097        });
8098    }
8099    open_dir_at(parent, name, display)
8100}
8101
8102#[cfg(unix)]
8103fn directory_entry_names(directory: &std::fs::File) -> LinkResult<Vec<std::ffi::CString>> {
8104    use std::os::fd::AsRawFd as _;
8105
8106    let duplicate = unsafe { libc::dup(directory.as_raw_fd()) };
8107    if duplicate < 0 {
8108        return Err(std::io::Error::last_os_error().into());
8109    }
8110    let stream = unsafe { libc::fdopendir(duplicate) };
8111    if stream.is_null() {
8112        let error = std::io::Error::last_os_error();
8113        unsafe {
8114            libc::close(duplicate);
8115        }
8116        return Err(error.into());
8117    }
8118    let mut names = Vec::new();
8119    loop {
8120        let entry = unsafe { libc::readdir(stream) };
8121        if entry.is_null() {
8122            break;
8123        }
8124        let raw = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) };
8125        if raw.to_bytes() != b"." && raw.to_bytes() != b".." {
8126            names.push(raw.to_owned());
8127        }
8128    }
8129    if unsafe { libc::closedir(stream) } != 0 {
8130        return Err(std::io::Error::last_os_error().into());
8131    }
8132    Ok(names)
8133}
8134
8135/// Remove an entry tree relative to a held directory capability. Symlinks are
8136/// unlinked, never traversed, even when an old mirror contains hostile names.
8137#[cfg(unix)]
8138fn remove_tree_at(
8139    parent: std::os::fd::RawFd,
8140    name: &std::ffi::CStr,
8141    display: &str,
8142) -> LinkResult<()> {
8143    use std::os::fd::AsRawFd as _;
8144
8145    match entry_is_dir_at(parent, name)? {
8146        None => return Ok(()),
8147        Some(false) => {
8148            if unsafe { libc::unlinkat(parent, name.as_ptr(), 0) } != 0 {
8149                return Err(std::io::Error::last_os_error().into());
8150            }
8151        }
8152        Some(true) => {
8153            let directory = open_dir_at(parent, name, display)?;
8154            for child in directory_entry_names(&directory)? {
8155                let child_display =
8156                    format!("{display}/{}", String::from_utf8_lossy(child.to_bytes()));
8157                remove_tree_at(directory.as_raw_fd(), &child, &child_display)?;
8158            }
8159            drop(directory);
8160            if unsafe { libc::unlinkat(parent, name.as_ptr(), libc::AT_REMOVEDIR) } != 0 {
8161                return Err(std::io::Error::last_os_error().into());
8162            }
8163        }
8164    }
8165    Ok(())
8166}
8167
8168/// Clone a live destination into a private sibling stage without following
8169/// any symlink. Regular files are streamed between held descriptors; symlinks
8170/// are reproduced as links, never opened. Special files are refused.
8171#[cfg(unix)]
8172fn clone_tree_contents(
8173    source: &std::fs::File,
8174    destination: &std::fs::File,
8175    display: &str,
8176) -> LinkResult<()> {
8177    use std::os::fd::{AsRawFd as _, FromRawFd as _};
8178
8179    for name in directory_entry_names(source)? {
8180        let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
8181        let mut stat: libc::stat = unsafe { std::mem::zeroed() };
8182        if unsafe {
8183            libc::fstatat(
8184                source.as_raw_fd(),
8185                name.as_ptr(),
8186                &mut stat,
8187                libc::AT_SYMLINK_NOFOLLOW,
8188            )
8189        } != 0
8190        {
8191            return Err(std::io::Error::last_os_error().into());
8192        }
8193        match stat.st_mode & libc::S_IFMT {
8194            libc::S_IFDIR => {
8195                if unsafe {
8196                    libc::mkdirat(destination.as_raw_fd(), name.as_ptr(), stat.st_mode & 0o777)
8197                } != 0
8198                {
8199                    return Err(std::io::Error::last_os_error().into());
8200                }
8201                let source_child = open_dir_at(source.as_raw_fd(), &name, &child_display)?;
8202                let destination_child =
8203                    open_dir_at(destination.as_raw_fd(), &name, &child_display)?;
8204                clone_tree_contents(&source_child, &destination_child, &child_display)?;
8205                destination_child.sync_all()?;
8206            }
8207            libc::S_IFREG => {
8208                let source_fd = unsafe {
8209                    libc::openat(
8210                        source.as_raw_fd(),
8211                        name.as_ptr(),
8212                        libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
8213                    )
8214                };
8215                if source_fd < 0 {
8216                    return Err(std::io::Error::last_os_error().into());
8217                }
8218                let destination_fd = unsafe {
8219                    libc::openat(
8220                        destination.as_raw_fd(),
8221                        name.as_ptr(),
8222                        libc::O_WRONLY
8223                            | libc::O_CREAT
8224                            | libc::O_EXCL
8225                            | libc::O_CLOEXEC
8226                            | libc::O_NOFOLLOW,
8227                        (stat.st_mode & 0o777) as libc::c_uint,
8228                    )
8229                };
8230                if destination_fd < 0 {
8231                    unsafe {
8232                        libc::close(source_fd);
8233                    }
8234                    return Err(std::io::Error::last_os_error().into());
8235                }
8236                let mut input = unsafe { std::fs::File::from_raw_fd(source_fd) };
8237                let mut output = unsafe { std::fs::File::from_raw_fd(destination_fd) };
8238                std::io::copy(&mut input, &mut output)?;
8239                output.sync_all()?;
8240            }
8241            libc::S_IFLNK => {
8242                let mut target = vec![0_u8; 4097];
8243                let length = unsafe {
8244                    libc::readlinkat(
8245                        source.as_raw_fd(),
8246                        name.as_ptr(),
8247                        target.as_mut_ptr().cast(),
8248                        target.len(),
8249                    )
8250                };
8251                if length < 0 || length as usize >= target.len() {
8252                    return Err(LinkError::UnsafePath {
8253                        path: child_display,
8254                    });
8255                }
8256                target.truncate(length as usize);
8257                let target = c_name(&target, &child_display)?;
8258                if unsafe {
8259                    libc::symlinkat(target.as_ptr(), destination.as_raw_fd(), name.as_ptr())
8260                } != 0
8261                {
8262                    return Err(std::io::Error::last_os_error().into());
8263                }
8264            }
8265            _ => {
8266                return Err(LinkError::UnsafePath {
8267                    path: child_display,
8268                });
8269            }
8270        }
8271    }
8272    destination.sync_all()?;
8273    Ok(())
8274}
8275
8276#[cfg(target_os = "linux")]
8277fn install_stage_at(
8278    parent: std::os::fd::RawFd,
8279    stage: &std::ffi::CStr,
8280    dest: &std::ffi::CStr,
8281    dest_exists: bool,
8282) -> LinkResult<()> {
8283    let flags = if dest_exists {
8284        libc::RENAME_EXCHANGE
8285    } else {
8286        libc::RENAME_NOREPLACE
8287    };
8288    // `libc::renameat2` is not exported for the musl targets we ship. Invoke
8289    // the kernel ABI directly, as `fsx::renameat_noreplace` does, so the same
8290    // atomic exchange/no-replace boundary compiles for glibc and musl.
8291    let result = unsafe {
8292        libc::syscall(
8293            libc::SYS_renameat2,
8294            parent,
8295            stage.as_ptr(),
8296            parent,
8297            dest.as_ptr(),
8298            flags,
8299        )
8300    };
8301    if result == 0 {
8302        Ok(())
8303    } else {
8304        Err(std::io::Error::last_os_error().into())
8305    }
8306}
8307
8308#[cfg(target_os = "macos")]
8309fn install_stage_at(
8310    parent: std::os::fd::RawFd,
8311    stage: &std::ffi::CStr,
8312    dest: &std::ffi::CStr,
8313    dest_exists: bool,
8314) -> LinkResult<()> {
8315    let flags = if dest_exists {
8316        libc::RENAME_SWAP
8317    } else {
8318        libc::RENAME_EXCL
8319    };
8320    let result =
8321        unsafe { libc::renameatx_np(parent, stage.as_ptr(), parent, dest.as_ptr(), flags) };
8322    if result == 0 {
8323        Ok(())
8324    } else {
8325        Err(std::io::Error::last_os_error().into())
8326    }
8327}
8328
8329#[cfg(unix)]
8330fn write_pull_entries_beneath_dir(
8331    root: &std::fs::File,
8332    entries: &[(String, Vec<u8>)],
8333) -> LinkResult<()> {
8334    use std::os::fd::{AsRawFd as _, FromRawFd as _};
8335
8336    for (path, content) in entries {
8337        let components: Vec<&str> = path.split('/').collect();
8338        let (leaf, parents) = components
8339            .split_last()
8340            .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
8341        let mut directory = root.try_clone()?;
8342        for component in parents {
8343            let name = c_name(component.as_bytes(), path)?;
8344            let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
8345            if made != 0 {
8346                let error = std::io::Error::last_os_error();
8347                if error.raw_os_error() != Some(libc::EEXIST) {
8348                    return Err(error.into());
8349                }
8350            }
8351            directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
8352        }
8353
8354        let leaf_name = c_name(leaf.as_bytes(), path)?;
8355        let mut existing: libc::stat = unsafe { std::mem::zeroed() };
8356        let inspected = unsafe {
8357            libc::fstatat(
8358                directory.as_raw_fd(),
8359                leaf_name.as_ptr(),
8360                &mut existing,
8361                libc::AT_SYMLINK_NOFOLLOW,
8362            )
8363        };
8364        if inspected == 0 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK {
8365            return Err(LinkError::UnsafePath { path: path.clone() });
8366        }
8367
8368        let nonce = std::time::SystemTime::now()
8369            .duration_since(std::time::UNIX_EPOCH)
8370            .unwrap_or_default()
8371            .as_nanos();
8372        let temp_name = format!(
8373            ".dbmd-pull-{}-{nonce}-{}",
8374            std::process::id(),
8375            content_sha256(format!("{path}\0{}", content.len()).as_bytes())
8376        );
8377        let temp = c_name(temp_name.as_bytes(), path)?;
8378        let fd = unsafe {
8379            libc::openat(
8380                directory.as_raw_fd(),
8381                temp.as_ptr(),
8382                libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
8383                0o600,
8384            )
8385        };
8386        if fd < 0 {
8387            return Err(std::io::Error::last_os_error().into());
8388        }
8389        let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
8390        if let Err(error) = file.write_all(content).and_then(|_| file.sync_all()) {
8391            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8392            return Err(error.into());
8393        }
8394        drop(file);
8395        let renamed = unsafe {
8396            libc::renameat(
8397                directory.as_raw_fd(),
8398                temp.as_ptr(),
8399                directory.as_raw_fd(),
8400                leaf_name.as_ptr(),
8401            )
8402        };
8403        if renamed != 0 {
8404            let error = std::io::Error::last_os_error();
8405            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8406            return Err(error.into());
8407        }
8408        directory.sync_all()?;
8409    }
8410    root.sync_all()?;
8411    Ok(())
8412}
8413
8414#[cfg(unix)]
8415fn write_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
8416    use std::os::fd::{AsRawFd as _, FromRawFd as _};
8417
8418    let path = &entry.path;
8419    let components: Vec<&str> = path.split('/').collect();
8420    let (leaf, parents) = components
8421        .split_last()
8422        .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
8423    let mut directory = root.try_clone()?;
8424    for component in parents {
8425        let name = c_name(component.as_bytes(), path)?;
8426        let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
8427        if made != 0 {
8428            let error = std::io::Error::last_os_error();
8429            if error.raw_os_error() != Some(libc::EEXIST) {
8430                return Err(error.into());
8431            }
8432        }
8433        directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
8434    }
8435    let leaf_name = c_name(leaf.as_bytes(), path)?;
8436    let mut existing: libc::stat = unsafe { std::mem::zeroed() };
8437    if unsafe {
8438        libc::fstatat(
8439            directory.as_raw_fd(),
8440            leaf_name.as_ptr(),
8441            &mut existing,
8442            libc::AT_SYMLINK_NOFOLLOW,
8443        )
8444    } == 0
8445        && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK
8446    {
8447        return Err(LinkError::UnsafePath { path: path.clone() });
8448    }
8449    let nonce = SystemTime::now()
8450        .duration_since(UNIX_EPOCH)
8451        .unwrap_or_default()
8452        .as_nanos();
8453    let temp_name = format!(
8454        ".dbmd-pull-{}-{nonce}-{}",
8455        std::process::id(),
8456        content_sha256(path.as_bytes())
8457    );
8458    let temp = c_name(temp_name.as_bytes(), path)?;
8459    let fd = unsafe {
8460        libc::openat(
8461            directory.as_raw_fd(),
8462            temp.as_ptr(),
8463            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
8464            0o600,
8465        )
8466    };
8467    if fd < 0 {
8468        return Err(std::io::Error::last_os_error().into());
8469    }
8470    let mut input = crate::fsx::open_regular_nofollow(&entry.source)?;
8471    let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
8472    let mut digest = Sha256::new();
8473    let mut total = 0_u64;
8474    let mut buffer = [0_u8; 64 * 1024];
8475    let copied = (|| -> std::io::Result<()> {
8476        loop {
8477            let read = input.read(&mut buffer)?;
8478            if read == 0 {
8479                break;
8480            }
8481            total = total.saturating_add(read as u64);
8482            if total > entry.bytes {
8483                return Err(std::io::Error::new(
8484                    std::io::ErrorKind::InvalidData,
8485                    "staged sync source grew beyond its verified length",
8486                ));
8487            }
8488            digest.update(&buffer[..read]);
8489            output.write_all(&buffer[..read])?;
8490        }
8491        Ok(())
8492    })();
8493    if let Err(error) = copied {
8494        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8495        return Err(error.into());
8496    }
8497    drop(output);
8498    if total != entry.bytes || format!("{:x}", digest.finalize()) != entry.sha256 {
8499        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8500        return Err(invalid_feed(
8501            "private staged sync source failed final integrity verification",
8502        ));
8503    }
8504    if unsafe {
8505        libc::renameat(
8506            directory.as_raw_fd(),
8507            temp.as_ptr(),
8508            directory.as_raw_fd(),
8509            leaf_name.as_ptr(),
8510        )
8511    } != 0
8512    {
8513        let error = std::io::Error::last_os_error();
8514        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8515        return Err(error.into());
8516    }
8517    Ok(())
8518}
8519
8520#[cfg(unix)]
8521fn sync_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
8522    use std::os::fd::{AsRawFd as _, FromRawFd as _};
8523
8524    let path = &entry.path;
8525    let components: Vec<&str> = path.split('/').collect();
8526    let (leaf, parents) = components
8527        .split_last()
8528        .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
8529    let mut directory = root.try_clone()?;
8530    for component in parents {
8531        directory = open_dir_at(
8532            directory.as_raw_fd(),
8533            &c_name(component.as_bytes(), path)?,
8534            path,
8535        )?;
8536    }
8537    let leaf = c_name(leaf.as_bytes(), path)?;
8538    let fd = unsafe {
8539        libc::openat(
8540            directory.as_raw_fd(),
8541            leaf.as_ptr(),
8542            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
8543        )
8544    };
8545    if fd < 0 {
8546        return Err(std::io::Error::last_os_error().into());
8547    }
8548    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
8549    if file.metadata()?.len() != entry.bytes || content_sha256_reader(&mut file)? != entry.sha256 {
8550        return Err(invalid_feed(
8551            "private pull stage changed before its durability barrier",
8552        ));
8553    }
8554    file.sync_all()?;
8555    Ok(())
8556}
8557
8558#[cfg(unix)]
8559fn run_pull_source_workers(
8560    root: &std::fs::File,
8561    entries: &[V2StagedFile],
8562    operation: fn(&std::fs::File, &V2StagedFile) -> LinkResult<()>,
8563) -> LinkResult<()> {
8564    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
8565
8566    let next = AtomicUsize::new(0);
8567    let failed = AtomicBool::new(false);
8568    let worker_count = entries.len().min(V2_PULL_INSTALL_WORKERS);
8569    let mut first_error = None;
8570    std::thread::scope(|scope| {
8571        let (sender, receiver) = std::sync::mpsc::channel();
8572        for _ in 0..worker_count {
8573            let sender = sender.clone();
8574            let next = &next;
8575            let failed = &failed;
8576            scope.spawn(move || {
8577                while !failed.load(Ordering::Acquire) {
8578                    let index = next.fetch_add(1, Ordering::Relaxed);
8579                    let Some(entry) = entries.get(index) else {
8580                        break;
8581                    };
8582                    let result = operation(root, entry);
8583                    if result.is_err() {
8584                        failed.store(true, Ordering::Release);
8585                    }
8586                    if sender.send(result).is_err() {
8587                        break;
8588                    }
8589                }
8590            });
8591        }
8592        drop(sender);
8593        for result in receiver {
8594            if let Err(error) = result {
8595                if first_error.is_none() {
8596                    first_error = Some(error);
8597                }
8598            }
8599        }
8600    });
8601    if let Some(error) = first_error {
8602        return Err(error);
8603    }
8604    if next.load(Ordering::Relaxed) < entries.len() {
8605        return Err(invalid_feed(
8606            "a bounded pull worker stopped before reporting every file",
8607        ));
8608    }
8609    Ok(())
8610}
8611
8612#[cfg(unix)]
8613fn sync_pull_directory_tree(root: &std::fs::File, display: &str) -> LinkResult<()> {
8614    use std::os::fd::AsRawFd as _;
8615
8616    for name in directory_entry_names(root)? {
8617        if entry_is_dir_at(root.as_raw_fd(), &name)? == Some(true) {
8618            let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
8619            let child = open_dir_at(root.as_raw_fd(), &name, &child_display)?;
8620            sync_pull_directory_tree(&child, &child_display)?;
8621        }
8622    }
8623    root.sync_all()?;
8624    Ok(())
8625}
8626
8627#[cfg(unix)]
8628fn write_pull_sources_beneath_dir(
8629    root: &std::fs::File,
8630    entries: &[V2StagedFile],
8631) -> LinkResult<()> {
8632    // The stage is still private and unreachable as the checkout here. Write
8633    // and verify every leaf first so the filesystem can coalesce dirty data,
8634    // then re-open/re-hash/fsync leaves through the held root capability. Only
8635    // after every file barrier succeeds do we flush each directory once. This
8636    // preserves the same crash durability as file-fsync + directory-fsync per
8637    // path without serializing 50,000 redundant directory barriers.
8638    run_pull_source_workers(root, entries, write_pull_source_beneath_dir)?;
8639    run_pull_source_workers(root, entries, sync_pull_source_beneath_dir)?;
8640    sync_pull_directory_tree(root, "v2 pull stage")
8641}
8642
8643#[cfg(unix)]
8644fn remove_pull_paths_beneath_dir(root: &std::fs::File, paths: &[String]) -> LinkResult<()> {
8645    use std::os::fd::AsRawFd as _;
8646    for path in paths {
8647        if !safe_store_rel_path(path) {
8648            return Err(LinkError::UnsafePath { path: path.clone() });
8649        }
8650        let components = path.split('/').collect::<Vec<_>>();
8651        let Some((leaf, parents)) = components.split_last() else {
8652            return Err(LinkError::UnsafePath { path: path.clone() });
8653        };
8654        let mut directory = root.try_clone()?;
8655        let mut missing = false;
8656        for component in parents {
8657            let name = c_name(component.as_bytes(), path)?;
8658            match entry_is_dir_at(directory.as_raw_fd(), &name)? {
8659                None => {
8660                    missing = true;
8661                    break;
8662                }
8663                Some(false) => return Err(LinkError::UnsafePath { path: path.clone() }),
8664                Some(true) => {
8665                    directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
8666                }
8667            }
8668        }
8669        if missing {
8670            continue;
8671        }
8672        let leaf = c_name(leaf.as_bytes(), path)?;
8673        match entry_is_dir_at(directory.as_raw_fd(), &leaf)? {
8674            None => {}
8675            Some(true) => return Err(LinkError::UnsafePath { path: path.clone() }),
8676            Some(false) => {
8677                if unsafe { libc::unlinkat(directory.as_raw_fd(), leaf.as_ptr(), 0) } != 0 {
8678                    return Err(std::io::Error::last_os_error().into());
8679                }
8680                directory.sync_all()?;
8681            }
8682        }
8683    }
8684    Ok(())
8685}
8686
8687#[cfg(unix)]
8688fn install_pulled_delta(
8689    dest: &Path,
8690    entries: &[(String, Vec<u8>)],
8691    deleted: &[String],
8692    rebuild_indexes: bool,
8693) -> LinkResult<()> {
8694    use ring::rand::SecureRandom as _;
8695    use std::os::fd::AsRawFd as _;
8696    use std::os::unix::ffi::OsStrExt as _;
8697
8698    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
8699    let name = dest
8700        .file_name()
8701        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
8702        .ok_or_else(|| LinkError::UnsafePath {
8703            path: dest.display().to_string(),
8704        })?;
8705    let parent_dir = open_or_create_dir_nofollow(parent)?;
8706    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
8707    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
8708        None => false,
8709        Some(true) => true,
8710        Some(false) => {
8711            return Err(LinkError::UnsafePath {
8712                path: dest.display().to_string(),
8713            });
8714        }
8715    };
8716
8717    let mut nonce = [0_u8; 16];
8718    ring::rand::SystemRandom::new()
8719        .fill(&mut nonce)
8720        .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
8721    let stage_label = format!(
8722        ".{}.dbmd-pull-stage-{}",
8723        name.to_string_lossy(),
8724        URL_SAFE_NO_PAD.encode(nonce)
8725    );
8726    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
8727    let stage_dir = create_dir_exclusive_at(
8728        parent_dir.as_raw_fd(),
8729        &stage_name,
8730        &dest.display().to_string(),
8731    )?;
8732
8733    let prepared = (|| -> LinkResult<()> {
8734        if dest_exists {
8735            let live = open_dir_at(
8736                parent_dir.as_raw_fd(),
8737                &dest_name,
8738                &dest.display().to_string(),
8739            )?;
8740            clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
8741        }
8742        remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
8743        write_pull_entries_beneath_dir(&stage_dir, entries)?;
8744        if rebuild_indexes {
8745            let stage_store =
8746                Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
8747                    .map_err(|error| LinkError::InvalidPack {
8748                        message: format!("v2 staging tree is not a valid db.md store: {error}"),
8749                    })?;
8750            crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
8751                LinkError::InvalidPack {
8752                    message: format!("could not materialize v2 local catalogs: {error}"),
8753                }
8754            })?;
8755        }
8756        stage_dir.sync_all()?;
8757        Ok(())
8758    })();
8759    if let Err(error) = prepared {
8760        let _ = remove_tree_at(
8761            parent_dir.as_raw_fd(),
8762            &stage_name,
8763            &dest.display().to_string(),
8764        );
8765        return Err(error);
8766    }
8767
8768    if let Err(error) =
8769        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
8770    {
8771        let _ = remove_tree_at(
8772            parent_dir.as_raw_fd(),
8773            &stage_name,
8774            &dest.display().to_string(),
8775        );
8776        return Err(error);
8777    }
8778    parent_dir.sync_all()?;
8779    if dest_exists {
8780        // Commit already happened atomically. Cleanup is best-effort so a
8781        // failure cannot be reported as a failed pull after the live tree
8782        // changed; a crash may leave only this private old-tree sibling.
8783        let _ = remove_tree_at(
8784            parent_dir.as_raw_fd(),
8785            &stage_name,
8786            &dest.display().to_string(),
8787        );
8788        let _ = parent_dir.sync_all();
8789    }
8790    Ok(())
8791}
8792
8793#[cfg(unix)]
8794fn install_pulled_delta_sources(
8795    dest: &Path,
8796    entries: &[V2StagedFile],
8797    deleted: &[String],
8798    rebuild_indexes: bool,
8799    _previous: Option<&V2SyncBaseline>,
8800    _next: &V2VerifiedHead,
8801) -> LinkResult<()> {
8802    use ring::rand::SecureRandom as _;
8803    use std::os::fd::AsRawFd as _;
8804    use std::os::unix::ffi::OsStrExt as _;
8805
8806    // An established checkout is updated only at the changed coordinates
8807    // through the durable pull journal shared with Windows. Initial clones
8808    // still use a private complete stage and one atomic directory install.
8809    if let Ok(store) = Store::open_strict(dest) {
8810        return install_established_v2_delta(
8811            store,
8812            entries,
8813            deleted,
8814            rebuild_indexes,
8815            _previous,
8816            _next,
8817        );
8818    }
8819
8820    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
8821    let name = dest
8822        .file_name()
8823        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
8824        .ok_or_else(|| LinkError::UnsafePath {
8825            path: dest.display().to_string(),
8826        })?;
8827    let parent_dir = open_or_create_dir_nofollow(parent)?;
8828    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
8829    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
8830        None => false,
8831        Some(true) => true,
8832        Some(false) => {
8833            return Err(LinkError::UnsafePath {
8834                path: dest.display().to_string(),
8835            })
8836        }
8837    };
8838    let mut nonce = [0_u8; 16];
8839    ring::rand::SystemRandom::new()
8840        .fill(&mut nonce)
8841        .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
8842    let stage_label = format!(
8843        ".{}.dbmd-pull-stage-{}",
8844        name.to_string_lossy(),
8845        URL_SAFE_NO_PAD.encode(nonce)
8846    );
8847    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
8848    let stage_dir = create_dir_exclusive_at(
8849        parent_dir.as_raw_fd(),
8850        &stage_name,
8851        &dest.display().to_string(),
8852    )?;
8853    let prepared = (|| -> LinkResult<()> {
8854        if dest_exists {
8855            let live = open_dir_at(
8856                parent_dir.as_raw_fd(),
8857                &dest_name,
8858                &dest.display().to_string(),
8859            )?;
8860            clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
8861        }
8862        remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
8863        write_pull_sources_beneath_dir(&stage_dir, entries)?;
8864        if rebuild_indexes {
8865            let stage_store =
8866                Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
8867                    .map_err(|error| LinkError::InvalidPack {
8868                        message: format!("v2 staging tree is not a valid db.md store: {error}"),
8869                    })?;
8870            crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
8871                LinkError::InvalidPack {
8872                    message: format!("could not materialize v2 local catalogs: {error}"),
8873                }
8874            })?;
8875        }
8876        stage_dir.sync_all()?;
8877        Ok(())
8878    })();
8879    if let Err(error) = prepared {
8880        let _ = remove_tree_at(
8881            parent_dir.as_raw_fd(),
8882            &stage_name,
8883            &dest.display().to_string(),
8884        );
8885        return Err(error);
8886    }
8887    if let Err(error) =
8888        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
8889    {
8890        let _ = remove_tree_at(
8891            parent_dir.as_raw_fd(),
8892            &stage_name,
8893            &dest.display().to_string(),
8894        );
8895        return Err(error);
8896    }
8897    parent_dir.sync_all()?;
8898    if dest_exists {
8899        let _ = remove_tree_at(
8900            parent_dir.as_raw_fd(),
8901            &stage_name,
8902            &dest.display().to_string(),
8903        );
8904        let _ = parent_dir.sync_all();
8905    }
8906    Ok(())
8907}
8908
8909#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
8910struct V2PullCoordinate {
8911    head_seq: Option<u64>,
8912    commit_hash: Option<String>,
8913    view_kind: Option<String>,
8914    view_revision: Option<String>,
8915}
8916
8917#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
8918struct V2PullFileCoordinate {
8919    sha256: String,
8920    bytes: u64,
8921}
8922
8923#[derive(Debug, Clone, Deserialize, Serialize)]
8924struct V2PullJournalEntry {
8925    path: String,
8926    old: Option<V2PullFileCoordinate>,
8927    new: Option<V2PullFileCoordinate>,
8928    backup: Option<String>,
8929}
8930
8931#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
8932#[serde(rename_all = "snake_case")]
8933enum V2PullPhase {
8934    Preparing,
8935    Ready,
8936}
8937
8938#[derive(Debug, Clone, Deserialize, Serialize)]
8939struct V2PullJournal {
8940    v: u8,
8941    phase: V2PullPhase,
8942    brain: String,
8943    previous: V2PullCoordinate,
8944    next: V2PullCoordinate,
8945    backup_dir: String,
8946    entries: Vec<V2PullJournalEntry>,
8947}
8948
8949const V2_PULL_JOURNAL: &str = ".dbmd/pull-journal.json";
8950
8951fn v2_pull_baseline_coordinate(baseline: Option<&V2SyncBaseline>) -> V2PullCoordinate {
8952    V2PullCoordinate {
8953        head_seq: baseline.and_then(|value| value.head_seq),
8954        commit_hash: baseline.and_then(|value| value.commit_hash.clone()),
8955        view_kind: baseline.and_then(|value| value.view_kind.clone()),
8956        view_revision: baseline.and_then(|value| value.view_revision.clone()),
8957    }
8958}
8959
8960fn v2_pull_head_coordinate(head: &V2VerifiedHead) -> V2PullCoordinate {
8961    V2PullCoordinate {
8962        head_seq: head.pointer.as_ref().map(|value| value.seq),
8963        commit_hash: head.pointer.as_ref().map(|value| value.commit_hash.clone()),
8964        view_kind: Some(head.view_kind.clone()),
8965        view_revision: Some(head.view_revision.clone()),
8966    }
8967}
8968
8969fn v2_pull_file_coordinate(
8970    store: &Store,
8971    path: &str,
8972    limit: u64,
8973) -> LinkResult<Option<V2PullFileCoordinate>> {
8974    let file = match store.open_regular(Path::new(path)) {
8975        Ok(file) => file,
8976        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
8977        Err(error) => return Err(error.into()),
8978    };
8979    let bytes = file.metadata()?.len();
8980    if bytes > limit || bytes > MAX_STORE_BYTES {
8981        return Err(invalid_feed(
8982            "pull transaction file exceeds its declared bound",
8983        ));
8984    }
8985    Ok(Some(V2PullFileCoordinate {
8986        sha256: content_sha256_reader(file)?,
8987        bytes,
8988    }))
8989}
8990
8991fn v2_pull_journal_bytes(journal: &V2PullJournal) -> LinkResult<Vec<u8>> {
8992    let mut bytes = serde_json::to_vec_pretty(journal)
8993        .map_err(|_| invalid_feed("could not serialize v2 pull journal"))?;
8994    bytes.push(b'\n');
8995    Ok(bytes)
8996}
8997
8998fn validate_v2_pull_journal(journal: &V2PullJournal) -> LinkResult<()> {
8999    let backup_prefix = ".dbmd/pull-backup-";
9000    let suffix = journal
9001        .backup_dir
9002        .strip_prefix(backup_prefix)
9003        .ok_or_else(|| invalid_feed("v2 pull journal backup address is invalid"))?;
9004    let mut paths = std::collections::BTreeSet::new();
9005    if journal.v != 1
9006        || !crate::ulid::is_ulid(&journal.brain)
9007        || !crate::ulid::is_ulid(suffix)
9008        || journal.entries.is_empty()
9009        || journal.entries.len() > MAX_PUSH_FILES + 4
9010        || journal.previous == journal.next
9011    {
9012        return Err(invalid_feed("v2 pull journal failed validation"));
9013    }
9014    for (index, entry) in journal.entries.iter().enumerate() {
9015        if !safe_store_rel_path(&entry.path)
9016            || entry.path == V2_PULL_JOURNAL
9017            || entry.path.starts_with(backup_prefix)
9018            || !paths.insert(entry.path.clone())
9019            || (entry.old.is_none() && entry.new.is_none())
9020            || entry
9021                .old
9022                .iter()
9023                .chain(entry.new.iter())
9024                .any(|value| !is_sha256(&value.sha256) || value.bytes > MAX_STORE_BYTES)
9025            || entry.backup.as_deref()
9026                != entry
9027                    .old
9028                    .as_ref()
9029                    .map(|_| format!("{index:08x}"))
9030                    .as_deref()
9031        {
9032            return Err(invalid_feed("v2 pull journal entry failed validation"));
9033        }
9034    }
9035    Ok(())
9036}
9037
9038fn load_v2_pull_journal(store: &Store) -> LinkResult<Option<V2PullJournal>> {
9039    #[cfg(unix)]
9040    {
9041        use std::os::unix::fs::PermissionsExt as _;
9042        match store.regular_metadata(Path::new(V2_PULL_JOURNAL)) {
9043            Ok(metadata) if metadata.permissions().mode() & 0o077 != 0 => {
9044                return Err(invalid_feed(
9045                    "v2 pull journal is accessible to group/other; set mode 0600",
9046                ));
9047            }
9048            Ok(_) => {}
9049            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
9050            Err(error) => return Err(error.into()),
9051        }
9052    }
9053    let bytes = match store.read_bounded(Path::new(V2_PULL_JOURNAL), 64 * 1024 * 1024) {
9054        Ok(bytes) => bytes,
9055        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
9056        Err(error) => return Err(error.into()),
9057    };
9058    let journal: V2PullJournal =
9059        serde_json::from_slice(&bytes).map_err(|_| invalid_feed("v2 pull journal is corrupt"))?;
9060    validate_v2_pull_journal(&journal)?;
9061    Ok(Some(journal))
9062}
9063
9064fn cleanup_v2_pull_journal(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
9065    // The journal is the only recovery authority. Remove it first only after
9066    // rollback or baseline commit is durable; an orphan private backup is safe
9067    // and can be pruned, while a journal without its backups is not recoverable.
9068    match store.remove_file(Path::new(V2_PULL_JOURNAL)) {
9069        Ok(()) => {}
9070        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
9071        Err(error) => return Err(error.into()),
9072    }
9073    match store.remove_private_tree(Path::new(&journal.backup_dir)) {
9074        Ok(()) => Ok(()),
9075        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
9076        Err(error) => Err(error.into()),
9077    }
9078}
9079
9080fn prune_orphan_v2_pull_backups(store: &Store) -> LinkResult<()> {
9081    let names = match store.directory_names(Path::new(".dbmd")) {
9082        Ok(names) => names,
9083        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
9084        Err(error) => return Err(error.into()),
9085    };
9086    for name in names {
9087        let Some(name) = name.to_str() else {
9088            continue;
9089        };
9090        let Some(suffix) = name.strip_prefix("pull-backup-") else {
9091            continue;
9092        };
9093        if crate::ulid::is_ulid(suffix) {
9094            store.remove_private_tree(&Path::new(".dbmd").join(name))?;
9095        }
9096    }
9097    Ok(())
9098}
9099
9100fn rollback_v2_pull(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
9101    // Validate every live coordinate and every backup before the first restore.
9102    for entry in &journal.entries {
9103        let limit = entry
9104            .old
9105            .as_ref()
9106            .into_iter()
9107            .chain(entry.new.iter())
9108            .map(|value| value.bytes)
9109            .max()
9110            .unwrap_or(0);
9111        let current = v2_pull_file_coordinate(store, &entry.path, limit)?;
9112        if current != entry.old && current != entry.new {
9113            return Err(LinkError::InvalidPack {
9114                message: format!(
9115                    "cannot recover interrupted pull because `{}` changed afterward",
9116                    entry.path
9117                ),
9118            });
9119        }
9120        if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
9121            let path = Path::new(&journal.backup_dir).join(backup);
9122            let file = store.open_regular(&path)?;
9123            if file.metadata()?.len() != old.bytes || content_sha256_reader(file)? != old.sha256 {
9124                return Err(invalid_feed("v2 pull recovery backup failed verification"));
9125            }
9126        }
9127    }
9128    for entry in journal.entries.iter().rev() {
9129        match (&entry.old, &entry.backup) {
9130            (Some(old), Some(backup)) => {
9131                let bytes =
9132                    store.read_bounded(&Path::new(&journal.backup_dir).join(backup), old.bytes)?;
9133                store.write_atomic(Path::new(&entry.path), &bytes)?;
9134            }
9135            (None, None) if store.regular_file_exists(Path::new(&entry.path))? => {
9136                store.remove_file(Path::new(&entry.path))?;
9137            }
9138            (None, None) => {}
9139            _ => return Err(invalid_feed("v2 pull recovery entry is inconsistent")),
9140        }
9141    }
9142    crate::index::Index::rebuild_all(store).map_err(|error| LinkError::InvalidPack {
9143        message: format!("could not rebuild catalogs after pull recovery: {error}"),
9144    })?;
9145    cleanup_v2_pull_journal(store, journal)
9146}
9147
9148fn recover_v2_pull(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<()> {
9149    let Ok(store) = Store::open_strict(dest) else {
9150        return Ok(());
9151    };
9152    if let Some(journal) = load_v2_pull_journal(&store)? {
9153        if journal.brain != brain {
9154            return Err(invalid_feed("v2 pull journal belongs to another brain"));
9155        }
9156        if journal.phase == V2PullPhase::Preparing {
9157            cleanup_v2_pull_journal(&store, &journal)?;
9158        } else {
9159            let baseline = load_v2_baseline(cfg, brain, dest)?;
9160            let current = v2_pull_baseline_coordinate(baseline.as_ref());
9161            if current == journal.next {
9162                cleanup_v2_pull_journal(&store, &journal)?;
9163            } else {
9164                if current != journal.previous {
9165                    return Err(invalid_feed(
9166                        "cannot recover interrupted pull because its baseline changed afterward",
9167                    ));
9168                }
9169                rollback_v2_pull(&store, &journal)?;
9170            }
9171        }
9172    }
9173    // Cleanup removes the journal first after the state is durably old or new.
9174    // A hard kill in the tiny interval before deleting its backup directory can
9175    // therefore leave only an inert, private orphan. The per-brain operation
9176    // lock is held by the caller, so no live pull can own one here.
9177    prune_orphan_v2_pull_backups(&store)
9178}
9179
9180fn complete_v2_pull(dest: &Path) -> LinkResult<()> {
9181    let store = Store::open_strict(dest).map_err(|error| LinkError::InvalidPack {
9182        message: format!("installed v2 checkout is not a valid db.md store: {error}"),
9183    })?;
9184    if let Some(journal) = load_v2_pull_journal(&store)? {
9185        cleanup_v2_pull_journal(&store, &journal)?;
9186    }
9187    Ok(())
9188}
9189
9190#[cfg(windows)]
9191fn install_windows_initial_sources(
9192    dest: &Path,
9193    entries: &[V2StagedFile],
9194    rebuild_indexes: bool,
9195) -> LinkResult<()> {
9196    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
9197    let name = dest.file_name().ok_or_else(|| LinkError::UnsafePath {
9198        path: dest.display().to_string(),
9199    })?;
9200    let parent_capability = crate::fsx::open_or_create_directory_nofollow(parent)?;
9201    if crate::fsx::directory_exists_beneath(&parent_capability, Path::new(name))? {
9202        return Err(LinkError::UnsafePath {
9203            path: dest.display().to_string(),
9204        });
9205    }
9206    let stage_name = format!(
9207        ".{}.dbmd-pull-stage-{}",
9208        name.to_string_lossy(),
9209        crate::ulid::mint()
9210    );
9211    let stage_path = parent.join(&stage_name);
9212    let stage_capability =
9213        crate::fsx::open_directory_beneath(&parent_capability, Path::new(&stage_name), true)?;
9214    let stage = Store::from_root_and_config(&stage_path, crate::Config::default())?;
9215    let prepared = (|| -> LinkResult<()> {
9216        for entry in entries {
9217            let bytes = crate::fsx::read_bounded_nofollow(&entry.source, entry.bytes)?;
9218            if bytes.len() as u64 != entry.bytes || content_sha256(&bytes) != entry.sha256 {
9219                return Err(invalid_feed(
9220                    "private staged sync source failed final integrity verification",
9221                ));
9222            }
9223            stage.write_atomic(Path::new(&entry.path), &bytes)?;
9224        }
9225        let strict = Store::from_held_root_strict(&stage_path, stage_capability.try_clone()?)
9226            .map_err(|error| LinkError::InvalidPack {
9227                message: format!("v2 staging tree is not a valid db.md store: {error}"),
9228            })?;
9229        if rebuild_indexes {
9230            crate::index::Index::rebuild_all(&strict).map_err(|error| LinkError::InvalidPack {
9231                message: format!("could not materialize v2 local catalogs: {error}"),
9232            })?;
9233        }
9234        Ok(())
9235    })();
9236    if let Err(error) = prepared {
9237        let _ = crate::fsx::remove_tree_beneath(&parent_capability, Path::new(&stage_name));
9238        return Err(error);
9239    }
9240    crate::fsx::rename_directory_beneath(
9241        &parent_capability,
9242        Path::new(&stage_name),
9243        Path::new(name),
9244    )?;
9245    Ok(())
9246}
9247
9248fn install_established_v2_delta(
9249    store: Store,
9250    entries: &[V2StagedFile],
9251    deleted: &[String],
9252    rebuild_indexes: bool,
9253    previous: Option<&V2SyncBaseline>,
9254    next: &V2VerifiedHead,
9255) -> LinkResult<()> {
9256    if load_v2_pull_journal(&store)?.is_some() {
9257        return Err(invalid_feed(
9258            "an interrupted pull must be recovered before installing",
9259        ));
9260    }
9261    let mut sources = std::collections::BTreeMap::new();
9262    for entry in entries {
9263        if sources.insert(entry.path.clone(), entry).is_some() || deleted.contains(&entry.path) {
9264            return Err(invalid_feed("pull mutation repeats a path"));
9265        }
9266    }
9267    let mut paths = sources.keys().cloned().collect::<Vec<_>>();
9268    paths.extend(deleted.iter().cloned());
9269    paths.sort();
9270    paths.dedup();
9271    if paths.is_empty() {
9272        return Ok(());
9273    }
9274    let backup_dir = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
9275    let mut journal = V2PullJournal {
9276        v: 1,
9277        phase: V2PullPhase::Preparing,
9278        brain: next.brain_id.clone(),
9279        previous: v2_pull_baseline_coordinate(previous),
9280        next: v2_pull_head_coordinate(next),
9281        backup_dir: backup_dir.clone(),
9282        entries: Vec::with_capacity(paths.len()),
9283    };
9284    for path in &paths {
9285        let old = v2_pull_file_coordinate(&store, path, MAX_STORE_BYTES)?;
9286        let new = sources.get(path).map(|entry| V2PullFileCoordinate {
9287            sha256: entry.sha256.clone(),
9288            bytes: entry.bytes,
9289        });
9290        if old == new {
9291            continue;
9292        }
9293        let index = journal.entries.len();
9294        journal.entries.push(V2PullJournalEntry {
9295            path: path.clone(),
9296            backup: old.as_ref().map(|_| format!("{index:08x}")),
9297            old,
9298            new,
9299        });
9300    }
9301    if journal.entries.is_empty() {
9302        return Ok(());
9303    }
9304    let backup_bytes = journal.entries.iter().try_fold(0_u64, |total, entry| {
9305        entry
9306            .old
9307            .as_ref()
9308            .map_or(Some(total), |old| total.checked_add(old.bytes))
9309    });
9310    if backup_bytes.is_none_or(|bytes| bytes > MAX_STORE_BYTES) {
9311        return Err(LinkError::InvalidPack {
9312            message: "pull recovery preimages exceed the 512 MB transaction limit".to_string(),
9313        });
9314    }
9315    validate_v2_pull_journal(&journal)?;
9316    store.write_private_atomic_new(
9317        Path::new(V2_PULL_JOURNAL),
9318        &v2_pull_journal_bytes(&journal)?,
9319    )?;
9320    let prepared = (|| -> LinkResult<()> {
9321        store.create_private_dir_all(Path::new(&backup_dir))?;
9322        for entry in &journal.entries {
9323            if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
9324                let bytes = store.read_bounded(Path::new(&entry.path), old.bytes)?;
9325                if content_sha256(&bytes) != old.sha256 {
9326                    return Err(invalid_feed("live pull source changed during backup"));
9327                }
9328                store.write_private_atomic_new(&Path::new(&backup_dir).join(backup), &bytes)?;
9329            }
9330        }
9331        journal.phase = V2PullPhase::Ready;
9332        store.write_private_atomic(
9333            Path::new(V2_PULL_JOURNAL),
9334            &v2_pull_journal_bytes(&journal)?,
9335        )?;
9336        Ok(())
9337    })();
9338    if let Err(error) = prepared {
9339        let cleanup = cleanup_v2_pull_journal(&store, &journal);
9340        return match cleanup {
9341            Ok(()) => Err(error),
9342            Err(cleanup) => Err(LinkError::InvalidPack {
9343                message: format!("{error}; recovery metadata cleanup also failed: {cleanup}"),
9344            }),
9345        };
9346    }
9347    let installed = (|| -> LinkResult<()> {
9348        for entry in &journal.entries {
9349            if v2_pull_file_coordinate(&store, &entry.path, MAX_STORE_BYTES)? != entry.old {
9350                return Err(LinkError::InvalidPack {
9351                    message: format!("local path `{}` changed during pull", entry.path),
9352                });
9353            }
9354            if let Some(source) = sources.get(&entry.path) {
9355                let bytes = crate::fsx::read_bounded_nofollow(&source.source, source.bytes)?;
9356                if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != source.sha256 {
9357                    return Err(invalid_feed(
9358                        "private staged sync source failed final integrity verification",
9359                    ));
9360                }
9361                store.write_atomic(Path::new(&entry.path), &bytes)?;
9362            } else if entry.old.is_some() {
9363                store.remove_file(Path::new(&entry.path))?;
9364            }
9365        }
9366        if rebuild_indexes {
9367            crate::index::Index::rebuild_all(&store).map_err(|error| LinkError::InvalidPack {
9368                message: format!("could not materialize v2 local catalogs: {error}"),
9369            })?;
9370        }
9371        Ok(())
9372    })();
9373    if let Err(error) = installed {
9374        return match rollback_v2_pull(&store, &journal) {
9375            Ok(()) => Err(error),
9376            Err(rollback) => Err(LinkError::InvalidPack {
9377                message: format!("{error}; durable pull rollback also failed: {rollback}"),
9378            }),
9379        };
9380    }
9381    Ok(())
9382}
9383
9384#[cfg(windows)]
9385fn install_pulled_delta_sources(
9386    dest: &Path,
9387    entries: &[V2StagedFile],
9388    deleted: &[String],
9389    rebuild_indexes: bool,
9390    previous: Option<&V2SyncBaseline>,
9391    next: &V2VerifiedHead,
9392) -> LinkResult<()> {
9393    match Store::open_strict(dest) {
9394        Ok(store) => {
9395            install_established_v2_delta(store, entries, deleted, rebuild_indexes, previous, next)
9396        }
9397        Err(_) => install_windows_initial_sources(dest, entries, rebuild_indexes),
9398    }
9399}
9400
9401#[cfg(not(any(unix, windows)))]
9402fn install_pulled_delta_sources(
9403    _dest: &Path,
9404    _entries: &[V2StagedFile],
9405    _deleted: &[String],
9406    _rebuild_indexes: bool,
9407    _previous: Option<&V2SyncBaseline>,
9408    _next: &V2VerifiedHead,
9409) -> LinkResult<()> {
9410    Err(LinkError::UnsupportedPlatform {
9411        operation: "atomic v2 pull install",
9412    })
9413}
9414
9415#[cfg(unix)]
9416fn install_pulled_snapshot(dest: &Path, entries: &[(String, Vec<u8>)]) -> LinkResult<()> {
9417    install_pulled_delta(dest, entries, &[], false)
9418}
9419
9420#[cfg(not(windows))]
9421fn is_safe_slug(slug: &str) -> bool {
9422    !slug.is_empty()
9423        && slug.len() <= 63
9424        && !slug.starts_with('-')
9425        && !slug.ends_with('-')
9426        && slug
9427            .bytes()
9428            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
9429}
9430
9431fn le_u16(bytes: &[u8], at: usize) -> Option<u16> {
9432    Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
9433}
9434
9435fn le_u32(bytes: &[u8], at: usize) -> Option<u32> {
9436    Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
9437}
9438
9439fn le_u64(bytes: &[u8], at: usize) -> Option<u64> {
9440    Some(u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?))
9441}
9442
9443fn preflight_zip_central_directory(
9444    bytes: &[u8],
9445    offset: usize,
9446    size: usize,
9447    count: u64,
9448) -> LinkResult<()> {
9449    const CENTRAL_ENTRY_SIG: &[u8; 4] = b"PK\x01\x02";
9450    let end = offset
9451        .checked_add(size)
9452        .filter(|end| *end <= bytes.len())
9453        .ok_or_else(|| LinkError::InvalidPack {
9454            message: "ZIP central directory is out of bounds".to_string(),
9455        })?;
9456    let mut cursor = offset;
9457    for _ in 0..count {
9458        if bytes.get(cursor..cursor + 4) != Some(CENTRAL_ENTRY_SIG.as_slice()) {
9459            return Err(LinkError::InvalidPack {
9460                message: "ZIP central directory entry count is inconsistent".to_string(),
9461            });
9462        }
9463        if le_u16(bytes, cursor + 34) != Some(0) {
9464            return Err(LinkError::InvalidPack {
9465                message: "multi-disk ZIP archives are not supported".to_string(),
9466            });
9467        }
9468        let variable = [28, 30, 32].into_iter().try_fold(0usize, |total, at| {
9469            total.checked_add(le_u16(bytes, cursor + at)? as usize)
9470        });
9471        cursor = cursor
9472            .checked_add(46)
9473            .and_then(|fixed| fixed.checked_add(variable?))
9474            .filter(|cursor| *cursor <= end)
9475            .ok_or_else(|| LinkError::InvalidPack {
9476                message: "ZIP central directory entry is truncated".to_string(),
9477            })?;
9478    }
9479    if cursor != end {
9480        return Err(LinkError::InvalidPack {
9481            message: "ZIP central directory size is inconsistent".to_string(),
9482        });
9483    }
9484    Ok(())
9485}
9486
9487/// Read only the bounded ZIP trailer before `ZipArchive::new` allocates one
9488/// metadata object per central-directory entry. Supports ordinary EOCD and the
9489/// Zip64 locator/record emitted for >65,535-entry archives.
9490fn preflight_zip_entry_count(bytes: &[u8], max_entries: usize) -> LinkResult<()> {
9491    const EOCD_SIG: &[u8; 4] = b"PK\x05\x06";
9492    const ZIP64_LOCATOR_SIG: &[u8; 4] = b"PK\x06\x07";
9493    const ZIP64_EOCD_SIG: &[u8; 4] = b"PK\x06\x06";
9494    let search_start = bytes.len().saturating_sub(22 + u16::MAX as usize);
9495    let eocd = bytes[search_start..]
9496        .windows(4)
9497        .rposition(|window| window == EOCD_SIG)
9498        .map(|offset| search_start + offset)
9499        .ok_or_else(|| LinkError::InvalidPack {
9500            message: "ZIP has no end-of-central-directory record".to_string(),
9501        })?;
9502    let invalid_end = || LinkError::InvalidPack {
9503        message: "ZIP has an invalid end-of-central-directory structure".to_string(),
9504    };
9505    let comment_len = le_u16(bytes, eocd + 20).ok_or_else(invalid_end)? as usize;
9506    if eocd
9507        .checked_add(22)
9508        .and_then(|end| end.checked_add(comment_len))
9509        != Some(bytes.len())
9510    {
9511        // Do not fall back to an earlier signature. ZipArchive does, and a
9512        // fake low-count EOCD appended after a real Zip64 entry-count bomb
9513        // would otherwise bypass this allocation preflight.
9514        return Err(invalid_end());
9515    }
9516    let disk = le_u16(bytes, eocd + 4);
9517    let central_disk = le_u16(bytes, eocd + 6);
9518    if disk != Some(0) || central_disk != Some(0) {
9519        return Err(LinkError::InvalidPack {
9520            message: "multi-disk ZIP archives are not supported".to_string(),
9521        });
9522    }
9523    let entries_on_disk = le_u16(bytes, eocd + 8).ok_or_else(invalid_end)?;
9524    let ordinary = le_u16(bytes, eocd + 10).ok_or_else(invalid_end)?;
9525    if entries_on_disk != ordinary {
9526        return Err(LinkError::InvalidPack {
9527            message: "multi-disk ZIP archives are not supported".to_string(),
9528        });
9529    }
9530    let zip64_locator = eocd
9531        .checked_sub(20)
9532        .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_LOCATOR_SIG.as_slice()));
9533    let (count, central_offset, central_size) = if ordinary != u16::MAX || zip64_locator.is_none() {
9534        let central_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)? as usize;
9535        let central_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)? as usize;
9536        if central_offset
9537            .checked_add(central_size)
9538            .filter(|end| *end == eocd)
9539            .is_none()
9540        {
9541            return Err(invalid_end());
9542        }
9543        (ordinary as u64, central_offset, central_size)
9544    } else {
9545        let Some(locator) = zip64_locator else {
9546            return Err(invalid_end());
9547        };
9548        if le_u32(bytes, locator + 4) != Some(0) || le_u32(bytes, locator + 16) != Some(1) {
9549            return Err(LinkError::InvalidPack {
9550                message: "multi-disk ZIP64 archives are not supported".to_string(),
9551            });
9552        }
9553        let record = le_u64(bytes, locator + 8)
9554            .and_then(|offset| usize::try_from(offset).ok())
9555            .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_EOCD_SIG.as_slice()))
9556            .ok_or_else(|| LinkError::InvalidPack {
9557                message: "ZIP64 archive has an invalid end record".to_string(),
9558            })?;
9559        let record_size = le_u64(bytes, record + 4)
9560            .and_then(|size| usize::try_from(size).ok())
9561            .filter(|size| *size >= 44)
9562            .ok_or_else(invalid_end)?;
9563        if record
9564            .checked_add(12)
9565            .and_then(|end| end.checked_add(record_size))
9566            != Some(locator)
9567            || le_u32(bytes, record + 16) != Some(0)
9568            || le_u32(bytes, record + 20) != Some(0)
9569        {
9570            return Err(invalid_end());
9571        }
9572        let zip64_on_disk = le_u64(bytes, record + 24).ok_or_else(invalid_end)?;
9573        let zip64_total = le_u64(bytes, record + 32).ok_or_else(invalid_end)?;
9574        let central_size = le_u64(bytes, record + 40)
9575            .and_then(|size| usize::try_from(size).ok())
9576            .ok_or_else(invalid_end)?;
9577        let central_offset = le_u64(bytes, record + 48)
9578            .and_then(|offset| usize::try_from(offset).ok())
9579            .ok_or_else(invalid_end)?;
9580        if zip64_on_disk != zip64_total
9581            || central_offset
9582                .checked_add(central_size)
9583                .filter(|end| *end == record)
9584                .is_none()
9585        {
9586            return Err(invalid_end());
9587        }
9588        let legacy_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)?;
9589        let legacy_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)?;
9590        if (legacy_size != u32::MAX && legacy_size as usize != central_size)
9591            || (legacy_offset != u32::MAX && legacy_offset as usize != central_offset)
9592        {
9593            return Err(invalid_end());
9594        }
9595        (zip64_total, central_offset, central_size)
9596    };
9597    if count == 0 || count > max_entries as u64 {
9598        return Err(LinkError::InvalidPack {
9599            message: format!("invalid file count {count}"),
9600        });
9601    }
9602    preflight_zip_central_directory(bytes, central_offset, central_size, count)?;
9603    Ok(())
9604}
9605
9606fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
9607    preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)?;
9608    let mut archive =
9609        zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
9610            message: format!("ZIP parse failed: {err}"),
9611        })?;
9612    if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
9613        return Err(LinkError::InvalidPack {
9614            message: format!("invalid file count {}", archive.len()),
9615        });
9616    }
9617    let mut total = 0u64;
9618    let mut seen = std::collections::HashSet::new();
9619    let mut entries = Vec::with_capacity(archive.len());
9620    for index in 0..archive.len() {
9621        let mut file = archive
9622            .by_index(index)
9623            .map_err(|err| LinkError::InvalidPack {
9624                message: format!("ZIP entry failed: {err}"),
9625            })?;
9626        if file.is_dir() {
9627            continue;
9628        }
9629        let path = file.name().to_string();
9630        if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
9631            return Err(LinkError::UnsafePath { path });
9632        }
9633        if file
9634            .unix_mode()
9635            .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
9636        {
9637            return Err(LinkError::InvalidPack {
9638                message: format!("non-file entry `{path}`"),
9639            });
9640        }
9641        if !seen.insert(path.clone()) {
9642            return Err(LinkError::InvalidPack {
9643                message: format!("duplicate path `{path}`"),
9644            });
9645        }
9646        let remaining = MAX_STORE_BYTES.saturating_sub(total);
9647        if file.size() > remaining {
9648            return Err(LinkError::InvalidPack {
9649                message: "expanded content exceeds the 512 MB limit".to_string(),
9650            });
9651        }
9652        let mut content = Vec::new();
9653        (&mut file)
9654            .take(remaining + 1)
9655            .read_to_end(&mut content)
9656            .map_err(|err| LinkError::InvalidPack {
9657                message: format!("could not decompress `{path}`: {err}"),
9658            })?;
9659        if content.len() as u64 > remaining {
9660            return Err(LinkError::InvalidPack {
9661                message: "expanded content exceeds the 512 MB limit".to_string(),
9662            });
9663        }
9664        if content.len() as u64 != file.size() {
9665            return Err(LinkError::InvalidPack {
9666                message: format!("length mismatch for `{path}`"),
9667            });
9668        }
9669        total += content.len() as u64;
9670        entries.push((path, content));
9671    }
9672    if entries.is_empty() {
9673        return Err(LinkError::InvalidPack {
9674            message: "pack contains no files".to_string(),
9675        });
9676    }
9677    Ok(entries)
9678}
9679
9680fn verify_snapshot_manifest(entries: &[(String, Vec<u8>)], signed: &[FeedFile]) -> LinkResult<()> {
9681    let mut expected = std::collections::BTreeMap::new();
9682    for file in signed {
9683        if !safe_store_rel_path(&file.path) {
9684            return Err(LinkError::UnsafePath {
9685                path: file.path.clone(),
9686            });
9687        }
9688        if !is_sha256(&file.sha256)
9689            || expected
9690                .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
9691                .is_some()
9692        {
9693            return Err(invalid_feed(
9694                "signed snapshot manifest contains an invalid or duplicate file",
9695            ));
9696        }
9697    }
9698    if expected.len() != entries.len() {
9699        return Err(invalid_feed(
9700            "downloaded pack file set differs from the signed snapshot manifest",
9701        ));
9702    }
9703    for (path, bytes) in entries {
9704        let Some((sha256, declared_bytes)) = expected.get(path.as_str()) else {
9705            return Err(invalid_feed(format!(
9706                "downloaded pack contains unsigned path `{path}`"
9707            )));
9708        };
9709        if *declared_bytes != bytes.len() as u64
9710            || *sha256 != format!("{:x}", Sha256::digest(bytes))
9711        {
9712            return Err(invalid_feed(format!(
9713                "downloaded file `{path}` differs from its signed manifest"
9714            )));
9715        }
9716    }
9717    Ok(())
9718}
9719
9720/// Collect the files a push sends: the store's owned text — `DB.md`,
9721/// `assets.jsonl` when present, and every content `.md` under `records/` and
9722/// `sources/` (the store walk, which already excludes hidden dirs like
9723/// `.dbmd/`, the `log/` archive, and derived `index.*` catalogs; the hub
9724/// derives its own index, and local history stays local). Returns
9725/// `(store-relative path, content)` pairs, path-sorted.
9726pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
9727    require_hardened_filesystem("sync push")?;
9728    preflight_push_ownership(store)?;
9729    let mut out: Vec<(String, String)> = Vec::new();
9730    let mut total = 0u64;
9731
9732    let mut read_text = |rel: &str| -> LinkResult<String> {
9733        let bytes = store.read_bounded(Path::new(rel), MAX_STORE_BYTES.saturating_sub(total))?;
9734        total = total
9735            .checked_add(bytes.len() as u64)
9736            .ok_or_else(|| LinkError::PushTooLarge {
9737                detail: "uncompressed byte count overflow".to_string(),
9738            })?;
9739        if total > MAX_STORE_BYTES {
9740            return Err(LinkError::PushTooLarge {
9741                detail: format!("{total} uncompressed bytes"),
9742            });
9743        }
9744        String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
9745            path: rel.to_string(),
9746        })
9747    };
9748
9749    out.push(("DB.md".to_string(), read_text("DB.md")?));
9750    if store
9751        .regular_file_exists(Path::new("assets.jsonl"))
9752        .unwrap_or(false)
9753    {
9754        out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
9755    }
9756
9757    for rel in store.walk()? {
9758        let rel_str = rel.to_string_lossy().replace('\\', "/");
9759        if !safe_store_rel_path(&rel_str) {
9760            // A locally-legal name outside the hub's portable charset cannot
9761            // travel this wire; refusing beats silently dropping it.
9762            return Err(LinkError::UnsafePath { path: rel_str });
9763        }
9764        let content = read_text(&rel_str)?;
9765        out.push((rel_str, content));
9766    }
9767
9768    out.sort_by(|a, b| a.0.cmp(&b.0));
9769    Ok(out)
9770}
9771
9772/// Refuse to build a destructive whole-store snapshot from an ambiguous local
9773/// tree. Ordinary read-only walks safely prune foreign paths, but a push that
9774/// silently omitted them could delete the hosted copies of those paths.
9775fn preflight_push_ownership(store: &Store) -> LinkResult<()> {
9776    if let Some(nested) = store.nested_store_roots()?.into_iter().next() {
9777        return Err(LinkError::from(std::io::Error::new(
9778            std::io::ErrorKind::PermissionDenied,
9779            format!("cannot push: nested db.md store at {}", nested.display()),
9780        )));
9781    }
9782
9783    if let Some(symlink) = store.unowned_symlinks()?.into_iter().next() {
9784        return Err(LinkError::from(std::io::Error::new(
9785            std::io::ErrorKind::PermissionDenied,
9786            format!(
9787                "cannot push: {} is a symlink outside the store ownership model",
9788                symlink.display()
9789            ),
9790        )));
9791    }
9792    Ok(())
9793}
9794
9795/// Push `files` to `brain` as a whole-store snapshot — the hub's push
9796/// semantics: the hosted copy becomes exactly this set (pull first if the
9797/// hosted side may have records the local copy lacks). Client-side caps
9798/// mirror the hub's JSON-path limits so an oversized push fails before the
9799/// upload.
9800pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
9801    require_safe_ref(brain)?;
9802    let remote = verified_remote_head(cfg, brain, false)?;
9803    if files.len() > MAX_PUSH_FILES {
9804        return Err(LinkError::PushTooLarge {
9805            detail: format!("{} files", files.len()),
9806        });
9807    }
9808    let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
9809    if raw_total > MAX_STORE_BYTES {
9810        return Err(LinkError::PushTooLarge {
9811            detail: format!("{raw_total} uncompressed bytes"),
9812        });
9813    }
9814
9815    // Self-custody (a brain key is configured): the JSON fast path is
9816    // hub-signed by construction, so every push goes through the pack flow
9817    // with a locally signed entry — the hub verifies and can never sign.
9818    if cfg.brain_key.is_none() {
9819        let body = json!({
9820            "files": files
9821                .iter()
9822                .map(|(p, c)| json!({ "path": p, "content": c }))
9823                .collect::<Vec<_>>(),
9824        });
9825        if body.to_string().len() <= MAX_PUSH_BYTES {
9826            let path = format!("/api/hub/brains/{brain}/push");
9827            let pushed = ensure_ok(
9828                request(cfg, "POST", &path, Some(&body), Auth::Required)?,
9829                "sync push",
9830            )?;
9831            return Ok(pushed);
9832        }
9833    }
9834
9835    let pack = build_store_pack(files)?;
9836    if pack.len() as u64 > MAX_PACK_BYTES {
9837        return Err(LinkError::PushTooLarge {
9838            detail: format!("{} pack bytes", pack.len()),
9839        });
9840    }
9841    let sha256 = format!("{:x}", Sha256::digest(&pack));
9842    let mut meta = json!({ "sha256": sha256, "bytes": pack.len() });
9843    if let Some(key) = &cfg.brain_key {
9844        if !remote.head.verified {
9845            return Err(invalid_feed(
9846                "self-custody push requires a fully verified, unscoped feed head",
9847            ));
9848        }
9849        let identity = remote
9850            .identity
9851            .as_ref()
9852            .ok_or_else(|| invalid_feed("verified brain has no current identity"))?;
9853        let current_multikey = format!("ed25519:{}", identity.fingerprint);
9854        if key.multikey != current_multikey || key.public_key_spki != identity.public_key_spki {
9855            return Err(invalid_feed(
9856                "configured brain key is not the verified current brain identity",
9857            ));
9858        }
9859        // The verified state pins seq + prev; a concurrent writer surfaces as
9860        // the hub's 422 on commit (re-run to retry against the new head).
9861        let next_seq = remote
9862            .head
9863            .seq
9864            .checked_add(1)
9865            .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
9866        let mut manifest: Vec<WireFeedFile> = files
9867            .iter()
9868            .map(|(path, content)| WireFeedFile {
9869                path: path.clone(),
9870                sha256: format!("{:x}", Sha256::digest(content.as_bytes())),
9871                bytes: content.len() as u64,
9872            })
9873            .collect();
9874        manifest.sort_by(|a, b| a.path.cmp(&b.path));
9875        let ts = crate::now()
9876            .with_timezone(&chrono::Utc)
9877            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
9878            .to_string();
9879        let entry = self_custody_entry(
9880            key,
9881            next_seq,
9882            ts,
9883            &sha256,
9884            &manifest,
9885            remote.head.feed_hash.as_deref(),
9886        )?;
9887        meta["entry"] = Value::String(entry);
9888    }
9889    let presigned = ensure_ok(
9890        request(
9891            cfg,
9892            "POST",
9893            &format!("/api/hub/brains/{brain}/packs/presign"),
9894            Some(&meta),
9895            Auth::Required,
9896        )?,
9897        "prepare pack upload",
9898    )?;
9899    let url = presigned
9900        .get("url")
9901        .and_then(Value::as_str)
9902        .ok_or_else(|| LinkError::InvalidPack {
9903            message: "the hub returned no upload URL".to_string(),
9904        })?;
9905    put_presigned(
9906        cfg,
9907        url,
9908        presigned.get("headers").unwrap_or(&Value::Null),
9909        &pack,
9910    )?;
9911    let committed = ensure_ok(
9912        request(
9913            cfg,
9914            "POST",
9915            &format!("/api/hub/brains/{brain}/packs/commit"),
9916            Some(&meta),
9917            Auth::Required,
9918        )?,
9919        "commit pack",
9920    )?;
9921    Ok(committed)
9922}
9923
9924fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
9925    const LOCAL_HEADER: u32 = 0x0403_4b50;
9926    const CENTRAL_HEADER: u32 = 0x0201_4b50;
9927    const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
9928    const VERSION_20: u16 = 20;
9929    const MADE_BY_UNIX_20: u16 = (3 << 8) | VERSION_20;
9930    const UTF8_FLAG: u16 = 1 << 11;
9931    const STORED: u16 = 0;
9932    const DOS_TIME_MIDNIGHT: u16 = 0;
9933    const DOS_DATE_1980_01_01: u16 = (1 << 5) | 1;
9934    const UNIX_REGULAR_0600: u32 = 0o100600 << 16;
9935
9936    struct CentralEntry<'a> {
9937        name: &'a [u8],
9938        crc32: u32,
9939        size: u32,
9940        local_offset: u32,
9941    }
9942
9943    fn push_u16(out: &mut Vec<u8>, value: u16) {
9944        out.extend_from_slice(&value.to_le_bytes());
9945    }
9946
9947    fn push_u32(out: &mut Vec<u8>, value: u32) {
9948        out.extend_from_slice(&value.to_le_bytes());
9949    }
9950
9951    if files.is_empty() {
9952        return Err(LinkError::InvalidPack {
9953            message: "cannot create an empty snapshot pack".to_string(),
9954        });
9955    }
9956    if files.len() > u16::MAX as usize {
9957        return Err(LinkError::PushTooLarge {
9958            detail: format!(
9959                "{} files (canonical ZIP32 packs cap at {})",
9960                files.len(),
9961                u16::MAX
9962            ),
9963        });
9964    }
9965
9966    let mut sorted: Vec<_> = files.iter().collect();
9967    sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
9968    let mut previous: Option<&str> = None;
9969    for (path, content) in &sorted {
9970        if !safe_store_rel_path(path) {
9971            return Err(LinkError::UnsafePath {
9972                path: (*path).clone(),
9973            });
9974        }
9975        if previous == Some(path.as_str()) {
9976            return Err(LinkError::InvalidPack {
9977                message: format!("duplicate path `{path}`"),
9978            });
9979        }
9980        previous = Some(path.as_str());
9981        u32::try_from(content.len()).map_err(|_| LinkError::PushTooLarge {
9982            detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
9983        })?;
9984    }
9985
9986    let mut out = Vec::new();
9987    let mut central = Vec::with_capacity(sorted.len());
9988    for (path, content) in sorted {
9989        let name = path.as_bytes();
9990        let name_len = u16::try_from(name.len()).map_err(|_| LinkError::InvalidPack {
9991            message: format!("ZIP entry name is too long: `{path}`"),
9992        })?;
9993        let bytes = content.as_bytes();
9994        let size = u32::try_from(bytes.len()).map_err(|_| LinkError::PushTooLarge {
9995            detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
9996        })?;
9997        let local_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
9998            detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
9999        })?;
10000        let crc32 = crc32fast::hash(bytes);
10001
10002        // Canonical local header: sizes and CRC are known up front. Bit 3 is
10003        // deliberately clear, so there is no trailing data descriptor.
10004        push_u32(&mut out, LOCAL_HEADER);
10005        push_u16(&mut out, VERSION_20);
10006        push_u16(&mut out, UTF8_FLAG);
10007        push_u16(&mut out, STORED);
10008        push_u16(&mut out, DOS_TIME_MIDNIGHT);
10009        push_u16(&mut out, DOS_DATE_1980_01_01);
10010        push_u32(&mut out, crc32);
10011        push_u32(&mut out, size);
10012        push_u32(&mut out, size);
10013        push_u16(&mut out, name_len);
10014        push_u16(&mut out, 0); // no local extra data
10015        out.extend_from_slice(name);
10016        out.extend_from_slice(bytes);
10017
10018        central.push(CentralEntry {
10019            name,
10020            crc32,
10021            size,
10022            local_offset,
10023        });
10024    }
10025
10026    let central_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
10027        detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
10028    })?;
10029    for entry in &central {
10030        push_u32(&mut out, CENTRAL_HEADER);
10031        push_u16(&mut out, MADE_BY_UNIX_20);
10032        push_u16(&mut out, VERSION_20);
10033        push_u16(&mut out, UTF8_FLAG);
10034        push_u16(&mut out, STORED);
10035        push_u16(&mut out, DOS_TIME_MIDNIGHT);
10036        push_u16(&mut out, DOS_DATE_1980_01_01);
10037        push_u32(&mut out, entry.crc32);
10038        push_u32(&mut out, entry.size);
10039        push_u32(&mut out, entry.size);
10040        push_u16(&mut out, entry.name.len() as u16);
10041        push_u16(&mut out, 0); // no central extra data
10042        push_u16(&mut out, 0); // no file comment
10043        push_u16(&mut out, 0); // disk number
10044        push_u16(&mut out, 0); // internal attributes
10045        push_u32(&mut out, UNIX_REGULAR_0600);
10046        push_u32(&mut out, entry.local_offset);
10047        out.extend_from_slice(entry.name);
10048    }
10049    let central_size = u32::try_from(out.len())
10050        .ok()
10051        .and_then(|end| end.checked_sub(central_offset))
10052        .ok_or_else(|| LinkError::PushTooLarge {
10053            detail: "canonical ZIP32 central directory exceeds its limit".to_string(),
10054        })?;
10055    let entry_count = central.len() as u16;
10056
10057    push_u32(&mut out, END_OF_CENTRAL_DIRECTORY);
10058    push_u16(&mut out, 0); // this disk
10059    push_u16(&mut out, 0); // central directory disk
10060    push_u16(&mut out, entry_count);
10061    push_u16(&mut out, entry_count);
10062    push_u32(&mut out, central_size);
10063    push_u32(&mut out, central_offset);
10064    push_u16(&mut out, 0); // no archive comment
10065
10066    if out.len() > u32::MAX as usize {
10067        return Err(LinkError::PushTooLarge {
10068            detail: "canonical ZIP32 pack exceeds 4 GiB".to_string(),
10069        });
10070    }
10071    Ok(out)
10072}
10073
10074// ─────────────────────────────────────────────────────────────────────────────
10075// grant — issue / list / revoke capabilities (owner-side)
10076// ─────────────────────────────────────────────────────────────────────────────
10077
10078/// The two capabilities a v0 hub enforces.
10079#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10080pub enum Capability {
10081    /// Read the granted slice.
10082    Read,
10083    /// Read and push (whole-store; a path-scoped grant is read-only).
10084    Write,
10085}
10086
10087impl Capability {
10088    /// The wire form.
10089    pub fn as_str(self) -> &'static str {
10090        match self {
10091            Capability::Read => "read",
10092            Capability::Write => "write",
10093        }
10094    }
10095}
10096
10097/// Issue (or refresh) a grant on `brain` to `grantee` — a hub principal named
10098/// by email in v0 (the protocol's near-term simplification; key-named
10099/// grantees arrive with the signing layer). `scope` is a store-path prefix
10100/// (the hub's enforcement unit); `until` an ISO 8601 expiry, absent = until
10101/// revoked.
10102pub fn grant_issue(
10103    cfg: &HubConfig,
10104    brain: &str,
10105    grantee: &str,
10106    can: Capability,
10107    scope: Option<&str>,
10108    until: Option<&str>,
10109) -> LinkResult<Value> {
10110    require_safe_ref(brain)?;
10111    let _ = verified_remote_head(cfg, brain, false)?;
10112    // Grantee shape decides the axis: a base64url Ed25519 SPKI is a bare
10113    // multikey holder (link.md §6 cross-party keys — no hub account; the
10114    // printed `publicKeySpki` from `dbmd key generate`); anything else is a
10115    // hub principal named by email.
10116    let is_key_grantee = URL_SAFE_NO_PAD
10117        .decode(grantee)
10118        .map(|der| der.len() == 44 && der.starts_with(&ED25519_SPKI_PREFIX))
10119        .unwrap_or(false);
10120    let mut body = if is_key_grantee {
10121        json!({ "keySpki": grantee, "capability": can.as_str() })
10122    } else {
10123        json!({ "email": grantee, "capability": can.as_str() })
10124    };
10125    if let Some(s) = scope {
10126        body["scopePrefix"] = json!(s);
10127    }
10128    if let Some(u) = until {
10129        body["expiresAt"] = json!(u);
10130    }
10131    let path = format!("/api/hub/brains/{brain}/grants");
10132    ensure_ok(
10133        request(cfg, "POST", &path, Some(&body), Auth::Required)?,
10134        "grant issue",
10135    )
10136}
10137
10138/// List the active grants (and pending invites) on `brain`. Owner-side.
10139pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
10140    require_safe_ref(brain)?;
10141    let _ = verified_remote_head(cfg, brain, false)?;
10142    let path = format!("/api/hub/brains/{brain}/grants");
10143    ensure_ok(
10144        request(cfg, "GET", &path, None, Auth::Required)?,
10145        "grant list",
10146    )
10147}
10148
10149/// Revoke a grant (or cancel a pending invite) by id. Owner-side; revocation
10150/// is soft on the hub (the audit trail survives).
10151pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
10152    require_safe_ref(brain)?;
10153    require_safe_grant_id(grant_id)?;
10154    let _ = verified_remote_head(cfg, brain, false)?;
10155    let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
10156    ensure_ok(
10157        request(cfg, "DELETE", &path, None, Auth::Required)?,
10158        "grant revoke",
10159    )
10160}
10161
10162// ─────────────────────────────────────────────────────────────────────────────
10163// permissioned v2 proposals — inspect, accept, reject
10164// ─────────────────────────────────────────────────────────────────────────────
10165
10166#[derive(Debug)]
10167struct VerifiedV2Proposal {
10168    value: Value,
10169    changes: Value,
10170    blobs: Vec<(String, u64, String)>,
10171}
10172
10173fn require_proposal_id(id: &str) -> LinkResult<()> {
10174    if crate::ulid::is_ulid(id) {
10175        Ok(())
10176    } else {
10177        Err(invalid_feed("proposal id is not a lowercase ULID"))
10178    }
10179}
10180
10181fn verified_v2_proposal(
10182    cfg: &HubConfig,
10183    head: &V2VerifiedHead,
10184    proposal_id: &str,
10185) -> LinkResult<VerifiedV2Proposal> {
10186    require_proposal_id(proposal_id)?;
10187    if head.view_kind != "full" {
10188        return Err(invalid_feed(
10189            "proposal review requires a full readable view",
10190        ));
10191    }
10192    let path = format!(
10193        "/api/hub/brains/{}/v2/proposals/{proposal_id}",
10194        head.brain_id
10195    );
10196    let value = ensure_ok(
10197        request_capped(
10198            cfg,
10199            "GET",
10200            &path,
10201            None,
10202            Auth::Required,
10203            MAX_FEED_RESPONSE_BYTES,
10204        )?,
10205        "v2 proposal",
10206    )?;
10207    verify_v2_proposal_value(head, proposal_id, value)
10208}
10209
10210fn verify_v2_proposal_value(
10211    head: &V2VerifiedHead,
10212    proposal_id: &str,
10213    value: Value,
10214) -> LinkResult<VerifiedV2Proposal> {
10215    if value.get("v").and_then(Value::as_u64) != Some(2) {
10216        return Err(invalid_feed("proposal response has an invalid version"));
10217    }
10218    let proposal = value
10219        .get("proposal")
10220        .and_then(Value::as_object)
10221        .ok_or_else(|| invalid_feed("proposal response has no proposal"))?;
10222    if proposal.get("id").and_then(Value::as_str) != Some(proposal_id) {
10223        return Err(invalid_feed("proposal response changed its id"));
10224    }
10225    let payload_hash = proposal
10226        .get("payload_sha256")
10227        .and_then(Value::as_str)
10228        .filter(|hash| is_sha256(hash))
10229        .ok_or_else(|| invalid_feed("proposal has no payload address"))?;
10230    let clear_hash = proposal
10231        .get("clear_sha256")
10232        .and_then(Value::as_str)
10233        .filter(|hash| is_sha256(hash))
10234        .ok_or_else(|| invalid_feed("proposal has no clear payload digest"))?;
10235    let submission_hash = proposal
10236        .get("submission_claim_sha256")
10237        .and_then(Value::as_str)
10238        .filter(|hash| is_sha256(hash))
10239        .ok_or_else(|| invalid_feed("proposal has no submission claim address"))?;
10240    let submission = STANDARD
10241        .decode(
10242            proposal
10243                .get("submission_claim_base64")
10244                .and_then(Value::as_str)
10245                .ok_or_else(|| invalid_feed("proposal has no submission claim"))?,
10246        )
10247        .map_err(|_| invalid_feed("proposal submission claim is not base64"))?;
10248    let submission_value: Value = serde_json::from_slice(&submission)
10249        .map_err(|_| invalid_feed("proposal submission claim is not JSON"))?;
10250    if crate::linkmd_v2::canonical_bytes(&submission_value)
10251        .map_err(|error| invalid_feed(error.to_string()))?
10252        != submission
10253        || crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &submission)
10254            .map_err(|error| invalid_feed(error.to_string()))?
10255            != submission_hash
10256    {
10257        return Err(invalid_feed(
10258            "proposal submission claim is not canonical or addressed",
10259        ));
10260    }
10261    let envelope = submission_value
10262        .as_object()
10263        .ok_or_else(|| invalid_feed("proposal submission claim is not an object"))?;
10264    let claim = envelope
10265        .get("claim")
10266        .ok_or_else(|| invalid_feed("proposal submission claim body is missing"))?;
10267    let claim_object = claim
10268        .as_object()
10269        .ok_or_else(|| invalid_feed("proposal submission claim body is not an object"))?;
10270    let actor_root = claim_object
10271        .get("actor_root")
10272        .and_then(Value::as_object)
10273        .ok_or_else(|| invalid_feed("proposal submission actor root is missing"))?;
10274    let public_key = envelope
10275        .get("public_key")
10276        .and_then(Value::as_str)
10277        .ok_or_else(|| invalid_feed("proposal submission signer is missing"))?;
10278    let fingerprint = envelope
10279        .get("fingerprint")
10280        .and_then(Value::as_str)
10281        .ok_or_else(|| invalid_feed("proposal submission fingerprint is missing"))?;
10282    let signature = envelope
10283        .get("sig")
10284        .and_then(Value::as_str)
10285        .ok_or_else(|| invalid_feed("proposal submission signature is missing"))?;
10286    let claim_bytes = crate::linkmd_v2::canonical_bytes(claim)
10287        .map_err(|error| invalid_feed(error.to_string()))?;
10288    let der = verify_v2_spki_signature(public_key, &claim_bytes, signature)?;
10289    let signer = format!("{fingerprint}:{public_key}");
10290    let actor_class = actor_root.get("actor_class").and_then(Value::as_str);
10291    let grants = actor_root.get("grants").and_then(Value::as_array);
10292    let grants_are_canonical = grants.is_some_and(|items| {
10293        let mut prior: Option<&str> = None;
10294        items.iter().all(|item| {
10295            let Some(grant) = item.as_str() else {
10296                return false;
10297            };
10298            if !crate::ulid::is_ulid(grant) || prior.is_some_and(|value| value >= grant) {
10299                return false;
10300            }
10301            prior = Some(grant);
10302            true
10303        })
10304    });
10305    let optional_actor_field = |name: &str| {
10306        actor_root.get(name).is_some_and(|value| {
10307            value.is_null() || value.as_str().is_some_and(|text| !text.is_empty())
10308        })
10309    };
10310    let submitted_at = claim_object.get("submitted_at").and_then(Value::as_str);
10311    if claim_object.get("v").and_then(Value::as_u64) != Some(2)
10312        || format!("{:x}", Sha256::digest(&der)) != fingerprint
10313        || head
10314            .trust
10315            .hub_signer
10316            .as_ref()
10317            .is_some_and(|known| known != &signer)
10318        || !matches!(
10319            actor_class,
10320            Some(
10321                "user"
10322                    | "owned_agent"
10323                    | "foreign_key"
10324                    | "curation"
10325                    | "inbox"
10326                    | "restore"
10327                    | "migration"
10328                    | "operator_recovery"
10329            )
10330        )
10331        || actor_root
10332            .get("principal")
10333            .and_then(Value::as_str)
10334            .is_none_or(|value| value.is_empty())
10335        || actor_root
10336            .get("credential")
10337            .and_then(Value::as_str)
10338            .is_none_or(|value| value.is_empty())
10339        || !optional_actor_field("organization")
10340        || !optional_actor_field("role")
10341        || !grants_are_canonical
10342        || claim.get("brain").and_then(Value::as_str) != Some(head.brain_id.as_str())
10343        || claim.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
10344        || !claim_object
10345            .get("mutation_id")
10346            .and_then(Value::as_str)
10347            .is_some_and(|value| {
10348                !value.is_empty()
10349                    && value.len() <= 128
10350                    && value.chars().enumerate().all(|(index, char)| {
10351                        char.is_ascii_alphanumeric()
10352                            || (index > 0 && matches!(char, '.' | '_' | ':' | '-'))
10353                    })
10354            })
10355        || claim.get("payload_sha256").and_then(Value::as_str) != Some(payload_hash)
10356        || claim.get("clear_sha256").and_then(Value::as_str) != Some(clear_hash)
10357        || !claim_object
10358            .get("control_revision")
10359            .and_then(Value::as_str)
10360            .is_some_and(is_sha256)
10361        || submitted_at.is_none_or(|value| {
10362            chrono::DateTime::parse_from_rfc3339(value).is_err()
10363                || proposal.get("submitted_at").and_then(Value::as_str) != Some(value)
10364        })
10365        || !proposal
10366            .get("state")
10367            .and_then(Value::as_str)
10368            .is_some_and(|value| matches!(value, "pending" | "accepted" | "rejected" | "expired"))
10369        || proposal
10370            .get("expires_at")
10371            .and_then(Value::as_str)
10372            .is_none_or(|value| chrono::DateTime::parse_from_rfc3339(value).is_err())
10373        || proposal
10374            .get("proposer")
10375            .and_then(Value::as_object)
10376            .and_then(|value| value.get("class"))
10377            .and_then(Value::as_str)
10378            != actor_class
10379    {
10380        return Err(invalid_feed(
10381            "proposal submission claim does not bind the verified proposal",
10382        ));
10383    }
10384    let changes_b64 = proposal
10385        .get("changes_base64")
10386        .and_then(Value::as_str)
10387        .ok_or_else(|| invalid_feed("proposal has no changeset"))?;
10388    let changes_bytes = STANDARD
10389        .decode(changes_b64)
10390        .map_err(|_| invalid_feed("proposal changeset is not base64"))?;
10391    let changes: Value = serde_json::from_slice(&changes_bytes)
10392        .map_err(|_| invalid_feed("proposal changeset is not JSON"))?;
10393    if crate::linkmd_v2::canonical_bytes(&changes)
10394        .map_err(|error| invalid_feed(error.to_string()))?
10395        != changes_bytes
10396        || changes.get("v").and_then(Value::as_u64) != Some(2)
10397        || !changes.get("operations").is_some_and(Value::is_array)
10398    {
10399        return Err(invalid_feed("proposal changeset is not canonical v2"));
10400    }
10401    let blob_values = proposal
10402        .get("blobs")
10403        .and_then(Value::as_array)
10404        .ok_or_else(|| invalid_feed("proposal has no blob declarations"))?;
10405    let mut blobs = Vec::with_capacity(blob_values.len());
10406    let mut descriptor_blobs = Vec::with_capacity(blob_values.len());
10407    let mut prior_hash: Option<String> = None;
10408    for item in blob_values {
10409        let hash = item
10410            .get("sha256")
10411            .and_then(Value::as_str)
10412            .filter(|hash| is_sha256(hash))
10413            .ok_or_else(|| invalid_feed("proposal blob has no address"))?;
10414        let bytes = item
10415            .get("bytes")
10416            .and_then(Value::as_u64)
10417            .filter(|bytes| *bytes <= MAX_STORE_BYTES)
10418            .ok_or_else(|| invalid_feed("proposal blob has an invalid size"))?;
10419        if prior_hash.as_deref().is_some_and(|prior| prior >= hash) {
10420            return Err(invalid_feed(
10421                "proposal blob declarations are not unique and sorted",
10422            ));
10423        }
10424        prior_hash = Some(hash.to_string());
10425        let endpoint = item
10426            .get("endpoint")
10427            .and_then(Value::as_str)
10428            .ok_or_else(|| invalid_feed("proposal blob has no endpoint"))?;
10429        let expected_endpoint = format!(
10430            "/api/hub/brains/{}/v2/proposals/{proposal_id}/blob?sha256={hash}",
10431            head.brain_id
10432        );
10433        if endpoint != expected_endpoint {
10434            return Err(invalid_feed("proposal blob endpoint is not origin-bound"));
10435        }
10436        descriptor_blobs.push(json!({ "bytes": bytes, "sha256": hash }));
10437        blobs.push((hash.to_string(), bytes, endpoint.to_string()));
10438    }
10439    let descriptor = json!({
10440        "base": proposal.get("base").cloned().unwrap_or(Value::Null),
10441        "blobs": descriptor_blobs,
10442        "changes_base64": changes_b64,
10443        "rebase": proposal.get("rebase").cloned().unwrap_or(Value::Null),
10444        "v": 2,
10445    });
10446    let descriptor_bytes = crate::linkmd_v2::canonical_bytes(&descriptor)
10447        .map_err(|error| invalid_feed(error.to_string()))?;
10448    if content_sha256(&descriptor_bytes) != clear_hash {
10449        return Err(invalid_feed(
10450            "proposal clear payload differs from its signed submission claim",
10451        ));
10452    }
10453    Ok(VerifiedV2Proposal {
10454        value,
10455        changes,
10456        blobs,
10457    })
10458}
10459
10460pub fn proposal_list(
10461    cfg: &HubConfig,
10462    brain: &str,
10463    state: &str,
10464    after: Option<&str>,
10465    limit: usize,
10466) -> LinkResult<Value> {
10467    require_safe_ref(brain)?;
10468    if !matches!(state, "pending" | "accepted" | "rejected" | "expired") {
10469        return Err(invalid_feed("proposal state is invalid"));
10470    }
10471    if after.is_some_and(|value| !crate::ulid::is_ulid(value)) {
10472        return Err(invalid_feed("proposal cursor is invalid"));
10473    }
10474    let head = v2_verified_head(cfg, brain)?
10475        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
10476    let path = format!(
10477        "/api/hub/brains/{}/v2/proposals?state={state}&limit={}{}",
10478        head.brain_id,
10479        limit.clamp(1, 100),
10480        after.map_or_else(String::new, |value| format!("&after={value}"))
10481    );
10482    ensure_ok(
10483        request_capped(
10484            cfg,
10485            "GET",
10486            &path,
10487            None,
10488            Auth::Required,
10489            MAX_FEED_RESPONSE_BYTES,
10490        )?,
10491        "v2 proposal list",
10492    )
10493}
10494
10495pub fn proposal_show(cfg: &HubConfig, brain: &str, proposal_id: &str) -> LinkResult<Value> {
10496    require_safe_ref(brain)?;
10497    let head = v2_verified_head(cfg, brain)?
10498        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
10499    Ok(verified_v2_proposal(cfg, &head, proposal_id)?.value)
10500}
10501
10502pub fn proposal_reject(
10503    cfg: &HubConfig,
10504    brain: &str,
10505    proposal_id: &str,
10506    mutation_id: &str,
10507    reason: &str,
10508) -> LinkResult<Value> {
10509    require_safe_ref(brain)?;
10510    require_proposal_id(proposal_id)?;
10511    let head = v2_verified_head(cfg, brain)?
10512        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
10513    let _ = verified_v2_proposal(cfg, &head, proposal_id)?;
10514    let body = json!({
10515        "mutation_id": mutation_id,
10516        "control_revision": head.control_revision,
10517        "reason": reason,
10518    });
10519    let path = format!(
10520        "/api/hub/brains/{}/v2/proposals/{proposal_id}",
10521        head.brain_id
10522    );
10523    ensure_ok(
10524        request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
10525        "v2 proposal rejection",
10526    )
10527}
10528
10529pub fn proposal_accept_exact(
10530    cfg: &HubConfig,
10531    brain: &str,
10532    proposal_id: &str,
10533    mutation_id: &str,
10534    reason: &str,
10535) -> LinkResult<Value> {
10536    require_safe_ref(brain)?;
10537    require_proposal_id(proposal_id)?;
10538    let head = v2_verified_head(cfg, brain)?
10539        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
10540    let proposal = verified_v2_proposal(cfg, &head, proposal_id)?;
10541    let operations = proposal
10542        .changes
10543        .get("operations")
10544        .and_then(Value::as_array)
10545        .cloned()
10546        .ok_or_else(|| invalid_feed("proposal changeset has no operations"))?;
10547    if operations.is_empty() || operations.len() > MAX_PUSH_FILES {
10548        return Err(invalid_feed("proposal operation count is invalid"));
10549    }
10550    let mut downloaded = std::collections::BTreeMap::new();
10551    for (hash, bytes, endpoint) in &proposal.blobs {
10552        let body = ensure_raw_ok(
10553            request_raw(cfg, "GET", endpoint, None, Auth::Required, *bytes)?,
10554            "v2 proposal blob",
10555        )?;
10556        if body.len() as u64 != *bytes || content_sha256(&body) != *hash {
10557            return Err(invalid_feed("proposal blob does not match its declaration"));
10558        }
10559        downloaded.insert(hash.clone(), body);
10560    }
10561    let remote = files_for_v2_view(
10562        &head,
10563        v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
10564    );
10565    let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
10566    let mut expected_candidate = remote.clone();
10567    let mut expected_candidate_assets = remote_assets;
10568    for operation in &operations {
10569        let op = operation
10570            .get("op")
10571            .and_then(Value::as_str)
10572            .ok_or_else(|| invalid_feed("proposal operation has no kind"))?;
10573        match op {
10574            "put" | "restore" => {
10575                let path = operation
10576                    .get("path")
10577                    .and_then(Value::as_str)
10578                    .ok_or_else(|| invalid_feed("proposal write has no path"))?;
10579                crate::linkmd_v2::normalize_path(path)
10580                    .map_err(|error| invalid_feed(error.to_string()))?;
10581                let hash = operation
10582                    .get("blob")
10583                    .and_then(Value::as_str)
10584                    .filter(|hash| is_sha256(hash))
10585                    .ok_or_else(|| invalid_feed("proposal write has no blob"))?;
10586                let bytes = operation
10587                    .get("bytes")
10588                    .and_then(Value::as_u64)
10589                    .ok_or_else(|| invalid_feed("proposal write has no size"))?;
10590                expected_candidate.insert(
10591                    path.to_string(),
10592                    V2BaselineFile {
10593                        sha256: hash.to_string(),
10594                        bytes,
10595                        proof: None,
10596                    },
10597                );
10598            }
10599            "delete" | "withdraw_from_hosting" => {
10600                let path = operation
10601                    .get("path")
10602                    .and_then(Value::as_str)
10603                    .ok_or_else(|| invalid_feed("proposal removal has no path"))?;
10604                crate::linkmd_v2::normalize_path(path)
10605                    .map_err(|error| invalid_feed(error.to_string()))?;
10606                expected_candidate.remove(path);
10607            }
10608            "rename" => {
10609                let from = operation
10610                    .get("from")
10611                    .and_then(Value::as_str)
10612                    .ok_or_else(|| invalid_feed("proposal rename has no source"))?;
10613                let to = operation
10614                    .get("to")
10615                    .and_then(Value::as_str)
10616                    .ok_or_else(|| invalid_feed("proposal rename has no destination"))?;
10617                crate::linkmd_v2::normalize_path(from)
10618                    .and_then(|_| crate::linkmd_v2::normalize_path(to))
10619                    .map_err(|error| invalid_feed(error.to_string()))?;
10620                let hash = operation
10621                    .get("blob")
10622                    .and_then(Value::as_str)
10623                    .filter(|hash| is_sha256(hash))
10624                    .ok_or_else(|| invalid_feed("proposal rename has no blob"))?;
10625                let bytes = operation
10626                    .get("bytes")
10627                    .and_then(Value::as_u64)
10628                    .ok_or_else(|| invalid_feed("proposal rename has no size"))?;
10629                expected_candidate.remove(from);
10630                expected_candidate.insert(
10631                    to.to_string(),
10632                    V2BaselineFile {
10633                        sha256: hash.to_string(),
10634                        bytes,
10635                        proof: None,
10636                    },
10637                );
10638            }
10639            "asset_delete" => {
10640                let path = operation
10641                    .get("path")
10642                    .and_then(Value::as_str)
10643                    .ok_or_else(|| invalid_feed("proposal asset delete has no path"))?;
10644                expected_candidate_assets.remove(path);
10645            }
10646            "asset_withdraw" => {
10647                let path = operation
10648                    .get("path")
10649                    .and_then(Value::as_str)
10650                    .ok_or_else(|| invalid_feed("proposal asset withdrawal has no path"))?;
10651                let asset = expected_candidate_assets
10652                    .get_mut(path)
10653                    .ok_or_else(|| invalid_feed("proposal withdraws an unknown asset"))?;
10654                asset.disposition = "withheld".to_string();
10655                asset.leaf_hash.clear();
10656            }
10657            "asset_put" | "asset_resume" => {
10658                let path = operation
10659                    .get("path")
10660                    .and_then(Value::as_str)
10661                    .ok_or_else(|| invalid_feed("proposal asset write has no path"))?;
10662                let asset = operation
10663                    .get("asset")
10664                    .and_then(Value::as_object)
10665                    .ok_or_else(|| invalid_feed("proposal asset write has no value"))?;
10666                let blob_sha256 = asset
10667                    .get("blob_sha256")
10668                    .and_then(Value::as_str)
10669                    .filter(|hash| is_sha256(hash))
10670                    .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
10671                let bytes = asset
10672                    .get("bytes")
10673                    .and_then(Value::as_u64)
10674                    .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
10675                let media_type = asset
10676                    .get("media_type")
10677                    .and_then(Value::as_str)
10678                    .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
10679                let wrappers = asset
10680                    .get("wrappers")
10681                    .and_then(Value::as_array)
10682                    .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
10683                    .iter()
10684                    .map(|wrapper| {
10685                        wrapper
10686                            .as_str()
10687                            .map(str::to_string)
10688                            .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
10689                    })
10690                    .collect::<LinkResult<Vec<_>>>()?;
10691                let required = asset
10692                    .get("required")
10693                    .and_then(Value::as_bool)
10694                    .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
10695                let disposition = asset
10696                    .get("disposition")
10697                    .and_then(Value::as_str)
10698                    .filter(|value| matches!(*value, "hosted" | "withheld"))
10699                    .ok_or_else(|| invalid_feed("proposal asset disposition is invalid"))?;
10700                expected_candidate_assets.insert(
10701                    path.to_string(),
10702                    V2BaselineAsset {
10703                        blob_sha256: blob_sha256.to_string(),
10704                        bytes,
10705                        media_type: media_type.to_string(),
10706                        wrappers,
10707                        required,
10708                        disposition: disposition.to_string(),
10709                        leaf_hash: String::new(),
10710                    },
10711                );
10712            }
10713            _ => return Err(invalid_feed("proposal operation kind is unsupported")),
10714        }
10715    }
10716    let base = head.pointer.as_ref().map(|pointer| {
10717        json!({
10718            "seq": pointer.seq,
10719            "commit_hash": pointer.commit_hash,
10720            "content_root": pointer.content_root,
10721            "asset_root": pointer.asset_root,
10722        })
10723    });
10724    let mut body = json!({
10725        "mutation_id": mutation_id,
10726        "base": base,
10727        "rebase": "strict",
10728        "reason": reason,
10729        "operations": operations,
10730        "blobs": downloaded
10731            .iter()
10732            .map(|(sha256, bytes)| json!({
10733                "sha256": sha256,
10734                "bytes": bytes.len(),
10735                "content_base64": STANDARD.encode(bytes),
10736            }))
10737            .collect::<Vec<_>>(),
10738        "proposal_id": proposal_id,
10739        "proposal_mode": "exact",
10740    });
10741    let changed_bytes = downloaded.values().try_fold(0_usize, |total, bytes| {
10742        total
10743            .checked_add(bytes.len())
10744            .ok_or_else(|| LinkError::PushTooLarge {
10745                detail: "proposal changed-byte total overflow".to_string(),
10746            })
10747    })?;
10748    if changed_bytes > 3 * 1024 * 1024 || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
10749        let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
10750        for operation in &operations {
10751            let Some(kind) = operation.get("op").and_then(Value::as_str) else {
10752                return Err(invalid_feed("proposal upload operation has no kind"));
10753            };
10754            let hash = match kind {
10755                "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
10756                "asset_put" | "asset_resume" => operation
10757                    .get("asset")
10758                    .and_then(|asset| asset.get("blob_sha256"))
10759                    .and_then(Value::as_str),
10760                _ => None,
10761            };
10762            let Some(hash) = hash else { continue };
10763            let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
10764            if kind == "rename" {
10765                for field in ["from", "to"] {
10766                    coordinates.insert(
10767                        operation
10768                            .get(field)
10769                            .and_then(Value::as_str)
10770                            .ok_or_else(|| invalid_feed("proposal rename has no coordinate"))?
10771                            .to_string(),
10772                    );
10773                }
10774            } else {
10775                let path = operation
10776                    .get("path")
10777                    .and_then(Value::as_str)
10778                    .ok_or_else(|| invalid_feed("proposal upload has no coordinate"))?;
10779                coordinates.insert(if kind.starts_with("asset_") {
10780                    format!("assets/{path}")
10781                } else {
10782                    path.to_string()
10783                });
10784            }
10785        }
10786        let declarations = downloaded
10787            .iter()
10788            .map(|(sha256, bytes)| {
10789                json!({
10790                    "sha256": sha256,
10791                    "bytes": bytes.len(),
10792                    "coordinates": coordinates_by_hash
10793                        .get(sha256)
10794                        .into_iter()
10795                        .flatten()
10796                        .collect::<Vec<_>>(),
10797                })
10798            })
10799            .collect::<Vec<_>>();
10800        let reserved = ensure_ok(
10801            request(
10802                cfg,
10803                "POST",
10804                &format!("/api/hub/brains/{}/v2/uploads", head.brain_id),
10805                Some(&json!({ "blobs": declarations })),
10806                Auth::Required,
10807            )?,
10808            "prepare proposal blob transport",
10809        )?;
10810        let items = reserved
10811            .get("uploads")
10812            .and_then(Value::as_array)
10813            .ok_or_else(|| invalid_feed("proposal upload reservation has no items"))?;
10814        if items.len() != downloaded.len() {
10815            return Err(invalid_feed("proposal upload reservation changed the set"));
10816        }
10817        let mut references = Vec::with_capacity(items.len());
10818        for item in items {
10819            let hash = item
10820                .get("sha256")
10821                .and_then(Value::as_str)
10822                .ok_or_else(|| invalid_feed("proposal upload reservation has no hash"))?;
10823            let bytes = downloaded
10824                .get(hash)
10825                .ok_or_else(|| invalid_feed("proposal upload reservation introduced a blob"))?;
10826            let reservation_id = item
10827                .get("reservation_id")
10828                .and_then(Value::as_str)
10829                .filter(|id| crate::ulid::is_ulid(id))
10830                .ok_or_else(|| invalid_feed("proposal upload reservation has no id"))?;
10831            let expected_coordinates = coordinates_by_hash
10832                .get(hash)
10833                .ok_or_else(|| invalid_feed("proposal upload has no coordinate binding"))?;
10834            let returned_coordinates = item
10835                .get("coordinates")
10836                .and_then(Value::as_array)
10837                .ok_or_else(|| invalid_feed("proposal upload reservation has no coordinates"))?;
10838            if returned_coordinates.len() != expected_coordinates.len()
10839                || returned_coordinates
10840                    .iter()
10841                    .zip(expected_coordinates)
10842                    .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
10843            {
10844                return Err(invalid_feed(
10845                    "proposal upload reservation changed its coordinates",
10846                ));
10847            }
10848            match item.get("status").and_then(Value::as_str) {
10849                Some("upload") => put_presigned(
10850                    cfg,
10851                    item.get("url")
10852                        .and_then(Value::as_str)
10853                        .ok_or_else(|| invalid_feed("proposal upload has no URL"))?,
10854                    item.get("headers").unwrap_or(&Value::Null),
10855                    bytes,
10856                )?,
10857                Some("already_present") => {}
10858                _ => return Err(invalid_feed("proposal upload status is invalid")),
10859            }
10860            references.push(json!({
10861                "sha256": hash,
10862                "bytes": bytes.len(),
10863                "reservation_id": reservation_id,
10864            }));
10865        }
10866        body["blobs"] = Value::Array(references);
10867    }
10868    if body.to_string().len() > MAX_PUSH_BYTES {
10869        return Err(LinkError::PushTooLarge {
10870            detail: "proposal operation metadata exceeds the commit request cap".to_string(),
10871        });
10872    }
10873    let path = format!("/api/hub/brains/{}/v2/commits", head.brain_id);
10874    let mut result = ensure_ok(
10875        request(cfg, "POST", &path, Some(&body), Auth::Required)?,
10876        "exact proposal acceptance",
10877    )?;
10878    let mut candidate_hub_signer = None;
10879    if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
10880        let challenge = result
10881            .get("signing_challenge")
10882            .ok_or_else(|| invalid_feed("proposal acceptance has no signing challenge"))?;
10883        let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
10884            cfg,
10885            &head,
10886            &expected_candidate,
10887            &expected_candidate_assets,
10888            mutation_id,
10889            &body,
10890            challenge,
10891        )?;
10892        body["signing_challenge_id"] = Value::String(challenge_id);
10893        body["signature_base64url"] = Value::String(signature);
10894        candidate_hub_signer = Some(actor_signer);
10895        result = ensure_ok(
10896            request(cfg, "POST", &path, Some(&body), Auth::Required)?,
10897            "signed exact proposal acceptance",
10898        )?;
10899    }
10900    let refreshed = v2_verified_head(cfg, brain)?
10901        .ok_or_else(|| invalid_feed("v2 head disappeared after proposal acceptance"))?;
10902    if candidate_hub_signer
10903        .as_ref()
10904        .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
10905        || refreshed
10906            .pointer
10907            .as_ref()
10908            .map(|pointer| pointer.commit_hash.as_str())
10909            != result.get("commit_hash").and_then(Value::as_str)
10910        || result.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
10911        || result.get("proposal_state").and_then(Value::as_str) != Some("accepted")
10912    {
10913        return Err(LinkError::RemoteAdvancedDuringSync);
10914    }
10915    accept_v2_head(cfg, &refreshed)?;
10916    Ok(result)
10917}
10918
10919// ─────────────────────────────────────────────────────────────────────────────
10920// propose — write without trust: evidence into the owner's inbox
10921// ─────────────────────────────────────────────────────────────────────────────
10922
10923/// Submit `body` to the published site `handle`, addressed to its app page
10924/// `app` (a page that declares the `write-inbox` capability). Deliberately
10925/// unauthenticated — this is the cross-party door; the submission lands as
10926/// *evidence* in the owner's `sources/inbox/`, never as truth, and the
10927/// owner's curator accepts or rejects it. Returns the hub's `{id, path}`
10928/// receipt.
10929pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
10930    require_valid_handle(handle)?;
10931    if body.len() as u64 > MAX_PROPOSE_BYTES {
10932        return Err(LinkError::ProposeTooLarge {
10933            bytes: body.len() as u64,
10934        });
10935    }
10936    let payload = json!({ "app": app, "body": body });
10937    // A ULID-shaped target is a bare brain address (link.md §7.4's
10938    // generalization): the brain inbox door, open on public brains, where a
10939    // configured credential earns a bigger actor-class budget. Anything else
10940    // is a published-site handle: that door is unauthenticated by design.
10941    let (path, auth) = if crate::ulid::is_ulid(handle) {
10942        (format!("/api/hub/brains/{handle}/inbox"), Auth::Optional)
10943    } else {
10944        (format!("/api/hub/sites/{handle}/inbox"), Auth::None)
10945    };
10946    ensure_ok(
10947        request(cfg, "POST", &path, Some(&payload), auth)?,
10948        "propose",
10949    )
10950}
10951
10952// ─────────────────────────────────────────────────────────────────────────────
10953// subscribe — follow feed-head movement
10954// ─────────────────────────────────────────────────────────────────────────────
10955
10956/// One observation of a brain's feed head.
10957#[derive(Debug, serde::Serialize)]
10958pub struct Head {
10959    /// The brain id.
10960    pub brain: String,
10961    /// The hub's durable feed cursor — advances on every accepted write.
10962    pub seq: u64,
10963    /// The hub's `updatedAt` for the brain, when present.
10964    #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
10965    pub updated_at: Option<String>,
10966    /// SHA-256 of the exact signed head entry.
10967    #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
10968    pub feed_hash: Option<String>,
10969    /// Whether the head entry's content hash, identity, and Ed25519 signature
10970    /// were verified locally. Path-scoped grants get head movement only.
10971    pub verified: bool,
10972}
10973
10974struct BoundedVecVisitor<T, const MAX: usize> {
10975    label: &'static str,
10976    marker: std::marker::PhantomData<T>,
10977}
10978
10979impl<'de, T, const MAX: usize> serde::de::Visitor<'de> for BoundedVecVisitor<T, MAX>
10980where
10981    T: Deserialize<'de>,
10982{
10983    type Value = Vec<T>;
10984
10985    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
10986        write!(formatter, "at most {MAX} {}", self.label)
10987    }
10988
10989    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
10990    where
10991        A: serde::de::SeqAccess<'de>,
10992    {
10993        if sequence.size_hint().is_some_and(|size| size > MAX) {
10994            return Err(serde::de::Error::custom(format!(
10995                "{} exceeds the {MAX}-item limit",
10996                self.label
10997            )));
10998        }
10999        let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX));
11000        while let Some(value) = sequence.next_element()? {
11001            if values.len() == MAX {
11002                return Err(serde::de::Error::custom(format!(
11003                    "{} exceeds the {MAX}-item limit",
11004                    self.label
11005                )));
11006            }
11007            values.push(value);
11008        }
11009        Ok(values)
11010    }
11011}
11012
11013fn deserialize_bounded_vec<'de, D, T, const MAX: usize>(
11014    deserializer: D,
11015    label: &'static str,
11016) -> Result<Vec<T>, D::Error>
11017where
11018    D: serde::Deserializer<'de>,
11019    T: Deserialize<'de>,
11020{
11021    deserializer.deserialize_seq(BoundedVecVisitor::<T, MAX> {
11022        label,
11023        marker: std::marker::PhantomData,
11024    })
11025}
11026
11027fn deserialize_feed_files<'de, D>(deserializer: D) -> Result<Vec<FeedFile>, D::Error>
11028where
11029    D: serde::Deserializer<'de>,
11030{
11031    deserialize_bounded_vec::<D, FeedFile, MAX_PUSH_FILES>(deserializer, "feed files")
11032}
11033
11034fn deserialize_removed_paths<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
11035where
11036    D: serde::Deserializer<'de>,
11037{
11038    deserialize_bounded_vec::<D, String, MAX_PUSH_FILES>(deserializer, "removed paths")
11039}
11040
11041fn deserialize_previous_identities<'de, D>(
11042    deserializer: D,
11043) -> Result<Vec<PreviousIdentity>, D::Error>
11044where
11045    D: serde::Deserializer<'de>,
11046{
11047    deserialize_bounded_vec::<D, PreviousIdentity, MAX_IDENTITY_ROTATIONS>(
11048        deserializer,
11049        "previous identities",
11050    )
11051}
11052
11053fn deserialize_rotations<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
11054where
11055    D: serde::Deserializer<'de>,
11056{
11057    deserialize_bounded_vec::<D, String, MAX_IDENTITY_ROTATIONS>(
11058        deserializer,
11059        "rotation statements",
11060    )
11061}
11062
11063fn deserialize_feed_items<'de, D>(deserializer: D) -> Result<Vec<FeedItem>, D::Error>
11064where
11065    D: serde::Deserializer<'de>,
11066{
11067    deserialize_bounded_vec::<D, FeedItem, FEED_PAGE_LIMIT>(deserializer, "feed entries")
11068}
11069
11070#[derive(Debug, Clone, Deserialize, Serialize)]
11071struct FeedFile {
11072    path: String,
11073    sha256: String,
11074    bytes: u64,
11075}
11076
11077#[cfg(test)]
11078#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11079enum V1DisclosureError {
11080    DuplicateFile,
11081    DuplicateRemoved,
11082    PushManifestMismatch,
11083    EditMissingChange,
11084    EditFalseFile,
11085    RemovedMismatch,
11086}
11087
11088/// Validate wire-profile-v1 `files`/`removed` disclosure semantics against
11089/// the exact previous and resulting pack manifests. `edit` accepts the
11090/// historical superset form while still requiring every actual changed path.
11091#[cfg(test)]
11092fn verify_v1_manifest_disclosure(
11093    kind: &str,
11094    previous: &[FeedFile],
11095    resulting: &[FeedFile],
11096    files: &[FeedFile],
11097    removed: &[String],
11098) -> Result<(), V1DisclosureError> {
11099    fn as_map(
11100        files: &[FeedFile],
11101    ) -> Result<std::collections::BTreeMap<&str, (&str, u64)>, V1DisclosureError> {
11102        let mut result = std::collections::BTreeMap::new();
11103        for file in files {
11104            if result
11105                .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
11106                .is_some()
11107            {
11108                return Err(V1DisclosureError::DuplicateFile);
11109            }
11110        }
11111        Ok(result)
11112    }
11113    let previous = as_map(previous)?;
11114    let resulting = as_map(resulting)?;
11115    let disclosed = as_map(files)?;
11116    let removed_set: std::collections::BTreeSet<&str> =
11117        removed.iter().map(String::as_str).collect();
11118    if removed_set.len() != removed.len() {
11119        return Err(V1DisclosureError::DuplicateRemoved);
11120    }
11121    let expected_removed: std::collections::BTreeSet<&str> = previous
11122        .keys()
11123        .copied()
11124        .filter(|path| !resulting.contains_key(path))
11125        .collect();
11126    if removed_set != expected_removed {
11127        return Err(V1DisclosureError::RemovedMismatch);
11128    }
11129    if kind == "push" {
11130        return if disclosed == resulting {
11131            Ok(())
11132        } else {
11133            Err(V1DisclosureError::PushManifestMismatch)
11134        };
11135    }
11136    if kind != "edit" {
11137        return Err(V1DisclosureError::EditFalseFile);
11138    }
11139    if disclosed
11140        .iter()
11141        .any(|(path, value)| resulting.get(path) != Some(value))
11142    {
11143        return Err(V1DisclosureError::EditFalseFile);
11144    }
11145    for (path, value) in &resulting {
11146        if previous.get(path) != Some(value) && !disclosed.contains_key(path) {
11147            return Err(V1DisclosureError::EditMissingChange);
11148        }
11149    }
11150    Ok(())
11151}
11152
11153#[derive(Debug, Clone, Deserialize, Serialize)]
11154struct FeedEntry {
11155    v: u8,
11156    seq: u64,
11157    ts: String,
11158    brain: String,
11159    public_key: String,
11160    kind: String,
11161    op: String,
11162    pack_sha256: String,
11163    #[serde(deserialize_with = "deserialize_feed_files")]
11164    files: Vec<FeedFile>,
11165    #[serde(deserialize_with = "deserialize_removed_paths")]
11166    removed: Vec<String>,
11167    prev_entry_hash: Option<String>,
11168    sig: String,
11169}
11170
11171#[derive(Serialize)]
11172struct UnsignedFeedEntry<'a> {
11173    v: u8,
11174    seq: u64,
11175    ts: &'a str,
11176    brain: &'a str,
11177    public_key: &'a str,
11178    kind: &'a str,
11179    op: &'a str,
11180    pack_sha256: &'a str,
11181    files: &'a [FeedFile],
11182    removed: &'a [String],
11183    prev_entry_hash: &'a Option<String>,
11184}
11185
11186#[derive(Debug, Clone, Deserialize, Serialize)]
11187struct FeedItem {
11188    hash: String,
11189    entry: FeedEntry,
11190}
11191
11192#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
11193struct FeedIdentity {
11194    fingerprint: String,
11195    #[serde(rename = "publicKeySpki")]
11196    public_key_spki: String,
11197    /// Rotation history (link.md §9.1): identities this brain previously
11198    /// signed as. Entries verify against current OR previous — rotation
11199    /// never invalidates history.
11200    #[serde(default, deserialize_with = "deserialize_previous_identities")]
11201    previous: Vec<PreviousIdentity>,
11202    /// Exact normative rotation statements, oldest first. A list of previous
11203    /// public keys without these old-key signatures is not a trust chain.
11204    #[serde(default, deserialize_with = "deserialize_rotations")]
11205    rotations: Vec<String>,
11206}
11207
11208#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
11209struct PreviousIdentity {
11210    fingerprint: String,
11211    #[serde(rename = "publicKeySpki")]
11212    public_key_spki: String,
11213}
11214
11215#[derive(Debug, Deserialize)]
11216struct FeedResponse {
11217    #[serde(rename = "headSeq")]
11218    head_seq: u64,
11219    #[serde(rename = "feedHash")]
11220    feed_hash: Option<String>,
11221    identity: Option<FeedIdentity>,
11222    #[serde(deserialize_with = "deserialize_feed_items")]
11223    entries: Vec<FeedItem>,
11224    #[serde(rename = "scopeLimited")]
11225    scope_limited: bool,
11226}
11227
11228#[derive(Debug, Deserialize, Serialize)]
11229#[serde(deny_unknown_fields)]
11230struct RotationStatement {
11231    v: u8,
11232    op: String,
11233    brain: String,
11234    public_key: String,
11235    new_brain: String,
11236    new_public_key: String,
11237    prior_head_seq: u64,
11238    prior_feed_hash: Option<String>,
11239    ts: String,
11240    sig: String,
11241}
11242
11243#[derive(Debug, Clone, Deserialize, Serialize)]
11244struct TrustState {
11245    v: u8,
11246    origin: String,
11247    /// The exact caller-visible ref whose resolution this checkpoint pins.
11248    /// Slugs/handles are mutable names at the hub; once observed, they may not
11249    /// silently resolve to a different canonical brain.
11250    #[serde(default)]
11251    requested: String,
11252    /// Canonical hub brain id returned for `requested`.
11253    brain: String,
11254    /// Federation home origin when this ref was learned from a registry.
11255    /// Once observed, the registry cannot silently relocate the same handle.
11256    #[serde(default, skip_serializing_if = "Option::is_none")]
11257    home: Option<String>,
11258    anchor: String,
11259    current: String,
11260    #[serde(rename = "headSeq")]
11261    head_seq: u64,
11262    #[serde(rename = "feedHash")]
11263    feed_hash: Option<String>,
11264    /// Exact accepted old-key-signed rotation statements, oldest first. v2
11265    /// checkpoints require this vector to be an immutable prefix of every
11266    /// subsequently served identity chain.
11267    #[serde(default)]
11268    rotations: Vec<String>,
11269    /// Hub pointer-signing identity pinned on first v2 observation. This is
11270    /// separate from the brain key, which signs commits.
11271    #[serde(default, skip_serializing_if = "Option::is_none")]
11272    hub_signer: Option<String>,
11273    /// Explicit transport profile. Older accepted non-empty v2 checkpoints
11274    /// are recognized by their pinned hub signer during one-way migration.
11275    #[serde(default, skip_serializing_if = "Option::is_none")]
11276    protocol_profile: Option<String>,
11277}
11278
11279fn accepted_as_v2(state: &TrustState) -> bool {
11280    state.protocol_profile.as_deref() == Some("link-v2") || state.hub_signer.is_some()
11281}
11282
11283fn has_accepted_v2_ref(cfg: &HubConfig, requested: &str) -> LinkResult<bool> {
11284    let directory = open_trust_dir(cfg)?;
11285    if load_trust_in(cfg, &directory, requested)?.is_some_and(|state| accepted_as_v2(&state)) {
11286        return Ok(true);
11287    }
11288    let Some(alias) = load_alias_in(cfg, &directory, requested)? else {
11289        return Ok(false);
11290    };
11291    Ok(load_trust_in(cfg, &directory, &alias.brain)?.is_some_and(|state| accepted_as_v2(&state)))
11292}
11293
11294#[derive(Debug, Clone, Deserialize, Serialize)]
11295struct AliasBinding {
11296    v: u8,
11297    origin: String,
11298    requested: String,
11299    brain: String,
11300    #[serde(default, skip_serializing_if = "Option::is_none")]
11301    home: Option<String>,
11302}
11303
11304struct VerifiedRemote {
11305    head: Head,
11306    identity: Option<FeedIdentity>,
11307    head_entry: Option<FeedItem>,
11308    /// Present when the caller requested and verified the complete chain.
11309    entries: Vec<FeedItem>,
11310    anchor: Option<String>,
11311}
11312
11313fn invalid_feed(message: impl Into<String>) -> LinkError {
11314    LinkError::InvalidFeed {
11315        message: message.into(),
11316    }
11317}
11318
11319fn is_sha256(value: &str) -> bool {
11320    value.len() == 64
11321        && value
11322            .bytes()
11323            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
11324}
11325
11326fn identity_fingerprint(public_key_spki: &str) -> LinkResult<String> {
11327    let der = URL_SAFE_NO_PAD
11328        .decode(public_key_spki)
11329        .map_err(|_| invalid_feed("identity public key is not base64url"))?;
11330    if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
11331        return Err(invalid_feed(
11332            "identity public key is not a valid Ed25519 SPKI",
11333        ));
11334    }
11335    Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(&der)))
11336}
11337
11338/// Verify the hub's current identity and every old-key-signed rotation that
11339/// leads from the original TOFU anchor to it. `previous` alone is metadata,
11340/// never authority.
11341fn verify_identity_chain(
11342    identity: &FeedIdentity,
11343    pinned: Option<&TrustState>,
11344) -> LinkResult<String> {
11345    if identity.previous.len() > MAX_IDENTITY_ROTATIONS
11346        || identity.rotations.len() > MAX_IDENTITY_ROTATIONS
11347    {
11348        return Err(invalid_feed(
11349            "identity rotation history exceeds the client cap",
11350        ));
11351    }
11352    if identity.fingerprint != identity_fingerprint(&identity.public_key_spki)? {
11353        return Err(invalid_feed(
11354            "current identity fingerprint does not match its public key",
11355        ));
11356    }
11357    for previous in &identity.previous {
11358        if previous.fingerprint != identity_fingerprint(&previous.public_key_spki)? {
11359            return Err(invalid_feed(
11360                "previous identity fingerprint does not match its public key",
11361            ));
11362        }
11363    }
11364    if identity.rotations.len() != identity.previous.len() {
11365        return Err(invalid_feed(
11366            "identity history is missing an old-key-signed rotation statement",
11367        ));
11368    }
11369
11370    // The HTTP identity lists prior identities newest first. Rotation
11371    // statements are chronological, so verification walks the reversed list
11372    // and ends at the current identity.
11373    let mut chain: Vec<(&str, &str)> = identity
11374        .previous
11375        .iter()
11376        .rev()
11377        .map(|p| (p.fingerprint.as_str(), p.public_key_spki.as_str()))
11378        .collect();
11379    chain.push((&identity.fingerprint, &identity.public_key_spki));
11380
11381    for (index, raw) in identity.rotations.iter().enumerate() {
11382        let statement: RotationStatement = serde_json::from_str(raw)
11383            .map_err(|_| invalid_feed("rotation statement did not parse exactly"))?;
11384        let (old_fingerprint, old_spki) = chain[index];
11385        let (new_fingerprint, new_spki) = chain[index + 1];
11386        if statement.v != 1
11387            || statement.op != "rotate"
11388            || statement.brain != format!("ed25519:{old_fingerprint}")
11389            || statement.public_key != old_spki
11390            || statement.new_brain != format!("ed25519:{new_fingerprint}")
11391            || statement.new_public_key != new_spki
11392            || (statement.prior_head_seq == 0 && statement.prior_feed_hash.is_some())
11393            || (statement.prior_head_seq > 0
11394                && statement
11395                    .prior_feed_hash
11396                    .as_deref()
11397                    .is_none_or(|hash| !is_sha256(hash)))
11398        {
11399            return Err(invalid_feed(
11400                "rotation statement does not connect adjacent identities",
11401            ));
11402        }
11403        let unsigned = serde_json::to_string(&UnsignedRotation {
11404            v: statement.v,
11405            op: &statement.op,
11406            brain: &statement.brain,
11407            public_key: &statement.public_key,
11408            new_brain: &statement.new_brain,
11409            new_public_key: &statement.new_public_key,
11410            prior_head_seq: statement.prior_head_seq,
11411            prior_feed_hash: statement.prior_feed_hash.as_deref(),
11412            ts: statement.ts.clone(),
11413        })
11414        .map_err(|_| invalid_feed("could not canonicalize rotation statement"))?;
11415        let exact = format!(
11416            "{},\"sig\":\"{}\"}}",
11417            &unsigned[..unsigned.len() - 1],
11418            statement.sig
11419        );
11420        if exact != *raw {
11421            return Err(invalid_feed(
11422                "rotation statement is not in normative serialization",
11423            ));
11424        }
11425        let der = URL_SAFE_NO_PAD
11426            .decode(old_spki)
11427            .map_err(|_| invalid_feed("rotation public key is not base64url"))?;
11428        let signature = URL_SAFE_NO_PAD
11429            .decode(&statement.sig)
11430            .map_err(|_| invalid_feed("rotation signature is not base64url"))?;
11431        UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
11432            .verify(unsigned.as_bytes(), &signature)
11433            .map_err(|_| invalid_feed("rotation signature verification failed"))?;
11434        if index > 0 {
11435            let prior: RotationStatement = serde_json::from_str(&identity.rotations[index - 1])
11436                .map_err(|_| invalid_feed("prior rotation statement did not parse"))?;
11437            if statement.prior_head_seq < prior.prior_head_seq {
11438                return Err(invalid_feed("rotation feed boundaries move backward"));
11439            }
11440        }
11441    }
11442
11443    let anchor = format!("ed25519:{}", chain[0].0);
11444    let current = format!("ed25519:{}", identity.fingerprint);
11445    if let Some(pin) = pinned {
11446        if pin.anchor != anchor {
11447            return Err(invalid_feed(
11448                "served identity chain does not descend from the pinned anchor",
11449            ));
11450        }
11451        if !chain
11452            .iter()
11453            .any(|(fingerprint, _)| pin.current == format!("ed25519:{fingerprint}"))
11454        {
11455            return Err(invalid_feed(
11456                "served identity chain forked away from the last pinned identity",
11457            ));
11458        }
11459        if pin.current == current && pin.anchor != current && identity.rotations.is_empty() {
11460            return Err(invalid_feed("served identity discarded its rotation chain"));
11461        }
11462        if pin.v >= 2
11463            && (identity.rotations.len() < pin.rotations.len()
11464                || identity.rotations[..pin.rotations.len()] != pin.rotations)
11465        {
11466            return Err(invalid_feed(
11467                "served identity rewrote the locally accepted rotation history",
11468            ));
11469        }
11470    }
11471    Ok(anchor)
11472}
11473
11474fn verify_rotation_feed_boundaries(
11475    identity: &FeedIdentity,
11476    pinned: Option<&TrustState>,
11477    observed: &[FeedItem],
11478    advertised_seq: u64,
11479) -> LinkResult<()> {
11480    let mut chain: Vec<String> = identity
11481        .previous
11482        .iter()
11483        .rev()
11484        .map(|previous| format!("ed25519:{}", previous.fingerprint))
11485        .collect();
11486    chain.push(format!("ed25519:{}", identity.fingerprint));
11487    let pinned_index = pinned.and_then(|pin| chain.iter().position(|key| key == &pin.current));
11488
11489    for (index, raw) in identity.rotations.iter().enumerate() {
11490        let rotation: RotationStatement = serde_json::from_str(raw)
11491            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
11492        if rotation.prior_head_seq > advertised_seq {
11493            return Err(invalid_feed(
11494                "rotation claims a feed boundary beyond the advertised head",
11495            ));
11496        }
11497        if let (Some(pin), Some(pin_index)) = (pinned, pinned_index) {
11498            if index >= pin_index && rotation.prior_head_seq < pin.head_seq {
11499                return Err(invalid_feed(
11500                    "newly disclosed rotation predates the local feed checkpoint",
11501                ));
11502            }
11503        }
11504        let actual = if rotation.prior_head_seq == 0 {
11505            None
11506        } else if pinned.is_some_and(|pin| pin.head_seq == rotation.prior_head_seq) {
11507            pinned.and_then(|pin| pin.feed_hash.as_deref())
11508        } else {
11509            observed
11510                .iter()
11511                .find(|item| item.entry.seq == rotation.prior_head_seq)
11512                .map(|item| item.hash.as_str())
11513        };
11514        if let Some(actual) = actual {
11515            if rotation.prior_feed_hash.as_deref() != Some(actual) {
11516                return Err(invalid_feed(
11517                    "rotation statement does not commit the verified feed boundary",
11518                ));
11519            }
11520        } else if rotation.prior_head_seq == 0 {
11521            // The empty-feed boundary is represented by (0, null); there is
11522            // no entry hash to find in `observed`.
11523        } else if pinned.is_some_and(|pin| {
11524            pinned_index.is_some_and(|pin_index| index >= pin_index)
11525                || rotation.prior_head_seq >= pin.head_seq
11526        }) {
11527            return Err(invalid_feed(
11528                "rotation feed boundary was not present in the verified chain",
11529            ));
11530        }
11531    }
11532    Ok(())
11533}
11534
11535/// Once a client has checkpointed identity K, identities retired before K may
11536/// verify old history but can never regain authority over a later sequence.
11537/// This is also the safe migration rule for legacy v1 checkpoints that did not
11538/// persist the exact accepted rotation statements.
11539fn reject_retired_signer_after_checkpoint(
11540    identity: &FeedIdentity,
11541    pinned: Option<&TrustState>,
11542    item: &FeedItem,
11543) -> LinkResult<()> {
11544    let Some(pin) = pinned else {
11545        return Ok(());
11546    };
11547    if item.entry.seq <= pin.head_seq {
11548        return Ok(());
11549    }
11550    let mut chain: Vec<String> = identity
11551        .previous
11552        .iter()
11553        .rev()
11554        .map(|previous| format!("ed25519:{}", previous.fingerprint))
11555        .collect();
11556    chain.push(format!("ed25519:{}", identity.fingerprint));
11557    let pinned_index = chain
11558        .iter()
11559        .position(|key| key == &pin.current)
11560        .ok_or_else(|| invalid_feed("pinned identity is absent from the served chain"))?;
11561    let signer_index = chain
11562        .iter()
11563        .position(|key| key == &item.entry.brain)
11564        .ok_or_else(|| invalid_feed("feed signer is absent from the served identity chain"))?;
11565    if signer_index < pinned_index {
11566        return Err(invalid_feed(
11567            "a retired identity attempted to sign after the local checkpoint",
11568        ));
11569    }
11570    Ok(())
11571}
11572
11573fn trust_file_name(cfg: &HubConfig, brain: &str) -> LinkResult<String> {
11574    let origin = normalized_origin(&cfg.hub)?;
11575    let key = format!(
11576        "{:x}",
11577        Sha256::digest(format!("{origin}\0{brain}").as_bytes())
11578    );
11579    Ok(format!("{key}.json"))
11580}
11581
11582fn alias_file_name(cfg: &HubConfig, alias: &str) -> LinkResult<String> {
11583    let origin = normalized_origin(&cfg.hub)?;
11584    let key = format!(
11585        "{:x}",
11586        Sha256::digest(format!("{origin}\0alias\0{alias}").as_bytes())
11587    );
11588    Ok(format!("alias-{key}.json"))
11589}
11590
11591#[cfg(any(unix, windows))]
11592struct TrustLock {
11593    _file: std::fs::File,
11594}
11595
11596#[cfg(unix)]
11597fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
11598    use std::os::fd::{AsRawFd as _, FromRawFd as _};
11599
11600    let lock_string = format!(".{state_name}.lock");
11601    let lock_name = c_name(lock_string.as_bytes(), &lock_string)?;
11602    let fd = unsafe {
11603        libc::openat(
11604            directory.as_raw_fd(),
11605            lock_name.as_ptr(),
11606            libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11607            0o600,
11608        )
11609    };
11610    if fd < 0 {
11611        return Err(std::io::Error::last_os_error().into());
11612    }
11613    let file = unsafe { std::fs::File::from_raw_fd(fd) };
11614    if !file.metadata()?.is_file() {
11615        return Err(LinkError::UnsafePath { path: lock_string });
11616    }
11617    if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
11618        return Err(std::io::Error::last_os_error().into());
11619    }
11620    Ok(TrustLock { _file: file })
11621}
11622
11623#[cfg(windows)]
11624fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
11625    let lock_name = format!(".{state_name}.lock");
11626    let file = crate::fsx::lock_exclusive_beneath(directory, Path::new(&lock_name))?;
11627    Ok(TrustLock { _file: file })
11628}
11629
11630#[cfg(any(unix, windows))]
11631fn lock_trust_many(
11632    cfg: &HubConfig,
11633    directory: &std::fs::File,
11634    refs: &[&str],
11635) -> LinkResult<Vec<TrustLock>> {
11636    let mut names = refs
11637        .iter()
11638        .map(|reference| trust_file_name(cfg, reference))
11639        .collect::<LinkResult<Vec<_>>>()?;
11640    names.sort();
11641    names.dedup();
11642    names
11643        .iter()
11644        .map(|name| lock_trust_name(directory, name))
11645        .collect()
11646}
11647
11648#[cfg(not(any(unix, windows)))]
11649fn lock_trust_many(
11650    _cfg: &HubConfig,
11651    _directory: &TrustDirectory,
11652    _refs: &[&str],
11653) -> LinkResult<Vec<()>> {
11654    Err(LinkError::UnsupportedPlatform {
11655        operation: "verified link.md state",
11656    })
11657}
11658
11659#[cfg(any(unix, windows))]
11660type TrustDirectory = std::fs::File;
11661
11662#[cfg(not(any(unix, windows)))]
11663struct TrustDirectory;
11664
11665#[cfg(unix)]
11666fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
11667    use std::os::fd::AsRawFd as _;
11668
11669    let directory = open_or_create_dir_nofollow(&cfg.state_dir.join("trust"))?;
11670    if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
11671        return Err(std::io::Error::last_os_error().into());
11672    }
11673    directory.sync_all()?;
11674    Ok(directory)
11675}
11676
11677#[cfg(windows)]
11678fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
11679    let marker = cfg.state_dir.join("trust").join(".directory");
11680    crate::fsx::write_atomic(&marker, b"link.md trust directory\n")?;
11681    Ok(crate::fsx::open_directory_nofollow(
11682        marker.parent().expect("trust marker has a parent"),
11683    )?)
11684}
11685
11686#[cfg(not(any(unix, windows)))]
11687fn open_trust_dir(_cfg: &HubConfig) -> LinkResult<TrustDirectory> {
11688    Err(LinkError::UnsupportedPlatform {
11689        operation: "verified link.md state",
11690    })
11691}
11692
11693#[cfg(unix)]
11694fn load_trust_in(
11695    cfg: &HubConfig,
11696    directory: &TrustDirectory,
11697    requested: &str,
11698) -> LinkResult<Option<TrustState>> {
11699    use std::os::fd::{AsRawFd as _, FromRawFd as _};
11700
11701    let name_string = trust_file_name(cfg, requested)?;
11702    let name = c_name(name_string.as_bytes(), &name_string)?;
11703    let fd = unsafe {
11704        libc::openat(
11705            directory.as_raw_fd(),
11706            name.as_ptr(),
11707            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11708        )
11709    };
11710    if fd < 0 {
11711        let error = std::io::Error::last_os_error();
11712        if error.kind() == std::io::ErrorKind::NotFound {
11713            return Ok(None);
11714        }
11715        return Err(LinkError::UnsafePath { path: name_string });
11716    }
11717    let file = unsafe { std::fs::File::from_raw_fd(fd) };
11718    if !file.metadata()?.is_file() {
11719        return Err(LinkError::UnsafePath { path: name_string });
11720    }
11721    let mut bytes = Vec::new();
11722    file.take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
11723    if bytes.len() > 1024 * 1024 {
11724        return Err(invalid_feed("local identity/feed checkpoint is oversized"));
11725    }
11726    let mut state: TrustState = serde_json::from_slice(&bytes)
11727        .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
11728    if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
11729        return Err(invalid_feed(
11730            "local identity/feed checkpoint does not match this hub and brain",
11731        ));
11732    }
11733    if state.v == 1 {
11734        // Legacy files were keyed by canonical brain id. They can migrate only
11735        // when the caller used that exact id; old slug lookups had no durable
11736        // alias binding and therefore cannot be guessed safely.
11737        if state.brain != requested {
11738            return Err(invalid_feed(
11739                "legacy checkpoint is not bound to the requested brain id",
11740            ));
11741        }
11742        state.requested = requested.to_string();
11743    } else if state.requested != requested {
11744        return Err(invalid_feed(
11745            "local identity/feed checkpoint is bound to a different requested ref",
11746        ));
11747    }
11748    Ok(Some(state))
11749}
11750
11751#[cfg(windows)]
11752fn load_trust_in(
11753    cfg: &HubConfig,
11754    directory: &TrustDirectory,
11755    requested: &str,
11756) -> LinkResult<Option<TrustState>> {
11757    let name = trust_file_name(cfg, requested)?;
11758    let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
11759    let bytes = match reader.read(Path::new(&name), 1024 * 1024) {
11760        Ok(bytes) => bytes,
11761        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
11762        Err(_) => return Err(LinkError::UnsafePath { path: name }),
11763    };
11764    let mut state: TrustState = serde_json::from_slice(&bytes)
11765        .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
11766    if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
11767        return Err(invalid_feed(
11768            "local identity/feed checkpoint does not match this hub and brain",
11769        ));
11770    }
11771    if state.v == 1 {
11772        if state.brain != requested {
11773            return Err(invalid_feed(
11774                "legacy checkpoint is not bound to the requested brain id",
11775            ));
11776        }
11777        state.requested = requested.to_string();
11778    } else if state.requested != requested {
11779        return Err(invalid_feed(
11780            "local identity/feed checkpoint is bound to a different requested ref",
11781        ));
11782    }
11783    Ok(Some(state))
11784}
11785
11786#[cfg(not(any(unix, windows)))]
11787fn load_trust_in(
11788    _cfg: &HubConfig,
11789    _directory: &TrustDirectory,
11790    _brain: &str,
11791) -> LinkResult<Option<TrustState>> {
11792    Err(LinkError::UnsupportedPlatform {
11793        operation: "verified link.md state",
11794    })
11795}
11796
11797#[cfg(all(test, any(unix, windows)))]
11798fn load_trust(cfg: &HubConfig, requested: &str) -> LinkResult<Option<TrustState>> {
11799    let directory = open_trust_dir(cfg)?;
11800    load_trust_in(cfg, &directory, requested)
11801}
11802
11803#[cfg(unix)]
11804fn save_trust_in(
11805    cfg: &HubConfig,
11806    directory: &TrustDirectory,
11807    state: &TrustState,
11808) -> LinkResult<()> {
11809    use std::os::fd::{AsRawFd as _, FromRawFd as _};
11810
11811    let name_string = trust_file_name(cfg, &state.requested)?;
11812    let name = c_name(name_string.as_bytes(), &name_string)?;
11813    let mut bytes = serde_json::to_vec(state)
11814        .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
11815    bytes.push(b'\n');
11816
11817    let nonce = std::time::SystemTime::now()
11818        .duration_since(std::time::UNIX_EPOCH)
11819        .unwrap_or_default()
11820        .as_nanos();
11821    let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
11822    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
11823    let fd = unsafe {
11824        libc::openat(
11825            directory.as_raw_fd(),
11826            temp.as_ptr(),
11827            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11828            0o600,
11829        )
11830    };
11831    if fd < 0 {
11832        return Err(std::io::Error::last_os_error().into());
11833    }
11834    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
11835    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
11836        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
11837        return Err(error.into());
11838    }
11839    drop(file);
11840    if unsafe {
11841        libc::renameat(
11842            directory.as_raw_fd(),
11843            temp.as_ptr(),
11844            directory.as_raw_fd(),
11845            name.as_ptr(),
11846        )
11847    } != 0
11848    {
11849        let error = std::io::Error::last_os_error();
11850        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
11851        return Err(error.into());
11852    }
11853    directory.sync_all()?;
11854    Ok(())
11855}
11856
11857#[cfg(windows)]
11858fn save_trust_in(
11859    cfg: &HubConfig,
11860    directory: &TrustDirectory,
11861    state: &TrustState,
11862) -> LinkResult<()> {
11863    let name = trust_file_name(cfg, &state.requested)?;
11864    let mut bytes = serde_json::to_vec(state)
11865        .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
11866    bytes.push(b'\n');
11867    crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
11868    Ok(())
11869}
11870
11871#[cfg(not(any(unix, windows)))]
11872fn save_trust_in(
11873    _cfg: &HubConfig,
11874    _directory: &TrustDirectory,
11875    _state: &TrustState,
11876) -> LinkResult<()> {
11877    Err(LinkError::UnsupportedPlatform {
11878        operation: "verified link.md state",
11879    })
11880}
11881
11882#[cfg(unix)]
11883fn load_alias_in(
11884    cfg: &HubConfig,
11885    directory: &TrustDirectory,
11886    requested: &str,
11887) -> LinkResult<Option<AliasBinding>> {
11888    use std::os::fd::{AsRawFd as _, FromRawFd as _};
11889
11890    let name_string = alias_file_name(cfg, requested)?;
11891    let name = c_name(name_string.as_bytes(), &name_string)?;
11892    let fd = unsafe {
11893        libc::openat(
11894            directory.as_raw_fd(),
11895            name.as_ptr(),
11896            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11897        )
11898    };
11899    if fd < 0 {
11900        let error = std::io::Error::last_os_error();
11901        if error.kind() == std::io::ErrorKind::NotFound {
11902            return Ok(None);
11903        }
11904        return Err(LinkError::UnsafePath { path: name_string });
11905    }
11906    let file = unsafe { std::fs::File::from_raw_fd(fd) };
11907    if !file.metadata()?.is_file() {
11908        return Err(LinkError::UnsafePath { path: name_string });
11909    }
11910    let mut bytes = Vec::new();
11911    file.take(64 * 1024 + 1).read_to_end(&mut bytes)?;
11912    if bytes.len() > 64 * 1024 {
11913        return Err(invalid_feed("local alias binding is oversized"));
11914    }
11915    let alias: AliasBinding = serde_json::from_slice(&bytes)
11916        .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
11917    if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
11918    {
11919        return Err(invalid_feed(
11920            "local alias binding does not match this hub and requested ref",
11921        ));
11922    }
11923    Ok(Some(alias))
11924}
11925
11926#[cfg(windows)]
11927fn load_alias_in(
11928    cfg: &HubConfig,
11929    directory: &TrustDirectory,
11930    requested: &str,
11931) -> LinkResult<Option<AliasBinding>> {
11932    let name = alias_file_name(cfg, requested)?;
11933    let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
11934    let bytes = match reader.read(Path::new(&name), 64 * 1024) {
11935        Ok(bytes) => bytes,
11936        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
11937        Err(_) => return Err(LinkError::UnsafePath { path: name }),
11938    };
11939    let alias: AliasBinding = serde_json::from_slice(&bytes)
11940        .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
11941    if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
11942    {
11943        return Err(invalid_feed(
11944            "local alias binding does not match this hub and requested ref",
11945        ));
11946    }
11947    Ok(Some(alias))
11948}
11949
11950#[cfg(not(any(unix, windows)))]
11951fn load_alias_in(
11952    _cfg: &HubConfig,
11953    _directory: &TrustDirectory,
11954    _requested: &str,
11955) -> LinkResult<Option<AliasBinding>> {
11956    Err(LinkError::UnsupportedPlatform {
11957        operation: "verified link.md state",
11958    })
11959}
11960
11961#[cfg(unix)]
11962fn save_alias_in(
11963    cfg: &HubConfig,
11964    directory: &TrustDirectory,
11965    alias: &AliasBinding,
11966) -> LinkResult<()> {
11967    use std::os::fd::{AsRawFd as _, FromRawFd as _};
11968
11969    let name_string = alias_file_name(cfg, &alias.requested)?;
11970    let name = c_name(name_string.as_bytes(), &name_string)?;
11971    let mut bytes = serde_json::to_vec(alias)
11972        .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
11973    bytes.push(b'\n');
11974    let nonce = std::time::SystemTime::now()
11975        .duration_since(std::time::UNIX_EPOCH)
11976        .unwrap_or_default()
11977        .as_nanos();
11978    let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
11979    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
11980    let fd = unsafe {
11981        libc::openat(
11982            directory.as_raw_fd(),
11983            temp.as_ptr(),
11984            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11985            0o600,
11986        )
11987    };
11988    if fd < 0 {
11989        return Err(std::io::Error::last_os_error().into());
11990    }
11991    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
11992    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
11993        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
11994        return Err(error.into());
11995    }
11996    drop(file);
11997    if unsafe {
11998        libc::renameat(
11999            directory.as_raw_fd(),
12000            temp.as_ptr(),
12001            directory.as_raw_fd(),
12002            name.as_ptr(),
12003        )
12004    } != 0
12005    {
12006        let error = std::io::Error::last_os_error();
12007        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
12008        return Err(error.into());
12009    }
12010    directory.sync_all()?;
12011    Ok(())
12012}
12013
12014#[cfg(windows)]
12015fn save_alias_in(
12016    cfg: &HubConfig,
12017    directory: &TrustDirectory,
12018    alias: &AliasBinding,
12019) -> LinkResult<()> {
12020    let name = alias_file_name(cfg, &alias.requested)?;
12021    let mut bytes = serde_json::to_vec(alias)
12022        .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
12023    bytes.push(b'\n');
12024    crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
12025    Ok(())
12026}
12027
12028#[cfg(not(any(unix, windows)))]
12029fn save_alias_in(
12030    _cfg: &HubConfig,
12031    _directory: &TrustDirectory,
12032    _alias: &AliasBinding,
12033) -> LinkResult<()> {
12034    Err(LinkError::UnsupportedPlatform {
12035        operation: "verified link.md state",
12036    })
12037}
12038
12039/// Load the canonical checkpoint shared by every spelling of one brain and
12040/// the separate alias binding. This also performs the one-way migration from
12041/// pre-v3 checkpoints that stored a full trust state under the alias itself.
12042/// Callers hold both alias and canonical locks before entering.
12043fn load_canonical_pin(
12044    cfg: &HubConfig,
12045    directory: &TrustDirectory,
12046    requested: &str,
12047    resolved_brain: &str,
12048) -> LinkResult<(Option<TrustState>, Option<AliasBinding>)> {
12049    let mut canonical = load_trust_in(cfg, directory, resolved_brain)?;
12050    if requested == resolved_brain {
12051        return Ok((canonical, None));
12052    }
12053
12054    let mut alias = load_alias_in(cfg, directory, requested)?;
12055    if let Some(binding) = &alias {
12056        if binding.brain != resolved_brain {
12057            return Err(LinkError::AliasRebindRequired {
12058                alias: requested.to_string(),
12059                from: binding.brain.clone(),
12060                to: resolved_brain.to_string(),
12061            });
12062        }
12063        return Ok((canonical, alias));
12064    }
12065
12066    // A v2 build stored the complete checkpoint under the requested slug.
12067    // Promote it to the canonical ULID key before creating the lightweight
12068    // alias binding. Never silently merge two independently advanced states.
12069    if let Some(legacy) = load_trust_in(cfg, directory, requested)? {
12070        if legacy.brain != resolved_brain {
12071            return Err(invalid_feed(
12072                "legacy alias checkpoint names a different canonical brain",
12073            ));
12074        }
12075        if let Some(existing) = &canonical {
12076            if existing.brain != legacy.brain
12077                || existing.anchor != legacy.anchor
12078                || existing.current != legacy.current
12079                || existing.head_seq != legacy.head_seq
12080                || existing.feed_hash != legacy.feed_hash
12081                || existing.rotations != legacy.rotations
12082            {
12083                return Err(invalid_feed(
12084                    "legacy alias checkpoint conflicts with the canonical checkpoint",
12085                ));
12086            }
12087        } else {
12088            let mut promoted = legacy.clone();
12089            promoted.requested = resolved_brain.to_string();
12090            promoted.home = None;
12091            save_trust_in(cfg, directory, &promoted)?;
12092            canonical = Some(promoted);
12093        }
12094        alias = Some(AliasBinding {
12095            v: 1,
12096            origin: normalized_origin(&cfg.hub)?,
12097            requested: requested.to_string(),
12098            brain: resolved_brain.to_string(),
12099            home: legacy.home,
12100        });
12101        save_alias_in(cfg, directory, alias.as_ref().expect("alias just created"))?;
12102    }
12103    Ok((canonical, alias))
12104}
12105
12106/// Explicitly replace one mutable alias binding after the operator reviews the
12107/// exact deleted/re-created brain ids. Canonical checkpoints are never removed
12108/// or rewritten, so old history remains pinned and the new brain establishes
12109/// its own independently verified trust chain.
12110pub fn rebind_v2_alias(cfg: &HubConfig, alias: &str, from: &str, to: &str) -> LinkResult<Value> {
12111    require_hardened_filesystem("verified alias rebind")?;
12112    require_safe_ref(alias)?;
12113    require_safe_ref(from)?;
12114    require_safe_ref(to)?;
12115    if crate::ulid::is_ulid(alias)
12116        || !crate::ulid::is_ulid(from)
12117        || !crate::ulid::is_ulid(to)
12118        || from == to
12119    {
12120        return Err(LinkError::InvalidPack {
12121            message:
12122                "alias rebind requires one non-ULID alias and two different exact canonical ULIDs"
12123                    .to_string(),
12124        });
12125    }
12126
12127    let verified = v2_verified_head(cfg, to)?.ok_or_else(|| LinkError::InvalidPack {
12128        message: "the proposed replacement is not a readable link.md v2 brain".to_string(),
12129    })?;
12130    accept_v2_head(cfg, &verified)?;
12131
12132    let alias_response = ensure_ok(
12133        request(
12134            cfg,
12135            "GET",
12136            &format!("/api/hub/brains/{alias}/v2/head"),
12137            None,
12138            Auth::Required,
12139        )?,
12140        "resolve alias for explicit rebind",
12141    )?;
12142    let resolved: V2HeadResponse = serde_json::from_value(alias_response)
12143        .map_err(|_| invalid_feed("alias rebind response has an invalid shape"))?;
12144    if resolved.v != 2 || resolved.brain_id != to {
12145        return Err(LinkError::RemoteAdvancedDuringSync);
12146    }
12147
12148    let directory = open_trust_dir(cfg)?;
12149    let _locks = lock_trust_many(cfg, &directory, &[alias, from, to])?;
12150    let binding = load_alias_in(cfg, &directory, alias)?.ok_or_else(|| LinkError::InvalidPack {
12151        message: "the requested alias has no existing local binding to replace".to_string(),
12152    })?;
12153    if binding.brain != from {
12154        return Err(LinkError::AliasRebindRequired {
12155            alias: alias.to_string(),
12156            from: binding.brain,
12157            to: to.to_string(),
12158        });
12159    }
12160    save_alias_in(
12161        cfg,
12162        &directory,
12163        &AliasBinding {
12164            v: 1,
12165            origin: normalized_origin(&cfg.hub)?,
12166            requested: alias.to_string(),
12167            brain: to.to_string(),
12168            home: binding.home,
12169        },
12170    )?;
12171    Ok(json!({
12172        "v": 2,
12173        "alias": alias,
12174        "from": from,
12175        "to": to,
12176        "outcome": "alias_rebound",
12177    }))
12178}
12179
12180fn save_canonical_pin_and_alias(
12181    cfg: &HubConfig,
12182    directory: &TrustDirectory,
12183    requested: &str,
12184    resolved_brain: &str,
12185    mut state: TrustState,
12186    existing_alias: Option<&AliasBinding>,
12187) -> LinkResult<()> {
12188    state.requested = resolved_brain.to_string();
12189    state.brain = resolved_brain.to_string();
12190    state.home = None;
12191    save_trust_in(cfg, directory, &state)?;
12192    if requested != resolved_brain {
12193        save_alias_in(
12194            cfg,
12195            directory,
12196            &AliasBinding {
12197                v: 1,
12198                origin: normalized_origin(&cfg.hub)?,
12199                requested: requested.to_string(),
12200                brain: resolved_brain.to_string(),
12201                home: existing_alias.and_then(|alias| alias.home.clone()),
12202            },
12203        )?;
12204    }
12205    Ok(())
12206}
12207
12208fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
12209    const ED25519_SPKI_PREFIX: &[u8] = &[
12210        0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
12211    ];
12212    let entry = &item.entry;
12213    let public_der = URL_SAFE_NO_PAD
12214        .decode(&entry.public_key)
12215        .map_err(|_| invalid_feed("public key is not base64url"))?;
12216    if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
12217        || !public_der.starts_with(ED25519_SPKI_PREFIX)
12218    {
12219        return Err(invalid_feed("entry public key is not a valid Ed25519 SPKI"));
12220    }
12221    let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
12222    if entry.brain != format!("ed25519:{fingerprint}") {
12223        return Err(invalid_feed(
12224            "brain fingerprint does not match its public key",
12225        ));
12226    }
12227    // Verify the complete rotation authority before using any previous key.
12228    let _ = verify_identity_chain(identity, None)?;
12229    let mut chain: Vec<(&str, &str)> = identity
12230        .previous
12231        .iter()
12232        .rev()
12233        .map(|previous| {
12234            (
12235                previous.fingerprint.as_str(),
12236                previous.public_key_spki.as_str(),
12237            )
12238        })
12239        .collect();
12240    chain.push((&identity.fingerprint, &identity.public_key_spki));
12241    let signer_index = chain.iter().position(|(known_fingerprint, spki)| {
12242        *known_fingerprint == fingerprint && *spki == entry.public_key
12243    });
12244    let Some(signer_index) = signer_index else {
12245        return Err(invalid_feed(
12246            "entry signer is not this brain's identity (current or rotated-from)",
12247        ));
12248    };
12249    let lower_boundary = if signer_index == 0 {
12250        None
12251    } else {
12252        let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
12253            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
12254        Some(prior.prior_head_seq)
12255    };
12256    let upper_boundary = if signer_index == identity.rotations.len() {
12257        None
12258    } else {
12259        let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
12260            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
12261        Some(next.prior_head_seq)
12262    };
12263    if lower_boundary.is_some_and(|boundary| entry.seq <= boundary)
12264        || upper_boundary.is_some_and(|boundary| entry.seq > boundary)
12265    {
12266        return Err(invalid_feed(
12267            "entry signer is outside its authenticated rotation epoch",
12268        ));
12269    }
12270    let unsigned = UnsignedFeedEntry {
12271        v: entry.v,
12272        seq: entry.seq,
12273        ts: &entry.ts,
12274        brain: &entry.brain,
12275        public_key: &entry.public_key,
12276        kind: &entry.kind,
12277        op: &entry.op,
12278        pack_sha256: &entry.pack_sha256,
12279        files: &entry.files,
12280        removed: &entry.removed,
12281        prev_entry_hash: &entry.prev_entry_hash,
12282    };
12283    let message =
12284        serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
12285    let signature = URL_SAFE_NO_PAD
12286        .decode(&entry.sig)
12287        .map_err(|_| invalid_feed("signature is not base64url"))?;
12288    UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
12289        .verify(&message, &signature)
12290        .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
12291
12292    let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
12293    exact.push(b'\n');
12294    let actual_hash = format!("{:x}", Sha256::digest(&exact));
12295    if actual_hash != item.hash {
12296        return Err(invalid_feed("entry SHA-256 does not match"));
12297    }
12298    Ok(())
12299}
12300
12301// ─────────────────────────────────────────────────────────────────────────────
12302// key rotation — link.md §9.1: the new key, signed by the old one
12303// ─────────────────────────────────────────────────────────────────────────────
12304
12305/// The unsigned rotation statement in its normative field order.
12306#[derive(Serialize)]
12307struct UnsignedRotation<'a> {
12308    v: u8,
12309    op: &'a str,
12310    brain: &'a str,
12311    public_key: &'a str,
12312    new_brain: &'a str,
12313    new_public_key: &'a str,
12314    prior_head_seq: u64,
12315    prior_feed_hash: Option<&'a str>,
12316    ts: String,
12317}
12318
12319/// Durable intent for an in-flight key rotation. The hub's recovery contract
12320/// identifies an ambiguous retry by the exact statement bytes, so the
12321/// statement cannot be reconstructed from the key and feed boundary later:
12322/// its timestamp and signature would differ.
12323#[derive(Debug, Deserialize, Serialize)]
12324#[serde(deny_unknown_fields)]
12325struct RotationJournal {
12326    v: u8,
12327    origin: String,
12328    brain: String,
12329    old_brain: String,
12330    new_brain: String,
12331    prior_head_seq: u64,
12332    prior_feed_hash: Option<String>,
12333    statement: String,
12334}
12335
12336fn rotation_journal_path(key_path: &Path) -> PathBuf {
12337    let mut path = key_path.as_os_str().to_os_string();
12338    path.push(".rotation.json");
12339    PathBuf::from(path)
12340}
12341
12342fn read_rotation_journal(path: &Path) -> LinkResult<RotationJournal> {
12343    #[cfg(unix)]
12344    let file = {
12345        use std::os::fd::{AsRawFd as _, FromRawFd as _};
12346        use std::os::unix::ffi::OsStrExt as _;
12347        let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
12348            .map_err(|error| {
12349                bad_agent_key(&format!("cannot open the rotation journal parent: {error}"))
12350            })?;
12351        let leaf_name = path
12352            .file_name()
12353            .ok_or_else(|| bad_agent_key("the rotation journal path has no file name"))?;
12354        let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
12355        let fd = unsafe {
12356            libc::openat(
12357                parent.as_raw_fd(),
12358                leaf.as_ptr(),
12359                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
12360            )
12361        };
12362        if fd < 0 {
12363            return Err(bad_agent_key(
12364                "the rotation journal must be an existing regular file without symlink ancestors",
12365            ));
12366        }
12367        unsafe { std::fs::File::from_raw_fd(fd) }
12368    };
12369    #[cfg(not(unix))]
12370    let file = std::fs::File::open(path)
12371        .map_err(|error| bad_agent_key(&format!("cannot read the rotation journal: {error}")))?;
12372    let metadata = file
12373        .metadata()
12374        .map_err(|error| bad_agent_key(&format!("cannot inspect the rotation journal: {error}")))?;
12375    if !metadata.is_file() || metadata.len() > MAX_REGISTRY_CARD_BYTES {
12376        return Err(bad_agent_key(
12377            "the rotation journal must be a bounded regular file",
12378        ));
12379    }
12380    #[cfg(unix)]
12381    {
12382        use std::os::unix::fs::PermissionsExt as _;
12383        if metadata.permissions().mode() & 0o077 != 0 {
12384            return Err(bad_agent_key(
12385                "the rotation journal is accessible to group/other; set mode 0600",
12386            ));
12387        }
12388    }
12389    serde_json::from_reader(file)
12390        .map_err(|_| bad_agent_key("the rotation journal is not valid exact JSON"))
12391}
12392
12393fn remove_rotation_journal(path: &Path) {
12394    #[cfg(unix)]
12395    {
12396        use std::os::fd::AsRawFd as _;
12397        use std::os::unix::ffi::OsStrExt as _;
12398        let Ok(parent) =
12399            open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
12400        else {
12401            return;
12402        };
12403        let Some(leaf_name) = path.file_name() else {
12404            return;
12405        };
12406        let Ok(leaf) = c_name(leaf_name.as_bytes(), &path.display().to_string()) else {
12407            return;
12408        };
12409        if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), 0) } == 0 {
12410            let _ = parent.sync_all();
12411        }
12412    }
12413    #[cfg(not(unix))]
12414    {
12415        let _ = std::fs::remove_file(path);
12416    }
12417}
12418
12419fn validate_rotation_journal(
12420    journal: &RotationJournal,
12421    cfg: &HubConfig,
12422    canonical_brain: &str,
12423    old_key: &AgentSigningKey,
12424    new_key: &AgentSigningKey,
12425    head: &Head,
12426) -> LinkResult<()> {
12427    if journal.v != 1
12428        || journal.origin != normalized_origin(&cfg.hub)?
12429        || journal.brain != canonical_brain
12430        || journal.old_brain != old_key.multikey
12431        || journal.new_brain != new_key.multikey
12432        || journal.prior_head_seq != head.seq
12433        || journal.prior_feed_hash != head.feed_hash
12434    {
12435        return Err(invalid_feed(
12436            "rotation journal does not match the verified key and feed boundary",
12437        ));
12438    }
12439    let statement: RotationStatement = serde_json::from_str(&journal.statement)
12440        .map_err(|_| invalid_feed("rotation journal statement did not parse exactly"))?;
12441    if statement.prior_head_seq != journal.prior_head_seq
12442        || statement.prior_feed_hash != journal.prior_feed_hash
12443        || statement.brain != old_key.multikey
12444        || statement.public_key != old_key.public_key_spki
12445        || statement.new_brain != new_key.multikey
12446        || statement.new_public_key != new_key.public_key_spki
12447    {
12448        return Err(invalid_feed(
12449            "rotation journal statement does not match its durable intent",
12450        ));
12451    }
12452    let identity = FeedIdentity {
12453        fingerprint: new_key.multikey.trim_start_matches("ed25519:").to_string(),
12454        public_key_spki: new_key.public_key_spki.clone(),
12455        previous: vec![PreviousIdentity {
12456            fingerprint: old_key.multikey.trim_start_matches("ed25519:").to_string(),
12457            public_key_spki: old_key.public_key_spki.clone(),
12458        }],
12459        rotations: vec![journal.statement.clone()],
12460    };
12461    verify_identity_chain(&identity, None)?;
12462    Ok(())
12463}
12464
12465/// What `dbmd key rotate` returns.
12466#[derive(Debug, Serialize)]
12467pub struct RotationReport {
12468    /// The brain id rotated.
12469    pub brain: String,
12470    /// The NEW identity the hub now serves.
12471    pub multikey: String,
12472    /// Where the new PKCS#8 secret landed (0600).
12473    #[serde(rename = "keyFile")]
12474    pub key_file: String,
12475    /// Prior identities (newest first) the feed still verifies against.
12476    pub previous: Vec<String>,
12477}
12478
12479/// Rotate a self-custodied brain's key: mint a fresh keypair, build the
12480/// §9.1 statement — the new key plus exact prior feed boundary, signed by the
12481/// OLD key in normative serialization — and send it to the hub. The new secret
12482/// is durably created at 0600 before the POST; an existing output is reused for
12483/// idempotent retry/reconciliation. The old key is left untouched.
12484pub fn rotate_brain_key(
12485    cfg: &HubConfig,
12486    brain: &str,
12487    old_key: &AgentSigningKey,
12488    out: &Path,
12489) -> LinkResult<RotationReport> {
12490    require_hardened_filesystem("key rotation")?;
12491    require_safe_ref(brain)?;
12492    // The new private key must be durable *before* the hub can accept its
12493    // public half. An existing file is the retry/reconciliation path after an
12494    // ambiguous network failure: reuse it, never generate another identity.
12495    let new_key = if out.exists() {
12496        load_signing_key(out)?
12497    } else {
12498        let rng = ring::rand::SystemRandom::new();
12499        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
12500            .map_err(|_| bad_agent_key("key generation failed"))?;
12501        let pair = agent_keypair(pkcs8.as_ref())?;
12502        let (public_key_spki, multikey) = public_identity_for(&pair);
12503        write_secret_new(
12504            out,
12505            format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
12506        )?;
12507        AgentSigningKey {
12508            pkcs8: pkcs8.as_ref().to_vec(),
12509            multikey,
12510            public_key_spki,
12511        }
12512    };
12513    let new_spki = new_key.public_key_spki.clone();
12514    let new_multikey = new_key.multikey.clone();
12515    let journal_path = rotation_journal_path(out);
12516    let before = verified_remote_head(cfg, brain, false)?;
12517    let served_identity = before
12518        .identity
12519        .as_ref()
12520        .ok_or_else(|| invalid_feed("cannot rotate a brain with no signed identity"))?;
12521    let served_multikey = format!("ed25519:{}", served_identity.fingerprint);
12522    if served_multikey == new_multikey {
12523        remove_rotation_journal(&journal_path);
12524        return Ok(RotationReport {
12525            brain: brain.to_string(),
12526            multikey: new_multikey,
12527            key_file: out.display().to_string(),
12528            previous: served_identity
12529                .previous
12530                .iter()
12531                .map(|identity| format!("ed25519:{}", identity.fingerprint))
12532                .collect(),
12533        });
12534    }
12535    if served_multikey != old_key.multikey {
12536        return Err(invalid_feed(
12537            "the supplied old key is not the brain's verified current identity",
12538        ));
12539    }
12540
12541    let journal = if journal_path.exists() {
12542        read_rotation_journal(&journal_path)?
12543    } else {
12544        let ts = crate::now()
12545            .with_timezone(&chrono::Utc)
12546            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
12547            .to_string();
12548        let unsigned = serde_json::to_string(&UnsignedRotation {
12549            v: 1,
12550            op: "rotate",
12551            brain: &old_key.multikey,
12552            public_key: &old_key.public_key_spki,
12553            new_brain: &new_multikey,
12554            new_public_key: &new_spki,
12555            prior_head_seq: before.head.seq,
12556            prior_feed_hash: before.head.feed_hash.as_deref(),
12557            ts,
12558        })
12559        .expect("serialize rotation");
12560        let old_pair = agent_keypair(&old_key.pkcs8)?;
12561        let sig = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
12562        let statement = format!("{},\"sig\":\"{}\"}}", &unsigned[..unsigned.len() - 1], sig);
12563        let journal = RotationJournal {
12564            v: 1,
12565            origin: normalized_origin(&cfg.hub)?,
12566            brain: before.head.brain.clone(),
12567            old_brain: old_key.multikey.clone(),
12568            new_brain: new_multikey.clone(),
12569            prior_head_seq: before.head.seq,
12570            prior_feed_hash: before.head.feed_hash.clone(),
12571            statement,
12572        };
12573        let mut exact = serde_json::to_vec(&journal)
12574            .map_err(|_| invalid_feed("could not serialize rotation journal"))?;
12575        exact.push(b'\n');
12576        if write_secret_new(&journal_path, &exact).is_err() {
12577            // A concurrent retry may have won the O_EXCL race. Only an exact,
12578            // fully validated journal is allowed to recover that race.
12579            read_rotation_journal(&journal_path)?
12580        } else {
12581            journal
12582        }
12583    };
12584    validate_rotation_journal(
12585        &journal,
12586        cfg,
12587        &before.head.brain,
12588        old_key,
12589        &new_key,
12590        &before.head,
12591    )?;
12592
12593    let body = json!({ "statement": journal.statement });
12594    let path = format!("/api/hub/brains/{brain}/rotate");
12595    let attempted = request(cfg, "POST", &path, Some(&body), Auth::Required);
12596    let attempted_failure = match attempted {
12597        Ok(response) if (200..300).contains(&response.status) => None,
12598        Ok(response) => Some(ensure_ok(response, "key rotate").unwrap_err()),
12599        Err(error) => Some(error),
12600    };
12601
12602    // A 2xx body is not authority, and a failed response may have followed a
12603    // committed mutation. In both cases success means the normal verifier sees
12604    // an append-only rotation chain ending at the durable new key.
12605    let after = match verified_remote_head(cfg, brain, false) {
12606        Ok(after) => after,
12607        Err(error) => return Err(attempted_failure.unwrap_or(error)),
12608    };
12609    let identity = after
12610        .identity
12611        .as_ref()
12612        .ok_or_else(|| invalid_feed("rotated brain has no verified identity"))?;
12613    if format!("ed25519:{}", identity.fingerprint) != new_multikey
12614        || identity.public_key_spki != new_spki
12615    {
12616        return Err(attempted_failure.unwrap_or_else(|| {
12617            invalid_feed("hub acknowledged rotation without committing the verified new identity")
12618        }));
12619    }
12620    let previous = identity
12621        .previous
12622        .iter()
12623        .map(|prior| format!("ed25519:{}", prior.fingerprint))
12624        .collect();
12625    remove_rotation_journal(&journal_path);
12626
12627    Ok(RotationReport {
12628        brain: brain.to_string(),
12629        multikey: new_multikey,
12630        key_file: out.display().to_string(),
12631        previous,
12632    })
12633}
12634
12635// ─────────────────────────────────────────────────────────────────────────────
12636// mirror — verified replication: the whole feed + files, re-servable
12637// ─────────────────────────────────────────────────────────────────────────────
12638
12639/// What `dbmd mirror` materialized.
12640#[derive(Debug, Serialize)]
12641pub struct MirrorReport {
12642    /// The brain id.
12643    pub brain: String,
12644    /// The mirrored feed head.
12645    #[serde(rename = "headSeq")]
12646    pub head_seq: u64,
12647    /// The head entry hash (the feed's advertised converged state).
12648    #[serde(rename = "feedHash")]
12649    pub feed_hash: Option<String>,
12650    /// Signed feed entries verified and stored.
12651    pub entries: u64,
12652    /// The brain's multikey, pinned in `.dbmd/config` (TOFU).
12653    pub pinned: String,
12654    /// Store files materialized by the pull.
12655    pub files: usize,
12656}
12657
12658/// The mirror state directory, relative to the mirror root.
12659pub const MIRROR_REL_DIR: &str = ".dbmd/mirror";
12660
12661/// Fully re-verified mirror material suitable for a read-only re-server.
12662#[derive(Debug)]
12663pub struct VerifiedMirrorMaterial {
12664    pub brain: String,
12665    pub head_seq: u64,
12666    pub feed_hash: Option<String>,
12667    pub identity: serde_json::Value,
12668    /// Sequence, exact normative entry JSON without newline, and entry hash.
12669    pub entries: Vec<(u64, String, String)>,
12670    pub pack_sha256: Option<String>,
12671}
12672
12673#[derive(Deserialize)]
12674#[serde(deny_unknown_fields)]
12675struct StoredMirrorHead {
12676    brain: String,
12677    #[serde(rename = "headSeq")]
12678    head_seq: u64,
12679    #[serde(rename = "feedHash")]
12680    feed_hash: Option<String>,
12681}
12682
12683/// Re-verify mirror metadata, every signature/hash/rotation boundary, and the
12684/// exact snapshot pack before `dbmd serve` exposes any bytes.
12685pub fn verify_mirror_material(
12686    head_bytes: &[u8],
12687    identity_bytes: &[u8],
12688    feed_bytes: &[Vec<u8>],
12689    snapshot_pack: Option<&[u8]>,
12690    expected_anchor: &str,
12691) -> LinkResult<VerifiedMirrorMaterial> {
12692    let snapshot_hash = snapshot_pack
12693        .filter(|pack| !pack.is_empty())
12694        .map(content_sha256);
12695    verify_mirror_material_with_pack_hash(
12696        head_bytes,
12697        identity_bytes,
12698        feed_bytes,
12699        snapshot_hash.as_deref(),
12700        expected_anchor,
12701    )
12702}
12703
12704/// Re-verify mirror metadata against a snapshot digest computed from a held
12705/// no-follow file capability. This lets `dbmd serve` authenticate and retain a
12706/// large pack without ever buffering the pack in process memory.
12707pub fn verify_mirror_material_with_pack_hash(
12708    head_bytes: &[u8],
12709    identity_bytes: &[u8],
12710    feed_bytes: &[Vec<u8>],
12711    snapshot_pack_sha256: Option<&str>,
12712    expected_anchor: &str,
12713) -> LinkResult<VerifiedMirrorMaterial> {
12714    let head: StoredMirrorHead = serde_json::from_slice(head_bytes)
12715        .map_err(|_| invalid_feed("stored mirror head did not parse exactly"))?;
12716    require_safe_ref(&head.brain)?;
12717    if head.head_seq > MAX_FEED_REPLAY_ENTRIES || feed_bytes.len() as u64 != head.head_seq {
12718        return Err(invalid_feed(
12719            "stored mirror feed count does not match its bounded head sequence",
12720        ));
12721    }
12722    let aggregate = feed_bytes
12723        .iter()
12724        .try_fold(0u64, |total, bytes| total.checked_add(bytes.len() as u64))
12725        .ok_or_else(|| invalid_feed("stored mirror feed size overflow"))?;
12726    if aggregate > MAX_FEED_REPLAY_BYTES {
12727        return Err(invalid_feed(
12728            "stored mirror feed metadata exceeds the aggregate limit",
12729        ));
12730    }
12731    let identity: FeedIdentity = serde_json::from_slice(identity_bytes)
12732        .map_err(|_| invalid_feed("stored mirror identity did not parse exactly"))?;
12733    let anchor = verify_identity_chain(&identity, None)?;
12734    if anchor != expected_anchor {
12735        return Err(invalid_feed(
12736            "stored mirror identity does not descend from the explicitly trusted anchor",
12737        ));
12738    }
12739
12740    let mut entries = Vec::with_capacity(feed_bytes.len());
12741    let mut items = Vec::with_capacity(feed_bytes.len());
12742    let mut previous_hash = None;
12743    let mut pack_sha256 = None;
12744    for (index, bytes) in feed_bytes.iter().enumerate() {
12745        let exact = bytes
12746            .strip_suffix(b"\n")
12747            .ok_or_else(|| invalid_feed("stored feed entry lacks its exact trailing newline"))?;
12748        if exact.ends_with(b"\n") {
12749            return Err(invalid_feed("stored feed entry has extra trailing bytes"));
12750        }
12751        let entry: FeedEntry = serde_json::from_slice(exact)
12752            .map_err(|_| invalid_feed("stored feed entry did not parse exactly"))?;
12753        let expected_seq = index as u64 + 1;
12754        if entry.seq != expected_seq || entry.prev_entry_hash != previous_hash {
12755            return Err(invalid_feed(
12756                "stored mirror feed is not contiguous and hash-chained",
12757            ));
12758        }
12759        let canonical = serde_json::to_vec(&entry)
12760            .map_err(|_| invalid_feed("could not canonicalize stored feed entry"))?;
12761        if canonical != exact {
12762            return Err(invalid_feed(
12763                "stored feed entry is not in normative serialization",
12764            ));
12765        }
12766        let hash = content_sha256(bytes);
12767        let item = FeedItem {
12768            hash: hash.clone(),
12769            entry,
12770        };
12771        verify_feed_item(&item, &identity)?;
12772        previous_hash = Some(hash.clone());
12773        if expected_seq == head.head_seq {
12774            pack_sha256 = Some(item.entry.pack_sha256.clone());
12775        }
12776        entries.push((
12777            expected_seq,
12778            std::str::from_utf8(exact)
12779                .map_err(|_| invalid_feed("stored feed entry is not UTF-8"))?
12780                .to_string(),
12781            hash,
12782        ));
12783        items.push(item);
12784    }
12785    if previous_hash != head.feed_hash {
12786        return Err(invalid_feed(
12787            "stored mirror feed does not converge on its advertised head",
12788        ));
12789    }
12790    verify_rotation_feed_boundaries(&identity, None, &items, head.head_seq)?;
12791    match (head.head_seq, snapshot_pack_sha256, pack_sha256.as_deref()) {
12792        (0, None, None) => {}
12793        (_, Some(actual), Some(expected)) if actual == expected => {}
12794        _ => {
12795            return Err(LinkError::InvalidPack {
12796                message: "stored snapshot pack does not match the signed head digest".to_string(),
12797            });
12798        }
12799    }
12800    let identity_value = serde_json::to_value(&identity)
12801        .map_err(|_| invalid_feed("could not serialize verified mirror identity"))?;
12802    Ok(VerifiedMirrorMaterial {
12803        brain: head.brain,
12804        head_seq: head.head_seq,
12805        feed_hash: head.feed_hash,
12806        identity: identity_value,
12807        entries,
12808        pack_sha256,
12809    })
12810}
12811
12812/// SHA-256 hex of one feed entry's stored bytes (`exact JSON + "\n"`) — the
12813/// entry hash every consumer recomputes (SPEC §5.3).
12814pub fn feed_entry_hash(exact_sans_newline: &str) -> String {
12815    format!(
12816        "{:x}",
12817        Sha256::digest(format!("{exact_sans_newline}\n").as_bytes())
12818    )
12819}
12820
12821/// SHA-256 hex for content a signed manifest names. Exposed for the thin
12822/// `dbmd serve` adapter; cryptographic verification remains centralized here.
12823pub fn content_sha256(bytes: &[u8]) -> String {
12824    format!("{:x}", Sha256::digest(bytes))
12825}
12826
12827/// SHA-256 a stream with a fixed-size working buffer.
12828pub fn content_sha256_reader(mut reader: impl Read) -> std::io::Result<String> {
12829    let mut digest = Sha256::new();
12830    let mut buffer = [0u8; 64 * 1024];
12831    loop {
12832        let read = reader.read(&mut buffer)?;
12833        if read == 0 {
12834            break;
12835        }
12836        digest.update(&buffer[..read]);
12837    }
12838    Ok(format!("{:x}", digest.finalize()))
12839}
12840
12841/// Replicate a brain with full verification (link.md §5.4 over the WHOLE
12842/// chain, not just the head): every entry's signature, hash, sequence
12843/// contiguity, prev-hash linkage, rotation chain, and exact signed pack are
12844/// checked in a sibling staging directory. Only then is the old mirror swapped
12845/// out through an atomic directory exchange. Every stage, install, and cleanup
12846/// operation is relative to one held no-follow parent-directory capability, so
12847/// renaming an ancestor cannot redirect any write or deletion.
12848#[cfg_attr(windows, allow(unreachable_code, unused_variables))]
12849pub fn mirror(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<MirrorReport> {
12850    require_hardened_filesystem("mirror")?;
12851    require_safe_ref(brain)?;
12852    #[cfg(windows)]
12853    {
12854        let _ = (cfg, dest);
12855        return Err(LinkError::UnsupportedPlatform {
12856            operation: "atomic whole-mirror replacement on Windows",
12857        });
12858    }
12859    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
12860    let name = dest
12861        .file_name()
12862        .and_then(|name| name.to_str())
12863        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
12864        .ok_or_else(|| LinkError::UnsafePath {
12865            path: dest.display().to_string(),
12866        })?;
12867    #[cfg(unix)]
12868    let parent_dir = open_or_create_dir_nofollow(parent)?;
12869    #[cfg(unix)]
12870    use std::os::fd::AsRawFd as _;
12871    #[cfg(unix)]
12872    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
12873    #[cfg(unix)]
12874    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
12875        None => false,
12876        Some(true) => true,
12877        Some(false) => {
12878            return Err(LinkError::UnsafePath {
12879                path: dest.display().to_string(),
12880            });
12881        }
12882    };
12883
12884    // A fixed backup was used by pre-hardening builds. Never interpret or
12885    // delete an attacker-planted entry at that name; require manual recovery.
12886    #[cfg(unix)]
12887    let legacy_backup_name = c_name(
12888        format!(".{name}.dbmd-backup").as_bytes(),
12889        &dest.display().to_string(),
12890    )?;
12891    #[cfg(unix)]
12892    if entry_is_dir_at(parent_dir.as_raw_fd(), &legacy_backup_name)?.is_some() {
12893        return Err(LinkError::UnsafePath {
12894            path: parent
12895                .join(format!(".{name}.dbmd-backup"))
12896                .display()
12897                .to_string(),
12898        });
12899    }
12900
12901    let nonce = std::time::SystemTime::now()
12902        .duration_since(std::time::UNIX_EPOCH)
12903        .unwrap_or_default()
12904        .as_nanos();
12905    let stage_label = format!(".{name}.dbmd-stage-{}-{nonce}", std::process::id());
12906    #[cfg(unix)]
12907    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
12908    #[cfg(unix)]
12909    let stage_dir = create_dir_exclusive_at(
12910        parent_dir.as_raw_fd(),
12911        &stage_name,
12912        &dest.display().to_string(),
12913    )?;
12914
12915    let assembled = (|| -> LinkResult<MirrorReport> {
12916        let remote = verified_remote_head(cfg, brain, true)?;
12917        let brain_id = remote.head.brain.clone();
12918        let identity = remote
12919            .identity
12920            .as_ref()
12921            .ok_or_else(|| invalid_feed("brain has no signed identity"))?;
12922        let anchor = remote
12923            .anchor
12924            .clone()
12925            .ok_or_else(|| invalid_feed("brain has no verified identity anchor"))?;
12926        let pack = download_verified_snapshot_pack(cfg, &brain_id, &remote)?;
12927        let snapshot_entries = parse_store_pack(pack.clone())?;
12928        let snapshot_count = snapshot_entries.len();
12929        let mut staged_entries = snapshot_entries;
12930        staged_entries.push((format!("{MIRROR_REL_DIR}/snapshot.pack"), pack));
12931        for item in &remote.entries {
12932            let mut exact = serde_json::to_vec(&item.entry)
12933                .map_err(|_| invalid_feed("could not serialize signed feed entry"))?;
12934            exact.push(b'\n');
12935            if format!("{:x}", Sha256::digest(&exact)) != item.hash {
12936                return Err(invalid_feed(
12937                    "serialized mirror entry differs from its verified hash",
12938                ));
12939            }
12940            staged_entries.push((
12941                format!("{MIRROR_REL_DIR}/feed/{}.json", item.entry.seq),
12942                exact,
12943            ));
12944        }
12945        let mut identity_bytes = serde_json::to_vec(identity)
12946            .map_err(|_| invalid_feed("could not serialize verified identity chain"))?;
12947        identity_bytes.push(b'\n');
12948        staged_entries.push((format!("{MIRROR_REL_DIR}/identity.json"), identity_bytes));
12949        let mut head_bytes = serde_json::to_vec(&json!({
12950            "brain": brain_id,
12951            "headSeq": remote.head.seq,
12952            "feedHash": remote.head.feed_hash,
12953        }))
12954        .map_err(|_| invalid_feed("could not serialize verified mirror head"))?;
12955        head_bytes.push(b'\n');
12956        staged_entries.push((format!("{MIRROR_REL_DIR}/head.json"), head_bytes));
12957        staged_entries.push((
12958            CONFIG_REL_PATH.to_string(),
12959            format!("hub = {}\npin = {anchor}\n", cfg.hub).into_bytes(),
12960        ));
12961        #[cfg(unix)]
12962        write_pull_entries_beneath_dir(&stage_dir, &staged_entries)?;
12963
12964        Ok(MirrorReport {
12965            brain: brain_id,
12966            head_seq: remote.head.seq,
12967            feed_hash: remote.head.feed_hash,
12968            entries: remote.entries.len() as u64,
12969            pinned: anchor,
12970            files: snapshot_count,
12971        })
12972    })();
12973
12974    let report = match assembled {
12975        Ok(report) => report,
12976        Err(error) => {
12977            #[cfg(unix)]
12978            let _ = remove_tree_at(
12979                parent_dir.as_raw_fd(),
12980                &stage_name,
12981                &dest.display().to_string(),
12982            );
12983            return Err(error);
12984        }
12985    };
12986
12987    #[cfg(unix)]
12988    if let Err(error) =
12989        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
12990    {
12991        let _ = remove_tree_at(
12992            parent_dir.as_raw_fd(),
12993            &stage_name,
12994            &dest.display().to_string(),
12995        );
12996        return Err(error);
12997    }
12998    // An exchange leaves the old mirror at the unique stage name. Cleanup is
12999    // capability-relative and never follows symlinks in hostile old content.
13000    #[cfg(unix)]
13001    if dest_exists {
13002        remove_tree_at(
13003            parent_dir.as_raw_fd(),
13004            &stage_name,
13005            &dest.display().to_string(),
13006        )?;
13007    }
13008    #[cfg(unix)]
13009    parent_dir.sync_all()?;
13010    Ok(report)
13011}
13012
13013fn verified_remote_head(
13014    cfg: &HubConfig,
13015    brain: &str,
13016    require_full_chain: bool,
13017) -> LinkResult<VerifiedRemote> {
13018    require_hardened_filesystem("verified link.md state")?;
13019    require_safe_ref(brain)?;
13020    // Refuse an unsafe state root before sending credentials or consulting an
13021    // untrusted card, and retain this exact directory inode for the complete
13022    // checkpoint transaction.
13023    let trust_directory = open_trust_dir(cfg)?;
13024    let path = format!("/api/hub/brains/{brain}");
13025    let body = ensure_ok(
13026        request(cfg, "GET", &path, None, Auth::Required)?,
13027        "subscribe",
13028    )?;
13029    let resolved_brain = body
13030        .get("id")
13031        .and_then(Value::as_str)
13032        .filter(|id| crate::ulid::is_ulid(id))
13033        .ok_or_else(|| invalid_feed("brain card has no canonical ULID id"))?
13034        .to_string();
13035    if crate::ulid::is_ulid(brain) && resolved_brain != brain {
13036        return Err(invalid_feed(
13037            "brain card id differs from the explicitly requested brain id",
13038        ));
13039    }
13040    // The card supplies the canonical ULID. Lock alias + canonical keys in
13041    // deterministic filename order, then hold both through verify + save.
13042    // Concurrent aliases for the same brain therefore converge on one
13043    // checkpoint instead of establishing independent TOFU universes.
13044    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &resolved_brain])?;
13045    let (pinned, alias_binding) =
13046        load_canonical_pin(cfg, &trust_directory, brain, &resolved_brain)?;
13047    let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
13048    let advertised_hash = body
13049        .get("feedHash")
13050        .and_then(Value::as_str)
13051        .map(str::to_string);
13052    let updated_at = body
13053        .get("updatedAt")
13054        .and_then(Value::as_str)
13055        .map(str::to_string);
13056    if let Some(pin) = &pinned {
13057        if seq < pin.head_seq {
13058            return Err(invalid_feed(format!(
13059                "feed rollback: hub advertised sequence {seq}, local checkpoint is {}",
13060                pin.head_seq
13061            )));
13062        }
13063        if seq == pin.head_seq && advertised_hash != pin.feed_hash {
13064            return Err(invalid_feed(
13065                "feed equivocation: the checkpoint sequence now has a different hash",
13066            ));
13067        }
13068    }
13069    if seq == 0 {
13070        if advertised_hash.is_some() {
13071            return Err(invalid_feed("an empty feed advertised a head hash"));
13072        }
13073        let identity: FeedIdentity = serde_json::from_value(
13074            body.get("identity")
13075                .cloned()
13076                .ok_or_else(|| invalid_feed("empty brain card has no signed identity"))?,
13077        )
13078        .map_err(|_| invalid_feed("empty brain card has an invalid identity"))?;
13079        let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
13080        // A valid old-key-signed rotation is still inconsistent if it commits
13081        // to history that the same card now claims never existed. Run the
13082        // identical feed-boundary proof used by non-empty heads before this
13083        // identity can become a durable TOFU checkpoint.
13084        verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], seq)?;
13085        save_canonical_pin_and_alias(
13086            cfg,
13087            &trust_directory,
13088            brain,
13089            &resolved_brain,
13090            TrustState {
13091                v: 2,
13092                origin: normalized_origin(&cfg.hub)?,
13093                requested: resolved_brain.clone(),
13094                brain: resolved_brain.clone(),
13095                home: None,
13096                anchor: anchor.clone(),
13097                current: format!("ed25519:{}", identity.fingerprint),
13098                head_seq: 0,
13099                feed_hash: None,
13100                rotations: identity.rotations.clone(),
13101                hub_signer: None,
13102                protocol_profile: None,
13103            },
13104            alias_binding.as_ref(),
13105        )?;
13106        return Ok(VerifiedRemote {
13107            head: Head {
13108                brain: resolved_brain,
13109                seq,
13110                updated_at,
13111                feed_hash: None,
13112                verified: true,
13113            },
13114            identity: Some(identity),
13115            head_entry: None,
13116            entries: Vec::new(),
13117            anchor: Some(anchor),
13118        });
13119    }
13120    if advertised_hash.as_ref().is_none_or(|hash| !is_sha256(hash)) {
13121        return Err(invalid_feed(
13122            "non-empty feed did not advertise a valid SHA-256 head",
13123        ));
13124    }
13125
13126    // On first contact the signed head itself is the TOFU checkpoint. A full
13127    // history replay cannot add authority before an anchor exists; mirrors
13128    // still request the complete chain because they promise an archival copy.
13129    let replay_head_only = !require_full_chain
13130        && pinned
13131            .as_ref()
13132            .is_none_or(|checkpoint| checkpoint.head_seq == seq);
13133    let mut after = if replay_head_only {
13134        seq - 1
13135    } else if require_full_chain || pinned.is_none() {
13136        0
13137    } else {
13138        pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq)
13139    };
13140    let mut expected_seq = after + 1;
13141    let mut previous_hash = if require_full_chain || pinned.is_none() || replay_head_only {
13142        None
13143    } else {
13144        pinned
13145            .as_ref()
13146            .and_then(|checkpoint| checkpoint.feed_hash.clone())
13147    };
13148    let mut identity: Option<FeedIdentity> = None;
13149    let mut anchor: Option<String> = None;
13150    let mut head_entry: Option<FeedItem> = None;
13151    let mut all_entries = Vec::new();
13152    let mut observed_entries = Vec::new();
13153    let replay_count = seq
13154        .checked_sub(after)
13155        .ok_or_else(|| invalid_feed("feed replay range moved backward"))?;
13156    if replay_count > MAX_FEED_REPLAY_ENTRIES {
13157        return Err(invalid_feed(format!(
13158            "feed replay requires {replay_count} entries, over the client cap"
13159        )));
13160    }
13161    let mut replay_bytes = 0u64;
13162
13163    loop {
13164        let feed_bytes = ensure_raw_ok(
13165            request_raw(
13166                cfg,
13167                "GET",
13168                &format!("/api/hub/brains/{brain}/feed?after={after}&limit=100"),
13169                None,
13170                Auth::Required,
13171                MAX_FEED_RESPONSE_BYTES,
13172            )?,
13173            "subscribe feed",
13174        )?;
13175        let feed: FeedResponse = serde_json::from_slice(&feed_bytes)
13176            .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
13177        if feed.head_seq != seq || feed.feed_hash != advertised_hash {
13178            return Err(invalid_feed("brain card and feed head disagree"));
13179        }
13180        if feed.entries.len() > FEED_PAGE_LIMIT {
13181            return Err(invalid_feed("feed page exceeds the requested entry limit"));
13182        }
13183        if feed.scope_limited {
13184            if require_full_chain {
13185                return Err(invalid_feed(
13186                    "path-scoped grants cannot verify a full snapshot chain",
13187                ));
13188            }
13189            return Ok(VerifiedRemote {
13190                head: Head {
13191                    brain: resolved_brain,
13192                    seq,
13193                    updated_at,
13194                    feed_hash: advertised_hash,
13195                    verified: false,
13196                },
13197                identity: None,
13198                head_entry: None,
13199                entries: Vec::new(),
13200                anchor: None,
13201            });
13202        }
13203        let page_identity = feed
13204            .identity
13205            .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
13206        let page_anchor = verify_identity_chain(&page_identity, pinned.as_ref())?;
13207        if identity
13208            .as_ref()
13209            .is_some_and(|existing| existing != &page_identity)
13210        {
13211            return Err(invalid_feed("identity changed while reading the feed"));
13212        }
13213        if anchor
13214            .as_ref()
13215            .is_some_and(|existing| existing != &page_anchor)
13216        {
13217            return Err(invalid_feed(
13218                "identity anchor changed while reading the feed",
13219            ));
13220        }
13221        identity = Some(page_identity.clone());
13222        if anchor.is_none() {
13223            anchor = Some(page_anchor);
13224        }
13225        if feed.entries.is_empty() {
13226            return Err(invalid_feed("feed page was empty before the signed head"));
13227        }
13228
13229        for item in feed.entries {
13230            if item.entry.seq != expected_seq {
13231                return Err(invalid_feed(format!(
13232                    "expected entry {expected_seq}, feed served {}",
13233                    item.entry.seq
13234                )));
13235            }
13236            if item.entry.seq > seq {
13237                return Err(invalid_feed("feed advanced past the card snapshot"));
13238            }
13239            if !replay_head_only && item.entry.prev_entry_hash != previous_hash {
13240                return Err(invalid_feed(format!(
13241                    "entry {} does not chain to the local checkpoint",
13242                    item.entry.seq
13243                )));
13244            }
13245            verify_feed_item(&item, &page_identity)?;
13246            reject_retired_signer_after_checkpoint(&page_identity, pinned.as_ref(), &item)?;
13247            replay_bytes = replay_bytes.saturating_add(
13248                serde_json::to_vec(&item)
13249                    .map_err(|_| invalid_feed("could not size feed entry"))?
13250                    .len() as u64,
13251            );
13252            if replay_bytes > MAX_FEED_REPLAY_BYTES {
13253                return Err(invalid_feed("feed replay metadata exceeds the client cap"));
13254            }
13255            previous_hash = Some(item.hash.clone());
13256            after = item.entry.seq;
13257            expected_seq = expected_seq
13258                .checked_add(1)
13259                .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
13260            if require_full_chain {
13261                all_entries.push(item.clone());
13262            }
13263            observed_entries.push(item.clone());
13264            head_entry = Some(item);
13265        }
13266        if after == seq {
13267            break;
13268        }
13269    }
13270
13271    if head_entry.as_ref().map(|item| &item.hash) != advertised_hash.as_ref() {
13272        return Err(invalid_feed(
13273            "verified chain does not converge on the advertised head",
13274        ));
13275    }
13276    let identity = identity.ok_or_else(|| invalid_feed("feed has no brain identity"))?;
13277    let anchor = anchor.ok_or_else(|| invalid_feed("feed has no identity anchor"))?;
13278    verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &observed_entries, seq)?;
13279    save_canonical_pin_and_alias(
13280        cfg,
13281        &trust_directory,
13282        brain,
13283        &resolved_brain,
13284        TrustState {
13285            v: 2,
13286            origin: normalized_origin(&cfg.hub)?,
13287            requested: resolved_brain.clone(),
13288            brain: resolved_brain.clone(),
13289            home: None,
13290            anchor: anchor.clone(),
13291            current: format!("ed25519:{}", identity.fingerprint),
13292            head_seq: seq,
13293            feed_hash: advertised_hash.clone(),
13294            rotations: identity.rotations.clone(),
13295            hub_signer: pinned.as_ref().and_then(|state| state.hub_signer.clone()),
13296            protocol_profile: pinned
13297                .as_ref()
13298                .and_then(|state| state.protocol_profile.clone()),
13299        },
13300        alias_binding.as_ref(),
13301    )?;
13302    Ok(VerifiedRemote {
13303        head: Head {
13304            brain: resolved_brain,
13305            seq,
13306            updated_at,
13307            feed_hash: advertised_hash,
13308            verified: true,
13309        },
13310        identity: Some(identity),
13311        head_entry,
13312        entries: all_entries,
13313        anchor: Some(anchor),
13314    })
13315}
13316
13317/// Read and locally verify the brain's current signed feed head. Identity
13318/// rotation is accepted only through an old-key-signed chain rooted at the
13319/// local TOFU anchor; sequence and hash checkpoints reject rollback and
13320/// equivocation across invocations.
13321pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
13322    Ok(verified_remote_head(cfg, brain, false)?.head)
13323}
13324
13325#[cfg(test)]
13326mod tests {
13327    use super::*;
13328
13329    const TEST_BRAIN_ID: &str = "01j5qc3v9k4ym8rwbn2tqe6f7d";
13330
13331    #[test]
13332    fn exact_source_move_becomes_one_provenance_preserving_rename() {
13333        let hash = "a".repeat(64);
13334        let operations = vec![
13335            json!({
13336                "op": "put",
13337                "path": "sources/curated/item.md",
13338                "expected": { "kind": "absent" },
13339                "blob": hash,
13340                "bytes": 19,
13341            }),
13342            json!({
13343                "op": "delete",
13344                "path": "sources/inbox/item.md",
13345                "expected": { "kind": "blob", "hash": hash },
13346            }),
13347        ];
13348
13349        assert_eq!(
13350            infer_exact_source_promotions(operations),
13351            vec![json!({
13352                "op": "rename",
13353                "from": "sources/inbox/item.md",
13354                "to": "sources/curated/item.md",
13355                "expected_from": { "kind": "blob", "hash": hash },
13356                "expected_to": { "kind": "absent" },
13357                "blob": hash,
13358                "bytes": 19,
13359            })]
13360        );
13361    }
13362
13363    #[test]
13364    fn duplicate_source_bytes_never_guess_which_evidence_was_promoted() {
13365        let hash = "b".repeat(64);
13366        let operations = vec![
13367            json!({
13368                "op": "delete",
13369                "path": "sources/inbox/a.md",
13370                "expected": { "kind": "blob", "hash": hash },
13371            }),
13372            json!({
13373                "op": "delete",
13374                "path": "sources/inbox/b.md",
13375                "expected": { "kind": "blob", "hash": hash },
13376            }),
13377            json!({
13378                "op": "put",
13379                "path": "sources/curated/item.md",
13380                "expected": { "kind": "absent" },
13381                "blob": hash,
13382                "bytes": 19,
13383            }),
13384        ];
13385
13386        assert_eq!(
13387            infer_exact_source_promotions(operations.clone()),
13388            operations,
13389            "an ambiguous filesystem diff must reach the hub unchanged and fail closed"
13390        );
13391    }
13392
13393    #[test]
13394    fn accepted_source_promotion_advances_the_local_baseline_exactly() {
13395        let hash = "c".repeat(64);
13396        let mut candidate = std::collections::BTreeMap::from([(
13397            "sources/inbox/item.md".to_string(),
13398            V2BaselineFile {
13399                sha256: hash.clone(),
13400                bytes: 19,
13401                proof: None,
13402            },
13403        )]);
13404        let mut candidate_assets = std::collections::BTreeMap::new();
13405        let operations = vec![
13406            json!({
13407                "op": "rename",
13408                "from": "sources/inbox/item.md",
13409                "to": "sources/curated/item.md",
13410                "expected_from": { "kind": "blob", "hash": hash },
13411                "expected_to": { "kind": "absent" },
13412                "blob": hash,
13413                "bytes": 19,
13414            }),
13415            json!({
13416                "op": "put",
13417                "path": "records/rsvps/item.md",
13418                "expected": { "kind": "absent" },
13419                "blob": "d".repeat(64),
13420                "bytes": 23,
13421            }),
13422        ];
13423
13424        assert!(!apply_generated_v2_operations(
13425            &operations,
13426            &std::collections::BTreeMap::new(),
13427            &mut candidate,
13428            &mut candidate_assets,
13429        )
13430        .unwrap());
13431        assert!(!candidate.contains_key("sources/inbox/item.md"));
13432        assert_eq!(
13433            candidate
13434                .get("sources/curated/item.md")
13435                .map(|file| (&file.sha256, file.bytes)),
13436            Some((&hash, 19))
13437        );
13438        assert_eq!(
13439            candidate
13440                .get("records/rsvps/item.md")
13441                .map(|file| (file.sha256.as_str(), file.bytes)),
13442            Some((
13443                "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
13444                23
13445            ))
13446        );
13447    }
13448
13449    fn merge_fixture(
13450        base: Option<&str>,
13451        remote: Option<&str>,
13452        local: Option<&str>,
13453        keep_local: bool,
13454    ) -> V2PulledMerge<String> {
13455        let map = |value: Option<&str>| {
13456            value
13457                .map(|value| [("records/a.md".to_string(), value.to_string())])
13458                .into_iter()
13459                .flatten()
13460                .collect::<std::collections::BTreeMap<_, _>>()
13461        };
13462        merge_v2_pulled_records(
13463            &map(base),
13464            &map(remote),
13465            &map(local),
13466            |value, _| value.clone(),
13467            |value, _| value.clone(),
13468            |_| keep_local,
13469        )
13470    }
13471
13472    #[test]
13473    fn v2_pull_three_way_merge_never_discards_a_local_only_change() {
13474        let path = "records/a.md".to_string();
13475
13476        let local_add = merge_fixture(None, None, Some("local"), false);
13477        assert_eq!(
13478            local_add.records.get(&path).map(String::as_str),
13479            Some("local")
13480        );
13481        assert!(local_add.accept_remote.is_empty());
13482        assert!(local_add.conflicts.is_empty());
13483
13484        let local_edit = merge_fixture(Some("old"), Some("old"), Some("local"), false);
13485        assert_eq!(
13486            local_edit.records.get(&path).map(String::as_str),
13487            Some("local")
13488        );
13489        assert!(local_edit.accept_remote.is_empty());
13490        assert!(local_edit.conflicts.is_empty());
13491
13492        let local_delete = merge_fixture(Some("old"), Some("old"), None, false);
13493        assert!(!local_delete.records.contains_key(&path));
13494        assert!(local_delete.accept_remote.is_empty());
13495        assert!(local_delete.conflicts.is_empty());
13496
13497        let remote_edit = merge_fixture(Some("old"), Some("remote"), Some("old"), false);
13498        assert_eq!(
13499            remote_edit.records.get(&path).map(String::as_str),
13500            Some("remote")
13501        );
13502        assert!(remote_edit.accept_remote.contains(&path));
13503        assert!(remote_edit.conflicts.is_empty());
13504
13505        let remote_delete = merge_fixture(Some("old"), None, Some("old"), false);
13506        assert!(!remote_delete.records.contains_key(&path));
13507        assert!(remote_delete.accept_remote.contains(&path));
13508        assert!(remote_delete.conflicts.is_empty());
13509
13510        let same_edit = merge_fixture(Some("old"), Some("same"), Some("same"), false);
13511        assert_eq!(
13512            same_edit.records.get(&path).map(String::as_str),
13513            Some("same")
13514        );
13515        assert!(same_edit.accept_remote.contains(&path));
13516        assert!(same_edit.conflicts.is_empty());
13517
13518        let conflict = merge_fixture(Some("old"), Some("remote"), Some("local"), false);
13519        assert_eq!(conflict.conflicts, vec![path.clone()]);
13520        assert_eq!(
13521            conflict.records.get(&path).map(String::as_str),
13522            Some("local")
13523        );
13524        assert!(conflict.accept_remote.is_empty());
13525
13526        let kept_home = merge_fixture(Some("old"), Some("remote"), Some("local"), true);
13527        assert_eq!(
13528            kept_home.records.get(&path).map(String::as_str),
13529            Some("local")
13530        );
13531        assert!(kept_home.accept_remote.is_empty());
13532        assert!(kept_home.conflicts.is_empty());
13533    }
13534
13535    #[test]
13536    fn v2_asset_hosting_resume_is_an_explicit_local_policy_transition() {
13537        let path = "sources/report.pdf";
13538        let record = crate::AssetRecord {
13539            path: path.to_string(),
13540            sha256: "a".repeat(64),
13541            bytes: 42,
13542            media_type: "application/pdf".to_string(),
13543            wrappers: vec!["gzip".to_string()],
13544            required: true,
13545        };
13546        let mut remote = V2BaselineAsset {
13547            blob_sha256: record.sha256.clone(),
13548            bytes: record.bytes,
13549            media_type: record.media_type.clone(),
13550            wrappers: record.wrappers.clone(),
13551            required: record.required,
13552            disposition: "withheld".to_string(),
13553            leaf_hash: "b".repeat(64),
13554        };
13555
13556        assert!(v2_asset_resumes_hosting(
13557            Some(&remote),
13558            path,
13559            &record,
13560            "hosted"
13561        ));
13562        assert!(!v2_asset_resumes_hosting(
13563            Some(&remote),
13564            path,
13565            &record,
13566            "withheld"
13567        ));
13568
13569        remote.disposition = "hosted".to_string();
13570        assert!(!v2_asset_resumes_hosting(
13571            Some(&remote),
13572            path,
13573            &record,
13574            "hosted"
13575        ));
13576
13577        remote.disposition = "withheld".to_string();
13578        remote.blob_sha256 = "c".repeat(64);
13579        assert!(!v2_asset_resumes_hosting(
13580            Some(&remote),
13581            path,
13582            &record,
13583            "hosted"
13584        ));
13585        assert!(!v2_asset_resumes_hosting(None, path, &record, "hosted"));
13586    }
13587
13588    #[test]
13589    fn take_remote_resolution_rebinds_to_the_exact_current_manifest() {
13590        let path = "records/team/alpha.md".to_string();
13591        let deleted_path = "records/team/deleted.md".to_string();
13592        let coordinate = |sha256: Option<String>, bytes: Option<u64>| V2ConflictCoordinate {
13593            sha256,
13594            bytes,
13595            file: None,
13596        };
13597        let files = vec![
13598            V2ConflictFile {
13599                path: path.clone(),
13600                base: coordinate(None, None),
13601                local: coordinate(Some("b".repeat(64)), Some(7)),
13602                remote: coordinate(Some("a".repeat(64)), Some(5)),
13603            },
13604            V2ConflictFile {
13605                path: deleted_path.clone(),
13606                base: coordinate(Some("c".repeat(64)), Some(9)),
13607                local: coordinate(Some("d".repeat(64)), Some(11)),
13608                remote: coordinate(None, None),
13609            },
13610        ];
13611        let proven = V2BaselineFile {
13612            sha256: "a".repeat(64),
13613            bytes: 5,
13614            proof: None,
13615        };
13616        let current = [(path.clone(), proven.clone())]
13617            .into_iter()
13618            .collect::<std::collections::BTreeMap<_, _>>();
13619
13620        let (selected, deleted) = v2_take_remote_selection(&files, &current).unwrap();
13621        assert_eq!(selected.get(&path).unwrap().sha256, proven.sha256);
13622        assert_eq!(deleted, vec![deleted_path.clone()]);
13623
13624        let changed = [(
13625            path.clone(),
13626            V2BaselineFile {
13627                sha256: "e".repeat(64),
13628                bytes: 5,
13629                proof: None,
13630            },
13631        )]
13632        .into_iter()
13633        .collect::<std::collections::BTreeMap<_, _>>();
13634        assert!(v2_take_remote_selection(&files, &changed).is_err());
13635
13636        let resurrected = [
13637            (path, proven),
13638            (
13639                deleted_path,
13640                V2BaselineFile {
13641                    sha256: "f".repeat(64),
13642                    bytes: 13,
13643                    proof: None,
13644                },
13645            ),
13646        ]
13647        .into_iter()
13648        .collect::<std::collections::BTreeMap<_, _>>();
13649        assert!(v2_take_remote_selection(&files, &resurrected).is_err());
13650    }
13651
13652    #[cfg(target_os = "linux")]
13653    #[test]
13654    fn linux_snapshot_install_is_atomic_for_create_and_exchange() {
13655        use std::os::fd::AsRawFd as _;
13656
13657        let sandbox = tempfile::TempDir::new().unwrap();
13658        let parent = std::fs::File::open(sandbox.path()).unwrap();
13659        let stage = std::ffi::CString::new("stage").unwrap();
13660        let destination = std::ffi::CString::new("brain").unwrap();
13661
13662        std::fs::create_dir(sandbox.path().join("stage")).unwrap();
13663        std::fs::write(sandbox.path().join("stage/value"), b"created").unwrap();
13664        install_stage_at(
13665            parent.as_raw_fd(),
13666            stage.as_c_str(),
13667            destination.as_c_str(),
13668            false,
13669        )
13670        .unwrap();
13671        assert!(!sandbox.path().join("stage").exists());
13672        assert_eq!(
13673            std::fs::read(sandbox.path().join("brain/value")).unwrap(),
13674            b"created"
13675        );
13676
13677        std::fs::create_dir(sandbox.path().join("stage")).unwrap();
13678        std::fs::write(sandbox.path().join("stage/value"), b"replacement").unwrap();
13679        install_stage_at(
13680            parent.as_raw_fd(),
13681            stage.as_c_str(),
13682            destination.as_c_str(),
13683            true,
13684        )
13685        .unwrap();
13686        assert_eq!(
13687            std::fs::read(sandbox.path().join("brain/value")).unwrap(),
13688            b"replacement"
13689        );
13690        assert_eq!(
13691            std::fs::read(sandbox.path().join("stage/value")).unwrap(),
13692            b"created",
13693            "RENAME_EXCHANGE must leave the predecessor at the unique stage name"
13694        );
13695    }
13696
13697    struct SignedRemoteFixture {
13698        card: String,
13699        feed: String,
13700        key: AgentSigningKey,
13701        identity: FeedIdentity,
13702    }
13703
13704    fn signed_remote_fixture() -> SignedRemoteFixture {
13705        let rng = ring::rand::SystemRandom::new();
13706        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
13707        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
13708        let (public_key, multikey) = public_identity_for(&pair);
13709        let identity = FeedIdentity {
13710            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
13711            public_key_spki: public_key.clone(),
13712            previous: Vec::new(),
13713            rotations: Vec::new(),
13714        };
13715        let mut entry = FeedEntry {
13716            v: 1,
13717            seq: 1,
13718            ts: "2026-07-30T12:00:00.000Z".to_string(),
13719            brain: multikey.clone(),
13720            public_key: public_key.clone(),
13721            kind: "push".to_string(),
13722            op: "snapshot".to_string(),
13723            pack_sha256: "a".repeat(64),
13724            files: Vec::new(),
13725            removed: Vec::new(),
13726            prev_entry_hash: None,
13727            sig: String::new(),
13728        };
13729        let unsigned = UnsignedFeedEntry {
13730            v: entry.v,
13731            seq: entry.seq,
13732            ts: &entry.ts,
13733            brain: &entry.brain,
13734            public_key: &entry.public_key,
13735            kind: &entry.kind,
13736            op: &entry.op,
13737            pack_sha256: &entry.pack_sha256,
13738            files: &entry.files,
13739            removed: &entry.removed,
13740            prev_entry_hash: &entry.prev_entry_hash,
13741        };
13742        entry.sig =
13743            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
13744        let mut exact = serde_json::to_vec(&entry).unwrap();
13745        exact.push(b'\n');
13746        let hash = content_sha256(&exact);
13747        let card = json!({
13748            "id": TEST_BRAIN_ID,
13749            "headSeq": 1,
13750            "feedHash": hash,
13751            "identity": identity.clone(),
13752        })
13753        .to_string();
13754        let feed = json!({
13755            "headSeq": 1,
13756            "feedHash": hash,
13757            "identity": identity.clone(),
13758            "entries": [{"hash": hash, "entry": entry}],
13759            "scopeLimited": false,
13760        })
13761        .to_string();
13762        SignedRemoteFixture {
13763            card,
13764            feed,
13765            key: AgentSigningKey {
13766                pkcs8: pkcs8.as_ref().to_vec(),
13767                multikey,
13768                public_key_spki: public_key,
13769            },
13770            identity,
13771        }
13772    }
13773
13774    #[test]
13775    fn v1_edit_disclosure_accepts_minimal_and_superset_forms() {
13776        let file = |path: &str, byte: char| FeedFile {
13777            path: path.to_string(),
13778            sha256: byte.to_string().repeat(64),
13779            bytes: 1,
13780        };
13781        let a0 = file("records/a.md", 'a');
13782        let a1 = file("records/a.md", 'b');
13783        let stable = file("records/stable.md", 'c');
13784        let added = file("records/added.md", 'd');
13785        let removed_file = file("records/removed.md", 'e');
13786        let previous = vec![a0, stable.clone(), removed_file.clone()];
13787        let resulting = vec![a1.clone(), stable.clone(), added.clone()];
13788        let removed = vec![removed_file.path.clone()];
13789
13790        assert_eq!(
13791            verify_v1_manifest_disclosure(
13792                "edit",
13793                &previous,
13794                &resulting,
13795                &[a1.clone(), added.clone()],
13796                &removed,
13797            ),
13798            Ok(())
13799        );
13800        assert_eq!(
13801            verify_v1_manifest_disclosure(
13802                "edit",
13803                &previous,
13804                &resulting,
13805                &[stable.clone(), added.clone(), a1.clone()],
13806                &removed,
13807            ),
13808            Ok(())
13809        );
13810        assert_eq!(
13811            verify_v1_manifest_disclosure(
13812                "edit",
13813                &previous,
13814                &resulting,
13815                std::slice::from_ref(&added),
13816                &removed,
13817            ),
13818            Err(V1DisclosureError::EditMissingChange)
13819        );
13820        assert_eq!(
13821            verify_v1_manifest_disclosure(
13822                "edit",
13823                &previous,
13824                &resulting,
13825                &[file("records/a.md", 'f'), added.clone()],
13826                &removed,
13827            ),
13828            Err(V1DisclosureError::EditFalseFile)
13829        );
13830        assert_eq!(
13831            verify_v1_manifest_disclosure(
13832                "edit",
13833                &previous,
13834                &resulting,
13835                &[a1.clone(), added.clone()],
13836                &[],
13837            ),
13838            Err(V1DisclosureError::RemovedMismatch)
13839        );
13840        assert_eq!(
13841            verify_v1_manifest_disclosure(
13842                "push",
13843                &previous,
13844                &resulting,
13845                &[added.clone(), stable, a1],
13846                &removed,
13847            ),
13848            Ok(())
13849        );
13850        assert_eq!(
13851            verify_v1_manifest_disclosure("push", &previous, &resulting, &[added], &removed,),
13852            Err(V1DisclosureError::PushManifestMismatch)
13853        );
13854    }
13855
13856    #[test]
13857    fn wire_sequences_are_rejected_at_their_endpoint_count_limits() {
13858        let fixture = signed_remote_fixture();
13859        let feed: Value = serde_json::from_str(&fixture.feed).unwrap();
13860        let item = feed["entries"][0].to_string();
13861        let oversized_page = format!(
13862            "{{\"headSeq\":1,\"feedHash\":null,\"identity\":null,\"entries\":[{}],\"scopeLimited\":false}}",
13863            std::iter::repeat_n(item.as_str(), FEED_PAGE_LIMIT + 1)
13864                .collect::<Vec<_>>()
13865                .join(",")
13866        );
13867        assert!(serde_json::from_str::<FeedResponse>(&oversized_page).is_err());
13868
13869        let oversized_identity = format!(
13870            "{{\"fingerprint\":\"fp\",\"publicKeySpki\":\"spki\",\"previous\":[],\"rotations\":[{}]}}",
13871            std::iter::repeat_n("\"rotation\"", MAX_IDENTITY_ROTATIONS + 1)
13872                .collect::<Vec<_>>()
13873                .join(",")
13874        );
13875        assert!(serde_json::from_str::<FeedIdentity>(&oversized_identity).is_err());
13876
13877        let file = r#"{"path":"a","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bytes":1}"#;
13878        let oversized_entry = format!(
13879            "{{\"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\"}}",
13880            "a".repeat(64),
13881            std::iter::repeat_n(file, MAX_PUSH_FILES + 1)
13882                .collect::<Vec<_>>()
13883                .join(",")
13884        );
13885        assert!(serde_json::from_str::<FeedEntry>(&oversized_entry).is_err());
13886    }
13887
13888    #[test]
13889    fn bulk_confirmation_parser_accepts_only_the_exact_wire_shape() {
13890        let id = "01arz3ndektsv4rrffq69g5fav";
13891        let digest = "a".repeat(64);
13892        assert_eq!(
13893            V2BulkConfirmation::parse(&format!("{id}:{digest}")).unwrap(),
13894            V2BulkConfirmation {
13895                id: id.to_string(),
13896                digest,
13897            }
13898        );
13899        for invalid in [
13900            "",
13901            "01arz3ndektsv4rrffq69g5fav",
13902            "01ARZ3NDEKTSV4RRFFQ69G5FAV:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
13903            "01arz3ndektsv4rrffq69g5fav:ABCDEF",
13904            "01arz3ndektsv4rrffq69g5fav:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
13905        ] {
13906            assert!(matches!(
13907                V2BulkConfirmation::parse(invalid),
13908                Err(LinkError::InvalidPack { .. })
13909            ));
13910        }
13911    }
13912
13913    fn scripted_json_hub(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle<()>) {
13914        use std::io::{BufRead as _, BufReader, Read as _, Write as _};
13915        use std::net::TcpListener;
13916
13917        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
13918        let url = format!("http://{}", listener.local_addr().unwrap());
13919        let handle = std::thread::spawn(move || {
13920            for (status, body) in responses {
13921                let (stream, _) = listener.accept().unwrap();
13922                let mut reader = BufReader::new(stream);
13923                let mut line = String::new();
13924                reader.read_line(&mut line).unwrap();
13925                let mut content_length = 0usize;
13926                loop {
13927                    line.clear();
13928                    reader.read_line(&mut line).unwrap();
13929                    if line == "\r\n" || line == "\n" || line.is_empty() {
13930                        break;
13931                    }
13932                    if let Some((name, value)) = line.split_once(':') {
13933                        if name.eq_ignore_ascii_case("content-length") {
13934                            content_length = value.trim().parse().unwrap();
13935                        }
13936                    }
13937                }
13938                let mut request_body = vec![0_u8; content_length];
13939                reader.read_exact(&mut request_body).unwrap();
13940                let response = format!(
13941                    "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
13942                    body.len()
13943                );
13944                reader.get_mut().write_all(response.as_bytes()).unwrap();
13945            }
13946        });
13947        (url, handle)
13948    }
13949
13950    fn routed_json_hub(
13951        requests: usize,
13952        mut respond: impl FnMut(&str) -> (u16, String) + Send + 'static,
13953    ) -> (String, std::thread::JoinHandle<()>) {
13954        use std::io::{BufRead as _, BufReader, Read as _, Write as _};
13955        use std::net::TcpListener;
13956
13957        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
13958        let url = format!("http://{}", listener.local_addr().unwrap());
13959        let handle = std::thread::spawn(move || {
13960            for _ in 0..requests {
13961                let (stream, _) = listener.accept().unwrap();
13962                let mut reader = BufReader::new(stream);
13963                let mut line = String::new();
13964                reader.read_line(&mut line).unwrap();
13965                let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
13966                let mut content_length = 0usize;
13967                loop {
13968                    line.clear();
13969                    reader.read_line(&mut line).unwrap();
13970                    if line == "\r\n" || line == "\n" || line.is_empty() {
13971                        break;
13972                    }
13973                    if let Some((name, value)) = line.split_once(':') {
13974                        if name.eq_ignore_ascii_case("content-length") {
13975                            content_length = value.trim().parse().unwrap();
13976                        }
13977                    }
13978                }
13979                let mut request_body = vec![0_u8; content_length];
13980                reader.read_exact(&mut request_body).unwrap();
13981                let (status, body) = respond(&path);
13982                let response = format!(
13983                    "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
13984                    body.len()
13985                );
13986                reader.get_mut().write_all(response.as_bytes()).unwrap();
13987            }
13988        });
13989        (url, handle)
13990    }
13991
13992    fn test_hub_config(hub: String, state_dir: PathBuf) -> HubConfig {
13993        HubConfig {
13994            hub,
13995            key: Some("test-key".to_string()),
13996            agent_key: None,
13997            brain_key: None,
13998            state_dir,
13999            store_selected: false,
14000        }
14001    }
14002
14003    #[test]
14004    fn linkmd_sig_v2_is_bound_to_the_exact_hub_origin() {
14005        use ring::signature::KeyPair as _;
14006
14007        let rng = ring::rand::SystemRandom::new();
14008        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
14009        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
14010        let (spki, multikey) = public_identity_for(&pair);
14011        let key = AgentSigningKey {
14012            pkcs8: pkcs8.as_ref().to_vec(),
14013            multikey,
14014            public_key_spki: spki,
14015        };
14016        let header = linkmd_sig_header(
14017            &key,
14018            "https://hub-a.example",
14019            "post",
14020            "/api/hub/brains/brain/push?mode=exact",
14021            Some("{\"ok\":true}"),
14022        )
14023        .unwrap();
14024        assert!(header.starts_with("LinkMD-Sig v2,"));
14025        let ts = header
14026            .split(",ts=")
14027            .nth(1)
14028            .unwrap()
14029            .split(',')
14030            .next()
14031            .unwrap();
14032        let signature = URL_SAFE_NO_PAD
14033            .decode(header.rsplit(",sig=").next().unwrap())
14034            .unwrap();
14035        let body_hash = format!("{:x}", Sha256::digest(b"{\"ok\":true}"));
14036        let accepted = format!(
14037            "v2\nhttps://hub-a.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
14038        );
14039        let replayed = format!(
14040            "v2\nhttps://hub-b.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
14041        );
14042        let public = pair.public_key().as_ref();
14043        assert!(UnparsedPublicKey::new(&ED25519, public)
14044            .verify(accepted.as_bytes(), &signature)
14045            .is_ok());
14046        assert!(
14047            UnparsedPublicKey::new(&ED25519, public)
14048                .verify(replayed.as_bytes(), &signature)
14049                .is_err(),
14050            "a proof captured at hub A must not authenticate at hub B"
14051        );
14052    }
14053
14054    #[test]
14055    fn explicit_brain_id_rejects_a_substituted_card_before_feed_trust() {
14056        let other = "01j5qc3v9k4ym8rwbn2tqe6f7e";
14057        let card = json!({
14058            "id": other,
14059            "headSeq": 0,
14060            "identity": signed_remote_fixture().identity,
14061        })
14062        .to_string();
14063        let (hub, server) = scripted_json_hub(vec![(200, card)]);
14064        let state = tempfile::tempdir().unwrap();
14065        let cfg = test_hub_config(hub, state.path().to_path_buf());
14066        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
14067        assert!(
14068            error.contains("differs from the explicitly requested"),
14069            "{error}"
14070        );
14071        server.join().unwrap();
14072    }
14073
14074    #[test]
14075    fn empty_brain_identity_is_pinned_and_cannot_be_substituted_later() {
14076        let first = signed_remote_fixture().identity;
14077        let second = signed_remote_fixture().identity;
14078        let card = |identity: FeedIdentity| {
14079            json!({
14080                "id": TEST_BRAIN_ID,
14081                "headSeq": 0,
14082                "identity": identity,
14083            })
14084            .to_string()
14085        };
14086        let (hub, server) = scripted_json_hub(vec![(200, card(first)), (200, card(second))]);
14087        let state = tempfile::tempdir().unwrap();
14088        let cfg = test_hub_config(hub, state.path().to_path_buf());
14089        assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
14090        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
14091        assert!(
14092            error.contains("pinned anchor") || error.contains("forked away"),
14093            "{error}"
14094        );
14095        server.join().unwrap();
14096    }
14097
14098    #[test]
14099    fn empty_head_rejects_a_rotation_that_commits_to_hidden_history() {
14100        let old = signed_remote_fixture();
14101        let new = signed_remote_fixture();
14102        let old_pair = ring::signature::Ed25519KeyPair::from_pkcs8(&old.key.pkcs8).unwrap();
14103        let unsigned = serde_json::to_string(&UnsignedRotation {
14104            v: 1,
14105            op: "rotate",
14106            brain: &old.key.multikey,
14107            public_key: &old.key.public_key_spki,
14108            new_brain: &new.key.multikey,
14109            new_public_key: &new.key.public_key_spki,
14110            prior_head_seq: 1,
14111            prior_feed_hash: Some(&"a".repeat(64)),
14112            ts: "2026-07-30T12:00:00.000Z".to_string(),
14113        })
14114        .unwrap();
14115        let signature = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
14116        let rotation = format!(
14117            "{},\"sig\":\"{}\"}}",
14118            &unsigned[..unsigned.len() - 1],
14119            signature
14120        );
14121        let identity = FeedIdentity {
14122            fingerprint: new.key.multikey.trim_start_matches("ed25519:").to_string(),
14123            public_key_spki: new.key.public_key_spki,
14124            previous: vec![PreviousIdentity {
14125                fingerprint: old.key.multikey.trim_start_matches("ed25519:").to_string(),
14126                public_key_spki: old.key.public_key_spki,
14127            }],
14128            rotations: vec![rotation],
14129        };
14130        let card = json!({
14131            "id": TEST_BRAIN_ID,
14132            "headSeq": 0,
14133            "feedHash": null,
14134            "identity": identity,
14135        })
14136        .to_string();
14137        let (hub, server) = scripted_json_hub(vec![(200, card)]);
14138        let state = tempfile::tempdir().unwrap();
14139        let cfg = test_hub_config(hub, state.path().to_path_buf());
14140        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
14141        assert!(
14142            error.contains("rotation claims a feed boundary beyond the advertised head"),
14143            "{error}"
14144        );
14145        assert!(
14146            load_trust(&cfg, TEST_BRAIN_ID).unwrap().is_none(),
14147            "an inconsistent empty-head identity must not become the TOFU checkpoint"
14148        );
14149        server.join().unwrap();
14150    }
14151
14152    #[test]
14153    fn trust_checkpoint_rejects_a_later_fork() {
14154        let fixture = signed_remote_fixture();
14155        let mut fork: Value = serde_json::from_str(&fixture.card).unwrap();
14156        fork["feedHash"] = Value::String("b".repeat(64));
14157        let (hub, server) = scripted_json_hub(vec![
14158            (200, fixture.card),
14159            (200, fixture.feed),
14160            (200, fork.to_string()),
14161        ]);
14162        let state = tempfile::tempdir().unwrap();
14163        let cfg = test_hub_config(hub, state.path().to_path_buf());
14164        assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
14165        assert!(head(&cfg, TEST_BRAIN_ID).is_err());
14166        server.join().unwrap();
14167    }
14168
14169    #[test]
14170    fn alias_and_canonical_id_share_one_identity_checkpoint() {
14171        let trusted = signed_remote_fixture();
14172        let attacker = signed_remote_fixture();
14173        let (hub, server) = scripted_json_hub(vec![
14174            (200, trusted.card),
14175            (200, trusted.feed),
14176            (200, attacker.card),
14177        ]);
14178        let state = tempfile::tempdir().unwrap();
14179        let cfg = test_hub_config(hub, state.path().to_path_buf());
14180        assert!(head(&cfg, "trusted-slug").unwrap().verified);
14181        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
14182        assert!(
14183            error.contains("equivocation")
14184                || error.contains("pinned")
14185                || error.contains("identity"),
14186            "{error}"
14187        );
14188        server.join().unwrap();
14189    }
14190
14191    #[test]
14192    fn moved_alias_requires_exact_explicit_rebind_without_rewriting_history() {
14193        let state = tempfile::tempdir().unwrap();
14194        let cfg = test_hub_config(
14195            "https://hub.example".to_string(),
14196            state.path().to_path_buf(),
14197        );
14198        let directory = open_trust_dir(&cfg).unwrap();
14199        let old = TEST_BRAIN_ID;
14200        let new = "01j5qc3v9k4ym8rwbn2tqe6f7e";
14201        save_alias_in(
14202            &cfg,
14203            &directory,
14204            &AliasBinding {
14205                v: 1,
14206                origin: normalized_origin(&cfg.hub).unwrap(),
14207                requested: "company-brain".to_string(),
14208                brain: old.to_string(),
14209                home: Some("company-brain".to_string()),
14210            },
14211        )
14212        .unwrap();
14213
14214        let error = load_canonical_pin(&cfg, &directory, "company-brain", new).unwrap_err();
14215        assert!(matches!(
14216            error,
14217            LinkError::AliasRebindRequired {
14218                alias,
14219                from,
14220                to
14221            } if alias == "company-brain" && from == old && to == new
14222        ));
14223        let unchanged = load_alias_in(&cfg, &directory, "company-brain")
14224            .unwrap()
14225            .unwrap();
14226        assert_eq!(unchanged.brain, old);
14227        assert_eq!(unchanged.home.as_deref(), Some("company-brain"));
14228    }
14229
14230    #[test]
14231    fn concurrent_aliases_cannot_establish_conflicting_tofu_pins() {
14232        let alpha = signed_remote_fixture();
14233        let beta = signed_remote_fixture();
14234        let alpha_card = alpha.card.clone();
14235        let alpha_feed = alpha.feed.clone();
14236        let beta_card = beta.card.clone();
14237        let beta_feed = beta.feed.clone();
14238        let (hub, server) = routed_json_hub(3, move |path| {
14239            if path.contains("/alpha/feed?") {
14240                (200, alpha_feed.clone())
14241            } else if path.contains("/beta/feed?") {
14242                (200, beta_feed.clone())
14243            } else if path.ends_with("/alpha") {
14244                (200, alpha_card.clone())
14245            } else if path.ends_with("/beta") {
14246                (200, beta_card.clone())
14247            } else {
14248                (500, r#"{"error":"unexpected path"}"#.to_string())
14249            }
14250        });
14251        let state = tempfile::tempdir().unwrap();
14252        let cfg = test_hub_config(hub, state.path().to_path_buf());
14253        let alpha_cfg = cfg.clone();
14254        let beta_cfg = cfg;
14255        let first = std::thread::spawn(move || head(&alpha_cfg, "alpha"));
14256        let second = std::thread::spawn(move || head(&beta_cfg, "beta"));
14257        let results = [first.join().unwrap(), second.join().unwrap()];
14258        assert_eq!(
14259            results.iter().filter(|result| result.is_ok()).count(),
14260            1,
14261            "only one alias identity may establish canonical TOFU: {results:?}"
14262        );
14263        assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
14264        server.join().unwrap();
14265    }
14266
14267    #[cfg(unix)]
14268    #[test]
14269    fn trust_transaction_survives_an_ancestor_swap_without_writing_outside() {
14270        use std::os::unix::fs::symlink;
14271
14272        let fixture = signed_remote_fixture();
14273        let card = json!({
14274            "id": TEST_BRAIN_ID,
14275            "headSeq": 0,
14276            "feedHash": Value::Null,
14277            "identity": fixture.identity,
14278        })
14279        .to_string();
14280        let work = tempfile::tempdir().unwrap();
14281        let outside = tempfile::tempdir().unwrap();
14282        let state = work.path().join("state");
14283        let moved = work.path().join("state-held");
14284        let swap_state = state.clone();
14285        let swap_moved = moved.clone();
14286        let outside_path = outside.path().to_path_buf();
14287        let (hub, server) = routed_json_hub(1, move |_| {
14288            // The client has already opened state/trust before this response.
14289            std::fs::rename(&swap_state, &swap_moved).unwrap();
14290            symlink(&outside_path, &swap_state).unwrap();
14291            (200, card.clone())
14292        });
14293        let cfg = test_hub_config(hub, state);
14294
14295        let verified = verified_remote_head(&cfg, TEST_BRAIN_ID, false).unwrap();
14296        assert_eq!(verified.head.seq, 0);
14297        assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
14298        assert!(std::fs::read_dir(moved.join("trust"))
14299            .unwrap()
14300            .flatten()
14301            .any(|entry| entry.path().extension().is_some_and(|ext| ext == "json")));
14302        server.join().unwrap();
14303    }
14304
14305    #[test]
14306    fn self_custody_push_refuses_an_unrelated_key_before_requesting_an_upload() {
14307        let remote = signed_remote_fixture();
14308        let unrelated = signed_remote_fixture().key;
14309        let (hub, server) = scripted_json_hub(vec![(200, remote.card), (200, remote.feed)]);
14310        let state = tempfile::tempdir().unwrap();
14311        let mut cfg = test_hub_config(hub, state.path().to_path_buf());
14312        cfg.brain_key = Some(unrelated);
14313        let error = sync_push(
14314            &cfg,
14315            TEST_BRAIN_ID,
14316            &[("DB.md".to_string(), "signed local content".to_string())],
14317        )
14318        .unwrap_err()
14319        .to_string();
14320        assert!(
14321            error.contains("not the verified current brain identity"),
14322            "{error}"
14323        );
14324        server.join().unwrap();
14325    }
14326
14327    #[test]
14328    fn rotation_ignores_a_forged_2xx_body_and_requires_verified_postcondition() {
14329        let remote = signed_remote_fixture();
14330        let new = signed_remote_fixture().key;
14331        let state = tempfile::tempdir().unwrap();
14332        let new_file = state.path().join("new.key");
14333        std::fs::write(
14334            &new_file,
14335            format!("{}\n", URL_SAFE_NO_PAD.encode(&new.pkcs8)),
14336        )
14337        .unwrap();
14338        #[cfg(unix)]
14339        {
14340            use std::os::unix::fs::PermissionsExt as _;
14341            std::fs::set_permissions(&new_file, std::fs::Permissions::from_mode(0o600)).unwrap();
14342        }
14343        let forged = json!({
14344            "brain": TEST_BRAIN_ID,
14345            "identity": {
14346                "fingerprint": new.multikey.trim_start_matches("ed25519:"),
14347                "publicKeySpki": new.public_key_spki,
14348            }
14349        })
14350        .to_string();
14351        let (hub, server) = scripted_json_hub(vec![
14352            (200, remote.card.clone()),
14353            (200, remote.feed.clone()),
14354            (200, forged),
14355            (200, remote.card),
14356            (200, remote.feed),
14357        ]);
14358        let cfg = test_hub_config(hub, state.path().to_path_buf());
14359        let error = rotate_brain_key(&cfg, TEST_BRAIN_ID, &remote.key, &new_file)
14360            .unwrap_err()
14361            .to_string();
14362        assert!(
14363            error.contains("without committing the verified new identity"),
14364            "{error}"
14365        );
14366        server.join().unwrap();
14367    }
14368
14369    #[test]
14370    fn resolve_record_is_derived_from_signed_pack_not_a_query_response() {
14371        let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
14372        let raw = format!(
14373            "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
14374        );
14375        let pack = build_store_pack(&[
14376            (
14377                "DB.md".to_string(),
14378                "---\ntype: db-md\nscope: company\nowner: Test\n---\n".to_string(),
14379            ),
14380            ("records/clients/truth.md".to_string(), raw.clone()),
14381        ])
14382        .unwrap();
14383        let by_id = resolve_from_verified_pack(
14384            "01j5qc3v9k4ym8rwbn2tqe6f7d",
14385            &AddressTarget::Id(record_id.to_string()),
14386            pack.clone(),
14387        )
14388        .unwrap();
14389        assert_eq!(by_id["document"]["summary"], "Signed truth");
14390        assert_eq!(by_id["document"]["body"], "# Signed truth\n");
14391        assert_eq!(
14392            by_id["document"]["contentSha"],
14393            content_sha256(raw.as_bytes())
14394        );
14395
14396        let by_path = resolve_from_verified_pack(
14397            "01j5qc3v9k4ym8rwbn2tqe6f7d",
14398            &AddressTarget::Path("records/clients/truth.md".to_string()),
14399            pack,
14400        )
14401        .unwrap();
14402        assert_eq!(by_path["document"]["id"], record_id);
14403        assert_eq!(by_path["document"]["path"], "records/clients/truth.md");
14404    }
14405
14406    #[test]
14407    fn canonical_store_pack_matches_the_cross_language_zip32_golden() {
14408        let unsorted = vec![
14409            ("records/a.md".to_string(), "alpha\n".to_string()),
14410            ("DB.md".to_string(), "# db\n".to_string()),
14411        ];
14412        let sorted = vec![
14413            ("DB.md".to_string(), "# db\n".to_string()),
14414            ("records/a.md".to_string(), "alpha\n".to_string()),
14415        ];
14416        let pack = build_store_pack(&unsorted).unwrap();
14417
14418        // This digest is shared with the hub implementation. It locks every
14419        // byte of the wire profile: raw UTF-8 order, STORED payloads, fixed DOS
14420        // epoch, explicit CRC/sizes, Unix regular-0600 attributes, and the
14421        // absence of descriptors/extras/comments/ZIP64.
14422        assert_eq!(pack.len(), 219);
14423        assert_eq!(
14424            content_sha256(&pack),
14425            "972fb2045becaa21588baaf4b349e62a430687fa2c21167b53f4ca0efa6c9408"
14426        );
14427        assert_eq!(pack, build_store_pack(&sorted).unwrap());
14428        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x07\x08"));
14429        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x06"));
14430        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x07"));
14431
14432        assert_eq!(
14433            parse_store_pack(pack).unwrap(),
14434            vec![
14435                ("DB.md".to_string(), b"# db\n".to_vec()),
14436                ("records/a.md".to_string(), b"alpha\n".to_vec()),
14437            ]
14438        );
14439    }
14440
14441    #[test]
14442    fn canonical_store_pack_validates_every_path_before_writing() {
14443        let duplicate = vec![
14444            ("DB.md".to_string(), "first".to_string()),
14445            ("DB.md".to_string(), "second".to_string()),
14446        ];
14447        assert!(build_store_pack(&duplicate)
14448            .unwrap_err()
14449            .to_string()
14450            .contains("duplicate path"));
14451        assert!(matches!(
14452            build_store_pack(&[("../escape.md".to_string(), "no".to_string())]),
14453            Err(LinkError::UnsafePath { .. })
14454        ));
14455    }
14456
14457    #[test]
14458    fn zip64_preflight_rejects_an_entry_count_bomb_before_zip_parsing() {
14459        const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
14460        // One byte stands in for the central directory. The trailer itself is
14461        // structurally valid; the count is the sole reason for refusal.
14462        let mut bytes = vec![0_u8];
14463        let zip64_offset = bytes.len() as u64;
14464        bytes.extend_from_slice(b"PK\x06\x06");
14465        bytes.extend_from_slice(&44_u64.to_le_bytes());
14466        bytes.extend_from_slice(&[0_u8; 12]);
14467        bytes.extend_from_slice(&COUNT.to_le_bytes());
14468        bytes.extend_from_slice(&COUNT.to_le_bytes());
14469        bytes.extend_from_slice(&1_u64.to_le_bytes());
14470        bytes.extend_from_slice(&0_u64.to_le_bytes());
14471        bytes.extend_from_slice(b"PK\x06\x07");
14472        bytes.extend_from_slice(&0_u32.to_le_bytes());
14473        bytes.extend_from_slice(&zip64_offset.to_le_bytes());
14474        bytes.extend_from_slice(&1_u32.to_le_bytes());
14475        bytes.extend_from_slice(b"PK\x05\x06");
14476        bytes.extend_from_slice(&0_u16.to_le_bytes());
14477        bytes.extend_from_slice(&0_u16.to_le_bytes());
14478        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
14479        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
14480        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
14481        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
14482        bytes.extend_from_slice(&0_u16.to_le_bytes());
14483
14484        let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
14485            .unwrap_err()
14486            .to_string();
14487        assert!(error.contains("invalid file count"), "{error}");
14488    }
14489
14490    #[test]
14491    fn zip_preflight_rejects_a_fake_trailing_eocd_before_zip_parsing() {
14492        const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
14493        let mut bytes = vec![0_u8];
14494        let zip64_offset = bytes.len() as u64;
14495        bytes.extend_from_slice(b"PK\x06\x06");
14496        bytes.extend_from_slice(&44_u64.to_le_bytes());
14497        bytes.extend_from_slice(&[0_u8; 12]);
14498        bytes.extend_from_slice(&COUNT.to_le_bytes());
14499        bytes.extend_from_slice(&COUNT.to_le_bytes());
14500        bytes.extend_from_slice(&1_u64.to_le_bytes());
14501        bytes.extend_from_slice(&0_u64.to_le_bytes());
14502        bytes.extend_from_slice(b"PK\x06\x07");
14503        bytes.extend_from_slice(&0_u32.to_le_bytes());
14504        bytes.extend_from_slice(&zip64_offset.to_le_bytes());
14505        bytes.extend_from_slice(&1_u32.to_le_bytes());
14506        bytes.extend_from_slice(b"PK\x05\x06");
14507        bytes.extend_from_slice(&0_u16.to_le_bytes());
14508        bytes.extend_from_slice(&0_u16.to_le_bytes());
14509        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
14510        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
14511        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
14512        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
14513        bytes.extend_from_slice(&0_u16.to_le_bytes());
14514        // The old parser trusted this last low-count signature, while
14515        // ZipArchive fell back to the real Zip64 directory and allocated for
14516        // COUNT entries. Its central-directory offsets are deliberately fake.
14517        let fake_eocd = bytes.len() as u32;
14518        bytes.extend_from_slice(b"PK\x05\x06");
14519        bytes.extend_from_slice(&0_u16.to_le_bytes());
14520        bytes.extend_from_slice(&0_u16.to_le_bytes());
14521        bytes.extend_from_slice(&1_u16.to_le_bytes());
14522        bytes.extend_from_slice(&1_u16.to_le_bytes());
14523        bytes.extend_from_slice(&0_u32.to_le_bytes());
14524        bytes.extend_from_slice(&fake_eocd.to_le_bytes());
14525        bytes.extend_from_slice(&0_u16.to_le_bytes());
14526
14527        let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
14528            .unwrap_err()
14529            .to_string();
14530        assert!(error.contains("central directory"), "{error}");
14531    }
14532
14533    #[test]
14534    fn strict_http_status_handling_rejects_redirects_without_panicking() {
14535        let error = ensure_ok(
14536            HubResponse {
14537                status: 302,
14538                body: Some(json!({"redirect": "/elsewhere"})),
14539            },
14540            "mutation",
14541        )
14542        .unwrap_err();
14543        assert!(matches!(error, LinkError::Http { status: 302, .. }));
14544
14545        let error = ensure_raw_ok(
14546            RawHubResponse {
14547                status: 302,
14548                body: br#"{"redirect":"/elsewhere"}"#.to_vec(),
14549            },
14550            "feed",
14551        )
14552        .unwrap_err();
14553        assert!(matches!(error, LinkError::Http { status: 302, .. }));
14554    }
14555
14556    #[cfg(unix)]
14557    #[test]
14558    fn collect_push_files_refuses_external_symlink_and_nested_store() {
14559        use std::os::unix::fs::symlink;
14560
14561        let root = tempfile::tempdir().unwrap();
14562        std::fs::write(
14563            root.path().join("DB.md"),
14564            "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
14565        )
14566        .unwrap();
14567        std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
14568
14569        let external = tempfile::tempdir().unwrap();
14570        let secret = external.path().join("secret.md");
14571        std::fs::write(&secret, "TOP SECRET").unwrap();
14572        symlink(&secret, root.path().join("records/notes/secret.md")).unwrap();
14573
14574        let store = Store::open_strict(root.path()).unwrap();
14575        let err = collect_push_files(&store).unwrap_err().to_string();
14576        assert!(err.contains("cannot push"), "{err}");
14577        assert!(
14578            !err.contains("TOP SECRET"),
14579            "external bytes must never leak"
14580        );
14581
14582        std::fs::remove_file(root.path().join("records/notes/secret.md")).unwrap();
14583        let nested = root.path().join("records/nested");
14584        std::fs::create_dir_all(&nested).unwrap();
14585        std::fs::write(
14586            nested.join("DB.md"),
14587            "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
14588        )
14589        .unwrap();
14590        let err = collect_push_files(&store).unwrap_err().to_string();
14591        assert!(err.contains("nested db.md store"), "{err}");
14592    }
14593
14594    #[cfg(unix)]
14595    #[test]
14596    fn remote_push_uses_opened_root_after_path_replacement() {
14597        use std::os::unix::fs::symlink;
14598
14599        let sandbox = tempfile::tempdir().unwrap();
14600        let root = sandbox.path().join("store");
14601        std::fs::create_dir_all(root.join("records/notes")).unwrap();
14602        std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
14603        std::fs::write(
14604            root.join("records/notes/owned.md"),
14605            "---\ntype: note\nsummary: owned\n---\nowned upload\n",
14606        )
14607        .unwrap();
14608        let store = Store::open_strict(&root).unwrap();
14609        let detached = sandbox.path().join("detached");
14610        std::fs::rename(&root, &detached).unwrap();
14611
14612        let replacement = sandbox.path().join("replacement");
14613        std::fs::create_dir_all(replacement.join("records/notes")).unwrap();
14614        std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
14615        std::fs::write(
14616            replacement.join("records/notes/secret.md"),
14617            "---\ntype: note\nsummary: secret\n---\nreplacement sentinel\n",
14618        )
14619        .unwrap();
14620        symlink(&replacement, &root).unwrap();
14621
14622        let files = collect_push_files(&store).unwrap();
14623        let wire_text = files
14624            .iter()
14625            .map(|(path, content)| format!("{path}\n{content}"))
14626            .collect::<Vec<_>>()
14627            .join("\n");
14628        assert!(wire_text.contains("owned upload"));
14629        assert!(!wire_text.contains("replacement sentinel"));
14630        assert!(!wire_text.contains("records/notes/secret.md"));
14631
14632        let remote = signed_remote_fixture();
14633        let (hub, server) = scripted_json_hub(vec![
14634            (200, remote.card),
14635            (200, remote.feed),
14636            (200, json!({"ok": true}).to_string()),
14637        ]);
14638        let state = tempfile::tempdir().unwrap();
14639        let cfg = test_hub_config(hub, state.path().to_path_buf());
14640        let pushed = sync_push(&cfg, TEST_BRAIN_ID, &files).unwrap();
14641        assert_eq!(pushed, json!({"ok": true}));
14642        server.join().unwrap();
14643    }
14644
14645    #[test]
14646    fn signed_feed_item_verifies_identity_hash_and_signature() {
14647        use ring::rand::SystemRandom;
14648        use ring::signature::{Ed25519KeyPair, KeyPair};
14649
14650        const PREFIX: &[u8] = &[
14651            0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
14652        ];
14653        let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
14654        let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
14655        let mut spki = PREFIX.to_vec();
14656        spki.extend_from_slice(pair.public_key().as_ref());
14657        let public_key = URL_SAFE_NO_PAD.encode(&spki);
14658        let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
14659        let mut entry = FeedEntry {
14660            v: 1,
14661            seq: 1,
14662            ts: "2026-07-14T00:00:00.000Z".to_string(),
14663            brain: format!("ed25519:{fingerprint}"),
14664            public_key: public_key.clone(),
14665            kind: "push".to_string(),
14666            op: "snapshot".to_string(),
14667            pack_sha256: "a".repeat(64),
14668            files: vec![FeedFile {
14669                path: "DB.md".to_string(),
14670                sha256: "b".repeat(64),
14671                bytes: 3,
14672            }],
14673            removed: vec![],
14674            prev_entry_hash: None,
14675            sig: String::new(),
14676        };
14677        let unsigned = UnsignedFeedEntry {
14678            v: entry.v,
14679            seq: entry.seq,
14680            ts: &entry.ts,
14681            brain: &entry.brain,
14682            public_key: &entry.public_key,
14683            kind: &entry.kind,
14684            op: &entry.op,
14685            pack_sha256: &entry.pack_sha256,
14686            files: &entry.files,
14687            removed: &entry.removed,
14688            prev_entry_hash: &entry.prev_entry_hash,
14689        };
14690        entry.sig =
14691            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
14692        let mut exact = serde_json::to_vec(&entry).unwrap();
14693        exact.push(b'\n');
14694        let item = FeedItem {
14695            hash: format!("{:x}", Sha256::digest(&exact)),
14696            entry,
14697        };
14698        let identity = FeedIdentity {
14699            fingerprint,
14700            public_key_spki: public_key,
14701            previous: Vec::new(),
14702            rotations: Vec::new(),
14703        };
14704        assert!(verify_feed_item(&item, &identity).is_ok());
14705        let mut tampered = item;
14706        tampered.entry.pack_sha256 = "c".repeat(64);
14707        assert!(verify_feed_item(&tampered, &identity).is_err());
14708    }
14709
14710    #[test]
14711    fn v2_commit_requires_exact_fields_and_a_valid_genesis_bridge() {
14712        let rng = ring::rand::SystemRandom::new();
14713        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
14714        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
14715        let (spki, multikey) = public_identity_for(&pair);
14716        let identity = V2HeadIdentity {
14717            custody: "self".to_string(),
14718            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
14719            public_key_spki: spki.clone(),
14720            previous: Vec::new(),
14721            rotations: Vec::new(),
14722        };
14723        let unsigned = json!({
14724            "actor_ref": "a".repeat(64),
14725            "asset_root": Value::Null,
14726            "brain": multikey,
14727            "changes_sha256": "b".repeat(64),
14728            "control_revision": "c".repeat(64),
14729            "materializer": "dbmd-projection-v1",
14730            "op": "changeset",
14731            "parent_asset_root": Value::Null,
14732            "parent_commit": Value::Null,
14733            "parent_root": Value::Null,
14734            "prev_entry_hash": Value::Null,
14735            "public_key": spki,
14736            "seq": 1,
14737            "signer_epoch": 1,
14738            "state_root": "d".repeat(64),
14739            "ts": "2026-08-19T12:00:00.000Z",
14740            "v": 2,
14741            "v1_bridge": {
14742                "feed_hash": "e".repeat(64),
14743                "head_seq": 7,
14744                "pack_sha256": "f".repeat(64),
14745            },
14746        });
14747        let sign_value = |value: Value| {
14748            let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
14749            let mut object = value.as_object().unwrap().clone();
14750            object.insert(
14751                "sig".to_string(),
14752                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
14753            );
14754            crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
14755        };
14756        assert!(verified_v2_commit_object(&sign_value(unsigned.clone()), &identity).is_ok());
14757
14758        let mut extra = unsigned.clone();
14759        extra
14760            .as_object_mut()
14761            .unwrap()
14762            .insert("future".to_string(), Value::Bool(true));
14763        assert!(verified_v2_commit_object(&sign_value(extra), &identity).is_err());
14764
14765        let mut missing = unsigned.clone();
14766        missing.as_object_mut().unwrap().remove("v1_bridge");
14767        assert!(verified_v2_commit_object(&sign_value(missing), &identity).is_err());
14768
14769        let mut invalid_bridge = unsigned;
14770        invalid_bridge.as_object_mut().unwrap().insert(
14771            "v1_bridge".to_string(),
14772            json!({"feed_hash": "e".repeat(64), "head_seq": 0, "pack_sha256": "f".repeat(64)}),
14773        );
14774        assert!(verified_v2_commit_object(&sign_value(invalid_bridge), &identity).is_err());
14775    }
14776
14777    #[test]
14778    fn shared_v2_commit_bridge_vector_matches_the_typescript_signer() {
14779        let vector: Value = serde_json::from_str(include_str!(
14780            "../tests/vectors/linkmd-v2-commit-bridge.json"
14781        ))
14782        .unwrap();
14783        let identity_value = vector.get("identity").unwrap();
14784        let identity = V2HeadIdentity {
14785            custody: "self".to_string(),
14786            fingerprint: identity_value
14787                .get("fingerprint")
14788                .and_then(Value::as_str)
14789                .unwrap()
14790                .to_string(),
14791            public_key_spki: identity_value
14792                .get("public_key_spki")
14793                .and_then(Value::as_str)
14794                .unwrap()
14795                .to_string(),
14796            previous: Vec::new(),
14797            rotations: Vec::new(),
14798        };
14799        let private = URL_SAFE_NO_PAD
14800            .decode(
14801                identity_value
14802                    .get("private_key_pkcs8")
14803                    .and_then(Value::as_str)
14804                    .unwrap(),
14805            )
14806            .unwrap();
14807        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&private)
14808            .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(&private))
14809            .unwrap();
14810        let base = vector.get("body").unwrap().as_object().unwrap();
14811
14812        for item in vector.get("valid").unwrap().as_array().unwrap() {
14813            let mut body = base.clone();
14814            body.insert(
14815                "v1_bridge".to_string(),
14816                item.get("v1_bridge").unwrap().clone(),
14817            );
14818            body.insert(
14819                "sig".to_string(),
14820                item.get("signature_base64url").unwrap().clone(),
14821            );
14822            let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
14823            assert!(verified_v2_commit_object(&signed, &identity).is_ok());
14824            assert_eq!(
14825                crate::linkmd_v2::domain_hash_bytes("v2/commit", &signed).unwrap(),
14826                item.get("commit_hash").and_then(Value::as_str).unwrap()
14827            );
14828            assert_eq!(
14829                format!("{:x}", Sha256::digest(&signed)),
14830                item.get("feed_hash").and_then(Value::as_str).unwrap()
14831            );
14832        }
14833
14834        for item in vector.get("invalid").unwrap().as_array().unwrap() {
14835            let mut body = base.clone();
14836            if let Some(remove) = item.get("remove").and_then(Value::as_array) {
14837                for field in remove {
14838                    body.remove(field.as_str().unwrap());
14839                }
14840            }
14841            if let Some(set) = item.get("set").and_then(Value::as_object) {
14842                for (field, value) in set {
14843                    body.insert(field.clone(), value.clone());
14844                }
14845            }
14846            let message = crate::linkmd_v2::canonical_bytes(&Value::Object(body.clone())).unwrap();
14847            body.insert(
14848                "sig".to_string(),
14849                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
14850            );
14851            let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
14852            assert!(
14853                verified_v2_commit_object(&signed, &identity).is_err(),
14854                "accepted invalid shared vector {}",
14855                item.get("reason").and_then(Value::as_str).unwrap()
14856            );
14857        }
14858    }
14859
14860    #[test]
14861    fn shared_v2_kept_home_changeset_vector_matches_the_typescript_hub() {
14862        let vector: Value = serde_json::from_str(include_str!(
14863            "../tests/vectors/linkmd-v2-changeset-withheld.json"
14864        ))
14865        .unwrap();
14866        assert_eq!(
14867            vector.get("profile").and_then(Value::as_str),
14868            Some("link.md-v2-changeset-withheld")
14869        );
14870        let canonical =
14871            crate::linkmd_v2::canonical_bytes(vector.get("changeset").unwrap()).unwrap();
14872        let expected = STANDARD
14873            .decode(
14874                vector
14875                    .get("canonical_base64")
14876                    .and_then(Value::as_str)
14877                    .unwrap(),
14878            )
14879            .unwrap();
14880        assert_eq!(canonical, expected);
14881        assert_eq!(
14882            crate::linkmd_v2::domain_hash_bytes("v2/changeset", &canonical).unwrap(),
14883            vector.get("domain_hash").and_then(Value::as_str).unwrap()
14884        );
14885    }
14886
14887    #[test]
14888    fn v2_profile_transition_reverifies_the_exact_signed_v1_bridge() {
14889        let remote = signed_remote_fixture();
14890        let legacy: FeedResponse = serde_json::from_str(&remote.feed).unwrap();
14891        let legacy_item = legacy.entries.first().unwrap();
14892        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&remote.key.pkcs8).unwrap();
14893        let body = json!({
14894            "actor_ref": "a".repeat(64),
14895            "asset_root": Value::Null,
14896            "brain": remote.key.multikey,
14897            "changes_sha256": "b".repeat(64),
14898            "control_revision": "c".repeat(64),
14899            "materializer": "dbmd-projection-v1",
14900            "op": "changeset",
14901            "parent_asset_root": Value::Null,
14902            "parent_commit": Value::Null,
14903            "parent_root": Value::Null,
14904            "prev_entry_hash": Value::Null,
14905            "public_key": remote.key.public_key_spki,
14906            "seq": 1,
14907            "signer_epoch": 1,
14908            "state_root": "d".repeat(64),
14909            "ts": "2026-08-19T12:00:00.000Z",
14910            "v": 2,
14911            "v1_bridge": {
14912                "feed_hash": legacy_item.hash,
14913                "head_seq": legacy_item.entry.seq,
14914                "pack_sha256": legacy_item.entry.pack_sha256,
14915            },
14916        });
14917        let message = crate::linkmd_v2::canonical_bytes(&body).unwrap();
14918        let mut signed = body.as_object().unwrap().clone();
14919        signed.insert(
14920            "sig".to_string(),
14921            Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
14922        );
14923        let raw = crate::linkmd_v2::canonical_bytes(&Value::Object(signed)).unwrap();
14924        let commit_hash = crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw).unwrap();
14925        let feed_hash = content_sha256(&raw);
14926        let pointer = V2PointerBody {
14927            v: 2,
14928            brain: TEST_BRAIN_ID.to_string(),
14929            seq: 1,
14930            commit_hash: commit_hash.clone(),
14931            feed_hash: feed_hash.clone(),
14932            content_root: Some("d".repeat(64)),
14933            asset_root: None,
14934            materializer: "dbmd-projection-v1".to_string(),
14935            signer_epoch: 1,
14936            control_revision: "c".repeat(64),
14937            backup_preparation: "e".repeat(64),
14938            prior_pointer_hash: None,
14939            signed_at: "2026-08-19T12:00:00.000Z".to_string(),
14940        };
14941        let v2_page = json!({
14942            "v": 2,
14943            "head_seq": 1,
14944            "head_commit_hash": commit_hash,
14945            "head_feed_hash": feed_hash,
14946            "entries": [{
14947                "seq": 1,
14948                "commit_hash": pointer.commit_hash,
14949                "feed_hash": pointer.feed_hash,
14950                "bytes_base64": STANDARD.encode(&raw),
14951            }],
14952            "next_after": 1,
14953            "complete": true,
14954        })
14955        .to_string();
14956        let identity = V2HeadIdentity {
14957            custody: "self".to_string(),
14958            fingerprint: remote.identity.fingerprint.clone(),
14959            public_key_spki: remote.identity.public_key_spki.clone(),
14960            previous: Vec::new(),
14961            rotations: Vec::new(),
14962        };
14963        let checkpoint = TrustState {
14964            v: 2,
14965            origin: "unused".to_string(),
14966            requested: TEST_BRAIN_ID.to_string(),
14967            brain: TEST_BRAIN_ID.to_string(),
14968            home: None,
14969            anchor: remote.key.multikey.clone(),
14970            current: remote.key.multikey,
14971            head_seq: legacy_item.entry.seq,
14972            feed_hash: Some(legacy_item.hash.clone()),
14973            rotations: Vec::new(),
14974            hub_signer: None,
14975            protocol_profile: None,
14976        };
14977        let (hub, server) = scripted_json_hub(vec![(200, v2_page), (200, remote.feed)]);
14978        let state = tempfile::tempdir().unwrap();
14979        let cfg = test_hub_config(hub, state.path().to_path_buf());
14980        verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &checkpoint).unwrap();
14981        server.join().unwrap();
14982
14983        let mut wrong = checkpoint;
14984        wrong.feed_hash = Some("0".repeat(64));
14985        let (hub, server) = scripted_json_hub(vec![(
14986            200,
14987            json!({
14988                "v": 2,
14989                "head_seq": 1,
14990                "head_commit_hash": pointer.commit_hash,
14991                "head_feed_hash": pointer.feed_hash,
14992                "entries": [{
14993                    "seq": 1,
14994                    "commit_hash": pointer.commit_hash,
14995                    "feed_hash": pointer.feed_hash,
14996                    "bytes_base64": STANDARD.encode(&raw),
14997                }],
14998                "next_after": 1,
14999                "complete": true,
15000            })
15001            .to_string(),
15002        )]);
15003        let state = tempfile::tempdir().unwrap();
15004        let cfg = test_hub_config(hub, state.path().to_path_buf());
15005        assert!(verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &wrong,).is_err());
15006        server.join().unwrap();
15007    }
15008
15009    #[test]
15010    fn v2_commit_signer_epoch_follows_the_authenticated_rotation_boundary() {
15011        let rng = ring::rand::SystemRandom::new();
15012        let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15013        let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
15014        let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15015        let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
15016        let (old_spki, old_multikey) = public_identity_for(&old);
15017        let (new_spki, new_multikey) = public_identity_for(&new);
15018        let rotation_unsigned = serde_json::to_string(&UnsignedRotation {
15019            v: 1,
15020            op: "rotate",
15021            brain: &old_multikey,
15022            public_key: &old_spki,
15023            new_brain: &new_multikey,
15024            new_public_key: &new_spki,
15025            prior_head_seq: 1,
15026            prior_feed_hash: Some(&"9".repeat(64)),
15027            ts: "2026-08-19T12:01:00.000Z".to_string(),
15028        })
15029        .unwrap();
15030        let rotation_sig = URL_SAFE_NO_PAD.encode(old.sign(rotation_unsigned.as_bytes()).as_ref());
15031        let rotation = format!(
15032            "{},\"sig\":\"{}\"}}",
15033            &rotation_unsigned[..rotation_unsigned.len() - 1],
15034            rotation_sig
15035        );
15036        let identity = V2HeadIdentity {
15037            custody: "self".to_string(),
15038            fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
15039            public_key_spki: new_spki.clone(),
15040            previous: vec![V2PreviousIdentity {
15041                fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
15042                public_key_spki: old_spki.clone(),
15043            }],
15044            rotations: vec![rotation],
15045        };
15046        let commit = |seq: u64,
15047                      epoch: u64,
15048                      multikey: &str,
15049                      spki: &str,
15050                      pair: &ring::signature::Ed25519KeyPair| {
15051            let value = json!({
15052                "actor_ref": "a".repeat(64),
15053                "asset_root": Value::Null,
15054                "brain": multikey,
15055                "changes_sha256": "b".repeat(64),
15056                "control_revision": "c".repeat(64),
15057                "materializer": "dbmd-projection-v1",
15058                "op": "changeset",
15059                "parent_asset_root": Value::Null,
15060                "parent_commit": if seq == 1 { Value::Null } else { Value::String("d".repeat(64)) },
15061                "parent_root": if seq == 1 { Value::Null } else { Value::String("e".repeat(64)) },
15062                "prev_entry_hash": if seq == 1 { Value::Null } else { Value::String("f".repeat(64)) },
15063                "public_key": spki,
15064                "seq": seq,
15065                "signer_epoch": epoch,
15066                "state_root": "1".repeat(64),
15067                "ts": "2026-08-19T12:00:00.000Z",
15068                "v": 2,
15069                "v1_bridge": Value::Null,
15070            });
15071            let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
15072            let mut object = value.as_object().unwrap().clone();
15073            object.insert(
15074                "sig".to_string(),
15075                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
15076            );
15077            crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
15078        };
15079
15080        assert!(verified_v2_commit_object(
15081            &commit(1, 1, &old_multikey, &old_spki, &old),
15082            &identity,
15083        )
15084        .is_ok());
15085        assert!(verified_v2_commit_object(
15086            &commit(2, 2, &new_multikey, &new_spki, &new),
15087            &identity,
15088        )
15089        .is_ok());
15090        assert!(verified_v2_commit_object(
15091            &commit(2, 1, &old_multikey, &old_spki, &old),
15092            &identity,
15093        )
15094        .is_err());
15095        assert!(verified_v2_commit_object(
15096            &commit(1, 2, &new_multikey, &new_spki, &new),
15097            &identity,
15098        )
15099        .is_err());
15100    }
15101
15102    #[test]
15103    fn a_self_custody_entry_verifies_like_any_hub_entry() {
15104        let rng = ring::rand::SystemRandom::new();
15105        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15106        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15107        let (spki, multikey) = public_identity_for(&pair);
15108        let key = AgentSigningKey {
15109            pkcs8: pkcs8.as_ref().to_vec(),
15110            multikey: multikey.clone(),
15111            public_key_spki: spki.clone(),
15112        };
15113        let files = vec![WireFeedFile {
15114            path: "DB.md".to_string(),
15115            sha256: "a".repeat(64),
15116            bytes: 3,
15117        }];
15118        let raw = self_custody_entry(
15119            &key,
15120            1,
15121            "2026-07-23T12:00:00.000Z".to_string(),
15122            &"c".repeat(64),
15123            &files,
15124            None,
15125        )
15126        .unwrap();
15127        // The exact client serialization parses as a feed entry and passes the
15128        // SAME verifier every subscribe read runs — the self-custody path
15129        // produces first-class wire-profile-v1 entries.
15130        let entry: FeedEntry = serde_json::from_str(&raw).unwrap();
15131        let hash = format!("{:x}", Sha256::digest(format!("{raw}\n").as_bytes()));
15132        let item = FeedItem { hash, entry };
15133        let identity = FeedIdentity {
15134            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
15135            public_key_spki: spki,
15136            previous: Vec::new(),
15137            rotations: Vec::new(),
15138        };
15139        assert!(verify_feed_item(&item, &identity).is_ok());
15140    }
15141
15142    #[test]
15143    fn identity_rotation_requires_old_key_signature_and_preserves_the_pin() {
15144        let rng = ring::rand::SystemRandom::new();
15145        let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15146        let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
15147        let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15148        let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
15149        let (old_spki, old_multikey) = public_identity_for(&old);
15150        let (new_spki, new_multikey) = public_identity_for(&new);
15151        let unsigned = serde_json::to_string(&UnsignedRotation {
15152            v: 1,
15153            op: "rotate",
15154            brain: &old_multikey,
15155            public_key: &old_spki,
15156            new_brain: &new_multikey,
15157            new_public_key: &new_spki,
15158            prior_head_seq: 1,
15159            prior_feed_hash: Some(&"a".repeat(64)),
15160            ts: "2026-07-30T12:00:00.000Z".to_string(),
15161        })
15162        .unwrap();
15163        let signature = URL_SAFE_NO_PAD.encode(old.sign(unsigned.as_bytes()).as_ref());
15164        let rotation = format!(
15165            "{},\"sig\":\"{}\"}}",
15166            &unsigned[..unsigned.len() - 1],
15167            signature
15168        );
15169        let identity = FeedIdentity {
15170            fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
15171            public_key_spki: new_spki,
15172            previous: vec![PreviousIdentity {
15173                fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
15174                public_key_spki: old_spki,
15175            }],
15176            rotations: vec![rotation],
15177        };
15178        let pin = TrustState {
15179            v: 2,
15180            origin: "https://hub.example".to_string(),
15181            requested: "brain".to_string(),
15182            brain: "brain".to_string(),
15183            home: None,
15184            anchor: old_multikey.clone(),
15185            current: old_multikey.clone(),
15186            head_seq: 1,
15187            feed_hash: Some("a".repeat(64)),
15188            rotations: Vec::new(),
15189            hub_signer: None,
15190            protocol_profile: None,
15191        };
15192        assert_eq!(
15193            verify_identity_chain(&identity, Some(&pin)).unwrap(),
15194            old_multikey
15195        );
15196        let mut accepted = pin.clone();
15197        accepted.current = new_multikey.clone();
15198        accepted.rotations = identity.rotations.clone();
15199        let alternate_unsigned = serde_json::to_string(&UnsignedRotation {
15200            v: 1,
15201            op: "rotate",
15202            brain: &old_multikey,
15203            public_key: &identity.previous[0].public_key_spki,
15204            new_brain: &new_multikey,
15205            new_public_key: &identity.public_key_spki,
15206            prior_head_seq: 1,
15207            prior_feed_hash: Some(&"a".repeat(64)),
15208            ts: "2026-07-30T12:00:01.000Z".to_string(),
15209        })
15210        .unwrap();
15211        let alternate_signature =
15212            URL_SAFE_NO_PAD.encode(old.sign(alternate_unsigned.as_bytes()).as_ref());
15213        let mut rewritten = identity.clone();
15214        rewritten.rotations[0] = format!(
15215            "{},\"sig\":\"{}\"}}",
15216            &alternate_unsigned[..alternate_unsigned.len() - 1],
15217            alternate_signature
15218        );
15219        assert!(
15220            verify_identity_chain(&rewritten, Some(&accepted)).is_err(),
15221            "an alternate valid statement must not rewrite accepted history"
15222        );
15223
15224        let mut stale_entry = FeedEntry {
15225            v: 1,
15226            seq: 2,
15227            ts: "2026-07-30T12:01:00.000Z".to_string(),
15228            brain: pin.current.clone(),
15229            public_key: identity.previous[0].public_key_spki.clone(),
15230            kind: "push".to_string(),
15231            op: "snapshot".to_string(),
15232            pack_sha256: "b".repeat(64),
15233            files: Vec::new(),
15234            removed: Vec::new(),
15235            prev_entry_hash: pin.feed_hash.clone(),
15236            sig: String::new(),
15237        };
15238        let stale_unsigned = UnsignedFeedEntry {
15239            v: stale_entry.v,
15240            seq: stale_entry.seq,
15241            ts: &stale_entry.ts,
15242            brain: &stale_entry.brain,
15243            public_key: &stale_entry.public_key,
15244            kind: &stale_entry.kind,
15245            op: &stale_entry.op,
15246            pack_sha256: &stale_entry.pack_sha256,
15247            files: &stale_entry.files,
15248            removed: &stale_entry.removed,
15249            prev_entry_hash: &stale_entry.prev_entry_hash,
15250        };
15251        stale_entry.sig = URL_SAFE_NO_PAD.encode(
15252            old.sign(&serde_json::to_vec(&stale_unsigned).unwrap())
15253                .as_ref(),
15254        );
15255        let mut stale_exact = serde_json::to_vec(&stale_entry).unwrap();
15256        stale_exact.push(b'\n');
15257        let stale_item = FeedItem {
15258            hash: content_sha256(&stale_exact),
15259            entry: stale_entry,
15260        };
15261        assert!(
15262            reject_retired_signer_after_checkpoint(&identity, Some(&accepted), &stale_item)
15263                .is_err(),
15264            "a key retired before the checkpoint must never append after it"
15265        );
15266        assert!(
15267            verify_feed_item(&stale_item, &identity).is_err(),
15268            "an old key must never append after its signed rotation boundary"
15269        );
15270
15271        let mut missing = identity.clone();
15272        missing.rotations.clear();
15273        assert!(verify_identity_chain(&missing, Some(&pin)).is_err());
15274
15275        let mut tampered = identity;
15276        tampered.rotations[0] = tampered.rotations[0].replace("\"sig\":\"", "\"sig\":\"A");
15277        assert!(verify_identity_chain(&tampered, Some(&pin)).is_err());
15278    }
15279
15280    #[cfg(unix)]
15281    #[test]
15282    fn key_creation_refuses_a_planted_symlink_without_touching_its_target() {
15283        use std::os::unix::fs::symlink;
15284
15285        let dir = tempfile::tempdir().unwrap();
15286        let target = dir.path().join("valuable.txt");
15287        let planted = dir.path().join("agent.key");
15288        std::fs::write(&target, "do not overwrite").unwrap();
15289        symlink(&target, &planted).unwrap();
15290
15291        assert!(matches!(
15292            generate_agent_key(&planted),
15293            Err(LinkError::BadAgentKey { .. })
15294        ));
15295        assert_eq!(std::fs::read_to_string(target).unwrap(), "do not overwrite");
15296    }
15297
15298    #[cfg(unix)]
15299    #[test]
15300    fn key_creation_refuses_a_symlinked_parent_without_writing_through_it() {
15301        use std::os::unix::fs::symlink;
15302
15303        let root = tempfile::tempdir().unwrap();
15304        let outside = tempfile::tempdir().unwrap();
15305        symlink(outside.path(), root.path().join("redirect")).unwrap();
15306
15307        assert!(generate_agent_key(&root.path().join("redirect/agent.key")).is_err());
15308        assert!(!outside.path().join("agent.key").exists());
15309    }
15310
15311    // ── Address parsing ─────────────────────────────────────────────────────
15312
15313    #[test]
15314    fn address_bare_brain_with_and_without_sigil() {
15315        for raw in ["@acme-ops", "acme-ops"] {
15316            let a = Address::parse(raw).expect(raw);
15317            assert_eq!(a.brain, "acme-ops");
15318            assert_eq!(a.target, None);
15319        }
15320    }
15321
15322    #[test]
15323    fn address_ulid_target_parses_as_id() {
15324        let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
15325        assert_eq!(a.brain, "acme");
15326        assert_eq!(
15327            a.target,
15328            Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
15329        );
15330    }
15331
15332    #[test]
15333    fn address_md_path_target_parses_as_path() {
15334        let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
15335        assert_eq!(
15336            a.target,
15337            Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
15338        );
15339    }
15340
15341    #[test]
15342    fn address_rejects_malformed_forms() {
15343        for raw in [
15344            "",
15345            "@",
15346            "@/x",
15347            "@acme/",
15348            "@acme/../etc/passwd",
15349            "@acme/records/.hidden.md",
15350            "@ACME",             // uppercase is not a hub ref shape
15351            "@acme/notes/x.txt", // target is neither ULID nor .md path
15352            "@a b",              // whitespace
15353        ] {
15354            assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
15355        }
15356    }
15357
15358    // ── Path safety ─────────────────────────────────────────────────────────
15359
15360    #[test]
15361    fn safe_paths_accept_store_shapes_and_reject_escapes() {
15362        for ok in [
15363            "DB.md",
15364            "assets.jsonl",
15365            "records/clients/lumio.md",
15366            "sources/emails/2026/07/x.md",
15367        ] {
15368            assert!(safe_store_rel_path(ok), "should accept {ok:?}");
15369        }
15370        for bad in [
15371            "",
15372            "/etc/passwd",
15373            "../up.md",
15374            "records/../../up.md",
15375            "records//x.md",
15376            ".dbmd/config",
15377            "records/.hidden/x.md",
15378            "records/a b.md",
15379            "records\\win.md",
15380        ] {
15381            assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
15382        }
15383    }
15384
15385    #[cfg(unix)]
15386    #[test]
15387    fn opened_destination_capability_survives_an_ancestor_path_swap() {
15388        use std::os::unix::fs::symlink;
15389
15390        let work = tempfile::tempdir().unwrap();
15391        let outside = tempfile::tempdir().unwrap();
15392        let original = work.path().join("destination");
15393        let moved = work.path().join("destination-moved");
15394        let directory = open_or_create_dir_nofollow(&original).unwrap();
15395
15396        std::fs::rename(&original, &moved).unwrap();
15397        symlink(outside.path(), &original).unwrap();
15398        write_pull_entries_beneath_dir(
15399            &directory,
15400            &[("records/note.md".to_string(), b"held inode".to_vec())],
15401        )
15402        .unwrap();
15403
15404        assert_eq!(
15405            std::fs::read(moved.join("records/note.md")).unwrap(),
15406            b"held inode"
15407        );
15408        assert!(!outside.path().join("records/note.md").exists());
15409    }
15410
15411    // ── Config resolution (flag + file precedence; env is covered by the CLI
15412    //    integration tests, where a child process isolates it) ───────────────
15413
15414    #[test]
15415    fn hub_config_flag_beats_file_and_requires_some_source() {
15416        let dir = tempfile::tempdir().unwrap();
15417        std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
15418        std::fs::write(
15419            dir.path().join(CONFIG_REL_PATH),
15420            "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
15421        )
15422        .unwrap();
15423
15424        let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
15425        assert_eq!(from_flag.hub, "https://flag.example.com");
15426
15427        let from_file = hub_config(None, dir.path()).unwrap();
15428        assert_eq!(from_file.hub, "https://file.example.com");
15429
15430        let none = hub_config(None, tempfile::tempdir().unwrap().path());
15431        assert!(matches!(none, Err(LinkError::NoHub)));
15432    }
15433
15434    #[test]
15435    fn https_guard_allows_loopback_only_for_plain_http() {
15436        assert!(assert_safe_hub("https://hub.example.com").is_ok());
15437        assert!(assert_safe_hub("http://localhost:3000").is_ok());
15438        assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
15439        assert!(assert_safe_hub("http://[::1]:3000").is_ok());
15440        assert!(matches!(
15441            assert_safe_hub("http://hub.example.com"),
15442            Err(LinkError::UnsafeHub { .. })
15443        ));
15444        assert!(matches!(
15445            assert_safe_hub("hub.example.com"),
15446            Err(LinkError::UnsafeHub { .. })
15447        ));
15448        assert!(matches!(
15449            assert_safe_hub("http://localhost:80@127.0.0.1:1"),
15450            Err(LinkError::UnsafeHub { .. })
15451        ));
15452        assert!(matches!(
15453            assert_safe_hub("https://hub.example.com@attacker.example"),
15454            Err(LinkError::UnsafeHub { .. })
15455        ));
15456        assert!(matches!(
15457            assert_safe_hub("https://hub.example.com/base"),
15458            Err(LinkError::UnsafeHub { .. })
15459        ));
15460    }
15461
15462    #[test]
15463    fn registry_ssrf_classifier_rejects_local_private_and_documentation_ranges() {
15464        for blocked in [
15465            "127.0.0.1",
15466            "10.0.0.1",
15467            "100.64.0.1",
15468            "169.254.169.254",
15469            "172.16.0.1",
15470            "192.168.0.1",
15471            "192.88.99.1",
15472            "198.18.0.1",
15473            "203.0.113.1",
15474            "::1",
15475            "fe80::1",
15476            "fd00::1",
15477            "2001:db8::1",
15478            "2001:1::1",
15479            "2002:7f00:1::",
15480            "3fff::1",
15481        ] {
15482            assert!(
15483                !is_public_registry_ip(blocked.parse().unwrap()),
15484                "must block {blocked}"
15485            );
15486        }
15487        assert!(is_public_registry_ip("1.1.1.1".parse().unwrap()));
15488        assert!(is_public_registry_ip(
15489            "2606:4700:4700::1111".parse().unwrap()
15490        ));
15491        assert!(is_public_registry_ip("3fff:1000::1".parse().unwrap()));
15492    }
15493
15494    #[test]
15495    fn registry_dns_answer_is_pinned_and_cannot_rebind_or_change_authority() {
15496        use ureq::Resolver as _;
15497
15498        let pinned: std::net::SocketAddr = "1.1.1.1:443".parse().unwrap();
15499        let resolver = PinnedRegistryResolver {
15500            netloc: "home.example:443".to_string(),
15501            addresses: vec![pinned],
15502        };
15503        assert_eq!(resolver.resolve("home.example:443").unwrap(), vec![pinned]);
15504        assert!(resolver.resolve("127.0.0.1:443").is_err());
15505        assert_eq!(
15506            resolver.resolve("home.example:443").unwrap(),
15507            vec![pinned],
15508            "subsequent connects reuse the validated answer instead of DNS"
15509        );
15510    }
15511
15512    #[test]
15513    fn production_object_urls_and_store_selected_hubs_cannot_reach_private_ips() {
15514        let cfg = HubConfig {
15515            hub: "https://hub.example".to_string(),
15516            key: None,
15517            agent_key: None,
15518            brain_key: None,
15519            state_dir: tempfile::tempdir().unwrap().keep(),
15520            store_selected: false,
15521        };
15522        assert!(
15523            presigned_agent(&cfg, "https://127.0.0.1/private").is_err(),
15524            "a production hub must not turn its presigned URL into an SSRF primitive"
15525        );
15526
15527        let store_selected = HubConfig {
15528            hub: "https://127.0.0.1".to_string(),
15529            store_selected: true,
15530            ..cfg
15531        };
15532        assert!(
15533            hub_agent(&store_selected).is_err(),
15534            "bytes in a cloned store must not select a private-network hub"
15535        );
15536    }
15537
15538    #[test]
15539    fn presigned_download_ceiling_cannot_overflow_at_u64_max() {
15540        assert_eq!(
15541            one_past_bounded_limit(MAX_PACK_BYTES),
15542            Some(MAX_PACK_BYTES + 1),
15543            "the presigned reader consumes exactly one refusal byte beyond its fixed pack cap"
15544        );
15545        assert_eq!(
15546            presigned_download_read_limit(),
15547            MAX_PACK_BYTES + 1,
15548            "the presigned reader is capped by the client constant, not a hub response"
15549        );
15550        assert_eq!(
15551            one_past_bounded_limit(u64::MAX),
15552            None,
15553            "the former attacker-controlled u64::MAX + 1 shape must fail without overflow"
15554        );
15555    }
15556
15557    #[test]
15558    fn https_guard_matches_the_scheme_case_insensitively() {
15559        // RFC 3986 schemes are case-insensitive: an uppercase-scheme HTTPS
15560        // hub is still HTTPS, never a misleading non-HTTPS refusal.
15561        assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
15562        assert!(assert_safe_hub("Https://hub.example.com").is_ok());
15563        // And an uppercase plain-HTTP hub is still refused outside loopback.
15564        assert!(matches!(
15565            assert_safe_hub("HTTP://hub.example.com"),
15566            Err(LinkError::UnsafeHub { .. })
15567        ));
15568    }
15569
15570    #[test]
15571    fn clean_key_refuses_paste_artifacts_without_echoing() {
15572        assert_eq!(clean_key("  vc_account_abc  ").unwrap(), "vc_account_abc");
15573        for bad in ["vc account", "vc\naccount", "ключ", ""] {
15574            let err = clean_key(bad).unwrap_err();
15575            assert!(matches!(err, LinkError::BadKey));
15576            assert!(
15577                !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
15578                "error must not echo the key"
15579            );
15580        }
15581    }
15582
15583    // ── Verb entry gates: refs must never reshape the request path ──────────
15584
15585    /// A config whose hub passes the loopback guard but is never listened on:
15586    /// every refusal below must come from the entry gate BEFORE a request
15587    /// exists — a dial on this dead port would surface `Transport` instead.
15588    fn dead_hub() -> HubConfig {
15589        HubConfig {
15590            hub: "http://127.0.0.1:9".to_string(),
15591            key: Some("k".to_string()),
15592            agent_key: None,
15593            brain_key: None,
15594            state_dir: PathBuf::from("."),
15595            store_selected: false,
15596        }
15597    }
15598
15599    #[test]
15600    fn request_retries_a_connection_failure_before_sending() {
15601        use std::io::{Read as _, Write as _};
15602        use std::net::TcpListener;
15603        use std::thread;
15604        use std::time::Duration;
15605
15606        let probe = TcpListener::bind("127.0.0.1:0").unwrap();
15607        let address = probe.local_addr().unwrap();
15608        drop(probe);
15609        let server = thread::spawn(move || {
15610            thread::sleep(Duration::from_millis(40));
15611            let listener = TcpListener::bind(address).unwrap();
15612            let (mut stream, _) = listener.accept().unwrap();
15613            let mut request_bytes = [0_u8; 1024];
15614            let _ = stream.read(&mut request_bytes).unwrap();
15615            stream
15616                .write_all(
15617                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
15618                )
15619                .unwrap();
15620        });
15621        let cfg = HubConfig {
15622            hub: format!("http://{address}"),
15623            key: None,
15624            agent_key: None,
15625            brain_key: None,
15626            state_dir: tempfile::tempdir().unwrap().keep(),
15627            store_selected: false,
15628        };
15629
15630        let response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
15631        assert_eq!(response.status, 200);
15632        assert_eq!(response.body, Some(json!({ "ok": true })));
15633        server.join().unwrap();
15634    }
15635
15636    #[test]
15637    fn endpoint_cap_refuses_a_body_before_json_parsing() {
15638        let (hub, server) = scripted_json_hub(vec![(200, "x".repeat(2_048))]);
15639        let cfg = HubConfig {
15640            hub,
15641            key: None,
15642            agent_key: None,
15643            brain_key: None,
15644            state_dir: tempfile::tempdir().unwrap().keep(),
15645            store_selected: false,
15646        };
15647
15648        assert!(matches!(
15649            request_capped(&cfg, "GET", "/bounded", None, Auth::None, 1_024),
15650            Err(LinkError::ResponseTooLarge { .. })
15651        ));
15652        server.join().unwrap();
15653    }
15654
15655    #[test]
15656    fn overall_deadline_stops_a_dribbled_response_body() {
15657        use std::io::{Read as _, Write as _};
15658        use std::net::TcpListener;
15659        use std::time::{Duration, Instant};
15660
15661        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15662        let url = format!("http://{}/dribble", listener.local_addr().unwrap());
15663        let server = std::thread::spawn(move || {
15664            let (mut stream, _) = listener.accept().unwrap();
15665            let mut request = [0_u8; 1024];
15666            let _ = stream.read(&mut request);
15667            stream
15668                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 32\r\nConnection: close\r\n\r\n")
15669                .unwrap();
15670            for byte in [b'x'; 32] {
15671                if stream.write_all(&[byte]).is_err() {
15672                    break;
15673                }
15674                std::thread::sleep(Duration::from_millis(40));
15675            }
15676        });
15677        let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
15678        let started = Instant::now();
15679        let response = http.get(&url).call().unwrap();
15680        let mut body = Vec::new();
15681        let error = response
15682            .into_reader()
15683            .read_to_end(&mut body)
15684            .expect_err("per-read progress must not reset the overall deadline");
15685        assert!(
15686            started.elapsed() < Duration::from_millis(700),
15687            "dribbled body exceeded the wall-clock budget: {error}"
15688        );
15689        server.join().unwrap();
15690    }
15691
15692    #[test]
15693    fn overall_deadline_stops_a_stalled_upload() {
15694        use std::net::TcpListener;
15695        use std::time::{Duration, Instant};
15696
15697        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15698        let url = format!("http://{}/upload", listener.local_addr().unwrap());
15699        let server = std::thread::spawn(move || {
15700            let (_stream, _) = listener.accept().unwrap();
15701            // Never consume the request body. Once the kernel send buffer fills,
15702            // the client must leave on its absolute write deadline.
15703            std::thread::sleep(Duration::from_millis(600));
15704        });
15705        let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
15706        let body = vec![0x5a; 32 * 1024 * 1024];
15707        let started = Instant::now();
15708        let error = http
15709            .put(&url)
15710            .send_bytes(&body)
15711            .expect_err("stalled request-body writes must time out");
15712        assert!(
15713            started.elapsed() < Duration::from_millis(700),
15714            "stalled upload exceeded the wall-clock budget: {error}"
15715        );
15716        server.join().unwrap();
15717    }
15718
15719    #[test]
15720    fn verb_entry_gates_accept_the_hub_ref_shapes() {
15721        for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
15722            assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
15723            assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
15724            assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
15725        }
15726    }
15727
15728    #[test]
15729    fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
15730        let cfg = dead_hub();
15731        for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
15732            assert!(
15733                matches!(
15734                    sync_pull(&cfg, bad, None),
15735                    Err(LinkError::BadAddress { .. })
15736                ),
15737                "sync_pull must refuse {bad:?}"
15738            );
15739            assert!(
15740                matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
15741                "sync_push must refuse {bad:?}"
15742            );
15743            assert!(
15744                matches!(
15745                    grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
15746                    Err(LinkError::BadAddress { .. })
15747                ),
15748                "grant_issue must refuse {bad:?}"
15749            );
15750            assert!(
15751                matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
15752                "grant_list must refuse {bad:?}"
15753            );
15754            assert!(
15755                matches!(
15756                    grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
15757                    Err(LinkError::BadAddress { .. })
15758                ),
15759                "grant_revoke must refuse brain {bad:?}"
15760            );
15761            assert!(
15762                matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
15763                "head must refuse {bad:?}"
15764            );
15765        }
15766    }
15767
15768    #[test]
15769    fn grant_revoke_refuses_url_reshaping_grant_ids() {
15770        let cfg = dead_hub();
15771        for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
15772            assert!(
15773                matches!(
15774                    grant_revoke(&cfg, "acme", bad),
15775                    Err(LinkError::BadGrantId { .. })
15776                ),
15777                "grant_revoke must refuse grant id {bad:?}"
15778            );
15779        }
15780    }
15781
15782    #[test]
15783    fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
15784        let cfg = dead_hub();
15785        for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
15786            assert!(
15787                matches!(
15788                    propose(&cfg, bad, "intake", "hi"),
15789                    Err(LinkError::BadAddress { .. })
15790                ),
15791                "propose must refuse handle {bad:?}"
15792            );
15793        }
15794        let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
15795        assert!(matches!(
15796            propose(&cfg, "acme-site", "intake", &oversize),
15797            Err(LinkError::ProposeTooLarge { .. })
15798        ));
15799        // A clean handle + in-cap body passes both gates: the failure is now
15800        // the (dead) wire, proving the gates refuse shape, not the verb.
15801        assert!(matches!(
15802            propose(&cfg, "acme-site", "intake", "hi"),
15803            Err(LinkError::Transport { .. })
15804        ));
15805    }
15806
15807    #[test]
15808    fn resolve_refuses_a_hand_built_unsafe_address() {
15809        let cfg = dead_hub();
15810        for brain in ["../up", "a/b", "a?x", "a#f"] {
15811            let addr = Address {
15812                brain: brain.to_string(),
15813                target: None,
15814            };
15815            assert!(
15816                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
15817                "resolve must refuse brain {brain:?}"
15818            );
15819        }
15820        for target in [
15821            AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
15822            AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), // not the minted shape
15823            AddressTarget::Path("../up.md".to_string()),
15824            AddressTarget::Path("records/x.md#frag".to_string()),
15825        ] {
15826            let addr = Address {
15827                brain: "acme".to_string(),
15828                target: Some(target.clone()),
15829            };
15830            assert!(
15831                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
15832                "resolve must refuse target {target:?}"
15833            );
15834        }
15835    }
15836
15837    #[test]
15838    fn v2_final_barrier_refuses_to_advance_a_remote_ahead_checkout() {
15839        let mut local = std::collections::BTreeMap::new();
15840        local.insert("records/a.md".to_string(), ("a".repeat(64), 0));
15841        local.insert("records/b.md".to_string(), ("b".repeat(64), 0));
15842        let mut remote = std::collections::BTreeMap::new();
15843        remote.insert(
15844            "records/a.md".to_string(),
15845            V2BaselineFile {
15846                sha256: "c".repeat(64),
15847                bytes: 1,
15848                proof: None,
15849            },
15850        );
15851        remote.insert(
15852            "records/b.md".to_string(),
15853            V2BaselineFile {
15854                sha256: "b".repeat(64),
15855                bytes: 1,
15856                proof: None,
15857            },
15858        );
15859        assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
15860    }
15861
15862    #[test]
15863    fn v2_final_barrier_ignores_only_explicit_kept_home_paths() {
15864        let local = std::collections::BTreeMap::new();
15865        let mut remote = std::collections::BTreeMap::new();
15866        remote.insert(
15867            "private/local.md".to_string(),
15868            V2BaselineFile {
15869                sha256: "d".repeat(64),
15870                bytes: 1,
15871                proof: None,
15872            },
15873        );
15874        assert!(v2_riding_matches_remote(&local, &remote, |path| path == "private/local.md"));
15875        assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
15876    }
15877
15878    fn scoped_test_head(revision: &str) -> V2VerifiedHead {
15879        V2VerifiedHead {
15880            requested: TEST_BRAIN_ID.to_string(),
15881            brain_id: TEST_BRAIN_ID.to_string(),
15882            view_kind: "scoped".to_string(),
15883            view_revision: revision.to_string(),
15884            control_revision: revision.to_string(),
15885            identity: V2HeadIdentity {
15886                custody: "hub".to_string(),
15887                fingerprint: "test".to_string(),
15888                public_key_spki: "test".to_string(),
15889                previous: Vec::new(),
15890                rotations: Vec::new(),
15891            },
15892            pointer: None,
15893            trust: TrustState {
15894                v: 2,
15895                origin: "https://hub.example".to_string(),
15896                requested: TEST_BRAIN_ID.to_string(),
15897                brain: TEST_BRAIN_ID.to_string(),
15898                home: None,
15899                anchor: "ed25519:test".to_string(),
15900                current: "ed25519:test".to_string(),
15901                head_seq: 0,
15902                feed_hash: None,
15903                rotations: Vec::new(),
15904                hub_signer: None,
15905                protocol_profile: Some("link-v2".to_string()),
15906            },
15907            alias: None,
15908        }
15909    }
15910
15911    #[test]
15912    fn accepted_v2_checkpoint_never_downgrades_after_profile_migration() {
15913        let mut trust = scoped_test_head(&"a".repeat(64)).trust;
15914        assert!(accepted_as_v2(&trust));
15915
15916        trust.protocol_profile = None;
15917        trust.hub_signer = Some("ed25519:hub".to_string());
15918        assert!(accepted_as_v2(&trust));
15919
15920        trust.hub_signer = None;
15921        assert!(!accepted_as_v2(&trust));
15922    }
15923
15924    fn scoped_test_baseline(revision: &str) -> V2SyncBaseline {
15925        V2SyncBaseline {
15926            v: 2,
15927            origin: "https://hub.example".to_string(),
15928            brain: TEST_BRAIN_ID.to_string(),
15929            checkout_id: Some("c".repeat(64)),
15930            head_seq: Some(0),
15931            commit_hash: None,
15932            content_root: None,
15933            asset_root: None,
15934            assets: std::collections::BTreeMap::new(),
15935            view_kind: Some("scoped".to_string()),
15936            view_revision: Some(revision.to_string()),
15937            projection_sha256: Some(scoped_projection_sha256(TEST_BRAIN_ID)),
15938            files: std::collections::BTreeMap::new(),
15939            local_policy_digest: None,
15940            local_eligibility: std::collections::BTreeMap::new(),
15941            remote_copy_remains: std::collections::BTreeMap::new(),
15942        }
15943    }
15944
15945    #[test]
15946    fn scoped_projection_is_a_valid_local_store_marker_but_never_rides() {
15947        let directory = tempfile::tempdir().unwrap();
15948        std::fs::write(
15949            directory.path().join("DB.md"),
15950            scoped_projection_bytes(TEST_BRAIN_ID),
15951        )
15952        .unwrap();
15953        let store = Store::open_strict(directory.path()).unwrap();
15954        let head = scoped_test_head(&"a".repeat(64));
15955        let baseline = scoped_test_baseline(&"a".repeat(64));
15956        let mut view = v2_local_files(&store).unwrap();
15957        remove_scoped_projection(&head, Some(&baseline), &mut view).unwrap();
15958        assert!(!view.riding.contains_key("DB.md"));
15959        assert!(!view.eligibility.contains_key("DB.md"));
15960    }
15961
15962    #[test]
15963    fn scoped_push_accepts_a_pull_verified_handoff_without_double_checking_projection() {
15964        let directory = tempfile::tempdir().unwrap();
15965        std::fs::write(
15966            directory.path().join("DB.md"),
15967            scoped_projection_bytes(TEST_BRAIN_ID),
15968        )
15969        .unwrap();
15970        let store = Store::open_strict(directory.path()).unwrap();
15971        let head = scoped_test_head(&"a".repeat(64));
15972        let baseline = scoped_test_baseline(&"a".repeat(64));
15973
15974        let mut carried = v2_local_files(&store).unwrap();
15975        remove_scoped_projection(&head, Some(&baseline), &mut carried).unwrap();
15976        let handed_off =
15977            local_view_for_v2_push(&store, &head, Some(&baseline), Some(carried)).unwrap();
15978        assert!(!handed_off.riding.contains_key("DB.md"));
15979
15980        let freshly_scanned = local_view_for_v2_push(&store, &head, Some(&baseline), None).unwrap();
15981        assert!(!freshly_scanned.riding.contains_key("DB.md"));
15982
15983        std::fs::write(
15984            directory.path().join("DB.md"),
15985            b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
15986        )
15987        .unwrap();
15988        let tampered = Store::open_strict(directory.path()).unwrap();
15989        assert!(matches!(
15990            local_view_for_v2_push(&tampered, &head, Some(&baseline), None),
15991            Err(LinkError::ScopedProjectionModified)
15992        ));
15993    }
15994
15995    #[test]
15996    fn v2_local_view_reports_only_exact_linked_kept_home_markdown() {
15997        let directory = tempfile::tempdir().unwrap();
15998        std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
15999        std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
16000        std::fs::write(
16001            directory.path().join("DB.md"),
16002            b"---\nname: Kept home test\n---\n",
16003        )
16004        .unwrap();
16005        std::fs::write(
16006            directory.path().join("records/notes/a.md"),
16007            b"---\ntype: note\n---\nSee [[sources/private/secret]].\n",
16008        )
16009        .unwrap();
16010        std::fs::write(
16011            directory.path().join("sources/private/secret.md"),
16012            b"---\ntype: note\n---\nlocal only\n",
16013        )
16014        .unwrap();
16015        std::fs::write(
16016            directory.path().join("sources/private/unlinked.md"),
16017            b"---\ntype: note\n---\nnot disclosed\n",
16018        )
16019        .unwrap();
16020        std::fs::write(
16021            directory.path().join(".sevralocal"),
16022            b"sources/private/**\n",
16023        )
16024        .unwrap();
16025
16026        let store = Store::open_strict(directory.path()).unwrap();
16027        let view = v2_local_files(&store).unwrap();
16028        assert!(!view.riding.contains_key("sources/private/secret.md"));
16029        assert_eq!(
16030            view.withheld_links,
16031            vec![V2WithheldLink {
16032                source: "records/notes/a.md".to_string(),
16033                target: "sources/private/secret.md".to_string(),
16034            }]
16035        );
16036    }
16037
16038    #[test]
16039    fn explicit_content_withdrawal_requires_exact_current_kept_home_bytes() {
16040        let directory = tempfile::tempdir().unwrap();
16041        std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
16042        std::fs::write(
16043            directory.path().join("DB.md"),
16044            b"---\nname: Withdrawal test\n---\n",
16045        )
16046        .unwrap();
16047        let source = b"---\ntype: note\n---\nlocal evidence\n";
16048        std::fs::write(directory.path().join("sources/private/evidence.md"), source).unwrap();
16049        std::fs::write(
16050            directory.path().join(".sevralocal"),
16051            b"sources/private/**\n",
16052        )
16053        .unwrap();
16054        let store = Store::open_strict(directory.path()).unwrap();
16055        let view = v2_local_files(&store).unwrap();
16056        let mut remote = std::collections::BTreeMap::new();
16057        remote.insert(
16058            "sources/private/evidence.md".to_string(),
16059            V2BaselineFile {
16060                sha256: content_sha256(source),
16061                bytes: source.len() as u64,
16062                proof: None,
16063            },
16064        );
16065        assert_eq!(
16066            v2_content_withdrawal_operation(
16067                &store,
16068                &view,
16069                &remote,
16070                "sources/private/evidence.md",
16071                "approved retention change",
16072            )
16073            .unwrap(),
16074            json!({
16075                "op": "withdraw_from_hosting",
16076                "path": "sources/private/evidence.md",
16077                "expected": { "kind": "blob", "hash": content_sha256(source) },
16078                "reason": "approved retention change",
16079            })
16080        );
16081
16082        std::fs::write(
16083            directory.path().join("sources/private/evidence.md"),
16084            b"changed after review",
16085        )
16086        .unwrap();
16087        assert!(matches!(
16088            v2_content_withdrawal_operation(
16089                &store,
16090                &view,
16091                &remote,
16092                "sources/private/evidence.md",
16093                "approved retention change",
16094            ),
16095            Err(LinkError::InvalidPack { .. })
16096        ));
16097    }
16098
16099    #[test]
16100    fn explicit_asset_withdrawal_requires_the_exact_hosted_leaf_and_local_bytes() {
16101        let directory = tempfile::tempdir().unwrap();
16102        std::fs::create_dir_all(directory.path().join("sources/files")).unwrap();
16103        std::fs::write(
16104            directory.path().join("DB.md"),
16105            b"---\nname: Asset withdrawal test\n---\n",
16106        )
16107        .unwrap();
16108        let bytes = b"private binary";
16109        std::fs::write(directory.path().join("sources/files/private.pdf"), bytes).unwrap();
16110        std::fs::write(
16111            directory.path().join(".sevralocal"),
16112            b"sources/files/private.pdf\n",
16113        )
16114        .unwrap();
16115        let store = Store::open_strict(directory.path()).unwrap();
16116        let view = v2_local_files(&store).unwrap();
16117        let local = crate::AssetRecord {
16118            path: "sources/files/private.pdf".to_string(),
16119            sha256: content_sha256(bytes),
16120            bytes: bytes.len() as u64,
16121            media_type: "application/pdf".to_string(),
16122            wrappers: vec!["sources/files/private.md".to_string()],
16123            required: true,
16124        };
16125        let current = V2BaselineAsset {
16126            blob_sha256: local.sha256.clone(),
16127            bytes: local.bytes,
16128            media_type: local.media_type.clone(),
16129            wrappers: local.wrappers.clone(),
16130            required: local.required,
16131            disposition: "hosted".to_string(),
16132            leaf_hash: "d".repeat(64),
16133        };
16134        assert_eq!(
16135            v2_asset_withdrawal_operation(
16136                &store,
16137                &view,
16138                &local.path,
16139                &local,
16140                &current,
16141                "approved retention change",
16142            )
16143            .unwrap(),
16144            json!({
16145                "op": "asset_withdraw",
16146                "path": local.path,
16147                "expected": { "kind": "asset", "hash": "d".repeat(64) },
16148                "reason": "approved retention change",
16149            })
16150        );
16151
16152        let mut mismatched = current.clone();
16153        mismatched.required = false;
16154        assert!(matches!(
16155            v2_asset_withdrawal_operation(
16156                &store,
16157                &view,
16158                &local.path,
16159                &local,
16160                &mismatched,
16161                "approved retention change",
16162            ),
16163            Err(LinkError::InvalidPack { .. })
16164        ));
16165    }
16166
16167    #[test]
16168    fn checkout_pseudonym_is_random_then_stable_from_private_baseline() {
16169        let first = v2_checkout_id(None).unwrap();
16170        assert_eq!(first, v2_checkout_id(Some(&first)).unwrap());
16171        assert_ne!(first, v2_checkout_id(None).unwrap());
16172        assert!(is_sha256(&first));
16173    }
16174
16175    #[test]
16176    fn scoped_projection_edit_and_scope_transition_fail_closed() {
16177        let directory = tempfile::tempdir().unwrap();
16178        std::fs::write(
16179            directory.path().join("DB.md"),
16180            b"---\ntype: db-md\nscope: company\nowner: someone\n---\n",
16181        )
16182        .unwrap();
16183        let store = Store::open_strict(directory.path()).unwrap();
16184        let head = scoped_test_head(&"a".repeat(64));
16185        let baseline = scoped_test_baseline(&"a".repeat(64));
16186        let mut view = v2_local_files(&store).unwrap();
16187        assert!(matches!(
16188            remove_scoped_projection(&head, Some(&baseline), &mut view),
16189            Err(LinkError::ScopedProjectionModified)
16190        ));
16191
16192        let changed = scoped_test_head(&"b".repeat(64));
16193        assert!(matches!(
16194            ensure_v2_view_compatible(&changed, Some(&baseline)),
16195            Err(LinkError::ScopedViewChanged)
16196        ));
16197
16198        let mut same_view_new_control = head.clone();
16199        same_view_new_control.control_revision = "c".repeat(64);
16200        assert!(ensure_v2_view_compatible(&same_view_new_control, Some(&baseline)).is_ok());
16201        assert!(!same_v2_head(&head, &same_view_new_control));
16202    }
16203
16204    #[test]
16205    fn established_checkout_never_becomes_an_empty_clone_when_db_md_is_invalid() {
16206        let scoped = scoped_test_head(&"a".repeat(64));
16207        let scoped_baseline = scoped_test_baseline(&"a".repeat(64));
16208        assert!(matches!(
16209            ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), false),
16210            Err(LinkError::ScopedProjectionModified)
16211        ));
16212
16213        let mut full = scoped.clone();
16214        full.view_kind = "full".to_string();
16215        let mut full_baseline = scoped_baseline.clone();
16216        full_baseline.view_kind = Some("full".to_string());
16217        full_baseline.projection_sha256 = None;
16218        assert!(matches!(
16219            ensure_established_v2_checkout_opened(&full, Some(&full_baseline), false),
16220            Err(LinkError::InvalidPack { .. })
16221        ));
16222
16223        assert!(ensure_established_v2_checkout_opened(&scoped, None, false).is_ok());
16224        assert!(
16225            ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), true).is_ok()
16226        );
16227    }
16228
16229    #[test]
16230    fn scoped_view_metadata_is_explicitly_non_authoritative() {
16231        let head = scoped_test_head(&"a".repeat(64));
16232        let value: Value =
16233            serde_json::from_slice(&scoped_view_metadata(&head, 7).unwrap()).unwrap();
16234        assert_eq!(value["kind"], "link.md-scoped-view");
16235        assert_eq!(value["authoritative"], false);
16236        assert_eq!(value["visible_files"], 7);
16237        assert_eq!(value["brain"], TEST_BRAIN_ID);
16238    }
16239
16240    #[test]
16241    fn local_scoped_marker_requires_the_exact_generated_projection() {
16242        let directory = tempfile::tempdir().unwrap();
16243        std::fs::create_dir(directory.path().join(".dbmd")).unwrap();
16244        std::fs::write(
16245            directory.path().join("DB.md"),
16246            scoped_projection_bytes(TEST_BRAIN_ID),
16247        )
16248        .unwrap();
16249        let head = scoped_test_head(&"a".repeat(64));
16250        std::fs::write(
16251            directory.path().join(".dbmd/view.json"),
16252            scoped_view_metadata(&head, 0).unwrap(),
16253        )
16254        .unwrap();
16255        let store = Store::open_strict(directory.path()).unwrap();
16256        assert!(has_verified_local_scoped_view(&store));
16257
16258        std::fs::write(
16259            directory.path().join("DB.md"),
16260            b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
16261        )
16262        .unwrap();
16263        let altered = Store::open_strict(directory.path()).unwrap();
16264        assert!(!has_verified_local_scoped_view(&altered));
16265    }
16266
16267    fn signed_proposal_fixture() -> (V2VerifiedHead, String, Value) {
16268        use ring::signature::KeyPair as _;
16269
16270        let proposal_id = "01k2r7bm9w5x6e8nq3tjhv4cya".to_string();
16271        let rng = ring::rand::SystemRandom::new();
16272        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16273        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16274        let public_der = [ED25519_SPKI_PREFIX.as_slice(), pair.public_key().as_ref()].concat();
16275        let public_key = URL_SAFE_NO_PAD.encode(&public_der);
16276        let fingerprint = format!("{:x}", Sha256::digest(&public_der));
16277        let blob = b"new";
16278        let blob_hash = content_sha256(blob);
16279        let changes = json!({
16280            "mutation_id": "sync:proposal-fixture",
16281            "operations": [{
16282                "blob": blob_hash,
16283                "bytes": blob.len(),
16284                "expected": null,
16285                "op": "put",
16286                "path": "records/new.md",
16287            }],
16288            "reason": "fixture",
16289            "v": 2,
16290        });
16291        let changes_bytes = crate::linkmd_v2::canonical_bytes(&changes).unwrap();
16292        let changes_base64 = STANDARD.encode(&changes_bytes);
16293        let descriptor = json!({
16294            "base": null,
16295            "blobs": [{ "bytes": blob.len(), "sha256": blob_hash }],
16296            "changes_base64": changes_base64,
16297            "rebase": "strict",
16298            "v": 2,
16299        });
16300        let clear_hash = content_sha256(&crate::linkmd_v2::canonical_bytes(&descriptor).unwrap());
16301        let payload_hash = "b".repeat(64);
16302        let submitted_at = "2026-08-19T12:00:00.000Z";
16303        let claim = json!({
16304            "actor_root": {
16305                "actor_class": "foreign_key",
16306                "credential": "ed25519:fixture",
16307                "grants": ["01k2r7bm9w5x6e8nq3tjhv4cyb"],
16308                "organization": "01k2r7bm9w5x6e8nq3tjhv4cyc",
16309                "principal": "key:fixture",
16310                "role": null,
16311            },
16312            "brain": TEST_BRAIN_ID,
16313            "clear_sha256": clear_hash,
16314            "control_revision": "c".repeat(64),
16315            "mutation_id": "sync:proposal-fixture",
16316            "payload_sha256": payload_hash,
16317            "proposal_id": proposal_id,
16318            "submitted_at": submitted_at,
16319            "v": 2,
16320        });
16321        let claim_bytes = crate::linkmd_v2::canonical_bytes(&claim).unwrap();
16322        let envelope = json!({
16323            "claim": claim,
16324            "fingerprint": fingerprint,
16325            "public_key": public_key,
16326            "sig": URL_SAFE_NO_PAD.encode(pair.sign(&claim_bytes).as_ref()),
16327        });
16328        let envelope_bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
16329        let submission_hash =
16330            crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &envelope_bytes).unwrap();
16331        let mut head = scoped_test_head(&"c".repeat(64));
16332        head.view_kind = "full".to_string();
16333        head.trust.hub_signer = Some(format!("{fingerprint}:{public_key}"));
16334        let value = json!({
16335            "proposal": {
16336                "base": null,
16337                "blobs": [{
16338                    "bytes": blob.len(),
16339                    "endpoint": format!(
16340                        "/api/hub/brains/{TEST_BRAIN_ID}/v2/proposals/{proposal_id}/blob?sha256={blob_hash}"
16341                    ),
16342                    "sha256": blob_hash,
16343                }],
16344                "changes_base64": changes_base64,
16345                "clear_sha256": clear_hash,
16346                "expires_at": "2026-08-26T12:00:00.000Z",
16347                "id": proposal_id,
16348                "payload_sha256": payload_hash,
16349                "proposer": { "class": "foreign_key" },
16350                "rebase": "strict",
16351                "state": "pending",
16352                "submission_claim_base64": STANDARD.encode(envelope_bytes),
16353                "submission_claim_sha256": submission_hash,
16354                "submitted_at": submitted_at,
16355            },
16356            "v": 2,
16357        });
16358        (head, proposal_id, value)
16359    }
16360
16361    #[test]
16362    fn v2_proposal_verifier_accepts_exact_signed_payload() {
16363        let (head, proposal_id, value) = signed_proposal_fixture();
16364        let verified = verify_v2_proposal_value(&head, &proposal_id, value).unwrap();
16365        assert_eq!(verified.blobs.len(), 1);
16366        assert_eq!(verified.changes["operations"][0]["path"], "records/new.md");
16367    }
16368
16369    #[test]
16370    fn v2_proposal_verifier_refuses_payload_endpoint_and_signature_tampering() {
16371        let (head, proposal_id, value) = signed_proposal_fixture();
16372
16373        let mut changed = value.clone();
16374        changed["proposal"]["changes_base64"] = Value::String(STANDARD.encode(b"{}"));
16375        assert!(verify_v2_proposal_value(&head, &proposal_id, changed).is_err());
16376
16377        let mut redirected = value.clone();
16378        redirected["proposal"]["blobs"][0]["endpoint"] =
16379            Value::String("https://attacker.example/blob".to_string());
16380        assert!(verify_v2_proposal_value(&head, &proposal_id, redirected).is_err());
16381
16382        let mut forged = value;
16383        let encoded = forged["proposal"]["submission_claim_base64"]
16384            .as_str()
16385            .unwrap();
16386        let mut envelope: Value =
16387            serde_json::from_slice(&STANDARD.decode(encoded).unwrap()).unwrap();
16388        envelope["sig"] = Value::String(URL_SAFE_NO_PAD.encode([0_u8; 64]));
16389        let bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
16390        forged["proposal"]["submission_claim_base64"] = Value::String(STANDARD.encode(&bytes));
16391        forged["proposal"]["submission_claim_sha256"] = Value::String(
16392            crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &bytes).unwrap(),
16393        );
16394        assert!(verify_v2_proposal_value(&head, &proposal_id, forged).is_err());
16395    }
16396
16397    #[cfg(unix)]
16398    #[test]
16399    fn v2_atomic_install_materializes_only_local_catalogs_before_swap() {
16400        let sandbox = tempfile::tempdir().unwrap();
16401        let destination = sandbox.path().join("brain");
16402        let entries = vec![
16403            (
16404                "DB.md".to_string(),
16405                scoped_projection_bytes(TEST_BRAIN_ID),
16406            ),
16407            (
16408                "records/contacts/a.md".to_string(),
16409                b"---\ntype: contact\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Visible contact\n---\n\n# A\n"
16410                    .to_vec(),
16411            ),
16412        ];
16413        install_pulled_delta(&destination, &entries, &[], true).unwrap();
16414        assert!(destination.join("index.md").is_file());
16415        assert!(destination.join("records/index.md").is_file());
16416        assert!(destination.join("records/contacts/index.md").is_file());
16417        assert!(destination.join("records/contacts/index.jsonl").is_file());
16418    }
16419
16420    #[cfg(unix)]
16421    #[test]
16422    fn v2_bounded_parallel_source_install_is_exact_and_atomic() {
16423        let sandbox = tempfile::tempdir().unwrap();
16424        let destination = sandbox.path().join("brain");
16425        let cache = sandbox.path().join("cache");
16426        std::fs::create_dir(&cache).unwrap();
16427        let db = scoped_projection_bytes(TEST_BRAIN_ID);
16428        let shared = b"---\ntype: note\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Shared\n---\n\n# Shared\n";
16429        let db_source = cache.join("db");
16430        let shared_source = cache.join("shared");
16431        crate::fsx::write_atomic(&db_source, &db).unwrap();
16432        crate::fsx::write_atomic(&shared_source, shared).unwrap();
16433        let mut entries = vec![V2StagedFile {
16434            path: "DB.md".to_string(),
16435            source: db_source,
16436            sha256: content_sha256(&db),
16437            bytes: db.len() as u64,
16438        }];
16439        for index in 0..512 {
16440            entries.push(V2StagedFile {
16441                path: format!("records/items/{index:05}.md"),
16442                source: shared_source.clone(),
16443                sha256: content_sha256(shared),
16444                bytes: shared.len() as u64,
16445            });
16446        }
16447        install_pulled_delta_sources(
16448            &destination,
16449            &entries,
16450            &[],
16451            false,
16452            None,
16453            &scoped_test_head(&"c".repeat(64)),
16454        )
16455        .unwrap();
16456        assert_eq!(std::fs::read(destination.join("DB.md")).unwrap(), db);
16457        for index in 0..512 {
16458            assert_eq!(
16459                std::fs::read(destination.join(format!("records/items/{index:05}.md"))).unwrap(),
16460                shared
16461            );
16462        }
16463        assert!(
16464            std::fs::read_dir(sandbox.path())
16465                .unwrap()
16466                .all(|entry| !entry
16467                    .unwrap()
16468                    .file_name()
16469                    .to_string_lossy()
16470                    .contains("pull-stage")),
16471            "the private stage must be atomically installed or removed"
16472        );
16473    }
16474
16475    #[cfg(unix)]
16476    #[test]
16477    fn v2_established_pull_touches_only_delta_and_rolls_back_exactly() {
16478        use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
16479
16480        let sandbox = tempfile::tempdir().unwrap();
16481        let root = sandbox.path().join("brain");
16482        std::fs::create_dir_all(root.join("records/items")).unwrap();
16483        let db = scoped_projection_bytes(TEST_BRAIN_ID);
16484        let old = b"---\ntype: note\n---\n\nold\n";
16485        let new = b"---\ntype: note\n---\n\nnew\n";
16486        let removed = b"---\ntype: note\n---\n\nremove me\n";
16487        std::fs::write(root.join("DB.md"), &db).unwrap();
16488        std::fs::write(root.join("records/items/change.md"), old).unwrap();
16489        std::fs::write(root.join("records/items/delete.md"), removed).unwrap();
16490        for index in 0..512 {
16491            std::fs::write(
16492                root.join(format!("records/items/untouched-{index:04}.md")),
16493                old,
16494            )
16495            .unwrap();
16496        }
16497        let untouched = root.join("records/items/untouched-0256.md");
16498        let untouched_inode = std::fs::metadata(&untouched).unwrap().ino();
16499        let source = sandbox.path().join("changed-source");
16500        crate::fsx::write_atomic(&source, new).unwrap();
16501        let same_source = sandbox.path().join("unchanged-source");
16502        crate::fsx::write_atomic(&same_source, old).unwrap();
16503        let same_entry = V2StagedFile {
16504            path: "records/items/change.md".to_string(),
16505            source: same_source,
16506            sha256: content_sha256(old),
16507            bytes: old.len() as u64,
16508        };
16509        let entry = V2StagedFile {
16510            path: "records/items/change.md".to_string(),
16511            source,
16512            sha256: content_sha256(new),
16513            bytes: new.len() as u64,
16514        };
16515        let head = scoped_test_head(&"c".repeat(64));
16516
16517        // A no-op established pull neither clones the checkout nor arms a
16518        // journal. Inode identity is a direct regression guard against the
16519        // former brain-wide private-copy implementation.
16520        install_established_v2_delta(
16521            Store::open_strict(&root).unwrap(),
16522            &[same_entry],
16523            &["records/items/already-absent.md".to_string()],
16524            true,
16525            None,
16526            &head,
16527        )
16528        .unwrap();
16529        assert_eq!(
16530            std::fs::metadata(&untouched).unwrap().ino(),
16531            untouched_inode
16532        );
16533        assert!(!root.join(V2_PULL_JOURNAL).exists());
16534
16535        install_established_v2_delta(
16536            Store::open_strict(&root).unwrap(),
16537            &[entry],
16538            &["records/items/delete.md".to_string()],
16539            false,
16540            None,
16541            &head,
16542        )
16543        .unwrap();
16544        assert_eq!(
16545            std::fs::read(root.join("records/items/change.md")).unwrap(),
16546            new
16547        );
16548        assert!(!root.join("records/items/delete.md").exists());
16549        assert_eq!(
16550            std::fs::metadata(&untouched).unwrap().ino(),
16551            untouched_inode
16552        );
16553        assert!(root.join(V2_PULL_JOURNAL).is_file());
16554        assert_eq!(
16555            std::fs::metadata(root.join(V2_PULL_JOURNAL))
16556                .unwrap()
16557                .permissions()
16558                .mode()
16559                & 0o777,
16560            0o600
16561        );
16562        let journal = load_v2_pull_journal(&Store::open_strict(&root).unwrap())
16563            .unwrap()
16564            .unwrap();
16565        assert_eq!(
16566            std::fs::metadata(root.join(&journal.backup_dir))
16567                .unwrap()
16568                .permissions()
16569                .mode()
16570                & 0o777,
16571            0o700
16572        );
16573        for entry in &journal.entries {
16574            if let Some(backup) = &entry.backup {
16575                assert_eq!(
16576                    std::fs::metadata(root.join(&journal.backup_dir).join(backup))
16577                        .unwrap()
16578                        .permissions()
16579                        .mode()
16580                        & 0o777,
16581                    0o600
16582                );
16583            }
16584        }
16585
16586        let cfg = test_hub_config(
16587            "https://example.test".to_string(),
16588            sandbox.path().join("state"),
16589        );
16590        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
16591        assert_eq!(
16592            std::fs::read(root.join("records/items/change.md")).unwrap(),
16593            old
16594        );
16595        assert_eq!(
16596            std::fs::read(root.join("records/items/delete.md")).unwrap(),
16597            removed
16598        );
16599        assert_eq!(
16600            std::fs::metadata(&untouched).unwrap().ino(),
16601            untouched_inode
16602        );
16603        assert!(!root.join(V2_PULL_JOURNAL).exists());
16604    }
16605
16606    #[test]
16607    fn v2_bulk_stream_is_exact_ordered_and_tamper_evident() {
16608        let body = b"bounded bytes";
16609        let path = "records/example.md".to_string();
16610        let file = V2BaselineFile {
16611            sha256: content_sha256(body),
16612            bytes: body.len() as u64,
16613            proof: None,
16614        };
16615        let header = serde_json::to_vec(&json!({
16616            "bytes": body.len(),
16617            "path": path,
16618            "sha256": file.sha256,
16619            "v": 2,
16620        }))
16621        .unwrap();
16622        let mut stream = V2_BULK_STREAM_MAGIC.to_vec();
16623        stream.extend_from_slice(&(header.len() as u32).to_be_bytes());
16624        stream.extend_from_slice(&header);
16625        stream.extend_from_slice(body);
16626        stream.extend_from_slice(&0_u32.to_be_bytes());
16627        let parsed = parse_v2_bulk_stream(&stream, &[(&path, &file)]).unwrap();
16628        assert_eq!(parsed, vec![(path.clone(), body.to_vec())]);
16629
16630        let mut tampered = stream.clone();
16631        let body_offset = V2_BULK_STREAM_MAGIC.len() + 4 + header.len();
16632        tampered[body_offset] ^= 1;
16633        assert!(parse_v2_bulk_stream(&tampered, &[(&path, &file)]).is_err());
16634
16635        let mut trailing = stream;
16636        trailing.push(0);
16637        assert!(parse_v2_bulk_stream(&trailing, &[(&path, &file)]).is_err());
16638    }
16639
16640    #[test]
16641    fn conflict_cache_prunes_only_expired_or_incomplete_state_by_default() {
16642        let sandbox = tempfile::TempDir::new().unwrap();
16643        let root = sandbox.path().join("brain");
16644        std::fs::create_dir_all(&root).unwrap();
16645        std::fs::write(
16646            root.join("DB.md"),
16647            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
16648        )
16649        .unwrap();
16650        let store = Store::open_strict(&root).unwrap();
16651        let incomplete = crate::ulid::mint();
16652        store
16653            .create_dir_all(&v2_conflict_relative(&incomplete, "files"))
16654            .unwrap();
16655        let expired = crate::ulid::mint();
16656        store
16657            .create_dir_all(&v2_conflict_relative(&expired, "files"))
16658            .unwrap();
16659        let plan = V2ConflictPlan {
16660            v: 2,
16661            class: "content_resolution_required".to_string(),
16662            bundle: expired.clone(),
16663            brain: TEST_BRAIN_ID.to_string(),
16664            origin: "https://example.test".to_string(),
16665            created_unix: 0,
16666            expires_unix: 0,
16667            base_seq: None,
16668            base_commit: None,
16669            remote_seq: 0,
16670            remote_commit: None,
16671            remote_content_root: None,
16672            view_kind: "full".to_string(),
16673            view_revision: "a".repeat(64),
16674            files: vec![V2ConflictFile {
16675                path: "records/value.md".to_string(),
16676                base: V2ConflictCoordinate {
16677                    sha256: None,
16678                    bytes: None,
16679                    file: None,
16680                },
16681                local: V2ConflictCoordinate {
16682                    sha256: None,
16683                    bytes: None,
16684                    file: None,
16685                },
16686                remote: V2ConflictCoordinate {
16687                    sha256: None,
16688                    bytes: None,
16689                    file: None,
16690                },
16691            }],
16692        };
16693        let mut bytes = serde_json::to_vec(&plan).unwrap();
16694        bytes.push(b'\n');
16695        store
16696            .write_atomic_new(&v2_conflict_relative(&expired, "plan.json"), &bytes)
16697            .unwrap();
16698
16699        let listed = sync_conflicts(&root, false, false).unwrap();
16700        assert_eq!(listed["bundles"], 2);
16701        assert_eq!(listed["pruned"], 0);
16702        let pruned = sync_conflicts(&root, true, false).unwrap();
16703        assert_eq!(pruned["bundles"], 0);
16704        assert_eq!(pruned["pruned"], 2);
16705        assert!(!root.join(".dbmd/conflicts").join(incomplete).exists());
16706        assert!(!root.join(".dbmd/conflicts").join(expired).exists());
16707    }
16708
16709    #[test]
16710    fn corrupt_completed_conflict_state_requires_explicit_discard_all() {
16711        let sandbox = tempfile::TempDir::new().unwrap();
16712        let root = sandbox.path().join("brain");
16713        std::fs::create_dir_all(&root).unwrap();
16714        std::fs::write(
16715            root.join("DB.md"),
16716            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
16717        )
16718        .unwrap();
16719        let store = Store::open_strict(&root).unwrap();
16720        let bundle = crate::ulid::mint();
16721        store
16722            .create_dir_all(&v2_conflict_relative(&bundle, "files"))
16723            .unwrap();
16724        store
16725            .write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), b"not-json\n")
16726            .unwrap();
16727
16728        assert!(sync_conflicts(&root, true, false).is_err());
16729        assert!(sync_conflicts(&root, false, true).is_err());
16730        let pruned = sync_conflicts(&root, true, true).unwrap();
16731        assert_eq!(pruned["pruned"], 1);
16732        assert!(!root.join(".dbmd/conflicts").join(bundle).exists());
16733    }
16734
16735    #[test]
16736    fn ready_pull_journal_rolls_back_exact_preimages() {
16737        let sandbox = tempfile::TempDir::new().unwrap();
16738        let root = sandbox.path().join("brain");
16739        std::fs::create_dir_all(root.join("records")).unwrap();
16740        std::fs::write(
16741            root.join("DB.md"),
16742            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
16743        )
16744        .unwrap();
16745        let path = "records/value.md";
16746        let old = b"---\ntype: note\n---\n\nold\n";
16747        let new = b"---\ntype: note\n---\n\nnew\n";
16748        std::fs::write(root.join(path), old).unwrap();
16749        let store = Store::open_strict(&root).unwrap();
16750        let bundle = crate::ulid::mint();
16751        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
16752        store
16753            .create_private_dir_all(Path::new(&backup_dir))
16754            .unwrap();
16755        store
16756            .write_private_atomic_new(&Path::new(&backup_dir).join("00000000"), old)
16757            .unwrap();
16758        let journal = V2PullJournal {
16759            v: 1,
16760            phase: V2PullPhase::Ready,
16761            brain: TEST_BRAIN_ID.to_string(),
16762            previous: V2PullCoordinate {
16763                head_seq: None,
16764                commit_hash: None,
16765                view_kind: None,
16766                view_revision: None,
16767            },
16768            next: V2PullCoordinate {
16769                head_seq: Some(2),
16770                commit_hash: Some("c".repeat(64)),
16771                view_kind: Some("full".to_string()),
16772                view_revision: Some("d".repeat(64)),
16773            },
16774            backup_dir: backup_dir.clone(),
16775            entries: vec![V2PullJournalEntry {
16776                path: path.to_string(),
16777                old: Some(V2PullFileCoordinate {
16778                    sha256: content_sha256(old),
16779                    bytes: old.len() as u64,
16780                }),
16781                new: Some(V2PullFileCoordinate {
16782                    sha256: content_sha256(new),
16783                    bytes: new.len() as u64,
16784                }),
16785                backup: Some("00000000".to_string()),
16786            }],
16787        };
16788        validate_v2_pull_journal(&journal).unwrap();
16789        store
16790            .write_private_atomic_new(
16791                Path::new(V2_PULL_JOURNAL),
16792                &v2_pull_journal_bytes(&journal).unwrap(),
16793            )
16794            .unwrap();
16795        store.write_atomic(Path::new(path), new).unwrap();
16796
16797        let cfg = test_hub_config(
16798            "https://example.test".to_string(),
16799            sandbox.path().join("state"),
16800        );
16801        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
16802        assert_eq!(std::fs::read(root.join(path)).unwrap(), old);
16803        assert!(!root.join(V2_PULL_JOURNAL).exists());
16804        assert!(!root.join(backup_dir).exists());
16805    }
16806
16807    #[test]
16808    fn preparing_pull_journal_discards_only_private_staging() {
16809        let sandbox = tempfile::TempDir::new().unwrap();
16810        let root = sandbox.path().join("brain");
16811        std::fs::create_dir_all(root.join(".dbmd")).unwrap();
16812        std::fs::write(
16813            root.join("DB.md"),
16814            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
16815        )
16816        .unwrap();
16817        let store = Store::open_strict(&root).unwrap();
16818        let bundle = crate::ulid::mint();
16819        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
16820        store
16821            .create_private_dir_all(Path::new(&backup_dir))
16822            .unwrap();
16823        let journal = V2PullJournal {
16824            v: 1,
16825            phase: V2PullPhase::Preparing,
16826            brain: TEST_BRAIN_ID.to_string(),
16827            previous: V2PullCoordinate {
16828                head_seq: None,
16829                commit_hash: None,
16830                view_kind: None,
16831                view_revision: None,
16832            },
16833            next: V2PullCoordinate {
16834                head_seq: Some(1),
16835                commit_hash: Some("a".repeat(64)),
16836                view_kind: Some("full".to_string()),
16837                view_revision: Some("b".repeat(64)),
16838            },
16839            backup_dir: backup_dir.clone(),
16840            entries: vec![V2PullJournalEntry {
16841                path: "records/new.md".to_string(),
16842                old: None,
16843                new: Some(V2PullFileCoordinate {
16844                    sha256: "c".repeat(64),
16845                    bytes: 1,
16846                }),
16847                backup: None,
16848            }],
16849        };
16850        store
16851            .write_private_atomic_new(
16852                Path::new(V2_PULL_JOURNAL),
16853                &v2_pull_journal_bytes(&journal).unwrap(),
16854            )
16855            .unwrap();
16856        let cfg = test_hub_config(
16857            "https://example.test".to_string(),
16858            sandbox.path().join("state"),
16859        );
16860
16861        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
16862
16863        assert!(root.join("DB.md").is_file());
16864        assert!(!root.join(V2_PULL_JOURNAL).exists());
16865        assert!(!root.join(backup_dir).exists());
16866    }
16867
16868    #[test]
16869    fn committed_baseline_keeps_installed_bytes_and_prunes_orphans() {
16870        let sandbox = tempfile::TempDir::new().unwrap();
16871        let root = sandbox.path().join("brain");
16872        std::fs::create_dir_all(root.join("records")).unwrap();
16873        std::fs::write(
16874            root.join("DB.md"),
16875            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
16876        )
16877        .unwrap();
16878        let new = b"---\ntype: note\n---\n\nnew\n";
16879        std::fs::write(root.join("records/value.md"), new).unwrap();
16880        let store = Store::open_strict(&root).unwrap();
16881        let bundle = crate::ulid::mint();
16882        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
16883        store
16884            .create_private_dir_all(Path::new(&backup_dir))
16885            .unwrap();
16886        let orphan = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
16887        store.create_private_dir_all(Path::new(&orphan)).unwrap();
16888        let next = V2PullCoordinate {
16889            head_seq: Some(2),
16890            commit_hash: Some("c".repeat(64)),
16891            view_kind: Some("full".to_string()),
16892            view_revision: Some("d".repeat(64)),
16893        };
16894        let journal = V2PullJournal {
16895            v: 1,
16896            phase: V2PullPhase::Ready,
16897            brain: TEST_BRAIN_ID.to_string(),
16898            previous: V2PullCoordinate {
16899                head_seq: Some(1),
16900                commit_hash: Some("a".repeat(64)),
16901                view_kind: Some("full".to_string()),
16902                view_revision: Some("b".repeat(64)),
16903            },
16904            next: next.clone(),
16905            backup_dir: backup_dir.clone(),
16906            entries: vec![V2PullJournalEntry {
16907                path: "records/value.md".to_string(),
16908                old: Some(V2PullFileCoordinate {
16909                    sha256: "e".repeat(64),
16910                    bytes: new.len() as u64,
16911                }),
16912                new: Some(V2PullFileCoordinate {
16913                    sha256: content_sha256(new),
16914                    bytes: new.len() as u64,
16915                }),
16916                backup: Some("00000000".to_string()),
16917            }],
16918        };
16919        store
16920            .write_private_atomic_new(
16921                Path::new(V2_PULL_JOURNAL),
16922                &v2_pull_journal_bytes(&journal).unwrap(),
16923            )
16924            .unwrap();
16925        let cfg = test_hub_config(
16926            "https://example.test".to_string(),
16927            sandbox.path().join("state"),
16928        );
16929        save_v2_baseline(
16930            &cfg,
16931            TEST_BRAIN_ID,
16932            &root,
16933            &V2SyncBaseline {
16934                v: 2,
16935                origin: "https://example.test".to_string(),
16936                brain: TEST_BRAIN_ID.to_string(),
16937                checkout_id: Some("c".repeat(64)),
16938                head_seq: next.head_seq,
16939                commit_hash: next.commit_hash.clone(),
16940                content_root: Some("f".repeat(64)),
16941                asset_root: None,
16942                assets: Default::default(),
16943                view_kind: next.view_kind.clone(),
16944                view_revision: next.view_revision.clone(),
16945                projection_sha256: None,
16946                files: Default::default(),
16947                local_policy_digest: None,
16948                local_eligibility: Default::default(),
16949                remote_copy_remains: Default::default(),
16950            },
16951        )
16952        .unwrap();
16953
16954        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
16955
16956        assert_eq!(std::fs::read(root.join("records/value.md")).unwrap(), new);
16957        assert!(!root.join(V2_PULL_JOURNAL).exists());
16958        assert!(!root.join(backup_dir).exists());
16959        assert!(!root.join(orphan).exists());
16960    }
16961}