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("delete" | "withdraw_from_hosting") => {
6290                let path = operation
6291                    .get("path")
6292                    .and_then(Value::as_str)
6293                    .ok_or_else(|| invalid_feed("v2 delete has no path"))?;
6294                candidate.remove(path);
6295            }
6296            Some("asset_delete") => {
6297                let path = operation
6298                    .get("path")
6299                    .and_then(Value::as_str)
6300                    .ok_or_else(|| invalid_feed("v2 asset delete has no path"))?;
6301                candidate_assets.remove(path);
6302                asset_changed = true;
6303            }
6304            Some("asset_put" | "asset_resume" | "asset_withdraw") => {
6305                let path = operation
6306                    .get("path")
6307                    .and_then(Value::as_str)
6308                    .ok_or_else(|| invalid_feed("v2 asset write has no path"))?;
6309                let record = local_assets
6310                    .get(path)
6311                    .ok_or_else(|| invalid_feed("v2 asset write has no local record"))?;
6312                let disposition =
6313                    if operation.get("op").and_then(Value::as_str) == Some("asset_withdraw") {
6314                        "withheld"
6315                    } else {
6316                        operation
6317                            .get("asset")
6318                            .and_then(|asset| asset.get("disposition"))
6319                            .and_then(Value::as_str)
6320                            .ok_or_else(|| invalid_feed("v2 asset write has no disposition"))?
6321                    };
6322                candidate_assets.insert(
6323                    path.to_string(),
6324                    V2BaselineAsset {
6325                        blob_sha256: record.sha256.clone(),
6326                        bytes: record.bytes,
6327                        media_type: record.media_type.clone(),
6328                        wrappers: record.wrappers.clone(),
6329                        required: record.required,
6330                        disposition: disposition.to_string(),
6331                        // The server assigns the signed leaf address. Callers
6332                        // that need an exact asset manifest re-read it below.
6333                        leaf_hash: String::new(),
6334                    },
6335                );
6336                asset_changed = true;
6337            }
6338            _ => return Err(invalid_feed("dbmd generated an unsupported v2 operation")),
6339        }
6340    }
6341    Ok(asset_changed)
6342}
6343
6344fn v2_riding_matches_remote(
6345    local: &std::collections::BTreeMap<String, (String, u64)>,
6346    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6347    keeps_home: impl Fn(&str) -> bool,
6348) -> bool {
6349    remote.iter().all(|(path, file)| {
6350        keeps_home(path)
6351            || local.get(path).map(|value| value.0.as_str()) == Some(file.sha256.as_str())
6352    }) && local.iter().all(|(path, (hash, _))| {
6353        remote.get(path).map(|file| file.sha256.as_str()) == Some(hash.as_str())
6354    })
6355}
6356
6357#[derive(Debug, Clone)]
6358struct V2ResolutionOverride {
6359    expected_remote: Option<String>,
6360    selected_local: Option<String>,
6361}
6362
6363#[derive(Debug, Clone)]
6364struct V2UploadSource {
6365    path: String,
6366    bytes: u64,
6367}
6368
6369struct V2SyncPushOptions<'a> {
6370    resume_local_policy: bool,
6371    bulk_confirmation: Option<&'a V2BulkConfirmation>,
6372    resolution: Option<&'a std::collections::BTreeMap<String, V2ResolutionOverride>>,
6373    pulled: Option<V2PulledSnapshot>,
6374    withdrawal_paths: &'a [String],
6375    withdrawal_reason: Option<&'a str>,
6376}
6377
6378fn verify_v2_upload_source(
6379    store: &Store,
6380    path: &str,
6381    sha256: &str,
6382    expected_bytes: u64,
6383) -> LinkResult<()> {
6384    let file = store.open_regular(Path::new(path))?;
6385    if file.metadata()?.len() != expected_bytes || content_sha256_reader(file)? != sha256 {
6386        return Err(LinkError::InvalidPack {
6387            message: format!("local path `{path}` changed during sync planning"),
6388        });
6389    }
6390    Ok(())
6391}
6392
6393fn put_presigned_source(
6394    cfg: &HubConfig,
6395    raw: &str,
6396    headers: &Value,
6397    store: &Store,
6398    source: &V2UploadSource,
6399) -> LinkResult<()> {
6400    let http = presigned_agent(cfg, raw)?;
6401    let mut attempt = 0;
6402    let result = loop {
6403        let file = store.open_regular(Path::new(&source.path))?;
6404        if file.metadata()?.len() != source.bytes {
6405            return Err(LinkError::InvalidPack {
6406                message: format!("local path `{}` changed before upload", source.path),
6407            });
6408        }
6409        let mut req = http
6410            .put(raw)
6411            .set("Content-Length", &source.bytes.to_string());
6412        if let Some(map) = headers.as_object() {
6413            for (name, value) in map {
6414                if let Some(value) = value.as_str() {
6415                    req = req.set(name, value);
6416                }
6417            }
6418        }
6419        match req.send(file) {
6420            Err(ureq::Error::Transport(error))
6421                if is_pre_request_transport(error.kind()) && attempt + 1 < CONNECT_ATTEMPTS =>
6422            {
6423                std::thread::sleep(std::time::Duration::from_millis(
6424                    CONNECT_RETRY_BACKOFF_MS[attempt],
6425                ));
6426                attempt += 1;
6427            }
6428            result => break result,
6429        }
6430    };
6431    match result {
6432        Ok(response) if (200..300).contains(&response.status()) => Ok(()),
6433        Ok(response) => Err(LinkError::Http {
6434            what: "v2 changed-byte upload",
6435            status: response.status(),
6436            message: "object store rejected the upload".to_string(),
6437            code: None,
6438            details: None,
6439        }),
6440        Err(error) => match error {
6441            ureq::Error::Status(412, _) => Ok(()),
6442            ureq::Error::Status(_, response) => Err(LinkError::Http {
6443                what: "v2 changed-byte upload",
6444                status: response.status(),
6445                message: "object store rejected the upload".to_string(),
6446                code: None,
6447                details: None,
6448            }),
6449            ureq::Error::Transport(error) => Err(LinkError::Transport {
6450                hub: "the object store".to_string(),
6451                message: error.to_string(),
6452            }),
6453        },
6454    }
6455}
6456
6457fn v2_sync_push(
6458    cfg: &HubConfig,
6459    requested_brain: &str,
6460    store: &Store,
6461    head: V2VerifiedHead,
6462    options: V2SyncPushOptions<'_>,
6463) -> LinkResult<Value> {
6464    let V2SyncPushOptions {
6465        resume_local_policy,
6466        bulk_confirmation,
6467        resolution,
6468        pulled,
6469        withdrawal_paths,
6470        withdrawal_reason,
6471    } = options;
6472    let _operation_lock = lock_v2_sync_operation(cfg, &head.brain_id)?;
6473    let head = v2_verified_head(cfg, requested_brain)?
6474        .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
6475    let (mut remote, mut remote_assets, carried_local, carried_local_assets) =
6476        match pulled.filter(|snapshot| same_v2_head(&snapshot.head, &head)) {
6477            Some(snapshot) => (
6478                snapshot.files,
6479                snapshot.assets,
6480                Some(snapshot.local),
6481                Some(snapshot.local_assets),
6482            ),
6483            None => (
6484                files_for_v2_view(
6485                    &head,
6486                    v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6487                ),
6488                v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6489                None,
6490                None,
6491            ),
6492        };
6493    let baseline = load_v2_baseline(cfg, &head.brain_id, &store.root)?;
6494    ensure_v2_view_compatible(&head, baseline.as_ref())?;
6495    if head.view_kind == "scoped" && baseline.is_none() {
6496        return Err(LinkError::ScopedViewChanged);
6497    }
6498    let local_view = local_view_for_v2_push(store, &head, baseline.as_ref(), carried_local)?;
6499    let local = &local_view.riding;
6500    let local_assets = match carried_local_assets {
6501        Some(assets) => assets,
6502        None => v2_local_asset_records(store)?,
6503    };
6504    if withdrawal_paths.len() > MAX_PUSH_FILES {
6505        return Err(LinkError::PushTooLarge {
6506            detail: "too many explicit withdrawal paths".to_string(),
6507        });
6508    }
6509    let withdrawal_reason = if withdrawal_paths.is_empty() {
6510        None
6511    } else {
6512        let reason = withdrawal_reason
6513            .map(str::trim)
6514            .filter(|reason| !reason.is_empty() && reason.len() <= 1000)
6515            .ok_or_else(|| LinkError::InvalidPack {
6516                message: "explicit withdrawal requires a non-empty --withdraw-reason of at most 1000 bytes".to_string(),
6517            })?;
6518        Some(reason)
6519    };
6520    let mut withdrawals = withdrawal_paths
6521        .iter()
6522        .map(|path| {
6523            crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
6524                path: error.to_string(),
6525            })
6526        })
6527        .collect::<LinkResult<Vec<_>>>()?;
6528    withdrawals.sort();
6529    withdrawals.dedup();
6530    if withdrawals.len() != withdrawal_paths.len() {
6531        return Err(LinkError::InvalidPack {
6532            message: "explicit withdrawal paths must be unique".to_string(),
6533        });
6534    }
6535    let withdrawal_set = withdrawals.iter().cloned().collect::<BTreeSet<_>>();
6536    let mut consumed_withdrawals = BTreeSet::new();
6537    if let Some(previous) = baseline.as_ref() {
6538        if previous.local_policy_digest.as_deref() != Some(&local_view.policy.digest)
6539            && !resume_local_policy
6540        {
6541            let mut newly_eligible = previous
6542                .local_eligibility
6543                .iter()
6544                .filter(|(path, riding)| !**riding && !local_view.policy.keeps_home(path))
6545                .map(|(path, _)| path.clone())
6546                .collect::<Vec<_>>();
6547            if !newly_eligible.is_empty() {
6548                newly_eligible.truncate(100);
6549                return Err(LinkError::LocalPolicyTransition {
6550                    paths: newly_eligible,
6551                });
6552            }
6553        }
6554    }
6555    let base = match baseline.as_ref() {
6556        Some(state) => &state.files,
6557        None if remote.is_empty() => &remote,
6558        None => {
6559            let mut conflicts = remote
6560                .iter()
6561                .filter(|(path, file)| {
6562                    local.get(*path).map(|value| value.0.as_str()) != Some(file.sha256.as_str())
6563                })
6564                .map(|(path, _)| path.clone())
6565                .collect::<Vec<_>>();
6566            if !conflicts.is_empty() {
6567                conflicts.truncate(100);
6568                let (bundle, paths) =
6569                    create_v2_conflict_bundle(cfg, store, &head, None, local, &remote, &conflicts)?;
6570                return Err(LinkError::ConflictBundle { bundle, paths });
6571            }
6572            &remote
6573        }
6574    };
6575    let all_paths = base
6576        .keys()
6577        .chain(remote.keys())
6578        .chain(local.keys())
6579        .cloned()
6580        .collect::<std::collections::BTreeSet<_>>();
6581    let mut conflicts = Vec::new();
6582    let mut operations = Vec::new();
6583    let mut upload_sources = std::collections::BTreeMap::<String, V2UploadSource>::new();
6584    for path in all_paths {
6585        let base_hash = base.get(&path).map(|file| file.sha256.as_str());
6586        let remote_file = remote.get(&path);
6587        let remote_hash = remote_file.map(|file| file.sha256.as_str());
6588        let local_file = local.get(&path);
6589        let local_hash = local_file.map(|file| file.0.as_str());
6590        if local_hash == base_hash {
6591            continue;
6592        }
6593        if resolution.is_some_and(|allowed| !allowed.contains_key(&path)) {
6594            continue;
6595        }
6596        if local_view.policy.keeps_home(&path) {
6597            // Kept-home is a local transfer exclusion, never an implicit
6598            // delete of the company's already-hosted coordinate.
6599            continue;
6600        }
6601        if remote_hash != base_hash && local_hash != remote_hash {
6602            let explicitly_resolved = resolution
6603                .and_then(|allowed| allowed.get(&path))
6604                .is_some_and(|selected| {
6605                    selected.expected_remote.as_deref() == remote_hash
6606                        && selected.selected_local.as_deref() == local_hash
6607                });
6608            if !explicitly_resolved {
6609                conflicts.push(path);
6610                continue;
6611            }
6612        }
6613        match local_file {
6614            Some((sha256, byte_count)) => {
6615                verify_v2_upload_source(store, &path, sha256, *byte_count)?;
6616                operations.push(json!({
6617                    "op": "put",
6618                    "path": path,
6619                    "expected": v2_expected(remote_file),
6620                    "blob": sha256,
6621                    "bytes": byte_count,
6622                }));
6623                upload_sources
6624                    .entry(sha256.clone())
6625                    .or_insert_with(|| V2UploadSource {
6626                        path: path.clone(),
6627                        bytes: *byte_count,
6628                    });
6629            }
6630            None => {
6631                let Some(current) = remote_file else {
6632                    continue;
6633                };
6634                operations.push(json!({
6635                    "op": "delete",
6636                    "path": path,
6637                    "expected": { "kind": "blob", "hash": current.sha256 },
6638                }));
6639            }
6640        }
6641    }
6642    operations = infer_exact_source_promotions(operations);
6643    for path in &withdrawals {
6644        if local_assets.contains_key(path) {
6645            continue;
6646        }
6647        operations.push(v2_content_withdrawal_operation(
6648            store,
6649            &local_view,
6650            &remote,
6651            path,
6652            withdrawal_reason.expect("non-empty withdrawal set has a reason"),
6653        )?);
6654        consumed_withdrawals.insert(path.clone());
6655    }
6656    if !conflicts.is_empty() {
6657        conflicts.truncate(100);
6658        let (bundle, paths) = create_v2_conflict_bundle(
6659            cfg,
6660            store,
6661            &head,
6662            baseline.as_ref(),
6663            local,
6664            &remote,
6665            &conflicts,
6666        )?;
6667        return Err(LinkError::ConflictBundle { bundle, paths });
6668    }
6669    let base_assets = match baseline.as_ref() {
6670        Some(state) => &state.assets,
6671        None if remote_assets.is_empty() => &remote_assets,
6672        None => {
6673            let mismatched = remote_assets.iter().any(|(path, remote)| {
6674                local_assets.get(path) != Some(&v2_asset_record(remote, path))
6675            }) || local_assets.len() != remote_assets.len();
6676            if mismatched {
6677                return Err(LinkError::Conflict {
6678                    paths: vec!["assets.jsonl".to_string()],
6679                });
6680            }
6681            &remote_assets
6682        }
6683    };
6684    let asset_paths = base_assets
6685        .keys()
6686        .chain(remote_assets.keys())
6687        .chain(local_assets.keys())
6688        .cloned()
6689        .collect::<std::collections::BTreeSet<_>>();
6690    let mut asset_policy_transitions = Vec::new();
6691    for path in asset_paths {
6692        let base_record = base_assets
6693            .get(&path)
6694            .map(|asset| v2_asset_record(asset, &path));
6695        let remote = remote_assets.get(&path);
6696        let remote_record = remote.map(|asset| v2_asset_record(asset, &path));
6697        let local_record = local_assets.get(&path);
6698        if withdrawal_set.contains(&path) {
6699            let record = local_record.ok_or_else(|| LinkError::InvalidPack {
6700                message: format!("asset withdrawal path `{path}` is absent from assets.jsonl"),
6701            })?;
6702            let current = remote.ok_or_else(|| LinkError::InvalidPack {
6703                message: format!(
6704                    "asset withdrawal path `{path}` has no readable hosted coordinate"
6705                ),
6706            })?;
6707            operations.push(v2_asset_withdrawal_operation(
6708                store,
6709                &local_view,
6710                &path,
6711                record,
6712                current,
6713                withdrawal_reason.expect("non-empty withdrawal set has a reason"),
6714            )?);
6715            consumed_withdrawals.insert(path.clone());
6716            continue;
6717        }
6718        let mut raw_present = false;
6719        let mut disposition = "withheld";
6720        let mut resumes_hosting = false;
6721        if let Some(record) = local_record {
6722            crate::linkmd_v2::normalize_path(&record.path)
6723                .map_err(|error| invalid_feed(error.to_string()))?;
6724            let kept_home = local_view.policy.keeps_home(&path);
6725            raw_present = matches!(store.regular_file_exists(Path::new(&path)), Ok(true));
6726            disposition = if kept_home || !raw_present {
6727                "withheld"
6728            } else {
6729                "hosted"
6730            };
6731            if !raw_present && record.required && !kept_home {
6732                return Err(LinkError::InvalidPack {
6733                    message: format!("required asset {path} is missing"),
6734                });
6735            }
6736            resumes_hosting = v2_asset_resumes_hosting(remote, &path, record, disposition);
6737        }
6738        if local_record == base_record.as_ref() && !resumes_hosting {
6739            continue;
6740        }
6741        if remote_record != base_record && local_record != remote_record.as_ref() {
6742            conflicts.push(path);
6743            continue;
6744        }
6745        let Some(record) = local_record else {
6746            if let Some(remote) = remote {
6747                operations.push(json!({
6748                    "op": "asset_delete",
6749                    "path": path,
6750                    "expected": v2_asset_expected(Some(remote)),
6751                }));
6752            }
6753            continue;
6754        };
6755        let raw = if raw_present {
6756            verify_v2_upload_source(store, &path, &record.sha256, record.bytes)?;
6757            Some(())
6758        } else {
6759            None
6760        };
6761        let op = if resumes_hosting {
6762            if !resume_local_policy {
6763                asset_policy_transitions.push(path);
6764                continue;
6765            }
6766            "asset_resume"
6767        } else {
6768            "asset_put"
6769        };
6770        operations.push(json!({
6771            "op": op,
6772            "path": path,
6773            "expected": v2_asset_expected(remote),
6774            "asset": v2_asset_value(record, disposition),
6775        }));
6776        if disposition == "hosted" {
6777            raw.expect("hosted asset was checked present");
6778            upload_sources
6779                .entry(record.sha256.clone())
6780                .or_insert_with(|| V2UploadSource {
6781                    path: path.clone(),
6782                    bytes: record.bytes,
6783                });
6784        }
6785    }
6786    if consumed_withdrawals != withdrawal_set {
6787        let missing = withdrawal_set
6788            .difference(&consumed_withdrawals)
6789            .next()
6790            .expect("different withdrawal sets have one member");
6791        return Err(LinkError::InvalidPack {
6792            message: format!(
6793                "withdrawal path `{missing}` is not a readable content or asset coordinate"
6794            ),
6795        });
6796    }
6797    if !conflicts.is_empty() {
6798        conflicts.truncate(100);
6799        return Err(LinkError::Conflict { paths: conflicts });
6800    }
6801    if !asset_policy_transitions.is_empty() {
6802        asset_policy_transitions.truncate(100);
6803        return Err(LinkError::LocalPolicyTransition {
6804            paths: asset_policy_transitions,
6805        });
6806    }
6807    let touched_sources = operations
6808        .iter()
6809        .filter_map(
6810            |operation| match operation.get("op").and_then(Value::as_str) {
6811                Some("put" | "restore") => operation.get("path").and_then(Value::as_str),
6812                Some("rename") => operation.get("to").and_then(Value::as_str),
6813                _ => None,
6814            },
6815        )
6816        .collect::<std::collections::BTreeSet<_>>();
6817    let withheld_links = local_view
6818        .withheld_links
6819        .iter()
6820        .filter(|link| touched_sources.contains(link.source.as_str()))
6821        .collect::<Vec<_>>();
6822    let checkout_pseudonym = v2_checkout_id(
6823        baseline
6824            .as_ref()
6825            .and_then(|current| current.checkout_id.as_deref()),
6826    )?;
6827    let checkout_id = if withheld_links.is_empty() {
6828        None
6829    } else {
6830        Some(checkout_pseudonym.clone())
6831    };
6832    if operations.is_empty() {
6833        let final_head = v2_verified_head(cfg, requested_brain)?
6834            .ok_or_else(|| invalid_feed("v2 head disappeared during sync"))?;
6835        if !same_v2_head(&head, &final_head) {
6836            return Err(LinkError::RemoteAdvancedDuringSync);
6837        }
6838        let mut final_local = v2_local_files(store)?;
6839        remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
6840        let final_assets = v2_local_asset_records(store)?;
6841        let local_changed = final_local.riding != local_view.riding || final_assets != local_assets;
6842        let remote_ahead = !v2_riding_matches_remote(&final_local.riding, &remote, |path| {
6843            final_local.policy.keeps_home(path)
6844        }) || !v2_asset_records_match_remote(&final_assets, &remote_assets);
6845        let next = v2_baseline_from_head(
6846            cfg,
6847            &head,
6848            remote,
6849            remote_assets,
6850            Some(&final_local),
6851            Some(&checkout_pseudonym),
6852        )?;
6853        let split_count = next.remote_copy_remains.len();
6854        accept_v2_head(cfg, &final_head)?;
6855        if !local_changed && !remote_ahead {
6856            refresh_scoped_view_marker(store, &head, next.files.len())?;
6857            save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
6858        }
6859        return Ok(json!({
6860            "v": 2,
6861            "outcome": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "no_change" },
6862            "sync_status": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "synced" },
6863            "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
6864            "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
6865            "local_policy": {
6866                "remote_copy_remains": split_count,
6867            },
6868        }));
6869    }
6870    let includes_contract = operations
6871        .iter()
6872        .any(|operation| operation.get("path").and_then(Value::as_str) == Some("DB.md"));
6873    let rebase = if head.pointer.is_none() || includes_contract {
6874        "strict"
6875    } else {
6876        "disjoint"
6877    };
6878    let base_value = head.pointer.as_ref().map(|pointer| {
6879        json!({
6880            "seq": pointer.seq,
6881            "commit_hash": pointer.commit_hash,
6882            "content_root": pointer.content_root,
6883            "asset_root": pointer.asset_root,
6884        })
6885    });
6886    // The mutation coordinate is stable for the exact observed base and local
6887    // operation set. A lost response can therefore be retried without a
6888    // duplicate commit/proposal even across process restarts.
6889    let entropy = format!(
6890        "{}\0{}\0{}\0{}\0{}\0{}",
6891        normalized_origin(&cfg.hub)?,
6892        head.brain_id,
6893        serde_json::to_string(&base_value).unwrap_or_default(),
6894        serde_json::to_string(&operations).unwrap_or_default(),
6895        serde_json::to_string(&withheld_links).unwrap_or_default(),
6896        checkout_id.as_deref().unwrap_or("")
6897    );
6898    let mutation_id = format!("dbmd-{}", content_sha256(entropy.as_bytes()));
6899    let changed_bytes = upload_sources.values().try_fold(0_u64, |total, source| {
6900        total
6901            .checked_add(source.bytes)
6902            .ok_or_else(|| LinkError::PushTooLarge {
6903                detail: "v2 changed-byte total overflow".to_string(),
6904            })
6905    })?;
6906    let inline = changed_bytes <= 3 * 1024 * 1024;
6907    let inline_blobs = if inline {
6908        upload_sources
6909            .iter()
6910            .map(|(sha256, source)| {
6911                let bytes = store.read_bounded(Path::new(&source.path), source.bytes)?;
6912                if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != *sha256 {
6913                    return Err(LinkError::InvalidPack {
6914                        message: format!("local path `{}` changed before upload", source.path),
6915                    });
6916                }
6917                Ok(json!({
6918                    "sha256": sha256,
6919                    "bytes": source.bytes,
6920                    "content_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
6921                }))
6922            })
6923            .collect::<LinkResult<Vec<_>>>()?
6924    } else {
6925        Vec::new()
6926    };
6927    let mut body = json!({
6928        "mutation_id": mutation_id,
6929        "base": base_value,
6930        "rebase": rebase,
6931        "reason": "dbmd sync",
6932        "operations": operations,
6933        "blobs": inline_blobs,
6934    });
6935    if !withheld_links.is_empty() {
6936        body["withheld_links"] = serde_json::to_value(&withheld_links)
6937            .map_err(|_| invalid_feed("could not serialize withheld-link observations"))?;
6938        body["checkout_id"] =
6939            Value::String(checkout_id.expect("non-empty withheld links have a checkout pseudonym"));
6940    }
6941    if let Some(confirmation) = bulk_confirmation {
6942        if !crate::ulid::is_ulid(&confirmation.id) || !is_sha256(&confirmation.digest) {
6943            return Err(LinkError::InvalidPack {
6944                message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
6945                    .to_string(),
6946            });
6947        }
6948        // A preview is bound to the exact observed head. Confirmation can
6949        // therefore never use disjoint rebase semantics, even when the normal
6950        // small-mutation path could.
6951        body["rebase"] = Value::String("strict".to_string());
6952        body["bulk_preview_id"] = Value::String(confirmation.id.clone());
6953        body["bulk_preview_digest"] = Value::String(confirmation.digest.clone());
6954    }
6955    if !inline || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
6956        let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
6957        for operation in &operations {
6958            let Some(kind) = operation.get("op").and_then(Value::as_str) else {
6959                return Err(invalid_feed("v2 upload operation has no kind"));
6960            };
6961            let hash = match kind {
6962                "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
6963                "asset_put" | "asset_resume" => operation
6964                    .get("asset")
6965                    .and_then(|asset| asset.get("blob_sha256"))
6966                    .and_then(Value::as_str),
6967                _ => None,
6968            };
6969            let Some(hash) = hash else { continue };
6970            let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
6971            if kind == "rename" {
6972                for field in ["from", "to"] {
6973                    coordinates.insert(
6974                        operation
6975                            .get(field)
6976                            .and_then(Value::as_str)
6977                            .ok_or_else(|| invalid_feed("v2 rename upload has no coordinate"))?
6978                            .to_string(),
6979                    );
6980                }
6981            } else {
6982                let path = operation
6983                    .get("path")
6984                    .and_then(Value::as_str)
6985                    .ok_or_else(|| invalid_feed("v2 upload has no coordinate"))?;
6986                coordinates.insert(if kind.starts_with("asset_") {
6987                    format!("assets/{path}")
6988                } else {
6989                    path.to_string()
6990                });
6991            }
6992        }
6993        let declarations = upload_sources
6994            .iter()
6995            .map(|(sha256, source)| {
6996                json!({
6997                    "sha256": sha256,
6998                    "bytes": source.bytes,
6999                    "coordinates": coordinates_by_hash
7000                        .get(sha256)
7001                        .into_iter()
7002                        .flatten()
7003                        .collect::<Vec<_>>(),
7004                })
7005            })
7006            .collect::<Vec<_>>();
7007        let reserved = ensure_ok(
7008            request(
7009                cfg,
7010                "POST",
7011                &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
7012                Some(&json!({ "blobs": declarations })),
7013                Auth::Required,
7014            )?,
7015            "prepare v2 changed-byte uploads",
7016        )?;
7017        let items = reserved
7018            .get("uploads")
7019            .and_then(Value::as_array)
7020            .ok_or_else(|| invalid_feed("v2 upload reservation response has no items"))?;
7021        if items.len() != upload_sources.len() {
7022            return Err(invalid_feed(
7023                "v2 upload reservation response changed the requested set",
7024            ));
7025        }
7026        let mut references = Vec::with_capacity(items.len());
7027        let mut seen = std::collections::BTreeSet::new();
7028        for item in items {
7029            let sha256 = item
7030                .get("sha256")
7031                .and_then(Value::as_str)
7032                .ok_or_else(|| invalid_feed("v2 upload reservation has no hash"))?;
7033            let source = upload_sources
7034                .get(sha256)
7035                .ok_or_else(|| invalid_feed("v2 upload reservation introduced a blob"))?;
7036            let declared_bytes = item
7037                .get("bytes")
7038                .and_then(Value::as_u64)
7039                .ok_or_else(|| invalid_feed("v2 upload reservation has no byte length"))?;
7040            let reservation_id = item
7041                .get("reservation_id")
7042                .and_then(Value::as_str)
7043                .ok_or_else(|| invalid_feed("v2 upload reservation has no opaque id"))?;
7044            let expected_coordinates = coordinates_by_hash
7045                .get(sha256)
7046                .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinate binding"))?;
7047            let returned_coordinates = item
7048                .get("coordinates")
7049                .and_then(Value::as_array)
7050                .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinates"))?;
7051            if declared_bytes != source.bytes
7052                || !crate::ulid::is_ulid(reservation_id)
7053                || !seen.insert(sha256.to_string())
7054                || returned_coordinates.len() != expected_coordinates.len()
7055                || returned_coordinates
7056                    .iter()
7057                    .zip(expected_coordinates)
7058                    .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
7059            {
7060                return Err(invalid_feed("v2 upload reservation item is inconsistent"));
7061            }
7062            match item.get("status").and_then(Value::as_str) {
7063                Some("upload") => {
7064                    let url = item
7065                        .get("url")
7066                        .and_then(Value::as_str)
7067                        .ok_or_else(|| invalid_feed("v2 upload reservation has no URL"))?;
7068                    put_presigned_source(
7069                        cfg,
7070                        url,
7071                        item.get("headers").unwrap_or(&Value::Null),
7072                        store,
7073                        source,
7074                    )?;
7075                    verify_v2_upload_source(store, &source.path, sha256, source.bytes)?;
7076                }
7077                Some("already_present") => {}
7078                _ => return Err(invalid_feed("v2 upload reservation has an unknown status")),
7079            }
7080            references.push(json!({
7081                "sha256": sha256,
7082                "bytes": source.bytes,
7083                "reservation_id": reservation_id,
7084            }));
7085        }
7086        body["blobs"] = Value::Array(references);
7087    }
7088    if body.to_string().len() > MAX_PUSH_BYTES {
7089        return Err(LinkError::PushTooLarge {
7090            detail: "v2 operation metadata exceeds the bounded commit request".to_string(),
7091        });
7092    }
7093    let path = format!("/api/hub/brains/{requested_brain}/v2/commits");
7094    let mut candidate_hub_signer: Option<String> = None;
7095    let mut response = request(cfg, "POST", &path, Some(&body), Auth::Required)?;
7096    let bulk_preview_required = !(200..300).contains(&response.status)
7097        && response.body.as_ref().is_some_and(|value| {
7098            value.get("code").and_then(Value::as_str) == Some("bulk_preview_required")
7099                || value
7100                    .get("details")
7101                    .and_then(|details| details.get("code"))
7102                    .and_then(Value::as_str)
7103                    == Some("bulk_preview_required")
7104        });
7105    if bulk_preview_required && bulk_confirmation.is_none() {
7106        body["rebase"] = Value::String("strict".to_string());
7107        body["preview_only"] = Value::Bool(true);
7108        let preview = ensure_ok(
7109            request(cfg, "POST", &path, Some(&body), Auth::Required)?,
7110            "v2 bulk preview",
7111        )?;
7112        let preview_code = preview.get("code").and_then(Value::as_str);
7113        let required = preview.get("required").and_then(Value::as_bool);
7114        if preview.get("v").and_then(Value::as_u64) != Some(2)
7115            || preview.get("mutation_id").and_then(Value::as_str) != Some(mutation_id.as_str())
7116            || !matches!(
7117                preview_code,
7118                Some("bulk_preview_created" | "bulk_preview_not_required")
7119            )
7120            || required.is_none()
7121        {
7122            return Err(invalid_feed(
7123                "bulk preview response is not bound to the requested mutation",
7124            ));
7125        }
7126        if required == Some(true) {
7127            let preview_id = preview.get("bulk_preview_id").and_then(Value::as_str);
7128            let preview_digest = preview.get("bulk_preview_digest").and_then(Value::as_str);
7129            if preview_code != Some("bulk_preview_created")
7130                || preview_id.is_none_or(|value| !crate::ulid::is_ulid(value))
7131                || preview_digest.is_none_or(|value| !is_sha256(value))
7132                || preview.get("expires_at").and_then(Value::as_str).is_none()
7133                || !preview.get("impact").is_some_and(Value::is_object)
7134            {
7135                return Err(invalid_feed("bulk preview receipt is malformed"));
7136            }
7137            return Err(LinkError::BulkPreviewRequired { preview });
7138        }
7139        if preview_code != Some("bulk_preview_not_required") {
7140            return Err(invalid_feed("bulk preview requirement is inconsistent"));
7141        }
7142        // The rolling activity window can expire between the refusal and
7143        // preview. Retry once, still pinned strictly to the observed head.
7144        body.as_object_mut()
7145            .expect("v2 commit request is an object")
7146            .remove("preview_only");
7147        response = request(cfg, "POST", &path, Some(&body), Auth::Required)?;
7148    }
7149    let mut result = ensure_ok(response, "v2 sync push")?;
7150    if result.get("code").and_then(Value::as_str) == Some("proposal_queued") {
7151        if let Some(object) = result.as_object_mut() {
7152            object.insert(
7153                "sync_status".to_string(),
7154                Value::String("proposal_pending".to_string()),
7155            );
7156        }
7157        return Ok(result);
7158    }
7159    if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
7160        let challenge = result
7161            .get("signing_challenge")
7162            .ok_or_else(|| invalid_feed("self-custody response has no signing challenge"))?;
7163        let mut expected_candidate = remote.clone();
7164        let mut expected_candidate_assets = remote_assets.clone();
7165        apply_generated_v2_operations(
7166            &operations,
7167            &local_assets,
7168            &mut expected_candidate,
7169            &mut expected_candidate_assets,
7170        )?;
7171        let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
7172            cfg,
7173            &head,
7174            &expected_candidate,
7175            &expected_candidate_assets,
7176            &mutation_id,
7177            &body,
7178            challenge,
7179        )?;
7180        body["signing_challenge_id"] = Value::String(challenge_id);
7181        body["signature_base64url"] = Value::String(signature);
7182        candidate_hub_signer = Some(actor_signer);
7183        result = ensure_ok(
7184            request(cfg, "POST", &path, Some(&body), Auth::Required)?,
7185            "v2 self-custody commit",
7186        )?;
7187    }
7188    let refreshed = v2_verified_head(cfg, requested_brain)?
7189        .ok_or_else(|| invalid_feed("v2 head disappeared after commit"))?;
7190    if candidate_hub_signer
7191        .as_ref()
7192        .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
7193    {
7194        return Err(invalid_feed(
7195            "self-custody actor signer differs from the committed hub pointer signer",
7196        ));
7197    }
7198    let accepted_hash = result.get("commit_hash").and_then(Value::as_str);
7199    if refreshed
7200        .pointer
7201        .as_ref()
7202        .map(|pointer| pointer.commit_hash.as_str())
7203        != accepted_hash
7204    {
7205        return Err(LinkError::RemoteAdvancedDuringSync);
7206    }
7207    ensure_v2_view_compatible(&refreshed, baseline.as_ref())?;
7208    let rebased = result
7209        .get("rebased")
7210        .and_then(Value::as_bool)
7211        .ok_or_else(|| invalid_feed("v2 commit receipt has no rebase result"))?;
7212    let (refreshed_files, refreshed_assets) = if rebased {
7213        (
7214            files_for_v2_view(
7215                &refreshed,
7216                v2_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
7217            ),
7218            v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
7219        )
7220    } else {
7221        let asset_changed = apply_generated_v2_operations(
7222            &operations,
7223            &local_assets,
7224            &mut remote,
7225            &mut remote_assets,
7226        )?;
7227        let assets = if asset_changed {
7228            // Asset leaf hashes are server-generated signed coordinates; only
7229            // asset-changing commits need this usually tiny post-commit read.
7230            v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?
7231        } else {
7232            remote_assets
7233        };
7234        (remote, assets)
7235    };
7236    let mut final_local = v2_local_files(store)?;
7237    remove_scoped_projection(&refreshed, baseline.as_ref(), &mut final_local)?;
7238    let final_assets = v2_local_asset_records(store)?;
7239    let local_dirty = final_local.riding != local_view.riding
7240        || !v2_riding_matches_remote(&final_local.riding, &refreshed_files, |path| {
7241            final_local.policy.keeps_home(path)
7242        })
7243        || final_assets != local_assets
7244        || !v2_asset_records_match_remote(&final_assets, &refreshed_assets);
7245    let next = v2_baseline_from_head(
7246        cfg,
7247        &refreshed,
7248        refreshed_files,
7249        refreshed_assets,
7250        Some(&final_local),
7251        Some(&checkout_pseudonym),
7252    )?;
7253    let split_count = next.remote_copy_remains.len();
7254    accept_v2_head(cfg, &refreshed)?;
7255    if !local_dirty {
7256        refresh_scoped_view_marker(store, &refreshed, next.files.len())?;
7257        save_v2_baseline(cfg, &refreshed.brain_id, &store.root, &next)?;
7258    }
7259    if let Some(object) = result.as_object_mut() {
7260        object.insert(
7261            "local_policy".to_string(),
7262            json!({ "remote_copy_remains": split_count }),
7263        );
7264        object.insert(
7265            "sync_status".to_string(),
7266            Value::String(if local_dirty {
7267                "remote_committed_local_dirty".to_string()
7268            } else {
7269                "synced".to_string()
7270            }),
7271        );
7272    }
7273    Ok(result)
7274}
7275
7276/// Negotiate v2 and send only local changes. A v1 hub retains the existing
7277/// whole-snapshot behavior until its brain advertises the new profile.
7278pub fn sync_push_incremental(cfg: &HubConfig, brain: &str, store: &Store) -> LinkResult<Value> {
7279    sync_push_incremental_with_policy(cfg, brain, store, false)
7280}
7281
7282/// The explicit `.sevralocal` adoption form. Ordinary sync never uploads a
7283/// path that became eligible because local policy was weakened or removed.
7284pub fn sync_push_incremental_with_policy(
7285    cfg: &HubConfig,
7286    brain: &str,
7287    store: &Store,
7288    resume_local_policy: bool,
7289) -> LinkResult<Value> {
7290    sync_push_incremental_with_options(cfg, brain, store, resume_local_policy, None)
7291}
7292
7293/// Incremental push with both local-policy adoption and an optional exact
7294/// permissioned bulk-preview confirmation.
7295pub fn sync_push_incremental_with_options(
7296    cfg: &HubConfig,
7297    brain: &str,
7298    store: &Store,
7299    resume_local_policy: bool,
7300    bulk_confirmation: Option<&V2BulkConfirmation>,
7301) -> LinkResult<Value> {
7302    sync_push_incremental_with_controls(
7303        cfg,
7304        brain,
7305        store,
7306        resume_local_policy,
7307        bulk_confirmation,
7308        &[],
7309        None,
7310    )
7311}
7312
7313/// Incremental push with explicit permissioned current-host withdrawal.
7314pub fn sync_push_incremental_with_controls(
7315    cfg: &HubConfig,
7316    brain: &str,
7317    store: &Store,
7318    resume_local_policy: bool,
7319    bulk_confirmation: Option<&V2BulkConfirmation>,
7320    withdrawal_paths: &[String],
7321    withdrawal_reason: Option<&str>,
7322) -> LinkResult<Value> {
7323    require_safe_ref(brain)?;
7324    if let Some(head) = v2_verified_head(cfg, brain)? {
7325        return v2_sync_push(
7326            cfg,
7327            brain,
7328            store,
7329            head,
7330            V2SyncPushOptions {
7331                resume_local_policy,
7332                bulk_confirmation,
7333                resolution: None,
7334                pulled: None,
7335                withdrawal_paths,
7336                withdrawal_reason,
7337            },
7338        );
7339    }
7340    if !withdrawal_paths.is_empty() {
7341        return Err(LinkError::InvalidPack {
7342            message: "explicit withdrawal requires a link.md v2 brain".to_string(),
7343        });
7344    }
7345    legacy_sync_push_incremental(cfg, brain, store, resume_local_policy, bulk_confirmation)
7346}
7347
7348/// Whether this exact hub/brain/path coordinate has an accepted v2 sync
7349/// baseline. CLI dispatch uses this to keep checkout identity sticky even when
7350/// a damaged `DB.md` makes strict store-open fail.
7351pub fn has_v2_sync_baseline(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<bool> {
7352    require_safe_ref(brain)?;
7353    Ok(load_v2_baseline(cfg, brain, checkout)?.is_some())
7354}
7355
7356#[cfg(windows)]
7357fn legacy_sync_push_incremental(
7358    _cfg: &HubConfig,
7359    _brain: &str,
7360    _store: &Store,
7361    _resume_local_policy: bool,
7362    _bulk_confirmation: Option<&V2BulkConfirmation>,
7363) -> LinkResult<Value> {
7364    Err(LinkError::UnsupportedPlatform {
7365        operation: "legacy v1 whole-snapshot push on Windows; upgrade the brain to link.md v2",
7366    })
7367}
7368
7369#[cfg(not(windows))]
7370fn legacy_sync_push_incremental(
7371    cfg: &HubConfig,
7372    brain: &str,
7373    store: &Store,
7374    resume_local_policy: bool,
7375    bulk_confirmation: Option<&V2BulkConfirmation>,
7376) -> LinkResult<Value> {
7377    if resume_local_policy || bulk_confirmation.is_some() {
7378        return Err(LinkError::InvalidPack {
7379            message: "v2 sync options require a link.md v2 brain".to_string(),
7380        });
7381    }
7382    let files = collect_push_files(store)?;
7383    sync_push(cfg, brain, &files)
7384}
7385
7386/// One explicit resolution for a previously preserved private conflict bundle.
7387#[derive(Debug, Clone)]
7388pub enum V2ConflictChoice {
7389    KeepLocal,
7390    TakeRemote,
7391    From(PathBuf),
7392}
7393
7394fn load_v2_conflict_plan(store: &Store, bundle: &str) -> LinkResult<V2ConflictPlan> {
7395    if !crate::ulid::is_ulid(bundle) {
7396        return Err(LinkError::InvalidPack {
7397            message: "conflict bundle must be a lowercase ULID".to_string(),
7398        });
7399    }
7400    let bytes = store.read_bounded(&v2_conflict_relative(bundle, "plan.json"), 1024 * 1024)?;
7401    let plan: V2ConflictPlan = serde_json::from_slice(&bytes)
7402        .map_err(|_| invalid_feed("private conflict plan is corrupt"))?;
7403    if plan.v != 2
7404        || plan.class != "content_resolution_required"
7405        || plan.bundle != bundle
7406        || !crate::ulid::is_ulid(&plan.brain)
7407        || plan.files.is_empty()
7408        || plan.files.len() > 100
7409        || plan.files.iter().any(|file| {
7410            crate::linkmd_v2::normalize_path(&file.path).is_err()
7411                || [&file.base, &file.local, &file.remote]
7412                    .into_iter()
7413                    .any(|coordinate| {
7414                        coordinate
7415                            .sha256
7416                            .as_deref()
7417                            .is_some_and(|hash| !is_sha256(hash))
7418                            || coordinate.file.as_deref().is_some_and(|name| {
7419                                name.starts_with('/')
7420                                    || name
7421                                        .split('/')
7422                                        .any(|part| part.is_empty() || part == "." || part == "..")
7423                            })
7424                    })
7425        })
7426    {
7427        return Err(invalid_feed("private conflict plan failed validation"));
7428    }
7429    Ok(plan)
7430}
7431
7432/// Inspect or prune private conflict-control state. Ordinary pruning removes
7433/// only expired completed bundles and interrupted bundles with no `plan.json`;
7434/// corrupt completed plans fail closed. `all` is an explicit local discard of
7435/// every well-addressed bundle and never changes hosted brain data.
7436pub fn sync_conflicts(checkout: &Path, prune: bool, all: bool) -> LinkResult<Value> {
7437    require_hardened_filesystem("private conflict maintenance")?;
7438    if all && !prune {
7439        return Err(LinkError::InvalidPack {
7440            message: "discarding all conflict bundles requires prune=true".to_string(),
7441        });
7442    }
7443    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
7444        message: format!("conflict checkout is not a valid db.md store: {error}"),
7445    })?;
7446    let _transaction = store.transaction()?;
7447    let root = Path::new(".dbmd/conflicts");
7448    let names = match store.directory_names(root) {
7449        Ok(names) => names,
7450        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
7451        Err(error) => return Err(error.into()),
7452    };
7453    let now = SystemTime::now()
7454        .duration_since(UNIX_EPOCH)
7455        .unwrap_or_default()
7456        .as_secs();
7457    let mut bundles = Vec::new();
7458    let mut pruned = 0_u64;
7459    for name in names {
7460        let Some(bundle) = name.to_str().filter(|value| crate::ulid::is_ulid(value)) else {
7461            continue;
7462        };
7463        let plan_path = v2_conflict_relative(bundle, "plan.json");
7464        let plan_exists = store.regular_file_exists(&plan_path)?;
7465        let expired = if plan_exists {
7466            match load_v2_conflict_plan(&store, bundle) {
7467                Ok(plan) => plan.expires_unix < now,
7468                Err(error) if all => {
7469                    let _ = error;
7470                    true
7471                }
7472                Err(error) => return Err(error),
7473            }
7474        } else {
7475            true
7476        };
7477        if prune && (all || expired) {
7478            store.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
7479            pruned += 1;
7480            continue;
7481        }
7482        bundles.push(json!({
7483            "bundle": bundle,
7484            "complete": plan_exists,
7485            "expired": expired,
7486        }));
7487    }
7488    Ok(json!({
7489        "v": 2,
7490        "class": "private_conflict_state",
7491        "bundles": bundles.len(),
7492        "pruned": pruned,
7493        "items": bundles,
7494    }))
7495}
7496
7497/// Resolve one exact private conflict plan. Drift never lowers a precondition:
7498/// the remote head/view and every original local coordinate are rechecked
7499/// before either an explicit local install or a fresh normal commit.
7500pub fn sync_resolve_conflict(
7501    cfg: &HubConfig,
7502    checkout: &Path,
7503    bundle: &str,
7504    choice: V2ConflictChoice,
7505    bulk_confirmation: Option<&V2BulkConfirmation>,
7506) -> LinkResult<Value> {
7507    require_hardened_filesystem("conflict resolution")?;
7508    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
7509        message: format!("conflict checkout is not a valid db.md store: {error}"),
7510    })?;
7511    let plan = load_v2_conflict_plan(&store, bundle)?;
7512    if plan.origin != normalized_origin(&cfg.hub)? {
7513        return Err(invalid_feed(
7514            "conflict bundle belongs to another hub origin",
7515        ));
7516    }
7517    let now = SystemTime::now()
7518        .duration_since(UNIX_EPOCH)
7519        .unwrap_or_default()
7520        .as_secs();
7521    if now > plan.expires_unix {
7522        return Err(LinkError::InvalidPack {
7523            message: "conflict bundle expired; rerun sync to obtain current coordinates"
7524                .to_string(),
7525        });
7526    }
7527    let head = v2_verified_head(cfg, &plan.brain)?
7528        .ok_or_else(|| invalid_feed("conflict brain no longer advertises v2"))?;
7529    let pointer = head.pointer.as_ref();
7530    if pointer.map_or(0, |value| value.seq) != plan.remote_seq
7531        || pointer.map(|value| value.commit_hash.as_str()) != plan.remote_commit.as_deref()
7532        || pointer.and_then(|value| value.content_root.as_deref())
7533            != plan.remote_content_root.as_deref()
7534        || head.view_kind != plan.view_kind
7535        || head.view_revision != plan.view_revision
7536    {
7537        return Err(LinkError::RemoteAdvancedDuringSync);
7538    }
7539
7540    // A resolution never overwrites a post-bundle local edit.
7541    for file in &plan.files {
7542        let actual = match store.regular_file_exists(Path::new(&file.path))? {
7543            true => Some(content_sha256(&store.read_bounded(
7544                Path::new(&file.path),
7545                file.local.bytes.unwrap_or(MAX_STORE_BYTES),
7546            )?)),
7547            false => None,
7548        };
7549        if actual.as_deref() != file.local.sha256.as_deref() {
7550            return Err(LinkError::InvalidPack {
7551                message: format!(
7552                    "local conflict path `{}` changed after the bundle was created",
7553                    file.path
7554                ),
7555            });
7556        }
7557    }
7558
7559    let from_source = match &choice {
7560        V2ConflictChoice::From(source) => Some(source.clone()),
7561        _ => None,
7562    };
7563    let result = match choice {
7564        V2ConflictChoice::TakeRemote => {
7565            if bulk_confirmation.is_some() {
7566                return Err(LinkError::InvalidPack {
7567                    message: "bulk confirmation applies to keep-local/from commits, not a local take-remote install".to_string(),
7568                });
7569            }
7570            // Re-read the exact current manifest so every selected remote byte
7571            // retains its signed inclusion proof. The private bundle is useful
7572            // for inspection, but is never promoted into live state as trust.
7573            let current_remote =
7574                files_for_v2_view(&head, v2_manifest(cfg, &plan.brain, head.pointer.as_ref())?);
7575            let _ = v2_take_remote_selection(&plan.files, &current_remote)?;
7576            let selected = plan
7577                .files
7578                .iter()
7579                .map(|file| file.path.clone())
7580                .collect::<std::collections::BTreeSet<_>>();
7581            serde_json::to_value(
7582                v2_sync_pull_with_resolution(
7583                    cfg,
7584                    &plan.brain,
7585                    head,
7586                    Some(checkout),
7587                    Some(&selected),
7588                )?
7589                .report,
7590            )
7591            .map_err(|_| invalid_feed("could not serialize conflict pull receipt"))?
7592        }
7593        V2ConflictChoice::KeepLocal | V2ConflictChoice::From(_) => {
7594            if let Some(source) = from_source.as_ref() {
7595                if plan.files.len() != 1 {
7596                    return Err(LinkError::InvalidPack {
7597                        message: "--from requires a bundle with exactly one conflict".to_string(),
7598                    });
7599                }
7600                let candidate = crate::fsx::read_bounded_nofollow(source, MAX_STORE_BYTES)?;
7601                if std::str::from_utf8(&candidate).is_err() {
7602                    return Err(LinkError::NotUtf8 {
7603                        path: source.display().to_string(),
7604                    });
7605                }
7606                store.write_atomic(Path::new(&plan.files[0].path), &candidate)?;
7607            }
7608            let refreshed_store =
7609                Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
7610                    message: format!("resolved checkout is not a valid db.md store: {error}"),
7611                })?;
7612            let mut overrides = std::collections::BTreeMap::new();
7613            for file in &plan.files {
7614                let selected_local = match refreshed_store
7615                    .regular_file_exists(Path::new(&file.path))?
7616                {
7617                    true => Some(content_sha256(
7618                        &refreshed_store.read_bounded(Path::new(&file.path), MAX_STORE_BYTES)?,
7619                    )),
7620                    false => None,
7621                };
7622                overrides.insert(
7623                    file.path.clone(),
7624                    V2ResolutionOverride {
7625                        expected_remote: file.remote.sha256.clone(),
7626                        selected_local,
7627                    },
7628                );
7629            }
7630            v2_sync_push(
7631                cfg,
7632                &plan.brain,
7633                &refreshed_store,
7634                head,
7635                V2SyncPushOptions {
7636                    resume_local_policy: true,
7637                    bulk_confirmation,
7638                    resolution: Some(&overrides),
7639                    pulled: None,
7640                    withdrawal_paths: &[],
7641                    withdrawal_reason: None,
7642                },
7643            )?
7644        }
7645    };
7646
7647    if result.get("code").and_then(Value::as_str) != Some("proposal_queued") {
7648        let installed = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
7649            message: format!("resolved checkout is not a valid db.md store: {error}"),
7650        })?;
7651        installed.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
7652    }
7653    Ok(json!({
7654        "v": 2,
7655        "class": "auto_converged",
7656        "bundle": bundle,
7657        "receipt": result,
7658    }))
7659}
7660
7661/// Converge one established permissioned-v2 checkout in both directions.
7662///
7663/// The pull half first performs the ordinary three-way conflict check and
7664/// crash-safe install against the accepted baseline. The push half then opens
7665/// that exact installed checkout, takes its local transaction guard, computes
7666/// a fresh three-way delta, and submits it with mandatory preconditions. A
7667/// remote movement between halves is therefore a normal rebase/conflict input,
7668/// never a blind overwrite. Legacy v1 remains explicit pull-only/push-only so
7669/// its whole-pack replacement semantics are never presented as granular
7670/// convergence.
7671pub fn sync_converge(
7672    cfg: &HubConfig,
7673    brain: &str,
7674    checkout: &Path,
7675    resume_local_policy: bool,
7676) -> LinkResult<Value> {
7677    sync_converge_with_options(cfg, brain, checkout, resume_local_policy, None)
7678}
7679
7680/// Bidirectional convergence with optional exact bulk-preview confirmation.
7681pub fn sync_converge_with_options(
7682    cfg: &HubConfig,
7683    brain: &str,
7684    checkout: &Path,
7685    resume_local_policy: bool,
7686    bulk_confirmation: Option<&V2BulkConfirmation>,
7687) -> LinkResult<Value> {
7688    sync_converge_with_controls(
7689        cfg,
7690        brain,
7691        checkout,
7692        resume_local_policy,
7693        bulk_confirmation,
7694        &[],
7695        None,
7696    )
7697}
7698
7699/// Bidirectional convergence with optional exact withdrawal intents.
7700pub fn sync_converge_with_controls(
7701    cfg: &HubConfig,
7702    brain: &str,
7703    checkout: &Path,
7704    resume_local_policy: bool,
7705    bulk_confirmation: Option<&V2BulkConfirmation>,
7706    withdrawal_paths: &[String],
7707    withdrawal_reason: Option<&str>,
7708) -> LinkResult<Value> {
7709    require_hardened_filesystem("bidirectional sync")?;
7710    require_safe_ref(brain)?;
7711    let head = v2_verified_head(cfg, brain)?.ok_or_else(|| LinkError::InvalidPack {
7712        message:
7713            "bidirectional sync requires link.md v2; use --pull-only or --push-only for a legacy brain"
7714                .to_string(),
7715    })?;
7716    let pulled = v2_sync_pull_with_resolution(cfg, brain, head, Some(checkout), None)?;
7717    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
7718        message: format!("installed v2 checkout is not a valid db.md store: {error}"),
7719    })?;
7720    let _transaction = store.transaction()?;
7721    let pulled_report = pulled.report.clone();
7722    let pulled_head = pulled.head.clone();
7723    let mut result = v2_sync_push(
7724        cfg,
7725        brain,
7726        &store,
7727        pulled_head,
7728        V2SyncPushOptions {
7729            resume_local_policy,
7730            bulk_confirmation,
7731            resolution: None,
7732            pulled: Some(pulled),
7733            withdrawal_paths,
7734            withdrawal_reason,
7735        },
7736    )?;
7737    if let Some(object) = result.as_object_mut() {
7738        object.insert("pulled_files".to_string(), json!(pulled_report.files));
7739        object.insert("checkout".to_string(), Value::String(pulled_report.dest));
7740        object.insert(
7741            "mode".to_string(),
7742            Value::String("bidirectional".to_string()),
7743        );
7744    }
7745    Ok(result)
7746}
7747
7748/// Pull the granted slice of `brain` to `out` (default: `./<slug>`). Every
7749/// exported path is safety-gated before it touches disk; files are written
7750/// atomically; nothing local is ever deleted (locals the export lacks are
7751/// *reported* in `extra_local` instead). Returns the report; rebuilding the
7752/// local index catalog afterwards is the caller's (cheap, optional) step.
7753pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
7754    require_hardened_filesystem("sync pull")?;
7755    require_safe_ref(brain)?;
7756    if let Some(head) = v2_verified_head(cfg, brain)? {
7757        return v2_sync_pull(cfg, brain, head, out);
7758    }
7759    legacy_sync_pull(cfg, brain, out)
7760}
7761
7762#[cfg(windows)]
7763fn legacy_sync_pull(_cfg: &HubConfig, _brain: &str, _out: Option<&Path>) -> LinkResult<PullReport> {
7764    Err(LinkError::UnsupportedPlatform {
7765        operation: "legacy v1 whole-snapshot pull on Windows; upgrade the brain to link.md v2",
7766    })
7767}
7768
7769#[cfg(not(windows))]
7770fn legacy_sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
7771    let remote = verified_remote_head(cfg, brain, false)?;
7772    if !remote.head.verified {
7773        return Err(invalid_feed(
7774            "the grant exposes head movement but no signed snapshot; refusing an unverifiable pull",
7775        ));
7776    }
7777    let snapshot_hash = remote.head.feed_hash.as_deref().unwrap_or("none");
7778    let path = format!(
7779        "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={snapshot_hash}",
7780        remote.head.seq
7781    );
7782    let body = ensure_ok(
7783        request(cfg, "GET", &path, None, Auth::Required)?,
7784        "sync pull",
7785    )?;
7786    if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
7787        || body.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
7788    {
7789        return Err(invalid_feed(
7790            "export response is not bound to the verified snapshot token",
7791        ));
7792    }
7793
7794    let remote_slug = body
7795        .get("slug")
7796        .and_then(Value::as_str)
7797        .filter(|slug| is_safe_slug(slug));
7798    let slug = remote_slug
7799        .or_else(|| is_safe_slug(brain).then_some(brain))
7800        .unwrap_or("brain")
7801        .to_string();
7802    let brain_id = body
7803        .get("brain")
7804        .and_then(Value::as_str)
7805        .unwrap_or(&remote.head.brain)
7806        .to_string();
7807    if brain_id != remote.head.brain {
7808        return Err(invalid_feed(
7809            "export response names a different brain than the verified head",
7810        ));
7811    }
7812    let head_seq = remote.head.seq;
7813    let dest: PathBuf = match out {
7814        Some(p) => p.to_path_buf(),
7815        None => PathBuf::from(&slug),
7816    };
7817    let entries = if head_seq == 0 {
7818        let files = body
7819            .get("files")
7820            .and_then(Value::as_array)
7821            .ok_or_else(|| invalid_feed("empty snapshot export did not carry an empty manifest"))?;
7822        if !files.is_empty() || body.get("url").is_some() {
7823            return Err(invalid_feed(
7824                "empty signed feed cannot authorize non-empty exported content",
7825            ));
7826        }
7827        Vec::new()
7828    } else {
7829        let signed_head = remote
7830            .head_entry
7831            .as_ref()
7832            .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
7833        let expected = &signed_head.entry.pack_sha256;
7834        if !is_sha256(expected) {
7835            return Err(invalid_feed(
7836                "signed head carries an invalid snapshot pack digest",
7837            ));
7838        }
7839        if let Some(url) = body.get("url").and_then(Value::as_str) {
7840            if body.get("sha256").and_then(Value::as_str) != Some(expected.as_str()) {
7841                return Err(invalid_feed(
7842                    "export pack digest does not match the signed head entry",
7843                ));
7844            }
7845            let bytes = get_presigned(cfg, url)?;
7846            let actual = format!("{:x}", Sha256::digest(&bytes));
7847            if actual != *expected {
7848                return Err(LinkError::InvalidPack {
7849                    message: "downloaded pack does not match the signed snapshot digest"
7850                        .to_string(),
7851                });
7852            }
7853            let entries = parse_store_pack(bytes)?;
7854            if signed_head.entry.kind == "push" {
7855                verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
7856            }
7857            entries
7858        } else {
7859            if signed_head.entry.kind != "push" {
7860                return Err(invalid_feed(
7861                    "delta snapshots must export the exact signed pack",
7862                ));
7863            }
7864            let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
7865                invalid_feed("verified snapshot export carried neither a pack nor files")
7866            })?;
7867            let mut entries = Vec::with_capacity(files.len());
7868            for file in files {
7869                let path = file
7870                    .get("path")
7871                    .and_then(Value::as_str)
7872                    .ok_or_else(|| invalid_feed("exported file path is not a string"))?;
7873                let content = file
7874                    .get("content")
7875                    .and_then(Value::as_str)
7876                    .ok_or_else(|| invalid_feed("exported inline content is not UTF-8 text"))?;
7877                entries.push((path.to_string(), content.as_bytes().to_vec()));
7878            }
7879            verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
7880            entries
7881        }
7882    };
7883
7884    // Gate the complete manifest before the first filesystem mutation.
7885    let mut seen = std::collections::HashSet::new();
7886    for (path, _) in &entries {
7887        if !safe_store_rel_path(path) {
7888            return Err(LinkError::UnsafePath { path: path.clone() });
7889        }
7890        if !seen.insert(path) {
7891            return Err(LinkError::InvalidPack {
7892                message: format!("duplicate path `{path}`"),
7893            });
7894        }
7895    }
7896    // Compute divergence against the still-live tree. The staged clone keeps
7897    // these extra locals byte-for-byte while overlaying the signed snapshot.
7898    let pulled: std::collections::BTreeSet<&str> =
7899        entries.iter().map(|(p, _)| p.as_str()).collect();
7900    let mut extra_local = Vec::new();
7901    if let Ok(store) = Store::open(&dest) {
7902        if let Ok(walked) = store.walk() {
7903            for rel in walked {
7904                let rel_str = rel.to_string_lossy().replace('\\', "/");
7905                if !pulled.contains(rel_str.as_str()) {
7906                    extra_local.push(rel_str);
7907                }
7908            }
7909        }
7910    }
7911    #[cfg(unix)]
7912    install_pulled_snapshot(&dest, &entries)?;
7913
7914    Ok(PullReport {
7915        brain: brain_id,
7916        slug,
7917        head_seq,
7918        files: entries.len(),
7919        dest: dest.to_string_lossy().into_owned(),
7920        extra_local,
7921        sync_status: "synced".to_string(),
7922    })
7923}
7924
7925#[cfg(unix)]
7926fn c_name(bytes: &[u8], display: &str) -> LinkResult<std::ffi::CString> {
7927    std::ffi::CString::new(bytes).map_err(|_| LinkError::UnsafePath {
7928        path: display.to_string(),
7929    })
7930}
7931
7932#[cfg(unix)]
7933fn open_dir_at(
7934    parent: std::os::fd::RawFd,
7935    name: &std::ffi::CStr,
7936    display: &str,
7937) -> LinkResult<std::fs::File> {
7938    use std::os::fd::FromRawFd as _;
7939    let fd = unsafe {
7940        libc::openat(
7941            parent,
7942            name.as_ptr(),
7943            libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
7944        )
7945    };
7946    if fd < 0 {
7947        return Err(LinkError::UnsafePath {
7948            path: display.to_string(),
7949        });
7950    }
7951    Ok(unsafe { std::fs::File::from_raw_fd(fd) })
7952}
7953
7954/// Open (and, where absent, create) a directory path without following a
7955/// symlink in any component. The returned directory capability remains bound
7956/// to the opened inode even if an attacker renames or replaces an ancestor.
7957#[cfg(unix)]
7958fn open_dir_path_nofollow(path: &Path, create: bool) -> LinkResult<std::fs::File> {
7959    use std::os::fd::AsRawFd as _;
7960
7961    // macOS exposes a few system-owned compatibility symlinks at the root.
7962    // Normalize only those fixed OS aliases; never canonicalize an arbitrary
7963    // caller-controlled ancestor.
7964    #[cfg(target_os = "macos")]
7965    let normalized = [("/var", "/private/var"), ("/tmp", "/private/tmp")]
7966        .into_iter()
7967        .find_map(|(alias, real)| {
7968            path.strip_prefix(alias)
7969                .ok()
7970                .map(|rest| Path::new(real).join(rest))
7971        })
7972        .unwrap_or_else(|| path.to_path_buf());
7973    #[cfg(not(target_os = "macos"))]
7974    let normalized = path.to_path_buf();
7975
7976    let start = if normalized.is_absolute() {
7977        std::fs::File::open("/")?
7978    } else {
7979        std::fs::File::open(".")?
7980    };
7981    let mut directory = start;
7982    for component in normalized.components() {
7983        use std::path::Component;
7984        let name = match component {
7985            Component::RootDir | Component::CurDir => continue,
7986            Component::Normal(name) => name,
7987            Component::ParentDir | Component::Prefix(_) => {
7988                return Err(LinkError::UnsafePath {
7989                    path: path.display().to_string(),
7990                });
7991            }
7992        };
7993        use std::os::unix::ffi::OsStrExt as _;
7994        let name = c_name(name.as_bytes(), &path.display().to_string())?;
7995        if create {
7996            let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
7997            if made != 0 {
7998                let error = std::io::Error::last_os_error();
7999                if error.raw_os_error() != Some(libc::EEXIST) {
8000                    return Err(error.into());
8001                }
8002            }
8003        }
8004        directory = open_dir_at(directory.as_raw_fd(), &name, &path.display().to_string())?;
8005    }
8006    Ok(directory)
8007}
8008
8009#[cfg(unix)]
8010fn open_or_create_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
8011    open_dir_path_nofollow(path, true)
8012}
8013
8014#[cfg(unix)]
8015fn open_existing_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
8016    open_dir_path_nofollow(path, false)
8017}
8018
8019#[cfg(unix)]
8020fn entry_is_dir_at(parent: std::os::fd::RawFd, name: &std::ffi::CStr) -> LinkResult<Option<bool>> {
8021    let mut stat: libc::stat = unsafe { std::mem::zeroed() };
8022    let result =
8023        unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) };
8024    if result == 0 {
8025        return Ok(Some((stat.st_mode & libc::S_IFMT) == libc::S_IFDIR));
8026    }
8027    let error = std::io::Error::last_os_error();
8028    if error.kind() == std::io::ErrorKind::NotFound {
8029        Ok(None)
8030    } else {
8031        Err(error.into())
8032    }
8033}
8034
8035#[cfg(unix)]
8036fn create_dir_exclusive_at(
8037    parent: std::os::fd::RawFd,
8038    name: &std::ffi::CStr,
8039    display: &str,
8040) -> LinkResult<std::fs::File> {
8041    let made = unsafe { libc::mkdirat(parent, name.as_ptr(), 0o700) };
8042    if made != 0 {
8043        return Err(LinkError::UnsafePath {
8044            path: display.to_string(),
8045        });
8046    }
8047    open_dir_at(parent, name, display)
8048}
8049
8050#[cfg(unix)]
8051fn directory_entry_names(directory: &std::fs::File) -> LinkResult<Vec<std::ffi::CString>> {
8052    use std::os::fd::AsRawFd as _;
8053
8054    let duplicate = unsafe { libc::dup(directory.as_raw_fd()) };
8055    if duplicate < 0 {
8056        return Err(std::io::Error::last_os_error().into());
8057    }
8058    let stream = unsafe { libc::fdopendir(duplicate) };
8059    if stream.is_null() {
8060        let error = std::io::Error::last_os_error();
8061        unsafe {
8062            libc::close(duplicate);
8063        }
8064        return Err(error.into());
8065    }
8066    let mut names = Vec::new();
8067    loop {
8068        let entry = unsafe { libc::readdir(stream) };
8069        if entry.is_null() {
8070            break;
8071        }
8072        let raw = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) };
8073        if raw.to_bytes() != b"." && raw.to_bytes() != b".." {
8074            names.push(raw.to_owned());
8075        }
8076    }
8077    if unsafe { libc::closedir(stream) } != 0 {
8078        return Err(std::io::Error::last_os_error().into());
8079    }
8080    Ok(names)
8081}
8082
8083/// Remove an entry tree relative to a held directory capability. Symlinks are
8084/// unlinked, never traversed, even when an old mirror contains hostile names.
8085#[cfg(unix)]
8086fn remove_tree_at(
8087    parent: std::os::fd::RawFd,
8088    name: &std::ffi::CStr,
8089    display: &str,
8090) -> LinkResult<()> {
8091    use std::os::fd::AsRawFd as _;
8092
8093    match entry_is_dir_at(parent, name)? {
8094        None => return Ok(()),
8095        Some(false) => {
8096            if unsafe { libc::unlinkat(parent, name.as_ptr(), 0) } != 0 {
8097                return Err(std::io::Error::last_os_error().into());
8098            }
8099        }
8100        Some(true) => {
8101            let directory = open_dir_at(parent, name, display)?;
8102            for child in directory_entry_names(&directory)? {
8103                let child_display =
8104                    format!("{display}/{}", String::from_utf8_lossy(child.to_bytes()));
8105                remove_tree_at(directory.as_raw_fd(), &child, &child_display)?;
8106            }
8107            drop(directory);
8108            if unsafe { libc::unlinkat(parent, name.as_ptr(), libc::AT_REMOVEDIR) } != 0 {
8109                return Err(std::io::Error::last_os_error().into());
8110            }
8111        }
8112    }
8113    Ok(())
8114}
8115
8116/// Clone a live destination into a private sibling stage without following
8117/// any symlink. Regular files are streamed between held descriptors; symlinks
8118/// are reproduced as links, never opened. Special files are refused.
8119#[cfg(unix)]
8120fn clone_tree_contents(
8121    source: &std::fs::File,
8122    destination: &std::fs::File,
8123    display: &str,
8124) -> LinkResult<()> {
8125    use std::os::fd::{AsRawFd as _, FromRawFd as _};
8126
8127    for name in directory_entry_names(source)? {
8128        let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
8129        let mut stat: libc::stat = unsafe { std::mem::zeroed() };
8130        if unsafe {
8131            libc::fstatat(
8132                source.as_raw_fd(),
8133                name.as_ptr(),
8134                &mut stat,
8135                libc::AT_SYMLINK_NOFOLLOW,
8136            )
8137        } != 0
8138        {
8139            return Err(std::io::Error::last_os_error().into());
8140        }
8141        match stat.st_mode & libc::S_IFMT {
8142            libc::S_IFDIR => {
8143                if unsafe {
8144                    libc::mkdirat(destination.as_raw_fd(), name.as_ptr(), stat.st_mode & 0o777)
8145                } != 0
8146                {
8147                    return Err(std::io::Error::last_os_error().into());
8148                }
8149                let source_child = open_dir_at(source.as_raw_fd(), &name, &child_display)?;
8150                let destination_child =
8151                    open_dir_at(destination.as_raw_fd(), &name, &child_display)?;
8152                clone_tree_contents(&source_child, &destination_child, &child_display)?;
8153                destination_child.sync_all()?;
8154            }
8155            libc::S_IFREG => {
8156                let source_fd = unsafe {
8157                    libc::openat(
8158                        source.as_raw_fd(),
8159                        name.as_ptr(),
8160                        libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
8161                    )
8162                };
8163                if source_fd < 0 {
8164                    return Err(std::io::Error::last_os_error().into());
8165                }
8166                let destination_fd = unsafe {
8167                    libc::openat(
8168                        destination.as_raw_fd(),
8169                        name.as_ptr(),
8170                        libc::O_WRONLY
8171                            | libc::O_CREAT
8172                            | libc::O_EXCL
8173                            | libc::O_CLOEXEC
8174                            | libc::O_NOFOLLOW,
8175                        (stat.st_mode & 0o777) as libc::c_uint,
8176                    )
8177                };
8178                if destination_fd < 0 {
8179                    unsafe {
8180                        libc::close(source_fd);
8181                    }
8182                    return Err(std::io::Error::last_os_error().into());
8183                }
8184                let mut input = unsafe { std::fs::File::from_raw_fd(source_fd) };
8185                let mut output = unsafe { std::fs::File::from_raw_fd(destination_fd) };
8186                std::io::copy(&mut input, &mut output)?;
8187                output.sync_all()?;
8188            }
8189            libc::S_IFLNK => {
8190                let mut target = vec![0_u8; 4097];
8191                let length = unsafe {
8192                    libc::readlinkat(
8193                        source.as_raw_fd(),
8194                        name.as_ptr(),
8195                        target.as_mut_ptr().cast(),
8196                        target.len(),
8197                    )
8198                };
8199                if length < 0 || length as usize >= target.len() {
8200                    return Err(LinkError::UnsafePath {
8201                        path: child_display,
8202                    });
8203                }
8204                target.truncate(length as usize);
8205                let target = c_name(&target, &child_display)?;
8206                if unsafe {
8207                    libc::symlinkat(target.as_ptr(), destination.as_raw_fd(), name.as_ptr())
8208                } != 0
8209                {
8210                    return Err(std::io::Error::last_os_error().into());
8211                }
8212            }
8213            _ => {
8214                return Err(LinkError::UnsafePath {
8215                    path: child_display,
8216                });
8217            }
8218        }
8219    }
8220    destination.sync_all()?;
8221    Ok(())
8222}
8223
8224#[cfg(target_os = "linux")]
8225fn install_stage_at(
8226    parent: std::os::fd::RawFd,
8227    stage: &std::ffi::CStr,
8228    dest: &std::ffi::CStr,
8229    dest_exists: bool,
8230) -> LinkResult<()> {
8231    let flags = if dest_exists {
8232        libc::RENAME_EXCHANGE
8233    } else {
8234        libc::RENAME_NOREPLACE
8235    };
8236    // `libc::renameat2` is not exported for the musl targets we ship. Invoke
8237    // the kernel ABI directly, as `fsx::renameat_noreplace` does, so the same
8238    // atomic exchange/no-replace boundary compiles for glibc and musl.
8239    let result = unsafe {
8240        libc::syscall(
8241            libc::SYS_renameat2,
8242            parent,
8243            stage.as_ptr(),
8244            parent,
8245            dest.as_ptr(),
8246            flags,
8247        )
8248    };
8249    if result == 0 {
8250        Ok(())
8251    } else {
8252        Err(std::io::Error::last_os_error().into())
8253    }
8254}
8255
8256#[cfg(target_os = "macos")]
8257fn install_stage_at(
8258    parent: std::os::fd::RawFd,
8259    stage: &std::ffi::CStr,
8260    dest: &std::ffi::CStr,
8261    dest_exists: bool,
8262) -> LinkResult<()> {
8263    let flags = if dest_exists {
8264        libc::RENAME_SWAP
8265    } else {
8266        libc::RENAME_EXCL
8267    };
8268    let result =
8269        unsafe { libc::renameatx_np(parent, stage.as_ptr(), parent, dest.as_ptr(), flags) };
8270    if result == 0 {
8271        Ok(())
8272    } else {
8273        Err(std::io::Error::last_os_error().into())
8274    }
8275}
8276
8277#[cfg(unix)]
8278fn write_pull_entries_beneath_dir(
8279    root: &std::fs::File,
8280    entries: &[(String, Vec<u8>)],
8281) -> LinkResult<()> {
8282    use std::os::fd::{AsRawFd as _, FromRawFd as _};
8283
8284    for (path, content) in entries {
8285        let components: Vec<&str> = path.split('/').collect();
8286        let (leaf, parents) = components
8287            .split_last()
8288            .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
8289        let mut directory = root.try_clone()?;
8290        for component in parents {
8291            let name = c_name(component.as_bytes(), path)?;
8292            let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
8293            if made != 0 {
8294                let error = std::io::Error::last_os_error();
8295                if error.raw_os_error() != Some(libc::EEXIST) {
8296                    return Err(error.into());
8297                }
8298            }
8299            directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
8300        }
8301
8302        let leaf_name = c_name(leaf.as_bytes(), path)?;
8303        let mut existing: libc::stat = unsafe { std::mem::zeroed() };
8304        let inspected = unsafe {
8305            libc::fstatat(
8306                directory.as_raw_fd(),
8307                leaf_name.as_ptr(),
8308                &mut existing,
8309                libc::AT_SYMLINK_NOFOLLOW,
8310            )
8311        };
8312        if inspected == 0 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK {
8313            return Err(LinkError::UnsafePath { path: path.clone() });
8314        }
8315
8316        let nonce = std::time::SystemTime::now()
8317            .duration_since(std::time::UNIX_EPOCH)
8318            .unwrap_or_default()
8319            .as_nanos();
8320        let temp_name = format!(
8321            ".dbmd-pull-{}-{nonce}-{}",
8322            std::process::id(),
8323            content_sha256(format!("{path}\0{}", content.len()).as_bytes())
8324        );
8325        let temp = c_name(temp_name.as_bytes(), path)?;
8326        let fd = unsafe {
8327            libc::openat(
8328                directory.as_raw_fd(),
8329                temp.as_ptr(),
8330                libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
8331                0o600,
8332            )
8333        };
8334        if fd < 0 {
8335            return Err(std::io::Error::last_os_error().into());
8336        }
8337        let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
8338        if let Err(error) = file.write_all(content).and_then(|_| file.sync_all()) {
8339            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8340            return Err(error.into());
8341        }
8342        drop(file);
8343        let renamed = unsafe {
8344            libc::renameat(
8345                directory.as_raw_fd(),
8346                temp.as_ptr(),
8347                directory.as_raw_fd(),
8348                leaf_name.as_ptr(),
8349            )
8350        };
8351        if renamed != 0 {
8352            let error = std::io::Error::last_os_error();
8353            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8354            return Err(error.into());
8355        }
8356        directory.sync_all()?;
8357    }
8358    root.sync_all()?;
8359    Ok(())
8360}
8361
8362#[cfg(unix)]
8363fn write_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
8364    use std::os::fd::{AsRawFd as _, FromRawFd as _};
8365
8366    let path = &entry.path;
8367    let components: Vec<&str> = path.split('/').collect();
8368    let (leaf, parents) = components
8369        .split_last()
8370        .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
8371    let mut directory = root.try_clone()?;
8372    for component in parents {
8373        let name = c_name(component.as_bytes(), path)?;
8374        let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
8375        if made != 0 {
8376            let error = std::io::Error::last_os_error();
8377            if error.raw_os_error() != Some(libc::EEXIST) {
8378                return Err(error.into());
8379            }
8380        }
8381        directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
8382    }
8383    let leaf_name = c_name(leaf.as_bytes(), path)?;
8384    let mut existing: libc::stat = unsafe { std::mem::zeroed() };
8385    if unsafe {
8386        libc::fstatat(
8387            directory.as_raw_fd(),
8388            leaf_name.as_ptr(),
8389            &mut existing,
8390            libc::AT_SYMLINK_NOFOLLOW,
8391        )
8392    } == 0
8393        && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK
8394    {
8395        return Err(LinkError::UnsafePath { path: path.clone() });
8396    }
8397    let nonce = SystemTime::now()
8398        .duration_since(UNIX_EPOCH)
8399        .unwrap_or_default()
8400        .as_nanos();
8401    let temp_name = format!(
8402        ".dbmd-pull-{}-{nonce}-{}",
8403        std::process::id(),
8404        content_sha256(path.as_bytes())
8405    );
8406    let temp = c_name(temp_name.as_bytes(), path)?;
8407    let fd = unsafe {
8408        libc::openat(
8409            directory.as_raw_fd(),
8410            temp.as_ptr(),
8411            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
8412            0o600,
8413        )
8414    };
8415    if fd < 0 {
8416        return Err(std::io::Error::last_os_error().into());
8417    }
8418    let mut input = crate::fsx::open_regular_nofollow(&entry.source)?;
8419    let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
8420    let mut digest = Sha256::new();
8421    let mut total = 0_u64;
8422    let mut buffer = [0_u8; 64 * 1024];
8423    let copied = (|| -> std::io::Result<()> {
8424        loop {
8425            let read = input.read(&mut buffer)?;
8426            if read == 0 {
8427                break;
8428            }
8429            total = total.saturating_add(read as u64);
8430            if total > entry.bytes {
8431                return Err(std::io::Error::new(
8432                    std::io::ErrorKind::InvalidData,
8433                    "staged sync source grew beyond its verified length",
8434                ));
8435            }
8436            digest.update(&buffer[..read]);
8437            output.write_all(&buffer[..read])?;
8438        }
8439        Ok(())
8440    })();
8441    if let Err(error) = copied {
8442        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8443        return Err(error.into());
8444    }
8445    drop(output);
8446    if total != entry.bytes || format!("{:x}", digest.finalize()) != entry.sha256 {
8447        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8448        return Err(invalid_feed(
8449            "private staged sync source failed final integrity verification",
8450        ));
8451    }
8452    if unsafe {
8453        libc::renameat(
8454            directory.as_raw_fd(),
8455            temp.as_ptr(),
8456            directory.as_raw_fd(),
8457            leaf_name.as_ptr(),
8458        )
8459    } != 0
8460    {
8461        let error = std::io::Error::last_os_error();
8462        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8463        return Err(error.into());
8464    }
8465    Ok(())
8466}
8467
8468#[cfg(unix)]
8469fn sync_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
8470    use std::os::fd::{AsRawFd as _, FromRawFd as _};
8471
8472    let path = &entry.path;
8473    let components: Vec<&str> = path.split('/').collect();
8474    let (leaf, parents) = components
8475        .split_last()
8476        .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
8477    let mut directory = root.try_clone()?;
8478    for component in parents {
8479        directory = open_dir_at(
8480            directory.as_raw_fd(),
8481            &c_name(component.as_bytes(), path)?,
8482            path,
8483        )?;
8484    }
8485    let leaf = c_name(leaf.as_bytes(), path)?;
8486    let fd = unsafe {
8487        libc::openat(
8488            directory.as_raw_fd(),
8489            leaf.as_ptr(),
8490            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
8491        )
8492    };
8493    if fd < 0 {
8494        return Err(std::io::Error::last_os_error().into());
8495    }
8496    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
8497    if file.metadata()?.len() != entry.bytes || content_sha256_reader(&mut file)? != entry.sha256 {
8498        return Err(invalid_feed(
8499            "private pull stage changed before its durability barrier",
8500        ));
8501    }
8502    file.sync_all()?;
8503    Ok(())
8504}
8505
8506#[cfg(unix)]
8507fn run_pull_source_workers(
8508    root: &std::fs::File,
8509    entries: &[V2StagedFile],
8510    operation: fn(&std::fs::File, &V2StagedFile) -> LinkResult<()>,
8511) -> LinkResult<()> {
8512    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
8513
8514    let next = AtomicUsize::new(0);
8515    let failed = AtomicBool::new(false);
8516    let worker_count = entries.len().min(V2_PULL_INSTALL_WORKERS);
8517    let mut first_error = None;
8518    std::thread::scope(|scope| {
8519        let (sender, receiver) = std::sync::mpsc::channel();
8520        for _ in 0..worker_count {
8521            let sender = sender.clone();
8522            let next = &next;
8523            let failed = &failed;
8524            scope.spawn(move || {
8525                while !failed.load(Ordering::Acquire) {
8526                    let index = next.fetch_add(1, Ordering::Relaxed);
8527                    let Some(entry) = entries.get(index) else {
8528                        break;
8529                    };
8530                    let result = operation(root, entry);
8531                    if result.is_err() {
8532                        failed.store(true, Ordering::Release);
8533                    }
8534                    if sender.send(result).is_err() {
8535                        break;
8536                    }
8537                }
8538            });
8539        }
8540        drop(sender);
8541        for result in receiver {
8542            if let Err(error) = result {
8543                if first_error.is_none() {
8544                    first_error = Some(error);
8545                }
8546            }
8547        }
8548    });
8549    if let Some(error) = first_error {
8550        return Err(error);
8551    }
8552    if next.load(Ordering::Relaxed) < entries.len() {
8553        return Err(invalid_feed(
8554            "a bounded pull worker stopped before reporting every file",
8555        ));
8556    }
8557    Ok(())
8558}
8559
8560#[cfg(unix)]
8561fn sync_pull_directory_tree(root: &std::fs::File, display: &str) -> LinkResult<()> {
8562    use std::os::fd::AsRawFd as _;
8563
8564    for name in directory_entry_names(root)? {
8565        if entry_is_dir_at(root.as_raw_fd(), &name)? == Some(true) {
8566            let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
8567            let child = open_dir_at(root.as_raw_fd(), &name, &child_display)?;
8568            sync_pull_directory_tree(&child, &child_display)?;
8569        }
8570    }
8571    root.sync_all()?;
8572    Ok(())
8573}
8574
8575#[cfg(unix)]
8576fn write_pull_sources_beneath_dir(
8577    root: &std::fs::File,
8578    entries: &[V2StagedFile],
8579) -> LinkResult<()> {
8580    // The stage is still private and unreachable as the checkout here. Write
8581    // and verify every leaf first so the filesystem can coalesce dirty data,
8582    // then re-open/re-hash/fsync leaves through the held root capability. Only
8583    // after every file barrier succeeds do we flush each directory once. This
8584    // preserves the same crash durability as file-fsync + directory-fsync per
8585    // path without serializing 50,000 redundant directory barriers.
8586    run_pull_source_workers(root, entries, write_pull_source_beneath_dir)?;
8587    run_pull_source_workers(root, entries, sync_pull_source_beneath_dir)?;
8588    sync_pull_directory_tree(root, "v2 pull stage")
8589}
8590
8591#[cfg(unix)]
8592fn remove_pull_paths_beneath_dir(root: &std::fs::File, paths: &[String]) -> LinkResult<()> {
8593    use std::os::fd::AsRawFd as _;
8594    for path in paths {
8595        if !safe_store_rel_path(path) {
8596            return Err(LinkError::UnsafePath { path: path.clone() });
8597        }
8598        let components = path.split('/').collect::<Vec<_>>();
8599        let Some((leaf, parents)) = components.split_last() else {
8600            return Err(LinkError::UnsafePath { path: path.clone() });
8601        };
8602        let mut directory = root.try_clone()?;
8603        let mut missing = false;
8604        for component in parents {
8605            let name = c_name(component.as_bytes(), path)?;
8606            match entry_is_dir_at(directory.as_raw_fd(), &name)? {
8607                None => {
8608                    missing = true;
8609                    break;
8610                }
8611                Some(false) => return Err(LinkError::UnsafePath { path: path.clone() }),
8612                Some(true) => {
8613                    directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
8614                }
8615            }
8616        }
8617        if missing {
8618            continue;
8619        }
8620        let leaf = c_name(leaf.as_bytes(), path)?;
8621        match entry_is_dir_at(directory.as_raw_fd(), &leaf)? {
8622            None => {}
8623            Some(true) => return Err(LinkError::UnsafePath { path: path.clone() }),
8624            Some(false) => {
8625                if unsafe { libc::unlinkat(directory.as_raw_fd(), leaf.as_ptr(), 0) } != 0 {
8626                    return Err(std::io::Error::last_os_error().into());
8627                }
8628                directory.sync_all()?;
8629            }
8630        }
8631    }
8632    Ok(())
8633}
8634
8635#[cfg(unix)]
8636fn install_pulled_delta(
8637    dest: &Path,
8638    entries: &[(String, Vec<u8>)],
8639    deleted: &[String],
8640    rebuild_indexes: bool,
8641) -> LinkResult<()> {
8642    use ring::rand::SecureRandom as _;
8643    use std::os::fd::AsRawFd as _;
8644    use std::os::unix::ffi::OsStrExt as _;
8645
8646    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
8647    let name = dest
8648        .file_name()
8649        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
8650        .ok_or_else(|| LinkError::UnsafePath {
8651            path: dest.display().to_string(),
8652        })?;
8653    let parent_dir = open_or_create_dir_nofollow(parent)?;
8654    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
8655    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
8656        None => false,
8657        Some(true) => true,
8658        Some(false) => {
8659            return Err(LinkError::UnsafePath {
8660                path: dest.display().to_string(),
8661            });
8662        }
8663    };
8664
8665    let mut nonce = [0_u8; 16];
8666    ring::rand::SystemRandom::new()
8667        .fill(&mut nonce)
8668        .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
8669    let stage_label = format!(
8670        ".{}.dbmd-pull-stage-{}",
8671        name.to_string_lossy(),
8672        URL_SAFE_NO_PAD.encode(nonce)
8673    );
8674    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
8675    let stage_dir = create_dir_exclusive_at(
8676        parent_dir.as_raw_fd(),
8677        &stage_name,
8678        &dest.display().to_string(),
8679    )?;
8680
8681    let prepared = (|| -> LinkResult<()> {
8682        if dest_exists {
8683            let live = open_dir_at(
8684                parent_dir.as_raw_fd(),
8685                &dest_name,
8686                &dest.display().to_string(),
8687            )?;
8688            clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
8689        }
8690        remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
8691        write_pull_entries_beneath_dir(&stage_dir, entries)?;
8692        if rebuild_indexes {
8693            let stage_store =
8694                Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
8695                    .map_err(|error| LinkError::InvalidPack {
8696                        message: format!("v2 staging tree is not a valid db.md store: {error}"),
8697                    })?;
8698            crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
8699                LinkError::InvalidPack {
8700                    message: format!("could not materialize v2 local catalogs: {error}"),
8701                }
8702            })?;
8703        }
8704        stage_dir.sync_all()?;
8705        Ok(())
8706    })();
8707    if let Err(error) = prepared {
8708        let _ = remove_tree_at(
8709            parent_dir.as_raw_fd(),
8710            &stage_name,
8711            &dest.display().to_string(),
8712        );
8713        return Err(error);
8714    }
8715
8716    if let Err(error) =
8717        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
8718    {
8719        let _ = remove_tree_at(
8720            parent_dir.as_raw_fd(),
8721            &stage_name,
8722            &dest.display().to_string(),
8723        );
8724        return Err(error);
8725    }
8726    parent_dir.sync_all()?;
8727    if dest_exists {
8728        // Commit already happened atomically. Cleanup is best-effort so a
8729        // failure cannot be reported as a failed pull after the live tree
8730        // changed; a crash may leave only this private old-tree sibling.
8731        let _ = remove_tree_at(
8732            parent_dir.as_raw_fd(),
8733            &stage_name,
8734            &dest.display().to_string(),
8735        );
8736        let _ = parent_dir.sync_all();
8737    }
8738    Ok(())
8739}
8740
8741#[cfg(unix)]
8742fn install_pulled_delta_sources(
8743    dest: &Path,
8744    entries: &[V2StagedFile],
8745    deleted: &[String],
8746    rebuild_indexes: bool,
8747    _previous: Option<&V2SyncBaseline>,
8748    _next: &V2VerifiedHead,
8749) -> LinkResult<()> {
8750    use ring::rand::SecureRandom as _;
8751    use std::os::fd::AsRawFd as _;
8752    use std::os::unix::ffi::OsStrExt as _;
8753
8754    // An established checkout is updated only at the changed coordinates
8755    // through the durable pull journal shared with Windows. Initial clones
8756    // still use a private complete stage and one atomic directory install.
8757    if let Ok(store) = Store::open_strict(dest) {
8758        return install_established_v2_delta(
8759            store,
8760            entries,
8761            deleted,
8762            rebuild_indexes,
8763            _previous,
8764            _next,
8765        );
8766    }
8767
8768    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
8769    let name = dest
8770        .file_name()
8771        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
8772        .ok_or_else(|| LinkError::UnsafePath {
8773            path: dest.display().to_string(),
8774        })?;
8775    let parent_dir = open_or_create_dir_nofollow(parent)?;
8776    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
8777    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
8778        None => false,
8779        Some(true) => true,
8780        Some(false) => {
8781            return Err(LinkError::UnsafePath {
8782                path: dest.display().to_string(),
8783            })
8784        }
8785    };
8786    let mut nonce = [0_u8; 16];
8787    ring::rand::SystemRandom::new()
8788        .fill(&mut nonce)
8789        .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
8790    let stage_label = format!(
8791        ".{}.dbmd-pull-stage-{}",
8792        name.to_string_lossy(),
8793        URL_SAFE_NO_PAD.encode(nonce)
8794    );
8795    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
8796    let stage_dir = create_dir_exclusive_at(
8797        parent_dir.as_raw_fd(),
8798        &stage_name,
8799        &dest.display().to_string(),
8800    )?;
8801    let prepared = (|| -> LinkResult<()> {
8802        if dest_exists {
8803            let live = open_dir_at(
8804                parent_dir.as_raw_fd(),
8805                &dest_name,
8806                &dest.display().to_string(),
8807            )?;
8808            clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
8809        }
8810        remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
8811        write_pull_sources_beneath_dir(&stage_dir, entries)?;
8812        if rebuild_indexes {
8813            let stage_store =
8814                Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
8815                    .map_err(|error| LinkError::InvalidPack {
8816                        message: format!("v2 staging tree is not a valid db.md store: {error}"),
8817                    })?;
8818            crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
8819                LinkError::InvalidPack {
8820                    message: format!("could not materialize v2 local catalogs: {error}"),
8821                }
8822            })?;
8823        }
8824        stage_dir.sync_all()?;
8825        Ok(())
8826    })();
8827    if let Err(error) = prepared {
8828        let _ = remove_tree_at(
8829            parent_dir.as_raw_fd(),
8830            &stage_name,
8831            &dest.display().to_string(),
8832        );
8833        return Err(error);
8834    }
8835    if let Err(error) =
8836        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
8837    {
8838        let _ = remove_tree_at(
8839            parent_dir.as_raw_fd(),
8840            &stage_name,
8841            &dest.display().to_string(),
8842        );
8843        return Err(error);
8844    }
8845    parent_dir.sync_all()?;
8846    if dest_exists {
8847        let _ = remove_tree_at(
8848            parent_dir.as_raw_fd(),
8849            &stage_name,
8850            &dest.display().to_string(),
8851        );
8852        let _ = parent_dir.sync_all();
8853    }
8854    Ok(())
8855}
8856
8857#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
8858struct V2PullCoordinate {
8859    head_seq: Option<u64>,
8860    commit_hash: Option<String>,
8861    view_kind: Option<String>,
8862    view_revision: Option<String>,
8863}
8864
8865#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
8866struct V2PullFileCoordinate {
8867    sha256: String,
8868    bytes: u64,
8869}
8870
8871#[derive(Debug, Clone, Deserialize, Serialize)]
8872struct V2PullJournalEntry {
8873    path: String,
8874    old: Option<V2PullFileCoordinate>,
8875    new: Option<V2PullFileCoordinate>,
8876    backup: Option<String>,
8877}
8878
8879#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
8880#[serde(rename_all = "snake_case")]
8881enum V2PullPhase {
8882    Preparing,
8883    Ready,
8884}
8885
8886#[derive(Debug, Clone, Deserialize, Serialize)]
8887struct V2PullJournal {
8888    v: u8,
8889    phase: V2PullPhase,
8890    brain: String,
8891    previous: V2PullCoordinate,
8892    next: V2PullCoordinate,
8893    backup_dir: String,
8894    entries: Vec<V2PullJournalEntry>,
8895}
8896
8897const V2_PULL_JOURNAL: &str = ".dbmd/pull-journal.json";
8898
8899fn v2_pull_baseline_coordinate(baseline: Option<&V2SyncBaseline>) -> V2PullCoordinate {
8900    V2PullCoordinate {
8901        head_seq: baseline.and_then(|value| value.head_seq),
8902        commit_hash: baseline.and_then(|value| value.commit_hash.clone()),
8903        view_kind: baseline.and_then(|value| value.view_kind.clone()),
8904        view_revision: baseline.and_then(|value| value.view_revision.clone()),
8905    }
8906}
8907
8908fn v2_pull_head_coordinate(head: &V2VerifiedHead) -> V2PullCoordinate {
8909    V2PullCoordinate {
8910        head_seq: head.pointer.as_ref().map(|value| value.seq),
8911        commit_hash: head.pointer.as_ref().map(|value| value.commit_hash.clone()),
8912        view_kind: Some(head.view_kind.clone()),
8913        view_revision: Some(head.view_revision.clone()),
8914    }
8915}
8916
8917fn v2_pull_file_coordinate(
8918    store: &Store,
8919    path: &str,
8920    limit: u64,
8921) -> LinkResult<Option<V2PullFileCoordinate>> {
8922    let file = match store.open_regular(Path::new(path)) {
8923        Ok(file) => file,
8924        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
8925        Err(error) => return Err(error.into()),
8926    };
8927    let bytes = file.metadata()?.len();
8928    if bytes > limit || bytes > MAX_STORE_BYTES {
8929        return Err(invalid_feed(
8930            "pull transaction file exceeds its declared bound",
8931        ));
8932    }
8933    Ok(Some(V2PullFileCoordinate {
8934        sha256: content_sha256_reader(file)?,
8935        bytes,
8936    }))
8937}
8938
8939fn v2_pull_journal_bytes(journal: &V2PullJournal) -> LinkResult<Vec<u8>> {
8940    let mut bytes = serde_json::to_vec_pretty(journal)
8941        .map_err(|_| invalid_feed("could not serialize v2 pull journal"))?;
8942    bytes.push(b'\n');
8943    Ok(bytes)
8944}
8945
8946fn validate_v2_pull_journal(journal: &V2PullJournal) -> LinkResult<()> {
8947    let backup_prefix = ".dbmd/pull-backup-";
8948    let suffix = journal
8949        .backup_dir
8950        .strip_prefix(backup_prefix)
8951        .ok_or_else(|| invalid_feed("v2 pull journal backup address is invalid"))?;
8952    let mut paths = std::collections::BTreeSet::new();
8953    if journal.v != 1
8954        || !crate::ulid::is_ulid(&journal.brain)
8955        || !crate::ulid::is_ulid(suffix)
8956        || journal.entries.is_empty()
8957        || journal.entries.len() > MAX_PUSH_FILES + 4
8958        || journal.previous == journal.next
8959    {
8960        return Err(invalid_feed("v2 pull journal failed validation"));
8961    }
8962    for (index, entry) in journal.entries.iter().enumerate() {
8963        if !safe_store_rel_path(&entry.path)
8964            || entry.path == V2_PULL_JOURNAL
8965            || entry.path.starts_with(backup_prefix)
8966            || !paths.insert(entry.path.clone())
8967            || (entry.old.is_none() && entry.new.is_none())
8968            || entry
8969                .old
8970                .iter()
8971                .chain(entry.new.iter())
8972                .any(|value| !is_sha256(&value.sha256) || value.bytes > MAX_STORE_BYTES)
8973            || entry.backup.as_deref()
8974                != entry
8975                    .old
8976                    .as_ref()
8977                    .map(|_| format!("{index:08x}"))
8978                    .as_deref()
8979        {
8980            return Err(invalid_feed("v2 pull journal entry failed validation"));
8981        }
8982    }
8983    Ok(())
8984}
8985
8986fn load_v2_pull_journal(store: &Store) -> LinkResult<Option<V2PullJournal>> {
8987    #[cfg(unix)]
8988    {
8989        use std::os::unix::fs::PermissionsExt as _;
8990        match store.regular_metadata(Path::new(V2_PULL_JOURNAL)) {
8991            Ok(metadata) if metadata.permissions().mode() & 0o077 != 0 => {
8992                return Err(invalid_feed(
8993                    "v2 pull journal is accessible to group/other; set mode 0600",
8994                ));
8995            }
8996            Ok(_) => {}
8997            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
8998            Err(error) => return Err(error.into()),
8999        }
9000    }
9001    let bytes = match store.read_bounded(Path::new(V2_PULL_JOURNAL), 64 * 1024 * 1024) {
9002        Ok(bytes) => bytes,
9003        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
9004        Err(error) => return Err(error.into()),
9005    };
9006    let journal: V2PullJournal =
9007        serde_json::from_slice(&bytes).map_err(|_| invalid_feed("v2 pull journal is corrupt"))?;
9008    validate_v2_pull_journal(&journal)?;
9009    Ok(Some(journal))
9010}
9011
9012fn cleanup_v2_pull_journal(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
9013    // The journal is the only recovery authority. Remove it first only after
9014    // rollback or baseline commit is durable; an orphan private backup is safe
9015    // and can be pruned, while a journal without its backups is not recoverable.
9016    match store.remove_file(Path::new(V2_PULL_JOURNAL)) {
9017        Ok(()) => {}
9018        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
9019        Err(error) => return Err(error.into()),
9020    }
9021    match store.remove_private_tree(Path::new(&journal.backup_dir)) {
9022        Ok(()) => Ok(()),
9023        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
9024        Err(error) => Err(error.into()),
9025    }
9026}
9027
9028fn prune_orphan_v2_pull_backups(store: &Store) -> LinkResult<()> {
9029    let names = match store.directory_names(Path::new(".dbmd")) {
9030        Ok(names) => names,
9031        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
9032        Err(error) => return Err(error.into()),
9033    };
9034    for name in names {
9035        let Some(name) = name.to_str() else {
9036            continue;
9037        };
9038        let Some(suffix) = name.strip_prefix("pull-backup-") else {
9039            continue;
9040        };
9041        if crate::ulid::is_ulid(suffix) {
9042            store.remove_private_tree(&Path::new(".dbmd").join(name))?;
9043        }
9044    }
9045    Ok(())
9046}
9047
9048fn rollback_v2_pull(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
9049    // Validate every live coordinate and every backup before the first restore.
9050    for entry in &journal.entries {
9051        let limit = entry
9052            .old
9053            .as_ref()
9054            .into_iter()
9055            .chain(entry.new.iter())
9056            .map(|value| value.bytes)
9057            .max()
9058            .unwrap_or(0);
9059        let current = v2_pull_file_coordinate(store, &entry.path, limit)?;
9060        if current != entry.old && current != entry.new {
9061            return Err(LinkError::InvalidPack {
9062                message: format!(
9063                    "cannot recover interrupted pull because `{}` changed afterward",
9064                    entry.path
9065                ),
9066            });
9067        }
9068        if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
9069            let path = Path::new(&journal.backup_dir).join(backup);
9070            let file = store.open_regular(&path)?;
9071            if file.metadata()?.len() != old.bytes || content_sha256_reader(file)? != old.sha256 {
9072                return Err(invalid_feed("v2 pull recovery backup failed verification"));
9073            }
9074        }
9075    }
9076    for entry in journal.entries.iter().rev() {
9077        match (&entry.old, &entry.backup) {
9078            (Some(old), Some(backup)) => {
9079                let bytes =
9080                    store.read_bounded(&Path::new(&journal.backup_dir).join(backup), old.bytes)?;
9081                store.write_atomic(Path::new(&entry.path), &bytes)?;
9082            }
9083            (None, None) if store.regular_file_exists(Path::new(&entry.path))? => {
9084                store.remove_file(Path::new(&entry.path))?;
9085            }
9086            (None, None) => {}
9087            _ => return Err(invalid_feed("v2 pull recovery entry is inconsistent")),
9088        }
9089    }
9090    crate::index::Index::rebuild_all(store).map_err(|error| LinkError::InvalidPack {
9091        message: format!("could not rebuild catalogs after pull recovery: {error}"),
9092    })?;
9093    cleanup_v2_pull_journal(store, journal)
9094}
9095
9096fn recover_v2_pull(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<()> {
9097    let Ok(store) = Store::open_strict(dest) else {
9098        return Ok(());
9099    };
9100    if let Some(journal) = load_v2_pull_journal(&store)? {
9101        if journal.brain != brain {
9102            return Err(invalid_feed("v2 pull journal belongs to another brain"));
9103        }
9104        if journal.phase == V2PullPhase::Preparing {
9105            cleanup_v2_pull_journal(&store, &journal)?;
9106        } else {
9107            let baseline = load_v2_baseline(cfg, brain, dest)?;
9108            let current = v2_pull_baseline_coordinate(baseline.as_ref());
9109            if current == journal.next {
9110                cleanup_v2_pull_journal(&store, &journal)?;
9111            } else {
9112                if current != journal.previous {
9113                    return Err(invalid_feed(
9114                        "cannot recover interrupted pull because its baseline changed afterward",
9115                    ));
9116                }
9117                rollback_v2_pull(&store, &journal)?;
9118            }
9119        }
9120    }
9121    // Cleanup removes the journal first after the state is durably old or new.
9122    // A hard kill in the tiny interval before deleting its backup directory can
9123    // therefore leave only an inert, private orphan. The per-brain operation
9124    // lock is held by the caller, so no live pull can own one here.
9125    prune_orphan_v2_pull_backups(&store)
9126}
9127
9128fn complete_v2_pull(dest: &Path) -> LinkResult<()> {
9129    let store = Store::open_strict(dest).map_err(|error| LinkError::InvalidPack {
9130        message: format!("installed v2 checkout is not a valid db.md store: {error}"),
9131    })?;
9132    if let Some(journal) = load_v2_pull_journal(&store)? {
9133        cleanup_v2_pull_journal(&store, &journal)?;
9134    }
9135    Ok(())
9136}
9137
9138#[cfg(windows)]
9139fn install_windows_initial_sources(
9140    dest: &Path,
9141    entries: &[V2StagedFile],
9142    rebuild_indexes: bool,
9143) -> LinkResult<()> {
9144    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
9145    let name = dest.file_name().ok_or_else(|| LinkError::UnsafePath {
9146        path: dest.display().to_string(),
9147    })?;
9148    let parent_capability = crate::fsx::open_or_create_directory_nofollow(parent)?;
9149    if crate::fsx::directory_exists_beneath(&parent_capability, Path::new(name))? {
9150        return Err(LinkError::UnsafePath {
9151            path: dest.display().to_string(),
9152        });
9153    }
9154    let stage_name = format!(
9155        ".{}.dbmd-pull-stage-{}",
9156        name.to_string_lossy(),
9157        crate::ulid::mint()
9158    );
9159    let stage_path = parent.join(&stage_name);
9160    let stage_capability =
9161        crate::fsx::open_directory_beneath(&parent_capability, Path::new(&stage_name), true)?;
9162    let stage = Store::from_root_and_config(&stage_path, crate::Config::default())?;
9163    let prepared = (|| -> LinkResult<()> {
9164        for entry in entries {
9165            let bytes = crate::fsx::read_bounded_nofollow(&entry.source, entry.bytes)?;
9166            if bytes.len() as u64 != entry.bytes || content_sha256(&bytes) != entry.sha256 {
9167                return Err(invalid_feed(
9168                    "private staged sync source failed final integrity verification",
9169                ));
9170            }
9171            stage.write_atomic(Path::new(&entry.path), &bytes)?;
9172        }
9173        let strict = Store::from_held_root_strict(&stage_path, stage_capability.try_clone()?)
9174            .map_err(|error| LinkError::InvalidPack {
9175                message: format!("v2 staging tree is not a valid db.md store: {error}"),
9176            })?;
9177        if rebuild_indexes {
9178            crate::index::Index::rebuild_all(&strict).map_err(|error| LinkError::InvalidPack {
9179                message: format!("could not materialize v2 local catalogs: {error}"),
9180            })?;
9181        }
9182        Ok(())
9183    })();
9184    if let Err(error) = prepared {
9185        let _ = crate::fsx::remove_tree_beneath(&parent_capability, Path::new(&stage_name));
9186        return Err(error);
9187    }
9188    crate::fsx::rename_directory_beneath(
9189        &parent_capability,
9190        Path::new(&stage_name),
9191        Path::new(name),
9192    )?;
9193    Ok(())
9194}
9195
9196fn install_established_v2_delta(
9197    store: Store,
9198    entries: &[V2StagedFile],
9199    deleted: &[String],
9200    rebuild_indexes: bool,
9201    previous: Option<&V2SyncBaseline>,
9202    next: &V2VerifiedHead,
9203) -> LinkResult<()> {
9204    if load_v2_pull_journal(&store)?.is_some() {
9205        return Err(invalid_feed(
9206            "an interrupted pull must be recovered before installing",
9207        ));
9208    }
9209    let mut sources = std::collections::BTreeMap::new();
9210    for entry in entries {
9211        if sources.insert(entry.path.clone(), entry).is_some() || deleted.contains(&entry.path) {
9212            return Err(invalid_feed("pull mutation repeats a path"));
9213        }
9214    }
9215    let mut paths = sources.keys().cloned().collect::<Vec<_>>();
9216    paths.extend(deleted.iter().cloned());
9217    paths.sort();
9218    paths.dedup();
9219    if paths.is_empty() {
9220        return Ok(());
9221    }
9222    let backup_dir = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
9223    let mut journal = V2PullJournal {
9224        v: 1,
9225        phase: V2PullPhase::Preparing,
9226        brain: next.brain_id.clone(),
9227        previous: v2_pull_baseline_coordinate(previous),
9228        next: v2_pull_head_coordinate(next),
9229        backup_dir: backup_dir.clone(),
9230        entries: Vec::with_capacity(paths.len()),
9231    };
9232    for path in &paths {
9233        let old = v2_pull_file_coordinate(&store, path, MAX_STORE_BYTES)?;
9234        let new = sources.get(path).map(|entry| V2PullFileCoordinate {
9235            sha256: entry.sha256.clone(),
9236            bytes: entry.bytes,
9237        });
9238        if old == new {
9239            continue;
9240        }
9241        let index = journal.entries.len();
9242        journal.entries.push(V2PullJournalEntry {
9243            path: path.clone(),
9244            backup: old.as_ref().map(|_| format!("{index:08x}")),
9245            old,
9246            new,
9247        });
9248    }
9249    if journal.entries.is_empty() {
9250        return Ok(());
9251    }
9252    let backup_bytes = journal.entries.iter().try_fold(0_u64, |total, entry| {
9253        entry
9254            .old
9255            .as_ref()
9256            .map_or(Some(total), |old| total.checked_add(old.bytes))
9257    });
9258    if backup_bytes.is_none_or(|bytes| bytes > MAX_STORE_BYTES) {
9259        return Err(LinkError::InvalidPack {
9260            message: "pull recovery preimages exceed the 512 MB transaction limit".to_string(),
9261        });
9262    }
9263    validate_v2_pull_journal(&journal)?;
9264    store.write_private_atomic_new(
9265        Path::new(V2_PULL_JOURNAL),
9266        &v2_pull_journal_bytes(&journal)?,
9267    )?;
9268    let prepared = (|| -> LinkResult<()> {
9269        store.create_private_dir_all(Path::new(&backup_dir))?;
9270        for entry in &journal.entries {
9271            if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
9272                let bytes = store.read_bounded(Path::new(&entry.path), old.bytes)?;
9273                if content_sha256(&bytes) != old.sha256 {
9274                    return Err(invalid_feed("live pull source changed during backup"));
9275                }
9276                store.write_private_atomic_new(&Path::new(&backup_dir).join(backup), &bytes)?;
9277            }
9278        }
9279        journal.phase = V2PullPhase::Ready;
9280        store.write_private_atomic(
9281            Path::new(V2_PULL_JOURNAL),
9282            &v2_pull_journal_bytes(&journal)?,
9283        )?;
9284        Ok(())
9285    })();
9286    if let Err(error) = prepared {
9287        let cleanup = cleanup_v2_pull_journal(&store, &journal);
9288        return match cleanup {
9289            Ok(()) => Err(error),
9290            Err(cleanup) => Err(LinkError::InvalidPack {
9291                message: format!("{error}; recovery metadata cleanup also failed: {cleanup}"),
9292            }),
9293        };
9294    }
9295    let installed = (|| -> LinkResult<()> {
9296        for entry in &journal.entries {
9297            if v2_pull_file_coordinate(&store, &entry.path, MAX_STORE_BYTES)? != entry.old {
9298                return Err(LinkError::InvalidPack {
9299                    message: format!("local path `{}` changed during pull", entry.path),
9300                });
9301            }
9302            if let Some(source) = sources.get(&entry.path) {
9303                let bytes = crate::fsx::read_bounded_nofollow(&source.source, source.bytes)?;
9304                if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != source.sha256 {
9305                    return Err(invalid_feed(
9306                        "private staged sync source failed final integrity verification",
9307                    ));
9308                }
9309                store.write_atomic(Path::new(&entry.path), &bytes)?;
9310            } else if entry.old.is_some() {
9311                store.remove_file(Path::new(&entry.path))?;
9312            }
9313        }
9314        if rebuild_indexes {
9315            crate::index::Index::rebuild_all(&store).map_err(|error| LinkError::InvalidPack {
9316                message: format!("could not materialize v2 local catalogs: {error}"),
9317            })?;
9318        }
9319        Ok(())
9320    })();
9321    if let Err(error) = installed {
9322        return match rollback_v2_pull(&store, &journal) {
9323            Ok(()) => Err(error),
9324            Err(rollback) => Err(LinkError::InvalidPack {
9325                message: format!("{error}; durable pull rollback also failed: {rollback}"),
9326            }),
9327        };
9328    }
9329    Ok(())
9330}
9331
9332#[cfg(windows)]
9333fn install_pulled_delta_sources(
9334    dest: &Path,
9335    entries: &[V2StagedFile],
9336    deleted: &[String],
9337    rebuild_indexes: bool,
9338    previous: Option<&V2SyncBaseline>,
9339    next: &V2VerifiedHead,
9340) -> LinkResult<()> {
9341    match Store::open_strict(dest) {
9342        Ok(store) => {
9343            install_established_v2_delta(store, entries, deleted, rebuild_indexes, previous, next)
9344        }
9345        Err(_) => install_windows_initial_sources(dest, entries, rebuild_indexes),
9346    }
9347}
9348
9349#[cfg(not(any(unix, windows)))]
9350fn install_pulled_delta_sources(
9351    _dest: &Path,
9352    _entries: &[V2StagedFile],
9353    _deleted: &[String],
9354    _rebuild_indexes: bool,
9355    _previous: Option<&V2SyncBaseline>,
9356    _next: &V2VerifiedHead,
9357) -> LinkResult<()> {
9358    Err(LinkError::UnsupportedPlatform {
9359        operation: "atomic v2 pull install",
9360    })
9361}
9362
9363#[cfg(unix)]
9364fn install_pulled_snapshot(dest: &Path, entries: &[(String, Vec<u8>)]) -> LinkResult<()> {
9365    install_pulled_delta(dest, entries, &[], false)
9366}
9367
9368#[cfg(not(windows))]
9369fn is_safe_slug(slug: &str) -> bool {
9370    !slug.is_empty()
9371        && slug.len() <= 63
9372        && !slug.starts_with('-')
9373        && !slug.ends_with('-')
9374        && slug
9375            .bytes()
9376            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
9377}
9378
9379fn le_u16(bytes: &[u8], at: usize) -> Option<u16> {
9380    Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
9381}
9382
9383fn le_u32(bytes: &[u8], at: usize) -> Option<u32> {
9384    Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
9385}
9386
9387fn le_u64(bytes: &[u8], at: usize) -> Option<u64> {
9388    Some(u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?))
9389}
9390
9391fn preflight_zip_central_directory(
9392    bytes: &[u8],
9393    offset: usize,
9394    size: usize,
9395    count: u64,
9396) -> LinkResult<()> {
9397    const CENTRAL_ENTRY_SIG: &[u8; 4] = b"PK\x01\x02";
9398    let end = offset
9399        .checked_add(size)
9400        .filter(|end| *end <= bytes.len())
9401        .ok_or_else(|| LinkError::InvalidPack {
9402            message: "ZIP central directory is out of bounds".to_string(),
9403        })?;
9404    let mut cursor = offset;
9405    for _ in 0..count {
9406        if bytes.get(cursor..cursor + 4) != Some(CENTRAL_ENTRY_SIG.as_slice()) {
9407            return Err(LinkError::InvalidPack {
9408                message: "ZIP central directory entry count is inconsistent".to_string(),
9409            });
9410        }
9411        if le_u16(bytes, cursor + 34) != Some(0) {
9412            return Err(LinkError::InvalidPack {
9413                message: "multi-disk ZIP archives are not supported".to_string(),
9414            });
9415        }
9416        let variable = [28, 30, 32].into_iter().try_fold(0usize, |total, at| {
9417            total.checked_add(le_u16(bytes, cursor + at)? as usize)
9418        });
9419        cursor = cursor
9420            .checked_add(46)
9421            .and_then(|fixed| fixed.checked_add(variable?))
9422            .filter(|cursor| *cursor <= end)
9423            .ok_or_else(|| LinkError::InvalidPack {
9424                message: "ZIP central directory entry is truncated".to_string(),
9425            })?;
9426    }
9427    if cursor != end {
9428        return Err(LinkError::InvalidPack {
9429            message: "ZIP central directory size is inconsistent".to_string(),
9430        });
9431    }
9432    Ok(())
9433}
9434
9435/// Read only the bounded ZIP trailer before `ZipArchive::new` allocates one
9436/// metadata object per central-directory entry. Supports ordinary EOCD and the
9437/// Zip64 locator/record emitted for >65,535-entry archives.
9438fn preflight_zip_entry_count(bytes: &[u8], max_entries: usize) -> LinkResult<()> {
9439    const EOCD_SIG: &[u8; 4] = b"PK\x05\x06";
9440    const ZIP64_LOCATOR_SIG: &[u8; 4] = b"PK\x06\x07";
9441    const ZIP64_EOCD_SIG: &[u8; 4] = b"PK\x06\x06";
9442    let search_start = bytes.len().saturating_sub(22 + u16::MAX as usize);
9443    let eocd = bytes[search_start..]
9444        .windows(4)
9445        .rposition(|window| window == EOCD_SIG)
9446        .map(|offset| search_start + offset)
9447        .ok_or_else(|| LinkError::InvalidPack {
9448            message: "ZIP has no end-of-central-directory record".to_string(),
9449        })?;
9450    let invalid_end = || LinkError::InvalidPack {
9451        message: "ZIP has an invalid end-of-central-directory structure".to_string(),
9452    };
9453    let comment_len = le_u16(bytes, eocd + 20).ok_or_else(invalid_end)? as usize;
9454    if eocd
9455        .checked_add(22)
9456        .and_then(|end| end.checked_add(comment_len))
9457        != Some(bytes.len())
9458    {
9459        // Do not fall back to an earlier signature. ZipArchive does, and a
9460        // fake low-count EOCD appended after a real Zip64 entry-count bomb
9461        // would otherwise bypass this allocation preflight.
9462        return Err(invalid_end());
9463    }
9464    let disk = le_u16(bytes, eocd + 4);
9465    let central_disk = le_u16(bytes, eocd + 6);
9466    if disk != Some(0) || central_disk != Some(0) {
9467        return Err(LinkError::InvalidPack {
9468            message: "multi-disk ZIP archives are not supported".to_string(),
9469        });
9470    }
9471    let entries_on_disk = le_u16(bytes, eocd + 8).ok_or_else(invalid_end)?;
9472    let ordinary = le_u16(bytes, eocd + 10).ok_or_else(invalid_end)?;
9473    if entries_on_disk != ordinary {
9474        return Err(LinkError::InvalidPack {
9475            message: "multi-disk ZIP archives are not supported".to_string(),
9476        });
9477    }
9478    let zip64_locator = eocd
9479        .checked_sub(20)
9480        .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_LOCATOR_SIG.as_slice()));
9481    let (count, central_offset, central_size) = if ordinary != u16::MAX || zip64_locator.is_none() {
9482        let central_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)? as usize;
9483        let central_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)? as usize;
9484        if central_offset
9485            .checked_add(central_size)
9486            .filter(|end| *end == eocd)
9487            .is_none()
9488        {
9489            return Err(invalid_end());
9490        }
9491        (ordinary as u64, central_offset, central_size)
9492    } else {
9493        let Some(locator) = zip64_locator else {
9494            return Err(invalid_end());
9495        };
9496        if le_u32(bytes, locator + 4) != Some(0) || le_u32(bytes, locator + 16) != Some(1) {
9497            return Err(LinkError::InvalidPack {
9498                message: "multi-disk ZIP64 archives are not supported".to_string(),
9499            });
9500        }
9501        let record = le_u64(bytes, locator + 8)
9502            .and_then(|offset| usize::try_from(offset).ok())
9503            .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_EOCD_SIG.as_slice()))
9504            .ok_or_else(|| LinkError::InvalidPack {
9505                message: "ZIP64 archive has an invalid end record".to_string(),
9506            })?;
9507        let record_size = le_u64(bytes, record + 4)
9508            .and_then(|size| usize::try_from(size).ok())
9509            .filter(|size| *size >= 44)
9510            .ok_or_else(invalid_end)?;
9511        if record
9512            .checked_add(12)
9513            .and_then(|end| end.checked_add(record_size))
9514            != Some(locator)
9515            || le_u32(bytes, record + 16) != Some(0)
9516            || le_u32(bytes, record + 20) != Some(0)
9517        {
9518            return Err(invalid_end());
9519        }
9520        let zip64_on_disk = le_u64(bytes, record + 24).ok_or_else(invalid_end)?;
9521        let zip64_total = le_u64(bytes, record + 32).ok_or_else(invalid_end)?;
9522        let central_size = le_u64(bytes, record + 40)
9523            .and_then(|size| usize::try_from(size).ok())
9524            .ok_or_else(invalid_end)?;
9525        let central_offset = le_u64(bytes, record + 48)
9526            .and_then(|offset| usize::try_from(offset).ok())
9527            .ok_or_else(invalid_end)?;
9528        if zip64_on_disk != zip64_total
9529            || central_offset
9530                .checked_add(central_size)
9531                .filter(|end| *end == record)
9532                .is_none()
9533        {
9534            return Err(invalid_end());
9535        }
9536        let legacy_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)?;
9537        let legacy_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)?;
9538        if (legacy_size != u32::MAX && legacy_size as usize != central_size)
9539            || (legacy_offset != u32::MAX && legacy_offset as usize != central_offset)
9540        {
9541            return Err(invalid_end());
9542        }
9543        (zip64_total, central_offset, central_size)
9544    };
9545    if count == 0 || count > max_entries as u64 {
9546        return Err(LinkError::InvalidPack {
9547            message: format!("invalid file count {count}"),
9548        });
9549    }
9550    preflight_zip_central_directory(bytes, central_offset, central_size, count)?;
9551    Ok(())
9552}
9553
9554fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
9555    preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)?;
9556    let mut archive =
9557        zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
9558            message: format!("ZIP parse failed: {err}"),
9559        })?;
9560    if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
9561        return Err(LinkError::InvalidPack {
9562            message: format!("invalid file count {}", archive.len()),
9563        });
9564    }
9565    let mut total = 0u64;
9566    let mut seen = std::collections::HashSet::new();
9567    let mut entries = Vec::with_capacity(archive.len());
9568    for index in 0..archive.len() {
9569        let mut file = archive
9570            .by_index(index)
9571            .map_err(|err| LinkError::InvalidPack {
9572                message: format!("ZIP entry failed: {err}"),
9573            })?;
9574        if file.is_dir() {
9575            continue;
9576        }
9577        let path = file.name().to_string();
9578        if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
9579            return Err(LinkError::UnsafePath { path });
9580        }
9581        if file
9582            .unix_mode()
9583            .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
9584        {
9585            return Err(LinkError::InvalidPack {
9586                message: format!("non-file entry `{path}`"),
9587            });
9588        }
9589        if !seen.insert(path.clone()) {
9590            return Err(LinkError::InvalidPack {
9591                message: format!("duplicate path `{path}`"),
9592            });
9593        }
9594        let remaining = MAX_STORE_BYTES.saturating_sub(total);
9595        if file.size() > remaining {
9596            return Err(LinkError::InvalidPack {
9597                message: "expanded content exceeds the 512 MB limit".to_string(),
9598            });
9599        }
9600        let mut content = Vec::new();
9601        (&mut file)
9602            .take(remaining + 1)
9603            .read_to_end(&mut content)
9604            .map_err(|err| LinkError::InvalidPack {
9605                message: format!("could not decompress `{path}`: {err}"),
9606            })?;
9607        if content.len() as u64 > remaining {
9608            return Err(LinkError::InvalidPack {
9609                message: "expanded content exceeds the 512 MB limit".to_string(),
9610            });
9611        }
9612        if content.len() as u64 != file.size() {
9613            return Err(LinkError::InvalidPack {
9614                message: format!("length mismatch for `{path}`"),
9615            });
9616        }
9617        total += content.len() as u64;
9618        entries.push((path, content));
9619    }
9620    if entries.is_empty() {
9621        return Err(LinkError::InvalidPack {
9622            message: "pack contains no files".to_string(),
9623        });
9624    }
9625    Ok(entries)
9626}
9627
9628fn verify_snapshot_manifest(entries: &[(String, Vec<u8>)], signed: &[FeedFile]) -> LinkResult<()> {
9629    let mut expected = std::collections::BTreeMap::new();
9630    for file in signed {
9631        if !safe_store_rel_path(&file.path) {
9632            return Err(LinkError::UnsafePath {
9633                path: file.path.clone(),
9634            });
9635        }
9636        if !is_sha256(&file.sha256)
9637            || expected
9638                .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
9639                .is_some()
9640        {
9641            return Err(invalid_feed(
9642                "signed snapshot manifest contains an invalid or duplicate file",
9643            ));
9644        }
9645    }
9646    if expected.len() != entries.len() {
9647        return Err(invalid_feed(
9648            "downloaded pack file set differs from the signed snapshot manifest",
9649        ));
9650    }
9651    for (path, bytes) in entries {
9652        let Some((sha256, declared_bytes)) = expected.get(path.as_str()) else {
9653            return Err(invalid_feed(format!(
9654                "downloaded pack contains unsigned path `{path}`"
9655            )));
9656        };
9657        if *declared_bytes != bytes.len() as u64
9658            || *sha256 != format!("{:x}", Sha256::digest(bytes))
9659        {
9660            return Err(invalid_feed(format!(
9661                "downloaded file `{path}` differs from its signed manifest"
9662            )));
9663        }
9664    }
9665    Ok(())
9666}
9667
9668/// Collect the files a push sends: the store's owned text — `DB.md`,
9669/// `assets.jsonl` when present, and every content `.md` under `records/` and
9670/// `sources/` (the store walk, which already excludes hidden dirs like
9671/// `.dbmd/`, the `log/` archive, and derived `index.*` catalogs; the hub
9672/// derives its own index, and local history stays local). Returns
9673/// `(store-relative path, content)` pairs, path-sorted.
9674pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
9675    require_hardened_filesystem("sync push")?;
9676    preflight_push_ownership(store)?;
9677    let mut out: Vec<(String, String)> = Vec::new();
9678    let mut total = 0u64;
9679
9680    let mut read_text = |rel: &str| -> LinkResult<String> {
9681        let bytes = store.read_bounded(Path::new(rel), MAX_STORE_BYTES.saturating_sub(total))?;
9682        total = total
9683            .checked_add(bytes.len() as u64)
9684            .ok_or_else(|| LinkError::PushTooLarge {
9685                detail: "uncompressed byte count overflow".to_string(),
9686            })?;
9687        if total > MAX_STORE_BYTES {
9688            return Err(LinkError::PushTooLarge {
9689                detail: format!("{total} uncompressed bytes"),
9690            });
9691        }
9692        String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
9693            path: rel.to_string(),
9694        })
9695    };
9696
9697    out.push(("DB.md".to_string(), read_text("DB.md")?));
9698    if store
9699        .regular_file_exists(Path::new("assets.jsonl"))
9700        .unwrap_or(false)
9701    {
9702        out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
9703    }
9704
9705    for rel in store.walk()? {
9706        let rel_str = rel.to_string_lossy().replace('\\', "/");
9707        if !safe_store_rel_path(&rel_str) {
9708            // A locally-legal name outside the hub's portable charset cannot
9709            // travel this wire; refusing beats silently dropping it.
9710            return Err(LinkError::UnsafePath { path: rel_str });
9711        }
9712        let content = read_text(&rel_str)?;
9713        out.push((rel_str, content));
9714    }
9715
9716    out.sort_by(|a, b| a.0.cmp(&b.0));
9717    Ok(out)
9718}
9719
9720/// Refuse to build a destructive whole-store snapshot from an ambiguous local
9721/// tree. Ordinary read-only walks safely prune foreign paths, but a push that
9722/// silently omitted them could delete the hosted copies of those paths.
9723fn preflight_push_ownership(store: &Store) -> LinkResult<()> {
9724    if let Some(nested) = store.nested_store_roots()?.into_iter().next() {
9725        return Err(LinkError::from(std::io::Error::new(
9726            std::io::ErrorKind::PermissionDenied,
9727            format!("cannot push: nested db.md store at {}", nested.display()),
9728        )));
9729    }
9730
9731    if let Some(symlink) = store.unowned_symlinks()?.into_iter().next() {
9732        return Err(LinkError::from(std::io::Error::new(
9733            std::io::ErrorKind::PermissionDenied,
9734            format!(
9735                "cannot push: {} is a symlink outside the store ownership model",
9736                symlink.display()
9737            ),
9738        )));
9739    }
9740    Ok(())
9741}
9742
9743/// Push `files` to `brain` as a whole-store snapshot — the hub's push
9744/// semantics: the hosted copy becomes exactly this set (pull first if the
9745/// hosted side may have records the local copy lacks). Client-side caps
9746/// mirror the hub's JSON-path limits so an oversized push fails before the
9747/// upload.
9748pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
9749    require_safe_ref(brain)?;
9750    let remote = verified_remote_head(cfg, brain, false)?;
9751    if files.len() > MAX_PUSH_FILES {
9752        return Err(LinkError::PushTooLarge {
9753            detail: format!("{} files", files.len()),
9754        });
9755    }
9756    let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
9757    if raw_total > MAX_STORE_BYTES {
9758        return Err(LinkError::PushTooLarge {
9759            detail: format!("{raw_total} uncompressed bytes"),
9760        });
9761    }
9762
9763    // Self-custody (a brain key is configured): the JSON fast path is
9764    // hub-signed by construction, so every push goes through the pack flow
9765    // with a locally signed entry — the hub verifies and can never sign.
9766    if cfg.brain_key.is_none() {
9767        let body = json!({
9768            "files": files
9769                .iter()
9770                .map(|(p, c)| json!({ "path": p, "content": c }))
9771                .collect::<Vec<_>>(),
9772        });
9773        if body.to_string().len() <= MAX_PUSH_BYTES {
9774            let path = format!("/api/hub/brains/{brain}/push");
9775            let pushed = ensure_ok(
9776                request(cfg, "POST", &path, Some(&body), Auth::Required)?,
9777                "sync push",
9778            )?;
9779            return Ok(pushed);
9780        }
9781    }
9782
9783    let pack = build_store_pack(files)?;
9784    if pack.len() as u64 > MAX_PACK_BYTES {
9785        return Err(LinkError::PushTooLarge {
9786            detail: format!("{} pack bytes", pack.len()),
9787        });
9788    }
9789    let sha256 = format!("{:x}", Sha256::digest(&pack));
9790    let mut meta = json!({ "sha256": sha256, "bytes": pack.len() });
9791    if let Some(key) = &cfg.brain_key {
9792        if !remote.head.verified {
9793            return Err(invalid_feed(
9794                "self-custody push requires a fully verified, unscoped feed head",
9795            ));
9796        }
9797        let identity = remote
9798            .identity
9799            .as_ref()
9800            .ok_or_else(|| invalid_feed("verified brain has no current identity"))?;
9801        let current_multikey = format!("ed25519:{}", identity.fingerprint);
9802        if key.multikey != current_multikey || key.public_key_spki != identity.public_key_spki {
9803            return Err(invalid_feed(
9804                "configured brain key is not the verified current brain identity",
9805            ));
9806        }
9807        // The verified state pins seq + prev; a concurrent writer surfaces as
9808        // the hub's 422 on commit (re-run to retry against the new head).
9809        let next_seq = remote
9810            .head
9811            .seq
9812            .checked_add(1)
9813            .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
9814        let mut manifest: Vec<WireFeedFile> = files
9815            .iter()
9816            .map(|(path, content)| WireFeedFile {
9817                path: path.clone(),
9818                sha256: format!("{:x}", Sha256::digest(content.as_bytes())),
9819                bytes: content.len() as u64,
9820            })
9821            .collect();
9822        manifest.sort_by(|a, b| a.path.cmp(&b.path));
9823        let ts = crate::now()
9824            .with_timezone(&chrono::Utc)
9825            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
9826            .to_string();
9827        let entry = self_custody_entry(
9828            key,
9829            next_seq,
9830            ts,
9831            &sha256,
9832            &manifest,
9833            remote.head.feed_hash.as_deref(),
9834        )?;
9835        meta["entry"] = Value::String(entry);
9836    }
9837    let presigned = ensure_ok(
9838        request(
9839            cfg,
9840            "POST",
9841            &format!("/api/hub/brains/{brain}/packs/presign"),
9842            Some(&meta),
9843            Auth::Required,
9844        )?,
9845        "prepare pack upload",
9846    )?;
9847    let url = presigned
9848        .get("url")
9849        .and_then(Value::as_str)
9850        .ok_or_else(|| LinkError::InvalidPack {
9851            message: "the hub returned no upload URL".to_string(),
9852        })?;
9853    put_presigned(
9854        cfg,
9855        url,
9856        presigned.get("headers").unwrap_or(&Value::Null),
9857        &pack,
9858    )?;
9859    let committed = ensure_ok(
9860        request(
9861            cfg,
9862            "POST",
9863            &format!("/api/hub/brains/{brain}/packs/commit"),
9864            Some(&meta),
9865            Auth::Required,
9866        )?,
9867        "commit pack",
9868    )?;
9869    Ok(committed)
9870}
9871
9872fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
9873    const LOCAL_HEADER: u32 = 0x0403_4b50;
9874    const CENTRAL_HEADER: u32 = 0x0201_4b50;
9875    const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
9876    const VERSION_20: u16 = 20;
9877    const MADE_BY_UNIX_20: u16 = (3 << 8) | VERSION_20;
9878    const UTF8_FLAG: u16 = 1 << 11;
9879    const STORED: u16 = 0;
9880    const DOS_TIME_MIDNIGHT: u16 = 0;
9881    const DOS_DATE_1980_01_01: u16 = (1 << 5) | 1;
9882    const UNIX_REGULAR_0600: u32 = 0o100600 << 16;
9883
9884    struct CentralEntry<'a> {
9885        name: &'a [u8],
9886        crc32: u32,
9887        size: u32,
9888        local_offset: u32,
9889    }
9890
9891    fn push_u16(out: &mut Vec<u8>, value: u16) {
9892        out.extend_from_slice(&value.to_le_bytes());
9893    }
9894
9895    fn push_u32(out: &mut Vec<u8>, value: u32) {
9896        out.extend_from_slice(&value.to_le_bytes());
9897    }
9898
9899    if files.is_empty() {
9900        return Err(LinkError::InvalidPack {
9901            message: "cannot create an empty snapshot pack".to_string(),
9902        });
9903    }
9904    if files.len() > u16::MAX as usize {
9905        return Err(LinkError::PushTooLarge {
9906            detail: format!(
9907                "{} files (canonical ZIP32 packs cap at {})",
9908                files.len(),
9909                u16::MAX
9910            ),
9911        });
9912    }
9913
9914    let mut sorted: Vec<_> = files.iter().collect();
9915    sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
9916    let mut previous: Option<&str> = None;
9917    for (path, content) in &sorted {
9918        if !safe_store_rel_path(path) {
9919            return Err(LinkError::UnsafePath {
9920                path: (*path).clone(),
9921            });
9922        }
9923        if previous == Some(path.as_str()) {
9924            return Err(LinkError::InvalidPack {
9925                message: format!("duplicate path `{path}`"),
9926            });
9927        }
9928        previous = Some(path.as_str());
9929        u32::try_from(content.len()).map_err(|_| LinkError::PushTooLarge {
9930            detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
9931        })?;
9932    }
9933
9934    let mut out = Vec::new();
9935    let mut central = Vec::with_capacity(sorted.len());
9936    for (path, content) in sorted {
9937        let name = path.as_bytes();
9938        let name_len = u16::try_from(name.len()).map_err(|_| LinkError::InvalidPack {
9939            message: format!("ZIP entry name is too long: `{path}`"),
9940        })?;
9941        let bytes = content.as_bytes();
9942        let size = u32::try_from(bytes.len()).map_err(|_| LinkError::PushTooLarge {
9943            detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
9944        })?;
9945        let local_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
9946            detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
9947        })?;
9948        let crc32 = crc32fast::hash(bytes);
9949
9950        // Canonical local header: sizes and CRC are known up front. Bit 3 is
9951        // deliberately clear, so there is no trailing data descriptor.
9952        push_u32(&mut out, LOCAL_HEADER);
9953        push_u16(&mut out, VERSION_20);
9954        push_u16(&mut out, UTF8_FLAG);
9955        push_u16(&mut out, STORED);
9956        push_u16(&mut out, DOS_TIME_MIDNIGHT);
9957        push_u16(&mut out, DOS_DATE_1980_01_01);
9958        push_u32(&mut out, crc32);
9959        push_u32(&mut out, size);
9960        push_u32(&mut out, size);
9961        push_u16(&mut out, name_len);
9962        push_u16(&mut out, 0); // no local extra data
9963        out.extend_from_slice(name);
9964        out.extend_from_slice(bytes);
9965
9966        central.push(CentralEntry {
9967            name,
9968            crc32,
9969            size,
9970            local_offset,
9971        });
9972    }
9973
9974    let central_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
9975        detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
9976    })?;
9977    for entry in &central {
9978        push_u32(&mut out, CENTRAL_HEADER);
9979        push_u16(&mut out, MADE_BY_UNIX_20);
9980        push_u16(&mut out, VERSION_20);
9981        push_u16(&mut out, UTF8_FLAG);
9982        push_u16(&mut out, STORED);
9983        push_u16(&mut out, DOS_TIME_MIDNIGHT);
9984        push_u16(&mut out, DOS_DATE_1980_01_01);
9985        push_u32(&mut out, entry.crc32);
9986        push_u32(&mut out, entry.size);
9987        push_u32(&mut out, entry.size);
9988        push_u16(&mut out, entry.name.len() as u16);
9989        push_u16(&mut out, 0); // no central extra data
9990        push_u16(&mut out, 0); // no file comment
9991        push_u16(&mut out, 0); // disk number
9992        push_u16(&mut out, 0); // internal attributes
9993        push_u32(&mut out, UNIX_REGULAR_0600);
9994        push_u32(&mut out, entry.local_offset);
9995        out.extend_from_slice(entry.name);
9996    }
9997    let central_size = u32::try_from(out.len())
9998        .ok()
9999        .and_then(|end| end.checked_sub(central_offset))
10000        .ok_or_else(|| LinkError::PushTooLarge {
10001            detail: "canonical ZIP32 central directory exceeds its limit".to_string(),
10002        })?;
10003    let entry_count = central.len() as u16;
10004
10005    push_u32(&mut out, END_OF_CENTRAL_DIRECTORY);
10006    push_u16(&mut out, 0); // this disk
10007    push_u16(&mut out, 0); // central directory disk
10008    push_u16(&mut out, entry_count);
10009    push_u16(&mut out, entry_count);
10010    push_u32(&mut out, central_size);
10011    push_u32(&mut out, central_offset);
10012    push_u16(&mut out, 0); // no archive comment
10013
10014    if out.len() > u32::MAX as usize {
10015        return Err(LinkError::PushTooLarge {
10016            detail: "canonical ZIP32 pack exceeds 4 GiB".to_string(),
10017        });
10018    }
10019    Ok(out)
10020}
10021
10022// ─────────────────────────────────────────────────────────────────────────────
10023// grant — issue / list / revoke capabilities (owner-side)
10024// ─────────────────────────────────────────────────────────────────────────────
10025
10026/// The two capabilities a v0 hub enforces.
10027#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10028pub enum Capability {
10029    /// Read the granted slice.
10030    Read,
10031    /// Read and push (whole-store; a path-scoped grant is read-only).
10032    Write,
10033}
10034
10035impl Capability {
10036    /// The wire form.
10037    pub fn as_str(self) -> &'static str {
10038        match self {
10039            Capability::Read => "read",
10040            Capability::Write => "write",
10041        }
10042    }
10043}
10044
10045/// Issue (or refresh) a grant on `brain` to `grantee` — a hub principal named
10046/// by email in v0 (the protocol's near-term simplification; key-named
10047/// grantees arrive with the signing layer). `scope` is a store-path prefix
10048/// (the hub's enforcement unit); `until` an ISO 8601 expiry, absent = until
10049/// revoked.
10050pub fn grant_issue(
10051    cfg: &HubConfig,
10052    brain: &str,
10053    grantee: &str,
10054    can: Capability,
10055    scope: Option<&str>,
10056    until: Option<&str>,
10057) -> LinkResult<Value> {
10058    require_safe_ref(brain)?;
10059    let _ = verified_remote_head(cfg, brain, false)?;
10060    // Grantee shape decides the axis: a base64url Ed25519 SPKI is a bare
10061    // multikey holder (link.md §6 cross-party keys — no hub account; the
10062    // printed `publicKeySpki` from `dbmd key generate`); anything else is a
10063    // hub principal named by email.
10064    let is_key_grantee = URL_SAFE_NO_PAD
10065        .decode(grantee)
10066        .map(|der| der.len() == 44 && der.starts_with(&ED25519_SPKI_PREFIX))
10067        .unwrap_or(false);
10068    let mut body = if is_key_grantee {
10069        json!({ "keySpki": grantee, "capability": can.as_str() })
10070    } else {
10071        json!({ "email": grantee, "capability": can.as_str() })
10072    };
10073    if let Some(s) = scope {
10074        body["scopePrefix"] = json!(s);
10075    }
10076    if let Some(u) = until {
10077        body["expiresAt"] = json!(u);
10078    }
10079    let path = format!("/api/hub/brains/{brain}/grants");
10080    ensure_ok(
10081        request(cfg, "POST", &path, Some(&body), Auth::Required)?,
10082        "grant issue",
10083    )
10084}
10085
10086/// List the active grants (and pending invites) on `brain`. Owner-side.
10087pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
10088    require_safe_ref(brain)?;
10089    let _ = verified_remote_head(cfg, brain, false)?;
10090    let path = format!("/api/hub/brains/{brain}/grants");
10091    ensure_ok(
10092        request(cfg, "GET", &path, None, Auth::Required)?,
10093        "grant list",
10094    )
10095}
10096
10097/// Revoke a grant (or cancel a pending invite) by id. Owner-side; revocation
10098/// is soft on the hub (the audit trail survives).
10099pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
10100    require_safe_ref(brain)?;
10101    require_safe_grant_id(grant_id)?;
10102    let _ = verified_remote_head(cfg, brain, false)?;
10103    let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
10104    ensure_ok(
10105        request(cfg, "DELETE", &path, None, Auth::Required)?,
10106        "grant revoke",
10107    )
10108}
10109
10110// ─────────────────────────────────────────────────────────────────────────────
10111// permissioned v2 proposals — inspect, accept, reject
10112// ─────────────────────────────────────────────────────────────────────────────
10113
10114#[derive(Debug)]
10115struct VerifiedV2Proposal {
10116    value: Value,
10117    changes: Value,
10118    blobs: Vec<(String, u64, String)>,
10119}
10120
10121fn require_proposal_id(id: &str) -> LinkResult<()> {
10122    if crate::ulid::is_ulid(id) {
10123        Ok(())
10124    } else {
10125        Err(invalid_feed("proposal id is not a lowercase ULID"))
10126    }
10127}
10128
10129fn verified_v2_proposal(
10130    cfg: &HubConfig,
10131    head: &V2VerifiedHead,
10132    proposal_id: &str,
10133) -> LinkResult<VerifiedV2Proposal> {
10134    require_proposal_id(proposal_id)?;
10135    if head.view_kind != "full" {
10136        return Err(invalid_feed(
10137            "proposal review requires a full readable view",
10138        ));
10139    }
10140    let path = format!(
10141        "/api/hub/brains/{}/v2/proposals/{proposal_id}",
10142        head.brain_id
10143    );
10144    let value = ensure_ok(
10145        request_capped(
10146            cfg,
10147            "GET",
10148            &path,
10149            None,
10150            Auth::Required,
10151            MAX_FEED_RESPONSE_BYTES,
10152        )?,
10153        "v2 proposal",
10154    )?;
10155    verify_v2_proposal_value(head, proposal_id, value)
10156}
10157
10158fn verify_v2_proposal_value(
10159    head: &V2VerifiedHead,
10160    proposal_id: &str,
10161    value: Value,
10162) -> LinkResult<VerifiedV2Proposal> {
10163    if value.get("v").and_then(Value::as_u64) != Some(2) {
10164        return Err(invalid_feed("proposal response has an invalid version"));
10165    }
10166    let proposal = value
10167        .get("proposal")
10168        .and_then(Value::as_object)
10169        .ok_or_else(|| invalid_feed("proposal response has no proposal"))?;
10170    if proposal.get("id").and_then(Value::as_str) != Some(proposal_id) {
10171        return Err(invalid_feed("proposal response changed its id"));
10172    }
10173    let payload_hash = proposal
10174        .get("payload_sha256")
10175        .and_then(Value::as_str)
10176        .filter(|hash| is_sha256(hash))
10177        .ok_or_else(|| invalid_feed("proposal has no payload address"))?;
10178    let clear_hash = proposal
10179        .get("clear_sha256")
10180        .and_then(Value::as_str)
10181        .filter(|hash| is_sha256(hash))
10182        .ok_or_else(|| invalid_feed("proposal has no clear payload digest"))?;
10183    let submission_hash = proposal
10184        .get("submission_claim_sha256")
10185        .and_then(Value::as_str)
10186        .filter(|hash| is_sha256(hash))
10187        .ok_or_else(|| invalid_feed("proposal has no submission claim address"))?;
10188    let submission = STANDARD
10189        .decode(
10190            proposal
10191                .get("submission_claim_base64")
10192                .and_then(Value::as_str)
10193                .ok_or_else(|| invalid_feed("proposal has no submission claim"))?,
10194        )
10195        .map_err(|_| invalid_feed("proposal submission claim is not base64"))?;
10196    let submission_value: Value = serde_json::from_slice(&submission)
10197        .map_err(|_| invalid_feed("proposal submission claim is not JSON"))?;
10198    if crate::linkmd_v2::canonical_bytes(&submission_value)
10199        .map_err(|error| invalid_feed(error.to_string()))?
10200        != submission
10201        || crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &submission)
10202            .map_err(|error| invalid_feed(error.to_string()))?
10203            != submission_hash
10204    {
10205        return Err(invalid_feed(
10206            "proposal submission claim is not canonical or addressed",
10207        ));
10208    }
10209    let envelope = submission_value
10210        .as_object()
10211        .ok_or_else(|| invalid_feed("proposal submission claim is not an object"))?;
10212    let claim = envelope
10213        .get("claim")
10214        .ok_or_else(|| invalid_feed("proposal submission claim body is missing"))?;
10215    let claim_object = claim
10216        .as_object()
10217        .ok_or_else(|| invalid_feed("proposal submission claim body is not an object"))?;
10218    let actor_root = claim_object
10219        .get("actor_root")
10220        .and_then(Value::as_object)
10221        .ok_or_else(|| invalid_feed("proposal submission actor root is missing"))?;
10222    let public_key = envelope
10223        .get("public_key")
10224        .and_then(Value::as_str)
10225        .ok_or_else(|| invalid_feed("proposal submission signer is missing"))?;
10226    let fingerprint = envelope
10227        .get("fingerprint")
10228        .and_then(Value::as_str)
10229        .ok_or_else(|| invalid_feed("proposal submission fingerprint is missing"))?;
10230    let signature = envelope
10231        .get("sig")
10232        .and_then(Value::as_str)
10233        .ok_or_else(|| invalid_feed("proposal submission signature is missing"))?;
10234    let claim_bytes = crate::linkmd_v2::canonical_bytes(claim)
10235        .map_err(|error| invalid_feed(error.to_string()))?;
10236    let der = verify_v2_spki_signature(public_key, &claim_bytes, signature)?;
10237    let signer = format!("{fingerprint}:{public_key}");
10238    let actor_class = actor_root.get("actor_class").and_then(Value::as_str);
10239    let grants = actor_root.get("grants").and_then(Value::as_array);
10240    let grants_are_canonical = grants.is_some_and(|items| {
10241        let mut prior: Option<&str> = None;
10242        items.iter().all(|item| {
10243            let Some(grant) = item.as_str() else {
10244                return false;
10245            };
10246            if !crate::ulid::is_ulid(grant) || prior.is_some_and(|value| value >= grant) {
10247                return false;
10248            }
10249            prior = Some(grant);
10250            true
10251        })
10252    });
10253    let optional_actor_field = |name: &str| {
10254        actor_root.get(name).is_some_and(|value| {
10255            value.is_null() || value.as_str().is_some_and(|text| !text.is_empty())
10256        })
10257    };
10258    let submitted_at = claim_object.get("submitted_at").and_then(Value::as_str);
10259    if claim_object.get("v").and_then(Value::as_u64) != Some(2)
10260        || format!("{:x}", Sha256::digest(&der)) != fingerprint
10261        || head
10262            .trust
10263            .hub_signer
10264            .as_ref()
10265            .is_some_and(|known| known != &signer)
10266        || !matches!(
10267            actor_class,
10268            Some(
10269                "user"
10270                    | "owned_agent"
10271                    | "foreign_key"
10272                    | "curation"
10273                    | "inbox"
10274                    | "restore"
10275                    | "migration"
10276                    | "operator_recovery"
10277            )
10278        )
10279        || actor_root
10280            .get("principal")
10281            .and_then(Value::as_str)
10282            .is_none_or(|value| value.is_empty())
10283        || actor_root
10284            .get("credential")
10285            .and_then(Value::as_str)
10286            .is_none_or(|value| value.is_empty())
10287        || !optional_actor_field("organization")
10288        || !optional_actor_field("role")
10289        || !grants_are_canonical
10290        || claim.get("brain").and_then(Value::as_str) != Some(head.brain_id.as_str())
10291        || claim.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
10292        || !claim_object
10293            .get("mutation_id")
10294            .and_then(Value::as_str)
10295            .is_some_and(|value| {
10296                !value.is_empty()
10297                    && value.len() <= 128
10298                    && value.chars().enumerate().all(|(index, char)| {
10299                        char.is_ascii_alphanumeric()
10300                            || (index > 0 && matches!(char, '.' | '_' | ':' | '-'))
10301                    })
10302            })
10303        || claim.get("payload_sha256").and_then(Value::as_str) != Some(payload_hash)
10304        || claim.get("clear_sha256").and_then(Value::as_str) != Some(clear_hash)
10305        || !claim_object
10306            .get("control_revision")
10307            .and_then(Value::as_str)
10308            .is_some_and(is_sha256)
10309        || submitted_at.is_none_or(|value| {
10310            chrono::DateTime::parse_from_rfc3339(value).is_err()
10311                || proposal.get("submitted_at").and_then(Value::as_str) != Some(value)
10312        })
10313        || !proposal
10314            .get("state")
10315            .and_then(Value::as_str)
10316            .is_some_and(|value| matches!(value, "pending" | "accepted" | "rejected" | "expired"))
10317        || proposal
10318            .get("expires_at")
10319            .and_then(Value::as_str)
10320            .is_none_or(|value| chrono::DateTime::parse_from_rfc3339(value).is_err())
10321        || proposal
10322            .get("proposer")
10323            .and_then(Value::as_object)
10324            .and_then(|value| value.get("class"))
10325            .and_then(Value::as_str)
10326            != actor_class
10327    {
10328        return Err(invalid_feed(
10329            "proposal submission claim does not bind the verified proposal",
10330        ));
10331    }
10332    let changes_b64 = proposal
10333        .get("changes_base64")
10334        .and_then(Value::as_str)
10335        .ok_or_else(|| invalid_feed("proposal has no changeset"))?;
10336    let changes_bytes = STANDARD
10337        .decode(changes_b64)
10338        .map_err(|_| invalid_feed("proposal changeset is not base64"))?;
10339    let changes: Value = serde_json::from_slice(&changes_bytes)
10340        .map_err(|_| invalid_feed("proposal changeset is not JSON"))?;
10341    if crate::linkmd_v2::canonical_bytes(&changes)
10342        .map_err(|error| invalid_feed(error.to_string()))?
10343        != changes_bytes
10344        || changes.get("v").and_then(Value::as_u64) != Some(2)
10345        || !changes.get("operations").is_some_and(Value::is_array)
10346    {
10347        return Err(invalid_feed("proposal changeset is not canonical v2"));
10348    }
10349    let blob_values = proposal
10350        .get("blobs")
10351        .and_then(Value::as_array)
10352        .ok_or_else(|| invalid_feed("proposal has no blob declarations"))?;
10353    let mut blobs = Vec::with_capacity(blob_values.len());
10354    let mut descriptor_blobs = Vec::with_capacity(blob_values.len());
10355    let mut prior_hash: Option<String> = None;
10356    for item in blob_values {
10357        let hash = item
10358            .get("sha256")
10359            .and_then(Value::as_str)
10360            .filter(|hash| is_sha256(hash))
10361            .ok_or_else(|| invalid_feed("proposal blob has no address"))?;
10362        let bytes = item
10363            .get("bytes")
10364            .and_then(Value::as_u64)
10365            .filter(|bytes| *bytes <= MAX_STORE_BYTES)
10366            .ok_or_else(|| invalid_feed("proposal blob has an invalid size"))?;
10367        if prior_hash.as_deref().is_some_and(|prior| prior >= hash) {
10368            return Err(invalid_feed(
10369                "proposal blob declarations are not unique and sorted",
10370            ));
10371        }
10372        prior_hash = Some(hash.to_string());
10373        let endpoint = item
10374            .get("endpoint")
10375            .and_then(Value::as_str)
10376            .ok_or_else(|| invalid_feed("proposal blob has no endpoint"))?;
10377        let expected_endpoint = format!(
10378            "/api/hub/brains/{}/v2/proposals/{proposal_id}/blob?sha256={hash}",
10379            head.brain_id
10380        );
10381        if endpoint != expected_endpoint {
10382            return Err(invalid_feed("proposal blob endpoint is not origin-bound"));
10383        }
10384        descriptor_blobs.push(json!({ "bytes": bytes, "sha256": hash }));
10385        blobs.push((hash.to_string(), bytes, endpoint.to_string()));
10386    }
10387    let descriptor = json!({
10388        "base": proposal.get("base").cloned().unwrap_or(Value::Null),
10389        "blobs": descriptor_blobs,
10390        "changes_base64": changes_b64,
10391        "rebase": proposal.get("rebase").cloned().unwrap_or(Value::Null),
10392        "v": 2,
10393    });
10394    let descriptor_bytes = crate::linkmd_v2::canonical_bytes(&descriptor)
10395        .map_err(|error| invalid_feed(error.to_string()))?;
10396    if content_sha256(&descriptor_bytes) != clear_hash {
10397        return Err(invalid_feed(
10398            "proposal clear payload differs from its signed submission claim",
10399        ));
10400    }
10401    Ok(VerifiedV2Proposal {
10402        value,
10403        changes,
10404        blobs,
10405    })
10406}
10407
10408pub fn proposal_list(
10409    cfg: &HubConfig,
10410    brain: &str,
10411    state: &str,
10412    after: Option<&str>,
10413    limit: usize,
10414) -> LinkResult<Value> {
10415    require_safe_ref(brain)?;
10416    if !matches!(state, "pending" | "accepted" | "rejected" | "expired") {
10417        return Err(invalid_feed("proposal state is invalid"));
10418    }
10419    if after.is_some_and(|value| !crate::ulid::is_ulid(value)) {
10420        return Err(invalid_feed("proposal cursor is invalid"));
10421    }
10422    let head = v2_verified_head(cfg, brain)?
10423        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
10424    let path = format!(
10425        "/api/hub/brains/{}/v2/proposals?state={state}&limit={}{}",
10426        head.brain_id,
10427        limit.clamp(1, 100),
10428        after.map_or_else(String::new, |value| format!("&after={value}"))
10429    );
10430    ensure_ok(
10431        request_capped(
10432            cfg,
10433            "GET",
10434            &path,
10435            None,
10436            Auth::Required,
10437            MAX_FEED_RESPONSE_BYTES,
10438        )?,
10439        "v2 proposal list",
10440    )
10441}
10442
10443pub fn proposal_show(cfg: &HubConfig, brain: &str, proposal_id: &str) -> LinkResult<Value> {
10444    require_safe_ref(brain)?;
10445    let head = v2_verified_head(cfg, brain)?
10446        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
10447    Ok(verified_v2_proposal(cfg, &head, proposal_id)?.value)
10448}
10449
10450pub fn proposal_reject(
10451    cfg: &HubConfig,
10452    brain: &str,
10453    proposal_id: &str,
10454    mutation_id: &str,
10455    reason: &str,
10456) -> LinkResult<Value> {
10457    require_safe_ref(brain)?;
10458    require_proposal_id(proposal_id)?;
10459    let head = v2_verified_head(cfg, brain)?
10460        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
10461    let _ = verified_v2_proposal(cfg, &head, proposal_id)?;
10462    let body = json!({
10463        "mutation_id": mutation_id,
10464        "control_revision": head.control_revision,
10465        "reason": reason,
10466    });
10467    let path = format!(
10468        "/api/hub/brains/{}/v2/proposals/{proposal_id}",
10469        head.brain_id
10470    );
10471    ensure_ok(
10472        request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
10473        "v2 proposal rejection",
10474    )
10475}
10476
10477pub fn proposal_accept_exact(
10478    cfg: &HubConfig,
10479    brain: &str,
10480    proposal_id: &str,
10481    mutation_id: &str,
10482    reason: &str,
10483) -> LinkResult<Value> {
10484    require_safe_ref(brain)?;
10485    require_proposal_id(proposal_id)?;
10486    let head = v2_verified_head(cfg, brain)?
10487        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
10488    let proposal = verified_v2_proposal(cfg, &head, proposal_id)?;
10489    let operations = proposal
10490        .changes
10491        .get("operations")
10492        .and_then(Value::as_array)
10493        .cloned()
10494        .ok_or_else(|| invalid_feed("proposal changeset has no operations"))?;
10495    if operations.is_empty() || operations.len() > MAX_PUSH_FILES {
10496        return Err(invalid_feed("proposal operation count is invalid"));
10497    }
10498    let mut downloaded = std::collections::BTreeMap::new();
10499    for (hash, bytes, endpoint) in &proposal.blobs {
10500        let body = ensure_raw_ok(
10501            request_raw(cfg, "GET", endpoint, None, Auth::Required, *bytes)?,
10502            "v2 proposal blob",
10503        )?;
10504        if body.len() as u64 != *bytes || content_sha256(&body) != *hash {
10505            return Err(invalid_feed("proposal blob does not match its declaration"));
10506        }
10507        downloaded.insert(hash.clone(), body);
10508    }
10509    let remote = files_for_v2_view(
10510        &head,
10511        v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
10512    );
10513    let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
10514    let mut expected_candidate = remote.clone();
10515    let mut expected_candidate_assets = remote_assets;
10516    for operation in &operations {
10517        let op = operation
10518            .get("op")
10519            .and_then(Value::as_str)
10520            .ok_or_else(|| invalid_feed("proposal operation has no kind"))?;
10521        match op {
10522            "put" | "restore" => {
10523                let path = operation
10524                    .get("path")
10525                    .and_then(Value::as_str)
10526                    .ok_or_else(|| invalid_feed("proposal write has no path"))?;
10527                crate::linkmd_v2::normalize_path(path)
10528                    .map_err(|error| invalid_feed(error.to_string()))?;
10529                let hash = operation
10530                    .get("blob")
10531                    .and_then(Value::as_str)
10532                    .filter(|hash| is_sha256(hash))
10533                    .ok_or_else(|| invalid_feed("proposal write has no blob"))?;
10534                let bytes = operation
10535                    .get("bytes")
10536                    .and_then(Value::as_u64)
10537                    .ok_or_else(|| invalid_feed("proposal write has no size"))?;
10538                expected_candidate.insert(
10539                    path.to_string(),
10540                    V2BaselineFile {
10541                        sha256: hash.to_string(),
10542                        bytes,
10543                        proof: None,
10544                    },
10545                );
10546            }
10547            "delete" | "withdraw_from_hosting" => {
10548                let path = operation
10549                    .get("path")
10550                    .and_then(Value::as_str)
10551                    .ok_or_else(|| invalid_feed("proposal removal has no path"))?;
10552                crate::linkmd_v2::normalize_path(path)
10553                    .map_err(|error| invalid_feed(error.to_string()))?;
10554                expected_candidate.remove(path);
10555            }
10556            "rename" => {
10557                let from = operation
10558                    .get("from")
10559                    .and_then(Value::as_str)
10560                    .ok_or_else(|| invalid_feed("proposal rename has no source"))?;
10561                let to = operation
10562                    .get("to")
10563                    .and_then(Value::as_str)
10564                    .ok_or_else(|| invalid_feed("proposal rename has no destination"))?;
10565                crate::linkmd_v2::normalize_path(from)
10566                    .and_then(|_| crate::linkmd_v2::normalize_path(to))
10567                    .map_err(|error| invalid_feed(error.to_string()))?;
10568                let hash = operation
10569                    .get("blob")
10570                    .and_then(Value::as_str)
10571                    .filter(|hash| is_sha256(hash))
10572                    .ok_or_else(|| invalid_feed("proposal rename has no blob"))?;
10573                let bytes = operation
10574                    .get("bytes")
10575                    .and_then(Value::as_u64)
10576                    .ok_or_else(|| invalid_feed("proposal rename has no size"))?;
10577                expected_candidate.remove(from);
10578                expected_candidate.insert(
10579                    to.to_string(),
10580                    V2BaselineFile {
10581                        sha256: hash.to_string(),
10582                        bytes,
10583                        proof: None,
10584                    },
10585                );
10586            }
10587            "asset_delete" => {
10588                let path = operation
10589                    .get("path")
10590                    .and_then(Value::as_str)
10591                    .ok_or_else(|| invalid_feed("proposal asset delete has no path"))?;
10592                expected_candidate_assets.remove(path);
10593            }
10594            "asset_withdraw" => {
10595                let path = operation
10596                    .get("path")
10597                    .and_then(Value::as_str)
10598                    .ok_or_else(|| invalid_feed("proposal asset withdrawal has no path"))?;
10599                let asset = expected_candidate_assets
10600                    .get_mut(path)
10601                    .ok_or_else(|| invalid_feed("proposal withdraws an unknown asset"))?;
10602                asset.disposition = "withheld".to_string();
10603                asset.leaf_hash.clear();
10604            }
10605            "asset_put" | "asset_resume" => {
10606                let path = operation
10607                    .get("path")
10608                    .and_then(Value::as_str)
10609                    .ok_or_else(|| invalid_feed("proposal asset write has no path"))?;
10610                let asset = operation
10611                    .get("asset")
10612                    .and_then(Value::as_object)
10613                    .ok_or_else(|| invalid_feed("proposal asset write has no value"))?;
10614                let blob_sha256 = asset
10615                    .get("blob_sha256")
10616                    .and_then(Value::as_str)
10617                    .filter(|hash| is_sha256(hash))
10618                    .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
10619                let bytes = asset
10620                    .get("bytes")
10621                    .and_then(Value::as_u64)
10622                    .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
10623                let media_type = asset
10624                    .get("media_type")
10625                    .and_then(Value::as_str)
10626                    .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
10627                let wrappers = asset
10628                    .get("wrappers")
10629                    .and_then(Value::as_array)
10630                    .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
10631                    .iter()
10632                    .map(|wrapper| {
10633                        wrapper
10634                            .as_str()
10635                            .map(str::to_string)
10636                            .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
10637                    })
10638                    .collect::<LinkResult<Vec<_>>>()?;
10639                let required = asset
10640                    .get("required")
10641                    .and_then(Value::as_bool)
10642                    .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
10643                let disposition = asset
10644                    .get("disposition")
10645                    .and_then(Value::as_str)
10646                    .filter(|value| matches!(*value, "hosted" | "withheld"))
10647                    .ok_or_else(|| invalid_feed("proposal asset disposition is invalid"))?;
10648                expected_candidate_assets.insert(
10649                    path.to_string(),
10650                    V2BaselineAsset {
10651                        blob_sha256: blob_sha256.to_string(),
10652                        bytes,
10653                        media_type: media_type.to_string(),
10654                        wrappers,
10655                        required,
10656                        disposition: disposition.to_string(),
10657                        leaf_hash: String::new(),
10658                    },
10659                );
10660            }
10661            _ => return Err(invalid_feed("proposal operation kind is unsupported")),
10662        }
10663    }
10664    let base = head.pointer.as_ref().map(|pointer| {
10665        json!({
10666            "seq": pointer.seq,
10667            "commit_hash": pointer.commit_hash,
10668            "content_root": pointer.content_root,
10669            "asset_root": pointer.asset_root,
10670        })
10671    });
10672    let mut body = json!({
10673        "mutation_id": mutation_id,
10674        "base": base,
10675        "rebase": "strict",
10676        "reason": reason,
10677        "operations": operations,
10678        "blobs": downloaded
10679            .iter()
10680            .map(|(sha256, bytes)| json!({
10681                "sha256": sha256,
10682                "bytes": bytes.len(),
10683                "content_base64": STANDARD.encode(bytes),
10684            }))
10685            .collect::<Vec<_>>(),
10686        "proposal_id": proposal_id,
10687        "proposal_mode": "exact",
10688    });
10689    let changed_bytes = downloaded.values().try_fold(0_usize, |total, bytes| {
10690        total
10691            .checked_add(bytes.len())
10692            .ok_or_else(|| LinkError::PushTooLarge {
10693                detail: "proposal changed-byte total overflow".to_string(),
10694            })
10695    })?;
10696    if changed_bytes > 3 * 1024 * 1024 || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
10697        let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
10698        for operation in &operations {
10699            let Some(kind) = operation.get("op").and_then(Value::as_str) else {
10700                return Err(invalid_feed("proposal upload operation has no kind"));
10701            };
10702            let hash = match kind {
10703                "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
10704                "asset_put" | "asset_resume" => operation
10705                    .get("asset")
10706                    .and_then(|asset| asset.get("blob_sha256"))
10707                    .and_then(Value::as_str),
10708                _ => None,
10709            };
10710            let Some(hash) = hash else { continue };
10711            let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
10712            if kind == "rename" {
10713                for field in ["from", "to"] {
10714                    coordinates.insert(
10715                        operation
10716                            .get(field)
10717                            .and_then(Value::as_str)
10718                            .ok_or_else(|| invalid_feed("proposal rename has no coordinate"))?
10719                            .to_string(),
10720                    );
10721                }
10722            } else {
10723                let path = operation
10724                    .get("path")
10725                    .and_then(Value::as_str)
10726                    .ok_or_else(|| invalid_feed("proposal upload has no coordinate"))?;
10727                coordinates.insert(if kind.starts_with("asset_") {
10728                    format!("assets/{path}")
10729                } else {
10730                    path.to_string()
10731                });
10732            }
10733        }
10734        let declarations = downloaded
10735            .iter()
10736            .map(|(sha256, bytes)| {
10737                json!({
10738                    "sha256": sha256,
10739                    "bytes": bytes.len(),
10740                    "coordinates": coordinates_by_hash
10741                        .get(sha256)
10742                        .into_iter()
10743                        .flatten()
10744                        .collect::<Vec<_>>(),
10745                })
10746            })
10747            .collect::<Vec<_>>();
10748        let reserved = ensure_ok(
10749            request(
10750                cfg,
10751                "POST",
10752                &format!("/api/hub/brains/{}/v2/uploads", head.brain_id),
10753                Some(&json!({ "blobs": declarations })),
10754                Auth::Required,
10755            )?,
10756            "prepare proposal blob transport",
10757        )?;
10758        let items = reserved
10759            .get("uploads")
10760            .and_then(Value::as_array)
10761            .ok_or_else(|| invalid_feed("proposal upload reservation has no items"))?;
10762        if items.len() != downloaded.len() {
10763            return Err(invalid_feed("proposal upload reservation changed the set"));
10764        }
10765        let mut references = Vec::with_capacity(items.len());
10766        for item in items {
10767            let hash = item
10768                .get("sha256")
10769                .and_then(Value::as_str)
10770                .ok_or_else(|| invalid_feed("proposal upload reservation has no hash"))?;
10771            let bytes = downloaded
10772                .get(hash)
10773                .ok_or_else(|| invalid_feed("proposal upload reservation introduced a blob"))?;
10774            let reservation_id = item
10775                .get("reservation_id")
10776                .and_then(Value::as_str)
10777                .filter(|id| crate::ulid::is_ulid(id))
10778                .ok_or_else(|| invalid_feed("proposal upload reservation has no id"))?;
10779            let expected_coordinates = coordinates_by_hash
10780                .get(hash)
10781                .ok_or_else(|| invalid_feed("proposal upload has no coordinate binding"))?;
10782            let returned_coordinates = item
10783                .get("coordinates")
10784                .and_then(Value::as_array)
10785                .ok_or_else(|| invalid_feed("proposal upload reservation has no coordinates"))?;
10786            if returned_coordinates.len() != expected_coordinates.len()
10787                || returned_coordinates
10788                    .iter()
10789                    .zip(expected_coordinates)
10790                    .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
10791            {
10792                return Err(invalid_feed(
10793                    "proposal upload reservation changed its coordinates",
10794                ));
10795            }
10796            match item.get("status").and_then(Value::as_str) {
10797                Some("upload") => put_presigned(
10798                    cfg,
10799                    item.get("url")
10800                        .and_then(Value::as_str)
10801                        .ok_or_else(|| invalid_feed("proposal upload has no URL"))?,
10802                    item.get("headers").unwrap_or(&Value::Null),
10803                    bytes,
10804                )?,
10805                Some("already_present") => {}
10806                _ => return Err(invalid_feed("proposal upload status is invalid")),
10807            }
10808            references.push(json!({
10809                "sha256": hash,
10810                "bytes": bytes.len(),
10811                "reservation_id": reservation_id,
10812            }));
10813        }
10814        body["blobs"] = Value::Array(references);
10815    }
10816    if body.to_string().len() > MAX_PUSH_BYTES {
10817        return Err(LinkError::PushTooLarge {
10818            detail: "proposal operation metadata exceeds the commit request cap".to_string(),
10819        });
10820    }
10821    let path = format!("/api/hub/brains/{}/v2/commits", head.brain_id);
10822    let mut result = ensure_ok(
10823        request(cfg, "POST", &path, Some(&body), Auth::Required)?,
10824        "exact proposal acceptance",
10825    )?;
10826    let mut candidate_hub_signer = None;
10827    if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
10828        let challenge = result
10829            .get("signing_challenge")
10830            .ok_or_else(|| invalid_feed("proposal acceptance has no signing challenge"))?;
10831        let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
10832            cfg,
10833            &head,
10834            &expected_candidate,
10835            &expected_candidate_assets,
10836            mutation_id,
10837            &body,
10838            challenge,
10839        )?;
10840        body["signing_challenge_id"] = Value::String(challenge_id);
10841        body["signature_base64url"] = Value::String(signature);
10842        candidate_hub_signer = Some(actor_signer);
10843        result = ensure_ok(
10844            request(cfg, "POST", &path, Some(&body), Auth::Required)?,
10845            "signed exact proposal acceptance",
10846        )?;
10847    }
10848    let refreshed = v2_verified_head(cfg, brain)?
10849        .ok_or_else(|| invalid_feed("v2 head disappeared after proposal acceptance"))?;
10850    if candidate_hub_signer
10851        .as_ref()
10852        .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
10853        || refreshed
10854            .pointer
10855            .as_ref()
10856            .map(|pointer| pointer.commit_hash.as_str())
10857            != result.get("commit_hash").and_then(Value::as_str)
10858        || result.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
10859        || result.get("proposal_state").and_then(Value::as_str) != Some("accepted")
10860    {
10861        return Err(LinkError::RemoteAdvancedDuringSync);
10862    }
10863    accept_v2_head(cfg, &refreshed)?;
10864    Ok(result)
10865}
10866
10867// ─────────────────────────────────────────────────────────────────────────────
10868// propose — write without trust: evidence into the owner's inbox
10869// ─────────────────────────────────────────────────────────────────────────────
10870
10871/// Submit `body` to the published site `handle`, addressed to its app page
10872/// `app` (a page that declares the `write-inbox` capability). Deliberately
10873/// unauthenticated — this is the cross-party door; the submission lands as
10874/// *evidence* in the owner's `sources/inbox/`, never as truth, and the
10875/// owner's curator accepts or rejects it. Returns the hub's `{id, path}`
10876/// receipt.
10877pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
10878    require_valid_handle(handle)?;
10879    if body.len() as u64 > MAX_PROPOSE_BYTES {
10880        return Err(LinkError::ProposeTooLarge {
10881            bytes: body.len() as u64,
10882        });
10883    }
10884    let payload = json!({ "app": app, "body": body });
10885    // A ULID-shaped target is a bare brain address (link.md §7.4's
10886    // generalization): the brain inbox door, open on public brains, where a
10887    // configured credential earns a bigger actor-class budget. Anything else
10888    // is a published-site handle: that door is unauthenticated by design.
10889    let (path, auth) = if crate::ulid::is_ulid(handle) {
10890        (format!("/api/hub/brains/{handle}/inbox"), Auth::Optional)
10891    } else {
10892        (format!("/api/hub/sites/{handle}/inbox"), Auth::None)
10893    };
10894    ensure_ok(
10895        request(cfg, "POST", &path, Some(&payload), auth)?,
10896        "propose",
10897    )
10898}
10899
10900// ─────────────────────────────────────────────────────────────────────────────
10901// subscribe — follow feed-head movement
10902// ─────────────────────────────────────────────────────────────────────────────
10903
10904/// One observation of a brain's feed head.
10905#[derive(Debug, serde::Serialize)]
10906pub struct Head {
10907    /// The brain id.
10908    pub brain: String,
10909    /// The hub's durable feed cursor — advances on every accepted write.
10910    pub seq: u64,
10911    /// The hub's `updatedAt` for the brain, when present.
10912    #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
10913    pub updated_at: Option<String>,
10914    /// SHA-256 of the exact signed head entry.
10915    #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
10916    pub feed_hash: Option<String>,
10917    /// Whether the head entry's content hash, identity, and Ed25519 signature
10918    /// were verified locally. Path-scoped grants get head movement only.
10919    pub verified: bool,
10920}
10921
10922struct BoundedVecVisitor<T, const MAX: usize> {
10923    label: &'static str,
10924    marker: std::marker::PhantomData<T>,
10925}
10926
10927impl<'de, T, const MAX: usize> serde::de::Visitor<'de> for BoundedVecVisitor<T, MAX>
10928where
10929    T: Deserialize<'de>,
10930{
10931    type Value = Vec<T>;
10932
10933    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
10934        write!(formatter, "at most {MAX} {}", self.label)
10935    }
10936
10937    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
10938    where
10939        A: serde::de::SeqAccess<'de>,
10940    {
10941        if sequence.size_hint().is_some_and(|size| size > MAX) {
10942            return Err(serde::de::Error::custom(format!(
10943                "{} exceeds the {MAX}-item limit",
10944                self.label
10945            )));
10946        }
10947        let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX));
10948        while let Some(value) = sequence.next_element()? {
10949            if values.len() == MAX {
10950                return Err(serde::de::Error::custom(format!(
10951                    "{} exceeds the {MAX}-item limit",
10952                    self.label
10953                )));
10954            }
10955            values.push(value);
10956        }
10957        Ok(values)
10958    }
10959}
10960
10961fn deserialize_bounded_vec<'de, D, T, const MAX: usize>(
10962    deserializer: D,
10963    label: &'static str,
10964) -> Result<Vec<T>, D::Error>
10965where
10966    D: serde::Deserializer<'de>,
10967    T: Deserialize<'de>,
10968{
10969    deserializer.deserialize_seq(BoundedVecVisitor::<T, MAX> {
10970        label,
10971        marker: std::marker::PhantomData,
10972    })
10973}
10974
10975fn deserialize_feed_files<'de, D>(deserializer: D) -> Result<Vec<FeedFile>, D::Error>
10976where
10977    D: serde::Deserializer<'de>,
10978{
10979    deserialize_bounded_vec::<D, FeedFile, MAX_PUSH_FILES>(deserializer, "feed files")
10980}
10981
10982fn deserialize_removed_paths<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
10983where
10984    D: serde::Deserializer<'de>,
10985{
10986    deserialize_bounded_vec::<D, String, MAX_PUSH_FILES>(deserializer, "removed paths")
10987}
10988
10989fn deserialize_previous_identities<'de, D>(
10990    deserializer: D,
10991) -> Result<Vec<PreviousIdentity>, D::Error>
10992where
10993    D: serde::Deserializer<'de>,
10994{
10995    deserialize_bounded_vec::<D, PreviousIdentity, MAX_IDENTITY_ROTATIONS>(
10996        deserializer,
10997        "previous identities",
10998    )
10999}
11000
11001fn deserialize_rotations<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
11002where
11003    D: serde::Deserializer<'de>,
11004{
11005    deserialize_bounded_vec::<D, String, MAX_IDENTITY_ROTATIONS>(
11006        deserializer,
11007        "rotation statements",
11008    )
11009}
11010
11011fn deserialize_feed_items<'de, D>(deserializer: D) -> Result<Vec<FeedItem>, D::Error>
11012where
11013    D: serde::Deserializer<'de>,
11014{
11015    deserialize_bounded_vec::<D, FeedItem, FEED_PAGE_LIMIT>(deserializer, "feed entries")
11016}
11017
11018#[derive(Debug, Clone, Deserialize, Serialize)]
11019struct FeedFile {
11020    path: String,
11021    sha256: String,
11022    bytes: u64,
11023}
11024
11025#[cfg(test)]
11026#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11027enum V1DisclosureError {
11028    DuplicateFile,
11029    DuplicateRemoved,
11030    PushManifestMismatch,
11031    EditMissingChange,
11032    EditFalseFile,
11033    RemovedMismatch,
11034}
11035
11036/// Validate wire-profile-v1 `files`/`removed` disclosure semantics against
11037/// the exact previous and resulting pack manifests. `edit` accepts the
11038/// historical superset form while still requiring every actual changed path.
11039#[cfg(test)]
11040fn verify_v1_manifest_disclosure(
11041    kind: &str,
11042    previous: &[FeedFile],
11043    resulting: &[FeedFile],
11044    files: &[FeedFile],
11045    removed: &[String],
11046) -> Result<(), V1DisclosureError> {
11047    fn as_map(
11048        files: &[FeedFile],
11049    ) -> Result<std::collections::BTreeMap<&str, (&str, u64)>, V1DisclosureError> {
11050        let mut result = std::collections::BTreeMap::new();
11051        for file in files {
11052            if result
11053                .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
11054                .is_some()
11055            {
11056                return Err(V1DisclosureError::DuplicateFile);
11057            }
11058        }
11059        Ok(result)
11060    }
11061    let previous = as_map(previous)?;
11062    let resulting = as_map(resulting)?;
11063    let disclosed = as_map(files)?;
11064    let removed_set: std::collections::BTreeSet<&str> =
11065        removed.iter().map(String::as_str).collect();
11066    if removed_set.len() != removed.len() {
11067        return Err(V1DisclosureError::DuplicateRemoved);
11068    }
11069    let expected_removed: std::collections::BTreeSet<&str> = previous
11070        .keys()
11071        .copied()
11072        .filter(|path| !resulting.contains_key(path))
11073        .collect();
11074    if removed_set != expected_removed {
11075        return Err(V1DisclosureError::RemovedMismatch);
11076    }
11077    if kind == "push" {
11078        return if disclosed == resulting {
11079            Ok(())
11080        } else {
11081            Err(V1DisclosureError::PushManifestMismatch)
11082        };
11083    }
11084    if kind != "edit" {
11085        return Err(V1DisclosureError::EditFalseFile);
11086    }
11087    if disclosed
11088        .iter()
11089        .any(|(path, value)| resulting.get(path) != Some(value))
11090    {
11091        return Err(V1DisclosureError::EditFalseFile);
11092    }
11093    for (path, value) in &resulting {
11094        if previous.get(path) != Some(value) && !disclosed.contains_key(path) {
11095            return Err(V1DisclosureError::EditMissingChange);
11096        }
11097    }
11098    Ok(())
11099}
11100
11101#[derive(Debug, Clone, Deserialize, Serialize)]
11102struct FeedEntry {
11103    v: u8,
11104    seq: u64,
11105    ts: String,
11106    brain: String,
11107    public_key: String,
11108    kind: String,
11109    op: String,
11110    pack_sha256: String,
11111    #[serde(deserialize_with = "deserialize_feed_files")]
11112    files: Vec<FeedFile>,
11113    #[serde(deserialize_with = "deserialize_removed_paths")]
11114    removed: Vec<String>,
11115    prev_entry_hash: Option<String>,
11116    sig: String,
11117}
11118
11119#[derive(Serialize)]
11120struct UnsignedFeedEntry<'a> {
11121    v: u8,
11122    seq: u64,
11123    ts: &'a str,
11124    brain: &'a str,
11125    public_key: &'a str,
11126    kind: &'a str,
11127    op: &'a str,
11128    pack_sha256: &'a str,
11129    files: &'a [FeedFile],
11130    removed: &'a [String],
11131    prev_entry_hash: &'a Option<String>,
11132}
11133
11134#[derive(Debug, Clone, Deserialize, Serialize)]
11135struct FeedItem {
11136    hash: String,
11137    entry: FeedEntry,
11138}
11139
11140#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
11141struct FeedIdentity {
11142    fingerprint: String,
11143    #[serde(rename = "publicKeySpki")]
11144    public_key_spki: String,
11145    /// Rotation history (link.md §9.1): identities this brain previously
11146    /// signed as. Entries verify against current OR previous — rotation
11147    /// never invalidates history.
11148    #[serde(default, deserialize_with = "deserialize_previous_identities")]
11149    previous: Vec<PreviousIdentity>,
11150    /// Exact normative rotation statements, oldest first. A list of previous
11151    /// public keys without these old-key signatures is not a trust chain.
11152    #[serde(default, deserialize_with = "deserialize_rotations")]
11153    rotations: Vec<String>,
11154}
11155
11156#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
11157struct PreviousIdentity {
11158    fingerprint: String,
11159    #[serde(rename = "publicKeySpki")]
11160    public_key_spki: String,
11161}
11162
11163#[derive(Debug, Deserialize)]
11164struct FeedResponse {
11165    #[serde(rename = "headSeq")]
11166    head_seq: u64,
11167    #[serde(rename = "feedHash")]
11168    feed_hash: Option<String>,
11169    identity: Option<FeedIdentity>,
11170    #[serde(deserialize_with = "deserialize_feed_items")]
11171    entries: Vec<FeedItem>,
11172    #[serde(rename = "scopeLimited")]
11173    scope_limited: bool,
11174}
11175
11176#[derive(Debug, Deserialize, Serialize)]
11177#[serde(deny_unknown_fields)]
11178struct RotationStatement {
11179    v: u8,
11180    op: String,
11181    brain: String,
11182    public_key: String,
11183    new_brain: String,
11184    new_public_key: String,
11185    prior_head_seq: u64,
11186    prior_feed_hash: Option<String>,
11187    ts: String,
11188    sig: String,
11189}
11190
11191#[derive(Debug, Clone, Deserialize, Serialize)]
11192struct TrustState {
11193    v: u8,
11194    origin: String,
11195    /// The exact caller-visible ref whose resolution this checkpoint pins.
11196    /// Slugs/handles are mutable names at the hub; once observed, they may not
11197    /// silently resolve to a different canonical brain.
11198    #[serde(default)]
11199    requested: String,
11200    /// Canonical hub brain id returned for `requested`.
11201    brain: String,
11202    /// Federation home origin when this ref was learned from a registry.
11203    /// Once observed, the registry cannot silently relocate the same handle.
11204    #[serde(default, skip_serializing_if = "Option::is_none")]
11205    home: Option<String>,
11206    anchor: String,
11207    current: String,
11208    #[serde(rename = "headSeq")]
11209    head_seq: u64,
11210    #[serde(rename = "feedHash")]
11211    feed_hash: Option<String>,
11212    /// Exact accepted old-key-signed rotation statements, oldest first. v2
11213    /// checkpoints require this vector to be an immutable prefix of every
11214    /// subsequently served identity chain.
11215    #[serde(default)]
11216    rotations: Vec<String>,
11217    /// Hub pointer-signing identity pinned on first v2 observation. This is
11218    /// separate from the brain key, which signs commits.
11219    #[serde(default, skip_serializing_if = "Option::is_none")]
11220    hub_signer: Option<String>,
11221    /// Explicit transport profile. Older accepted non-empty v2 checkpoints
11222    /// are recognized by their pinned hub signer during one-way migration.
11223    #[serde(default, skip_serializing_if = "Option::is_none")]
11224    protocol_profile: Option<String>,
11225}
11226
11227fn accepted_as_v2(state: &TrustState) -> bool {
11228    state.protocol_profile.as_deref() == Some("link-v2") || state.hub_signer.is_some()
11229}
11230
11231fn has_accepted_v2_ref(cfg: &HubConfig, requested: &str) -> LinkResult<bool> {
11232    let directory = open_trust_dir(cfg)?;
11233    if load_trust_in(cfg, &directory, requested)?.is_some_and(|state| accepted_as_v2(&state)) {
11234        return Ok(true);
11235    }
11236    let Some(alias) = load_alias_in(cfg, &directory, requested)? else {
11237        return Ok(false);
11238    };
11239    Ok(load_trust_in(cfg, &directory, &alias.brain)?.is_some_and(|state| accepted_as_v2(&state)))
11240}
11241
11242#[derive(Debug, Clone, Deserialize, Serialize)]
11243struct AliasBinding {
11244    v: u8,
11245    origin: String,
11246    requested: String,
11247    brain: String,
11248    #[serde(default, skip_serializing_if = "Option::is_none")]
11249    home: Option<String>,
11250}
11251
11252struct VerifiedRemote {
11253    head: Head,
11254    identity: Option<FeedIdentity>,
11255    head_entry: Option<FeedItem>,
11256    /// Present when the caller requested and verified the complete chain.
11257    entries: Vec<FeedItem>,
11258    anchor: Option<String>,
11259}
11260
11261fn invalid_feed(message: impl Into<String>) -> LinkError {
11262    LinkError::InvalidFeed {
11263        message: message.into(),
11264    }
11265}
11266
11267fn is_sha256(value: &str) -> bool {
11268    value.len() == 64
11269        && value
11270            .bytes()
11271            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
11272}
11273
11274fn identity_fingerprint(public_key_spki: &str) -> LinkResult<String> {
11275    let der = URL_SAFE_NO_PAD
11276        .decode(public_key_spki)
11277        .map_err(|_| invalid_feed("identity public key is not base64url"))?;
11278    if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
11279        return Err(invalid_feed(
11280            "identity public key is not a valid Ed25519 SPKI",
11281        ));
11282    }
11283    Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(&der)))
11284}
11285
11286/// Verify the hub's current identity and every old-key-signed rotation that
11287/// leads from the original TOFU anchor to it. `previous` alone is metadata,
11288/// never authority.
11289fn verify_identity_chain(
11290    identity: &FeedIdentity,
11291    pinned: Option<&TrustState>,
11292) -> LinkResult<String> {
11293    if identity.previous.len() > MAX_IDENTITY_ROTATIONS
11294        || identity.rotations.len() > MAX_IDENTITY_ROTATIONS
11295    {
11296        return Err(invalid_feed(
11297            "identity rotation history exceeds the client cap",
11298        ));
11299    }
11300    if identity.fingerprint != identity_fingerprint(&identity.public_key_spki)? {
11301        return Err(invalid_feed(
11302            "current identity fingerprint does not match its public key",
11303        ));
11304    }
11305    for previous in &identity.previous {
11306        if previous.fingerprint != identity_fingerprint(&previous.public_key_spki)? {
11307            return Err(invalid_feed(
11308                "previous identity fingerprint does not match its public key",
11309            ));
11310        }
11311    }
11312    if identity.rotations.len() != identity.previous.len() {
11313        return Err(invalid_feed(
11314            "identity history is missing an old-key-signed rotation statement",
11315        ));
11316    }
11317
11318    // The HTTP identity lists prior identities newest first. Rotation
11319    // statements are chronological, so verification walks the reversed list
11320    // and ends at the current identity.
11321    let mut chain: Vec<(&str, &str)> = identity
11322        .previous
11323        .iter()
11324        .rev()
11325        .map(|p| (p.fingerprint.as_str(), p.public_key_spki.as_str()))
11326        .collect();
11327    chain.push((&identity.fingerprint, &identity.public_key_spki));
11328
11329    for (index, raw) in identity.rotations.iter().enumerate() {
11330        let statement: RotationStatement = serde_json::from_str(raw)
11331            .map_err(|_| invalid_feed("rotation statement did not parse exactly"))?;
11332        let (old_fingerprint, old_spki) = chain[index];
11333        let (new_fingerprint, new_spki) = chain[index + 1];
11334        if statement.v != 1
11335            || statement.op != "rotate"
11336            || statement.brain != format!("ed25519:{old_fingerprint}")
11337            || statement.public_key != old_spki
11338            || statement.new_brain != format!("ed25519:{new_fingerprint}")
11339            || statement.new_public_key != new_spki
11340            || (statement.prior_head_seq == 0 && statement.prior_feed_hash.is_some())
11341            || (statement.prior_head_seq > 0
11342                && statement
11343                    .prior_feed_hash
11344                    .as_deref()
11345                    .is_none_or(|hash| !is_sha256(hash)))
11346        {
11347            return Err(invalid_feed(
11348                "rotation statement does not connect adjacent identities",
11349            ));
11350        }
11351        let unsigned = serde_json::to_string(&UnsignedRotation {
11352            v: statement.v,
11353            op: &statement.op,
11354            brain: &statement.brain,
11355            public_key: &statement.public_key,
11356            new_brain: &statement.new_brain,
11357            new_public_key: &statement.new_public_key,
11358            prior_head_seq: statement.prior_head_seq,
11359            prior_feed_hash: statement.prior_feed_hash.as_deref(),
11360            ts: statement.ts.clone(),
11361        })
11362        .map_err(|_| invalid_feed("could not canonicalize rotation statement"))?;
11363        let exact = format!(
11364            "{},\"sig\":\"{}\"}}",
11365            &unsigned[..unsigned.len() - 1],
11366            statement.sig
11367        );
11368        if exact != *raw {
11369            return Err(invalid_feed(
11370                "rotation statement is not in normative serialization",
11371            ));
11372        }
11373        let der = URL_SAFE_NO_PAD
11374            .decode(old_spki)
11375            .map_err(|_| invalid_feed("rotation public key is not base64url"))?;
11376        let signature = URL_SAFE_NO_PAD
11377            .decode(&statement.sig)
11378            .map_err(|_| invalid_feed("rotation signature is not base64url"))?;
11379        UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
11380            .verify(unsigned.as_bytes(), &signature)
11381            .map_err(|_| invalid_feed("rotation signature verification failed"))?;
11382        if index > 0 {
11383            let prior: RotationStatement = serde_json::from_str(&identity.rotations[index - 1])
11384                .map_err(|_| invalid_feed("prior rotation statement did not parse"))?;
11385            if statement.prior_head_seq < prior.prior_head_seq {
11386                return Err(invalid_feed("rotation feed boundaries move backward"));
11387            }
11388        }
11389    }
11390
11391    let anchor = format!("ed25519:{}", chain[0].0);
11392    let current = format!("ed25519:{}", identity.fingerprint);
11393    if let Some(pin) = pinned {
11394        if pin.anchor != anchor {
11395            return Err(invalid_feed(
11396                "served identity chain does not descend from the pinned anchor",
11397            ));
11398        }
11399        if !chain
11400            .iter()
11401            .any(|(fingerprint, _)| pin.current == format!("ed25519:{fingerprint}"))
11402        {
11403            return Err(invalid_feed(
11404                "served identity chain forked away from the last pinned identity",
11405            ));
11406        }
11407        if pin.current == current && pin.anchor != current && identity.rotations.is_empty() {
11408            return Err(invalid_feed("served identity discarded its rotation chain"));
11409        }
11410        if pin.v >= 2
11411            && (identity.rotations.len() < pin.rotations.len()
11412                || identity.rotations[..pin.rotations.len()] != pin.rotations)
11413        {
11414            return Err(invalid_feed(
11415                "served identity rewrote the locally accepted rotation history",
11416            ));
11417        }
11418    }
11419    Ok(anchor)
11420}
11421
11422fn verify_rotation_feed_boundaries(
11423    identity: &FeedIdentity,
11424    pinned: Option<&TrustState>,
11425    observed: &[FeedItem],
11426    advertised_seq: u64,
11427) -> LinkResult<()> {
11428    let mut chain: Vec<String> = identity
11429        .previous
11430        .iter()
11431        .rev()
11432        .map(|previous| format!("ed25519:{}", previous.fingerprint))
11433        .collect();
11434    chain.push(format!("ed25519:{}", identity.fingerprint));
11435    let pinned_index = pinned.and_then(|pin| chain.iter().position(|key| key == &pin.current));
11436
11437    for (index, raw) in identity.rotations.iter().enumerate() {
11438        let rotation: RotationStatement = serde_json::from_str(raw)
11439            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
11440        if rotation.prior_head_seq > advertised_seq {
11441            return Err(invalid_feed(
11442                "rotation claims a feed boundary beyond the advertised head",
11443            ));
11444        }
11445        if let (Some(pin), Some(pin_index)) = (pinned, pinned_index) {
11446            if index >= pin_index && rotation.prior_head_seq < pin.head_seq {
11447                return Err(invalid_feed(
11448                    "newly disclosed rotation predates the local feed checkpoint",
11449                ));
11450            }
11451        }
11452        let actual = if rotation.prior_head_seq == 0 {
11453            None
11454        } else if pinned.is_some_and(|pin| pin.head_seq == rotation.prior_head_seq) {
11455            pinned.and_then(|pin| pin.feed_hash.as_deref())
11456        } else {
11457            observed
11458                .iter()
11459                .find(|item| item.entry.seq == rotation.prior_head_seq)
11460                .map(|item| item.hash.as_str())
11461        };
11462        if let Some(actual) = actual {
11463            if rotation.prior_feed_hash.as_deref() != Some(actual) {
11464                return Err(invalid_feed(
11465                    "rotation statement does not commit the verified feed boundary",
11466                ));
11467            }
11468        } else if rotation.prior_head_seq == 0 {
11469            // The empty-feed boundary is represented by (0, null); there is
11470            // no entry hash to find in `observed`.
11471        } else if pinned.is_some_and(|pin| {
11472            pinned_index.is_some_and(|pin_index| index >= pin_index)
11473                || rotation.prior_head_seq >= pin.head_seq
11474        }) {
11475            return Err(invalid_feed(
11476                "rotation feed boundary was not present in the verified chain",
11477            ));
11478        }
11479    }
11480    Ok(())
11481}
11482
11483/// Once a client has checkpointed identity K, identities retired before K may
11484/// verify old history but can never regain authority over a later sequence.
11485/// This is also the safe migration rule for legacy v1 checkpoints that did not
11486/// persist the exact accepted rotation statements.
11487fn reject_retired_signer_after_checkpoint(
11488    identity: &FeedIdentity,
11489    pinned: Option<&TrustState>,
11490    item: &FeedItem,
11491) -> LinkResult<()> {
11492    let Some(pin) = pinned else {
11493        return Ok(());
11494    };
11495    if item.entry.seq <= pin.head_seq {
11496        return Ok(());
11497    }
11498    let mut chain: Vec<String> = identity
11499        .previous
11500        .iter()
11501        .rev()
11502        .map(|previous| format!("ed25519:{}", previous.fingerprint))
11503        .collect();
11504    chain.push(format!("ed25519:{}", identity.fingerprint));
11505    let pinned_index = chain
11506        .iter()
11507        .position(|key| key == &pin.current)
11508        .ok_or_else(|| invalid_feed("pinned identity is absent from the served chain"))?;
11509    let signer_index = chain
11510        .iter()
11511        .position(|key| key == &item.entry.brain)
11512        .ok_or_else(|| invalid_feed("feed signer is absent from the served identity chain"))?;
11513    if signer_index < pinned_index {
11514        return Err(invalid_feed(
11515            "a retired identity attempted to sign after the local checkpoint",
11516        ));
11517    }
11518    Ok(())
11519}
11520
11521fn trust_file_name(cfg: &HubConfig, brain: &str) -> LinkResult<String> {
11522    let origin = normalized_origin(&cfg.hub)?;
11523    let key = format!(
11524        "{:x}",
11525        Sha256::digest(format!("{origin}\0{brain}").as_bytes())
11526    );
11527    Ok(format!("{key}.json"))
11528}
11529
11530fn alias_file_name(cfg: &HubConfig, alias: &str) -> LinkResult<String> {
11531    let origin = normalized_origin(&cfg.hub)?;
11532    let key = format!(
11533        "{:x}",
11534        Sha256::digest(format!("{origin}\0alias\0{alias}").as_bytes())
11535    );
11536    Ok(format!("alias-{key}.json"))
11537}
11538
11539#[cfg(any(unix, windows))]
11540struct TrustLock {
11541    _file: std::fs::File,
11542}
11543
11544#[cfg(unix)]
11545fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
11546    use std::os::fd::{AsRawFd as _, FromRawFd as _};
11547
11548    let lock_string = format!(".{state_name}.lock");
11549    let lock_name = c_name(lock_string.as_bytes(), &lock_string)?;
11550    let fd = unsafe {
11551        libc::openat(
11552            directory.as_raw_fd(),
11553            lock_name.as_ptr(),
11554            libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11555            0o600,
11556        )
11557    };
11558    if fd < 0 {
11559        return Err(std::io::Error::last_os_error().into());
11560    }
11561    let file = unsafe { std::fs::File::from_raw_fd(fd) };
11562    if !file.metadata()?.is_file() {
11563        return Err(LinkError::UnsafePath { path: lock_string });
11564    }
11565    if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
11566        return Err(std::io::Error::last_os_error().into());
11567    }
11568    Ok(TrustLock { _file: file })
11569}
11570
11571#[cfg(windows)]
11572fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
11573    let lock_name = format!(".{state_name}.lock");
11574    let file = crate::fsx::lock_exclusive_beneath(directory, Path::new(&lock_name))?;
11575    Ok(TrustLock { _file: file })
11576}
11577
11578#[cfg(any(unix, windows))]
11579fn lock_trust_many(
11580    cfg: &HubConfig,
11581    directory: &std::fs::File,
11582    refs: &[&str],
11583) -> LinkResult<Vec<TrustLock>> {
11584    let mut names = refs
11585        .iter()
11586        .map(|reference| trust_file_name(cfg, reference))
11587        .collect::<LinkResult<Vec<_>>>()?;
11588    names.sort();
11589    names.dedup();
11590    names
11591        .iter()
11592        .map(|name| lock_trust_name(directory, name))
11593        .collect()
11594}
11595
11596#[cfg(not(any(unix, windows)))]
11597fn lock_trust_many(
11598    _cfg: &HubConfig,
11599    _directory: &TrustDirectory,
11600    _refs: &[&str],
11601) -> LinkResult<Vec<()>> {
11602    Err(LinkError::UnsupportedPlatform {
11603        operation: "verified link.md state",
11604    })
11605}
11606
11607#[cfg(any(unix, windows))]
11608type TrustDirectory = std::fs::File;
11609
11610#[cfg(not(any(unix, windows)))]
11611struct TrustDirectory;
11612
11613#[cfg(unix)]
11614fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
11615    use std::os::fd::AsRawFd as _;
11616
11617    let directory = open_or_create_dir_nofollow(&cfg.state_dir.join("trust"))?;
11618    if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
11619        return Err(std::io::Error::last_os_error().into());
11620    }
11621    directory.sync_all()?;
11622    Ok(directory)
11623}
11624
11625#[cfg(windows)]
11626fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
11627    let marker = cfg.state_dir.join("trust").join(".directory");
11628    crate::fsx::write_atomic(&marker, b"link.md trust directory\n")?;
11629    Ok(crate::fsx::open_directory_nofollow(
11630        marker.parent().expect("trust marker has a parent"),
11631    )?)
11632}
11633
11634#[cfg(not(any(unix, windows)))]
11635fn open_trust_dir(_cfg: &HubConfig) -> LinkResult<TrustDirectory> {
11636    Err(LinkError::UnsupportedPlatform {
11637        operation: "verified link.md state",
11638    })
11639}
11640
11641#[cfg(unix)]
11642fn load_trust_in(
11643    cfg: &HubConfig,
11644    directory: &TrustDirectory,
11645    requested: &str,
11646) -> LinkResult<Option<TrustState>> {
11647    use std::os::fd::{AsRawFd as _, FromRawFd as _};
11648
11649    let name_string = trust_file_name(cfg, requested)?;
11650    let name = c_name(name_string.as_bytes(), &name_string)?;
11651    let fd = unsafe {
11652        libc::openat(
11653            directory.as_raw_fd(),
11654            name.as_ptr(),
11655            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11656        )
11657    };
11658    if fd < 0 {
11659        let error = std::io::Error::last_os_error();
11660        if error.kind() == std::io::ErrorKind::NotFound {
11661            return Ok(None);
11662        }
11663        return Err(LinkError::UnsafePath { path: name_string });
11664    }
11665    let file = unsafe { std::fs::File::from_raw_fd(fd) };
11666    if !file.metadata()?.is_file() {
11667        return Err(LinkError::UnsafePath { path: name_string });
11668    }
11669    let mut bytes = Vec::new();
11670    file.take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
11671    if bytes.len() > 1024 * 1024 {
11672        return Err(invalid_feed("local identity/feed checkpoint is oversized"));
11673    }
11674    let mut state: TrustState = serde_json::from_slice(&bytes)
11675        .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
11676    if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
11677        return Err(invalid_feed(
11678            "local identity/feed checkpoint does not match this hub and brain",
11679        ));
11680    }
11681    if state.v == 1 {
11682        // Legacy files were keyed by canonical brain id. They can migrate only
11683        // when the caller used that exact id; old slug lookups had no durable
11684        // alias binding and therefore cannot be guessed safely.
11685        if state.brain != requested {
11686            return Err(invalid_feed(
11687                "legacy checkpoint is not bound to the requested brain id",
11688            ));
11689        }
11690        state.requested = requested.to_string();
11691    } else if state.requested != requested {
11692        return Err(invalid_feed(
11693            "local identity/feed checkpoint is bound to a different requested ref",
11694        ));
11695    }
11696    Ok(Some(state))
11697}
11698
11699#[cfg(windows)]
11700fn load_trust_in(
11701    cfg: &HubConfig,
11702    directory: &TrustDirectory,
11703    requested: &str,
11704) -> LinkResult<Option<TrustState>> {
11705    let name = trust_file_name(cfg, requested)?;
11706    let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
11707    let bytes = match reader.read(Path::new(&name), 1024 * 1024) {
11708        Ok(bytes) => bytes,
11709        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
11710        Err(_) => return Err(LinkError::UnsafePath { path: name }),
11711    };
11712    let mut state: TrustState = serde_json::from_slice(&bytes)
11713        .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
11714    if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
11715        return Err(invalid_feed(
11716            "local identity/feed checkpoint does not match this hub and brain",
11717        ));
11718    }
11719    if state.v == 1 {
11720        if state.brain != requested {
11721            return Err(invalid_feed(
11722                "legacy checkpoint is not bound to the requested brain id",
11723            ));
11724        }
11725        state.requested = requested.to_string();
11726    } else if state.requested != requested {
11727        return Err(invalid_feed(
11728            "local identity/feed checkpoint is bound to a different requested ref",
11729        ));
11730    }
11731    Ok(Some(state))
11732}
11733
11734#[cfg(not(any(unix, windows)))]
11735fn load_trust_in(
11736    _cfg: &HubConfig,
11737    _directory: &TrustDirectory,
11738    _brain: &str,
11739) -> LinkResult<Option<TrustState>> {
11740    Err(LinkError::UnsupportedPlatform {
11741        operation: "verified link.md state",
11742    })
11743}
11744
11745#[cfg(all(test, any(unix, windows)))]
11746fn load_trust(cfg: &HubConfig, requested: &str) -> LinkResult<Option<TrustState>> {
11747    let directory = open_trust_dir(cfg)?;
11748    load_trust_in(cfg, &directory, requested)
11749}
11750
11751#[cfg(unix)]
11752fn save_trust_in(
11753    cfg: &HubConfig,
11754    directory: &TrustDirectory,
11755    state: &TrustState,
11756) -> LinkResult<()> {
11757    use std::os::fd::{AsRawFd as _, FromRawFd as _};
11758
11759    let name_string = trust_file_name(cfg, &state.requested)?;
11760    let name = c_name(name_string.as_bytes(), &name_string)?;
11761    let mut bytes = serde_json::to_vec(state)
11762        .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
11763    bytes.push(b'\n');
11764
11765    let nonce = std::time::SystemTime::now()
11766        .duration_since(std::time::UNIX_EPOCH)
11767        .unwrap_or_default()
11768        .as_nanos();
11769    let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
11770    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
11771    let fd = unsafe {
11772        libc::openat(
11773            directory.as_raw_fd(),
11774            temp.as_ptr(),
11775            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11776            0o600,
11777        )
11778    };
11779    if fd < 0 {
11780        return Err(std::io::Error::last_os_error().into());
11781    }
11782    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
11783    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
11784        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
11785        return Err(error.into());
11786    }
11787    drop(file);
11788    if unsafe {
11789        libc::renameat(
11790            directory.as_raw_fd(),
11791            temp.as_ptr(),
11792            directory.as_raw_fd(),
11793            name.as_ptr(),
11794        )
11795    } != 0
11796    {
11797        let error = std::io::Error::last_os_error();
11798        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
11799        return Err(error.into());
11800    }
11801    directory.sync_all()?;
11802    Ok(())
11803}
11804
11805#[cfg(windows)]
11806fn save_trust_in(
11807    cfg: &HubConfig,
11808    directory: &TrustDirectory,
11809    state: &TrustState,
11810) -> LinkResult<()> {
11811    let name = trust_file_name(cfg, &state.requested)?;
11812    let mut bytes = serde_json::to_vec(state)
11813        .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
11814    bytes.push(b'\n');
11815    crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
11816    Ok(())
11817}
11818
11819#[cfg(not(any(unix, windows)))]
11820fn save_trust_in(
11821    _cfg: &HubConfig,
11822    _directory: &TrustDirectory,
11823    _state: &TrustState,
11824) -> LinkResult<()> {
11825    Err(LinkError::UnsupportedPlatform {
11826        operation: "verified link.md state",
11827    })
11828}
11829
11830#[cfg(unix)]
11831fn load_alias_in(
11832    cfg: &HubConfig,
11833    directory: &TrustDirectory,
11834    requested: &str,
11835) -> LinkResult<Option<AliasBinding>> {
11836    use std::os::fd::{AsRawFd as _, FromRawFd as _};
11837
11838    let name_string = alias_file_name(cfg, requested)?;
11839    let name = c_name(name_string.as_bytes(), &name_string)?;
11840    let fd = unsafe {
11841        libc::openat(
11842            directory.as_raw_fd(),
11843            name.as_ptr(),
11844            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11845        )
11846    };
11847    if fd < 0 {
11848        let error = std::io::Error::last_os_error();
11849        if error.kind() == std::io::ErrorKind::NotFound {
11850            return Ok(None);
11851        }
11852        return Err(LinkError::UnsafePath { path: name_string });
11853    }
11854    let file = unsafe { std::fs::File::from_raw_fd(fd) };
11855    if !file.metadata()?.is_file() {
11856        return Err(LinkError::UnsafePath { path: name_string });
11857    }
11858    let mut bytes = Vec::new();
11859    file.take(64 * 1024 + 1).read_to_end(&mut bytes)?;
11860    if bytes.len() > 64 * 1024 {
11861        return Err(invalid_feed("local alias binding is oversized"));
11862    }
11863    let alias: AliasBinding = serde_json::from_slice(&bytes)
11864        .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
11865    if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
11866    {
11867        return Err(invalid_feed(
11868            "local alias binding does not match this hub and requested ref",
11869        ));
11870    }
11871    Ok(Some(alias))
11872}
11873
11874#[cfg(windows)]
11875fn load_alias_in(
11876    cfg: &HubConfig,
11877    directory: &TrustDirectory,
11878    requested: &str,
11879) -> LinkResult<Option<AliasBinding>> {
11880    let name = alias_file_name(cfg, requested)?;
11881    let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
11882    let bytes = match reader.read(Path::new(&name), 64 * 1024) {
11883        Ok(bytes) => bytes,
11884        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
11885        Err(_) => return Err(LinkError::UnsafePath { path: name }),
11886    };
11887    let alias: AliasBinding = serde_json::from_slice(&bytes)
11888        .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
11889    if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
11890    {
11891        return Err(invalid_feed(
11892            "local alias binding does not match this hub and requested ref",
11893        ));
11894    }
11895    Ok(Some(alias))
11896}
11897
11898#[cfg(not(any(unix, windows)))]
11899fn load_alias_in(
11900    _cfg: &HubConfig,
11901    _directory: &TrustDirectory,
11902    _requested: &str,
11903) -> LinkResult<Option<AliasBinding>> {
11904    Err(LinkError::UnsupportedPlatform {
11905        operation: "verified link.md state",
11906    })
11907}
11908
11909#[cfg(unix)]
11910fn save_alias_in(
11911    cfg: &HubConfig,
11912    directory: &TrustDirectory,
11913    alias: &AliasBinding,
11914) -> LinkResult<()> {
11915    use std::os::fd::{AsRawFd as _, FromRawFd as _};
11916
11917    let name_string = alias_file_name(cfg, &alias.requested)?;
11918    let name = c_name(name_string.as_bytes(), &name_string)?;
11919    let mut bytes = serde_json::to_vec(alias)
11920        .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
11921    bytes.push(b'\n');
11922    let nonce = std::time::SystemTime::now()
11923        .duration_since(std::time::UNIX_EPOCH)
11924        .unwrap_or_default()
11925        .as_nanos();
11926    let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
11927    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
11928    let fd = unsafe {
11929        libc::openat(
11930            directory.as_raw_fd(),
11931            temp.as_ptr(),
11932            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11933            0o600,
11934        )
11935    };
11936    if fd < 0 {
11937        return Err(std::io::Error::last_os_error().into());
11938    }
11939    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
11940    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
11941        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
11942        return Err(error.into());
11943    }
11944    drop(file);
11945    if unsafe {
11946        libc::renameat(
11947            directory.as_raw_fd(),
11948            temp.as_ptr(),
11949            directory.as_raw_fd(),
11950            name.as_ptr(),
11951        )
11952    } != 0
11953    {
11954        let error = std::io::Error::last_os_error();
11955        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
11956        return Err(error.into());
11957    }
11958    directory.sync_all()?;
11959    Ok(())
11960}
11961
11962#[cfg(windows)]
11963fn save_alias_in(
11964    cfg: &HubConfig,
11965    directory: &TrustDirectory,
11966    alias: &AliasBinding,
11967) -> LinkResult<()> {
11968    let name = alias_file_name(cfg, &alias.requested)?;
11969    let mut bytes = serde_json::to_vec(alias)
11970        .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
11971    bytes.push(b'\n');
11972    crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
11973    Ok(())
11974}
11975
11976#[cfg(not(any(unix, windows)))]
11977fn save_alias_in(
11978    _cfg: &HubConfig,
11979    _directory: &TrustDirectory,
11980    _alias: &AliasBinding,
11981) -> LinkResult<()> {
11982    Err(LinkError::UnsupportedPlatform {
11983        operation: "verified link.md state",
11984    })
11985}
11986
11987/// Load the canonical checkpoint shared by every spelling of one brain and
11988/// the separate alias binding. This also performs the one-way migration from
11989/// pre-v3 checkpoints that stored a full trust state under the alias itself.
11990/// Callers hold both alias and canonical locks before entering.
11991fn load_canonical_pin(
11992    cfg: &HubConfig,
11993    directory: &TrustDirectory,
11994    requested: &str,
11995    resolved_brain: &str,
11996) -> LinkResult<(Option<TrustState>, Option<AliasBinding>)> {
11997    let mut canonical = load_trust_in(cfg, directory, resolved_brain)?;
11998    if requested == resolved_brain {
11999        return Ok((canonical, None));
12000    }
12001
12002    let mut alias = load_alias_in(cfg, directory, requested)?;
12003    if let Some(binding) = &alias {
12004        if binding.brain != resolved_brain {
12005            return Err(LinkError::AliasRebindRequired {
12006                alias: requested.to_string(),
12007                from: binding.brain.clone(),
12008                to: resolved_brain.to_string(),
12009            });
12010        }
12011        return Ok((canonical, alias));
12012    }
12013
12014    // A v2 build stored the complete checkpoint under the requested slug.
12015    // Promote it to the canonical ULID key before creating the lightweight
12016    // alias binding. Never silently merge two independently advanced states.
12017    if let Some(legacy) = load_trust_in(cfg, directory, requested)? {
12018        if legacy.brain != resolved_brain {
12019            return Err(invalid_feed(
12020                "legacy alias checkpoint names a different canonical brain",
12021            ));
12022        }
12023        if let Some(existing) = &canonical {
12024            if existing.brain != legacy.brain
12025                || existing.anchor != legacy.anchor
12026                || existing.current != legacy.current
12027                || existing.head_seq != legacy.head_seq
12028                || existing.feed_hash != legacy.feed_hash
12029                || existing.rotations != legacy.rotations
12030            {
12031                return Err(invalid_feed(
12032                    "legacy alias checkpoint conflicts with the canonical checkpoint",
12033                ));
12034            }
12035        } else {
12036            let mut promoted = legacy.clone();
12037            promoted.requested = resolved_brain.to_string();
12038            promoted.home = None;
12039            save_trust_in(cfg, directory, &promoted)?;
12040            canonical = Some(promoted);
12041        }
12042        alias = Some(AliasBinding {
12043            v: 1,
12044            origin: normalized_origin(&cfg.hub)?,
12045            requested: requested.to_string(),
12046            brain: resolved_brain.to_string(),
12047            home: legacy.home,
12048        });
12049        save_alias_in(cfg, directory, alias.as_ref().expect("alias just created"))?;
12050    }
12051    Ok((canonical, alias))
12052}
12053
12054/// Explicitly replace one mutable alias binding after the operator reviews the
12055/// exact deleted/re-created brain ids. Canonical checkpoints are never removed
12056/// or rewritten, so old history remains pinned and the new brain establishes
12057/// its own independently verified trust chain.
12058pub fn rebind_v2_alias(cfg: &HubConfig, alias: &str, from: &str, to: &str) -> LinkResult<Value> {
12059    require_hardened_filesystem("verified alias rebind")?;
12060    require_safe_ref(alias)?;
12061    require_safe_ref(from)?;
12062    require_safe_ref(to)?;
12063    if crate::ulid::is_ulid(alias)
12064        || !crate::ulid::is_ulid(from)
12065        || !crate::ulid::is_ulid(to)
12066        || from == to
12067    {
12068        return Err(LinkError::InvalidPack {
12069            message:
12070                "alias rebind requires one non-ULID alias and two different exact canonical ULIDs"
12071                    .to_string(),
12072        });
12073    }
12074
12075    let verified = v2_verified_head(cfg, to)?.ok_or_else(|| LinkError::InvalidPack {
12076        message: "the proposed replacement is not a readable link.md v2 brain".to_string(),
12077    })?;
12078    accept_v2_head(cfg, &verified)?;
12079
12080    let alias_response = ensure_ok(
12081        request(
12082            cfg,
12083            "GET",
12084            &format!("/api/hub/brains/{alias}/v2/head"),
12085            None,
12086            Auth::Required,
12087        )?,
12088        "resolve alias for explicit rebind",
12089    )?;
12090    let resolved: V2HeadResponse = serde_json::from_value(alias_response)
12091        .map_err(|_| invalid_feed("alias rebind response has an invalid shape"))?;
12092    if resolved.v != 2 || resolved.brain_id != to {
12093        return Err(LinkError::RemoteAdvancedDuringSync);
12094    }
12095
12096    let directory = open_trust_dir(cfg)?;
12097    let _locks = lock_trust_many(cfg, &directory, &[alias, from, to])?;
12098    let binding = load_alias_in(cfg, &directory, alias)?.ok_or_else(|| LinkError::InvalidPack {
12099        message: "the requested alias has no existing local binding to replace".to_string(),
12100    })?;
12101    if binding.brain != from {
12102        return Err(LinkError::AliasRebindRequired {
12103            alias: alias.to_string(),
12104            from: binding.brain,
12105            to: to.to_string(),
12106        });
12107    }
12108    save_alias_in(
12109        cfg,
12110        &directory,
12111        &AliasBinding {
12112            v: 1,
12113            origin: normalized_origin(&cfg.hub)?,
12114            requested: alias.to_string(),
12115            brain: to.to_string(),
12116            home: binding.home,
12117        },
12118    )?;
12119    Ok(json!({
12120        "v": 2,
12121        "alias": alias,
12122        "from": from,
12123        "to": to,
12124        "outcome": "alias_rebound",
12125    }))
12126}
12127
12128fn save_canonical_pin_and_alias(
12129    cfg: &HubConfig,
12130    directory: &TrustDirectory,
12131    requested: &str,
12132    resolved_brain: &str,
12133    mut state: TrustState,
12134    existing_alias: Option<&AliasBinding>,
12135) -> LinkResult<()> {
12136    state.requested = resolved_brain.to_string();
12137    state.brain = resolved_brain.to_string();
12138    state.home = None;
12139    save_trust_in(cfg, directory, &state)?;
12140    if requested != resolved_brain {
12141        save_alias_in(
12142            cfg,
12143            directory,
12144            &AliasBinding {
12145                v: 1,
12146                origin: normalized_origin(&cfg.hub)?,
12147                requested: requested.to_string(),
12148                brain: resolved_brain.to_string(),
12149                home: existing_alias.and_then(|alias| alias.home.clone()),
12150            },
12151        )?;
12152    }
12153    Ok(())
12154}
12155
12156fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
12157    const ED25519_SPKI_PREFIX: &[u8] = &[
12158        0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
12159    ];
12160    let entry = &item.entry;
12161    let public_der = URL_SAFE_NO_PAD
12162        .decode(&entry.public_key)
12163        .map_err(|_| invalid_feed("public key is not base64url"))?;
12164    if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
12165        || !public_der.starts_with(ED25519_SPKI_PREFIX)
12166    {
12167        return Err(invalid_feed("entry public key is not a valid Ed25519 SPKI"));
12168    }
12169    let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
12170    if entry.brain != format!("ed25519:{fingerprint}") {
12171        return Err(invalid_feed(
12172            "brain fingerprint does not match its public key",
12173        ));
12174    }
12175    // Verify the complete rotation authority before using any previous key.
12176    let _ = verify_identity_chain(identity, None)?;
12177    let mut chain: Vec<(&str, &str)> = identity
12178        .previous
12179        .iter()
12180        .rev()
12181        .map(|previous| {
12182            (
12183                previous.fingerprint.as_str(),
12184                previous.public_key_spki.as_str(),
12185            )
12186        })
12187        .collect();
12188    chain.push((&identity.fingerprint, &identity.public_key_spki));
12189    let signer_index = chain.iter().position(|(known_fingerprint, spki)| {
12190        *known_fingerprint == fingerprint && *spki == entry.public_key
12191    });
12192    let Some(signer_index) = signer_index else {
12193        return Err(invalid_feed(
12194            "entry signer is not this brain's identity (current or rotated-from)",
12195        ));
12196    };
12197    let lower_boundary = if signer_index == 0 {
12198        None
12199    } else {
12200        let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
12201            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
12202        Some(prior.prior_head_seq)
12203    };
12204    let upper_boundary = if signer_index == identity.rotations.len() {
12205        None
12206    } else {
12207        let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
12208            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
12209        Some(next.prior_head_seq)
12210    };
12211    if lower_boundary.is_some_and(|boundary| entry.seq <= boundary)
12212        || upper_boundary.is_some_and(|boundary| entry.seq > boundary)
12213    {
12214        return Err(invalid_feed(
12215            "entry signer is outside its authenticated rotation epoch",
12216        ));
12217    }
12218    let unsigned = UnsignedFeedEntry {
12219        v: entry.v,
12220        seq: entry.seq,
12221        ts: &entry.ts,
12222        brain: &entry.brain,
12223        public_key: &entry.public_key,
12224        kind: &entry.kind,
12225        op: &entry.op,
12226        pack_sha256: &entry.pack_sha256,
12227        files: &entry.files,
12228        removed: &entry.removed,
12229        prev_entry_hash: &entry.prev_entry_hash,
12230    };
12231    let message =
12232        serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
12233    let signature = URL_SAFE_NO_PAD
12234        .decode(&entry.sig)
12235        .map_err(|_| invalid_feed("signature is not base64url"))?;
12236    UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
12237        .verify(&message, &signature)
12238        .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
12239
12240    let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
12241    exact.push(b'\n');
12242    let actual_hash = format!("{:x}", Sha256::digest(&exact));
12243    if actual_hash != item.hash {
12244        return Err(invalid_feed("entry SHA-256 does not match"));
12245    }
12246    Ok(())
12247}
12248
12249// ─────────────────────────────────────────────────────────────────────────────
12250// key rotation — link.md §9.1: the new key, signed by the old one
12251// ─────────────────────────────────────────────────────────────────────────────
12252
12253/// The unsigned rotation statement in its normative field order.
12254#[derive(Serialize)]
12255struct UnsignedRotation<'a> {
12256    v: u8,
12257    op: &'a str,
12258    brain: &'a str,
12259    public_key: &'a str,
12260    new_brain: &'a str,
12261    new_public_key: &'a str,
12262    prior_head_seq: u64,
12263    prior_feed_hash: Option<&'a str>,
12264    ts: String,
12265}
12266
12267/// Durable intent for an in-flight key rotation. The hub's recovery contract
12268/// identifies an ambiguous retry by the exact statement bytes, so the
12269/// statement cannot be reconstructed from the key and feed boundary later:
12270/// its timestamp and signature would differ.
12271#[derive(Debug, Deserialize, Serialize)]
12272#[serde(deny_unknown_fields)]
12273struct RotationJournal {
12274    v: u8,
12275    origin: String,
12276    brain: String,
12277    old_brain: String,
12278    new_brain: String,
12279    prior_head_seq: u64,
12280    prior_feed_hash: Option<String>,
12281    statement: String,
12282}
12283
12284fn rotation_journal_path(key_path: &Path) -> PathBuf {
12285    let mut path = key_path.as_os_str().to_os_string();
12286    path.push(".rotation.json");
12287    PathBuf::from(path)
12288}
12289
12290fn read_rotation_journal(path: &Path) -> LinkResult<RotationJournal> {
12291    #[cfg(unix)]
12292    let file = {
12293        use std::os::fd::{AsRawFd as _, FromRawFd as _};
12294        use std::os::unix::ffi::OsStrExt as _;
12295        let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
12296            .map_err(|error| {
12297                bad_agent_key(&format!("cannot open the rotation journal parent: {error}"))
12298            })?;
12299        let leaf_name = path
12300            .file_name()
12301            .ok_or_else(|| bad_agent_key("the rotation journal path has no file name"))?;
12302        let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
12303        let fd = unsafe {
12304            libc::openat(
12305                parent.as_raw_fd(),
12306                leaf.as_ptr(),
12307                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
12308            )
12309        };
12310        if fd < 0 {
12311            return Err(bad_agent_key(
12312                "the rotation journal must be an existing regular file without symlink ancestors",
12313            ));
12314        }
12315        unsafe { std::fs::File::from_raw_fd(fd) }
12316    };
12317    #[cfg(not(unix))]
12318    let file = std::fs::File::open(path)
12319        .map_err(|error| bad_agent_key(&format!("cannot read the rotation journal: {error}")))?;
12320    let metadata = file
12321        .metadata()
12322        .map_err(|error| bad_agent_key(&format!("cannot inspect the rotation journal: {error}")))?;
12323    if !metadata.is_file() || metadata.len() > MAX_REGISTRY_CARD_BYTES {
12324        return Err(bad_agent_key(
12325            "the rotation journal must be a bounded regular file",
12326        ));
12327    }
12328    #[cfg(unix)]
12329    {
12330        use std::os::unix::fs::PermissionsExt as _;
12331        if metadata.permissions().mode() & 0o077 != 0 {
12332            return Err(bad_agent_key(
12333                "the rotation journal is accessible to group/other; set mode 0600",
12334            ));
12335        }
12336    }
12337    serde_json::from_reader(file)
12338        .map_err(|_| bad_agent_key("the rotation journal is not valid exact JSON"))
12339}
12340
12341fn remove_rotation_journal(path: &Path) {
12342    #[cfg(unix)]
12343    {
12344        use std::os::fd::AsRawFd as _;
12345        use std::os::unix::ffi::OsStrExt as _;
12346        let Ok(parent) =
12347            open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
12348        else {
12349            return;
12350        };
12351        let Some(leaf_name) = path.file_name() else {
12352            return;
12353        };
12354        let Ok(leaf) = c_name(leaf_name.as_bytes(), &path.display().to_string()) else {
12355            return;
12356        };
12357        if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), 0) } == 0 {
12358            let _ = parent.sync_all();
12359        }
12360    }
12361    #[cfg(not(unix))]
12362    {
12363        let _ = std::fs::remove_file(path);
12364    }
12365}
12366
12367fn validate_rotation_journal(
12368    journal: &RotationJournal,
12369    cfg: &HubConfig,
12370    canonical_brain: &str,
12371    old_key: &AgentSigningKey,
12372    new_key: &AgentSigningKey,
12373    head: &Head,
12374) -> LinkResult<()> {
12375    if journal.v != 1
12376        || journal.origin != normalized_origin(&cfg.hub)?
12377        || journal.brain != canonical_brain
12378        || journal.old_brain != old_key.multikey
12379        || journal.new_brain != new_key.multikey
12380        || journal.prior_head_seq != head.seq
12381        || journal.prior_feed_hash != head.feed_hash
12382    {
12383        return Err(invalid_feed(
12384            "rotation journal does not match the verified key and feed boundary",
12385        ));
12386    }
12387    let statement: RotationStatement = serde_json::from_str(&journal.statement)
12388        .map_err(|_| invalid_feed("rotation journal statement did not parse exactly"))?;
12389    if statement.prior_head_seq != journal.prior_head_seq
12390        || statement.prior_feed_hash != journal.prior_feed_hash
12391        || statement.brain != old_key.multikey
12392        || statement.public_key != old_key.public_key_spki
12393        || statement.new_brain != new_key.multikey
12394        || statement.new_public_key != new_key.public_key_spki
12395    {
12396        return Err(invalid_feed(
12397            "rotation journal statement does not match its durable intent",
12398        ));
12399    }
12400    let identity = FeedIdentity {
12401        fingerprint: new_key.multikey.trim_start_matches("ed25519:").to_string(),
12402        public_key_spki: new_key.public_key_spki.clone(),
12403        previous: vec![PreviousIdentity {
12404            fingerprint: old_key.multikey.trim_start_matches("ed25519:").to_string(),
12405            public_key_spki: old_key.public_key_spki.clone(),
12406        }],
12407        rotations: vec![journal.statement.clone()],
12408    };
12409    verify_identity_chain(&identity, None)?;
12410    Ok(())
12411}
12412
12413/// What `dbmd key rotate` returns.
12414#[derive(Debug, Serialize)]
12415pub struct RotationReport {
12416    /// The brain id rotated.
12417    pub brain: String,
12418    /// The NEW identity the hub now serves.
12419    pub multikey: String,
12420    /// Where the new PKCS#8 secret landed (0600).
12421    #[serde(rename = "keyFile")]
12422    pub key_file: String,
12423    /// Prior identities (newest first) the feed still verifies against.
12424    pub previous: Vec<String>,
12425}
12426
12427/// Rotate a self-custodied brain's key: mint a fresh keypair, build the
12428/// §9.1 statement — the new key plus exact prior feed boundary, signed by the
12429/// OLD key in normative serialization — and send it to the hub. The new secret
12430/// is durably created at 0600 before the POST; an existing output is reused for
12431/// idempotent retry/reconciliation. The old key is left untouched.
12432pub fn rotate_brain_key(
12433    cfg: &HubConfig,
12434    brain: &str,
12435    old_key: &AgentSigningKey,
12436    out: &Path,
12437) -> LinkResult<RotationReport> {
12438    require_hardened_filesystem("key rotation")?;
12439    require_safe_ref(brain)?;
12440    // The new private key must be durable *before* the hub can accept its
12441    // public half. An existing file is the retry/reconciliation path after an
12442    // ambiguous network failure: reuse it, never generate another identity.
12443    let new_key = if out.exists() {
12444        load_signing_key(out)?
12445    } else {
12446        let rng = ring::rand::SystemRandom::new();
12447        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
12448            .map_err(|_| bad_agent_key("key generation failed"))?;
12449        let pair = agent_keypair(pkcs8.as_ref())?;
12450        let (public_key_spki, multikey) = public_identity_for(&pair);
12451        write_secret_new(
12452            out,
12453            format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
12454        )?;
12455        AgentSigningKey {
12456            pkcs8: pkcs8.as_ref().to_vec(),
12457            multikey,
12458            public_key_spki,
12459        }
12460    };
12461    let new_spki = new_key.public_key_spki.clone();
12462    let new_multikey = new_key.multikey.clone();
12463    let journal_path = rotation_journal_path(out);
12464    let before = verified_remote_head(cfg, brain, false)?;
12465    let served_identity = before
12466        .identity
12467        .as_ref()
12468        .ok_or_else(|| invalid_feed("cannot rotate a brain with no signed identity"))?;
12469    let served_multikey = format!("ed25519:{}", served_identity.fingerprint);
12470    if served_multikey == new_multikey {
12471        remove_rotation_journal(&journal_path);
12472        return Ok(RotationReport {
12473            brain: brain.to_string(),
12474            multikey: new_multikey,
12475            key_file: out.display().to_string(),
12476            previous: served_identity
12477                .previous
12478                .iter()
12479                .map(|identity| format!("ed25519:{}", identity.fingerprint))
12480                .collect(),
12481        });
12482    }
12483    if served_multikey != old_key.multikey {
12484        return Err(invalid_feed(
12485            "the supplied old key is not the brain's verified current identity",
12486        ));
12487    }
12488
12489    let journal = if journal_path.exists() {
12490        read_rotation_journal(&journal_path)?
12491    } else {
12492        let ts = crate::now()
12493            .with_timezone(&chrono::Utc)
12494            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
12495            .to_string();
12496        let unsigned = serde_json::to_string(&UnsignedRotation {
12497            v: 1,
12498            op: "rotate",
12499            brain: &old_key.multikey,
12500            public_key: &old_key.public_key_spki,
12501            new_brain: &new_multikey,
12502            new_public_key: &new_spki,
12503            prior_head_seq: before.head.seq,
12504            prior_feed_hash: before.head.feed_hash.as_deref(),
12505            ts,
12506        })
12507        .expect("serialize rotation");
12508        let old_pair = agent_keypair(&old_key.pkcs8)?;
12509        let sig = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
12510        let statement = format!("{},\"sig\":\"{}\"}}", &unsigned[..unsigned.len() - 1], sig);
12511        let journal = RotationJournal {
12512            v: 1,
12513            origin: normalized_origin(&cfg.hub)?,
12514            brain: before.head.brain.clone(),
12515            old_brain: old_key.multikey.clone(),
12516            new_brain: new_multikey.clone(),
12517            prior_head_seq: before.head.seq,
12518            prior_feed_hash: before.head.feed_hash.clone(),
12519            statement,
12520        };
12521        let mut exact = serde_json::to_vec(&journal)
12522            .map_err(|_| invalid_feed("could not serialize rotation journal"))?;
12523        exact.push(b'\n');
12524        if write_secret_new(&journal_path, &exact).is_err() {
12525            // A concurrent retry may have won the O_EXCL race. Only an exact,
12526            // fully validated journal is allowed to recover that race.
12527            read_rotation_journal(&journal_path)?
12528        } else {
12529            journal
12530        }
12531    };
12532    validate_rotation_journal(
12533        &journal,
12534        cfg,
12535        &before.head.brain,
12536        old_key,
12537        &new_key,
12538        &before.head,
12539    )?;
12540
12541    let body = json!({ "statement": journal.statement });
12542    let path = format!("/api/hub/brains/{brain}/rotate");
12543    let attempted = request(cfg, "POST", &path, Some(&body), Auth::Required);
12544    let attempted_failure = match attempted {
12545        Ok(response) if (200..300).contains(&response.status) => None,
12546        Ok(response) => Some(ensure_ok(response, "key rotate").unwrap_err()),
12547        Err(error) => Some(error),
12548    };
12549
12550    // A 2xx body is not authority, and a failed response may have followed a
12551    // committed mutation. In both cases success means the normal verifier sees
12552    // an append-only rotation chain ending at the durable new key.
12553    let after = match verified_remote_head(cfg, brain, false) {
12554        Ok(after) => after,
12555        Err(error) => return Err(attempted_failure.unwrap_or(error)),
12556    };
12557    let identity = after
12558        .identity
12559        .as_ref()
12560        .ok_or_else(|| invalid_feed("rotated brain has no verified identity"))?;
12561    if format!("ed25519:{}", identity.fingerprint) != new_multikey
12562        || identity.public_key_spki != new_spki
12563    {
12564        return Err(attempted_failure.unwrap_or_else(|| {
12565            invalid_feed("hub acknowledged rotation without committing the verified new identity")
12566        }));
12567    }
12568    let previous = identity
12569        .previous
12570        .iter()
12571        .map(|prior| format!("ed25519:{}", prior.fingerprint))
12572        .collect();
12573    remove_rotation_journal(&journal_path);
12574
12575    Ok(RotationReport {
12576        brain: brain.to_string(),
12577        multikey: new_multikey,
12578        key_file: out.display().to_string(),
12579        previous,
12580    })
12581}
12582
12583// ─────────────────────────────────────────────────────────────────────────────
12584// mirror — verified replication: the whole feed + files, re-servable
12585// ─────────────────────────────────────────────────────────────────────────────
12586
12587/// What `dbmd mirror` materialized.
12588#[derive(Debug, Serialize)]
12589pub struct MirrorReport {
12590    /// The brain id.
12591    pub brain: String,
12592    /// The mirrored feed head.
12593    #[serde(rename = "headSeq")]
12594    pub head_seq: u64,
12595    /// The head entry hash (the feed's advertised converged state).
12596    #[serde(rename = "feedHash")]
12597    pub feed_hash: Option<String>,
12598    /// Signed feed entries verified and stored.
12599    pub entries: u64,
12600    /// The brain's multikey, pinned in `.dbmd/config` (TOFU).
12601    pub pinned: String,
12602    /// Store files materialized by the pull.
12603    pub files: usize,
12604}
12605
12606/// The mirror state directory, relative to the mirror root.
12607pub const MIRROR_REL_DIR: &str = ".dbmd/mirror";
12608
12609/// Fully re-verified mirror material suitable for a read-only re-server.
12610#[derive(Debug)]
12611pub struct VerifiedMirrorMaterial {
12612    pub brain: String,
12613    pub head_seq: u64,
12614    pub feed_hash: Option<String>,
12615    pub identity: serde_json::Value,
12616    /// Sequence, exact normative entry JSON without newline, and entry hash.
12617    pub entries: Vec<(u64, String, String)>,
12618    pub pack_sha256: Option<String>,
12619}
12620
12621#[derive(Deserialize)]
12622#[serde(deny_unknown_fields)]
12623struct StoredMirrorHead {
12624    brain: String,
12625    #[serde(rename = "headSeq")]
12626    head_seq: u64,
12627    #[serde(rename = "feedHash")]
12628    feed_hash: Option<String>,
12629}
12630
12631/// Re-verify mirror metadata, every signature/hash/rotation boundary, and the
12632/// exact snapshot pack before `dbmd serve` exposes any bytes.
12633pub fn verify_mirror_material(
12634    head_bytes: &[u8],
12635    identity_bytes: &[u8],
12636    feed_bytes: &[Vec<u8>],
12637    snapshot_pack: Option<&[u8]>,
12638    expected_anchor: &str,
12639) -> LinkResult<VerifiedMirrorMaterial> {
12640    let snapshot_hash = snapshot_pack
12641        .filter(|pack| !pack.is_empty())
12642        .map(content_sha256);
12643    verify_mirror_material_with_pack_hash(
12644        head_bytes,
12645        identity_bytes,
12646        feed_bytes,
12647        snapshot_hash.as_deref(),
12648        expected_anchor,
12649    )
12650}
12651
12652/// Re-verify mirror metadata against a snapshot digest computed from a held
12653/// no-follow file capability. This lets `dbmd serve` authenticate and retain a
12654/// large pack without ever buffering the pack in process memory.
12655pub fn verify_mirror_material_with_pack_hash(
12656    head_bytes: &[u8],
12657    identity_bytes: &[u8],
12658    feed_bytes: &[Vec<u8>],
12659    snapshot_pack_sha256: Option<&str>,
12660    expected_anchor: &str,
12661) -> LinkResult<VerifiedMirrorMaterial> {
12662    let head: StoredMirrorHead = serde_json::from_slice(head_bytes)
12663        .map_err(|_| invalid_feed("stored mirror head did not parse exactly"))?;
12664    require_safe_ref(&head.brain)?;
12665    if head.head_seq > MAX_FEED_REPLAY_ENTRIES || feed_bytes.len() as u64 != head.head_seq {
12666        return Err(invalid_feed(
12667            "stored mirror feed count does not match its bounded head sequence",
12668        ));
12669    }
12670    let aggregate = feed_bytes
12671        .iter()
12672        .try_fold(0u64, |total, bytes| total.checked_add(bytes.len() as u64))
12673        .ok_or_else(|| invalid_feed("stored mirror feed size overflow"))?;
12674    if aggregate > MAX_FEED_REPLAY_BYTES {
12675        return Err(invalid_feed(
12676            "stored mirror feed metadata exceeds the aggregate limit",
12677        ));
12678    }
12679    let identity: FeedIdentity = serde_json::from_slice(identity_bytes)
12680        .map_err(|_| invalid_feed("stored mirror identity did not parse exactly"))?;
12681    let anchor = verify_identity_chain(&identity, None)?;
12682    if anchor != expected_anchor {
12683        return Err(invalid_feed(
12684            "stored mirror identity does not descend from the explicitly trusted anchor",
12685        ));
12686    }
12687
12688    let mut entries = Vec::with_capacity(feed_bytes.len());
12689    let mut items = Vec::with_capacity(feed_bytes.len());
12690    let mut previous_hash = None;
12691    let mut pack_sha256 = None;
12692    for (index, bytes) in feed_bytes.iter().enumerate() {
12693        let exact = bytes
12694            .strip_suffix(b"\n")
12695            .ok_or_else(|| invalid_feed("stored feed entry lacks its exact trailing newline"))?;
12696        if exact.ends_with(b"\n") {
12697            return Err(invalid_feed("stored feed entry has extra trailing bytes"));
12698        }
12699        let entry: FeedEntry = serde_json::from_slice(exact)
12700            .map_err(|_| invalid_feed("stored feed entry did not parse exactly"))?;
12701        let expected_seq = index as u64 + 1;
12702        if entry.seq != expected_seq || entry.prev_entry_hash != previous_hash {
12703            return Err(invalid_feed(
12704                "stored mirror feed is not contiguous and hash-chained",
12705            ));
12706        }
12707        let canonical = serde_json::to_vec(&entry)
12708            .map_err(|_| invalid_feed("could not canonicalize stored feed entry"))?;
12709        if canonical != exact {
12710            return Err(invalid_feed(
12711                "stored feed entry is not in normative serialization",
12712            ));
12713        }
12714        let hash = content_sha256(bytes);
12715        let item = FeedItem {
12716            hash: hash.clone(),
12717            entry,
12718        };
12719        verify_feed_item(&item, &identity)?;
12720        previous_hash = Some(hash.clone());
12721        if expected_seq == head.head_seq {
12722            pack_sha256 = Some(item.entry.pack_sha256.clone());
12723        }
12724        entries.push((
12725            expected_seq,
12726            std::str::from_utf8(exact)
12727                .map_err(|_| invalid_feed("stored feed entry is not UTF-8"))?
12728                .to_string(),
12729            hash,
12730        ));
12731        items.push(item);
12732    }
12733    if previous_hash != head.feed_hash {
12734        return Err(invalid_feed(
12735            "stored mirror feed does not converge on its advertised head",
12736        ));
12737    }
12738    verify_rotation_feed_boundaries(&identity, None, &items, head.head_seq)?;
12739    match (head.head_seq, snapshot_pack_sha256, pack_sha256.as_deref()) {
12740        (0, None, None) => {}
12741        (_, Some(actual), Some(expected)) if actual == expected => {}
12742        _ => {
12743            return Err(LinkError::InvalidPack {
12744                message: "stored snapshot pack does not match the signed head digest".to_string(),
12745            });
12746        }
12747    }
12748    let identity_value = serde_json::to_value(&identity)
12749        .map_err(|_| invalid_feed("could not serialize verified mirror identity"))?;
12750    Ok(VerifiedMirrorMaterial {
12751        brain: head.brain,
12752        head_seq: head.head_seq,
12753        feed_hash: head.feed_hash,
12754        identity: identity_value,
12755        entries,
12756        pack_sha256,
12757    })
12758}
12759
12760/// SHA-256 hex of one feed entry's stored bytes (`exact JSON + "\n"`) — the
12761/// entry hash every consumer recomputes (SPEC §5.3).
12762pub fn feed_entry_hash(exact_sans_newline: &str) -> String {
12763    format!(
12764        "{:x}",
12765        Sha256::digest(format!("{exact_sans_newline}\n").as_bytes())
12766    )
12767}
12768
12769/// SHA-256 hex for content a signed manifest names. Exposed for the thin
12770/// `dbmd serve` adapter; cryptographic verification remains centralized here.
12771pub fn content_sha256(bytes: &[u8]) -> String {
12772    format!("{:x}", Sha256::digest(bytes))
12773}
12774
12775/// SHA-256 a stream with a fixed-size working buffer.
12776pub fn content_sha256_reader(mut reader: impl Read) -> std::io::Result<String> {
12777    let mut digest = Sha256::new();
12778    let mut buffer = [0u8; 64 * 1024];
12779    loop {
12780        let read = reader.read(&mut buffer)?;
12781        if read == 0 {
12782            break;
12783        }
12784        digest.update(&buffer[..read]);
12785    }
12786    Ok(format!("{:x}", digest.finalize()))
12787}
12788
12789/// Replicate a brain with full verification (link.md §5.4 over the WHOLE
12790/// chain, not just the head): every entry's signature, hash, sequence
12791/// contiguity, prev-hash linkage, rotation chain, and exact signed pack are
12792/// checked in a sibling staging directory. Only then is the old mirror swapped
12793/// out through an atomic directory exchange. Every stage, install, and cleanup
12794/// operation is relative to one held no-follow parent-directory capability, so
12795/// renaming an ancestor cannot redirect any write or deletion.
12796#[cfg_attr(windows, allow(unreachable_code, unused_variables))]
12797pub fn mirror(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<MirrorReport> {
12798    require_hardened_filesystem("mirror")?;
12799    require_safe_ref(brain)?;
12800    #[cfg(windows)]
12801    {
12802        let _ = (cfg, dest);
12803        return Err(LinkError::UnsupportedPlatform {
12804            operation: "atomic whole-mirror replacement on Windows",
12805        });
12806    }
12807    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
12808    let name = dest
12809        .file_name()
12810        .and_then(|name| name.to_str())
12811        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
12812        .ok_or_else(|| LinkError::UnsafePath {
12813            path: dest.display().to_string(),
12814        })?;
12815    #[cfg(unix)]
12816    let parent_dir = open_or_create_dir_nofollow(parent)?;
12817    #[cfg(unix)]
12818    use std::os::fd::AsRawFd as _;
12819    #[cfg(unix)]
12820    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
12821    #[cfg(unix)]
12822    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
12823        None => false,
12824        Some(true) => true,
12825        Some(false) => {
12826            return Err(LinkError::UnsafePath {
12827                path: dest.display().to_string(),
12828            });
12829        }
12830    };
12831
12832    // A fixed backup was used by pre-hardening builds. Never interpret or
12833    // delete an attacker-planted entry at that name; require manual recovery.
12834    #[cfg(unix)]
12835    let legacy_backup_name = c_name(
12836        format!(".{name}.dbmd-backup").as_bytes(),
12837        &dest.display().to_string(),
12838    )?;
12839    #[cfg(unix)]
12840    if entry_is_dir_at(parent_dir.as_raw_fd(), &legacy_backup_name)?.is_some() {
12841        return Err(LinkError::UnsafePath {
12842            path: parent
12843                .join(format!(".{name}.dbmd-backup"))
12844                .display()
12845                .to_string(),
12846        });
12847    }
12848
12849    let nonce = std::time::SystemTime::now()
12850        .duration_since(std::time::UNIX_EPOCH)
12851        .unwrap_or_default()
12852        .as_nanos();
12853    let stage_label = format!(".{name}.dbmd-stage-{}-{nonce}", std::process::id());
12854    #[cfg(unix)]
12855    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
12856    #[cfg(unix)]
12857    let stage_dir = create_dir_exclusive_at(
12858        parent_dir.as_raw_fd(),
12859        &stage_name,
12860        &dest.display().to_string(),
12861    )?;
12862
12863    let assembled = (|| -> LinkResult<MirrorReport> {
12864        let remote = verified_remote_head(cfg, brain, true)?;
12865        let brain_id = remote.head.brain.clone();
12866        let identity = remote
12867            .identity
12868            .as_ref()
12869            .ok_or_else(|| invalid_feed("brain has no signed identity"))?;
12870        let anchor = remote
12871            .anchor
12872            .clone()
12873            .ok_or_else(|| invalid_feed("brain has no verified identity anchor"))?;
12874        let pack = download_verified_snapshot_pack(cfg, &brain_id, &remote)?;
12875        let snapshot_entries = parse_store_pack(pack.clone())?;
12876        let snapshot_count = snapshot_entries.len();
12877        let mut staged_entries = snapshot_entries;
12878        staged_entries.push((format!("{MIRROR_REL_DIR}/snapshot.pack"), pack));
12879        for item in &remote.entries {
12880            let mut exact = serde_json::to_vec(&item.entry)
12881                .map_err(|_| invalid_feed("could not serialize signed feed entry"))?;
12882            exact.push(b'\n');
12883            if format!("{:x}", Sha256::digest(&exact)) != item.hash {
12884                return Err(invalid_feed(
12885                    "serialized mirror entry differs from its verified hash",
12886                ));
12887            }
12888            staged_entries.push((
12889                format!("{MIRROR_REL_DIR}/feed/{}.json", item.entry.seq),
12890                exact,
12891            ));
12892        }
12893        let mut identity_bytes = serde_json::to_vec(identity)
12894            .map_err(|_| invalid_feed("could not serialize verified identity chain"))?;
12895        identity_bytes.push(b'\n');
12896        staged_entries.push((format!("{MIRROR_REL_DIR}/identity.json"), identity_bytes));
12897        let mut head_bytes = serde_json::to_vec(&json!({
12898            "brain": brain_id,
12899            "headSeq": remote.head.seq,
12900            "feedHash": remote.head.feed_hash,
12901        }))
12902        .map_err(|_| invalid_feed("could not serialize verified mirror head"))?;
12903        head_bytes.push(b'\n');
12904        staged_entries.push((format!("{MIRROR_REL_DIR}/head.json"), head_bytes));
12905        staged_entries.push((
12906            CONFIG_REL_PATH.to_string(),
12907            format!("hub = {}\npin = {anchor}\n", cfg.hub).into_bytes(),
12908        ));
12909        #[cfg(unix)]
12910        write_pull_entries_beneath_dir(&stage_dir, &staged_entries)?;
12911
12912        Ok(MirrorReport {
12913            brain: brain_id,
12914            head_seq: remote.head.seq,
12915            feed_hash: remote.head.feed_hash,
12916            entries: remote.entries.len() as u64,
12917            pinned: anchor,
12918            files: snapshot_count,
12919        })
12920    })();
12921
12922    let report = match assembled {
12923        Ok(report) => report,
12924        Err(error) => {
12925            #[cfg(unix)]
12926            let _ = remove_tree_at(
12927                parent_dir.as_raw_fd(),
12928                &stage_name,
12929                &dest.display().to_string(),
12930            );
12931            return Err(error);
12932        }
12933    };
12934
12935    #[cfg(unix)]
12936    if let Err(error) =
12937        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
12938    {
12939        let _ = remove_tree_at(
12940            parent_dir.as_raw_fd(),
12941            &stage_name,
12942            &dest.display().to_string(),
12943        );
12944        return Err(error);
12945    }
12946    // An exchange leaves the old mirror at the unique stage name. Cleanup is
12947    // capability-relative and never follows symlinks in hostile old content.
12948    #[cfg(unix)]
12949    if dest_exists {
12950        remove_tree_at(
12951            parent_dir.as_raw_fd(),
12952            &stage_name,
12953            &dest.display().to_string(),
12954        )?;
12955    }
12956    #[cfg(unix)]
12957    parent_dir.sync_all()?;
12958    Ok(report)
12959}
12960
12961fn verified_remote_head(
12962    cfg: &HubConfig,
12963    brain: &str,
12964    require_full_chain: bool,
12965) -> LinkResult<VerifiedRemote> {
12966    require_hardened_filesystem("verified link.md state")?;
12967    require_safe_ref(brain)?;
12968    // Refuse an unsafe state root before sending credentials or consulting an
12969    // untrusted card, and retain this exact directory inode for the complete
12970    // checkpoint transaction.
12971    let trust_directory = open_trust_dir(cfg)?;
12972    let path = format!("/api/hub/brains/{brain}");
12973    let body = ensure_ok(
12974        request(cfg, "GET", &path, None, Auth::Required)?,
12975        "subscribe",
12976    )?;
12977    let resolved_brain = body
12978        .get("id")
12979        .and_then(Value::as_str)
12980        .filter(|id| crate::ulid::is_ulid(id))
12981        .ok_or_else(|| invalid_feed("brain card has no canonical ULID id"))?
12982        .to_string();
12983    if crate::ulid::is_ulid(brain) && resolved_brain != brain {
12984        return Err(invalid_feed(
12985            "brain card id differs from the explicitly requested brain id",
12986        ));
12987    }
12988    // The card supplies the canonical ULID. Lock alias + canonical keys in
12989    // deterministic filename order, then hold both through verify + save.
12990    // Concurrent aliases for the same brain therefore converge on one
12991    // checkpoint instead of establishing independent TOFU universes.
12992    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &resolved_brain])?;
12993    let (pinned, alias_binding) =
12994        load_canonical_pin(cfg, &trust_directory, brain, &resolved_brain)?;
12995    let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
12996    let advertised_hash = body
12997        .get("feedHash")
12998        .and_then(Value::as_str)
12999        .map(str::to_string);
13000    let updated_at = body
13001        .get("updatedAt")
13002        .and_then(Value::as_str)
13003        .map(str::to_string);
13004    if let Some(pin) = &pinned {
13005        if seq < pin.head_seq {
13006            return Err(invalid_feed(format!(
13007                "feed rollback: hub advertised sequence {seq}, local checkpoint is {}",
13008                pin.head_seq
13009            )));
13010        }
13011        if seq == pin.head_seq && advertised_hash != pin.feed_hash {
13012            return Err(invalid_feed(
13013                "feed equivocation: the checkpoint sequence now has a different hash",
13014            ));
13015        }
13016    }
13017    if seq == 0 {
13018        if advertised_hash.is_some() {
13019            return Err(invalid_feed("an empty feed advertised a head hash"));
13020        }
13021        let identity: FeedIdentity = serde_json::from_value(
13022            body.get("identity")
13023                .cloned()
13024                .ok_or_else(|| invalid_feed("empty brain card has no signed identity"))?,
13025        )
13026        .map_err(|_| invalid_feed("empty brain card has an invalid identity"))?;
13027        let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
13028        // A valid old-key-signed rotation is still inconsistent if it commits
13029        // to history that the same card now claims never existed. Run the
13030        // identical feed-boundary proof used by non-empty heads before this
13031        // identity can become a durable TOFU checkpoint.
13032        verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], seq)?;
13033        save_canonical_pin_and_alias(
13034            cfg,
13035            &trust_directory,
13036            brain,
13037            &resolved_brain,
13038            TrustState {
13039                v: 2,
13040                origin: normalized_origin(&cfg.hub)?,
13041                requested: resolved_brain.clone(),
13042                brain: resolved_brain.clone(),
13043                home: None,
13044                anchor: anchor.clone(),
13045                current: format!("ed25519:{}", identity.fingerprint),
13046                head_seq: 0,
13047                feed_hash: None,
13048                rotations: identity.rotations.clone(),
13049                hub_signer: None,
13050                protocol_profile: None,
13051            },
13052            alias_binding.as_ref(),
13053        )?;
13054        return Ok(VerifiedRemote {
13055            head: Head {
13056                brain: resolved_brain,
13057                seq,
13058                updated_at,
13059                feed_hash: None,
13060                verified: true,
13061            },
13062            identity: Some(identity),
13063            head_entry: None,
13064            entries: Vec::new(),
13065            anchor: Some(anchor),
13066        });
13067    }
13068    if advertised_hash.as_ref().is_none_or(|hash| !is_sha256(hash)) {
13069        return Err(invalid_feed(
13070            "non-empty feed did not advertise a valid SHA-256 head",
13071        ));
13072    }
13073
13074    // On first contact the signed head itself is the TOFU checkpoint. A full
13075    // history replay cannot add authority before an anchor exists; mirrors
13076    // still request the complete chain because they promise an archival copy.
13077    let replay_head_only = !require_full_chain
13078        && pinned
13079            .as_ref()
13080            .is_none_or(|checkpoint| checkpoint.head_seq == seq);
13081    let mut after = if replay_head_only {
13082        seq - 1
13083    } else if require_full_chain || pinned.is_none() {
13084        0
13085    } else {
13086        pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq)
13087    };
13088    let mut expected_seq = after + 1;
13089    let mut previous_hash = if require_full_chain || pinned.is_none() || replay_head_only {
13090        None
13091    } else {
13092        pinned
13093            .as_ref()
13094            .and_then(|checkpoint| checkpoint.feed_hash.clone())
13095    };
13096    let mut identity: Option<FeedIdentity> = None;
13097    let mut anchor: Option<String> = None;
13098    let mut head_entry: Option<FeedItem> = None;
13099    let mut all_entries = Vec::new();
13100    let mut observed_entries = Vec::new();
13101    let replay_count = seq
13102        .checked_sub(after)
13103        .ok_or_else(|| invalid_feed("feed replay range moved backward"))?;
13104    if replay_count > MAX_FEED_REPLAY_ENTRIES {
13105        return Err(invalid_feed(format!(
13106            "feed replay requires {replay_count} entries, over the client cap"
13107        )));
13108    }
13109    let mut replay_bytes = 0u64;
13110
13111    loop {
13112        let feed_bytes = ensure_raw_ok(
13113            request_raw(
13114                cfg,
13115                "GET",
13116                &format!("/api/hub/brains/{brain}/feed?after={after}&limit=100"),
13117                None,
13118                Auth::Required,
13119                MAX_FEED_RESPONSE_BYTES,
13120            )?,
13121            "subscribe feed",
13122        )?;
13123        let feed: FeedResponse = serde_json::from_slice(&feed_bytes)
13124            .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
13125        if feed.head_seq != seq || feed.feed_hash != advertised_hash {
13126            return Err(invalid_feed("brain card and feed head disagree"));
13127        }
13128        if feed.entries.len() > FEED_PAGE_LIMIT {
13129            return Err(invalid_feed("feed page exceeds the requested entry limit"));
13130        }
13131        if feed.scope_limited {
13132            if require_full_chain {
13133                return Err(invalid_feed(
13134                    "path-scoped grants cannot verify a full snapshot chain",
13135                ));
13136            }
13137            return Ok(VerifiedRemote {
13138                head: Head {
13139                    brain: resolved_brain,
13140                    seq,
13141                    updated_at,
13142                    feed_hash: advertised_hash,
13143                    verified: false,
13144                },
13145                identity: None,
13146                head_entry: None,
13147                entries: Vec::new(),
13148                anchor: None,
13149            });
13150        }
13151        let page_identity = feed
13152            .identity
13153            .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
13154        let page_anchor = verify_identity_chain(&page_identity, pinned.as_ref())?;
13155        if identity
13156            .as_ref()
13157            .is_some_and(|existing| existing != &page_identity)
13158        {
13159            return Err(invalid_feed("identity changed while reading the feed"));
13160        }
13161        if anchor
13162            .as_ref()
13163            .is_some_and(|existing| existing != &page_anchor)
13164        {
13165            return Err(invalid_feed(
13166                "identity anchor changed while reading the feed",
13167            ));
13168        }
13169        identity = Some(page_identity.clone());
13170        if anchor.is_none() {
13171            anchor = Some(page_anchor);
13172        }
13173        if feed.entries.is_empty() {
13174            return Err(invalid_feed("feed page was empty before the signed head"));
13175        }
13176
13177        for item in feed.entries {
13178            if item.entry.seq != expected_seq {
13179                return Err(invalid_feed(format!(
13180                    "expected entry {expected_seq}, feed served {}",
13181                    item.entry.seq
13182                )));
13183            }
13184            if item.entry.seq > seq {
13185                return Err(invalid_feed("feed advanced past the card snapshot"));
13186            }
13187            if !replay_head_only && item.entry.prev_entry_hash != previous_hash {
13188                return Err(invalid_feed(format!(
13189                    "entry {} does not chain to the local checkpoint",
13190                    item.entry.seq
13191                )));
13192            }
13193            verify_feed_item(&item, &page_identity)?;
13194            reject_retired_signer_after_checkpoint(&page_identity, pinned.as_ref(), &item)?;
13195            replay_bytes = replay_bytes.saturating_add(
13196                serde_json::to_vec(&item)
13197                    .map_err(|_| invalid_feed("could not size feed entry"))?
13198                    .len() as u64,
13199            );
13200            if replay_bytes > MAX_FEED_REPLAY_BYTES {
13201                return Err(invalid_feed("feed replay metadata exceeds the client cap"));
13202            }
13203            previous_hash = Some(item.hash.clone());
13204            after = item.entry.seq;
13205            expected_seq = expected_seq
13206                .checked_add(1)
13207                .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
13208            if require_full_chain {
13209                all_entries.push(item.clone());
13210            }
13211            observed_entries.push(item.clone());
13212            head_entry = Some(item);
13213        }
13214        if after == seq {
13215            break;
13216        }
13217    }
13218
13219    if head_entry.as_ref().map(|item| &item.hash) != advertised_hash.as_ref() {
13220        return Err(invalid_feed(
13221            "verified chain does not converge on the advertised head",
13222        ));
13223    }
13224    let identity = identity.ok_or_else(|| invalid_feed("feed has no brain identity"))?;
13225    let anchor = anchor.ok_or_else(|| invalid_feed("feed has no identity anchor"))?;
13226    verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &observed_entries, seq)?;
13227    save_canonical_pin_and_alias(
13228        cfg,
13229        &trust_directory,
13230        brain,
13231        &resolved_brain,
13232        TrustState {
13233            v: 2,
13234            origin: normalized_origin(&cfg.hub)?,
13235            requested: resolved_brain.clone(),
13236            brain: resolved_brain.clone(),
13237            home: None,
13238            anchor: anchor.clone(),
13239            current: format!("ed25519:{}", identity.fingerprint),
13240            head_seq: seq,
13241            feed_hash: advertised_hash.clone(),
13242            rotations: identity.rotations.clone(),
13243            hub_signer: pinned.as_ref().and_then(|state| state.hub_signer.clone()),
13244            protocol_profile: pinned
13245                .as_ref()
13246                .and_then(|state| state.protocol_profile.clone()),
13247        },
13248        alias_binding.as_ref(),
13249    )?;
13250    Ok(VerifiedRemote {
13251        head: Head {
13252            brain: resolved_brain,
13253            seq,
13254            updated_at,
13255            feed_hash: advertised_hash,
13256            verified: true,
13257        },
13258        identity: Some(identity),
13259        head_entry,
13260        entries: all_entries,
13261        anchor: Some(anchor),
13262    })
13263}
13264
13265/// Read and locally verify the brain's current signed feed head. Identity
13266/// rotation is accepted only through an old-key-signed chain rooted at the
13267/// local TOFU anchor; sequence and hash checkpoints reject rollback and
13268/// equivocation across invocations.
13269pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
13270    Ok(verified_remote_head(cfg, brain, false)?.head)
13271}
13272
13273#[cfg(test)]
13274mod tests {
13275    use super::*;
13276
13277    const TEST_BRAIN_ID: &str = "01j5qc3v9k4ym8rwbn2tqe6f7d";
13278
13279    #[test]
13280    fn exact_source_move_becomes_one_provenance_preserving_rename() {
13281        let hash = "a".repeat(64);
13282        let operations = vec![
13283            json!({
13284                "op": "put",
13285                "path": "sources/curated/item.md",
13286                "expected": { "kind": "absent" },
13287                "blob": hash,
13288                "bytes": 19,
13289            }),
13290            json!({
13291                "op": "delete",
13292                "path": "sources/inbox/item.md",
13293                "expected": { "kind": "blob", "hash": hash },
13294            }),
13295        ];
13296
13297        assert_eq!(
13298            infer_exact_source_promotions(operations),
13299            vec![json!({
13300                "op": "rename",
13301                "from": "sources/inbox/item.md",
13302                "to": "sources/curated/item.md",
13303                "expected_from": { "kind": "blob", "hash": hash },
13304                "expected_to": { "kind": "absent" },
13305                "blob": hash,
13306                "bytes": 19,
13307            })]
13308        );
13309    }
13310
13311    #[test]
13312    fn duplicate_source_bytes_never_guess_which_evidence_was_promoted() {
13313        let hash = "b".repeat(64);
13314        let operations = vec![
13315            json!({
13316                "op": "delete",
13317                "path": "sources/inbox/a.md",
13318                "expected": { "kind": "blob", "hash": hash },
13319            }),
13320            json!({
13321                "op": "delete",
13322                "path": "sources/inbox/b.md",
13323                "expected": { "kind": "blob", "hash": hash },
13324            }),
13325            json!({
13326                "op": "put",
13327                "path": "sources/curated/item.md",
13328                "expected": { "kind": "absent" },
13329                "blob": hash,
13330                "bytes": 19,
13331            }),
13332        ];
13333
13334        assert_eq!(
13335            infer_exact_source_promotions(operations.clone()),
13336            operations,
13337            "an ambiguous filesystem diff must reach the hub unchanged and fail closed"
13338        );
13339    }
13340
13341    fn merge_fixture(
13342        base: Option<&str>,
13343        remote: Option<&str>,
13344        local: Option<&str>,
13345        keep_local: bool,
13346    ) -> V2PulledMerge<String> {
13347        let map = |value: Option<&str>| {
13348            value
13349                .map(|value| [("records/a.md".to_string(), value.to_string())])
13350                .into_iter()
13351                .flatten()
13352                .collect::<std::collections::BTreeMap<_, _>>()
13353        };
13354        merge_v2_pulled_records(
13355            &map(base),
13356            &map(remote),
13357            &map(local),
13358            |value, _| value.clone(),
13359            |value, _| value.clone(),
13360            |_| keep_local,
13361        )
13362    }
13363
13364    #[test]
13365    fn v2_pull_three_way_merge_never_discards_a_local_only_change() {
13366        let path = "records/a.md".to_string();
13367
13368        let local_add = merge_fixture(None, None, Some("local"), false);
13369        assert_eq!(
13370            local_add.records.get(&path).map(String::as_str),
13371            Some("local")
13372        );
13373        assert!(local_add.accept_remote.is_empty());
13374        assert!(local_add.conflicts.is_empty());
13375
13376        let local_edit = merge_fixture(Some("old"), Some("old"), Some("local"), false);
13377        assert_eq!(
13378            local_edit.records.get(&path).map(String::as_str),
13379            Some("local")
13380        );
13381        assert!(local_edit.accept_remote.is_empty());
13382        assert!(local_edit.conflicts.is_empty());
13383
13384        let local_delete = merge_fixture(Some("old"), Some("old"), None, false);
13385        assert!(!local_delete.records.contains_key(&path));
13386        assert!(local_delete.accept_remote.is_empty());
13387        assert!(local_delete.conflicts.is_empty());
13388
13389        let remote_edit = merge_fixture(Some("old"), Some("remote"), Some("old"), false);
13390        assert_eq!(
13391            remote_edit.records.get(&path).map(String::as_str),
13392            Some("remote")
13393        );
13394        assert!(remote_edit.accept_remote.contains(&path));
13395        assert!(remote_edit.conflicts.is_empty());
13396
13397        let remote_delete = merge_fixture(Some("old"), None, Some("old"), false);
13398        assert!(!remote_delete.records.contains_key(&path));
13399        assert!(remote_delete.accept_remote.contains(&path));
13400        assert!(remote_delete.conflicts.is_empty());
13401
13402        let same_edit = merge_fixture(Some("old"), Some("same"), Some("same"), false);
13403        assert_eq!(
13404            same_edit.records.get(&path).map(String::as_str),
13405            Some("same")
13406        );
13407        assert!(same_edit.accept_remote.contains(&path));
13408        assert!(same_edit.conflicts.is_empty());
13409
13410        let conflict = merge_fixture(Some("old"), Some("remote"), Some("local"), false);
13411        assert_eq!(conflict.conflicts, vec![path.clone()]);
13412        assert_eq!(
13413            conflict.records.get(&path).map(String::as_str),
13414            Some("local")
13415        );
13416        assert!(conflict.accept_remote.is_empty());
13417
13418        let kept_home = merge_fixture(Some("old"), Some("remote"), Some("local"), true);
13419        assert_eq!(
13420            kept_home.records.get(&path).map(String::as_str),
13421            Some("local")
13422        );
13423        assert!(kept_home.accept_remote.is_empty());
13424        assert!(kept_home.conflicts.is_empty());
13425    }
13426
13427    #[test]
13428    fn v2_asset_hosting_resume_is_an_explicit_local_policy_transition() {
13429        let path = "sources/report.pdf";
13430        let record = crate::AssetRecord {
13431            path: path.to_string(),
13432            sha256: "a".repeat(64),
13433            bytes: 42,
13434            media_type: "application/pdf".to_string(),
13435            wrappers: vec!["gzip".to_string()],
13436            required: true,
13437        };
13438        let mut remote = V2BaselineAsset {
13439            blob_sha256: record.sha256.clone(),
13440            bytes: record.bytes,
13441            media_type: record.media_type.clone(),
13442            wrappers: record.wrappers.clone(),
13443            required: record.required,
13444            disposition: "withheld".to_string(),
13445            leaf_hash: "b".repeat(64),
13446        };
13447
13448        assert!(v2_asset_resumes_hosting(
13449            Some(&remote),
13450            path,
13451            &record,
13452            "hosted"
13453        ));
13454        assert!(!v2_asset_resumes_hosting(
13455            Some(&remote),
13456            path,
13457            &record,
13458            "withheld"
13459        ));
13460
13461        remote.disposition = "hosted".to_string();
13462        assert!(!v2_asset_resumes_hosting(
13463            Some(&remote),
13464            path,
13465            &record,
13466            "hosted"
13467        ));
13468
13469        remote.disposition = "withheld".to_string();
13470        remote.blob_sha256 = "c".repeat(64);
13471        assert!(!v2_asset_resumes_hosting(
13472            Some(&remote),
13473            path,
13474            &record,
13475            "hosted"
13476        ));
13477        assert!(!v2_asset_resumes_hosting(None, path, &record, "hosted"));
13478    }
13479
13480    #[test]
13481    fn take_remote_resolution_rebinds_to_the_exact_current_manifest() {
13482        let path = "records/team/alpha.md".to_string();
13483        let deleted_path = "records/team/deleted.md".to_string();
13484        let coordinate = |sha256: Option<String>, bytes: Option<u64>| V2ConflictCoordinate {
13485            sha256,
13486            bytes,
13487            file: None,
13488        };
13489        let files = vec![
13490            V2ConflictFile {
13491                path: path.clone(),
13492                base: coordinate(None, None),
13493                local: coordinate(Some("b".repeat(64)), Some(7)),
13494                remote: coordinate(Some("a".repeat(64)), Some(5)),
13495            },
13496            V2ConflictFile {
13497                path: deleted_path.clone(),
13498                base: coordinate(Some("c".repeat(64)), Some(9)),
13499                local: coordinate(Some("d".repeat(64)), Some(11)),
13500                remote: coordinate(None, None),
13501            },
13502        ];
13503        let proven = V2BaselineFile {
13504            sha256: "a".repeat(64),
13505            bytes: 5,
13506            proof: None,
13507        };
13508        let current = [(path.clone(), proven.clone())]
13509            .into_iter()
13510            .collect::<std::collections::BTreeMap<_, _>>();
13511
13512        let (selected, deleted) = v2_take_remote_selection(&files, &current).unwrap();
13513        assert_eq!(selected.get(&path).unwrap().sha256, proven.sha256);
13514        assert_eq!(deleted, vec![deleted_path.clone()]);
13515
13516        let changed = [(
13517            path.clone(),
13518            V2BaselineFile {
13519                sha256: "e".repeat(64),
13520                bytes: 5,
13521                proof: None,
13522            },
13523        )]
13524        .into_iter()
13525        .collect::<std::collections::BTreeMap<_, _>>();
13526        assert!(v2_take_remote_selection(&files, &changed).is_err());
13527
13528        let resurrected = [
13529            (path, proven),
13530            (
13531                deleted_path,
13532                V2BaselineFile {
13533                    sha256: "f".repeat(64),
13534                    bytes: 13,
13535                    proof: None,
13536                },
13537            ),
13538        ]
13539        .into_iter()
13540        .collect::<std::collections::BTreeMap<_, _>>();
13541        assert!(v2_take_remote_selection(&files, &resurrected).is_err());
13542    }
13543
13544    #[cfg(target_os = "linux")]
13545    #[test]
13546    fn linux_snapshot_install_is_atomic_for_create_and_exchange() {
13547        use std::os::fd::AsRawFd as _;
13548
13549        let sandbox = tempfile::TempDir::new().unwrap();
13550        let parent = std::fs::File::open(sandbox.path()).unwrap();
13551        let stage = std::ffi::CString::new("stage").unwrap();
13552        let destination = std::ffi::CString::new("brain").unwrap();
13553
13554        std::fs::create_dir(sandbox.path().join("stage")).unwrap();
13555        std::fs::write(sandbox.path().join("stage/value"), b"created").unwrap();
13556        install_stage_at(
13557            parent.as_raw_fd(),
13558            stage.as_c_str(),
13559            destination.as_c_str(),
13560            false,
13561        )
13562        .unwrap();
13563        assert!(!sandbox.path().join("stage").exists());
13564        assert_eq!(
13565            std::fs::read(sandbox.path().join("brain/value")).unwrap(),
13566            b"created"
13567        );
13568
13569        std::fs::create_dir(sandbox.path().join("stage")).unwrap();
13570        std::fs::write(sandbox.path().join("stage/value"), b"replacement").unwrap();
13571        install_stage_at(
13572            parent.as_raw_fd(),
13573            stage.as_c_str(),
13574            destination.as_c_str(),
13575            true,
13576        )
13577        .unwrap();
13578        assert_eq!(
13579            std::fs::read(sandbox.path().join("brain/value")).unwrap(),
13580            b"replacement"
13581        );
13582        assert_eq!(
13583            std::fs::read(sandbox.path().join("stage/value")).unwrap(),
13584            b"created",
13585            "RENAME_EXCHANGE must leave the predecessor at the unique stage name"
13586        );
13587    }
13588
13589    struct SignedRemoteFixture {
13590        card: String,
13591        feed: String,
13592        key: AgentSigningKey,
13593        identity: FeedIdentity,
13594    }
13595
13596    fn signed_remote_fixture() -> SignedRemoteFixture {
13597        let rng = ring::rand::SystemRandom::new();
13598        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
13599        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
13600        let (public_key, multikey) = public_identity_for(&pair);
13601        let identity = FeedIdentity {
13602            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
13603            public_key_spki: public_key.clone(),
13604            previous: Vec::new(),
13605            rotations: Vec::new(),
13606        };
13607        let mut entry = FeedEntry {
13608            v: 1,
13609            seq: 1,
13610            ts: "2026-07-30T12:00:00.000Z".to_string(),
13611            brain: multikey.clone(),
13612            public_key: public_key.clone(),
13613            kind: "push".to_string(),
13614            op: "snapshot".to_string(),
13615            pack_sha256: "a".repeat(64),
13616            files: Vec::new(),
13617            removed: Vec::new(),
13618            prev_entry_hash: None,
13619            sig: String::new(),
13620        };
13621        let unsigned = UnsignedFeedEntry {
13622            v: entry.v,
13623            seq: entry.seq,
13624            ts: &entry.ts,
13625            brain: &entry.brain,
13626            public_key: &entry.public_key,
13627            kind: &entry.kind,
13628            op: &entry.op,
13629            pack_sha256: &entry.pack_sha256,
13630            files: &entry.files,
13631            removed: &entry.removed,
13632            prev_entry_hash: &entry.prev_entry_hash,
13633        };
13634        entry.sig =
13635            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
13636        let mut exact = serde_json::to_vec(&entry).unwrap();
13637        exact.push(b'\n');
13638        let hash = content_sha256(&exact);
13639        let card = json!({
13640            "id": TEST_BRAIN_ID,
13641            "headSeq": 1,
13642            "feedHash": hash,
13643            "identity": identity.clone(),
13644        })
13645        .to_string();
13646        let feed = json!({
13647            "headSeq": 1,
13648            "feedHash": hash,
13649            "identity": identity.clone(),
13650            "entries": [{"hash": hash, "entry": entry}],
13651            "scopeLimited": false,
13652        })
13653        .to_string();
13654        SignedRemoteFixture {
13655            card,
13656            feed,
13657            key: AgentSigningKey {
13658                pkcs8: pkcs8.as_ref().to_vec(),
13659                multikey,
13660                public_key_spki: public_key,
13661            },
13662            identity,
13663        }
13664    }
13665
13666    #[test]
13667    fn v1_edit_disclosure_accepts_minimal_and_superset_forms() {
13668        let file = |path: &str, byte: char| FeedFile {
13669            path: path.to_string(),
13670            sha256: byte.to_string().repeat(64),
13671            bytes: 1,
13672        };
13673        let a0 = file("records/a.md", 'a');
13674        let a1 = file("records/a.md", 'b');
13675        let stable = file("records/stable.md", 'c');
13676        let added = file("records/added.md", 'd');
13677        let removed_file = file("records/removed.md", 'e');
13678        let previous = vec![a0, stable.clone(), removed_file.clone()];
13679        let resulting = vec![a1.clone(), stable.clone(), added.clone()];
13680        let removed = vec![removed_file.path.clone()];
13681
13682        assert_eq!(
13683            verify_v1_manifest_disclosure(
13684                "edit",
13685                &previous,
13686                &resulting,
13687                &[a1.clone(), added.clone()],
13688                &removed,
13689            ),
13690            Ok(())
13691        );
13692        assert_eq!(
13693            verify_v1_manifest_disclosure(
13694                "edit",
13695                &previous,
13696                &resulting,
13697                &[stable.clone(), added.clone(), a1.clone()],
13698                &removed,
13699            ),
13700            Ok(())
13701        );
13702        assert_eq!(
13703            verify_v1_manifest_disclosure(
13704                "edit",
13705                &previous,
13706                &resulting,
13707                std::slice::from_ref(&added),
13708                &removed,
13709            ),
13710            Err(V1DisclosureError::EditMissingChange)
13711        );
13712        assert_eq!(
13713            verify_v1_manifest_disclosure(
13714                "edit",
13715                &previous,
13716                &resulting,
13717                &[file("records/a.md", 'f'), added.clone()],
13718                &removed,
13719            ),
13720            Err(V1DisclosureError::EditFalseFile)
13721        );
13722        assert_eq!(
13723            verify_v1_manifest_disclosure(
13724                "edit",
13725                &previous,
13726                &resulting,
13727                &[a1.clone(), added.clone()],
13728                &[],
13729            ),
13730            Err(V1DisclosureError::RemovedMismatch)
13731        );
13732        assert_eq!(
13733            verify_v1_manifest_disclosure(
13734                "push",
13735                &previous,
13736                &resulting,
13737                &[added.clone(), stable, a1],
13738                &removed,
13739            ),
13740            Ok(())
13741        );
13742        assert_eq!(
13743            verify_v1_manifest_disclosure("push", &previous, &resulting, &[added], &removed,),
13744            Err(V1DisclosureError::PushManifestMismatch)
13745        );
13746    }
13747
13748    #[test]
13749    fn wire_sequences_are_rejected_at_their_endpoint_count_limits() {
13750        let fixture = signed_remote_fixture();
13751        let feed: Value = serde_json::from_str(&fixture.feed).unwrap();
13752        let item = feed["entries"][0].to_string();
13753        let oversized_page = format!(
13754            "{{\"headSeq\":1,\"feedHash\":null,\"identity\":null,\"entries\":[{}],\"scopeLimited\":false}}",
13755            std::iter::repeat_n(item.as_str(), FEED_PAGE_LIMIT + 1)
13756                .collect::<Vec<_>>()
13757                .join(",")
13758        );
13759        assert!(serde_json::from_str::<FeedResponse>(&oversized_page).is_err());
13760
13761        let oversized_identity = format!(
13762            "{{\"fingerprint\":\"fp\",\"publicKeySpki\":\"spki\",\"previous\":[],\"rotations\":[{}]}}",
13763            std::iter::repeat_n("\"rotation\"", MAX_IDENTITY_ROTATIONS + 1)
13764                .collect::<Vec<_>>()
13765                .join(",")
13766        );
13767        assert!(serde_json::from_str::<FeedIdentity>(&oversized_identity).is_err());
13768
13769        let file = r#"{"path":"a","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bytes":1}"#;
13770        let oversized_entry = format!(
13771            "{{\"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\"}}",
13772            "a".repeat(64),
13773            std::iter::repeat_n(file, MAX_PUSH_FILES + 1)
13774                .collect::<Vec<_>>()
13775                .join(",")
13776        );
13777        assert!(serde_json::from_str::<FeedEntry>(&oversized_entry).is_err());
13778    }
13779
13780    #[test]
13781    fn bulk_confirmation_parser_accepts_only_the_exact_wire_shape() {
13782        let id = "01arz3ndektsv4rrffq69g5fav";
13783        let digest = "a".repeat(64);
13784        assert_eq!(
13785            V2BulkConfirmation::parse(&format!("{id}:{digest}")).unwrap(),
13786            V2BulkConfirmation {
13787                id: id.to_string(),
13788                digest,
13789            }
13790        );
13791        for invalid in [
13792            "",
13793            "01arz3ndektsv4rrffq69g5fav",
13794            "01ARZ3NDEKTSV4RRFFQ69G5FAV:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
13795            "01arz3ndektsv4rrffq69g5fav:ABCDEF",
13796            "01arz3ndektsv4rrffq69g5fav:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
13797        ] {
13798            assert!(matches!(
13799                V2BulkConfirmation::parse(invalid),
13800                Err(LinkError::InvalidPack { .. })
13801            ));
13802        }
13803    }
13804
13805    fn scripted_json_hub(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle<()>) {
13806        use std::io::{BufRead as _, BufReader, Read as _, Write as _};
13807        use std::net::TcpListener;
13808
13809        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
13810        let url = format!("http://{}", listener.local_addr().unwrap());
13811        let handle = std::thread::spawn(move || {
13812            for (status, body) in responses {
13813                let (stream, _) = listener.accept().unwrap();
13814                let mut reader = BufReader::new(stream);
13815                let mut line = String::new();
13816                reader.read_line(&mut line).unwrap();
13817                let mut content_length = 0usize;
13818                loop {
13819                    line.clear();
13820                    reader.read_line(&mut line).unwrap();
13821                    if line == "\r\n" || line == "\n" || line.is_empty() {
13822                        break;
13823                    }
13824                    if let Some((name, value)) = line.split_once(':') {
13825                        if name.eq_ignore_ascii_case("content-length") {
13826                            content_length = value.trim().parse().unwrap();
13827                        }
13828                    }
13829                }
13830                let mut request_body = vec![0_u8; content_length];
13831                reader.read_exact(&mut request_body).unwrap();
13832                let response = format!(
13833                    "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
13834                    body.len()
13835                );
13836                reader.get_mut().write_all(response.as_bytes()).unwrap();
13837            }
13838        });
13839        (url, handle)
13840    }
13841
13842    fn routed_json_hub(
13843        requests: usize,
13844        mut respond: impl FnMut(&str) -> (u16, String) + Send + 'static,
13845    ) -> (String, std::thread::JoinHandle<()>) {
13846        use std::io::{BufRead as _, BufReader, Read as _, Write as _};
13847        use std::net::TcpListener;
13848
13849        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
13850        let url = format!("http://{}", listener.local_addr().unwrap());
13851        let handle = std::thread::spawn(move || {
13852            for _ in 0..requests {
13853                let (stream, _) = listener.accept().unwrap();
13854                let mut reader = BufReader::new(stream);
13855                let mut line = String::new();
13856                reader.read_line(&mut line).unwrap();
13857                let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
13858                let mut content_length = 0usize;
13859                loop {
13860                    line.clear();
13861                    reader.read_line(&mut line).unwrap();
13862                    if line == "\r\n" || line == "\n" || line.is_empty() {
13863                        break;
13864                    }
13865                    if let Some((name, value)) = line.split_once(':') {
13866                        if name.eq_ignore_ascii_case("content-length") {
13867                            content_length = value.trim().parse().unwrap();
13868                        }
13869                    }
13870                }
13871                let mut request_body = vec![0_u8; content_length];
13872                reader.read_exact(&mut request_body).unwrap();
13873                let (status, body) = respond(&path);
13874                let response = format!(
13875                    "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
13876                    body.len()
13877                );
13878                reader.get_mut().write_all(response.as_bytes()).unwrap();
13879            }
13880        });
13881        (url, handle)
13882    }
13883
13884    fn test_hub_config(hub: String, state_dir: PathBuf) -> HubConfig {
13885        HubConfig {
13886            hub,
13887            key: Some("test-key".to_string()),
13888            agent_key: None,
13889            brain_key: None,
13890            state_dir,
13891            store_selected: false,
13892        }
13893    }
13894
13895    #[test]
13896    fn linkmd_sig_v2_is_bound_to_the_exact_hub_origin() {
13897        use ring::signature::KeyPair as _;
13898
13899        let rng = ring::rand::SystemRandom::new();
13900        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
13901        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
13902        let (spki, multikey) = public_identity_for(&pair);
13903        let key = AgentSigningKey {
13904            pkcs8: pkcs8.as_ref().to_vec(),
13905            multikey,
13906            public_key_spki: spki,
13907        };
13908        let header = linkmd_sig_header(
13909            &key,
13910            "https://hub-a.example",
13911            "post",
13912            "/api/hub/brains/brain/push?mode=exact",
13913            Some("{\"ok\":true}"),
13914        )
13915        .unwrap();
13916        assert!(header.starts_with("LinkMD-Sig v2,"));
13917        let ts = header
13918            .split(",ts=")
13919            .nth(1)
13920            .unwrap()
13921            .split(',')
13922            .next()
13923            .unwrap();
13924        let signature = URL_SAFE_NO_PAD
13925            .decode(header.rsplit(",sig=").next().unwrap())
13926            .unwrap();
13927        let body_hash = format!("{:x}", Sha256::digest(b"{\"ok\":true}"));
13928        let accepted = format!(
13929            "v2\nhttps://hub-a.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
13930        );
13931        let replayed = format!(
13932            "v2\nhttps://hub-b.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
13933        );
13934        let public = pair.public_key().as_ref();
13935        assert!(UnparsedPublicKey::new(&ED25519, public)
13936            .verify(accepted.as_bytes(), &signature)
13937            .is_ok());
13938        assert!(
13939            UnparsedPublicKey::new(&ED25519, public)
13940                .verify(replayed.as_bytes(), &signature)
13941                .is_err(),
13942            "a proof captured at hub A must not authenticate at hub B"
13943        );
13944    }
13945
13946    #[test]
13947    fn explicit_brain_id_rejects_a_substituted_card_before_feed_trust() {
13948        let other = "01j5qc3v9k4ym8rwbn2tqe6f7e";
13949        let card = json!({
13950            "id": other,
13951            "headSeq": 0,
13952            "identity": signed_remote_fixture().identity,
13953        })
13954        .to_string();
13955        let (hub, server) = scripted_json_hub(vec![(200, card)]);
13956        let state = tempfile::tempdir().unwrap();
13957        let cfg = test_hub_config(hub, state.path().to_path_buf());
13958        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
13959        assert!(
13960            error.contains("differs from the explicitly requested"),
13961            "{error}"
13962        );
13963        server.join().unwrap();
13964    }
13965
13966    #[test]
13967    fn empty_brain_identity_is_pinned_and_cannot_be_substituted_later() {
13968        let first = signed_remote_fixture().identity;
13969        let second = signed_remote_fixture().identity;
13970        let card = |identity: FeedIdentity| {
13971            json!({
13972                "id": TEST_BRAIN_ID,
13973                "headSeq": 0,
13974                "identity": identity,
13975            })
13976            .to_string()
13977        };
13978        let (hub, server) = scripted_json_hub(vec![(200, card(first)), (200, card(second))]);
13979        let state = tempfile::tempdir().unwrap();
13980        let cfg = test_hub_config(hub, state.path().to_path_buf());
13981        assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
13982        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
13983        assert!(
13984            error.contains("pinned anchor") || error.contains("forked away"),
13985            "{error}"
13986        );
13987        server.join().unwrap();
13988    }
13989
13990    #[test]
13991    fn empty_head_rejects_a_rotation_that_commits_to_hidden_history() {
13992        let old = signed_remote_fixture();
13993        let new = signed_remote_fixture();
13994        let old_pair = ring::signature::Ed25519KeyPair::from_pkcs8(&old.key.pkcs8).unwrap();
13995        let unsigned = serde_json::to_string(&UnsignedRotation {
13996            v: 1,
13997            op: "rotate",
13998            brain: &old.key.multikey,
13999            public_key: &old.key.public_key_spki,
14000            new_brain: &new.key.multikey,
14001            new_public_key: &new.key.public_key_spki,
14002            prior_head_seq: 1,
14003            prior_feed_hash: Some(&"a".repeat(64)),
14004            ts: "2026-07-30T12:00:00.000Z".to_string(),
14005        })
14006        .unwrap();
14007        let signature = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
14008        let rotation = format!(
14009            "{},\"sig\":\"{}\"}}",
14010            &unsigned[..unsigned.len() - 1],
14011            signature
14012        );
14013        let identity = FeedIdentity {
14014            fingerprint: new.key.multikey.trim_start_matches("ed25519:").to_string(),
14015            public_key_spki: new.key.public_key_spki,
14016            previous: vec![PreviousIdentity {
14017                fingerprint: old.key.multikey.trim_start_matches("ed25519:").to_string(),
14018                public_key_spki: old.key.public_key_spki,
14019            }],
14020            rotations: vec![rotation],
14021        };
14022        let card = json!({
14023            "id": TEST_BRAIN_ID,
14024            "headSeq": 0,
14025            "feedHash": null,
14026            "identity": identity,
14027        })
14028        .to_string();
14029        let (hub, server) = scripted_json_hub(vec![(200, card)]);
14030        let state = tempfile::tempdir().unwrap();
14031        let cfg = test_hub_config(hub, state.path().to_path_buf());
14032        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
14033        assert!(
14034            error.contains("rotation claims a feed boundary beyond the advertised head"),
14035            "{error}"
14036        );
14037        assert!(
14038            load_trust(&cfg, TEST_BRAIN_ID).unwrap().is_none(),
14039            "an inconsistent empty-head identity must not become the TOFU checkpoint"
14040        );
14041        server.join().unwrap();
14042    }
14043
14044    #[test]
14045    fn trust_checkpoint_rejects_a_later_fork() {
14046        let fixture = signed_remote_fixture();
14047        let mut fork: Value = serde_json::from_str(&fixture.card).unwrap();
14048        fork["feedHash"] = Value::String("b".repeat(64));
14049        let (hub, server) = scripted_json_hub(vec![
14050            (200, fixture.card),
14051            (200, fixture.feed),
14052            (200, fork.to_string()),
14053        ]);
14054        let state = tempfile::tempdir().unwrap();
14055        let cfg = test_hub_config(hub, state.path().to_path_buf());
14056        assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
14057        assert!(head(&cfg, TEST_BRAIN_ID).is_err());
14058        server.join().unwrap();
14059    }
14060
14061    #[test]
14062    fn alias_and_canonical_id_share_one_identity_checkpoint() {
14063        let trusted = signed_remote_fixture();
14064        let attacker = signed_remote_fixture();
14065        let (hub, server) = scripted_json_hub(vec![
14066            (200, trusted.card),
14067            (200, trusted.feed),
14068            (200, attacker.card),
14069        ]);
14070        let state = tempfile::tempdir().unwrap();
14071        let cfg = test_hub_config(hub, state.path().to_path_buf());
14072        assert!(head(&cfg, "trusted-slug").unwrap().verified);
14073        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
14074        assert!(
14075            error.contains("equivocation")
14076                || error.contains("pinned")
14077                || error.contains("identity"),
14078            "{error}"
14079        );
14080        server.join().unwrap();
14081    }
14082
14083    #[test]
14084    fn moved_alias_requires_exact_explicit_rebind_without_rewriting_history() {
14085        let state = tempfile::tempdir().unwrap();
14086        let cfg = test_hub_config(
14087            "https://hub.example".to_string(),
14088            state.path().to_path_buf(),
14089        );
14090        let directory = open_trust_dir(&cfg).unwrap();
14091        let old = TEST_BRAIN_ID;
14092        let new = "01j5qc3v9k4ym8rwbn2tqe6f7e";
14093        save_alias_in(
14094            &cfg,
14095            &directory,
14096            &AliasBinding {
14097                v: 1,
14098                origin: normalized_origin(&cfg.hub).unwrap(),
14099                requested: "company-brain".to_string(),
14100                brain: old.to_string(),
14101                home: Some("company-brain".to_string()),
14102            },
14103        )
14104        .unwrap();
14105
14106        let error = load_canonical_pin(&cfg, &directory, "company-brain", new).unwrap_err();
14107        assert!(matches!(
14108            error,
14109            LinkError::AliasRebindRequired {
14110                alias,
14111                from,
14112                to
14113            } if alias == "company-brain" && from == old && to == new
14114        ));
14115        let unchanged = load_alias_in(&cfg, &directory, "company-brain")
14116            .unwrap()
14117            .unwrap();
14118        assert_eq!(unchanged.brain, old);
14119        assert_eq!(unchanged.home.as_deref(), Some("company-brain"));
14120    }
14121
14122    #[test]
14123    fn concurrent_aliases_cannot_establish_conflicting_tofu_pins() {
14124        let alpha = signed_remote_fixture();
14125        let beta = signed_remote_fixture();
14126        let alpha_card = alpha.card.clone();
14127        let alpha_feed = alpha.feed.clone();
14128        let beta_card = beta.card.clone();
14129        let beta_feed = beta.feed.clone();
14130        let (hub, server) = routed_json_hub(3, move |path| {
14131            if path.contains("/alpha/feed?") {
14132                (200, alpha_feed.clone())
14133            } else if path.contains("/beta/feed?") {
14134                (200, beta_feed.clone())
14135            } else if path.ends_with("/alpha") {
14136                (200, alpha_card.clone())
14137            } else if path.ends_with("/beta") {
14138                (200, beta_card.clone())
14139            } else {
14140                (500, r#"{"error":"unexpected path"}"#.to_string())
14141            }
14142        });
14143        let state = tempfile::tempdir().unwrap();
14144        let cfg = test_hub_config(hub, state.path().to_path_buf());
14145        let alpha_cfg = cfg.clone();
14146        let beta_cfg = cfg;
14147        let first = std::thread::spawn(move || head(&alpha_cfg, "alpha"));
14148        let second = std::thread::spawn(move || head(&beta_cfg, "beta"));
14149        let results = [first.join().unwrap(), second.join().unwrap()];
14150        assert_eq!(
14151            results.iter().filter(|result| result.is_ok()).count(),
14152            1,
14153            "only one alias identity may establish canonical TOFU: {results:?}"
14154        );
14155        assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
14156        server.join().unwrap();
14157    }
14158
14159    #[cfg(unix)]
14160    #[test]
14161    fn trust_transaction_survives_an_ancestor_swap_without_writing_outside() {
14162        use std::os::unix::fs::symlink;
14163
14164        let fixture = signed_remote_fixture();
14165        let card = json!({
14166            "id": TEST_BRAIN_ID,
14167            "headSeq": 0,
14168            "feedHash": Value::Null,
14169            "identity": fixture.identity,
14170        })
14171        .to_string();
14172        let work = tempfile::tempdir().unwrap();
14173        let outside = tempfile::tempdir().unwrap();
14174        let state = work.path().join("state");
14175        let moved = work.path().join("state-held");
14176        let swap_state = state.clone();
14177        let swap_moved = moved.clone();
14178        let outside_path = outside.path().to_path_buf();
14179        let (hub, server) = routed_json_hub(1, move |_| {
14180            // The client has already opened state/trust before this response.
14181            std::fs::rename(&swap_state, &swap_moved).unwrap();
14182            symlink(&outside_path, &swap_state).unwrap();
14183            (200, card.clone())
14184        });
14185        let cfg = test_hub_config(hub, state);
14186
14187        let verified = verified_remote_head(&cfg, TEST_BRAIN_ID, false).unwrap();
14188        assert_eq!(verified.head.seq, 0);
14189        assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
14190        assert!(std::fs::read_dir(moved.join("trust"))
14191            .unwrap()
14192            .flatten()
14193            .any(|entry| entry.path().extension().is_some_and(|ext| ext == "json")));
14194        server.join().unwrap();
14195    }
14196
14197    #[test]
14198    fn self_custody_push_refuses_an_unrelated_key_before_requesting_an_upload() {
14199        let remote = signed_remote_fixture();
14200        let unrelated = signed_remote_fixture().key;
14201        let (hub, server) = scripted_json_hub(vec![(200, remote.card), (200, remote.feed)]);
14202        let state = tempfile::tempdir().unwrap();
14203        let mut cfg = test_hub_config(hub, state.path().to_path_buf());
14204        cfg.brain_key = Some(unrelated);
14205        let error = sync_push(
14206            &cfg,
14207            TEST_BRAIN_ID,
14208            &[("DB.md".to_string(), "signed local content".to_string())],
14209        )
14210        .unwrap_err()
14211        .to_string();
14212        assert!(
14213            error.contains("not the verified current brain identity"),
14214            "{error}"
14215        );
14216        server.join().unwrap();
14217    }
14218
14219    #[test]
14220    fn rotation_ignores_a_forged_2xx_body_and_requires_verified_postcondition() {
14221        let remote = signed_remote_fixture();
14222        let new = signed_remote_fixture().key;
14223        let state = tempfile::tempdir().unwrap();
14224        let new_file = state.path().join("new.key");
14225        std::fs::write(
14226            &new_file,
14227            format!("{}\n", URL_SAFE_NO_PAD.encode(&new.pkcs8)),
14228        )
14229        .unwrap();
14230        #[cfg(unix)]
14231        {
14232            use std::os::unix::fs::PermissionsExt as _;
14233            std::fs::set_permissions(&new_file, std::fs::Permissions::from_mode(0o600)).unwrap();
14234        }
14235        let forged = json!({
14236            "brain": TEST_BRAIN_ID,
14237            "identity": {
14238                "fingerprint": new.multikey.trim_start_matches("ed25519:"),
14239                "publicKeySpki": new.public_key_spki,
14240            }
14241        })
14242        .to_string();
14243        let (hub, server) = scripted_json_hub(vec![
14244            (200, remote.card.clone()),
14245            (200, remote.feed.clone()),
14246            (200, forged),
14247            (200, remote.card),
14248            (200, remote.feed),
14249        ]);
14250        let cfg = test_hub_config(hub, state.path().to_path_buf());
14251        let error = rotate_brain_key(&cfg, TEST_BRAIN_ID, &remote.key, &new_file)
14252            .unwrap_err()
14253            .to_string();
14254        assert!(
14255            error.contains("without committing the verified new identity"),
14256            "{error}"
14257        );
14258        server.join().unwrap();
14259    }
14260
14261    #[test]
14262    fn resolve_record_is_derived_from_signed_pack_not_a_query_response() {
14263        let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
14264        let raw = format!(
14265            "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
14266        );
14267        let pack = build_store_pack(&[
14268            (
14269                "DB.md".to_string(),
14270                "---\ntype: db-md\nscope: company\nowner: Test\n---\n".to_string(),
14271            ),
14272            ("records/clients/truth.md".to_string(), raw.clone()),
14273        ])
14274        .unwrap();
14275        let by_id = resolve_from_verified_pack(
14276            "01j5qc3v9k4ym8rwbn2tqe6f7d",
14277            &AddressTarget::Id(record_id.to_string()),
14278            pack.clone(),
14279        )
14280        .unwrap();
14281        assert_eq!(by_id["document"]["summary"], "Signed truth");
14282        assert_eq!(by_id["document"]["body"], "# Signed truth\n");
14283        assert_eq!(
14284            by_id["document"]["contentSha"],
14285            content_sha256(raw.as_bytes())
14286        );
14287
14288        let by_path = resolve_from_verified_pack(
14289            "01j5qc3v9k4ym8rwbn2tqe6f7d",
14290            &AddressTarget::Path("records/clients/truth.md".to_string()),
14291            pack,
14292        )
14293        .unwrap();
14294        assert_eq!(by_path["document"]["id"], record_id);
14295        assert_eq!(by_path["document"]["path"], "records/clients/truth.md");
14296    }
14297
14298    #[test]
14299    fn canonical_store_pack_matches_the_cross_language_zip32_golden() {
14300        let unsorted = vec![
14301            ("records/a.md".to_string(), "alpha\n".to_string()),
14302            ("DB.md".to_string(), "# db\n".to_string()),
14303        ];
14304        let sorted = vec![
14305            ("DB.md".to_string(), "# db\n".to_string()),
14306            ("records/a.md".to_string(), "alpha\n".to_string()),
14307        ];
14308        let pack = build_store_pack(&unsorted).unwrap();
14309
14310        // This digest is shared with the hub implementation. It locks every
14311        // byte of the wire profile: raw UTF-8 order, STORED payloads, fixed DOS
14312        // epoch, explicit CRC/sizes, Unix regular-0600 attributes, and the
14313        // absence of descriptors/extras/comments/ZIP64.
14314        assert_eq!(pack.len(), 219);
14315        assert_eq!(
14316            content_sha256(&pack),
14317            "972fb2045becaa21588baaf4b349e62a430687fa2c21167b53f4ca0efa6c9408"
14318        );
14319        assert_eq!(pack, build_store_pack(&sorted).unwrap());
14320        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x07\x08"));
14321        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x06"));
14322        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x07"));
14323
14324        assert_eq!(
14325            parse_store_pack(pack).unwrap(),
14326            vec![
14327                ("DB.md".to_string(), b"# db\n".to_vec()),
14328                ("records/a.md".to_string(), b"alpha\n".to_vec()),
14329            ]
14330        );
14331    }
14332
14333    #[test]
14334    fn canonical_store_pack_validates_every_path_before_writing() {
14335        let duplicate = vec![
14336            ("DB.md".to_string(), "first".to_string()),
14337            ("DB.md".to_string(), "second".to_string()),
14338        ];
14339        assert!(build_store_pack(&duplicate)
14340            .unwrap_err()
14341            .to_string()
14342            .contains("duplicate path"));
14343        assert!(matches!(
14344            build_store_pack(&[("../escape.md".to_string(), "no".to_string())]),
14345            Err(LinkError::UnsafePath { .. })
14346        ));
14347    }
14348
14349    #[test]
14350    fn zip64_preflight_rejects_an_entry_count_bomb_before_zip_parsing() {
14351        const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
14352        // One byte stands in for the central directory. The trailer itself is
14353        // structurally valid; the count is the sole reason for refusal.
14354        let mut bytes = vec![0_u8];
14355        let zip64_offset = bytes.len() as u64;
14356        bytes.extend_from_slice(b"PK\x06\x06");
14357        bytes.extend_from_slice(&44_u64.to_le_bytes());
14358        bytes.extend_from_slice(&[0_u8; 12]);
14359        bytes.extend_from_slice(&COUNT.to_le_bytes());
14360        bytes.extend_from_slice(&COUNT.to_le_bytes());
14361        bytes.extend_from_slice(&1_u64.to_le_bytes());
14362        bytes.extend_from_slice(&0_u64.to_le_bytes());
14363        bytes.extend_from_slice(b"PK\x06\x07");
14364        bytes.extend_from_slice(&0_u32.to_le_bytes());
14365        bytes.extend_from_slice(&zip64_offset.to_le_bytes());
14366        bytes.extend_from_slice(&1_u32.to_le_bytes());
14367        bytes.extend_from_slice(b"PK\x05\x06");
14368        bytes.extend_from_slice(&0_u16.to_le_bytes());
14369        bytes.extend_from_slice(&0_u16.to_le_bytes());
14370        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
14371        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
14372        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
14373        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
14374        bytes.extend_from_slice(&0_u16.to_le_bytes());
14375
14376        let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
14377            .unwrap_err()
14378            .to_string();
14379        assert!(error.contains("invalid file count"), "{error}");
14380    }
14381
14382    #[test]
14383    fn zip_preflight_rejects_a_fake_trailing_eocd_before_zip_parsing() {
14384        const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
14385        let mut bytes = vec![0_u8];
14386        let zip64_offset = bytes.len() as u64;
14387        bytes.extend_from_slice(b"PK\x06\x06");
14388        bytes.extend_from_slice(&44_u64.to_le_bytes());
14389        bytes.extend_from_slice(&[0_u8; 12]);
14390        bytes.extend_from_slice(&COUNT.to_le_bytes());
14391        bytes.extend_from_slice(&COUNT.to_le_bytes());
14392        bytes.extend_from_slice(&1_u64.to_le_bytes());
14393        bytes.extend_from_slice(&0_u64.to_le_bytes());
14394        bytes.extend_from_slice(b"PK\x06\x07");
14395        bytes.extend_from_slice(&0_u32.to_le_bytes());
14396        bytes.extend_from_slice(&zip64_offset.to_le_bytes());
14397        bytes.extend_from_slice(&1_u32.to_le_bytes());
14398        bytes.extend_from_slice(b"PK\x05\x06");
14399        bytes.extend_from_slice(&0_u16.to_le_bytes());
14400        bytes.extend_from_slice(&0_u16.to_le_bytes());
14401        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
14402        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
14403        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
14404        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
14405        bytes.extend_from_slice(&0_u16.to_le_bytes());
14406        // The old parser trusted this last low-count signature, while
14407        // ZipArchive fell back to the real Zip64 directory and allocated for
14408        // COUNT entries. Its central-directory offsets are deliberately fake.
14409        let fake_eocd = bytes.len() as u32;
14410        bytes.extend_from_slice(b"PK\x05\x06");
14411        bytes.extend_from_slice(&0_u16.to_le_bytes());
14412        bytes.extend_from_slice(&0_u16.to_le_bytes());
14413        bytes.extend_from_slice(&1_u16.to_le_bytes());
14414        bytes.extend_from_slice(&1_u16.to_le_bytes());
14415        bytes.extend_from_slice(&0_u32.to_le_bytes());
14416        bytes.extend_from_slice(&fake_eocd.to_le_bytes());
14417        bytes.extend_from_slice(&0_u16.to_le_bytes());
14418
14419        let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
14420            .unwrap_err()
14421            .to_string();
14422        assert!(error.contains("central directory"), "{error}");
14423    }
14424
14425    #[test]
14426    fn strict_http_status_handling_rejects_redirects_without_panicking() {
14427        let error = ensure_ok(
14428            HubResponse {
14429                status: 302,
14430                body: Some(json!({"redirect": "/elsewhere"})),
14431            },
14432            "mutation",
14433        )
14434        .unwrap_err();
14435        assert!(matches!(error, LinkError::Http { status: 302, .. }));
14436
14437        let error = ensure_raw_ok(
14438            RawHubResponse {
14439                status: 302,
14440                body: br#"{"redirect":"/elsewhere"}"#.to_vec(),
14441            },
14442            "feed",
14443        )
14444        .unwrap_err();
14445        assert!(matches!(error, LinkError::Http { status: 302, .. }));
14446    }
14447
14448    #[cfg(unix)]
14449    #[test]
14450    fn collect_push_files_refuses_external_symlink_and_nested_store() {
14451        use std::os::unix::fs::symlink;
14452
14453        let root = tempfile::tempdir().unwrap();
14454        std::fs::write(
14455            root.path().join("DB.md"),
14456            "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
14457        )
14458        .unwrap();
14459        std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
14460
14461        let external = tempfile::tempdir().unwrap();
14462        let secret = external.path().join("secret.md");
14463        std::fs::write(&secret, "TOP SECRET").unwrap();
14464        symlink(&secret, root.path().join("records/notes/secret.md")).unwrap();
14465
14466        let store = Store::open_strict(root.path()).unwrap();
14467        let err = collect_push_files(&store).unwrap_err().to_string();
14468        assert!(err.contains("cannot push"), "{err}");
14469        assert!(
14470            !err.contains("TOP SECRET"),
14471            "external bytes must never leak"
14472        );
14473
14474        std::fs::remove_file(root.path().join("records/notes/secret.md")).unwrap();
14475        let nested = root.path().join("records/nested");
14476        std::fs::create_dir_all(&nested).unwrap();
14477        std::fs::write(
14478            nested.join("DB.md"),
14479            "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
14480        )
14481        .unwrap();
14482        let err = collect_push_files(&store).unwrap_err().to_string();
14483        assert!(err.contains("nested db.md store"), "{err}");
14484    }
14485
14486    #[cfg(unix)]
14487    #[test]
14488    fn remote_push_uses_opened_root_after_path_replacement() {
14489        use std::os::unix::fs::symlink;
14490
14491        let sandbox = tempfile::tempdir().unwrap();
14492        let root = sandbox.path().join("store");
14493        std::fs::create_dir_all(root.join("records/notes")).unwrap();
14494        std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
14495        std::fs::write(
14496            root.join("records/notes/owned.md"),
14497            "---\ntype: note\nsummary: owned\n---\nowned upload\n",
14498        )
14499        .unwrap();
14500        let store = Store::open_strict(&root).unwrap();
14501        let detached = sandbox.path().join("detached");
14502        std::fs::rename(&root, &detached).unwrap();
14503
14504        let replacement = sandbox.path().join("replacement");
14505        std::fs::create_dir_all(replacement.join("records/notes")).unwrap();
14506        std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
14507        std::fs::write(
14508            replacement.join("records/notes/secret.md"),
14509            "---\ntype: note\nsummary: secret\n---\nreplacement sentinel\n",
14510        )
14511        .unwrap();
14512        symlink(&replacement, &root).unwrap();
14513
14514        let files = collect_push_files(&store).unwrap();
14515        let wire_text = files
14516            .iter()
14517            .map(|(path, content)| format!("{path}\n{content}"))
14518            .collect::<Vec<_>>()
14519            .join("\n");
14520        assert!(wire_text.contains("owned upload"));
14521        assert!(!wire_text.contains("replacement sentinel"));
14522        assert!(!wire_text.contains("records/notes/secret.md"));
14523
14524        let remote = signed_remote_fixture();
14525        let (hub, server) = scripted_json_hub(vec![
14526            (200, remote.card),
14527            (200, remote.feed),
14528            (200, json!({"ok": true}).to_string()),
14529        ]);
14530        let state = tempfile::tempdir().unwrap();
14531        let cfg = test_hub_config(hub, state.path().to_path_buf());
14532        let pushed = sync_push(&cfg, TEST_BRAIN_ID, &files).unwrap();
14533        assert_eq!(pushed, json!({"ok": true}));
14534        server.join().unwrap();
14535    }
14536
14537    #[test]
14538    fn signed_feed_item_verifies_identity_hash_and_signature() {
14539        use ring::rand::SystemRandom;
14540        use ring::signature::{Ed25519KeyPair, KeyPair};
14541
14542        const PREFIX: &[u8] = &[
14543            0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
14544        ];
14545        let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
14546        let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
14547        let mut spki = PREFIX.to_vec();
14548        spki.extend_from_slice(pair.public_key().as_ref());
14549        let public_key = URL_SAFE_NO_PAD.encode(&spki);
14550        let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
14551        let mut entry = FeedEntry {
14552            v: 1,
14553            seq: 1,
14554            ts: "2026-07-14T00:00:00.000Z".to_string(),
14555            brain: format!("ed25519:{fingerprint}"),
14556            public_key: public_key.clone(),
14557            kind: "push".to_string(),
14558            op: "snapshot".to_string(),
14559            pack_sha256: "a".repeat(64),
14560            files: vec![FeedFile {
14561                path: "DB.md".to_string(),
14562                sha256: "b".repeat(64),
14563                bytes: 3,
14564            }],
14565            removed: vec![],
14566            prev_entry_hash: None,
14567            sig: String::new(),
14568        };
14569        let unsigned = UnsignedFeedEntry {
14570            v: entry.v,
14571            seq: entry.seq,
14572            ts: &entry.ts,
14573            brain: &entry.brain,
14574            public_key: &entry.public_key,
14575            kind: &entry.kind,
14576            op: &entry.op,
14577            pack_sha256: &entry.pack_sha256,
14578            files: &entry.files,
14579            removed: &entry.removed,
14580            prev_entry_hash: &entry.prev_entry_hash,
14581        };
14582        entry.sig =
14583            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
14584        let mut exact = serde_json::to_vec(&entry).unwrap();
14585        exact.push(b'\n');
14586        let item = FeedItem {
14587            hash: format!("{:x}", Sha256::digest(&exact)),
14588            entry,
14589        };
14590        let identity = FeedIdentity {
14591            fingerprint,
14592            public_key_spki: public_key,
14593            previous: Vec::new(),
14594            rotations: Vec::new(),
14595        };
14596        assert!(verify_feed_item(&item, &identity).is_ok());
14597        let mut tampered = item;
14598        tampered.entry.pack_sha256 = "c".repeat(64);
14599        assert!(verify_feed_item(&tampered, &identity).is_err());
14600    }
14601
14602    #[test]
14603    fn v2_commit_requires_exact_fields_and_a_valid_genesis_bridge() {
14604        let rng = ring::rand::SystemRandom::new();
14605        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
14606        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
14607        let (spki, multikey) = public_identity_for(&pair);
14608        let identity = V2HeadIdentity {
14609            custody: "self".to_string(),
14610            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
14611            public_key_spki: spki.clone(),
14612            previous: Vec::new(),
14613            rotations: Vec::new(),
14614        };
14615        let unsigned = json!({
14616            "actor_ref": "a".repeat(64),
14617            "asset_root": Value::Null,
14618            "brain": multikey,
14619            "changes_sha256": "b".repeat(64),
14620            "control_revision": "c".repeat(64),
14621            "materializer": "dbmd-projection-v1",
14622            "op": "changeset",
14623            "parent_asset_root": Value::Null,
14624            "parent_commit": Value::Null,
14625            "parent_root": Value::Null,
14626            "prev_entry_hash": Value::Null,
14627            "public_key": spki,
14628            "seq": 1,
14629            "signer_epoch": 1,
14630            "state_root": "d".repeat(64),
14631            "ts": "2026-08-19T12:00:00.000Z",
14632            "v": 2,
14633            "v1_bridge": {
14634                "feed_hash": "e".repeat(64),
14635                "head_seq": 7,
14636                "pack_sha256": "f".repeat(64),
14637            },
14638        });
14639        let sign_value = |value: Value| {
14640            let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
14641            let mut object = value.as_object().unwrap().clone();
14642            object.insert(
14643                "sig".to_string(),
14644                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
14645            );
14646            crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
14647        };
14648        assert!(verified_v2_commit_object(&sign_value(unsigned.clone()), &identity).is_ok());
14649
14650        let mut extra = unsigned.clone();
14651        extra
14652            .as_object_mut()
14653            .unwrap()
14654            .insert("future".to_string(), Value::Bool(true));
14655        assert!(verified_v2_commit_object(&sign_value(extra), &identity).is_err());
14656
14657        let mut missing = unsigned.clone();
14658        missing.as_object_mut().unwrap().remove("v1_bridge");
14659        assert!(verified_v2_commit_object(&sign_value(missing), &identity).is_err());
14660
14661        let mut invalid_bridge = unsigned;
14662        invalid_bridge.as_object_mut().unwrap().insert(
14663            "v1_bridge".to_string(),
14664            json!({"feed_hash": "e".repeat(64), "head_seq": 0, "pack_sha256": "f".repeat(64)}),
14665        );
14666        assert!(verified_v2_commit_object(&sign_value(invalid_bridge), &identity).is_err());
14667    }
14668
14669    #[test]
14670    fn shared_v2_commit_bridge_vector_matches_the_typescript_signer() {
14671        let vector: Value = serde_json::from_str(include_str!(
14672            "../tests/vectors/linkmd-v2-commit-bridge.json"
14673        ))
14674        .unwrap();
14675        let identity_value = vector.get("identity").unwrap();
14676        let identity = V2HeadIdentity {
14677            custody: "self".to_string(),
14678            fingerprint: identity_value
14679                .get("fingerprint")
14680                .and_then(Value::as_str)
14681                .unwrap()
14682                .to_string(),
14683            public_key_spki: identity_value
14684                .get("public_key_spki")
14685                .and_then(Value::as_str)
14686                .unwrap()
14687                .to_string(),
14688            previous: Vec::new(),
14689            rotations: Vec::new(),
14690        };
14691        let private = URL_SAFE_NO_PAD
14692            .decode(
14693                identity_value
14694                    .get("private_key_pkcs8")
14695                    .and_then(Value::as_str)
14696                    .unwrap(),
14697            )
14698            .unwrap();
14699        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&private)
14700            .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(&private))
14701            .unwrap();
14702        let base = vector.get("body").unwrap().as_object().unwrap();
14703
14704        for item in vector.get("valid").unwrap().as_array().unwrap() {
14705            let mut body = base.clone();
14706            body.insert(
14707                "v1_bridge".to_string(),
14708                item.get("v1_bridge").unwrap().clone(),
14709            );
14710            body.insert(
14711                "sig".to_string(),
14712                item.get("signature_base64url").unwrap().clone(),
14713            );
14714            let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
14715            assert!(verified_v2_commit_object(&signed, &identity).is_ok());
14716            assert_eq!(
14717                crate::linkmd_v2::domain_hash_bytes("v2/commit", &signed).unwrap(),
14718                item.get("commit_hash").and_then(Value::as_str).unwrap()
14719            );
14720            assert_eq!(
14721                format!("{:x}", Sha256::digest(&signed)),
14722                item.get("feed_hash").and_then(Value::as_str).unwrap()
14723            );
14724        }
14725
14726        for item in vector.get("invalid").unwrap().as_array().unwrap() {
14727            let mut body = base.clone();
14728            if let Some(remove) = item.get("remove").and_then(Value::as_array) {
14729                for field in remove {
14730                    body.remove(field.as_str().unwrap());
14731                }
14732            }
14733            if let Some(set) = item.get("set").and_then(Value::as_object) {
14734                for (field, value) in set {
14735                    body.insert(field.clone(), value.clone());
14736                }
14737            }
14738            let message = crate::linkmd_v2::canonical_bytes(&Value::Object(body.clone())).unwrap();
14739            body.insert(
14740                "sig".to_string(),
14741                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
14742            );
14743            let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
14744            assert!(
14745                verified_v2_commit_object(&signed, &identity).is_err(),
14746                "accepted invalid shared vector {}",
14747                item.get("reason").and_then(Value::as_str).unwrap()
14748            );
14749        }
14750    }
14751
14752    #[test]
14753    fn shared_v2_kept_home_changeset_vector_matches_the_typescript_hub() {
14754        let vector: Value = serde_json::from_str(include_str!(
14755            "../tests/vectors/linkmd-v2-changeset-withheld.json"
14756        ))
14757        .unwrap();
14758        assert_eq!(
14759            vector.get("profile").and_then(Value::as_str),
14760            Some("link.md-v2-changeset-withheld")
14761        );
14762        let canonical =
14763            crate::linkmd_v2::canonical_bytes(vector.get("changeset").unwrap()).unwrap();
14764        let expected = STANDARD
14765            .decode(
14766                vector
14767                    .get("canonical_base64")
14768                    .and_then(Value::as_str)
14769                    .unwrap(),
14770            )
14771            .unwrap();
14772        assert_eq!(canonical, expected);
14773        assert_eq!(
14774            crate::linkmd_v2::domain_hash_bytes("v2/changeset", &canonical).unwrap(),
14775            vector.get("domain_hash").and_then(Value::as_str).unwrap()
14776        );
14777    }
14778
14779    #[test]
14780    fn v2_profile_transition_reverifies_the_exact_signed_v1_bridge() {
14781        let remote = signed_remote_fixture();
14782        let legacy: FeedResponse = serde_json::from_str(&remote.feed).unwrap();
14783        let legacy_item = legacy.entries.first().unwrap();
14784        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&remote.key.pkcs8).unwrap();
14785        let body = json!({
14786            "actor_ref": "a".repeat(64),
14787            "asset_root": Value::Null,
14788            "brain": remote.key.multikey,
14789            "changes_sha256": "b".repeat(64),
14790            "control_revision": "c".repeat(64),
14791            "materializer": "dbmd-projection-v1",
14792            "op": "changeset",
14793            "parent_asset_root": Value::Null,
14794            "parent_commit": Value::Null,
14795            "parent_root": Value::Null,
14796            "prev_entry_hash": Value::Null,
14797            "public_key": remote.key.public_key_spki,
14798            "seq": 1,
14799            "signer_epoch": 1,
14800            "state_root": "d".repeat(64),
14801            "ts": "2026-08-19T12:00:00.000Z",
14802            "v": 2,
14803            "v1_bridge": {
14804                "feed_hash": legacy_item.hash,
14805                "head_seq": legacy_item.entry.seq,
14806                "pack_sha256": legacy_item.entry.pack_sha256,
14807            },
14808        });
14809        let message = crate::linkmd_v2::canonical_bytes(&body).unwrap();
14810        let mut signed = body.as_object().unwrap().clone();
14811        signed.insert(
14812            "sig".to_string(),
14813            Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
14814        );
14815        let raw = crate::linkmd_v2::canonical_bytes(&Value::Object(signed)).unwrap();
14816        let commit_hash = crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw).unwrap();
14817        let feed_hash = content_sha256(&raw);
14818        let pointer = V2PointerBody {
14819            v: 2,
14820            brain: TEST_BRAIN_ID.to_string(),
14821            seq: 1,
14822            commit_hash: commit_hash.clone(),
14823            feed_hash: feed_hash.clone(),
14824            content_root: Some("d".repeat(64)),
14825            asset_root: None,
14826            materializer: "dbmd-projection-v1".to_string(),
14827            signer_epoch: 1,
14828            control_revision: "c".repeat(64),
14829            backup_preparation: "e".repeat(64),
14830            prior_pointer_hash: None,
14831            signed_at: "2026-08-19T12:00:00.000Z".to_string(),
14832        };
14833        let v2_page = json!({
14834            "v": 2,
14835            "head_seq": 1,
14836            "head_commit_hash": commit_hash,
14837            "head_feed_hash": feed_hash,
14838            "entries": [{
14839                "seq": 1,
14840                "commit_hash": pointer.commit_hash,
14841                "feed_hash": pointer.feed_hash,
14842                "bytes_base64": STANDARD.encode(&raw),
14843            }],
14844            "next_after": 1,
14845            "complete": true,
14846        })
14847        .to_string();
14848        let identity = V2HeadIdentity {
14849            custody: "self".to_string(),
14850            fingerprint: remote.identity.fingerprint.clone(),
14851            public_key_spki: remote.identity.public_key_spki.clone(),
14852            previous: Vec::new(),
14853            rotations: Vec::new(),
14854        };
14855        let checkpoint = TrustState {
14856            v: 2,
14857            origin: "unused".to_string(),
14858            requested: TEST_BRAIN_ID.to_string(),
14859            brain: TEST_BRAIN_ID.to_string(),
14860            home: None,
14861            anchor: remote.key.multikey.clone(),
14862            current: remote.key.multikey,
14863            head_seq: legacy_item.entry.seq,
14864            feed_hash: Some(legacy_item.hash.clone()),
14865            rotations: Vec::new(),
14866            hub_signer: None,
14867            protocol_profile: None,
14868        };
14869        let (hub, server) = scripted_json_hub(vec![(200, v2_page), (200, remote.feed)]);
14870        let state = tempfile::tempdir().unwrap();
14871        let cfg = test_hub_config(hub, state.path().to_path_buf());
14872        verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &checkpoint).unwrap();
14873        server.join().unwrap();
14874
14875        let mut wrong = checkpoint;
14876        wrong.feed_hash = Some("0".repeat(64));
14877        let (hub, server) = scripted_json_hub(vec![(
14878            200,
14879            json!({
14880                "v": 2,
14881                "head_seq": 1,
14882                "head_commit_hash": pointer.commit_hash,
14883                "head_feed_hash": pointer.feed_hash,
14884                "entries": [{
14885                    "seq": 1,
14886                    "commit_hash": pointer.commit_hash,
14887                    "feed_hash": pointer.feed_hash,
14888                    "bytes_base64": STANDARD.encode(&raw),
14889                }],
14890                "next_after": 1,
14891                "complete": true,
14892            })
14893            .to_string(),
14894        )]);
14895        let state = tempfile::tempdir().unwrap();
14896        let cfg = test_hub_config(hub, state.path().to_path_buf());
14897        assert!(verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &wrong,).is_err());
14898        server.join().unwrap();
14899    }
14900
14901    #[test]
14902    fn v2_commit_signer_epoch_follows_the_authenticated_rotation_boundary() {
14903        let rng = ring::rand::SystemRandom::new();
14904        let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
14905        let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
14906        let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
14907        let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
14908        let (old_spki, old_multikey) = public_identity_for(&old);
14909        let (new_spki, new_multikey) = public_identity_for(&new);
14910        let rotation_unsigned = serde_json::to_string(&UnsignedRotation {
14911            v: 1,
14912            op: "rotate",
14913            brain: &old_multikey,
14914            public_key: &old_spki,
14915            new_brain: &new_multikey,
14916            new_public_key: &new_spki,
14917            prior_head_seq: 1,
14918            prior_feed_hash: Some(&"9".repeat(64)),
14919            ts: "2026-08-19T12:01:00.000Z".to_string(),
14920        })
14921        .unwrap();
14922        let rotation_sig = URL_SAFE_NO_PAD.encode(old.sign(rotation_unsigned.as_bytes()).as_ref());
14923        let rotation = format!(
14924            "{},\"sig\":\"{}\"}}",
14925            &rotation_unsigned[..rotation_unsigned.len() - 1],
14926            rotation_sig
14927        );
14928        let identity = V2HeadIdentity {
14929            custody: "self".to_string(),
14930            fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
14931            public_key_spki: new_spki.clone(),
14932            previous: vec![V2PreviousIdentity {
14933                fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
14934                public_key_spki: old_spki.clone(),
14935            }],
14936            rotations: vec![rotation],
14937        };
14938        let commit = |seq: u64,
14939                      epoch: u64,
14940                      multikey: &str,
14941                      spki: &str,
14942                      pair: &ring::signature::Ed25519KeyPair| {
14943            let value = json!({
14944                "actor_ref": "a".repeat(64),
14945                "asset_root": Value::Null,
14946                "brain": multikey,
14947                "changes_sha256": "b".repeat(64),
14948                "control_revision": "c".repeat(64),
14949                "materializer": "dbmd-projection-v1",
14950                "op": "changeset",
14951                "parent_asset_root": Value::Null,
14952                "parent_commit": if seq == 1 { Value::Null } else { Value::String("d".repeat(64)) },
14953                "parent_root": if seq == 1 { Value::Null } else { Value::String("e".repeat(64)) },
14954                "prev_entry_hash": if seq == 1 { Value::Null } else { Value::String("f".repeat(64)) },
14955                "public_key": spki,
14956                "seq": seq,
14957                "signer_epoch": epoch,
14958                "state_root": "1".repeat(64),
14959                "ts": "2026-08-19T12:00:00.000Z",
14960                "v": 2,
14961                "v1_bridge": Value::Null,
14962            });
14963            let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
14964            let mut object = value.as_object().unwrap().clone();
14965            object.insert(
14966                "sig".to_string(),
14967                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
14968            );
14969            crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
14970        };
14971
14972        assert!(verified_v2_commit_object(
14973            &commit(1, 1, &old_multikey, &old_spki, &old),
14974            &identity,
14975        )
14976        .is_ok());
14977        assert!(verified_v2_commit_object(
14978            &commit(2, 2, &new_multikey, &new_spki, &new),
14979            &identity,
14980        )
14981        .is_ok());
14982        assert!(verified_v2_commit_object(
14983            &commit(2, 1, &old_multikey, &old_spki, &old),
14984            &identity,
14985        )
14986        .is_err());
14987        assert!(verified_v2_commit_object(
14988            &commit(1, 2, &new_multikey, &new_spki, &new),
14989            &identity,
14990        )
14991        .is_err());
14992    }
14993
14994    #[test]
14995    fn a_self_custody_entry_verifies_like_any_hub_entry() {
14996        let rng = ring::rand::SystemRandom::new();
14997        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
14998        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
14999        let (spki, multikey) = public_identity_for(&pair);
15000        let key = AgentSigningKey {
15001            pkcs8: pkcs8.as_ref().to_vec(),
15002            multikey: multikey.clone(),
15003            public_key_spki: spki.clone(),
15004        };
15005        let files = vec![WireFeedFile {
15006            path: "DB.md".to_string(),
15007            sha256: "a".repeat(64),
15008            bytes: 3,
15009        }];
15010        let raw = self_custody_entry(
15011            &key,
15012            1,
15013            "2026-07-23T12:00:00.000Z".to_string(),
15014            &"c".repeat(64),
15015            &files,
15016            None,
15017        )
15018        .unwrap();
15019        // The exact client serialization parses as a feed entry and passes the
15020        // SAME verifier every subscribe read runs — the self-custody path
15021        // produces first-class wire-profile-v1 entries.
15022        let entry: FeedEntry = serde_json::from_str(&raw).unwrap();
15023        let hash = format!("{:x}", Sha256::digest(format!("{raw}\n").as_bytes()));
15024        let item = FeedItem { hash, entry };
15025        let identity = FeedIdentity {
15026            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
15027            public_key_spki: spki,
15028            previous: Vec::new(),
15029            rotations: Vec::new(),
15030        };
15031        assert!(verify_feed_item(&item, &identity).is_ok());
15032    }
15033
15034    #[test]
15035    fn identity_rotation_requires_old_key_signature_and_preserves_the_pin() {
15036        let rng = ring::rand::SystemRandom::new();
15037        let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15038        let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
15039        let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15040        let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
15041        let (old_spki, old_multikey) = public_identity_for(&old);
15042        let (new_spki, new_multikey) = public_identity_for(&new);
15043        let unsigned = serde_json::to_string(&UnsignedRotation {
15044            v: 1,
15045            op: "rotate",
15046            brain: &old_multikey,
15047            public_key: &old_spki,
15048            new_brain: &new_multikey,
15049            new_public_key: &new_spki,
15050            prior_head_seq: 1,
15051            prior_feed_hash: Some(&"a".repeat(64)),
15052            ts: "2026-07-30T12:00:00.000Z".to_string(),
15053        })
15054        .unwrap();
15055        let signature = URL_SAFE_NO_PAD.encode(old.sign(unsigned.as_bytes()).as_ref());
15056        let rotation = format!(
15057            "{},\"sig\":\"{}\"}}",
15058            &unsigned[..unsigned.len() - 1],
15059            signature
15060        );
15061        let identity = FeedIdentity {
15062            fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
15063            public_key_spki: new_spki,
15064            previous: vec![PreviousIdentity {
15065                fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
15066                public_key_spki: old_spki,
15067            }],
15068            rotations: vec![rotation],
15069        };
15070        let pin = TrustState {
15071            v: 2,
15072            origin: "https://hub.example".to_string(),
15073            requested: "brain".to_string(),
15074            brain: "brain".to_string(),
15075            home: None,
15076            anchor: old_multikey.clone(),
15077            current: old_multikey.clone(),
15078            head_seq: 1,
15079            feed_hash: Some("a".repeat(64)),
15080            rotations: Vec::new(),
15081            hub_signer: None,
15082            protocol_profile: None,
15083        };
15084        assert_eq!(
15085            verify_identity_chain(&identity, Some(&pin)).unwrap(),
15086            old_multikey
15087        );
15088        let mut accepted = pin.clone();
15089        accepted.current = new_multikey.clone();
15090        accepted.rotations = identity.rotations.clone();
15091        let alternate_unsigned = serde_json::to_string(&UnsignedRotation {
15092            v: 1,
15093            op: "rotate",
15094            brain: &old_multikey,
15095            public_key: &identity.previous[0].public_key_spki,
15096            new_brain: &new_multikey,
15097            new_public_key: &identity.public_key_spki,
15098            prior_head_seq: 1,
15099            prior_feed_hash: Some(&"a".repeat(64)),
15100            ts: "2026-07-30T12:00:01.000Z".to_string(),
15101        })
15102        .unwrap();
15103        let alternate_signature =
15104            URL_SAFE_NO_PAD.encode(old.sign(alternate_unsigned.as_bytes()).as_ref());
15105        let mut rewritten = identity.clone();
15106        rewritten.rotations[0] = format!(
15107            "{},\"sig\":\"{}\"}}",
15108            &alternate_unsigned[..alternate_unsigned.len() - 1],
15109            alternate_signature
15110        );
15111        assert!(
15112            verify_identity_chain(&rewritten, Some(&accepted)).is_err(),
15113            "an alternate valid statement must not rewrite accepted history"
15114        );
15115
15116        let mut stale_entry = FeedEntry {
15117            v: 1,
15118            seq: 2,
15119            ts: "2026-07-30T12:01:00.000Z".to_string(),
15120            brain: pin.current.clone(),
15121            public_key: identity.previous[0].public_key_spki.clone(),
15122            kind: "push".to_string(),
15123            op: "snapshot".to_string(),
15124            pack_sha256: "b".repeat(64),
15125            files: Vec::new(),
15126            removed: Vec::new(),
15127            prev_entry_hash: pin.feed_hash.clone(),
15128            sig: String::new(),
15129        };
15130        let stale_unsigned = UnsignedFeedEntry {
15131            v: stale_entry.v,
15132            seq: stale_entry.seq,
15133            ts: &stale_entry.ts,
15134            brain: &stale_entry.brain,
15135            public_key: &stale_entry.public_key,
15136            kind: &stale_entry.kind,
15137            op: &stale_entry.op,
15138            pack_sha256: &stale_entry.pack_sha256,
15139            files: &stale_entry.files,
15140            removed: &stale_entry.removed,
15141            prev_entry_hash: &stale_entry.prev_entry_hash,
15142        };
15143        stale_entry.sig = URL_SAFE_NO_PAD.encode(
15144            old.sign(&serde_json::to_vec(&stale_unsigned).unwrap())
15145                .as_ref(),
15146        );
15147        let mut stale_exact = serde_json::to_vec(&stale_entry).unwrap();
15148        stale_exact.push(b'\n');
15149        let stale_item = FeedItem {
15150            hash: content_sha256(&stale_exact),
15151            entry: stale_entry,
15152        };
15153        assert!(
15154            reject_retired_signer_after_checkpoint(&identity, Some(&accepted), &stale_item)
15155                .is_err(),
15156            "a key retired before the checkpoint must never append after it"
15157        );
15158        assert!(
15159            verify_feed_item(&stale_item, &identity).is_err(),
15160            "an old key must never append after its signed rotation boundary"
15161        );
15162
15163        let mut missing = identity.clone();
15164        missing.rotations.clear();
15165        assert!(verify_identity_chain(&missing, Some(&pin)).is_err());
15166
15167        let mut tampered = identity;
15168        tampered.rotations[0] = tampered.rotations[0].replace("\"sig\":\"", "\"sig\":\"A");
15169        assert!(verify_identity_chain(&tampered, Some(&pin)).is_err());
15170    }
15171
15172    #[cfg(unix)]
15173    #[test]
15174    fn key_creation_refuses_a_planted_symlink_without_touching_its_target() {
15175        use std::os::unix::fs::symlink;
15176
15177        let dir = tempfile::tempdir().unwrap();
15178        let target = dir.path().join("valuable.txt");
15179        let planted = dir.path().join("agent.key");
15180        std::fs::write(&target, "do not overwrite").unwrap();
15181        symlink(&target, &planted).unwrap();
15182
15183        assert!(matches!(
15184            generate_agent_key(&planted),
15185            Err(LinkError::BadAgentKey { .. })
15186        ));
15187        assert_eq!(std::fs::read_to_string(target).unwrap(), "do not overwrite");
15188    }
15189
15190    #[cfg(unix)]
15191    #[test]
15192    fn key_creation_refuses_a_symlinked_parent_without_writing_through_it() {
15193        use std::os::unix::fs::symlink;
15194
15195        let root = tempfile::tempdir().unwrap();
15196        let outside = tempfile::tempdir().unwrap();
15197        symlink(outside.path(), root.path().join("redirect")).unwrap();
15198
15199        assert!(generate_agent_key(&root.path().join("redirect/agent.key")).is_err());
15200        assert!(!outside.path().join("agent.key").exists());
15201    }
15202
15203    // ── Address parsing ─────────────────────────────────────────────────────
15204
15205    #[test]
15206    fn address_bare_brain_with_and_without_sigil() {
15207        for raw in ["@acme-ops", "acme-ops"] {
15208            let a = Address::parse(raw).expect(raw);
15209            assert_eq!(a.brain, "acme-ops");
15210            assert_eq!(a.target, None);
15211        }
15212    }
15213
15214    #[test]
15215    fn address_ulid_target_parses_as_id() {
15216        let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
15217        assert_eq!(a.brain, "acme");
15218        assert_eq!(
15219            a.target,
15220            Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
15221        );
15222    }
15223
15224    #[test]
15225    fn address_md_path_target_parses_as_path() {
15226        let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
15227        assert_eq!(
15228            a.target,
15229            Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
15230        );
15231    }
15232
15233    #[test]
15234    fn address_rejects_malformed_forms() {
15235        for raw in [
15236            "",
15237            "@",
15238            "@/x",
15239            "@acme/",
15240            "@acme/../etc/passwd",
15241            "@acme/records/.hidden.md",
15242            "@ACME",             // uppercase is not a hub ref shape
15243            "@acme/notes/x.txt", // target is neither ULID nor .md path
15244            "@a b",              // whitespace
15245        ] {
15246            assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
15247        }
15248    }
15249
15250    // ── Path safety ─────────────────────────────────────────────────────────
15251
15252    #[test]
15253    fn safe_paths_accept_store_shapes_and_reject_escapes() {
15254        for ok in [
15255            "DB.md",
15256            "assets.jsonl",
15257            "records/clients/lumio.md",
15258            "sources/emails/2026/07/x.md",
15259        ] {
15260            assert!(safe_store_rel_path(ok), "should accept {ok:?}");
15261        }
15262        for bad in [
15263            "",
15264            "/etc/passwd",
15265            "../up.md",
15266            "records/../../up.md",
15267            "records//x.md",
15268            ".dbmd/config",
15269            "records/.hidden/x.md",
15270            "records/a b.md",
15271            "records\\win.md",
15272        ] {
15273            assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
15274        }
15275    }
15276
15277    #[cfg(unix)]
15278    #[test]
15279    fn opened_destination_capability_survives_an_ancestor_path_swap() {
15280        use std::os::unix::fs::symlink;
15281
15282        let work = tempfile::tempdir().unwrap();
15283        let outside = tempfile::tempdir().unwrap();
15284        let original = work.path().join("destination");
15285        let moved = work.path().join("destination-moved");
15286        let directory = open_or_create_dir_nofollow(&original).unwrap();
15287
15288        std::fs::rename(&original, &moved).unwrap();
15289        symlink(outside.path(), &original).unwrap();
15290        write_pull_entries_beneath_dir(
15291            &directory,
15292            &[("records/note.md".to_string(), b"held inode".to_vec())],
15293        )
15294        .unwrap();
15295
15296        assert_eq!(
15297            std::fs::read(moved.join("records/note.md")).unwrap(),
15298            b"held inode"
15299        );
15300        assert!(!outside.path().join("records/note.md").exists());
15301    }
15302
15303    // ── Config resolution (flag + file precedence; env is covered by the CLI
15304    //    integration tests, where a child process isolates it) ───────────────
15305
15306    #[test]
15307    fn hub_config_flag_beats_file_and_requires_some_source() {
15308        let dir = tempfile::tempdir().unwrap();
15309        std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
15310        std::fs::write(
15311            dir.path().join(CONFIG_REL_PATH),
15312            "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
15313        )
15314        .unwrap();
15315
15316        let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
15317        assert_eq!(from_flag.hub, "https://flag.example.com");
15318
15319        let from_file = hub_config(None, dir.path()).unwrap();
15320        assert_eq!(from_file.hub, "https://file.example.com");
15321
15322        let none = hub_config(None, tempfile::tempdir().unwrap().path());
15323        assert!(matches!(none, Err(LinkError::NoHub)));
15324    }
15325
15326    #[test]
15327    fn https_guard_allows_loopback_only_for_plain_http() {
15328        assert!(assert_safe_hub("https://hub.example.com").is_ok());
15329        assert!(assert_safe_hub("http://localhost:3000").is_ok());
15330        assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
15331        assert!(assert_safe_hub("http://[::1]:3000").is_ok());
15332        assert!(matches!(
15333            assert_safe_hub("http://hub.example.com"),
15334            Err(LinkError::UnsafeHub { .. })
15335        ));
15336        assert!(matches!(
15337            assert_safe_hub("hub.example.com"),
15338            Err(LinkError::UnsafeHub { .. })
15339        ));
15340        assert!(matches!(
15341            assert_safe_hub("http://localhost:80@127.0.0.1:1"),
15342            Err(LinkError::UnsafeHub { .. })
15343        ));
15344        assert!(matches!(
15345            assert_safe_hub("https://hub.example.com@attacker.example"),
15346            Err(LinkError::UnsafeHub { .. })
15347        ));
15348        assert!(matches!(
15349            assert_safe_hub("https://hub.example.com/base"),
15350            Err(LinkError::UnsafeHub { .. })
15351        ));
15352    }
15353
15354    #[test]
15355    fn registry_ssrf_classifier_rejects_local_private_and_documentation_ranges() {
15356        for blocked in [
15357            "127.0.0.1",
15358            "10.0.0.1",
15359            "100.64.0.1",
15360            "169.254.169.254",
15361            "172.16.0.1",
15362            "192.168.0.1",
15363            "192.88.99.1",
15364            "198.18.0.1",
15365            "203.0.113.1",
15366            "::1",
15367            "fe80::1",
15368            "fd00::1",
15369            "2001:db8::1",
15370            "2001:1::1",
15371            "2002:7f00:1::",
15372            "3fff::1",
15373        ] {
15374            assert!(
15375                !is_public_registry_ip(blocked.parse().unwrap()),
15376                "must block {blocked}"
15377            );
15378        }
15379        assert!(is_public_registry_ip("1.1.1.1".parse().unwrap()));
15380        assert!(is_public_registry_ip(
15381            "2606:4700:4700::1111".parse().unwrap()
15382        ));
15383        assert!(is_public_registry_ip("3fff:1000::1".parse().unwrap()));
15384    }
15385
15386    #[test]
15387    fn registry_dns_answer_is_pinned_and_cannot_rebind_or_change_authority() {
15388        use ureq::Resolver as _;
15389
15390        let pinned: std::net::SocketAddr = "1.1.1.1:443".parse().unwrap();
15391        let resolver = PinnedRegistryResolver {
15392            netloc: "home.example:443".to_string(),
15393            addresses: vec![pinned],
15394        };
15395        assert_eq!(resolver.resolve("home.example:443").unwrap(), vec![pinned]);
15396        assert!(resolver.resolve("127.0.0.1:443").is_err());
15397        assert_eq!(
15398            resolver.resolve("home.example:443").unwrap(),
15399            vec![pinned],
15400            "subsequent connects reuse the validated answer instead of DNS"
15401        );
15402    }
15403
15404    #[test]
15405    fn production_object_urls_and_store_selected_hubs_cannot_reach_private_ips() {
15406        let cfg = HubConfig {
15407            hub: "https://hub.example".to_string(),
15408            key: None,
15409            agent_key: None,
15410            brain_key: None,
15411            state_dir: tempfile::tempdir().unwrap().keep(),
15412            store_selected: false,
15413        };
15414        assert!(
15415            presigned_agent(&cfg, "https://127.0.0.1/private").is_err(),
15416            "a production hub must not turn its presigned URL into an SSRF primitive"
15417        );
15418
15419        let store_selected = HubConfig {
15420            hub: "https://127.0.0.1".to_string(),
15421            store_selected: true,
15422            ..cfg
15423        };
15424        assert!(
15425            hub_agent(&store_selected).is_err(),
15426            "bytes in a cloned store must not select a private-network hub"
15427        );
15428    }
15429
15430    #[test]
15431    fn presigned_download_ceiling_cannot_overflow_at_u64_max() {
15432        assert_eq!(
15433            one_past_bounded_limit(MAX_PACK_BYTES),
15434            Some(MAX_PACK_BYTES + 1),
15435            "the presigned reader consumes exactly one refusal byte beyond its fixed pack cap"
15436        );
15437        assert_eq!(
15438            presigned_download_read_limit(),
15439            MAX_PACK_BYTES + 1,
15440            "the presigned reader is capped by the client constant, not a hub response"
15441        );
15442        assert_eq!(
15443            one_past_bounded_limit(u64::MAX),
15444            None,
15445            "the former attacker-controlled u64::MAX + 1 shape must fail without overflow"
15446        );
15447    }
15448
15449    #[test]
15450    fn https_guard_matches_the_scheme_case_insensitively() {
15451        // RFC 3986 schemes are case-insensitive: an uppercase-scheme HTTPS
15452        // hub is still HTTPS, never a misleading non-HTTPS refusal.
15453        assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
15454        assert!(assert_safe_hub("Https://hub.example.com").is_ok());
15455        // And an uppercase plain-HTTP hub is still refused outside loopback.
15456        assert!(matches!(
15457            assert_safe_hub("HTTP://hub.example.com"),
15458            Err(LinkError::UnsafeHub { .. })
15459        ));
15460    }
15461
15462    #[test]
15463    fn clean_key_refuses_paste_artifacts_without_echoing() {
15464        assert_eq!(clean_key("  vc_account_abc  ").unwrap(), "vc_account_abc");
15465        for bad in ["vc account", "vc\naccount", "ключ", ""] {
15466            let err = clean_key(bad).unwrap_err();
15467            assert!(matches!(err, LinkError::BadKey));
15468            assert!(
15469                !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
15470                "error must not echo the key"
15471            );
15472        }
15473    }
15474
15475    // ── Verb entry gates: refs must never reshape the request path ──────────
15476
15477    /// A config whose hub passes the loopback guard but is never listened on:
15478    /// every refusal below must come from the entry gate BEFORE a request
15479    /// exists — a dial on this dead port would surface `Transport` instead.
15480    fn dead_hub() -> HubConfig {
15481        HubConfig {
15482            hub: "http://127.0.0.1:9".to_string(),
15483            key: Some("k".to_string()),
15484            agent_key: None,
15485            brain_key: None,
15486            state_dir: PathBuf::from("."),
15487            store_selected: false,
15488        }
15489    }
15490
15491    #[test]
15492    fn request_retries_a_connection_failure_before_sending() {
15493        use std::io::{Read as _, Write as _};
15494        use std::net::TcpListener;
15495        use std::thread;
15496        use std::time::Duration;
15497
15498        let probe = TcpListener::bind("127.0.0.1:0").unwrap();
15499        let address = probe.local_addr().unwrap();
15500        drop(probe);
15501        let server = thread::spawn(move || {
15502            thread::sleep(Duration::from_millis(40));
15503            let listener = TcpListener::bind(address).unwrap();
15504            let (mut stream, _) = listener.accept().unwrap();
15505            let mut request_bytes = [0_u8; 1024];
15506            let _ = stream.read(&mut request_bytes).unwrap();
15507            stream
15508                .write_all(
15509                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
15510                )
15511                .unwrap();
15512        });
15513        let cfg = HubConfig {
15514            hub: format!("http://{address}"),
15515            key: None,
15516            agent_key: None,
15517            brain_key: None,
15518            state_dir: tempfile::tempdir().unwrap().keep(),
15519            store_selected: false,
15520        };
15521
15522        let response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
15523        assert_eq!(response.status, 200);
15524        assert_eq!(response.body, Some(json!({ "ok": true })));
15525        server.join().unwrap();
15526    }
15527
15528    #[test]
15529    fn endpoint_cap_refuses_a_body_before_json_parsing() {
15530        let (hub, server) = scripted_json_hub(vec![(200, "x".repeat(2_048))]);
15531        let cfg = HubConfig {
15532            hub,
15533            key: None,
15534            agent_key: None,
15535            brain_key: None,
15536            state_dir: tempfile::tempdir().unwrap().keep(),
15537            store_selected: false,
15538        };
15539
15540        assert!(matches!(
15541            request_capped(&cfg, "GET", "/bounded", None, Auth::None, 1_024),
15542            Err(LinkError::ResponseTooLarge { .. })
15543        ));
15544        server.join().unwrap();
15545    }
15546
15547    #[test]
15548    fn overall_deadline_stops_a_dribbled_response_body() {
15549        use std::io::{Read as _, Write as _};
15550        use std::net::TcpListener;
15551        use std::time::{Duration, Instant};
15552
15553        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15554        let url = format!("http://{}/dribble", listener.local_addr().unwrap());
15555        let server = std::thread::spawn(move || {
15556            let (mut stream, _) = listener.accept().unwrap();
15557            let mut request = [0_u8; 1024];
15558            let _ = stream.read(&mut request);
15559            stream
15560                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 32\r\nConnection: close\r\n\r\n")
15561                .unwrap();
15562            for byte in [b'x'; 32] {
15563                if stream.write_all(&[byte]).is_err() {
15564                    break;
15565                }
15566                std::thread::sleep(Duration::from_millis(40));
15567            }
15568        });
15569        let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
15570        let started = Instant::now();
15571        let response = http.get(&url).call().unwrap();
15572        let mut body = Vec::new();
15573        let error = response
15574            .into_reader()
15575            .read_to_end(&mut body)
15576            .expect_err("per-read progress must not reset the overall deadline");
15577        assert!(
15578            started.elapsed() < Duration::from_millis(700),
15579            "dribbled body exceeded the wall-clock budget: {error}"
15580        );
15581        server.join().unwrap();
15582    }
15583
15584    #[test]
15585    fn overall_deadline_stops_a_stalled_upload() {
15586        use std::net::TcpListener;
15587        use std::time::{Duration, Instant};
15588
15589        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15590        let url = format!("http://{}/upload", listener.local_addr().unwrap());
15591        let server = std::thread::spawn(move || {
15592            let (_stream, _) = listener.accept().unwrap();
15593            // Never consume the request body. Once the kernel send buffer fills,
15594            // the client must leave on its absolute write deadline.
15595            std::thread::sleep(Duration::from_millis(600));
15596        });
15597        let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
15598        let body = vec![0x5a; 32 * 1024 * 1024];
15599        let started = Instant::now();
15600        let error = http
15601            .put(&url)
15602            .send_bytes(&body)
15603            .expect_err("stalled request-body writes must time out");
15604        assert!(
15605            started.elapsed() < Duration::from_millis(700),
15606            "stalled upload exceeded the wall-clock budget: {error}"
15607        );
15608        server.join().unwrap();
15609    }
15610
15611    #[test]
15612    fn verb_entry_gates_accept_the_hub_ref_shapes() {
15613        for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
15614            assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
15615            assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
15616            assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
15617        }
15618    }
15619
15620    #[test]
15621    fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
15622        let cfg = dead_hub();
15623        for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
15624            assert!(
15625                matches!(
15626                    sync_pull(&cfg, bad, None),
15627                    Err(LinkError::BadAddress { .. })
15628                ),
15629                "sync_pull must refuse {bad:?}"
15630            );
15631            assert!(
15632                matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
15633                "sync_push must refuse {bad:?}"
15634            );
15635            assert!(
15636                matches!(
15637                    grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
15638                    Err(LinkError::BadAddress { .. })
15639                ),
15640                "grant_issue must refuse {bad:?}"
15641            );
15642            assert!(
15643                matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
15644                "grant_list must refuse {bad:?}"
15645            );
15646            assert!(
15647                matches!(
15648                    grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
15649                    Err(LinkError::BadAddress { .. })
15650                ),
15651                "grant_revoke must refuse brain {bad:?}"
15652            );
15653            assert!(
15654                matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
15655                "head must refuse {bad:?}"
15656            );
15657        }
15658    }
15659
15660    #[test]
15661    fn grant_revoke_refuses_url_reshaping_grant_ids() {
15662        let cfg = dead_hub();
15663        for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
15664            assert!(
15665                matches!(
15666                    grant_revoke(&cfg, "acme", bad),
15667                    Err(LinkError::BadGrantId { .. })
15668                ),
15669                "grant_revoke must refuse grant id {bad:?}"
15670            );
15671        }
15672    }
15673
15674    #[test]
15675    fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
15676        let cfg = dead_hub();
15677        for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
15678            assert!(
15679                matches!(
15680                    propose(&cfg, bad, "intake", "hi"),
15681                    Err(LinkError::BadAddress { .. })
15682                ),
15683                "propose must refuse handle {bad:?}"
15684            );
15685        }
15686        let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
15687        assert!(matches!(
15688            propose(&cfg, "acme-site", "intake", &oversize),
15689            Err(LinkError::ProposeTooLarge { .. })
15690        ));
15691        // A clean handle + in-cap body passes both gates: the failure is now
15692        // the (dead) wire, proving the gates refuse shape, not the verb.
15693        assert!(matches!(
15694            propose(&cfg, "acme-site", "intake", "hi"),
15695            Err(LinkError::Transport { .. })
15696        ));
15697    }
15698
15699    #[test]
15700    fn resolve_refuses_a_hand_built_unsafe_address() {
15701        let cfg = dead_hub();
15702        for brain in ["../up", "a/b", "a?x", "a#f"] {
15703            let addr = Address {
15704                brain: brain.to_string(),
15705                target: None,
15706            };
15707            assert!(
15708                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
15709                "resolve must refuse brain {brain:?}"
15710            );
15711        }
15712        for target in [
15713            AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
15714            AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), // not the minted shape
15715            AddressTarget::Path("../up.md".to_string()),
15716            AddressTarget::Path("records/x.md#frag".to_string()),
15717        ] {
15718            let addr = Address {
15719                brain: "acme".to_string(),
15720                target: Some(target.clone()),
15721            };
15722            assert!(
15723                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
15724                "resolve must refuse target {target:?}"
15725            );
15726        }
15727    }
15728
15729    #[test]
15730    fn v2_final_barrier_refuses_to_advance_a_remote_ahead_checkout() {
15731        let mut local = std::collections::BTreeMap::new();
15732        local.insert("records/a.md".to_string(), ("a".repeat(64), 0));
15733        local.insert("records/b.md".to_string(), ("b".repeat(64), 0));
15734        let mut remote = std::collections::BTreeMap::new();
15735        remote.insert(
15736            "records/a.md".to_string(),
15737            V2BaselineFile {
15738                sha256: "c".repeat(64),
15739                bytes: 1,
15740                proof: None,
15741            },
15742        );
15743        remote.insert(
15744            "records/b.md".to_string(),
15745            V2BaselineFile {
15746                sha256: "b".repeat(64),
15747                bytes: 1,
15748                proof: None,
15749            },
15750        );
15751        assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
15752    }
15753
15754    #[test]
15755    fn v2_final_barrier_ignores_only_explicit_kept_home_paths() {
15756        let local = std::collections::BTreeMap::new();
15757        let mut remote = std::collections::BTreeMap::new();
15758        remote.insert(
15759            "private/local.md".to_string(),
15760            V2BaselineFile {
15761                sha256: "d".repeat(64),
15762                bytes: 1,
15763                proof: None,
15764            },
15765        );
15766        assert!(v2_riding_matches_remote(&local, &remote, |path| path == "private/local.md"));
15767        assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
15768    }
15769
15770    fn scoped_test_head(revision: &str) -> V2VerifiedHead {
15771        V2VerifiedHead {
15772            requested: TEST_BRAIN_ID.to_string(),
15773            brain_id: TEST_BRAIN_ID.to_string(),
15774            view_kind: "scoped".to_string(),
15775            view_revision: revision.to_string(),
15776            control_revision: revision.to_string(),
15777            identity: V2HeadIdentity {
15778                custody: "hub".to_string(),
15779                fingerprint: "test".to_string(),
15780                public_key_spki: "test".to_string(),
15781                previous: Vec::new(),
15782                rotations: Vec::new(),
15783            },
15784            pointer: None,
15785            trust: TrustState {
15786                v: 2,
15787                origin: "https://hub.example".to_string(),
15788                requested: TEST_BRAIN_ID.to_string(),
15789                brain: TEST_BRAIN_ID.to_string(),
15790                home: None,
15791                anchor: "ed25519:test".to_string(),
15792                current: "ed25519:test".to_string(),
15793                head_seq: 0,
15794                feed_hash: None,
15795                rotations: Vec::new(),
15796                hub_signer: None,
15797                protocol_profile: Some("link-v2".to_string()),
15798            },
15799            alias: None,
15800        }
15801    }
15802
15803    #[test]
15804    fn accepted_v2_checkpoint_never_downgrades_after_profile_migration() {
15805        let mut trust = scoped_test_head(&"a".repeat(64)).trust;
15806        assert!(accepted_as_v2(&trust));
15807
15808        trust.protocol_profile = None;
15809        trust.hub_signer = Some("ed25519:hub".to_string());
15810        assert!(accepted_as_v2(&trust));
15811
15812        trust.hub_signer = None;
15813        assert!(!accepted_as_v2(&trust));
15814    }
15815
15816    fn scoped_test_baseline(revision: &str) -> V2SyncBaseline {
15817        V2SyncBaseline {
15818            v: 2,
15819            origin: "https://hub.example".to_string(),
15820            brain: TEST_BRAIN_ID.to_string(),
15821            checkout_id: Some("c".repeat(64)),
15822            head_seq: Some(0),
15823            commit_hash: None,
15824            content_root: None,
15825            asset_root: None,
15826            assets: std::collections::BTreeMap::new(),
15827            view_kind: Some("scoped".to_string()),
15828            view_revision: Some(revision.to_string()),
15829            projection_sha256: Some(scoped_projection_sha256(TEST_BRAIN_ID)),
15830            files: std::collections::BTreeMap::new(),
15831            local_policy_digest: None,
15832            local_eligibility: std::collections::BTreeMap::new(),
15833            remote_copy_remains: std::collections::BTreeMap::new(),
15834        }
15835    }
15836
15837    #[test]
15838    fn scoped_projection_is_a_valid_local_store_marker_but_never_rides() {
15839        let directory = tempfile::tempdir().unwrap();
15840        std::fs::write(
15841            directory.path().join("DB.md"),
15842            scoped_projection_bytes(TEST_BRAIN_ID),
15843        )
15844        .unwrap();
15845        let store = Store::open_strict(directory.path()).unwrap();
15846        let head = scoped_test_head(&"a".repeat(64));
15847        let baseline = scoped_test_baseline(&"a".repeat(64));
15848        let mut view = v2_local_files(&store).unwrap();
15849        remove_scoped_projection(&head, Some(&baseline), &mut view).unwrap();
15850        assert!(!view.riding.contains_key("DB.md"));
15851        assert!(!view.eligibility.contains_key("DB.md"));
15852    }
15853
15854    #[test]
15855    fn scoped_push_accepts_a_pull_verified_handoff_without_double_checking_projection() {
15856        let directory = tempfile::tempdir().unwrap();
15857        std::fs::write(
15858            directory.path().join("DB.md"),
15859            scoped_projection_bytes(TEST_BRAIN_ID),
15860        )
15861        .unwrap();
15862        let store = Store::open_strict(directory.path()).unwrap();
15863        let head = scoped_test_head(&"a".repeat(64));
15864        let baseline = scoped_test_baseline(&"a".repeat(64));
15865
15866        let mut carried = v2_local_files(&store).unwrap();
15867        remove_scoped_projection(&head, Some(&baseline), &mut carried).unwrap();
15868        let handed_off =
15869            local_view_for_v2_push(&store, &head, Some(&baseline), Some(carried)).unwrap();
15870        assert!(!handed_off.riding.contains_key("DB.md"));
15871
15872        let freshly_scanned = local_view_for_v2_push(&store, &head, Some(&baseline), None).unwrap();
15873        assert!(!freshly_scanned.riding.contains_key("DB.md"));
15874
15875        std::fs::write(
15876            directory.path().join("DB.md"),
15877            b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
15878        )
15879        .unwrap();
15880        let tampered = Store::open_strict(directory.path()).unwrap();
15881        assert!(matches!(
15882            local_view_for_v2_push(&tampered, &head, Some(&baseline), None),
15883            Err(LinkError::ScopedProjectionModified)
15884        ));
15885    }
15886
15887    #[test]
15888    fn v2_local_view_reports_only_exact_linked_kept_home_markdown() {
15889        let directory = tempfile::tempdir().unwrap();
15890        std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
15891        std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
15892        std::fs::write(
15893            directory.path().join("DB.md"),
15894            b"---\nname: Kept home test\n---\n",
15895        )
15896        .unwrap();
15897        std::fs::write(
15898            directory.path().join("records/notes/a.md"),
15899            b"---\ntype: note\n---\nSee [[sources/private/secret]].\n",
15900        )
15901        .unwrap();
15902        std::fs::write(
15903            directory.path().join("sources/private/secret.md"),
15904            b"---\ntype: note\n---\nlocal only\n",
15905        )
15906        .unwrap();
15907        std::fs::write(
15908            directory.path().join("sources/private/unlinked.md"),
15909            b"---\ntype: note\n---\nnot disclosed\n",
15910        )
15911        .unwrap();
15912        std::fs::write(
15913            directory.path().join(".sevralocal"),
15914            b"sources/private/**\n",
15915        )
15916        .unwrap();
15917
15918        let store = Store::open_strict(directory.path()).unwrap();
15919        let view = v2_local_files(&store).unwrap();
15920        assert!(!view.riding.contains_key("sources/private/secret.md"));
15921        assert_eq!(
15922            view.withheld_links,
15923            vec![V2WithheldLink {
15924                source: "records/notes/a.md".to_string(),
15925                target: "sources/private/secret.md".to_string(),
15926            }]
15927        );
15928    }
15929
15930    #[test]
15931    fn explicit_content_withdrawal_requires_exact_current_kept_home_bytes() {
15932        let directory = tempfile::tempdir().unwrap();
15933        std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
15934        std::fs::write(
15935            directory.path().join("DB.md"),
15936            b"---\nname: Withdrawal test\n---\n",
15937        )
15938        .unwrap();
15939        let source = b"---\ntype: note\n---\nlocal evidence\n";
15940        std::fs::write(directory.path().join("sources/private/evidence.md"), source).unwrap();
15941        std::fs::write(
15942            directory.path().join(".sevralocal"),
15943            b"sources/private/**\n",
15944        )
15945        .unwrap();
15946        let store = Store::open_strict(directory.path()).unwrap();
15947        let view = v2_local_files(&store).unwrap();
15948        let mut remote = std::collections::BTreeMap::new();
15949        remote.insert(
15950            "sources/private/evidence.md".to_string(),
15951            V2BaselineFile {
15952                sha256: content_sha256(source),
15953                bytes: source.len() as u64,
15954                proof: None,
15955            },
15956        );
15957        assert_eq!(
15958            v2_content_withdrawal_operation(
15959                &store,
15960                &view,
15961                &remote,
15962                "sources/private/evidence.md",
15963                "approved retention change",
15964            )
15965            .unwrap(),
15966            json!({
15967                "op": "withdraw_from_hosting",
15968                "path": "sources/private/evidence.md",
15969                "expected": { "kind": "blob", "hash": content_sha256(source) },
15970                "reason": "approved retention change",
15971            })
15972        );
15973
15974        std::fs::write(
15975            directory.path().join("sources/private/evidence.md"),
15976            b"changed after review",
15977        )
15978        .unwrap();
15979        assert!(matches!(
15980            v2_content_withdrawal_operation(
15981                &store,
15982                &view,
15983                &remote,
15984                "sources/private/evidence.md",
15985                "approved retention change",
15986            ),
15987            Err(LinkError::InvalidPack { .. })
15988        ));
15989    }
15990
15991    #[test]
15992    fn explicit_asset_withdrawal_requires_the_exact_hosted_leaf_and_local_bytes() {
15993        let directory = tempfile::tempdir().unwrap();
15994        std::fs::create_dir_all(directory.path().join("sources/files")).unwrap();
15995        std::fs::write(
15996            directory.path().join("DB.md"),
15997            b"---\nname: Asset withdrawal test\n---\n",
15998        )
15999        .unwrap();
16000        let bytes = b"private binary";
16001        std::fs::write(directory.path().join("sources/files/private.pdf"), bytes).unwrap();
16002        std::fs::write(
16003            directory.path().join(".sevralocal"),
16004            b"sources/files/private.pdf\n",
16005        )
16006        .unwrap();
16007        let store = Store::open_strict(directory.path()).unwrap();
16008        let view = v2_local_files(&store).unwrap();
16009        let local = crate::AssetRecord {
16010            path: "sources/files/private.pdf".to_string(),
16011            sha256: content_sha256(bytes),
16012            bytes: bytes.len() as u64,
16013            media_type: "application/pdf".to_string(),
16014            wrappers: vec!["sources/files/private.md".to_string()],
16015            required: true,
16016        };
16017        let current = V2BaselineAsset {
16018            blob_sha256: local.sha256.clone(),
16019            bytes: local.bytes,
16020            media_type: local.media_type.clone(),
16021            wrappers: local.wrappers.clone(),
16022            required: local.required,
16023            disposition: "hosted".to_string(),
16024            leaf_hash: "d".repeat(64),
16025        };
16026        assert_eq!(
16027            v2_asset_withdrawal_operation(
16028                &store,
16029                &view,
16030                &local.path,
16031                &local,
16032                &current,
16033                "approved retention change",
16034            )
16035            .unwrap(),
16036            json!({
16037                "op": "asset_withdraw",
16038                "path": local.path,
16039                "expected": { "kind": "asset", "hash": "d".repeat(64) },
16040                "reason": "approved retention change",
16041            })
16042        );
16043
16044        let mut mismatched = current.clone();
16045        mismatched.required = false;
16046        assert!(matches!(
16047            v2_asset_withdrawal_operation(
16048                &store,
16049                &view,
16050                &local.path,
16051                &local,
16052                &mismatched,
16053                "approved retention change",
16054            ),
16055            Err(LinkError::InvalidPack { .. })
16056        ));
16057    }
16058
16059    #[test]
16060    fn checkout_pseudonym_is_random_then_stable_from_private_baseline() {
16061        let first = v2_checkout_id(None).unwrap();
16062        assert_eq!(first, v2_checkout_id(Some(&first)).unwrap());
16063        assert_ne!(first, v2_checkout_id(None).unwrap());
16064        assert!(is_sha256(&first));
16065    }
16066
16067    #[test]
16068    fn scoped_projection_edit_and_scope_transition_fail_closed() {
16069        let directory = tempfile::tempdir().unwrap();
16070        std::fs::write(
16071            directory.path().join("DB.md"),
16072            b"---\ntype: db-md\nscope: company\nowner: someone\n---\n",
16073        )
16074        .unwrap();
16075        let store = Store::open_strict(directory.path()).unwrap();
16076        let head = scoped_test_head(&"a".repeat(64));
16077        let baseline = scoped_test_baseline(&"a".repeat(64));
16078        let mut view = v2_local_files(&store).unwrap();
16079        assert!(matches!(
16080            remove_scoped_projection(&head, Some(&baseline), &mut view),
16081            Err(LinkError::ScopedProjectionModified)
16082        ));
16083
16084        let changed = scoped_test_head(&"b".repeat(64));
16085        assert!(matches!(
16086            ensure_v2_view_compatible(&changed, Some(&baseline)),
16087            Err(LinkError::ScopedViewChanged)
16088        ));
16089
16090        let mut same_view_new_control = head.clone();
16091        same_view_new_control.control_revision = "c".repeat(64);
16092        assert!(ensure_v2_view_compatible(&same_view_new_control, Some(&baseline)).is_ok());
16093        assert!(!same_v2_head(&head, &same_view_new_control));
16094    }
16095
16096    #[test]
16097    fn established_checkout_never_becomes_an_empty_clone_when_db_md_is_invalid() {
16098        let scoped = scoped_test_head(&"a".repeat(64));
16099        let scoped_baseline = scoped_test_baseline(&"a".repeat(64));
16100        assert!(matches!(
16101            ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), false),
16102            Err(LinkError::ScopedProjectionModified)
16103        ));
16104
16105        let mut full = scoped.clone();
16106        full.view_kind = "full".to_string();
16107        let mut full_baseline = scoped_baseline.clone();
16108        full_baseline.view_kind = Some("full".to_string());
16109        full_baseline.projection_sha256 = None;
16110        assert!(matches!(
16111            ensure_established_v2_checkout_opened(&full, Some(&full_baseline), false),
16112            Err(LinkError::InvalidPack { .. })
16113        ));
16114
16115        assert!(ensure_established_v2_checkout_opened(&scoped, None, false).is_ok());
16116        assert!(
16117            ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), true).is_ok()
16118        );
16119    }
16120
16121    #[test]
16122    fn scoped_view_metadata_is_explicitly_non_authoritative() {
16123        let head = scoped_test_head(&"a".repeat(64));
16124        let value: Value =
16125            serde_json::from_slice(&scoped_view_metadata(&head, 7).unwrap()).unwrap();
16126        assert_eq!(value["kind"], "link.md-scoped-view");
16127        assert_eq!(value["authoritative"], false);
16128        assert_eq!(value["visible_files"], 7);
16129        assert_eq!(value["brain"], TEST_BRAIN_ID);
16130    }
16131
16132    #[test]
16133    fn local_scoped_marker_requires_the_exact_generated_projection() {
16134        let directory = tempfile::tempdir().unwrap();
16135        std::fs::create_dir(directory.path().join(".dbmd")).unwrap();
16136        std::fs::write(
16137            directory.path().join("DB.md"),
16138            scoped_projection_bytes(TEST_BRAIN_ID),
16139        )
16140        .unwrap();
16141        let head = scoped_test_head(&"a".repeat(64));
16142        std::fs::write(
16143            directory.path().join(".dbmd/view.json"),
16144            scoped_view_metadata(&head, 0).unwrap(),
16145        )
16146        .unwrap();
16147        let store = Store::open_strict(directory.path()).unwrap();
16148        assert!(has_verified_local_scoped_view(&store));
16149
16150        std::fs::write(
16151            directory.path().join("DB.md"),
16152            b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
16153        )
16154        .unwrap();
16155        let altered = Store::open_strict(directory.path()).unwrap();
16156        assert!(!has_verified_local_scoped_view(&altered));
16157    }
16158
16159    fn signed_proposal_fixture() -> (V2VerifiedHead, String, Value) {
16160        use ring::signature::KeyPair as _;
16161
16162        let proposal_id = "01k2r7bm9w5x6e8nq3tjhv4cya".to_string();
16163        let rng = ring::rand::SystemRandom::new();
16164        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16165        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16166        let public_der = [ED25519_SPKI_PREFIX.as_slice(), pair.public_key().as_ref()].concat();
16167        let public_key = URL_SAFE_NO_PAD.encode(&public_der);
16168        let fingerprint = format!("{:x}", Sha256::digest(&public_der));
16169        let blob = b"new";
16170        let blob_hash = content_sha256(blob);
16171        let changes = json!({
16172            "mutation_id": "sync:proposal-fixture",
16173            "operations": [{
16174                "blob": blob_hash,
16175                "bytes": blob.len(),
16176                "expected": null,
16177                "op": "put",
16178                "path": "records/new.md",
16179            }],
16180            "reason": "fixture",
16181            "v": 2,
16182        });
16183        let changes_bytes = crate::linkmd_v2::canonical_bytes(&changes).unwrap();
16184        let changes_base64 = STANDARD.encode(&changes_bytes);
16185        let descriptor = json!({
16186            "base": null,
16187            "blobs": [{ "bytes": blob.len(), "sha256": blob_hash }],
16188            "changes_base64": changes_base64,
16189            "rebase": "strict",
16190            "v": 2,
16191        });
16192        let clear_hash = content_sha256(&crate::linkmd_v2::canonical_bytes(&descriptor).unwrap());
16193        let payload_hash = "b".repeat(64);
16194        let submitted_at = "2026-08-19T12:00:00.000Z";
16195        let claim = json!({
16196            "actor_root": {
16197                "actor_class": "foreign_key",
16198                "credential": "ed25519:fixture",
16199                "grants": ["01k2r7bm9w5x6e8nq3tjhv4cyb"],
16200                "organization": "01k2r7bm9w5x6e8nq3tjhv4cyc",
16201                "principal": "key:fixture",
16202                "role": null,
16203            },
16204            "brain": TEST_BRAIN_ID,
16205            "clear_sha256": clear_hash,
16206            "control_revision": "c".repeat(64),
16207            "mutation_id": "sync:proposal-fixture",
16208            "payload_sha256": payload_hash,
16209            "proposal_id": proposal_id,
16210            "submitted_at": submitted_at,
16211            "v": 2,
16212        });
16213        let claim_bytes = crate::linkmd_v2::canonical_bytes(&claim).unwrap();
16214        let envelope = json!({
16215            "claim": claim,
16216            "fingerprint": fingerprint,
16217            "public_key": public_key,
16218            "sig": URL_SAFE_NO_PAD.encode(pair.sign(&claim_bytes).as_ref()),
16219        });
16220        let envelope_bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
16221        let submission_hash =
16222            crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &envelope_bytes).unwrap();
16223        let mut head = scoped_test_head(&"c".repeat(64));
16224        head.view_kind = "full".to_string();
16225        head.trust.hub_signer = Some(format!("{fingerprint}:{public_key}"));
16226        let value = json!({
16227            "proposal": {
16228                "base": null,
16229                "blobs": [{
16230                    "bytes": blob.len(),
16231                    "endpoint": format!(
16232                        "/api/hub/brains/{TEST_BRAIN_ID}/v2/proposals/{proposal_id}/blob?sha256={blob_hash}"
16233                    ),
16234                    "sha256": blob_hash,
16235                }],
16236                "changes_base64": changes_base64,
16237                "clear_sha256": clear_hash,
16238                "expires_at": "2026-08-26T12:00:00.000Z",
16239                "id": proposal_id,
16240                "payload_sha256": payload_hash,
16241                "proposer": { "class": "foreign_key" },
16242                "rebase": "strict",
16243                "state": "pending",
16244                "submission_claim_base64": STANDARD.encode(envelope_bytes),
16245                "submission_claim_sha256": submission_hash,
16246                "submitted_at": submitted_at,
16247            },
16248            "v": 2,
16249        });
16250        (head, proposal_id, value)
16251    }
16252
16253    #[test]
16254    fn v2_proposal_verifier_accepts_exact_signed_payload() {
16255        let (head, proposal_id, value) = signed_proposal_fixture();
16256        let verified = verify_v2_proposal_value(&head, &proposal_id, value).unwrap();
16257        assert_eq!(verified.blobs.len(), 1);
16258        assert_eq!(verified.changes["operations"][0]["path"], "records/new.md");
16259    }
16260
16261    #[test]
16262    fn v2_proposal_verifier_refuses_payload_endpoint_and_signature_tampering() {
16263        let (head, proposal_id, value) = signed_proposal_fixture();
16264
16265        let mut changed = value.clone();
16266        changed["proposal"]["changes_base64"] = Value::String(STANDARD.encode(b"{}"));
16267        assert!(verify_v2_proposal_value(&head, &proposal_id, changed).is_err());
16268
16269        let mut redirected = value.clone();
16270        redirected["proposal"]["blobs"][0]["endpoint"] =
16271            Value::String("https://attacker.example/blob".to_string());
16272        assert!(verify_v2_proposal_value(&head, &proposal_id, redirected).is_err());
16273
16274        let mut forged = value;
16275        let encoded = forged["proposal"]["submission_claim_base64"]
16276            .as_str()
16277            .unwrap();
16278        let mut envelope: Value =
16279            serde_json::from_slice(&STANDARD.decode(encoded).unwrap()).unwrap();
16280        envelope["sig"] = Value::String(URL_SAFE_NO_PAD.encode([0_u8; 64]));
16281        let bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
16282        forged["proposal"]["submission_claim_base64"] = Value::String(STANDARD.encode(&bytes));
16283        forged["proposal"]["submission_claim_sha256"] = Value::String(
16284            crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &bytes).unwrap(),
16285        );
16286        assert!(verify_v2_proposal_value(&head, &proposal_id, forged).is_err());
16287    }
16288
16289    #[cfg(unix)]
16290    #[test]
16291    fn v2_atomic_install_materializes_only_local_catalogs_before_swap() {
16292        let sandbox = tempfile::tempdir().unwrap();
16293        let destination = sandbox.path().join("brain");
16294        let entries = vec![
16295            (
16296                "DB.md".to_string(),
16297                scoped_projection_bytes(TEST_BRAIN_ID),
16298            ),
16299            (
16300                "records/contacts/a.md".to_string(),
16301                b"---\ntype: contact\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Visible contact\n---\n\n# A\n"
16302                    .to_vec(),
16303            ),
16304        ];
16305        install_pulled_delta(&destination, &entries, &[], true).unwrap();
16306        assert!(destination.join("index.md").is_file());
16307        assert!(destination.join("records/index.md").is_file());
16308        assert!(destination.join("records/contacts/index.md").is_file());
16309        assert!(destination.join("records/contacts/index.jsonl").is_file());
16310    }
16311
16312    #[cfg(unix)]
16313    #[test]
16314    fn v2_bounded_parallel_source_install_is_exact_and_atomic() {
16315        let sandbox = tempfile::tempdir().unwrap();
16316        let destination = sandbox.path().join("brain");
16317        let cache = sandbox.path().join("cache");
16318        std::fs::create_dir(&cache).unwrap();
16319        let db = scoped_projection_bytes(TEST_BRAIN_ID);
16320        let shared = b"---\ntype: note\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Shared\n---\n\n# Shared\n";
16321        let db_source = cache.join("db");
16322        let shared_source = cache.join("shared");
16323        crate::fsx::write_atomic(&db_source, &db).unwrap();
16324        crate::fsx::write_atomic(&shared_source, shared).unwrap();
16325        let mut entries = vec![V2StagedFile {
16326            path: "DB.md".to_string(),
16327            source: db_source,
16328            sha256: content_sha256(&db),
16329            bytes: db.len() as u64,
16330        }];
16331        for index in 0..512 {
16332            entries.push(V2StagedFile {
16333                path: format!("records/items/{index:05}.md"),
16334                source: shared_source.clone(),
16335                sha256: content_sha256(shared),
16336                bytes: shared.len() as u64,
16337            });
16338        }
16339        install_pulled_delta_sources(
16340            &destination,
16341            &entries,
16342            &[],
16343            false,
16344            None,
16345            &scoped_test_head(&"c".repeat(64)),
16346        )
16347        .unwrap();
16348        assert_eq!(std::fs::read(destination.join("DB.md")).unwrap(), db);
16349        for index in 0..512 {
16350            assert_eq!(
16351                std::fs::read(destination.join(format!("records/items/{index:05}.md"))).unwrap(),
16352                shared
16353            );
16354        }
16355        assert!(
16356            std::fs::read_dir(sandbox.path())
16357                .unwrap()
16358                .all(|entry| !entry
16359                    .unwrap()
16360                    .file_name()
16361                    .to_string_lossy()
16362                    .contains("pull-stage")),
16363            "the private stage must be atomically installed or removed"
16364        );
16365    }
16366
16367    #[cfg(unix)]
16368    #[test]
16369    fn v2_established_pull_touches_only_delta_and_rolls_back_exactly() {
16370        use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
16371
16372        let sandbox = tempfile::tempdir().unwrap();
16373        let root = sandbox.path().join("brain");
16374        std::fs::create_dir_all(root.join("records/items")).unwrap();
16375        let db = scoped_projection_bytes(TEST_BRAIN_ID);
16376        let old = b"---\ntype: note\n---\n\nold\n";
16377        let new = b"---\ntype: note\n---\n\nnew\n";
16378        let removed = b"---\ntype: note\n---\n\nremove me\n";
16379        std::fs::write(root.join("DB.md"), &db).unwrap();
16380        std::fs::write(root.join("records/items/change.md"), old).unwrap();
16381        std::fs::write(root.join("records/items/delete.md"), removed).unwrap();
16382        for index in 0..512 {
16383            std::fs::write(
16384                root.join(format!("records/items/untouched-{index:04}.md")),
16385                old,
16386            )
16387            .unwrap();
16388        }
16389        let untouched = root.join("records/items/untouched-0256.md");
16390        let untouched_inode = std::fs::metadata(&untouched).unwrap().ino();
16391        let source = sandbox.path().join("changed-source");
16392        crate::fsx::write_atomic(&source, new).unwrap();
16393        let same_source = sandbox.path().join("unchanged-source");
16394        crate::fsx::write_atomic(&same_source, old).unwrap();
16395        let same_entry = V2StagedFile {
16396            path: "records/items/change.md".to_string(),
16397            source: same_source,
16398            sha256: content_sha256(old),
16399            bytes: old.len() as u64,
16400        };
16401        let entry = V2StagedFile {
16402            path: "records/items/change.md".to_string(),
16403            source,
16404            sha256: content_sha256(new),
16405            bytes: new.len() as u64,
16406        };
16407        let head = scoped_test_head(&"c".repeat(64));
16408
16409        // A no-op established pull neither clones the checkout nor arms a
16410        // journal. Inode identity is a direct regression guard against the
16411        // former brain-wide private-copy implementation.
16412        install_established_v2_delta(
16413            Store::open_strict(&root).unwrap(),
16414            &[same_entry],
16415            &["records/items/already-absent.md".to_string()],
16416            true,
16417            None,
16418            &head,
16419        )
16420        .unwrap();
16421        assert_eq!(
16422            std::fs::metadata(&untouched).unwrap().ino(),
16423            untouched_inode
16424        );
16425        assert!(!root.join(V2_PULL_JOURNAL).exists());
16426
16427        install_established_v2_delta(
16428            Store::open_strict(&root).unwrap(),
16429            &[entry],
16430            &["records/items/delete.md".to_string()],
16431            false,
16432            None,
16433            &head,
16434        )
16435        .unwrap();
16436        assert_eq!(
16437            std::fs::read(root.join("records/items/change.md")).unwrap(),
16438            new
16439        );
16440        assert!(!root.join("records/items/delete.md").exists());
16441        assert_eq!(
16442            std::fs::metadata(&untouched).unwrap().ino(),
16443            untouched_inode
16444        );
16445        assert!(root.join(V2_PULL_JOURNAL).is_file());
16446        assert_eq!(
16447            std::fs::metadata(root.join(V2_PULL_JOURNAL))
16448                .unwrap()
16449                .permissions()
16450                .mode()
16451                & 0o777,
16452            0o600
16453        );
16454        let journal = load_v2_pull_journal(&Store::open_strict(&root).unwrap())
16455            .unwrap()
16456            .unwrap();
16457        assert_eq!(
16458            std::fs::metadata(root.join(&journal.backup_dir))
16459                .unwrap()
16460                .permissions()
16461                .mode()
16462                & 0o777,
16463            0o700
16464        );
16465        for entry in &journal.entries {
16466            if let Some(backup) = &entry.backup {
16467                assert_eq!(
16468                    std::fs::metadata(root.join(&journal.backup_dir).join(backup))
16469                        .unwrap()
16470                        .permissions()
16471                        .mode()
16472                        & 0o777,
16473                    0o600
16474                );
16475            }
16476        }
16477
16478        let cfg = test_hub_config(
16479            "https://example.test".to_string(),
16480            sandbox.path().join("state"),
16481        );
16482        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
16483        assert_eq!(
16484            std::fs::read(root.join("records/items/change.md")).unwrap(),
16485            old
16486        );
16487        assert_eq!(
16488            std::fs::read(root.join("records/items/delete.md")).unwrap(),
16489            removed
16490        );
16491        assert_eq!(
16492            std::fs::metadata(&untouched).unwrap().ino(),
16493            untouched_inode
16494        );
16495        assert!(!root.join(V2_PULL_JOURNAL).exists());
16496    }
16497
16498    #[test]
16499    fn v2_bulk_stream_is_exact_ordered_and_tamper_evident() {
16500        let body = b"bounded bytes";
16501        let path = "records/example.md".to_string();
16502        let file = V2BaselineFile {
16503            sha256: content_sha256(body),
16504            bytes: body.len() as u64,
16505            proof: None,
16506        };
16507        let header = serde_json::to_vec(&json!({
16508            "bytes": body.len(),
16509            "path": path,
16510            "sha256": file.sha256,
16511            "v": 2,
16512        }))
16513        .unwrap();
16514        let mut stream = V2_BULK_STREAM_MAGIC.to_vec();
16515        stream.extend_from_slice(&(header.len() as u32).to_be_bytes());
16516        stream.extend_from_slice(&header);
16517        stream.extend_from_slice(body);
16518        stream.extend_from_slice(&0_u32.to_be_bytes());
16519        let parsed = parse_v2_bulk_stream(&stream, &[(&path, &file)]).unwrap();
16520        assert_eq!(parsed, vec![(path.clone(), body.to_vec())]);
16521
16522        let mut tampered = stream.clone();
16523        let body_offset = V2_BULK_STREAM_MAGIC.len() + 4 + header.len();
16524        tampered[body_offset] ^= 1;
16525        assert!(parse_v2_bulk_stream(&tampered, &[(&path, &file)]).is_err());
16526
16527        let mut trailing = stream;
16528        trailing.push(0);
16529        assert!(parse_v2_bulk_stream(&trailing, &[(&path, &file)]).is_err());
16530    }
16531
16532    #[test]
16533    fn conflict_cache_prunes_only_expired_or_incomplete_state_by_default() {
16534        let sandbox = tempfile::TempDir::new().unwrap();
16535        let root = sandbox.path().join("brain");
16536        std::fs::create_dir_all(&root).unwrap();
16537        std::fs::write(
16538            root.join("DB.md"),
16539            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
16540        )
16541        .unwrap();
16542        let store = Store::open_strict(&root).unwrap();
16543        let incomplete = crate::ulid::mint();
16544        store
16545            .create_dir_all(&v2_conflict_relative(&incomplete, "files"))
16546            .unwrap();
16547        let expired = crate::ulid::mint();
16548        store
16549            .create_dir_all(&v2_conflict_relative(&expired, "files"))
16550            .unwrap();
16551        let plan = V2ConflictPlan {
16552            v: 2,
16553            class: "content_resolution_required".to_string(),
16554            bundle: expired.clone(),
16555            brain: TEST_BRAIN_ID.to_string(),
16556            origin: "https://example.test".to_string(),
16557            created_unix: 0,
16558            expires_unix: 0,
16559            base_seq: None,
16560            base_commit: None,
16561            remote_seq: 0,
16562            remote_commit: None,
16563            remote_content_root: None,
16564            view_kind: "full".to_string(),
16565            view_revision: "a".repeat(64),
16566            files: vec![V2ConflictFile {
16567                path: "records/value.md".to_string(),
16568                base: V2ConflictCoordinate {
16569                    sha256: None,
16570                    bytes: None,
16571                    file: None,
16572                },
16573                local: V2ConflictCoordinate {
16574                    sha256: None,
16575                    bytes: None,
16576                    file: None,
16577                },
16578                remote: V2ConflictCoordinate {
16579                    sha256: None,
16580                    bytes: None,
16581                    file: None,
16582                },
16583            }],
16584        };
16585        let mut bytes = serde_json::to_vec(&plan).unwrap();
16586        bytes.push(b'\n');
16587        store
16588            .write_atomic_new(&v2_conflict_relative(&expired, "plan.json"), &bytes)
16589            .unwrap();
16590
16591        let listed = sync_conflicts(&root, false, false).unwrap();
16592        assert_eq!(listed["bundles"], 2);
16593        assert_eq!(listed["pruned"], 0);
16594        let pruned = sync_conflicts(&root, true, false).unwrap();
16595        assert_eq!(pruned["bundles"], 0);
16596        assert_eq!(pruned["pruned"], 2);
16597        assert!(!root.join(".dbmd/conflicts").join(incomplete).exists());
16598        assert!(!root.join(".dbmd/conflicts").join(expired).exists());
16599    }
16600
16601    #[test]
16602    fn corrupt_completed_conflict_state_requires_explicit_discard_all() {
16603        let sandbox = tempfile::TempDir::new().unwrap();
16604        let root = sandbox.path().join("brain");
16605        std::fs::create_dir_all(&root).unwrap();
16606        std::fs::write(
16607            root.join("DB.md"),
16608            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
16609        )
16610        .unwrap();
16611        let store = Store::open_strict(&root).unwrap();
16612        let bundle = crate::ulid::mint();
16613        store
16614            .create_dir_all(&v2_conflict_relative(&bundle, "files"))
16615            .unwrap();
16616        store
16617            .write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), b"not-json\n")
16618            .unwrap();
16619
16620        assert!(sync_conflicts(&root, true, false).is_err());
16621        assert!(sync_conflicts(&root, false, true).is_err());
16622        let pruned = sync_conflicts(&root, true, true).unwrap();
16623        assert_eq!(pruned["pruned"], 1);
16624        assert!(!root.join(".dbmd/conflicts").join(bundle).exists());
16625    }
16626
16627    #[test]
16628    fn ready_pull_journal_rolls_back_exact_preimages() {
16629        let sandbox = tempfile::TempDir::new().unwrap();
16630        let root = sandbox.path().join("brain");
16631        std::fs::create_dir_all(root.join("records")).unwrap();
16632        std::fs::write(
16633            root.join("DB.md"),
16634            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
16635        )
16636        .unwrap();
16637        let path = "records/value.md";
16638        let old = b"---\ntype: note\n---\n\nold\n";
16639        let new = b"---\ntype: note\n---\n\nnew\n";
16640        std::fs::write(root.join(path), old).unwrap();
16641        let store = Store::open_strict(&root).unwrap();
16642        let bundle = crate::ulid::mint();
16643        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
16644        store
16645            .create_private_dir_all(Path::new(&backup_dir))
16646            .unwrap();
16647        store
16648            .write_private_atomic_new(&Path::new(&backup_dir).join("00000000"), old)
16649            .unwrap();
16650        let journal = V2PullJournal {
16651            v: 1,
16652            phase: V2PullPhase::Ready,
16653            brain: TEST_BRAIN_ID.to_string(),
16654            previous: V2PullCoordinate {
16655                head_seq: None,
16656                commit_hash: None,
16657                view_kind: None,
16658                view_revision: None,
16659            },
16660            next: V2PullCoordinate {
16661                head_seq: Some(2),
16662                commit_hash: Some("c".repeat(64)),
16663                view_kind: Some("full".to_string()),
16664                view_revision: Some("d".repeat(64)),
16665            },
16666            backup_dir: backup_dir.clone(),
16667            entries: vec![V2PullJournalEntry {
16668                path: path.to_string(),
16669                old: Some(V2PullFileCoordinate {
16670                    sha256: content_sha256(old),
16671                    bytes: old.len() as u64,
16672                }),
16673                new: Some(V2PullFileCoordinate {
16674                    sha256: content_sha256(new),
16675                    bytes: new.len() as u64,
16676                }),
16677                backup: Some("00000000".to_string()),
16678            }],
16679        };
16680        validate_v2_pull_journal(&journal).unwrap();
16681        store
16682            .write_private_atomic_new(
16683                Path::new(V2_PULL_JOURNAL),
16684                &v2_pull_journal_bytes(&journal).unwrap(),
16685            )
16686            .unwrap();
16687        store.write_atomic(Path::new(path), new).unwrap();
16688
16689        let cfg = test_hub_config(
16690            "https://example.test".to_string(),
16691            sandbox.path().join("state"),
16692        );
16693        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
16694        assert_eq!(std::fs::read(root.join(path)).unwrap(), old);
16695        assert!(!root.join(V2_PULL_JOURNAL).exists());
16696        assert!(!root.join(backup_dir).exists());
16697    }
16698
16699    #[test]
16700    fn preparing_pull_journal_discards_only_private_staging() {
16701        let sandbox = tempfile::TempDir::new().unwrap();
16702        let root = sandbox.path().join("brain");
16703        std::fs::create_dir_all(root.join(".dbmd")).unwrap();
16704        std::fs::write(
16705            root.join("DB.md"),
16706            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
16707        )
16708        .unwrap();
16709        let store = Store::open_strict(&root).unwrap();
16710        let bundle = crate::ulid::mint();
16711        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
16712        store
16713            .create_private_dir_all(Path::new(&backup_dir))
16714            .unwrap();
16715        let journal = V2PullJournal {
16716            v: 1,
16717            phase: V2PullPhase::Preparing,
16718            brain: TEST_BRAIN_ID.to_string(),
16719            previous: V2PullCoordinate {
16720                head_seq: None,
16721                commit_hash: None,
16722                view_kind: None,
16723                view_revision: None,
16724            },
16725            next: V2PullCoordinate {
16726                head_seq: Some(1),
16727                commit_hash: Some("a".repeat(64)),
16728                view_kind: Some("full".to_string()),
16729                view_revision: Some("b".repeat(64)),
16730            },
16731            backup_dir: backup_dir.clone(),
16732            entries: vec![V2PullJournalEntry {
16733                path: "records/new.md".to_string(),
16734                old: None,
16735                new: Some(V2PullFileCoordinate {
16736                    sha256: "c".repeat(64),
16737                    bytes: 1,
16738                }),
16739                backup: None,
16740            }],
16741        };
16742        store
16743            .write_private_atomic_new(
16744                Path::new(V2_PULL_JOURNAL),
16745                &v2_pull_journal_bytes(&journal).unwrap(),
16746            )
16747            .unwrap();
16748        let cfg = test_hub_config(
16749            "https://example.test".to_string(),
16750            sandbox.path().join("state"),
16751        );
16752
16753        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
16754
16755        assert!(root.join("DB.md").is_file());
16756        assert!(!root.join(V2_PULL_JOURNAL).exists());
16757        assert!(!root.join(backup_dir).exists());
16758    }
16759
16760    #[test]
16761    fn committed_baseline_keeps_installed_bytes_and_prunes_orphans() {
16762        let sandbox = tempfile::TempDir::new().unwrap();
16763        let root = sandbox.path().join("brain");
16764        std::fs::create_dir_all(root.join("records")).unwrap();
16765        std::fs::write(
16766            root.join("DB.md"),
16767            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
16768        )
16769        .unwrap();
16770        let new = b"---\ntype: note\n---\n\nnew\n";
16771        std::fs::write(root.join("records/value.md"), new).unwrap();
16772        let store = Store::open_strict(&root).unwrap();
16773        let bundle = crate::ulid::mint();
16774        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
16775        store
16776            .create_private_dir_all(Path::new(&backup_dir))
16777            .unwrap();
16778        let orphan = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
16779        store.create_private_dir_all(Path::new(&orphan)).unwrap();
16780        let next = V2PullCoordinate {
16781            head_seq: Some(2),
16782            commit_hash: Some("c".repeat(64)),
16783            view_kind: Some("full".to_string()),
16784            view_revision: Some("d".repeat(64)),
16785        };
16786        let journal = V2PullJournal {
16787            v: 1,
16788            phase: V2PullPhase::Ready,
16789            brain: TEST_BRAIN_ID.to_string(),
16790            previous: V2PullCoordinate {
16791                head_seq: Some(1),
16792                commit_hash: Some("a".repeat(64)),
16793                view_kind: Some("full".to_string()),
16794                view_revision: Some("b".repeat(64)),
16795            },
16796            next: next.clone(),
16797            backup_dir: backup_dir.clone(),
16798            entries: vec![V2PullJournalEntry {
16799                path: "records/value.md".to_string(),
16800                old: Some(V2PullFileCoordinate {
16801                    sha256: "e".repeat(64),
16802                    bytes: new.len() as u64,
16803                }),
16804                new: Some(V2PullFileCoordinate {
16805                    sha256: content_sha256(new),
16806                    bytes: new.len() as u64,
16807                }),
16808                backup: Some("00000000".to_string()),
16809            }],
16810        };
16811        store
16812            .write_private_atomic_new(
16813                Path::new(V2_PULL_JOURNAL),
16814                &v2_pull_journal_bytes(&journal).unwrap(),
16815            )
16816            .unwrap();
16817        let cfg = test_hub_config(
16818            "https://example.test".to_string(),
16819            sandbox.path().join("state"),
16820        );
16821        save_v2_baseline(
16822            &cfg,
16823            TEST_BRAIN_ID,
16824            &root,
16825            &V2SyncBaseline {
16826                v: 2,
16827                origin: "https://example.test".to_string(),
16828                brain: TEST_BRAIN_ID.to_string(),
16829                checkout_id: Some("c".repeat(64)),
16830                head_seq: next.head_seq,
16831                commit_hash: next.commit_hash.clone(),
16832                content_root: Some("f".repeat(64)),
16833                asset_root: None,
16834                assets: Default::default(),
16835                view_kind: next.view_kind.clone(),
16836                view_revision: next.view_revision.clone(),
16837                projection_sha256: None,
16838                files: Default::default(),
16839                local_policy_digest: None,
16840                local_eligibility: Default::default(),
16841                remote_copy_remains: Default::default(),
16842            },
16843        )
16844        .unwrap();
16845
16846        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
16847
16848        assert_eq!(std::fs::read(root.join("records/value.md")).unwrap(), new);
16849        assert!(!root.join(V2_PULL_JOURNAL).exists());
16850        assert!(!root.join(backup_dir).exists());
16851        assert!(!root.join(orphan).exists());
16852    }
16853}