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/// An upload reservation body carries one entry per changed blob, so a real
145/// store's first push does not fit in a single request: the hub bounds the body
146/// and the hosting platform rejects an oversized payload before the route sees
147/// it. Reservations are therefore batched. The budget is deliberately well
148/// under the hub's own ceiling, and both bounds are enforced because a single
149/// hash can carry many coordinates and a path may be long.
150/// The count matches the reservation window a hub grants per request; batching
151/// above it only fails deeper in, after the body has already been accepted.
152const MAX_UPLOAD_RESERVATION_BYTES: usize = 1024 * 1024;
153const MAX_UPLOAD_RESERVATION_BLOBS: usize = 1_000;
154
155/// A legitimate brain should rotate rarely. Bound adversarial identity
156/// histories before allocating and repeatedly verifying an unbounded chain.
157const MAX_IDENTITY_ROTATIONS: usize = 1_024;
158
159/// Split blob declarations into request-sized batches, by serialized size and
160/// by count. Order is preserved so a caller can concatenate the responses.
161fn batch_upload_declarations(declarations: Vec<Value>) -> Vec<Vec<Value>> {
162    let mut batches: Vec<Vec<Value>> = Vec::new();
163    let mut current: Vec<Value> = Vec::new();
164    let mut current_bytes = 0usize;
165    for declaration in declarations {
166        let declared_bytes = serde_json::to_string(&declaration)
167            .map(|text| text.len())
168            .unwrap_or(MAX_UPLOAD_RESERVATION_BYTES)
169            + 1;
170        if !current.is_empty()
171            && (current.len() >= MAX_UPLOAD_RESERVATION_BLOBS
172                || current_bytes + declared_bytes > MAX_UPLOAD_RESERVATION_BYTES)
173        {
174            batches.push(std::mem::take(&mut current));
175            current_bytes = 0;
176        }
177        current_bytes += declared_bytes;
178        current.push(declaration);
179    }
180    if !current.is_empty() {
181        batches.push(current);
182    }
183    batches
184}
185/// A client never needs to replay an unbounded feed in one invocation. Large
186/// histories are mirrored incrementally; a first mirror beyond this cap needs a
187/// checkpoint/export rather than allocating attacker-controlled metadata.
188const MAX_FEED_REPLAY_ENTRIES: u64 = 100_000;
189const MAX_FEED_REPLAY_BYTES: u64 = 64 * 1024 * 1024;
190const FEED_PAGE_LIMIT: usize = 100;
191
192/// The hub's inbox cap on one `propose` submission body, mirrored client-side
193/// so an oversized body fails before the upload, not after (the same
194/// fail-before-upload contract as the push caps). Public so the CLI can
195/// pre-check a `--body-file` from file metadata without reading it.
196pub const MAX_PROPOSE_BYTES: u64 = 16 * 1024;
197
198/// Bounded connect so a dead hub fails fast; a generous read window so a
199/// large export on a slow link still completes.
200const CONNECT_TIMEOUT_SECS: u64 = 10;
201const READ_TIMEOUT_SECS: u64 = 120;
202/// Hard wall-clock budget for one HTTP attempt, spanning socket writes,
203/// response headers, and the complete response body. DNS performed for pinned
204/// agents has its own interruptible deadline below.
205const OVERALL_REQUEST_TIMEOUT_SECS: u64 = 120;
206const CONNECT_ATTEMPTS: usize = 3;
207const CONNECT_RETRY_BACKOFF_MS: [u64; CONNECT_ATTEMPTS - 1] = [100, 300];
208
209/// Staging a real store means tens of thousands of immutable object PUTs in a
210/// row. A single refusal under sustained load must not fail the migration, so
211/// the upload lane retries further and waits longer than a connect blip does.
212const UPLOAD_ATTEMPTS: usize = 6;
213const UPLOAD_RETRY_BACKOFF_MS: [u64; UPLOAD_ATTEMPTS - 1] = [200, 600, 1_500, 3_000, 6_000];
214
215fn upload_retry_backoff_ms(attempt: usize) -> u64 {
216    UPLOAD_RETRY_BACKOFF_MS[attempt.min(UPLOAD_RETRY_BACKOFF_MS.len() - 1)]
217}
218
219/// Statuses worth replaying an immutable, content-addressed upload for: edge
220/// and object-store refusals that are load-shaped rather than a statement about
221/// these bytes. A checksum or precondition failure is never retried.
222fn is_retryable_upload_status(status: u16) -> bool {
223    matches!(status, 400 | 408 | 429 | 500 | 502 | 503 | 504)
224}
225/// Cold checkouts may need every visible blob, but short-lived direct object
226/// capabilities must not create an unbounded connection storm. Keep the
227/// verified downloads inside this fixed worker count.
228const V2_BLOB_DOWNLOAD_WORKERS: usize = 16;
229/// File durability barriers are independent inside the private pull stage.
230/// Keep enough in flight to hide storage flush latency without approaching
231/// ordinary process descriptor limits.
232#[cfg(unix)]
233const V2_PULL_INSTALL_WORKERS: usize = 16;
234/// One authenticated link.md v2 bulk frame remains small enough for bounded
235/// retries and serverless streaming while amortizing tens of thousands of
236/// immutable blobs over a few hundred requests instead of one request each.
237const V2_BULK_STREAM_FILES: usize = 256;
238const V2_BULK_STREAM_CONTENT_BYTES: u64 = 8 * 1024 * 1024;
239const V2_BULK_STREAM_RESPONSE_BYTES: u64 = 10 * 1024 * 1024;
240const V2_BULK_STREAM_MAGIC: &[u8; 8] = b"LMD2STRM";
241
242/// Everything that can go wrong on the wire or at its edges. Each variant maps
243/// onto one stable CLI error code; messages are single-line and never echo the
244/// credential.
245#[derive(Debug, thiserror::Error)]
246pub enum LinkError {
247    /// No hub URL was configured anywhere (flag, env, `.dbmd/config`).
248    #[error(
249        "no hub configured — pass --hub <URL>, set {HUB_URL_ENV}, or add `hub = <URL>` to {CONFIG_REL_PATH}"
250    )]
251    NoHub,
252
253    /// The verb needs a credential and none was present.
254    #[error("no hub credential — set {HUB_KEY_ENV} (credentials never live in {CONFIG_REL_PATH})")]
255    NoCredential,
256
257    /// The credential contains whitespace / non-ASCII (a paste artifact). The
258    /// key is deliberately not echoed.
259    #[error(
260        "the hub credential in {HUB_KEY_ENV} contains whitespace or non-ASCII characters — re-copy it (the key is not shown here on purpose)"
261    )]
262    BadKey,
263
264    /// A store selected the destination while an ambient credential was
265    /// present, but the operator did not bind that credential to the same
266    /// origin. This is a hard refusal, not an anonymous fallback: silently
267    /// dropping a credential can turn an intended private operation into a
268    /// confusing public one.
269    #[error(
270        "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}"
271    )]
272    UnboundCredential,
273
274    /// The agent signing key file named by [`AGENT_KEY_FILE_ENV`] is missing,
275    /// unreadable, or not a valid Ed25519 PKCS#8 — key material is never
276    /// echoed.
277    #[error("invalid agent signing key ({message}) — mint one with `dbmd key generate`")]
278    BadAgentKey {
279        /// What failed, without any key material.
280        message: String,
281    },
282
283    /// A non-HTTPS hub outside loopback: the bearer key would travel in cleartext.
284    #[error("refusing non-HTTPS hub {hub} — the credential would travel in cleartext (localhost is exempt)")]
285    UnsafeHub {
286        /// The offending hub URL.
287        hub: String,
288    },
289
290    /// TCP/TLS-level failure: the hub never answered.
291    #[error("hub unreachable at {hub}: {message}")]
292    Transport {
293        /// The hub base URL.
294        hub: String,
295        /// The transport-layer error text.
296        message: String,
297    },
298
299    /// The hub answered with an HTTP error status.
300    #[error("{what} failed (HTTP {status}): {message}")]
301    Http {
302        /// What the client was doing (e.g. `"resolve"`, `"sync pull"`).
303        what: &'static str,
304        /// The HTTP status code.
305        status: u16,
306        /// The hub's own `error` string when it sent one, else a placeholder.
307        message: String,
308        /// The hub's machine `code` field when it sent one.
309        code: Option<String>,
310        /// Permission-filtered structured refusal details, when supplied.
311        details: Option<Value>,
312    },
313
314    /// A 2xx whose body is not JSON — a captive portal, a proxy, or a wrong
315    /// URL — refused here rather than deserializing into nothing downstream.
316    #[error("{what}: the hub answered HTTP {status} with a non-JSON body — check the hub URL")]
317    NotJson {
318        /// What the client was doing.
319        what: &'static str,
320        /// The (2xx) status that carried the non-JSON body.
321        status: u16,
322    },
323
324    /// The hub response exceeded the selected endpoint's byte cap.
325    #[error("hub response exceeded the {limit_bytes}-byte endpoint cap — refusing to buffer it")]
326    ResponseTooLarge {
327        /// The endpoint-specific cap applied before JSON parsing.
328        limit_bytes: u64,
329    },
330
331    /// A malformed `@brain/id` address.
332    #[error("invalid address `{given}`: {reason}")]
333    BadAddress {
334        /// The raw address as typed.
335        given: String,
336        /// Why it did not parse.
337        reason: String,
338    },
339
340    /// A grant id whose shape cannot travel as a URL path segment.
341    #[error(
342        "invalid grant id `{given}` — grant ids come from `grant list` (lowercase letters, digits, hyphens)"
343    )]
344    BadGrantId {
345        /// The raw id as typed.
346        given: String,
347    },
348
349    /// An exported file path that would escape or pollute the destination
350    /// (absolute, `..`, a dot-leading segment, or an illegal character). The
351    /// hub is not trusted with local path layout.
352    #[error("refusing unsafe path from the hub: `{path}`")]
353    UnsafePath {
354        /// The offending path as received.
355        path: String,
356    },
357
358    /// The store exceeds the hub's bounded whole-snapshot caps.
359    #[error(
360        "push too large ({detail}) — one snapshot caps at {} MB uncompressed, {} MB as a pack, and {MAX_PUSH_FILES} files",
361        MAX_STORE_BYTES / (1024 * 1024),
362        MAX_PACK_BYTES / (1024 * 1024)
363    )]
364    PushTooLarge {
365        /// Which cap was hit, human-readable.
366        detail: String,
367    },
368
369    /// The propose body exceeds the hub's inbox cap.
370    #[error(
371        "propose body too large ({bytes} bytes) — the hub's inbox caps one submission at {} KB",
372        MAX_PROPOSE_BYTES / 1024
373    )]
374    ProposeTooLarge {
375        /// The offending body size in bytes.
376        bytes: u64,
377    },
378
379    /// A store file that is not valid UTF-8 cannot travel the JSON push path.
380    #[error("store file `{path}` is not valid UTF-8 — the JSON push path carries text only")]
381    NotUtf8 {
382        /// The store-relative path of the offending file.
383        path: String,
384    },
385
386    /// A downloaded pack failed validation before any local write.
387    #[error("invalid store pack: {message}")]
388    InvalidPack {
389        /// Hash, ZIP, path, count, or expansion failure.
390        message: String,
391    },
392
393    /// A signed feed entry, hash chain, or advertised feed head did not verify.
394    #[error("invalid signed feed: {message}")]
395    InvalidFeed {
396        /// The failed integrity condition, without untrusted secret material.
397        message: String,
398    },
399
400    /// A mutable alias moved to another canonical brain. Ordinary sync fails
401    /// closed; only the exact old/new ids can authorize replacing that one
402    /// alias binding while preserving both canonical trust histories.
403    #[error(
404        "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}`"
405    )]
406    AliasRebindRequired {
407        alias: String,
408        from: String,
409        to: String,
410    },
411
412    /// Local and remote both changed one or more coordinates since the last
413    /// verified sync baseline. No side was overwritten.
414    #[error("sync conflict on {paths:?} — resolve the named files and retry")]
415    Conflict {
416        /// Bounded, portable paths safe to show to the agent/operator.
417        paths: Vec<String>,
418    },
419
420    /// A readable v2 conflict was preserved as a private, exact-head bundle.
421    /// The bundle is local control state; none of its bytes ride with the
422    /// brain, and resolving it always creates a fresh explicit mutation.
423    #[error(
424        "sync conflict preserved in private bundle `{bundle}` for {paths:?} — run `dbmd sync resolve {bundle} --keep-local`, `--take-remote`, or `--from <safe-file>`"
425    )]
426    ConflictBundle {
427        /// Local ULID naming `.dbmd/conflicts/<bundle>/plan.json`.
428        bundle: String,
429        /// Bounded, readable conflict paths.
430        paths: Vec<String>,
431    },
432
433    /// `.sevralocal` newly made paths eligible to ride. Uploading them is an
434    /// explicit adoption boundary, never an accidental side effect of editing
435    /// or removing a local policy file.
436    #[error(
437        "local sync policy newly exposes {paths:?} — review and retry with --resume-local-policy"
438    )]
439    LocalPolicyTransition {
440        /// Bounded local-only paths. They are never sent in telemetry.
441        paths: Vec<String>,
442    },
443
444    /// The exact mutation crosses a permissioned bulk-impact boundary. The
445    /// hub has not committed it; `preview` is the bounded, permission-filtered
446    /// receipt an agent must inspect before explicitly confirming the same
447    /// request.
448    #[error(
449        "bulk change requires explicit confirmation — review the preview and retry the same sync with --confirm-bulk <id>:<digest>"
450    )]
451    BulkPreviewRequired {
452        /// Stable structured preview returned by the hub.
453        preview: Value,
454    },
455
456    /// A scoped checkout's generated store marker was edited or removed.
457    /// It is local projection metadata and is never accepted as brain data.
458    #[error(
459        "the generated DB.md for this scoped view was modified — clone a fresh scoped checkout"
460    )]
461    ScopedProjectionModified,
462
463    /// The effective permission slice changed after this checkout was pinned.
464    /// Reusing the directory could conceal removals or accidentally adopt files
465    /// revealed by a wider grant, so a new checkout is required.
466    #[error(
467        "the checkout's permission scope changed — clone into a new directory to accept the new view"
468    )]
469    ScopedViewChanged,
470
471    /// A v2 identity/head was previously accepted for this ref, but the hub
472    /// now hides it. Never reinterpret that as a v1 downgrade.
473    #[error("the previously verified brain is unavailable — access may have been revoked or the brain removed")]
474    BrainUnavailable,
475
476    /// The verified remote head advanced while a pull or post-commit barrier
477    /// was in flight. The old baseline is deliberately retained.
478    #[error(
479        "the remote brain advanced during sync — retry to converge from the new verified head"
480    )]
481    RemoteAdvancedDuringSync,
482
483    /// This build cannot provide the no-follow, directory-handle-relative
484    /// filesystem semantics required for trust/key/snapshot state.
485    #[error(
486        "{operation} is unavailable on this platform — use the official macOS/Linux build or WSL"
487    )]
488    UnsupportedPlatform {
489        /// The operation that requires hardened local filesystem primitives.
490        operation: &'static str,
491    },
492
493    /// Local filesystem failure while materializing a pull or reading a push.
494    #[error(transparent)]
495    Io(#[from] std::io::Error),
496
497    /// A store-level failure (walking the local store for a push).
498    #[error(transparent)]
499    Store(#[from] crate::StoreError),
500}
501
502/// Result alias for link.md client operations.
503pub type LinkResult<T> = std::result::Result<T, LinkError>;
504
505/// Exact approval token returned by a permissioned v2 bulk preview.
506#[derive(Debug, Clone, PartialEq, Eq)]
507pub struct V2BulkConfirmation {
508    /// Opaque preview receipt id.
509    pub id: String,
510    /// Digest binding the receipt to principal, head, control revision, and
511    /// the exact strict mutation.
512    pub digest: String,
513}
514
515impl V2BulkConfirmation {
516    /// Parse the CLI/wire handoff form `<id>:<digest>` and reject anything
517    /// outside the protocol's exact lowercase shapes before network I/O.
518    pub fn parse(value: &str) -> LinkResult<Self> {
519        let (id, digest) = value
520            .split_once(':')
521            .ok_or_else(|| LinkError::InvalidPack {
522                message: "bulk confirmation must be <id>:<digest>".to_string(),
523            })?;
524        if !crate::ulid::is_ulid(id) || !is_sha256(digest) {
525            return Err(LinkError::InvalidPack {
526                message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
527                    .to_string(),
528            });
529        }
530        Ok(Self {
531            id: id.to_string(),
532            digest: digest.to_string(),
533        })
534    }
535}
536
537/// Security-sensitive link.md state and destination writes require a hardened
538/// descriptor/handle-relative filesystem implementation. Unix uses
539/// `openat`/`O_NOFOLLOW`; Windows uses held directory handles, reparse-point
540/// refusal, exclusive share modes, and write-through replacement.
541fn require_hardened_filesystem(operation: &'static str) -> LinkResult<()> {
542    #[cfg(any(target_os = "linux", target_os = "macos", windows))]
543    {
544        let _ = operation;
545        Ok(())
546    }
547    #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
548    {
549        Err(LinkError::UnsupportedPlatform { operation })
550    }
551}
552
553// ─────────────────────────────────────────────────────────────────────────────
554// Addressing — `@brain[/id]`, the reserved shape (SPEC § Addressing)
555// ─────────────────────────────────────────────────────────────────────────────
556
557/// What the part after `@brain/` names.
558#[derive(Debug, Clone, PartialEq, Eq)]
559pub enum AddressTarget {
560    /// A record `id` — the db.md lowercase ULID (the reserved `@brain/id` shape).
561    Id(String),
562    /// A store-relative `.md` path — a client-side convenience the hub's
563    /// resolve endpoint also accepts (`?path=`). Not part of the reserved
564    /// shape; unambiguous because a ULID is never a path.
565    Path(String),
566}
567
568/// Why a brain reference failed [`is_safe_ref`] — shared by [`Address::parse`]
569/// and the per-verb entry gates so the two surfaces never drift.
570const BAD_BRAIN_REASON: &str =
571    "the brain reference must be a brain id (lowercase ULID) or a slug (lowercase letters, digits, hyphens)";
572
573/// Why an address target failed its shape check — shared by [`Address::parse`]
574/// and the [`resolve`] entry gate.
575const BAD_TARGET_REASON: &str =
576    "the part after `/` must be a record id (lowercase ULID) or a store-relative `.md` path";
577
578/// A parsed `@brain[/target]` address. `brain` is a hub brain reference — the
579/// brain's ULID id (works for any caller, including cross-party on a public
580/// brain) or a slug (which a hub resolves only against the caller's own
581/// brains; slugs are unique per owner, not globally).
582#[derive(Debug, Clone, PartialEq, Eq)]
583pub struct Address {
584    /// The brain reference (leading `@` stripped).
585    pub brain: String,
586    /// The record target, when the address names one.
587    pub target: Option<AddressTarget>,
588}
589
590impl Address {
591    /// Parse `@brain`, `@brain/<ulid>`, or `@brain/<store-path>.md`. The `@`
592    /// sigil is optional (an agent piping ids around should not have to quote
593    /// it back on). Whitespace and empty segments are malformed.
594    pub fn parse(raw: &str) -> LinkResult<Address> {
595        let bad = |reason: &str| LinkError::BadAddress {
596            given: raw.to_string(),
597            reason: reason.to_string(),
598        };
599
600        let trimmed = raw.trim();
601        let body = trimmed.strip_prefix('@').unwrap_or(trimmed);
602        if body.is_empty() {
603            return Err(bad("empty address"));
604        }
605
606        let (brain, rest) = match body.split_once('/') {
607            Some((b, r)) => (b, Some(r)),
608            None => (body, None),
609        };
610
611        if brain.is_empty() {
612            return Err(bad("missing brain reference before `/`"));
613        }
614        if !is_safe_ref(brain) {
615            return Err(bad(BAD_BRAIN_REASON));
616        }
617
618        let target = match rest {
619            None => None,
620            Some("") => return Err(bad("trailing `/` with no record id or path")),
621            Some(r) if crate::ulid::is_ulid(r) => Some(AddressTarget::Id(r.to_string())),
622            Some(r) => {
623                if !safe_store_rel_path(r) || !r.ends_with(".md") {
624                    return Err(bad(BAD_TARGET_REASON));
625                }
626                Some(AddressTarget::Path(r.to_string()))
627            }
628        };
629
630        Ok(Address {
631            brain: brain.to_string(),
632            target,
633        })
634    }
635}
636
637/// A brain reference safe to embed in a URL path segment: the shapes a hub
638/// accepts (ULID id or slug), which are also exactly URL-path-clean.
639fn is_safe_ref(s: &str) -> bool {
640    !s.is_empty()
641        && s.len() <= 64
642        && s.bytes()
643            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
644}
645
646/// A published-site handle (the `propose` target). Same lexical shape as a
647/// slug.
648pub fn is_valid_handle(s: &str) -> bool {
649    is_safe_ref(s)
650}
651
652/// True when `p` is a store-relative path this client will read from or write
653/// to disk: relative, no `..`, no empty or dot-leading segment (which shields
654/// `.dbmd/` and `.git/`), and only the hub-portable character set. Applied to
655/// every path an export hands us (the hub is not trusted with local layout)
656/// and to every path a push sends (mirroring the hub's own gate).
657pub fn safe_store_rel_path(p: &str) -> bool {
658    if p.is_empty() || p.len() > MAX_STORE_PATH_BYTES || p.starts_with('/') {
659        return false;
660    }
661    if !p
662        .bytes()
663        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/'))
664    {
665        return false;
666    }
667    p.split('/')
668        .all(|seg| !seg.is_empty() && seg != "." && seg != ".." && !seg.starts_with('.'))
669}
670
671/// Entry gate for every verb that embeds a caller-supplied brain reference in
672/// a URL path segment. `resolve` reaches the same check through
673/// [`Address::parse`]; the raw-ref verbs (`sync`, `grant`, `subscribe`) call
674/// this directly, so a ref carrying `/`, `..`, `?`, `#`, or any other
675/// URL-reshaping byte is refused before a request exists (the `url` crate
676/// normalizes dot segments, so an unvalidated ref would redirect the
677/// authenticated request to a different hub path).
678fn require_safe_ref(brain: &str) -> LinkResult<()> {
679    if is_safe_ref(brain) {
680        Ok(())
681    } else {
682        Err(LinkError::BadAddress {
683            given: brain.to_string(),
684            reason: BAD_BRAIN_REASON.to_string(),
685        })
686    }
687}
688
689/// Entry gate for the published-site handle `propose` embeds in its URL path.
690fn require_valid_handle(handle: &str) -> LinkResult<()> {
691    if is_valid_handle(handle) {
692        Ok(())
693    } else {
694        Err(LinkError::BadAddress {
695            given: handle.to_string(),
696            reason: "the site handle must be lowercase letters, digits, hyphens".to_string(),
697        })
698    }
699}
700
701/// Entry gate for the grant id `grant revoke` embeds in its URL path. Hub
702/// grant ids are lowercase ULIDs; the gate accepts the same URL-path-clean
703/// shape as a brain ref rather than pinning one mint scheme.
704fn require_safe_grant_id(id: &str) -> LinkResult<()> {
705    if is_safe_ref(id) {
706        Ok(())
707    } else {
708        Err(LinkError::BadGrantId {
709            given: id.to_string(),
710        })
711    }
712}
713
714// ─────────────────────────────────────────────────────────────────────────────
715// Configuration — flag > env > .dbmd/config; credential from env only
716// ─────────────────────────────────────────────────────────────────────────────
717
718/// The resolved client configuration for one invocation.
719#[derive(Debug, Clone)]
720pub struct HubConfig {
721    /// The hub base URL, trailing slash stripped, HTTPS-or-loopback enforced.
722    pub hub: String,
723    /// The bearer credential, when the environment carries one.
724    pub key: Option<String>,
725    /// The agent signing key, when [`AGENT_KEY_FILE_ENV`] names one. Wins
726    /// over the bearer for authenticated requests (link.md §8).
727    pub agent_key: Option<AgentSigningKey>,
728    /// The self-custodied brain signing key, when [`BRAIN_KEY_FILE_ENV`]
729    /// names one — `sync --push` then signs feed entries locally (§2.4).
730    pub brain_key: Option<AgentSigningKey>,
731    /// User-owned global toolkit state root. Identity pins and monotonic feed
732    /// checkpoints live below `<state_dir>/trust/`, never under a store.
733    pub state_dir: PathBuf,
734    /// True only when the origin came from untrusted store-local configuration.
735    /// Such origins are resolved and public-IP-pinned for every request.
736    store_selected: bool,
737}
738
739/// A loaded agent signing key: the PKCS#8 secret plus its derived public
740/// multikey. Debug never prints key material.
741#[derive(Clone)]
742pub struct AgentSigningKey {
743    pkcs8: Vec<u8>,
744    /// The key's public identity, `ed25519:<base64url sha256(SPKI)>`.
745    pub multikey: String,
746    /// The full public key, `base64url(SPKI DER)` — what feed entries carry.
747    pub public_key_spki: String,
748}
749
750impl std::fmt::Debug for AgentSigningKey {
751    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
752        f.debug_struct("AgentSigningKey")
753            .field("multikey", &self.multikey)
754            .field("pkcs8", &"<redacted>")
755            .finish()
756    }
757}
758
759impl HubConfig {
760    /// The credential, or the canonical "not configured" error. Verbs that
761    /// authenticate call this; `propose` never does.
762    pub fn require_key(&self) -> LinkResult<&str> {
763        self.key.as_deref().ok_or(LinkError::NoCredential)
764    }
765}
766
767/// Resolve the client configuration: `flag_hub` beats [`HUB_URL_ENV`] beats
768/// the `hub =` line in `<dir>/.dbmd/config`; no fallback default exists. The
769/// credential comes from [`HUB_KEY_ENV`] alone and is validated as a clean
770/// header token (never echoed on failure).
771pub fn hub_config(flag_hub: Option<&str>, dir: &Path) -> LinkResult<HubConfig> {
772    let explicit_hub = flag_hub
773        .map(str::to_string)
774        .or_else(|| env_nonempty(HUB_URL_ENV));
775    let selected_by_store = explicit_hub.is_none();
776    let hub = explicit_hub
777        .or_else(|| config_file_hub(&dir.join(CONFIG_REL_PATH)))
778        .ok_or(LinkError::NoHub)?;
779    let hub = hub.trim().trim_end_matches('/').to_string();
780    assert_safe_hub(&hub)?;
781    if selected_by_store {
782        let parsed =
783            url::Url::parse(&hub).map_err(|_| LinkError::UnsafeHub { hub: hub.clone() })?;
784        // A cloned store is not an operator opt-in to local-network access.
785        // Local/private development hubs must be selected explicitly by flag or
786        // environment, never by bytes inside the store.
787        if !parsed.scheme().eq_ignore_ascii_case("https")
788            || (parsed.path() != "/" && !parsed.path().is_empty())
789        {
790            return Err(LinkError::UnsafeHub { hub });
791        }
792    }
793
794    let key = match env_nonempty(HUB_KEY_ENV) {
795        Some(raw) => Some(clean_key(&raw)?),
796        None => None,
797    };
798
799    let agent_key = match env_nonempty(AGENT_KEY_FILE_ENV) {
800        Some(path) => Some(load_agent_key(Path::new(&path))?),
801        None => None,
802    };
803
804    let brain_key = match env_nonempty(BRAIN_KEY_FILE_ENV) {
805        Some(path) => Some(load_agent_key(Path::new(&path))?),
806        None => None,
807    };
808
809    // A cloned store controls `.dbmd/config`. It must never be able to point
810    // the process at an attacker origin and harvest the user's ambient account
811    // bearer, signed agent identity, or self-custodied brain signatures/content.
812    // Explicit --hub/DBMD_HUB_URL selection already pairs target + credential
813    // in the invocation environment; a store-selected target additionally
814    // needs an exact origin binding.
815    if selected_by_store && (key.is_some() || agent_key.is_some() || brain_key.is_some()) {
816        let bound = env_nonempty(HUB_CREDENTIAL_ORIGIN_ENV)
817            .and_then(|value| normalized_origin(&value).ok());
818        let selected_origin = normalized_origin(&hub)?;
819        if bound.as_deref() != Some(selected_origin.as_str()) {
820            return Err(LinkError::UnboundCredential);
821        }
822    }
823
824    Ok(HubConfig {
825        hub,
826        key,
827        agent_key,
828        brain_key,
829        state_dir: toolkit_state_dir()?,
830        store_selected: selected_by_store,
831    })
832}
833
834fn toolkit_state_dir() -> LinkResult<PathBuf> {
835    if let Some(path) = env_nonempty(STATE_DIR_ENV) {
836        let path = PathBuf::from(path);
837        if !path.is_absolute() {
838            return Err(LinkError::UnsafePath {
839                path: path.display().to_string(),
840            });
841        }
842        return Ok(path);
843    }
844    #[cfg(windows)]
845    if let Some(base) = env_nonempty("LOCALAPPDATA") {
846        let base = PathBuf::from(base);
847        if base.is_absolute() {
848            return Ok(base.join("dbmd").join("state"));
849        }
850    }
851    #[cfg(windows)]
852    {
853        Err(LinkError::Io(std::io::Error::new(
854            std::io::ErrorKind::NotFound,
855            format!("cannot locate user state; set {STATE_DIR_ENV} or LOCALAPPDATA"),
856        )))
857    }
858    #[cfg(not(windows))]
859    if let Some(base) = env_nonempty("XDG_STATE_HOME") {
860        let base = PathBuf::from(base);
861        if base.is_absolute() {
862            return Ok(base.join("dbmd"));
863        }
864    }
865    #[cfg(not(windows))]
866    let home = PathBuf::from(env_nonempty("HOME").ok_or_else(|| {
867        LinkError::Io(std::io::Error::new(
868            std::io::ErrorKind::NotFound,
869            format!("cannot locate user state; set {STATE_DIR_ENV}"),
870        ))
871    })?);
872    #[cfg(not(windows))]
873    if !home.is_absolute() {
874        return Err(LinkError::UnsafePath {
875            path: home.display().to_string(),
876        });
877    }
878    #[cfg(target_os = "macos")]
879    {
880        Ok(home
881            .join("Library")
882            .join("Application Support")
883            .join("dbmd")
884            .join("state"))
885    }
886    #[cfg(all(not(target_os = "macos"), not(windows)))]
887    {
888        Ok(home.join(".local").join("state").join("dbmd"))
889    }
890}
891
892fn normalized_origin(value: &str) -> LinkResult<String> {
893    let parsed = url::Url::parse(value).map_err(|_| LinkError::UnsafeHub {
894        hub: value.to_string(),
895    })?;
896    if !(parsed.scheme().eq_ignore_ascii_case("https")
897        || parsed.scheme().eq_ignore_ascii_case("http"))
898        || !parsed.username().is_empty()
899        || parsed.password().is_some()
900        || (parsed.path() != "/" && !parsed.path().is_empty())
901        || parsed.query().is_some()
902        || parsed.fragment().is_some()
903    {
904        return Err(LinkError::UnsafeHub {
905            hub: value.to_string(),
906        });
907    }
908    let host = parsed.host_str().ok_or_else(|| LinkError::UnsafeHub {
909        hub: value.to_string(),
910    })?;
911    let host = if host.contains(':') {
912        format!("[{host}]")
913    } else {
914        host.to_ascii_lowercase()
915    };
916    let port = parsed
917        .port_or_known_default()
918        .ok_or_else(|| LinkError::UnsafeHub {
919            hub: value.to_string(),
920        })?;
921    let default = (parsed.scheme().eq_ignore_ascii_case("https") && port == 443)
922        || (parsed.scheme().eq_ignore_ascii_case("http") && port == 80);
923    Ok(format!(
924        "{}://{}{}",
925        parsed.scheme().to_ascii_lowercase(),
926        host,
927        if default {
928            String::new()
929        } else {
930            format!(":{port}")
931        }
932    ))
933}
934
935// ─────────────────────────────────────────────────────────────────────────────
936// Agent signing keys — link.md §8 `LinkMD-Sig` proof of possession
937// ─────────────────────────────────────────────────────────────────────────────
938
939/// The DER prefix that wraps a raw Ed25519 public key into a
940/// SubjectPublicKeyInfo (RFC 8410).
941const ED25519_SPKI_PREFIX: [u8; 12] = [
942    0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
943];
944
945fn bad_agent_key(message: &str) -> LinkError {
946    LinkError::BadAgentKey {
947        message: message.to_string(),
948    }
949}
950
951fn agent_keypair(pkcs8: &[u8]) -> LinkResult<ring::signature::Ed25519KeyPair> {
952    // `from_pkcs8` wants ring's own v2 encoding (private + public); keys from
953    // other tools are often PKCS#8 v1, which `maybe_unchecked` accepts by
954    // deriving the public half itself.
955    ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8)
956        .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(pkcs8))
957        .map_err(|_| bad_agent_key("not an Ed25519 PKCS#8 key"))
958}
959
960/// Derive `(publicKeySpki b64u, multikey)` from a keypair.
961fn public_identity_for(pair: &ring::signature::Ed25519KeyPair) -> (String, String) {
962    use ring::signature::KeyPair as _;
963    let mut spki = Vec::with_capacity(44);
964    spki.extend_from_slice(&ED25519_SPKI_PREFIX);
965    spki.extend_from_slice(pair.public_key().as_ref());
966    (
967        URL_SAFE_NO_PAD.encode(&spki),
968        format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&spki))),
969    )
970}
971
972/// Load and validate a signing-key file (agent or brain — same format):
973/// one base64url line of PKCS#8. Public so `dbmd key rotate` can load the
974/// old key explicitly.
975pub fn load_signing_key(path: &Path) -> LinkResult<AgentSigningKey> {
976    load_agent_key(path)
977}
978
979/// Load and validate the agent key file: one base64url line of PKCS#8.
980fn load_agent_key(path: &Path) -> LinkResult<AgentSigningKey> {
981    #[cfg(unix)]
982    let file = {
983        use std::os::fd::{AsRawFd as _, FromRawFd as _};
984        use std::os::unix::ffi::OsStrExt as _;
985        let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
986            .map_err(|e| bad_agent_key(&format!("cannot open the key parent: {e}")))?;
987        let leaf = path
988            .file_name()
989            .ok_or_else(|| bad_agent_key("the key path has no file name"))?;
990        let leaf = c_name(leaf.as_bytes(), &path.display().to_string())?;
991        let fd = unsafe {
992            libc::openat(
993                parent.as_raw_fd(),
994                leaf.as_ptr(),
995                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
996            )
997        };
998        if fd < 0 {
999            return Err(bad_agent_key(
1000                "the key path must be an existing regular file without symlink ancestors",
1001            ));
1002        }
1003        unsafe { std::fs::File::from_raw_fd(fd) }
1004    };
1005    #[cfg(not(unix))]
1006    let file = std::fs::File::open(path)
1007        .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
1008    let metadata = file
1009        .metadata()
1010        .map_err(|e| bad_agent_key(&format!("cannot inspect the key file: {e}")))?;
1011    if !metadata.is_file() {
1012        return Err(bad_agent_key("the key path must be a regular file"));
1013    }
1014    #[cfg(unix)]
1015    {
1016        use std::os::unix::fs::PermissionsExt as _;
1017        if metadata.permissions().mode() & 0o077 != 0 {
1018            return Err(bad_agent_key(
1019                "the key file is accessible to group/other; set mode 0600",
1020            ));
1021        }
1022    }
1023    let mut text = String::new();
1024    file.take(1024 * 1024 + 1)
1025        .read_to_string(&mut text)
1026        .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
1027    if text.len() > 1024 * 1024 {
1028        return Err(bad_agent_key("the key file exceeds the size limit"));
1029    }
1030    let pkcs8 = URL_SAFE_NO_PAD
1031        .decode(text.trim())
1032        .map_err(|_| bad_agent_key("the key file is not one base64url line"))?;
1033    let (public_key_spki, multikey) = public_identity_for(&agent_keypair(&pkcs8)?);
1034    Ok(AgentSigningKey {
1035        pkcs8,
1036        multikey,
1037        public_key_spki,
1038    })
1039}
1040
1041/// Durably create a new secret file without an exists→write race and without
1042/// ever exposing a default-mode (commonly 0644) key between write and chmod.
1043/// `create_new` also refuses a planted symlink. The file and its parent
1044/// directory are synced before the caller can publish the corresponding
1045/// public identity remotely.
1046fn write_secret_new(path: &Path, bytes: &[u8]) -> LinkResult<()> {
1047    #[cfg(unix)]
1048    let (mut file, parent, leaf) = {
1049        use std::os::fd::{AsRawFd as _, FromRawFd as _};
1050        use std::os::unix::ffi::OsStrExt as _;
1051        let parent = open_or_create_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))?;
1052        let leaf_name = path
1053            .file_name()
1054            .ok_or_else(|| bad_agent_key("the output key path has no file name"))?;
1055        let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
1056        let fd = unsafe {
1057            libc::openat(
1058                parent.as_raw_fd(),
1059                leaf.as_ptr(),
1060                libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1061                0o600,
1062            )
1063        };
1064        if fd < 0 {
1065            let error = std::io::Error::last_os_error();
1066            if error.kind() == std::io::ErrorKind::AlreadyExists {
1067                return Err(bad_agent_key(
1068                    "the output file already exists — refusing to overwrite a key",
1069                ));
1070            }
1071            return Err(error.into());
1072        }
1073        (unsafe { std::fs::File::from_raw_fd(fd) }, parent, leaf)
1074    };
1075    #[cfg(not(unix))]
1076    let mut file = std::fs::OpenOptions::new()
1077        .write(true)
1078        .create_new(true)
1079        .open(path)
1080        .map_err(|error| {
1081            if error.kind() == std::io::ErrorKind::AlreadyExists {
1082                bad_agent_key("the output file already exists — refusing to overwrite a key")
1083            } else {
1084                LinkError::Io(error)
1085            }
1086        })?;
1087    if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_all()) {
1088        drop(file);
1089        #[cfg(unix)]
1090        let _ =
1091            unsafe { libc::unlinkat(std::os::fd::AsRawFd::as_raw_fd(&parent), leaf.as_ptr(), 0) };
1092        #[cfg(not(unix))]
1093        let _ = std::fs::remove_file(path);
1094        return Err(LinkError::Io(error));
1095    }
1096    drop(file);
1097    #[cfg(unix)]
1098    parent.sync_all()?;
1099    Ok(())
1100}
1101
1102/// What `dbmd key generate` returns: the public identity to register plus
1103/// where the secret landed.
1104#[derive(Debug, Serialize)]
1105pub struct GeneratedAgentKey {
1106    /// `ed25519:<fingerprint>` — the grantable/registerable identity.
1107    pub multikey: String,
1108    /// base64url SPKI DER — what a hub's register endpoint takes.
1109    #[serde(rename = "publicKeySpki")]
1110    pub public_key_spki: String,
1111    /// Where the PKCS#8 secret was written (mode 0600).
1112    #[serde(rename = "keyFile")]
1113    pub key_file: String,
1114}
1115
1116/// Mint a fresh Ed25519 agent keypair. The secret is written to `out`
1117/// (base64url PKCS#8, one line, 0600, refusing to overwrite); only public
1118/// identity is returned. The private key never enters a store and never
1119/// travels — requests carry per-request signatures instead (link.md §8).
1120pub fn generate_agent_key(out: &Path) -> LinkResult<GeneratedAgentKey> {
1121    require_hardened_filesystem("key generation")?;
1122    let rng = ring::rand::SystemRandom::new();
1123    let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
1124        .map_err(|_| bad_agent_key("key generation failed"))?;
1125    let pair = agent_keypair(pkcs8.as_ref())?;
1126    let (spki_b64u, multikey) = public_identity_for(&pair);
1127
1128    write_secret_new(
1129        out,
1130        format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
1131    )?;
1132
1133    Ok(GeneratedAgentKey {
1134        multikey,
1135        public_key_spki: spki_b64u,
1136        key_file: out.display().to_string(),
1137    })
1138}
1139
1140/// Build the origin-bound `LinkMD-Sig` v2 header for one request:
1141/// `canonical = "v2" LF origin LF METHOD LF path+query LF ts LF
1142/// (sha256hex(body) | "-")`.
1143///
1144/// The origin is derived from the already-validated hub URL, never from
1145/// attacker-controlled request metadata. Binding it closes the v1 replay
1146/// class where a proof captured by one hub could be replayed to another hub
1147/// serving the same path inside the timestamp window.
1148fn linkmd_sig_header(
1149    key: &AgentSigningKey,
1150    origin: &str,
1151    method: &str,
1152    path: &str,
1153    body: Option<&str>,
1154) -> LinkResult<String> {
1155    let ts = std::time::SystemTime::now()
1156        .duration_since(std::time::UNIX_EPOCH)
1157        .map_err(|_| bad_agent_key("system clock is before the epoch"))?
1158        .as_secs();
1159    let body_hash = match body {
1160        Some(b) => format!("{:x}", Sha256::digest(b.as_bytes())),
1161        None => "-".to_string(),
1162    };
1163    let canonical = format!(
1164        "v2\n{}\n{}\n{}\n{}\n{}",
1165        origin,
1166        method.to_uppercase(),
1167        path,
1168        ts,
1169        body_hash
1170    );
1171    let pair = agent_keypair(&key.pkcs8)?;
1172    let sig = URL_SAFE_NO_PAD.encode(pair.sign(canonical.as_bytes()).as_ref());
1173    let fingerprint = key.multikey.trim_start_matches("ed25519:");
1174    Ok(format!(
1175        "LinkMD-Sig v2,key=ed25519:{fingerprint},ts={ts},sig={sig}"
1176    ))
1177}
1178
1179// ─────────────────────────────────────────────────────────────────────────────
1180// Self-custody feed entries — the client signs what the hub only verifies
1181// ─────────────────────────────────────────────────────────────────────────────
1182
1183/// One `files` element of a wire-profile-v1 feed entry (SPEC §5.1: fields in
1184/// exactly this order).
1185#[derive(Serialize)]
1186struct WireFeedFile {
1187    path: String,
1188    sha256: String,
1189    bytes: u64,
1190}
1191
1192/// The unsigned entry in the normative §5.1 field order — serde serializes
1193/// struct fields in declaration order, which IS the wire contract.
1194#[derive(Serialize)]
1195struct UnsignedWireEntry<'a> {
1196    v: u8,
1197    seq: u64,
1198    ts: String,
1199    brain: &'a str,
1200    public_key: &'a str,
1201    kind: &'a str,
1202    op: &'a str,
1203    pack_sha256: &'a str,
1204    files: &'a [WireFeedFile],
1205    removed: &'a [String],
1206    prev_entry_hash: Option<&'a str>,
1207}
1208
1209/// Build and sign a wire-profile-v1 `push` feed entry with a self-custodied
1210/// brain key: serialize the unsigned entry compactly in the normative order,
1211/// Ed25519-sign those exact bytes, splice `sig` on as the final field. The
1212/// returned string is the exact serialization the hub stores verbatim (plus
1213/// one trailing newline) and every independent reader re-derives.
1214fn self_custody_entry(
1215    key: &AgentSigningKey,
1216    seq: u64,
1217    ts: String,
1218    pack_sha256: &str,
1219    files: &[WireFeedFile],
1220    prev_entry_hash: Option<&str>,
1221) -> LinkResult<String> {
1222    let removed: [String; 0] = [];
1223    let unsigned = serde_json::to_string(&UnsignedWireEntry {
1224        v: 1,
1225        seq,
1226        ts,
1227        brain: &key.multikey,
1228        public_key: &key.public_key_spki,
1229        kind: "push",
1230        op: "snapshot",
1231        pack_sha256,
1232        files,
1233        removed: &removed,
1234        prev_entry_hash,
1235    })
1236    .expect("serialize feed entry");
1237    let pair = agent_keypair(&key.pkcs8)?;
1238    let sig = URL_SAFE_NO_PAD.encode(pair.sign(unsigned.as_bytes()).as_ref());
1239    Ok(format!(
1240        "{},\"sig\":\"{}\"}}",
1241        &unsigned[..unsigned.len() - 1],
1242        sig
1243    ))
1244}
1245
1246/// An env var, treated as absent when unset or empty (an empty
1247/// `DBMD_HUB_KEY=` falls through rather than becoming an empty credential).
1248fn env_nonempty(name: &str) -> Option<String> {
1249    std::env::var(name).ok().filter(|v| !v.trim().is_empty())
1250}
1251
1252/// Read the `hub = <URL>` line out of a `.dbmd/config` file. The format is
1253/// deliberately minimal: `key = value` lines, `#` comments, unknown keys
1254/// ignored (forward-compatible). A missing or unreadable file is simply "not
1255/// configured here".
1256fn config_file_hub(path: &Path) -> Option<String> {
1257    const MAX_CONFIG_BYTES: u64 = 64 * 1024;
1258    #[cfg(unix)]
1259    let file = {
1260        use std::os::fd::{AsRawFd as _, FromRawFd as _};
1261        use std::os::unix::ffi::OsStrExt as _;
1262        let parent =
1263            open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new("."))).ok()?;
1264        let leaf = c_name(path.file_name()?.as_bytes(), &path.display().to_string()).ok()?;
1265        let fd = unsafe {
1266            libc::openat(
1267                parent.as_raw_fd(),
1268                leaf.as_ptr(),
1269                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1270            )
1271        };
1272        if fd < 0 {
1273            return None;
1274        }
1275        unsafe { std::fs::File::from_raw_fd(fd) }
1276    };
1277    #[cfg(not(unix))]
1278    let file = std::fs::File::open(path).ok()?;
1279    let metadata = file.metadata().ok()?;
1280    if !metadata.is_file() || metadata.len() > MAX_CONFIG_BYTES {
1281        return None;
1282    }
1283    let mut bytes = Vec::with_capacity(metadata.len() as usize);
1284    file.take(MAX_CONFIG_BYTES + 1)
1285        .read_to_end(&mut bytes)
1286        .ok()?;
1287    if bytes.len() as u64 > MAX_CONFIG_BYTES {
1288        return None;
1289    }
1290    let text = String::from_utf8(bytes).ok()?;
1291    for line in text.lines() {
1292        let line = line.trim();
1293        if line.is_empty() || line.starts_with('#') {
1294            continue;
1295        }
1296        if let Some((k, v)) = line.split_once('=') {
1297            if k.trim() == "hub" {
1298                let v = v.trim();
1299                if !v.is_empty() {
1300                    return Some(v.to_string());
1301                }
1302            }
1303        }
1304    }
1305    None
1306}
1307
1308/// The bearer key must never travel in cleartext; only loopback hosts may
1309/// skip TLS (local development against a hub on localhost).
1310fn assert_safe_hub(hub: &str) -> LinkResult<()> {
1311    let parsed = url::Url::parse(hub).map_err(|_| LinkError::UnsafeHub {
1312        hub: hub.to_string(),
1313    })?;
1314    if !(parsed.scheme().eq_ignore_ascii_case("https")
1315        || parsed.scheme().eq_ignore_ascii_case("http"))
1316        || !parsed.username().is_empty()
1317        || parsed.password().is_some()
1318        || (parsed.path() != "/" && !parsed.path().is_empty())
1319        || parsed.query().is_some()
1320        || parsed.fragment().is_some()
1321    {
1322        return Err(LinkError::UnsafeHub {
1323            hub: hub.to_string(),
1324        });
1325    }
1326    let loopback = match parsed.host() {
1327        Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
1328        Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
1329        Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
1330        None => false,
1331    };
1332    if parsed.scheme().eq_ignore_ascii_case("https") || loopback {
1333        Ok(())
1334    } else {
1335        Err(LinkError::UnsafeHub {
1336            hub: hub.to_string(),
1337        })
1338    }
1339}
1340
1341/// Trim paste artifacts and refuse anything outside the printable-ASCII token
1342/// range WITHOUT echoing the key — an HTTP library rejecting a bad header
1343/// value tends to echo the whole header line, credential included, so the
1344/// gate sits here instead.
1345fn clean_key(raw: &str) -> LinkResult<String> {
1346    let k = raw.trim();
1347    if k.is_empty() || k.bytes().any(|b| !(0x21..=0x7e).contains(&b)) {
1348        return Err(LinkError::BadKey);
1349    }
1350    Ok(k.to_string())
1351}
1352
1353// ─────────────────────────────────────────────────────────────────────────────
1354// Transport — one blocking agent, capped reads, the JSON-or-refuse contract
1355// ─────────────────────────────────────────────────────────────────────────────
1356
1357/// One hub response: the status plus the parsed JSON body when there was one.
1358#[derive(Debug)]
1359pub struct HubResponse {
1360    /// The HTTP status code.
1361    pub status: u16,
1362    /// The parsed JSON body, `None` when the body was empty or not JSON.
1363    pub body: Option<Value>,
1364}
1365
1366struct RawHubResponse {
1367    status: u16,
1368    body: Vec<u8>,
1369}
1370
1371/// Whether a request carries the bearer credential.
1372#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1373enum Auth {
1374    /// Send `authorization: Bearer <key>`; error without a key.
1375    Required,
1376    /// Send no credential — the propose door is unauthenticated by design.
1377    None,
1378    /// Send the configured credential when one exists, otherwise nothing —
1379    /// brain-addressed propose works anonymously on public brains, and an
1380    /// authenticated caller earns a bigger actor-class budget.
1381    Optional,
1382}
1383
1384fn agent_builder_with_timeout(overall: std::time::Duration) -> ureq::AgentBuilder {
1385    ureq::AgentBuilder::new()
1386        .user_agent(concat!("dbmd/", env!("CARGO_PKG_VERSION")))
1387        // Never follow a redirect while a bearer, a store pack, or a signed
1388        // response is in flight. Callers see the 3xx as a non-success instead
1389        // of letting an origin steer sensitive material elsewhere.
1390        .redirects(0)
1391        .timeout_connect(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
1392        .timeout_read(std::time::Duration::from_secs(READ_TIMEOUT_SECS))
1393        .timeout_write(overall)
1394        .timeout(overall)
1395}
1396
1397fn agent_builder() -> ureq::AgentBuilder {
1398    agent_builder_with_timeout(std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS))
1399}
1400
1401fn agent() -> ureq::Agent {
1402    agent_builder().build()
1403}
1404
1405fn hub_agent(cfg: &HubConfig) -> LinkResult<ureq::Agent> {
1406    if !cfg.store_selected {
1407        return Ok(agent());
1408    }
1409    let parsed = url::Url::parse(&cfg.hub).map_err(|_| LinkError::UnsafeHub {
1410        hub: cfg.hub.clone(),
1411    })?;
1412    pinned_public_agent(&parsed, false, "store-selected hub")
1413}
1414
1415/// Perform one hub request. `path` is the binding path (starts with `/`);
1416/// `body` posts JSON. Transport failures, oversized bodies, and non-UTF-8 are
1417/// all surfaced as typed [`LinkError`]s; HTTP error statuses are returned in
1418/// the [`HubResponse`] for [`ensure_ok`] to shape.
1419fn request_raw(
1420    cfg: &HubConfig,
1421    method: &str,
1422    path: &str,
1423    body: Option<&Value>,
1424    auth: Auth,
1425    max_response_bytes: u64,
1426) -> LinkResult<RawHubResponse> {
1427    let http = hub_agent(cfg)?;
1428    request_raw_with_agent(
1429        cfg,
1430        &http,
1431        method,
1432        path,
1433        body,
1434        RawRequestOptions {
1435            auth,
1436            max_response_bytes,
1437            request_id: None,
1438        },
1439    )
1440}
1441
1442struct RawRequestOptions<'a> {
1443    auth: Auth,
1444    max_response_bytes: u64,
1445    request_id: Option<&'a str>,
1446}
1447
1448fn request_raw_with_agent(
1449    cfg: &HubConfig,
1450    http: &ureq::Agent,
1451    method: &str,
1452    path: &str,
1453    body: Option<&Value>,
1454    options: RawRequestOptions<'_>,
1455) -> LinkResult<RawHubResponse> {
1456    let url = format!("{}{}", cfg.hub, path);
1457    let encoded_body = body.map(Value::to_string);
1458    let origin = normalized_origin(&cfg.hub)?;
1459    // An agent signing key outranks the bearer: possession proofs put nothing
1460    // reusable on the wire, so when both are configured the stronger one wins.
1461    let credential = match options.auth {
1462        Auth::Required => Some(match &cfg.agent_key {
1463            Some(key) => linkmd_sig_header(key, &origin, method, path, encoded_body.as_deref())?,
1464            None => format!("Bearer {}", cfg.require_key()?),
1465        }),
1466        Auth::Optional => match &cfg.agent_key {
1467            Some(key) => Some(linkmd_sig_header(
1468                key,
1469                &origin,
1470                method,
1471                path,
1472                encoded_body.as_deref(),
1473            )?),
1474            None => cfg.key.as_deref().map(|k| format!("Bearer {k}")),
1475        },
1476        Auth::None => None,
1477    };
1478    let result = with_connect_retries(|| {
1479        let mut req = http.request(method, &url);
1480        if let Some(value) = &credential {
1481            req = req.set("authorization", value);
1482        }
1483        if let Some(value) = options.request_id {
1484            req = req.set("x-request-id", value);
1485        }
1486        match &encoded_body {
1487            Some(value) => req
1488                .set("content-type", "application/json")
1489                .send_string(value)
1490                .map_err(Box::new),
1491            None => req.call().map_err(Box::new),
1492        }
1493    });
1494    let resp = match result {
1495        Ok(resp) => resp,
1496        Err(error) => match *error {
1497            ureq::Error::Status(_, resp) => resp,
1498            ureq::Error::Transport(error) => {
1499                return Err(LinkError::Transport {
1500                    hub: cfg.hub.clone(),
1501                    message: error.to_string(),
1502                });
1503            }
1504        },
1505    };
1506
1507    let status = resp.status();
1508    let mut buf = Vec::new();
1509    resp.into_reader()
1510        .take(options.max_response_bytes + 1)
1511        .read_to_end(&mut buf)?;
1512    if buf.len() as u64 > options.max_response_bytes {
1513        return Err(LinkError::ResponseTooLarge {
1514            limit_bytes: options.max_response_bytes,
1515        });
1516    }
1517    Ok(RawHubResponse { status, body: buf })
1518}
1519
1520fn request_capped(
1521    cfg: &HubConfig,
1522    method: &str,
1523    path: &str,
1524    body: Option<&Value>,
1525    auth: Auth,
1526    max_response_bytes: u64,
1527) -> LinkResult<HubResponse> {
1528    let raw = request_raw(cfg, method, path, body, auth, max_response_bytes)?;
1529    let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
1530    Ok(HubResponse {
1531        status: raw.status,
1532        body: parsed,
1533    })
1534}
1535
1536fn request(
1537    cfg: &HubConfig,
1538    method: &str,
1539    path: &str,
1540    body: Option<&Value>,
1541    auth: Auth,
1542) -> LinkResult<HubResponse> {
1543    request_capped(cfg, method, path, body, auth, MAX_RESPONSE_BYTES)
1544}
1545
1546/// Repeat one challenge-response mutation with the exact request identity the
1547/// hub bound into its actor claim. A new request id would create a new actor
1548/// hash and therefore a different canonical commit candidate, making the
1549/// otherwise correct self-custody signature unusable.
1550fn request_with_request_id(
1551    cfg: &HubConfig,
1552    method: &str,
1553    path: &str,
1554    body: Option<&Value>,
1555    auth: Auth,
1556    request_id: &str,
1557) -> LinkResult<HubResponse> {
1558    if request_id.is_empty()
1559        || request_id.len() > 128
1560        || !request_id
1561            .bytes()
1562            .all(|byte| byte.is_ascii_alphanumeric() || b"-_.:".contains(&byte))
1563    {
1564        return Err(invalid_feed("hub returned an unsafe request id"));
1565    }
1566    let http = hub_agent(cfg)?;
1567    let raw = request_raw_with_agent(
1568        cfg,
1569        &http,
1570        method,
1571        path,
1572        body,
1573        RawRequestOptions {
1574            auth,
1575            max_response_bytes: MAX_RESPONSE_BYTES,
1576            request_id: Some(request_id),
1577        },
1578    )?;
1579    Ok(HubResponse {
1580        status: raw.status,
1581        body: serde_json::from_slice(&raw.body).ok(),
1582    })
1583}
1584
1585fn ensure_raw_ok(r: RawHubResponse, what: &'static str) -> LinkResult<Vec<u8>> {
1586    if (200..300).contains(&r.status) {
1587        return Ok(r.body);
1588    }
1589    ensure_ok(
1590        HubResponse {
1591            status: r.status,
1592            body: serde_json::from_slice(&r.body).ok(),
1593        },
1594        what,
1595    )
1596    .and_then(|_| Err(invalid_feed("a non-2xx response was accepted unexpectedly")))
1597}
1598
1599/// These failures happen before any HTTP request reaches the hub, so retrying
1600/// cannot duplicate a mutation. Mid-stream I/O is deliberately excluded: once
1601/// bytes may have crossed the wire, the caller must rely on the verb's own
1602/// idempotency contract instead of guessing.
1603fn is_pre_request_transport(kind: ureq::ErrorKind) -> bool {
1604    matches!(
1605        kind,
1606        ureq::ErrorKind::Dns | ureq::ErrorKind::ConnectionFailed | ureq::ErrorKind::ProxyConnect
1607    )
1608}
1609
1610fn with_connect_retries(
1611    mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
1612) -> Result<ureq::Response, Box<ureq::Error>> {
1613    let mut attempt = 0;
1614    loop {
1615        match send() {
1616            Err(error)
1617                if matches!(
1618                    error.as_ref(),
1619                    ureq::Error::Transport(transport)
1620                        if is_pre_request_transport(transport.kind())
1621                ) && attempt + 1 < CONNECT_ATTEMPTS =>
1622            {
1623                std::thread::sleep(std::time::Duration::from_millis(
1624                    CONNECT_RETRY_BACKOFF_MS[attempt],
1625                ));
1626                attempt += 1;
1627            }
1628            result => return result,
1629        }
1630    }
1631}
1632
1633fn hub_is_loopback(hub: &str) -> bool {
1634    url::Url::parse(hub).ok().is_some_and(|parsed| {
1635        parsed.host().is_some_and(|host| match host {
1636            url::Host::Domain(host) => host.eq_ignore_ascii_case("localhost"),
1637            url::Host::Ipv4(ip) => ip.is_loopback(),
1638            url::Host::Ipv6(ip) => ip.is_loopback(),
1639        })
1640    })
1641}
1642
1643fn presigned_agent(cfg: &HubConfig, raw: &str) -> LinkResult<ureq::Agent> {
1644    let parsed = url::Url::parse(raw).map_err(|_| LinkError::InvalidPack {
1645        message: "the hub returned an invalid object-store URL".to_string(),
1646    })?;
1647    let allow_private = hub_is_loopback(&cfg.hub)
1648        || env_nonempty(ALLOW_PRIVATE_OBJECT_URL_ENV).as_deref() == Some("1");
1649    if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1650        || !parsed.username().is_empty()
1651        || parsed.password().is_some()
1652        || parsed.fragment().is_some()
1653    {
1654        return Err(LinkError::InvalidPack {
1655            message: "the hub returned an unsafe object-store URL".to_string(),
1656        });
1657    }
1658    pinned_public_agent(&parsed, allow_private, "object-store URL").map_err(|_| {
1659        LinkError::InvalidPack {
1660            message: "the hub returned an object-store URL with an unsafe network target"
1661                .to_string(),
1662        }
1663    })
1664}
1665
1666fn put_presigned(cfg: &HubConfig, raw: &str, headers: &Value, bytes: &[u8]) -> LinkResult<()> {
1667    let http = presigned_agent(cfg, raw)?;
1668    let result = with_connect_retries(|| {
1669        let mut req = http.put(raw);
1670        if let Some(map) = headers.as_object() {
1671            for (name, value) in map {
1672                if let Some(value) = value.as_str() {
1673                    req = req.set(name, value);
1674                }
1675            }
1676        }
1677        req.send_bytes(bytes).map_err(Box::new)
1678    });
1679    match result {
1680        Ok(resp) if (200..300).contains(&resp.status()) => Ok(()),
1681        Ok(resp) => Err(LinkError::Http {
1682            what: "pack upload",
1683            status: resp.status(),
1684            message: "object store rejected the upload".to_string(),
1685            code: None,
1686            details: None,
1687        }),
1688        Err(error) => match *error {
1689            // Immutable uploads use If-None-Match. A concurrent writer may
1690            // win the same content address; the commit/confirm endpoint reads
1691            // and hashes that object before accepting it, so 412 safely means
1692            // "continue to verification", never "trust the upload".
1693            ureq::Error::Status(412, _) => Ok(()),
1694            ureq::Error::Status(_, resp) => Err(LinkError::Http {
1695                what: "pack upload",
1696                status: resp.status(),
1697                message: "object store rejected the upload".to_string(),
1698                code: None,
1699                details: None,
1700            }),
1701            ureq::Error::Transport(err) => Err(LinkError::Transport {
1702                hub: "the object store".to_string(),
1703                message: err.to_string(),
1704            }),
1705        },
1706    }
1707}
1708
1709fn one_past_bounded_limit(max_bytes: u64) -> Option<u64> {
1710    max_bytes.checked_add(1)
1711}
1712
1713fn presigned_download_read_limit() -> u64 {
1714    one_past_bounded_limit(MAX_PACK_BYTES)
1715        .expect("the fixed presigned-download ceiling must leave room for the refusal byte")
1716}
1717
1718fn get_presigned(cfg: &HubConfig, raw: &str) -> LinkResult<Vec<u8>> {
1719    let http = presigned_agent(cfg, raw)?;
1720    let resp = match with_connect_retries(|| http.get(raw).call().map_err(Box::new)) {
1721        Ok(resp) => resp,
1722        Err(error) => match *error {
1723            ureq::Error::Status(_, resp) => {
1724                return Err(LinkError::Http {
1725                    what: "pack download",
1726                    status: resp.status(),
1727                    message: "object store rejected the download".to_string(),
1728                    code: None,
1729                    details: None,
1730                });
1731            }
1732            ureq::Error::Transport(err) => {
1733                return Err(LinkError::Transport {
1734                    hub: "the object store".to_string(),
1735                    message: err.to_string(),
1736                });
1737            }
1738        },
1739    };
1740    if !(200..300).contains(&resp.status()) {
1741        return Err(LinkError::Http {
1742            what: "pack download",
1743            status: resp.status(),
1744            message: "object store rejected the download".to_string(),
1745            code: None,
1746            details: None,
1747        });
1748    }
1749    let mut bytes = Vec::new();
1750    resp.into_reader()
1751        .take(presigned_download_read_limit())
1752        .read_to_end(&mut bytes)?;
1753    if bytes.len() as u64 > MAX_PACK_BYTES {
1754        return Err(LinkError::InvalidPack {
1755            message: "download exceeds the compressed-size limit".to_string(),
1756        });
1757    }
1758    Ok(bytes)
1759}
1760
1761/// Unwrap a successful JSON body, or shape the failure: a >=400 surfaces the
1762/// hub's own `error` + `code`; a 2xx without JSON is refused as not a hub
1763/// answer.
1764fn ensure_ok(r: HubResponse, what: &'static str) -> LinkResult<Value> {
1765    if !(200..300).contains(&r.status) {
1766        let message = r
1767            .body
1768            .as_ref()
1769            .and_then(|b| b.get("error"))
1770            .and_then(Value::as_str)
1771            .unwrap_or("unknown error")
1772            .to_string();
1773        let code = r
1774            .body
1775            .as_ref()
1776            .and_then(|b| b.get("code"))
1777            .and_then(Value::as_str)
1778            .map(str::to_string);
1779        let details = r.body.as_ref().and_then(|b| b.get("details")).cloned();
1780        return Err(LinkError::Http {
1781            what,
1782            status: r.status,
1783            message,
1784            code,
1785            details,
1786        });
1787    }
1788    r.body.ok_or(LinkError::NotJson {
1789        what,
1790        status: r.status,
1791    })
1792}
1793
1794// ─────────────────────────────────────────────────────────────────────────────
1795// resolve — handle → brain card; @brain/id → the record
1796// ─────────────────────────────────────────────────────────────────────────────
1797
1798/// Resolve an address. A bare `@brain` returns the brain card (metadata +
1799/// index stats — the v0 form of the card; keys arrive with the protocol's
1800/// signing layer). `@brain/<id>` and `@brain/<path>.md` return the full
1801/// record, frontmatter + body.
1802fn is_public_registry_ip(ip: std::net::IpAddr) -> bool {
1803    match ip {
1804        std::net::IpAddr::V4(ip) => {
1805            let [a, b, c, _] = ip.octets();
1806            !(a == 0
1807                || a == 10
1808                || a == 127
1809                || (a == 100 && (64..=127).contains(&b))
1810                || (a == 169 && b == 254)
1811                || (a == 172 && (16..=31).contains(&b))
1812                || (a == 192 && b == 0 && c == 0)
1813                || (a == 192 && b == 0 && c == 2)
1814                || (a == 192 && b == 88 && c == 99)
1815                || (a == 192 && b == 168)
1816                || (a == 198 && (b == 18 || b == 19))
1817                || (a == 198 && b == 51 && c == 100)
1818                || (a == 203 && b == 0 && c == 113)
1819                || a >= 224)
1820        }
1821        std::net::IpAddr::V6(ip) => {
1822            let segments = ip.segments();
1823            // Conservatively accept only global unicast 2000::/3, excluding
1824            // special-purpose/transition blocks. In particular, 6to4 embeds
1825            // an IPv4 destination and must not tunnel a validated public
1826            // connect to 127/8 or RFC1918.
1827            (segments[0] & 0xe000) == 0x2000
1828                && !(segments[0] == 0x2001 && (segments[1] & 0xfe00) == 0)
1829                && !(segments[0] == 0x2001 && segments[1] == 0x0db8)
1830                && segments[0] != 0x2002
1831                && !(segments[0] == 0x3fff && (segments[1] & 0xf000) == 0)
1832        }
1833    }
1834}
1835
1836#[derive(Clone)]
1837struct PinnedRegistryResolver {
1838    netloc: String,
1839    addresses: Vec<std::net::SocketAddr>,
1840}
1841
1842impl ureq::Resolver for PinnedRegistryResolver {
1843    fn resolve(&self, requested: &str) -> std::io::Result<Vec<std::net::SocketAddr>> {
1844        if requested == self.netloc {
1845            Ok(self.addresses.clone())
1846        } else {
1847            Err(std::io::Error::new(
1848                std::io::ErrorKind::PermissionDenied,
1849                "registry request attempted to resolve an unvalidated authority",
1850            ))
1851        }
1852    }
1853}
1854
1855fn pinned_public_agent(
1856    url: &url::Url,
1857    allow_private: bool,
1858    label: &str,
1859) -> LinkResult<ureq::Agent> {
1860    let host = url
1861        .host_str()
1862        .ok_or_else(|| invalid_feed(format!("{label} has no host")))?;
1863    let port = url
1864        .port_or_known_default()
1865        .ok_or_else(|| invalid_feed(format!("{label} has no port")))?;
1866    let addresses = resolve_addresses_with_deadline(
1867        host,
1868        port,
1869        std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS),
1870    )
1871    .map_err(|error| invalid_feed(format!("{label} DNS resolution failed: {error}")))?;
1872    if addresses.is_empty() {
1873        return Err(invalid_feed(format!("{label} DNS returned no addresses")));
1874    }
1875    if !allow_private
1876        && addresses
1877            .iter()
1878            .any(|address| !is_public_registry_ip(address.ip()))
1879    {
1880        return Err(invalid_feed(format!(
1881            "{label} resolves to a non-public address"
1882        )));
1883    }
1884    let netloc = if host.contains(':') {
1885        format!("[{host}]:{port}")
1886    } else {
1887        format!("{host}:{port}")
1888    };
1889    Ok(agent_builder()
1890        .resolver(PinnedRegistryResolver { netloc, addresses })
1891        .build())
1892}
1893
1894/// Resolve one authority without allowing libc DNS to hold the caller forever.
1895/// `ToSocketAddrs` itself has no timeout API, so resolution runs in a detached
1896/// worker and only its result channel is awaited. A late resolver result has no
1897/// side effects and is discarded after the deadline.
1898fn resolve_addresses_with_deadline(
1899    host: &str,
1900    port: u16,
1901    timeout: std::time::Duration,
1902) -> std::io::Result<Vec<std::net::SocketAddr>> {
1903    use std::net::ToSocketAddrs as _;
1904
1905    let host = host.to_string();
1906    let (send, receive) = std::sync::mpsc::sync_channel(1);
1907    std::thread::Builder::new()
1908        .name("dbmd-dns".to_string())
1909        .spawn(move || {
1910            let result = (host.as_str(), port)
1911                .to_socket_addrs()
1912                .map(|addresses| addresses.collect());
1913            let _ = send.send(result);
1914        })
1915        .map_err(|error| std::io::Error::other(format!("cannot start resolver: {error}")))?;
1916    match receive.recv_timeout(timeout) {
1917        Ok(result) => result,
1918        Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(std::io::Error::new(
1919            std::io::ErrorKind::TimedOut,
1920            "resolution exceeded its deadline",
1921        )),
1922        Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(std::io::Error::other(
1923            "resolver stopped without returning a result",
1924        )),
1925    }
1926}
1927
1928fn registry_agent(url: &url::Url) -> LinkResult<ureq::Agent> {
1929    let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
1930    pinned_public_agent(url, allow_private, "registry home")
1931}
1932
1933/// GET an absolute URL as JSON with NO credential — used to fetch a brain card
1934/// from a FOREIGN home during registry resolution. DNS is resolved once,
1935/// every answer is required to be public, and that exact answer set is pinned
1936/// into a no-redirect HTTP agent to close private-network SSRF and rebinding.
1937fn get_json_absolute(url: &str) -> LinkResult<Value> {
1938    let parsed = url::Url::parse(url).map_err(|_| invalid_feed("invalid registry home URL"))?;
1939    let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
1940    if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1941        || !parsed.username().is_empty()
1942        || parsed.password().is_some()
1943        || parsed.query().is_some()
1944        || parsed.fragment().is_some()
1945    {
1946        return Err(invalid_feed("unsafe registry home URL"));
1947    }
1948    let http = registry_agent(&parsed)?;
1949    let resp = match with_connect_retries(|| http.get(url).call().map_err(Box::new)) {
1950        Ok(resp) => resp,
1951        Err(error) => match *error {
1952            ureq::Error::Status(status, resp) => {
1953                let _ = resp;
1954                return Err(LinkError::Http {
1955                    what: "registry home fetch",
1956                    status,
1957                    message: "the home node rejected the card request".to_string(),
1958                    code: None,
1959                    details: None,
1960                });
1961            }
1962            ureq::Error::Transport(err) => {
1963                return Err(LinkError::Transport {
1964                    hub: url.to_string(),
1965                    message: err.to_string(),
1966                });
1967            }
1968        },
1969    };
1970    if !(200..300).contains(&resp.status()) {
1971        return Err(LinkError::Http {
1972            what: "registry home fetch",
1973            status: resp.status(),
1974            message: "the home node returned a redirect or error".to_string(),
1975            code: None,
1976            details: None,
1977        });
1978    }
1979    let mut buf = Vec::new();
1980    resp.into_reader()
1981        .take(MAX_REGISTRY_CARD_BYTES + 1)
1982        .read_to_end(&mut buf)?;
1983    if buf.len() as u64 > MAX_REGISTRY_CARD_BYTES {
1984        return Err(LinkError::ResponseTooLarge {
1985            limit_bytes: MAX_REGISTRY_CARD_BYTES,
1986        });
1987    }
1988    serde_json::from_slice(&buf).map_err(|_| LinkError::InvalidFeed {
1989        message: "the home node returned invalid JSON".to_string(),
1990    })
1991}
1992
1993/// Resolve a bare `@handle` through the federation registry (link.md §7.1,
1994/// E5): look the handle up in the hub's registry, fetch the brain card from
1995/// the returned HOME node, and PIN — the card's identity fingerprint must
1996/// equal the registry's, or resolution fails. Returns the card enriched with
1997/// the resolved `home`, or `Ok(None)` when the registry has no such handle
1998/// (so the caller can fall back to a direct lookup).
1999pub fn resolve_registry(cfg: &HubConfig, handle: &str) -> LinkResult<Option<Value>> {
2000    require_safe_ref(handle)?;
2001    // Hold the validated trust-directory capability before any network I/O.
2002    // Every lock/load/save below remains relative to this exact inode even if
2003    // an attacker swaps an ancestor while the registry or home is answering.
2004    let trust_directory = open_trust_dir(cfg)?;
2005    let reg = request_capped(
2006        cfg,
2007        "GET",
2008        &format!("/api/hub/registry/{handle}"),
2009        None,
2010        Auth::None,
2011        MAX_REGISTRY_CARD_BYTES,
2012    )?;
2013    if reg.status == 404 {
2014        return Ok(None);
2015    }
2016    let body = ensure_ok(reg, "registry resolve")?;
2017    let home = body
2018        .get("home")
2019        .and_then(Value::as_str)
2020        .ok_or_else(|| invalid_feed("registry entry has no home"))?;
2021    let brain = body
2022        .get("brain")
2023        .and_then(Value::as_str)
2024        .ok_or_else(|| invalid_feed("registry entry has no brain"))?;
2025    if !crate::ulid::is_ulid(brain) {
2026        return Err(invalid_feed(
2027            "registry entry brain is not a canonical lowercase ULID",
2028        ));
2029    }
2030    let want_fp = body
2031        .get("identity")
2032        .and_then(|i| i.get("fingerprint"))
2033        .and_then(Value::as_str)
2034        .ok_or_else(|| invalid_feed("registry entry has no identity fingerprint"))?;
2035
2036    let home = home.trim_end_matches('/');
2037    let origin = normalized_origin(home)?;
2038    if origin != home {
2039        return Err(invalid_feed(
2040            "registry home must be an origin without a path, query, or fragment",
2041        ));
2042    }
2043    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[handle, brain])?;
2044    let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, handle, brain)?;
2045    if let Some(binding) = &alias_binding {
2046        if binding
2047            .home
2048            .as_deref()
2049            .is_some_and(|pinned_home| pinned_home != home)
2050        {
2051            return Err(invalid_feed(
2052                "registry relocated a pinned handle to a different home",
2053            ));
2054        }
2055    }
2056    let card = get_json_absolute(&format!("{home}/api/hub/brains/{brain}"))?;
2057    if card.get("id").and_then(Value::as_str) != Some(brain) {
2058        return Err(invalid_feed(
2059            "the home node served a card for a different brain",
2060        ));
2061    }
2062    let identity: FeedIdentity = serde_json::from_value(
2063        card.get("identity")
2064            .cloned()
2065            .ok_or_else(|| invalid_feed("the home node served no identity"))?,
2066    )
2067    .map_err(|_| invalid_feed("the home node served an invalid identity"))?;
2068    let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
2069    let got_fp = card
2070        .get("identity")
2071        .and_then(|i| i.get("fingerprint"))
2072        .and_then(Value::as_str)
2073        .unwrap_or_default();
2074    if got_fp != want_fp {
2075        return Err(invalid_feed(
2076            "the home node served an identity that does not match the registry — refusing",
2077        ));
2078    }
2079    let current = format!("ed25519:{}", identity.fingerprint);
2080    let advertised_seq = card
2081        .get("headSeq")
2082        .and_then(Value::as_u64)
2083        .ok_or_else(|| invalid_feed("the home node served an invalid head sequence"))?;
2084    let advertised_hash = card.get("feedHash").and_then(Value::as_str);
2085    if (advertised_seq == 0 && !card.get("feedHash").is_some_and(Value::is_null))
2086        || (advertised_seq > 0 && advertised_hash.is_none_or(|hash| !is_sha256(hash)))
2087    {
2088        return Err(invalid_feed(
2089            "the home node served an invalid feed head boundary",
2090        ));
2091    }
2092    // This is required even on first contact. Otherwise a syntactically valid
2093    // identity may claim that a rotation happened after a head the home has
2094    // never reached, then become the permanent TOFU anchor.
2095    verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], advertised_seq)?;
2096    let registry_alias = AliasBinding {
2097        v: 1,
2098        origin: normalized_origin(&cfg.hub)?,
2099        requested: handle.to_string(),
2100        brain: brain.to_string(),
2101        home: Some(home.to_string()),
2102    };
2103    save_canonical_pin_and_alias(
2104        cfg,
2105        &trust_directory,
2106        handle,
2107        brain,
2108        TrustState {
2109            v: 2,
2110            origin: normalized_origin(&cfg.hub)?,
2111            requested: brain.to_string(),
2112            brain: brain.to_string(),
2113            home: None,
2114            anchor,
2115            current,
2116            head_seq: pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq),
2117            feed_hash: pinned
2118                .as_ref()
2119                .and_then(|checkpoint| checkpoint.feed_hash.clone()),
2120            rotations: identity.rotations.clone(),
2121            hub_signer: None,
2122            protocol_profile: None,
2123        },
2124        Some(&registry_alias),
2125    )?;
2126    let mut out = card;
2127    if let Value::Object(map) = &mut out {
2128        map.insert("home".to_string(), Value::String(home.to_string()));
2129        map.insert(
2130            "resolvedVia".to_string(),
2131            Value::String("registry".to_string()),
2132        );
2133    }
2134    Ok(Some(out))
2135}
2136
2137pub fn resolve(cfg: &HubConfig, addr: &Address) -> LinkResult<Value> {
2138    // `Address::parse` refuses these shapes already, but `Address` has public
2139    // fields — re-assert at the wire so a hand-built address can never
2140    // reshape the request path.
2141    require_safe_ref(&addr.brain)?;
2142    if let Some(target) = &addr.target {
2143        let (given, ok) = match target {
2144            AddressTarget::Id(id) => (id, crate::ulid::is_ulid(id)),
2145            AddressTarget::Path(p) => (p, safe_store_rel_path(p) && p.ends_with(".md")),
2146        };
2147        if !ok {
2148            return Err(LinkError::BadAddress {
2149                given: given.clone(),
2150                reason: BAD_TARGET_REASON.to_string(),
2151            });
2152        }
2153    }
2154
2155    // A record is never accepted from the hub's mutable query/index response.
2156    // Under v2, an id lookup may suggest a path, but the client accepts only
2157    // bytes whose exact path proof reaches the verified content root and whose
2158    // parsed id still matches. Under v1, the signed snapshot pack remains the
2159    // binding. Both profiles therefore reject a forged query response.
2160    if let Some(target) = &addr.target {
2161        if let Some(head) = v2_verified_head(cfg, &addr.brain)? {
2162            let pointer = head.pointer.as_ref().ok_or_else(|| LinkError::Http {
2163                what: "resolve",
2164                status: 404,
2165                message: "record not found".to_string(),
2166                code: Some("NOT_FOUND".to_string()),
2167                details: None,
2168            })?;
2169            let (path, file) = match target {
2170                AddressTarget::Path(path) => {
2171                    let file =
2172                        v2_manifest_file(cfg, &head.brain_id, pointer, path)?.ok_or_else(|| {
2173                            LinkError::Http {
2174                                what: "resolve",
2175                                status: 404,
2176                                message: "record not found".to_string(),
2177                                code: Some("NOT_FOUND".to_string()),
2178                                details: None,
2179                            }
2180                        })?;
2181                    (path.clone(), file)
2182                }
2183                AddressTarget::Id(id) => v2_manifest_file_by_id(cfg, &head.brain_id, pointer, id)?,
2184            };
2185            let mut downloaded =
2186                download_v2_blobs(cfg, &head.brain_id, pointer, vec![(&path, &file)])?;
2187            let (_, bytes) = downloaded
2188                .pop()
2189                .ok_or_else(|| invalid_feed("v2 record download returned no bytes"))?;
2190            let resolved = resolve_from_verified_record_bytes(&head.brain_id, target, path, bytes)?;
2191            accept_v2_head(cfg, &head)?;
2192            return Ok(resolved);
2193        }
2194        let remote = verified_remote_head(cfg, &addr.brain, false)?;
2195        if !remote.head.verified {
2196            return Err(invalid_feed(
2197                "a path-scoped feed cannot prove a record against the full signed snapshot",
2198            ));
2199        }
2200        if remote.head.seq == 0 {
2201            return Err(LinkError::Http {
2202                what: "resolve",
2203                status: 404,
2204                message: "record not found".to_string(),
2205                code: Some("NOT_FOUND".to_string()),
2206                details: None,
2207            });
2208        }
2209        let brain = remote.head.brain.clone();
2210        let pack = download_verified_snapshot_pack(cfg, &brain, &remote)?;
2211        return resolve_from_verified_pack(&brain, target, pack);
2212    }
2213
2214    let path = format!("/api/hub/brains/{}", addr.brain);
2215    // Direct first: the caller's own slug and hub-hosted public handles resolve
2216    // here unchanged. Only a bare `@handle` the hub can't resolve directly
2217    // (404) falls through to the federation registry — how a handle reaches a
2218    // brain on ANOTHER node (link.md §7.1, E5).
2219    let direct = request(cfg, "GET", &path, None, Auth::Required)?;
2220    if direct.status == 404 && addr.target.is_none() && !crate::ulid::is_ulid(&addr.brain) {
2221        if let Some(card) = resolve_registry(cfg, &addr.brain)? {
2222            return Ok(card);
2223        }
2224    }
2225    let mut resolved = ensure_ok(direct, "resolve")?;
2226    if resolved.get("storageProfile").and_then(Value::as_str) == Some("v2") {
2227        let v2 = v2_verified_head(cfg, &addr.brain)?
2228            .ok_or_else(|| invalid_feed("v2 resolve card has no verified v2 head"))?;
2229        if resolved.get("id").and_then(Value::as_str) != Some(v2.brain_id.as_str()) {
2230            return Err(invalid_feed(
2231                "resolve card is not bound to the verified v2 brain",
2232            ));
2233        }
2234        let card_identity: FeedIdentity = serde_json::from_value(
2235            resolved
2236                .get("identity")
2237                .cloned()
2238                .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2239        )
2240        .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2241        if card_identity != v2_identity(&v2.identity) {
2242            return Err(invalid_feed(
2243                "resolve card identity differs from the verified v2 identity",
2244            ));
2245        }
2246        accept_v2_head(cfg, &v2)?;
2247        if let Value::Object(card) = &mut resolved {
2248            card.insert(
2249                "headSeq".to_string(),
2250                json!(v2.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
2251            );
2252            card.insert(
2253                "feedHash".to_string(),
2254                v2.pointer
2255                    .as_ref()
2256                    .map(|pointer| Value::String(pointer.feed_hash.clone()))
2257                    .unwrap_or(Value::Null),
2258            );
2259            card.insert(
2260                "storageProfile".to_string(),
2261                Value::String("v2".to_string()),
2262            );
2263            if let Some(pointer) = &v2.pointer {
2264                card.insert(
2265                    "updatedAt".to_string(),
2266                    Value::String(pointer.signed_at.clone()),
2267                );
2268            }
2269        }
2270        return Ok(resolved);
2271    }
2272    // A successful direct response is accepted only after the same centralized
2273    // identity/rotation/feed checkpoint verification used by sync and
2274    // subscribe. No verb gets a weaker ad-hoc pinning path.
2275    let remote = verified_remote_head(cfg, &addr.brain, false)?;
2276    if resolved.get("id").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2277        || resolved.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2278        || resolved.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
2279    {
2280        return Err(invalid_feed(
2281            "resolve card is not bound to the exact verified feed checkpoint",
2282        ));
2283    }
2284    let card_identity: FeedIdentity = serde_json::from_value(
2285        resolved
2286            .get("identity")
2287            .cloned()
2288            .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2289    )
2290    .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2291    if remote.identity.as_ref() != Some(&card_identity) {
2292        return Err(invalid_feed(
2293            "resolve card identity differs from the verified feed identity",
2294        ));
2295    }
2296    Ok(resolved)
2297}
2298
2299/// Resolve one record strictly from the exact signed snapshot pack. The hub's
2300/// mutable query/index result is intentionally not consulted: a signed pack
2301/// digest is the only cryptographic binding between a feed checkpoint and
2302/// record bytes in wire profile v1.
2303fn resolve_from_verified_pack(
2304    brain: &str,
2305    target: &AddressTarget,
2306    pack: Vec<u8>,
2307) -> LinkResult<Value> {
2308    let entries = parse_store_pack(pack)?;
2309    let mut matched: Option<(String, Vec<u8>)> = None;
2310
2311    for (path, bytes) in entries {
2312        let is_candidate = match target {
2313            AddressTarget::Path(want) => &path == want,
2314            AddressTarget::Id(_) => {
2315                path.ends_with(".md")
2316                    && (path.starts_with("records/") || path.starts_with("sources/"))
2317            }
2318        };
2319        if !is_candidate {
2320            continue;
2321        }
2322        let text = std::str::from_utf8(&bytes)
2323            .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2324        let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2325            .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2326        if let AddressTarget::Id(want) = target {
2327            let frontmatter =
2328                crate::parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(&path))
2329                    .map_err(|_| {
2330                        invalid_feed(format!("signed snapshot record `{path}` is malformed"))
2331                    })?;
2332            if frontmatter.id.as_deref() != Some(want) {
2333                continue;
2334            }
2335        }
2336        if matched.is_some() {
2337            return Err(invalid_feed(
2338                "signed snapshot contains more than one record for the requested target",
2339            ));
2340        }
2341        matched = Some((path, bytes));
2342    }
2343
2344    let (path, bytes) = matched.ok_or_else(|| LinkError::Http {
2345        what: "resolve",
2346        status: 404,
2347        message: "record not found".to_string(),
2348        code: Some("NOT_FOUND".to_string()),
2349        details: None,
2350    })?;
2351    resolve_from_verified_record_bytes(brain, target, path, bytes)
2352}
2353
2354fn resolve_from_verified_record_bytes(
2355    brain: &str,
2356    target: &AddressTarget,
2357    path: String,
2358    bytes: Vec<u8>,
2359) -> LinkResult<Value> {
2360    match target {
2361        AddressTarget::Path(expected) if expected != &path => {
2362            return Err(invalid_feed(
2363                "verified record path differs from the requested path",
2364            ));
2365        }
2366        AddressTarget::Id(_) if !(path.starts_with("records/") || path.starts_with("sources/")) => {
2367            return Err(invalid_feed(
2368                "verified id resolved outside records or sources",
2369            ));
2370        }
2371        _ => {}
2372    }
2373    let text = std::str::from_utf8(&bytes)
2374        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2375    let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2376        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2377    let frontmatter: Value = serde_norway::from_str(&parsed.frontmatter_yaml)
2378        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2379    let Value::Object(fields) = frontmatter else {
2380        return Err(invalid_feed(format!(
2381            "signed snapshot record `{path}` frontmatter is not a mapping"
2382        )));
2383    };
2384    if let AddressTarget::Id(expected) = target {
2385        if fields.get("id").and_then(Value::as_str) != Some(expected.as_str()) {
2386            return Err(invalid_feed(
2387                "verified record id differs from the requested id",
2388            ));
2389        }
2390    }
2391    let mut document = serde_json::Map::new();
2392    document.insert("path".to_string(), Value::String(path));
2393    for (key, value) in fields {
2394        document.insert(key, value);
2395    }
2396    document.insert("body".to_string(), Value::String(parsed.body));
2397    document.insert(
2398        "contentSha".to_string(),
2399        Value::String(content_sha256(&bytes)),
2400    );
2401    Ok(json!({
2402        "brain": brain,
2403        "document": Value::Object(document),
2404    }))
2405}
2406
2407// ─────────────────────────────────────────────────────────────────────────────
2408// sync — pull the granted slice as files; push the local store as a snapshot
2409// ─────────────────────────────────────────────────────────────────────────────
2410
2411/// What a pull materialized.
2412#[derive(Debug, Clone, serde::Serialize)]
2413pub struct PullReport {
2414    /// The brain id the hub reported.
2415    pub brain: String,
2416    /// The brain's slug.
2417    pub slug: String,
2418    /// The hub's feed head at export time.
2419    #[serde(rename = "headSeq")]
2420    pub head_seq: u64,
2421    /// How many files were written.
2422    pub files: usize,
2423    /// Where they were written (as given or derived from the slug).
2424    pub dest: String,
2425    /// Local content files that the export did not carry — present so a
2426    /// caller sees divergence; nothing is ever deleted locally.
2427    #[serde(rename = "extraLocal")]
2428    pub extra_local: Vec<String>,
2429    /// Exact convergence result from the post-install barrier.
2430    #[serde(rename = "syncStatus")]
2431    pub sync_status: String,
2432}
2433
2434struct V2PulledSnapshot {
2435    report: PullReport,
2436    head: V2VerifiedHead,
2437    files: std::collections::BTreeMap<String, V2BaselineFile>,
2438    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
2439    local: V2LocalView,
2440    local_assets: std::collections::BTreeMap<String, crate::AssetRecord>,
2441}
2442
2443fn download_verified_snapshot_pack(
2444    cfg: &HubConfig,
2445    brain: &str,
2446    remote: &VerifiedRemote,
2447) -> LinkResult<Vec<u8>> {
2448    let feed_hash = remote
2449        .head
2450        .feed_hash
2451        .as_deref()
2452        .ok_or_else(|| invalid_feed("non-empty snapshot has no verified feed hash"))?;
2453    let signed_head = remote
2454        .head_entry
2455        .as_ref()
2456        .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
2457    let expected = &signed_head.entry.pack_sha256;
2458    if !is_sha256(expected) {
2459        return Err(invalid_feed(
2460            "signed head carries an invalid snapshot pack digest",
2461        ));
2462    }
2463    let path = format!(
2464        "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={feed_hash}",
2465        remote.head.seq
2466    );
2467    let body = ensure_ok(
2468        request(cfg, "GET", &path, None, Auth::Required)?,
2469        "sync pull",
2470    )?;
2471    if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2472        || body.get("feedHash").and_then(Value::as_str) != Some(feed_hash)
2473        || body.get("brain").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2474        || body.get("sha256").and_then(Value::as_str) != Some(expected.as_str())
2475    {
2476        return Err(invalid_feed(
2477            "export response is not bound to the exact verified snapshot",
2478        ));
2479    }
2480    let url = body
2481        .get("url")
2482        .and_then(Value::as_str)
2483        .ok_or_else(|| invalid_feed("verified snapshot export carried no exact pack URL"))?;
2484    let bytes = get_presigned(cfg, url)?;
2485    if content_sha256(&bytes) != *expected {
2486        return Err(LinkError::InvalidPack {
2487            message: "downloaded pack does not match the signed snapshot digest".to_string(),
2488        });
2489    }
2490    let entries = parse_store_pack(bytes.clone())?;
2491    if signed_head.entry.kind == "push" {
2492        verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
2493    }
2494    Ok(bytes)
2495}
2496
2497#[derive(Debug, Clone, Deserialize, Serialize)]
2498struct V2PointerBody {
2499    v: u8,
2500    brain: String,
2501    seq: u64,
2502    commit_hash: String,
2503    feed_hash: String,
2504    content_root: Option<String>,
2505    asset_root: Option<String>,
2506    materializer: String,
2507    signer_epoch: u64,
2508    control_revision: String,
2509    backup_preparation: String,
2510    prior_pointer_hash: Option<String>,
2511    signed_at: String,
2512}
2513
2514#[derive(Debug, Clone, Deserialize)]
2515struct V2SignedPointer {
2516    pointer: V2PointerBody,
2517    hub_public_key: String,
2518    hub_fingerprint: String,
2519    sig: String,
2520}
2521
2522#[derive(Debug, Clone, Deserialize)]
2523struct V2HeadIdentity {
2524    #[serde(default)]
2525    custody: String,
2526    fingerprint: String,
2527    public_key_spki: String,
2528    #[serde(default)]
2529    previous: Vec<V2PreviousIdentity>,
2530    #[serde(default)]
2531    rotations: Vec<String>,
2532}
2533
2534#[derive(Debug, Clone, Deserialize)]
2535struct V2PreviousIdentity {
2536    fingerprint: String,
2537    public_key_spki: String,
2538}
2539
2540#[derive(Debug, Deserialize)]
2541struct V2HeadResponse {
2542    v: u8,
2543    brain_id: String,
2544    profile: String,
2545    view: Option<V2HeadView>,
2546    pointer: Option<V2SignedPointer>,
2547    identity: Option<V2HeadIdentity>,
2548}
2549
2550#[derive(Debug, Clone, Deserialize)]
2551struct V2HeadView {
2552    kind: String,
2553    #[serde(default)]
2554    id: Option<String>,
2555    control_revision: String,
2556}
2557
2558#[derive(Debug, Clone)]
2559struct V2VerifiedHead {
2560    requested: String,
2561    brain_id: String,
2562    view_kind: String,
2563    /// Stable identity of the exact current-content path projection.
2564    view_revision: String,
2565    /// Use-time authority revision independently bound into writes/signatures.
2566    control_revision: String,
2567    identity: V2HeadIdentity,
2568    pointer: Option<V2PointerBody>,
2569    trust: TrustState,
2570    alias: Option<AliasBinding>,
2571}
2572
2573fn verify_v2_spki_signature(
2574    public_key: &str,
2575    message: &[u8],
2576    signature: &str,
2577) -> LinkResult<Vec<u8>> {
2578    let der = URL_SAFE_NO_PAD
2579        .decode(public_key)
2580        .map_err(|_| invalid_feed("v2 signer public key is not base64url"))?;
2581    if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
2582        return Err(invalid_feed("v2 signer public key is not Ed25519 SPKI"));
2583    }
2584    let sig = URL_SAFE_NO_PAD
2585        .decode(signature)
2586        .map_err(|_| invalid_feed("v2 signature is not base64url"))?;
2587    UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
2588        .verify(message, &sig)
2589        .map_err(|_| invalid_feed("v2 Ed25519 signature failed"))?;
2590    Ok(der)
2591}
2592
2593fn verify_v2_pointer(pointer: &V2SignedPointer, expected_brain: &str) -> LinkResult<String> {
2594    if pointer.pointer.v != 2
2595        || pointer.pointer.brain != expected_brain
2596        || pointer.pointer.seq == 0
2597        || !is_sha256(&pointer.pointer.commit_hash)
2598        || !is_sha256(&pointer.pointer.feed_hash)
2599        || pointer
2600            .pointer
2601            .content_root
2602            .as_deref()
2603            .is_some_and(|hash| !is_sha256(hash))
2604        || !is_sha256(&pointer.pointer.backup_preparation)
2605    {
2606        return Err(invalid_feed("v2 pointer fields are invalid"));
2607    }
2608    let value = serde_json::to_value(&pointer.pointer)
2609        .map_err(|_| invalid_feed("v2 pointer could not be canonicalized"))?;
2610    let message = crate::linkmd_v2::canonical_bytes(&value)
2611        .map_err(|error| invalid_feed(error.to_string()))?;
2612    let der = verify_v2_spki_signature(&pointer.hub_public_key, &message, &pointer.sig)?;
2613    let fingerprint = format!("{:x}", Sha256::digest(&der));
2614    if fingerprint != pointer.hub_fingerprint {
2615        return Err(invalid_feed("v2 hub signer fingerprint mismatch"));
2616    }
2617    Ok(format!(
2618        "{}:{}",
2619        pointer.hub_fingerprint, pointer.hub_public_key
2620    ))
2621}
2622
2623fn v2_identity(identity: &V2HeadIdentity) -> FeedIdentity {
2624    FeedIdentity {
2625        fingerprint: identity.fingerprint.clone(),
2626        public_key_spki: identity.public_key_spki.clone(),
2627        previous: identity
2628            .previous
2629            .iter()
2630            .map(|previous| PreviousIdentity {
2631                fingerprint: previous.fingerprint.clone(),
2632                public_key_spki: previous.public_key_spki.clone(),
2633            })
2634            .collect(),
2635        rotations: identity.rotations.clone(),
2636    }
2637}
2638
2639fn verified_v2_commit_object(
2640    raw: &[u8],
2641    identity: &V2HeadIdentity,
2642) -> LinkResult<serde_json::Map<String, Value>> {
2643    let mut value: Value =
2644        serde_json::from_slice(raw).map_err(|_| invalid_feed("v2 commit is not JSON"))?;
2645    let canonical = crate::linkmd_v2::canonical_bytes(&value)
2646        .map_err(|error| invalid_feed(error.to_string()))?;
2647    if canonical != raw {
2648        return Err(invalid_feed("v2 commit is not canonical JSON"));
2649    }
2650    let object = value
2651        .as_object_mut()
2652        .ok_or_else(|| invalid_feed("v2 commit is not an object"))?;
2653    let sig = object
2654        .remove("sig")
2655        .and_then(|value| value.as_str().map(str::to_string))
2656        .ok_or_else(|| invalid_feed("v2 commit has no signature"))?;
2657    const FIELDS: [&str; 18] = [
2658        "actor_ref",
2659        "asset_root",
2660        "brain",
2661        "changes_sha256",
2662        "control_revision",
2663        "materializer",
2664        "op",
2665        "parent_asset_root",
2666        "parent_commit",
2667        "parent_root",
2668        "prev_entry_hash",
2669        "public_key",
2670        "seq",
2671        "signer_epoch",
2672        "state_root",
2673        "ts",
2674        "v",
2675        "v1_bridge",
2676    ];
2677    if object.len() != FIELDS.len() || FIELDS.iter().any(|field| !object.contains_key(*field)) {
2678        return Err(invalid_feed("v2 commit has a non-normative field set"));
2679    }
2680    let seq = object
2681        .get("seq")
2682        .and_then(Value::as_u64)
2683        .filter(|seq| *seq > 0)
2684        .ok_or_else(|| invalid_feed("v2 commit has an invalid sequence"))?;
2685    let signer_epoch = object
2686        .get("signer_epoch")
2687        .and_then(Value::as_u64)
2688        .filter(|epoch| *epoch > 0)
2689        .ok_or_else(|| invalid_feed("v2 commit has an invalid signer epoch"))?;
2690    let hash_or_null = |field: &str| {
2691        object
2692            .get(field)
2693            .is_some_and(|value| value.is_null() || value.as_str().is_some_and(is_sha256))
2694    };
2695    if object.get("v").and_then(Value::as_u64) != Some(2)
2696        || object.get("op").and_then(Value::as_str) != Some("changeset")
2697        || !object
2698            .get("changes_sha256")
2699            .and_then(Value::as_str)
2700            .is_some_and(is_sha256)
2701        || !object
2702            .get("actor_ref")
2703            .and_then(Value::as_str)
2704            .is_some_and(is_sha256)
2705        || !object
2706            .get("control_revision")
2707            .and_then(Value::as_str)
2708            .is_some_and(is_sha256)
2709        || !object
2710            .get("state_root")
2711            .and_then(Value::as_str)
2712            .is_some_and(is_sha256)
2713        || !hash_or_null("parent_commit")
2714        || !hash_or_null("parent_root")
2715        || !hash_or_null("parent_asset_root")
2716        || !hash_or_null("asset_root")
2717        || !hash_or_null("prev_entry_hash")
2718        || !object
2719            .get("materializer")
2720            .and_then(Value::as_str)
2721            .is_some_and(|value| !value.is_empty() && value.len() <= 128)
2722        || !object
2723            .get("ts")
2724            .and_then(Value::as_str)
2725            .is_some_and(|value| value.len() == 24 && value.ends_with('Z'))
2726    {
2727        return Err(invalid_feed("v2 commit fields are invalid"));
2728    }
2729    if (seq == 1
2730        && [
2731            "parent_commit",
2732            "parent_root",
2733            "parent_asset_root",
2734            "prev_entry_hash",
2735        ]
2736        .iter()
2737        .any(|field| !object.get(*field).is_some_and(Value::is_null)))
2738        || (seq > 1
2739            && ["parent_commit", "parent_root", "prev_entry_hash"]
2740                .iter()
2741                .any(|field| object.get(*field).and_then(Value::as_str).is_none()))
2742    {
2743        return Err(invalid_feed("v2 commit parent shape is invalid"));
2744    }
2745    match object.get("v1_bridge") {
2746        Some(Value::Null) => {}
2747        Some(Value::Object(bridge))
2748            if seq == 1
2749                && bridge.len() == 3
2750                && bridge
2751                    .get("head_seq")
2752                    .and_then(Value::as_u64)
2753                    .is_some_and(|v| v > 0)
2754                && bridge
2755                    .get("feed_hash")
2756                    .and_then(Value::as_str)
2757                    .is_some_and(is_sha256)
2758                && bridge
2759                    .get("pack_sha256")
2760                    .and_then(Value::as_str)
2761                    .is_some_and(is_sha256) => {}
2762        _ => return Err(invalid_feed("v2 commit has an invalid v1 bridge")),
2763    }
2764    let public_key = object
2765        .get("public_key")
2766        .and_then(Value::as_str)
2767        .ok_or_else(|| invalid_feed("v2 commit has no public key"))?;
2768    let der = URL_SAFE_NO_PAD
2769        .decode(public_key)
2770        .map_err(|_| invalid_feed("v2 brain public key is not base64url"))?;
2771    let expected_multikey = format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&der)));
2772    if object.get("brain").and_then(Value::as_str) != Some(expected_multikey.as_str()) {
2773        return Err(invalid_feed("v2 commit brain identity mismatch"));
2774    }
2775    // Verify the old-key-signed chain before trusting any prior public key.
2776    verify_identity_chain(&v2_identity(identity), None)?;
2777    // Old commits remain verifiable after brain-key rotation. `previous` is
2778    // newest-first, while signer epochs and rotation statements are oldest-first.
2779    let mut chain: Vec<(&str, &str)> = identity
2780        .previous
2781        .iter()
2782        .rev()
2783        .map(|previous| {
2784            (
2785                previous.fingerprint.as_str(),
2786                previous.public_key_spki.as_str(),
2787            )
2788        })
2789        .collect();
2790    chain.push((&identity.fingerprint, &identity.public_key_spki));
2791    let signer_index = chain.iter().position(|(fingerprint, spki)| {
2792        *fingerprint == expected_multikey.trim_start_matches("ed25519:") && *spki == public_key
2793    });
2794    let Some(signer_index) = signer_index else {
2795        return Err(invalid_feed("v2 commit uses an unrecognized brain key"));
2796    };
2797    if signer_epoch != signer_index as u64 + 1 {
2798        return Err(invalid_feed("v2 commit signer epoch differs from its key"));
2799    }
2800    let lower_boundary = if signer_index == 0 {
2801        None
2802    } else {
2803        let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
2804            .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
2805        Some(prior.prior_head_seq)
2806    };
2807    let upper_boundary = if signer_index == identity.rotations.len() {
2808        None
2809    } else {
2810        let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
2811            .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
2812        Some(next.prior_head_seq)
2813    };
2814    if lower_boundary.is_some_and(|boundary| seq <= boundary)
2815        || upper_boundary.is_some_and(|boundary| seq > boundary)
2816    {
2817        return Err(invalid_feed(
2818            "v2 commit signer is outside its authenticated rotation epoch",
2819        ));
2820    }
2821    let unsigned = crate::linkmd_v2::canonical_bytes(&Value::Object(object.clone()))
2822        .map_err(|error| invalid_feed(error.to_string()))?;
2823    verify_v2_spki_signature(public_key, &unsigned, &sig)?;
2824    Ok(object.clone())
2825}
2826
2827#[derive(Debug, Deserialize)]
2828struct V2FeedWireEntry {
2829    seq: u64,
2830    commit_hash: String,
2831    feed_hash: String,
2832    bytes_base64: String,
2833}
2834
2835#[derive(Debug, Deserialize)]
2836struct V2FeedPage {
2837    v: u8,
2838    head_seq: u64,
2839    head_commit_hash: String,
2840    head_feed_hash: String,
2841    entries: Vec<V2FeedWireEntry>,
2842    next_after: u64,
2843    complete: bool,
2844}
2845
2846fn replay_v2_feed(
2847    cfg: &HubConfig,
2848    brain: &str,
2849    pointer: &V2PointerBody,
2850    identity: &V2HeadIdentity,
2851    start_after: u64,
2852    start_feed: Option<String>,
2853) -> LinkResult<()> {
2854    let mut after = start_after;
2855    let mut prior_feed = start_feed;
2856    let mut final_object = None;
2857    let mut replayed_entries = 0_u64;
2858    let mut replayed_bytes = 0_u64;
2859    while after < pointer.seq {
2860        let path = format!("/api/hub/brains/{brain}/v2/feed?after={after}&limit=100");
2861        let value = ensure_ok(
2862            request_capped(
2863                cfg,
2864                "GET",
2865                &path,
2866                None,
2867                Auth::Required,
2868                MAX_FEED_REPLAY_BYTES,
2869            )?,
2870            "v2 feed replay",
2871        )?;
2872        let page: V2FeedPage = serde_json::from_value(value)
2873            .map_err(|_| invalid_feed("v2 feed page has an invalid shape"))?;
2874        if page.v != 2
2875            || page.head_seq != pointer.seq
2876            || page.head_commit_hash != pointer.commit_hash
2877            || page.head_feed_hash != pointer.feed_hash
2878            || page.entries.is_empty()
2879            || page.entries.len() > FEED_PAGE_LIMIT
2880        {
2881            return Err(invalid_feed("v2 feed page differs from the signed head"));
2882        }
2883        for entry in page.entries {
2884            if entry.seq != after + 1
2885                || !is_sha256(&entry.commit_hash)
2886                || !is_sha256(&entry.feed_hash)
2887            {
2888                return Err(invalid_feed("v2 feed sequence is not contiguous"));
2889            }
2890            let raw = base64::engine::general_purpose::STANDARD
2891                .decode(&entry.bytes_base64)
2892                .map_err(|_| invalid_feed("v2 feed bytes are not canonical base64"))?;
2893            replayed_entries = replayed_entries
2894                .checked_add(1)
2895                .ok_or_else(|| invalid_feed("v2 feed replay count overflow"))?;
2896            replayed_bytes = replayed_bytes
2897                .checked_add(raw.len() as u64)
2898                .ok_or_else(|| invalid_feed("v2 feed replay byte count overflow"))?;
2899            if replayed_entries > MAX_FEED_REPLAY_ENTRIES || replayed_bytes > MAX_FEED_REPLAY_BYTES
2900            {
2901                return Err(invalid_feed("v2 feed replay exceeds its safety bound"));
2902            }
2903            if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
2904                .map_err(|error| invalid_feed(error.to_string()))?
2905                != entry.commit_hash
2906                || content_sha256(&raw) != entry.feed_hash
2907            {
2908                return Err(invalid_feed("v2 feed entry address mismatch"));
2909            }
2910            let object = verified_v2_commit_object(&raw, identity)?;
2911            if object.get("seq").and_then(Value::as_u64) != Some(entry.seq)
2912                || object.get("prev_entry_hash").and_then(Value::as_str) != prior_feed.as_deref()
2913            {
2914                return Err(invalid_feed(
2915                    "v2 feed entry does not extend its predecessor",
2916                ));
2917            }
2918            after = entry.seq;
2919            prior_feed = Some(entry.feed_hash);
2920            final_object = Some((entry.commit_hash, object));
2921        }
2922        if page.next_after != after || (page.complete != (after == pointer.seq)) {
2923            return Err(invalid_feed("v2 feed page cursor is inconsistent"));
2924        }
2925    }
2926    let (final_hash, object) =
2927        final_object.ok_or_else(|| invalid_feed("v2 feed replay made no progress"))?;
2928    if final_hash != pointer.commit_hash
2929        || prior_feed.as_deref() != Some(pointer.feed_hash.as_str())
2930        || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
2931        || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
2932        || object.get("control_revision").and_then(Value::as_str)
2933            != Some(pointer.control_revision.as_str())
2934        || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
2935    {
2936        return Err(invalid_feed(
2937            "v2 replay did not converge on the signed pointer",
2938        ));
2939    }
2940    Ok(())
2941}
2942
2943fn verify_v1_to_v2_bridge(
2944    cfg: &HubConfig,
2945    brain: &str,
2946    pointer: &V2PointerBody,
2947    identity: &V2HeadIdentity,
2948    checkpoint: &TrustState,
2949) -> LinkResult<()> {
2950    let value = ensure_ok(
2951        request_capped(
2952            cfg,
2953            "GET",
2954            &format!("/api/hub/brains/{brain}/v2/feed?after=0&limit=1"),
2955            None,
2956            Auth::Required,
2957            MAX_FEED_RESPONSE_BYTES,
2958        )?,
2959        "v2 genesis bridge",
2960    )?;
2961    let page: V2FeedPage = serde_json::from_value(value)
2962        .map_err(|_| invalid_feed("v2 genesis bridge page has an invalid shape"))?;
2963    if page.v != 2
2964        || page.head_seq != pointer.seq
2965        || page.head_commit_hash != pointer.commit_hash
2966        || page.head_feed_hash != pointer.feed_hash
2967        || page.entries.len() != 1
2968        || page.entries[0].seq != 1
2969        || !is_sha256(&page.entries[0].commit_hash)
2970        || !is_sha256(&page.entries[0].feed_hash)
2971    {
2972        return Err(invalid_feed(
2973            "v2 genesis bridge page differs from the signed head",
2974        ));
2975    }
2976    let first = &page.entries[0];
2977    let raw = STANDARD
2978        .decode(&first.bytes_base64)
2979        .map_err(|_| invalid_feed("v2 genesis bridge bytes are not canonical base64"))?;
2980    if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
2981        .map_err(|error| invalid_feed(error.to_string()))?
2982        != first.commit_hash
2983        || content_sha256(&raw) != first.feed_hash
2984    {
2985        return Err(invalid_feed("v2 genesis bridge address mismatch"));
2986    }
2987    let object = verified_v2_commit_object(&raw, identity)?;
2988    if checkpoint.head_seq == 0 {
2989        if checkpoint.feed_hash.is_some() || object.get("v1_bridge") != Some(&Value::Null) {
2990            return Err(invalid_feed(
2991                "empty v1 checkpoint did not transition through an empty v2 genesis",
2992            ));
2993        }
2994        return Ok(());
2995    }
2996    let bridge = object
2997        .get("v1_bridge")
2998        .and_then(Value::as_object)
2999        .ok_or_else(|| invalid_feed("v2 genesis omitted the pinned v1 boundary"))?;
3000    let checkpoint_feed = checkpoint
3001        .feed_hash
3002        .as_deref()
3003        .ok_or_else(|| invalid_feed("non-empty v1 checkpoint has no feed hash"))?;
3004    if bridge.get("head_seq").and_then(Value::as_u64) != Some(checkpoint.head_seq)
3005        || bridge.get("feed_hash").and_then(Value::as_str) != Some(checkpoint_feed)
3006    {
3007        return Err(invalid_feed(
3008            "v2 genesis bridge differs from the pinned v1 checkpoint",
3009        ));
3010    }
3011    let legacy_raw = ensure_raw_ok(
3012        request_raw(
3013            cfg,
3014            "GET",
3015            &format!(
3016                "/api/hub/brains/{brain}/feed?after={}&limit=1",
3017                checkpoint.head_seq - 1
3018            ),
3019            None,
3020            Auth::Required,
3021            MAX_FEED_RESPONSE_BYTES,
3022        )?,
3023        "v1 bridge boundary",
3024    )?;
3025    let legacy: FeedResponse = serde_json::from_slice(&legacy_raw)
3026        .map_err(|_| invalid_feed("v1 bridge boundary has an invalid feed shape"))?;
3027    let legacy_identity = legacy
3028        .identity
3029        .ok_or_else(|| invalid_feed("v1 bridge boundary has no identity"))?;
3030    let item = legacy
3031        .entries
3032        .first()
3033        .filter(|_| legacy.entries.len() == 1)
3034        .ok_or_else(|| invalid_feed("v1 bridge boundary did not return one exact entry"))?;
3035    if legacy.scope_limited
3036        || legacy.head_seq != checkpoint.head_seq
3037        || legacy.feed_hash.as_deref() != Some(checkpoint_feed)
3038        || item.entry.seq != checkpoint.head_seq
3039        || item.hash != checkpoint_feed
3040        || legacy_identity != v2_identity(identity)
3041        || bridge.get("pack_sha256").and_then(Value::as_str)
3042            != Some(item.entry.pack_sha256.as_str())
3043    {
3044        return Err(invalid_feed(
3045            "v1 bridge boundary differs from its signed legacy head",
3046        ));
3047    }
3048    let anchor = verify_identity_chain(&legacy_identity, Some(checkpoint))?;
3049    if anchor != checkpoint.anchor {
3050        return Err(invalid_feed("v1 bridge changed the pinned identity anchor"));
3051    }
3052    verify_feed_item(item, &legacy_identity)?;
3053    verify_rotation_feed_boundaries(
3054        &legacy_identity,
3055        Some(checkpoint),
3056        std::slice::from_ref(item),
3057        checkpoint.head_seq,
3058    )?;
3059    Ok(())
3060}
3061
3062fn verify_v2_commit(
3063    cfg: &HubConfig,
3064    brain: &str,
3065    pointer: &V2PointerBody,
3066    identity: &V2HeadIdentity,
3067    pinned: Option<&TrustState>,
3068) -> LinkResult<()> {
3069    let path = format!(
3070        "/api/hub/brains/{brain}/v2/commit?commit={}",
3071        pointer.commit_hash
3072    );
3073    let raw = ensure_raw_ok(
3074        request_raw(cfg, "GET", &path, None, Auth::Required, MAX_RESPONSE_BYTES)?,
3075        "v2 commit",
3076    )?;
3077    if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3078        .map_err(|error| invalid_feed(error.to_string()))?
3079        != pointer.commit_hash
3080        || content_sha256(&raw) != pointer.feed_hash
3081    {
3082        return Err(invalid_feed("v2 commit address differs from the pointer"));
3083    }
3084    let object = verified_v2_commit_object(&raw, identity)?;
3085    if object.get("seq").and_then(Value::as_u64) != Some(pointer.seq)
3086        || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
3087        || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
3088        || object.get("control_revision").and_then(Value::as_str)
3089            != Some(pointer.control_revision.as_str())
3090        || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
3091    {
3092        return Err(invalid_feed("v2 commit fields differ from the pointer"));
3093    }
3094    if let Some(checkpoint) = pinned.filter(|checkpoint| accepted_as_v2(checkpoint)) {
3095        if pointer.seq == checkpoint.head_seq + 1
3096            && object.get("prev_entry_hash").and_then(Value::as_str)
3097                != checkpoint.feed_hash.as_deref()
3098        {
3099            return Err(invalid_feed(
3100                "v2 commit does not extend the pinned feed hash",
3101            ));
3102        }
3103        if pointer.seq > checkpoint.head_seq + 1 {
3104            return replay_v2_feed(
3105                cfg,
3106                brain,
3107                pointer,
3108                identity,
3109                checkpoint.head_seq,
3110                checkpoint.feed_hash.clone(),
3111            );
3112        }
3113    } else {
3114        if let Some(checkpoint) = pinned {
3115            verify_v1_to_v2_bridge(cfg, brain, pointer, identity, checkpoint)?;
3116        }
3117        if pointer.seq > 1 {
3118            return replay_v2_feed(cfg, brain, pointer, identity, 0, None);
3119        }
3120    }
3121    Ok(())
3122}
3123
3124fn v2_verified_head(cfg: &HubConfig, brain: &str) -> LinkResult<Option<V2VerifiedHead>> {
3125    require_hardened_filesystem("verified link.md v2 state")?;
3126    require_safe_ref(brain)?;
3127    // Open and pin the private trust directory before a credential can leave
3128    // the process. A hostile checkout must not turn an unsafe state root into
3129    // a network oracle or receive a bearer before checkpoint safety is known.
3130    let trust_directory = open_trust_dir(cfg)?;
3131    let path = format!("/api/hub/brains/{brain}/v2/head");
3132    let response = request(cfg, "GET", &path, None, Auth::Required)?;
3133    if response.status == 404 {
3134        if has_accepted_v2_ref(cfg, brain)? {
3135            return Err(LinkError::BrainUnavailable);
3136        }
3137        return Ok(None);
3138    }
3139    let body = ensure_ok(response, "v2 head")?;
3140    let head: V2HeadResponse = serde_json::from_value(body)
3141        .map_err(|_| invalid_feed("v2 head response has an invalid shape"))?;
3142    if head.v != 2 || !crate::ulid::is_ulid(&head.brain_id) {
3143        return Err(invalid_feed("v2 head has no canonical brain id"));
3144    }
3145    if crate::ulid::is_ulid(brain) && head.brain_id != brain {
3146        return Err(invalid_feed("v2 head resolved a different brain id"));
3147    }
3148    if head.profile == "v1" {
3149        return Ok(None);
3150    }
3151    if head.profile != "v2" && head.profile != "v2-empty" {
3152        return Err(invalid_feed("v2 head advertised an unknown profile"));
3153    }
3154    let view = head
3155        .view
3156        .as_ref()
3157        .ok_or_else(|| invalid_feed("v2 head has no permission view"))?;
3158    if !matches!(view.kind.as_str(), "full" | "scoped")
3159        || !is_sha256(&view.control_revision)
3160        || view.id.as_deref().is_some_and(|id| !is_sha256(id))
3161    {
3162        return Err(invalid_feed("v2 head has an invalid permission view"));
3163    }
3164    let view_kind = view.kind.clone();
3165    // `id` is the stable materialized namespace. Older v2 hubs did not expose
3166    // it, so retain the fail-closed control-revision fallback for compatibility.
3167    let view_revision = view
3168        .id
3169        .clone()
3170        .unwrap_or_else(|| view.control_revision.clone());
3171    let control_revision = view.control_revision.clone();
3172    let identity = head
3173        .identity
3174        .as_ref()
3175        .ok_or_else(|| invalid_feed("v2 head has no brain identity"))?;
3176    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &head.brain_id])?;
3177    let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, brain, &head.brain_id)?;
3178    let feed_identity = v2_identity(identity);
3179    let anchor = verify_identity_chain(&feed_identity, pinned.as_ref())?;
3180    let (seq, feed_hash, hub_signer) = match &head.pointer {
3181        None => {
3182            if head.profile != "v2-empty" {
3183                return Err(invalid_feed("initialized v2 head has no pointer"));
3184            }
3185            (
3186                0,
3187                None,
3188                pinned.as_ref().and_then(|state| state.hub_signer.clone()),
3189            )
3190        }
3191        Some(signed) => {
3192            let signer = verify_v2_pointer(signed, &head.brain_id)?;
3193            if pinned
3194                .as_ref()
3195                .and_then(|state| state.hub_signer.as_ref())
3196                .is_some_and(|known| known != &signer)
3197            {
3198                return Err(invalid_feed(
3199                    "v2 hub pointer signer changed without a trust transition",
3200                ));
3201            }
3202            if let Some(checkpoint) = pinned.as_ref().filter(|state| accepted_as_v2(state)) {
3203                if signed.pointer.seq < checkpoint.head_seq
3204                    || (signed.pointer.seq == checkpoint.head_seq
3205                        && checkpoint.feed_hash.as_deref()
3206                            != Some(signed.pointer.feed_hash.as_str()))
3207                {
3208                    return Err(invalid_feed("v2 pointer rolled back or equivocated"));
3209                }
3210            }
3211            verify_v2_commit(
3212                cfg,
3213                &head.brain_id,
3214                &signed.pointer,
3215                identity,
3216                pinned.as_ref(),
3217            )?;
3218            (
3219                signed.pointer.seq,
3220                Some(signed.pointer.feed_hash.clone()),
3221                Some(signer),
3222            )
3223        }
3224    };
3225    let trust = TrustState {
3226        v: 2,
3227        origin: normalized_origin(&cfg.hub)?,
3228        requested: head.brain_id.clone(),
3229        brain: head.brain_id.clone(),
3230        home: None,
3231        anchor,
3232        current: format!("ed25519:{}", identity.fingerprint),
3233        head_seq: seq,
3234        feed_hash,
3235        rotations: identity.rotations.clone(),
3236        hub_signer,
3237        protocol_profile: Some("link-v2".to_string()),
3238    };
3239    Ok(Some(V2VerifiedHead {
3240        requested: brain.to_string(),
3241        brain_id: head.brain_id,
3242        view_kind,
3243        view_revision,
3244        control_revision,
3245        identity: identity.clone(),
3246        pointer: head.pointer.map(|signed| signed.pointer),
3247        trust,
3248        alias: alias_binding,
3249    }))
3250}
3251
3252fn accept_v2_head(cfg: &HubConfig, head: &V2VerifiedHead) -> LinkResult<()> {
3253    let directory = open_trust_dir(cfg)?;
3254    let _locks = lock_trust_many(cfg, &directory, &[&head.requested, &head.brain_id])?;
3255    let (current, alias) = load_canonical_pin(cfg, &directory, &head.requested, &head.brain_id)?;
3256    if let Some(current) = current {
3257        let common_invalid = head.trust.anchor != current.anchor
3258            || !head.trust.rotations.starts_with(&current.rotations);
3259        let profile_invalid = if accepted_as_v2(&current) {
3260            head.trust.head_seq < current.head_seq
3261                || (head.trust.head_seq == current.head_seq
3262                    && head.trust.feed_hash != current.feed_hash)
3263                || current
3264                    .hub_signer
3265                    .as_ref()
3266                    .is_some_and(|known| head.trust.hub_signer.as_ref() != Some(known))
3267        } else {
3268            head.trust.protocol_profile.as_deref() != Some("link-v2")
3269                || head.trust.hub_signer.is_none()
3270        };
3271        if common_invalid || profile_invalid {
3272            return Err(invalid_feed(
3273                "v2 head cannot advance the currently accepted trust checkpoint",
3274            ));
3275        }
3276    }
3277    save_canonical_pin_and_alias(
3278        cfg,
3279        &directory,
3280        &head.requested,
3281        &head.brain_id,
3282        head.trust.clone(),
3283        alias.as_ref().or(head.alias.as_ref()),
3284    )
3285}
3286
3287#[derive(Debug, Clone, Deserialize, Serialize)]
3288struct V2BaselineFile {
3289    sha256: String,
3290    bytes: u64,
3291    #[serde(skip)]
3292    proof: Option<Vec<V2ProofStep>>,
3293}
3294
3295#[derive(Debug, Clone, Deserialize, Serialize)]
3296struct V2SyncBaseline {
3297    v: u8,
3298    origin: String,
3299    brain: String,
3300    #[serde(default)]
3301    checkout_id: Option<String>,
3302    #[serde(default)]
3303    head_seq: Option<u64>,
3304    commit_hash: Option<String>,
3305    content_root: Option<String>,
3306    #[serde(default)]
3307    asset_root: Option<String>,
3308    #[serde(default)]
3309    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
3310    #[serde(default)]
3311    view_kind: Option<String>,
3312    #[serde(default)]
3313    view_revision: Option<String>,
3314    #[serde(default)]
3315    projection_sha256: Option<String>,
3316    files: std::collections::BTreeMap<String, V2BaselineFile>,
3317    #[serde(default)]
3318    local_policy_digest: Option<String>,
3319    #[serde(default)]
3320    local_eligibility: std::collections::BTreeMap<String, bool>,
3321    #[serde(default)]
3322    remote_copy_remains: std::collections::BTreeMap<String, String>,
3323}
3324
3325struct V2LocalView {
3326    riding: std::collections::BTreeMap<String, (String, u64)>,
3327    eligibility: std::collections::BTreeMap<String, bool>,
3328    policy: crate::linkmd_sync_policy::SyncPolicy,
3329    withheld_links: Vec<V2WithheldLink>,
3330}
3331
3332#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
3333struct V2WithheldLink {
3334    source: String,
3335    target: String,
3336}
3337
3338#[derive(Debug, Clone, Deserialize, Serialize)]
3339struct V2ProofStep {
3340    directory_root: String,
3341    component: String,
3342    proof: crate::linkmd_v2::HamtProof,
3343}
3344
3345#[derive(Debug, Deserialize)]
3346struct V2ManifestFile {
3347    path: String,
3348    sha256: String,
3349    bytes: u64,
3350    proof: Vec<V2ProofStep>,
3351}
3352
3353#[derive(Debug, Deserialize)]
3354struct V2ManifestPage {
3355    v: u8,
3356    commit: String,
3357    content_root: Option<String>,
3358    files: Vec<V2ManifestFile>,
3359    next_cursor: Option<String>,
3360}
3361
3362#[derive(Debug, Clone, Deserialize, Serialize)]
3363struct V2BaselineAsset {
3364    blob_sha256: String,
3365    bytes: u64,
3366    media_type: String,
3367    wrappers: Vec<String>,
3368    required: bool,
3369    disposition: String,
3370    leaf_hash: String,
3371}
3372
3373#[derive(Debug, Deserialize)]
3374struct V2AssetManifestItem {
3375    path: String,
3376    blob_sha256: String,
3377    bytes: u64,
3378    media_type: String,
3379    wrappers: Vec<String>,
3380    required: bool,
3381    disposition: String,
3382    leaf_hash: String,
3383    proof: crate::linkmd_v2::HamtProof,
3384}
3385
3386#[derive(Debug, Deserialize)]
3387struct V2AssetManifestPage {
3388    v: u8,
3389    commit: String,
3390    asset_root: Option<String>,
3391    assets: Vec<V2AssetManifestItem>,
3392    next_cursor: Option<String>,
3393}
3394
3395#[derive(Debug, Deserialize)]
3396struct V2SigningCandidate {
3397    seq: u64,
3398    content_root: Option<String>,
3399    asset_root: Option<String>,
3400    signing_bytes_base64: String,
3401    changes_base64: String,
3402    actor_claim_base64: String,
3403}
3404
3405#[derive(Debug, Deserialize)]
3406struct V2SigningCandidatePage {
3407    v: u8,
3408    challenge_id: String,
3409    mutation_id: String,
3410    request_hash: String,
3411    parent: V2SigningParent,
3412    candidate: V2SigningCandidate,
3413    files: Vec<V2ManifestFile>,
3414    #[serde(default)]
3415    assets: Vec<V2AssetManifestItem>,
3416    next_cursor: Option<String>,
3417    expires_at: String,
3418}
3419
3420#[derive(Debug, Deserialize)]
3421struct V2SigningParent {
3422    seq: u64,
3423    commit_hash: Option<String>,
3424}
3425
3426fn verify_v2_file_proof(root: &str, file: &V2ManifestFile) -> LinkResult<()> {
3427    let normalized = crate::linkmd_v2::normalize_path(&file.path)
3428        .map_err(|error| invalid_feed(error.to_string()))?;
3429    let components = normalized.split('/').collect::<Vec<_>>();
3430    if components.len() != file.proof.len() || !is_sha256(&file.sha256) {
3431        return Err(invalid_feed("v2 file proof has the wrong shape"));
3432    }
3433    let mut directory_root = root.to_string();
3434    for (index, step) in file.proof.iter().enumerate() {
3435        if step.directory_root != directory_root || step.component != components[index] {
3436            return Err(invalid_feed(
3437                "v2 file proof path chain differs from its manifest",
3438            ));
3439        }
3440        if !crate::linkmd_v2::verify_proof(&directory_root, &step.component, &step.proof)
3441            .map_err(|error| invalid_feed(error.to_string()))?
3442        {
3443            return Err(invalid_feed("v2 file proof failed verification"));
3444        }
3445        let entry = match &step.proof {
3446            crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry,
3447            crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
3448                return Err(invalid_feed("v2 manifest carried a non-inclusion proof"));
3449            }
3450        };
3451        if index + 1 == components.len() {
3452            if entry.kind != crate::linkmd_v2::EntryKind::Blob
3453                || entry.child_hash != file.sha256
3454                || entry.bytes != Some(file.bytes)
3455            {
3456                return Err(invalid_feed("v2 file proof leaf differs from its manifest"));
3457            }
3458        } else if entry.kind != crate::linkmd_v2::EntryKind::Tree {
3459            return Err(invalid_feed("v2 file proof traversed a non-directory"));
3460        } else {
3461            directory_root = entry.child_hash.clone();
3462        }
3463    }
3464    Ok(())
3465}
3466
3467fn v2_manifest(
3468    cfg: &HubConfig,
3469    brain: &str,
3470    pointer: Option<&V2PointerBody>,
3471) -> LinkResult<std::collections::BTreeMap<String, V2BaselineFile>> {
3472    let Some(pointer) = pointer else {
3473        return Ok(std::collections::BTreeMap::new());
3474    };
3475    let Some(root) = pointer.content_root.as_deref() else {
3476        return Ok(std::collections::BTreeMap::new());
3477    };
3478    let mut files = std::collections::BTreeMap::new();
3479    let mut after = String::new();
3480    loop {
3481        let encoded_after: String =
3482            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3483        let path = format!(
3484            "/api/hub/brains/{brain}/v2/files?commit={}&limit=500&after={encoded_after}",
3485            pointer.commit_hash
3486        );
3487        let value = ensure_ok(
3488            request_capped(
3489                cfg,
3490                "GET",
3491                &path,
3492                None,
3493                Auth::Required,
3494                MAX_FEED_RESPONSE_BYTES,
3495            )?,
3496            "v2 file manifest",
3497        )?;
3498        let page: V2ManifestPage = serde_json::from_value(value)
3499            .map_err(|_| invalid_feed("v2 file manifest has an invalid shape"))?;
3500        if page.v != 2
3501            || page.commit != pointer.commit_hash
3502            || page.content_root.as_deref() != Some(root)
3503            || page.files.len() > 500
3504        {
3505            return Err(invalid_feed(
3506                "v2 file manifest is not bound to the verified head",
3507            ));
3508        }
3509        for file in page.files {
3510            verify_v2_file_proof(root, &file)?;
3511            if files
3512                .insert(
3513                    file.path.clone(),
3514                    V2BaselineFile {
3515                        sha256: file.sha256,
3516                        bytes: file.bytes,
3517                        proof: Some(file.proof),
3518                    },
3519                )
3520                .is_some()
3521            {
3522                return Err(invalid_feed("v2 file manifest repeats a path"));
3523            }
3524            if files.len() > MAX_PUSH_FILES {
3525                return Err(invalid_feed(
3526                    "v2 file manifest exceeds the file-count bound",
3527                ));
3528            }
3529        }
3530        match page.next_cursor {
3531            None => break,
3532            Some(next) if next > after => after = next,
3533            Some(_) => return Err(invalid_feed("v2 file manifest cursor did not advance")),
3534        }
3535    }
3536    Ok(files)
3537}
3538
3539/// Fetch one exact path proof instead of enumerating the whole permission
3540/// view. This keeps `resolve @brain/path` proportional to path depth even for
3541/// a very large brain. The returned leaf is accepted only after its complete
3542/// proof chain reaches the content root signed by `pointer`.
3543fn v2_manifest_file(
3544    cfg: &HubConfig,
3545    brain: &str,
3546    pointer: &V2PointerBody,
3547    path: &str,
3548) -> LinkResult<Option<V2BaselineFile>> {
3549    let Some(root) = pointer.content_root.as_deref() else {
3550        return Ok(None);
3551    };
3552    crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
3553        path: error.to_string(),
3554    })?;
3555    let encoded: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
3556    let value = ensure_ok(
3557        request_capped(
3558            cfg,
3559            "GET",
3560            &format!(
3561                "/api/hub/brains/{brain}/v2/files?commit={}&path={encoded}",
3562                pointer.commit_hash
3563            ),
3564            None,
3565            Auth::Required,
3566            MAX_FEED_RESPONSE_BYTES,
3567        )?,
3568        "v2 exact file proof",
3569    )?;
3570    let mut page: V2ManifestPage = serde_json::from_value(value)
3571        .map_err(|_| invalid_feed("v2 exact file proof has an invalid shape"))?;
3572    if page.v != 2
3573        || page.commit != pointer.commit_hash
3574        || page.content_root.as_deref() != Some(root)
3575        || page.next_cursor.is_some()
3576        || page.files.len() != 1
3577        || page.files[0].path != path
3578    {
3579        return Err(invalid_feed(
3580            "v2 exact file proof is not bound to the requested signed path",
3581        ));
3582    }
3583    let file = page.files.pop().expect("exactly one file was checked");
3584    verify_v2_file_proof(root, &file)?;
3585    Ok(Some(V2BaselineFile {
3586        sha256: file.sha256,
3587        bytes: file.bytes,
3588        proof: Some(file.proof),
3589    }))
3590}
3591
3592/// Resolve an id through the hub's synchronously maintained, content-light
3593/// validation certificate, then require the same exact signed path proof as a
3594/// path lookup. The certificate is only an efficient locator: the downloaded
3595/// bytes are parsed again and their id is checked by the caller.
3596fn v2_manifest_file_by_id(
3597    cfg: &HubConfig,
3598    brain: &str,
3599    pointer: &V2PointerBody,
3600    id: &str,
3601) -> LinkResult<(String, V2BaselineFile)> {
3602    let root = pointer
3603        .content_root
3604        .as_deref()
3605        .ok_or_else(|| LinkError::Http {
3606            what: "resolve",
3607            status: 404,
3608            message: "record not found".to_string(),
3609            code: Some("NOT_FOUND".to_string()),
3610            details: None,
3611        })?;
3612    if !crate::ulid::is_ulid(id) {
3613        return Err(LinkError::BadAddress {
3614            given: id.to_string(),
3615            reason: BAD_TARGET_REASON.to_string(),
3616        });
3617    }
3618    let encoded: String = url::form_urlencoded::byte_serialize(id.as_bytes()).collect();
3619    let value = ensure_ok(
3620        request_capped(
3621            cfg,
3622            "GET",
3623            &format!(
3624                "/api/hub/brains/{brain}/v2/files?commit={}&id={encoded}",
3625                pointer.commit_hash
3626            ),
3627            None,
3628            Auth::Required,
3629            MAX_FEED_RESPONSE_BYTES,
3630        )?,
3631        "v2 exact id proof",
3632    )?;
3633    let mut page: V2ManifestPage = serde_json::from_value(value)
3634        .map_err(|_| invalid_feed("v2 exact id proof has an invalid shape"))?;
3635    if page.v != 2
3636        || page.commit != pointer.commit_hash
3637        || page.content_root.as_deref() != Some(root)
3638        || page.next_cursor.is_some()
3639        || page.files.len() != 1
3640    {
3641        return Err(invalid_feed(
3642            "v2 exact id proof is not bound to one signed path",
3643        ));
3644    }
3645    let file = page.files.pop().expect("exactly one file was checked");
3646    if !safe_store_rel_path(&file.path)
3647        || !file.path.ends_with(".md")
3648        || !(file.path.starts_with("records/") || file.path.starts_with("sources/"))
3649    {
3650        return Err(invalid_feed("v2 exact id proof has an invalid record path"));
3651    }
3652    verify_v2_file_proof(root, &file)?;
3653    Ok((
3654        file.path,
3655        V2BaselineFile {
3656            sha256: file.sha256,
3657            bytes: file.bytes,
3658            proof: Some(file.proof),
3659        },
3660    ))
3661}
3662
3663fn verify_v2_asset_proof(root: &str, item: &V2AssetManifestItem) -> LinkResult<()> {
3664    crate::linkmd_v2::normalize_path(&item.path)
3665        .map_err(|error| invalid_feed(error.to_string()))?;
3666    if !is_sha256(&item.blob_sha256)
3667        || !is_sha256(&item.leaf_hash)
3668        || item.wrappers.is_empty()
3669        || !matches!(item.disposition.as_str(), "hosted" | "withheld")
3670        || item
3671            .wrappers
3672            .iter()
3673            .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
3674    {
3675        return Err(invalid_feed("v2 asset manifest item is invalid"));
3676    }
3677    let leaf = json!({
3678        "blob_sha256": item.blob_sha256,
3679        "bytes": item.bytes,
3680        "disposition": item.disposition,
3681        "media_type": item.media_type,
3682        "path": item.path,
3683        "required": item.required,
3684        "v": 2,
3685        "wrappers": item.wrappers,
3686    });
3687    if crate::linkmd_v2::domain_hash("v2/asset-leaf", &leaf)
3688        .map_err(|error| invalid_feed(error.to_string()))?
3689        != item.leaf_hash
3690        || !crate::linkmd_v2::verify_proof_with_domain(
3691            root,
3692            &item.path,
3693            &item.proof,
3694            crate::linkmd_v2::ASSET_TREE_HASH_DOMAIN,
3695        )
3696        .map_err(|error| invalid_feed(error.to_string()))?
3697    {
3698        return Err(invalid_feed("v2 asset inclusion proof failed"));
3699    }
3700    match &item.proof {
3701        crate::linkmd_v2::HamtProof::Inclusion { entry, .. }
3702            if entry.name == item.path
3703                && entry.kind == crate::linkmd_v2::EntryKind::Blob
3704                && entry.child_hash == item.leaf_hash
3705                && entry.bytes == Some(item.bytes) =>
3706        {
3707            Ok(())
3708        }
3709        _ => Err(invalid_feed(
3710            "v2 asset proof leaf differs from its manifest",
3711        )),
3712    }
3713}
3714
3715fn v2_asset_manifest(
3716    cfg: &HubConfig,
3717    brain: &str,
3718    pointer: Option<&V2PointerBody>,
3719) -> LinkResult<std::collections::BTreeMap<String, V2BaselineAsset>> {
3720    let Some(pointer) = pointer else {
3721        return Ok(std::collections::BTreeMap::new());
3722    };
3723    let Some(root) = pointer.asset_root.as_deref() else {
3724        return Ok(std::collections::BTreeMap::new());
3725    };
3726    let mut assets = std::collections::BTreeMap::new();
3727    let mut after = String::new();
3728    loop {
3729        let encoded_after: String =
3730            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3731        let path = format!(
3732            "/api/hub/brains/{brain}/v2/assets?commit={}&limit=500&after={encoded_after}",
3733            pointer.commit_hash
3734        );
3735        let value = ensure_ok(
3736            request_capped(
3737                cfg,
3738                "GET",
3739                &path,
3740                None,
3741                Auth::Required,
3742                MAX_FEED_RESPONSE_BYTES,
3743            )?,
3744            "v2 asset manifest",
3745        )?;
3746        let page: V2AssetManifestPage = serde_json::from_value(value)
3747            .map_err(|_| invalid_feed("v2 asset manifest has an invalid shape"))?;
3748        if page.v != 2
3749            || page.commit != pointer.commit_hash
3750            || page.asset_root.as_deref() != Some(root)
3751            || page.assets.len() > 500
3752        {
3753            return Err(invalid_feed(
3754                "v2 asset manifest is not bound to the verified head",
3755            ));
3756        }
3757        for item in page.assets {
3758            verify_v2_asset_proof(root, &item)?;
3759            let path = item.path.clone();
3760            if assets
3761                .insert(
3762                    path,
3763                    V2BaselineAsset {
3764                        blob_sha256: item.blob_sha256,
3765                        bytes: item.bytes,
3766                        media_type: item.media_type,
3767                        wrappers: item.wrappers,
3768                        required: item.required,
3769                        disposition: item.disposition,
3770                        leaf_hash: item.leaf_hash,
3771                    },
3772                )
3773                .is_some()
3774            {
3775                return Err(invalid_feed("v2 asset manifest repeats a path"));
3776            }
3777            if assets.len() > MAX_PUSH_FILES {
3778                return Err(invalid_feed(
3779                    "v2 asset manifest exceeds the item-count bound",
3780                ));
3781            }
3782        }
3783        match page.next_cursor {
3784            None => break,
3785            Some(next) if next > after => after = next,
3786            Some(_) => return Err(invalid_feed("v2 asset manifest cursor did not advance")),
3787        }
3788    }
3789    Ok(assets)
3790}
3791
3792fn v2_asset_record(asset: &V2BaselineAsset, path: &str) -> crate::AssetRecord {
3793    crate::AssetRecord {
3794        path: path.to_string(),
3795        sha256: asset.blob_sha256.clone(),
3796        bytes: asset.bytes,
3797        media_type: asset.media_type.clone(),
3798        wrappers: asset.wrappers.clone(),
3799        required: asset.required,
3800    }
3801}
3802
3803fn v2_asset_resumes_hosting(
3804    remote: Option<&V2BaselineAsset>,
3805    path: &str,
3806    record: &crate::AssetRecord,
3807    disposition: &str,
3808) -> bool {
3809    remote.is_some_and(|asset| {
3810        asset.disposition == "withheld"
3811            && disposition == "hosted"
3812            && v2_asset_record(asset, path) == *record
3813    })
3814}
3815
3816fn v2_asset_record_manifest_bytes(
3817    assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
3818) -> LinkResult<Vec<u8>> {
3819    let mut bytes = Vec::new();
3820    for (path, asset) in assets {
3821        if asset.path != *path {
3822            return Err(invalid_feed(
3823                "local asset manifest key differs from its record path",
3824            ));
3825        }
3826        serde_json::to_writer(&mut bytes, asset)
3827            .map_err(|_| invalid_feed("could not materialize merged v2 assets.jsonl"))?;
3828        bytes.push(b'\n');
3829    }
3830    Ok(bytes)
3831}
3832
3833fn v2_local_asset_records(
3834    store: &Store,
3835) -> LinkResult<std::collections::BTreeMap<String, crate::AssetRecord>> {
3836    Ok(crate::assets::read_manifest(store)
3837        .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
3838        .into_iter()
3839        .map(|asset| (asset.path.clone(), asset))
3840        .collect())
3841}
3842
3843fn v2_asset_records_match_remote(
3844    local: &std::collections::BTreeMap<String, crate::AssetRecord>,
3845    remote: &std::collections::BTreeMap<String, V2BaselineAsset>,
3846) -> bool {
3847    local.len() == remote.len()
3848        && remote
3849            .iter()
3850            .all(|(path, asset)| local.get(path) == Some(&v2_asset_record(asset, path)))
3851}
3852
3853#[derive(Debug, Clone, PartialEq, Eq)]
3854struct V2PulledMerge<T> {
3855    records: std::collections::BTreeMap<String, T>,
3856    accept_remote: std::collections::BTreeSet<String>,
3857    conflicts: Vec<String>,
3858}
3859
3860/// Compute the local checkout that a pull may install without discarding a
3861/// local-only edit. Remote-only changes are accepted, identical concurrent
3862/// changes converge, and divergent concurrent changes are reported. The
3863/// caller can exempt kept-home coordinates from both remote installation and
3864/// conflict disclosure.
3865fn merge_v2_pulled_records<Base, Remote, Record, BaseRecord, RemoteRecord, KeepLocal>(
3866    base: &std::collections::BTreeMap<String, Base>,
3867    remote: &std::collections::BTreeMap<String, Remote>,
3868    local: &std::collections::BTreeMap<String, Record>,
3869    base_record: BaseRecord,
3870    remote_record: RemoteRecord,
3871    keep_local: KeepLocal,
3872) -> V2PulledMerge<Record>
3873where
3874    Record: Clone + Eq,
3875    BaseRecord: Fn(&Base, &str) -> Record,
3876    RemoteRecord: Fn(&Remote, &str) -> Record,
3877    KeepLocal: Fn(&str) -> bool,
3878{
3879    let paths = base
3880        .keys()
3881        .chain(remote.keys())
3882        .chain(local.keys())
3883        .cloned()
3884        .collect::<std::collections::BTreeSet<_>>();
3885    let mut records = local.clone();
3886    let mut accept_remote = std::collections::BTreeSet::new();
3887    let mut conflicts = Vec::new();
3888    for path in paths {
3889        if keep_local(&path) {
3890            continue;
3891        }
3892        let base_value = base.get(&path).map(|value| base_record(value, &path));
3893        let remote_value = remote.get(&path).map(|value| remote_record(value, &path));
3894        let local_value = local.get(&path).cloned();
3895        if local_value != base_value && remote_value != base_value && local_value != remote_value {
3896            conflicts.push(path);
3897            continue;
3898        }
3899        if local_value == base_value || local_value == remote_value {
3900            accept_remote.insert(path.clone());
3901            match remote_value {
3902                Some(value) => {
3903                    records.insert(path, value);
3904                }
3905                None => {
3906                    records.remove(&path);
3907                }
3908            }
3909        }
3910    }
3911    V2PulledMerge {
3912        records,
3913        accept_remote,
3914        conflicts,
3915    }
3916}
3917
3918fn sign_verified_v2_candidate(
3919    cfg: &HubConfig,
3920    head: &V2VerifiedHead,
3921    expected: &std::collections::BTreeMap<String, V2BaselineFile>,
3922    expected_assets: &std::collections::BTreeMap<String, V2BaselineAsset>,
3923    mutation_id: &str,
3924    request_body: &Value,
3925    challenge_value: &Value,
3926) -> LinkResult<(String, String, String)> {
3927    if head.view_kind != "full" {
3928        return Err(invalid_feed(
3929            "a scoped self-custody writer must use the proposal workflow",
3930        ));
3931    }
3932    if head.identity.custody != "self" {
3933        return Err(invalid_feed(
3934            "a hub-custodied brain unexpectedly requested an external signature",
3935        ));
3936    }
3937    let key = cfg
3938        .brain_key
3939        .as_ref()
3940        .ok_or_else(|| bad_agent_key("this self-custodied brain requires DBMD_BRAIN_KEY_FILE"))?;
3941    if key.multikey != format!("ed25519:{}", head.identity.fingerprint)
3942        || key.public_key_spki != head.identity.public_key_spki
3943    {
3944        return Err(bad_agent_key(
3945            "DBMD_BRAIN_KEY_FILE does not match the verified brain identity",
3946        ));
3947    }
3948    let challenge_id = challenge_value
3949        .get("id")
3950        .and_then(Value::as_str)
3951        .filter(|id| crate::ulid::is_ulid(id))
3952        .ok_or_else(|| invalid_feed("self-custody challenge has no canonical id"))?;
3953    let expected_endpoint = format!(
3954        "/api/hub/brains/{}/v2/signing-challenges/{challenge_id}",
3955        head.brain_id
3956    );
3957    if challenge_value
3958        .get("candidate_endpoint")
3959        .and_then(Value::as_str)
3960        != Some(expected_endpoint.as_str())
3961    {
3962        return Err(invalid_feed(
3963            "self-custody challenge candidate endpoint is not origin-bound",
3964        ));
3965    }
3966
3967    let mut files = std::collections::BTreeMap::new();
3968    let mut after = String::new();
3969    type CandidateCoordinate = (
3970        String,
3971        String,
3972        String,
3973        String,
3974        Option<String>,
3975        Option<String>,
3976        u64,
3977        Option<String>,
3978    );
3979    let mut pinned: Option<CandidateCoordinate> = None;
3980    loop {
3981        let encoded_after: String =
3982            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3983        let path = format!("{expected_endpoint}?limit=500&after={encoded_after}");
3984        let value = ensure_ok(
3985            request_capped(
3986                cfg,
3987                "GET",
3988                &path,
3989                None,
3990                Auth::Required,
3991                MAX_FEED_RESPONSE_BYTES,
3992            )?,
3993            "v2 self-custody candidate",
3994        )?;
3995        let page: V2SigningCandidatePage = serde_json::from_value(value)
3996            .map_err(|_| invalid_feed("self-custody candidate has an invalid shape"))?;
3997        if page.v != 2
3998            || page.challenge_id != challenge_id
3999            || page.mutation_id != mutation_id
4000            || page.candidate.seq != page.parent.seq + 1
4001            || page.files.len() > 500
4002            || page.expires_at.is_empty()
4003        {
4004            return Err(invalid_feed(
4005                "self-custody candidate is not bound to this mutation",
4006            ));
4007        }
4008        let coordinate = (
4009            page.request_hash.clone(),
4010            page.candidate.signing_bytes_base64.clone(),
4011            page.candidate.changes_base64.clone(),
4012            page.candidate.actor_claim_base64.clone(),
4013            page.candidate.content_root.clone(),
4014            page.candidate.asset_root.clone(),
4015            page.parent.seq,
4016            page.parent.commit_hash.clone(),
4017        );
4018        if pinned.as_ref().is_some_and(|prior| prior != &coordinate) {
4019            return Err(invalid_feed(
4020                "self-custody candidate changed between manifest pages",
4021            ));
4022        }
4023        pinned = Some(coordinate);
4024        let root = page
4025            .candidate
4026            .content_root
4027            .as_deref()
4028            .ok_or_else(|| invalid_feed("self-custody candidate has no content root"))?;
4029        for file in page.files {
4030            verify_v2_file_proof(root, &file)?;
4031            if files
4032                .insert(
4033                    file.path.clone(),
4034                    V2BaselineFile {
4035                        sha256: file.sha256,
4036                        bytes: file.bytes,
4037                        proof: Some(file.proof),
4038                    },
4039                )
4040                .is_some()
4041            {
4042                return Err(invalid_feed(
4043                    "self-custody candidate repeats a manifest path",
4044                ));
4045            }
4046            if files.len() > MAX_PUSH_FILES {
4047                return Err(invalid_feed(
4048                    "self-custody candidate exceeds the file-count bound",
4049                ));
4050            }
4051        }
4052        match page.next_cursor {
4053            None => break,
4054            Some(next) if next > after => after = next,
4055            Some(_) => {
4056                return Err(invalid_feed(
4057                    "self-custody candidate cursor did not advance",
4058                ))
4059            }
4060        }
4061    }
4062    if files.len() != expected.len()
4063        || files.iter().any(|(path, file)| {
4064            expected.get(path).is_none_or(|expected| {
4065                expected.sha256 != file.sha256 || expected.bytes != file.bytes
4066            })
4067        })
4068    {
4069        return Err(invalid_feed(
4070            "self-custody candidate contains an unexpected file mutation",
4071        ));
4072    }
4073    let mut assets = std::collections::BTreeMap::new();
4074    after.clear();
4075    loop {
4076        let encoded_after: String =
4077            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4078        let path = format!("{expected_endpoint}?kind=assets&limit=500&after={encoded_after}");
4079        let value = ensure_ok(
4080            request_capped(
4081                cfg,
4082                "GET",
4083                &path,
4084                None,
4085                Auth::Required,
4086                MAX_FEED_RESPONSE_BYTES,
4087            )?,
4088            "v2 self-custody asset candidate",
4089        )?;
4090        let page: V2SigningCandidatePage = serde_json::from_value(value)
4091            .map_err(|_| invalid_feed("self-custody asset candidate has an invalid shape"))?;
4092        let coordinate = (
4093            page.request_hash.clone(),
4094            page.candidate.signing_bytes_base64.clone(),
4095            page.candidate.changes_base64.clone(),
4096            page.candidate.actor_claim_base64.clone(),
4097            page.candidate.content_root.clone(),
4098            page.candidate.asset_root.clone(),
4099            page.parent.seq,
4100            page.parent.commit_hash.clone(),
4101        );
4102        if page.v != 2
4103            || page.challenge_id != challenge_id
4104            || page.mutation_id != mutation_id
4105            || page.assets.len() > 500
4106            || pinned.as_ref() != Some(&coordinate)
4107        {
4108            return Err(invalid_feed(
4109                "self-custody asset candidate changed or is not bound",
4110            ));
4111        }
4112        let root = page.candidate.asset_root.as_deref();
4113        if !page.assets.is_empty() && root.is_none() {
4114            return Err(invalid_feed("asset candidate has no asset root"));
4115        }
4116        for item in page.assets {
4117            verify_v2_asset_proof(root.expect("non-empty assets checked"), &item)?;
4118            if assets
4119                .insert(
4120                    item.path.clone(),
4121                    V2BaselineAsset {
4122                        blob_sha256: item.blob_sha256,
4123                        bytes: item.bytes,
4124                        media_type: item.media_type,
4125                        wrappers: item.wrappers,
4126                        required: item.required,
4127                        disposition: item.disposition,
4128                        leaf_hash: item.leaf_hash,
4129                    },
4130                )
4131                .is_some()
4132            {
4133                return Err(invalid_feed("self-custody candidate repeats an asset"));
4134            }
4135        }
4136        match page.next_cursor {
4137            None => break,
4138            Some(next) if next > after => after = next,
4139            Some(_) => {
4140                return Err(invalid_feed(
4141                    "self-custody asset candidate cursor did not advance",
4142                ))
4143            }
4144        }
4145    }
4146    if assets.len() != expected_assets.len()
4147        || assets.iter().any(|(path, asset)| {
4148            expected_assets.get(path).is_none_or(|expected| {
4149                asset.blob_sha256 != expected.blob_sha256
4150                    || asset.bytes != expected.bytes
4151                    || asset.media_type != expected.media_type
4152                    || asset.wrappers != expected.wrappers
4153                    || asset.required != expected.required
4154                    || asset.disposition != expected.disposition
4155            })
4156        })
4157    {
4158        return Err(invalid_feed(
4159            "self-custody candidate contains an unexpected asset mutation",
4160        ));
4161    }
4162    let Some((
4163        request_hash,
4164        signing_b64,
4165        changes_b64,
4166        actor_b64,
4167        root,
4168        asset_root,
4169        parent_seq,
4170        parent,
4171    )) = pinned
4172    else {
4173        return Err(invalid_feed("self-custody candidate has no manifest"));
4174    };
4175    let current_seq = head.pointer.as_ref().map_or(0, |pointer| pointer.seq);
4176    let current_commit = head
4177        .pointer
4178        .as_ref()
4179        .map(|pointer| pointer.commit_hash.clone());
4180    if parent_seq != current_seq || parent != current_commit {
4181        return Err(LinkError::RemoteAdvancedDuringSync);
4182    }
4183    let changes = STANDARD
4184        .decode(changes_b64)
4185        .map_err(|_| invalid_feed("self-custody changeset is not base64"))?;
4186    let mut expected_changes = json!({
4187        "mutation_id": mutation_id,
4188        "operations": request_body.get("operations").cloned().unwrap_or(Value::Null),
4189        "reason": request_body.get("reason").cloned().unwrap_or(Value::Null),
4190        "v": 2,
4191    });
4192    if let Some(withheld_links) = request_body.get("withheld_links") {
4193        expected_changes["withheld_links"] = withheld_links.clone();
4194    }
4195    if let Some(checkout_id) = request_body.get("checkout_id") {
4196        expected_changes["checkout_id"] = checkout_id.clone();
4197    }
4198    let expected_changes_bytes = crate::linkmd_v2::canonical_bytes(&expected_changes)
4199        .map_err(|error| invalid_feed(error.to_string()))?;
4200    if changes != expected_changes_bytes {
4201        return Err(invalid_feed(
4202            "self-custody changeset differs from the requested mutation",
4203        ));
4204    }
4205    let changes_hash = crate::linkmd_v2::domain_hash_bytes("v2/changeset", &changes)
4206        .map_err(|error| invalid_feed(error.to_string()))?;
4207    let request_value = json!({
4208        "base": request_body.get("base").cloned().unwrap_or(Value::Null),
4209        "brain": head.brain_id,
4210        "changes_sha256": changes_hash,
4211        "rebase": request_body.get("rebase").cloned().unwrap_or(Value::Null),
4212        "v": 2,
4213        "v1_bridge": Value::Null,
4214    });
4215    let expected_request_hash = crate::linkmd_v2::domain_hash("v2/request", &request_value)
4216        .map_err(|error| invalid_feed(error.to_string()))?;
4217    if request_hash != expected_request_hash {
4218        return Err(invalid_feed(
4219            "self-custody request hash differs from the requested mutation",
4220        ));
4221    }
4222    let actor = STANDARD
4223        .decode(actor_b64)
4224        .map_err(|_| invalid_feed("self-custody actor claim is not base64"))?;
4225    let actor_value: Value = serde_json::from_slice(&actor)
4226        .map_err(|_| invalid_feed("self-custody actor claim is not JSON"))?;
4227    if crate::linkmd_v2::canonical_bytes(&actor_value)
4228        .map_err(|error| invalid_feed(error.to_string()))?
4229        != actor
4230    {
4231        return Err(invalid_feed("self-custody actor claim is not canonical"));
4232    }
4233    let actor_object = actor_value
4234        .as_object()
4235        .ok_or_else(|| invalid_feed("self-custody actor claim is not an object"))?;
4236    let actor_claim = actor_object
4237        .get("claim")
4238        .ok_or_else(|| invalid_feed("self-custody actor claim body is missing"))?;
4239    let actor_public_key = actor_object
4240        .get("public_key")
4241        .and_then(Value::as_str)
4242        .ok_or_else(|| invalid_feed("self-custody actor signer is missing"))?;
4243    let actor_fingerprint = actor_object
4244        .get("fingerprint")
4245        .and_then(Value::as_str)
4246        .ok_or_else(|| invalid_feed("self-custody actor fingerprint is missing"))?;
4247    let actor_signature = actor_object
4248        .get("sig")
4249        .and_then(Value::as_str)
4250        .ok_or_else(|| invalid_feed("self-custody actor signature is missing"))?;
4251    let actor_message = crate::linkmd_v2::canonical_bytes(actor_claim)
4252        .map_err(|error| invalid_feed(error.to_string()))?;
4253    let actor_der = verify_v2_spki_signature(actor_public_key, &actor_message, actor_signature)?;
4254    let expected_actor_signer = format!("{actor_fingerprint}:{actor_public_key}");
4255    let expected_actor_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4256    let expected_actor_asset_root = asset_root.clone().map(Value::String).unwrap_or(Value::Null);
4257    let impact = actor_claim
4258        .get("result")
4259        .and_then(|result| result.get("impact"))
4260        .and_then(Value::as_object);
4261    let impact_fields = [
4262        "creates",
4263        "updates",
4264        "deletes",
4265        "withdrawals",
4266        "renames",
4267        "restores",
4268        "asset_changes",
4269        "public_expansions",
4270        "executable_activations",
4271    ];
4272    let impact_is_valid = impact.is_some_and(|impact| {
4273        impact.len() == impact_fields.len() + 1
4274            && impact.get("v").and_then(Value::as_u64) == Some(1)
4275            && impact_fields
4276                .iter()
4277                .all(|field| impact.get(*field).and_then(Value::as_u64).is_some())
4278    });
4279    if format!("{:x}", Sha256::digest(&actor_der)) != actor_fingerprint
4280        || head
4281            .trust
4282            .hub_signer
4283            .as_ref()
4284            .is_some_and(|known| known != &expected_actor_signer)
4285        || actor_claim.get("mutation_id").and_then(Value::as_str) != Some(mutation_id)
4286        || actor_claim.get("request_hash").and_then(Value::as_str) != Some(request_hash.as_str())
4287        || actor_claim
4288            .get("candidate")
4289            .and_then(|candidate| candidate.get("changes_sha256"))
4290            .and_then(Value::as_str)
4291            != Some(changes_hash.as_str())
4292        || actor_claim
4293            .get("candidate")
4294            .and_then(|candidate| candidate.get("state_root"))
4295            != Some(&expected_actor_root)
4296        || actor_claim
4297            .get("candidate")
4298            .and_then(|candidate| candidate.get("asset_root"))
4299            != Some(&expected_actor_asset_root)
4300        || actor_claim
4301            .get("candidate")
4302            .and_then(|candidate| candidate.get("control_revision"))
4303            .and_then(Value::as_str)
4304            != Some(head.control_revision.as_str())
4305        || !impact_is_valid
4306    {
4307        return Err(invalid_feed(
4308            "self-custody actor claim does not bind the verified authority",
4309        ));
4310    }
4311    let actor_hash = crate::linkmd_v2::domain_hash_bytes("v2/actor-claim", &actor)
4312        .map_err(|error| invalid_feed(error.to_string()))?;
4313    let signing = STANDARD
4314        .decode(signing_b64)
4315        .map_err(|_| invalid_feed("self-custody signing bytes are not base64"))?;
4316    let signing_value: Value = serde_json::from_slice(&signing)
4317        .map_err(|_| invalid_feed("self-custody signing bytes are not JSON"))?;
4318    if crate::linkmd_v2::canonical_bytes(&signing_value)
4319        .map_err(|error| invalid_feed(error.to_string()))?
4320        != signing
4321    {
4322        return Err(invalid_feed("self-custody signing bytes are not canonical"));
4323    }
4324    let pointer = head.pointer.as_ref();
4325    let expected_materializer = pointer
4326        .map(|value| value.materializer.as_str())
4327        .unwrap_or("dbmd-projection-v1");
4328    let expected_parent_commit = request_body
4329        .get("base")
4330        .and_then(|base| base.get("commit_hash"))
4331        .cloned()
4332        .unwrap_or(Value::Null);
4333    let expected_parent_root = request_body
4334        .get("base")
4335        .and_then(|base| base.get("content_root"))
4336        .cloned()
4337        .unwrap_or(Value::Null);
4338    let expected_state_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4339    let expected_parent_asset_root = request_body
4340        .get("base")
4341        .and_then(|base| base.get("asset_root"))
4342        .cloned()
4343        .unwrap_or(Value::Null);
4344    let expected_asset_root = asset_root.map(Value::String).unwrap_or(Value::Null);
4345    let expected_prev_entry = pointer
4346        .map(|value| Value::String(value.feed_hash.clone()))
4347        .unwrap_or(Value::Null);
4348    let expected_signer_epoch = u64::try_from(head.identity.previous.len())
4349        .map_err(|_| invalid_feed("brain identity history is too large"))?
4350        + 1;
4351    if signing_value.get("v").and_then(Value::as_u64) != Some(2)
4352        || signing_value.get("seq").and_then(Value::as_u64) != Some(current_seq + 1)
4353        || signing_value.get("signer_epoch").and_then(Value::as_u64) != Some(expected_signer_epoch)
4354        || signing_value.get("brain").and_then(Value::as_str) != Some(key.multikey.as_str())
4355        || signing_value.get("public_key").and_then(Value::as_str)
4356            != Some(key.public_key_spki.as_str())
4357        || signing_value.get("parent_commit") != Some(&expected_parent_commit)
4358        || signing_value.get("parent_root") != Some(&expected_parent_root)
4359        || signing_value.get("state_root") != Some(&expected_state_root)
4360        || signing_value.get("parent_asset_root") != Some(&expected_parent_asset_root)
4361        || signing_value.get("asset_root") != Some(&expected_asset_root)
4362        || signing_value.get("materializer").and_then(Value::as_str) != Some(expected_materializer)
4363        || signing_value.get("changes_sha256").and_then(Value::as_str)
4364            != Some(changes_hash.as_str())
4365        || signing_value.get("actor_ref").and_then(Value::as_str) != Some(actor_hash.as_str())
4366        || signing_value
4367            .get("control_revision")
4368            .and_then(Value::as_str)
4369            != Some(head.control_revision.as_str())
4370        || signing_value.get("prev_entry_hash") != Some(&expected_prev_entry)
4371        || signing_value.get("v1_bridge") != Some(&Value::Null)
4372        || signing_value.get("op").and_then(Value::as_str) != Some("changeset")
4373    {
4374        return Err(invalid_feed(
4375            "self-custody signing bytes do not bind the verified candidate",
4376        ));
4377    }
4378    let pair = agent_keypair(&key.pkcs8)?;
4379    let signature = URL_SAFE_NO_PAD.encode(pair.sign(&signing).as_ref());
4380    Ok((challenge_id.to_string(), signature, expected_actor_signer))
4381}
4382
4383fn v2_baseline_name(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<String> {
4384    let origin = normalized_origin(&cfg.hub)?;
4385    let absolute = if checkout.is_absolute() {
4386        checkout.to_path_buf()
4387    } else {
4388        std::env::current_dir()?.join(checkout)
4389    };
4390    Ok(format!(
4391        "sync-{}.json",
4392        content_sha256(format!("{origin}\0{brain}\0{}", absolute.display()).as_bytes())
4393    ))
4394}
4395
4396fn v2_checkout_id(existing: Option<&str>) -> LinkResult<String> {
4397    if let Some(value) = existing {
4398        if !is_sha256(value) {
4399            return Err(invalid_feed("v2 checkout pseudonym is invalid"));
4400        }
4401        return Ok(value.to_string());
4402    }
4403    use ring::rand::SecureRandom as _;
4404    let mut random = [0_u8; 32];
4405    ring::rand::SystemRandom::new()
4406        .fill(&mut random)
4407        .map_err(|_| invalid_feed("could not generate a checkout pseudonym"))?;
4408    Ok(random.iter().map(|byte| format!("{byte:02x}")).collect())
4409}
4410
4411#[cfg(any(unix, windows))]
4412fn lock_v2_sync_operation(cfg: &HubConfig, brain: &str) -> LinkResult<TrustLock> {
4413    let directory = open_trust_dir(cfg)?;
4414    let origin = normalized_origin(&cfg.hub)?;
4415    let name = format!(
4416        "operation-{}.lock",
4417        content_sha256(format!("{origin}\0{brain}").as_bytes())
4418    );
4419    lock_trust_name(&directory, &name)
4420}
4421
4422#[cfg(not(any(unix, windows)))]
4423fn lock_v2_sync_operation(_cfg: &HubConfig, _brain: &str) -> LinkResult<()> {
4424    Err(LinkError::UnsupportedPlatform {
4425        operation: "serialized link.md v2 sync",
4426    })
4427}
4428
4429fn same_v2_head(left: &V2VerifiedHead, right: &V2VerifiedHead) -> bool {
4430    left.brain_id == right.brain_id
4431        && left.view_kind == right.view_kind
4432        && left.view_revision == right.view_revision
4433        && left.control_revision == right.control_revision
4434        && match (&left.pointer, &right.pointer) {
4435            (None, None) => true,
4436            (Some(left), Some(right)) => {
4437                left.seq == right.seq
4438                    && left.commit_hash == right.commit_hash
4439                    && left.content_root == right.content_root
4440                    && left.asset_root == right.asset_root
4441                    && left.feed_hash == right.feed_hash
4442            }
4443            _ => false,
4444        }
4445}
4446
4447fn scoped_projection_bytes(brain: &str) -> Vec<u8> {
4448    format!(
4449        "---\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"
4450    )
4451    .into_bytes()
4452}
4453
4454fn scoped_projection_sha256(brain: &str) -> String {
4455    content_sha256(&scoped_projection_bytes(brain))
4456}
4457
4458#[derive(Deserialize)]
4459struct LocalScopedViewMarker {
4460    v: u8,
4461    kind: String,
4462    authoritative: bool,
4463    brain: String,
4464    projection_sha256: String,
4465}
4466
4467/// True only when this store carries the exact generated marker for a local
4468/// link.md scoped view. This is presentation context, never authorization;
4469/// the hub remains the only authority for reads and writes.
4470pub fn has_verified_local_scoped_view(store: &Store) -> bool {
4471    let marker = store
4472        .read_bounded(Path::new(".dbmd/view.json"), 64 * 1024)
4473        .ok()
4474        .and_then(|bytes| serde_json::from_slice::<LocalScopedViewMarker>(&bytes).ok());
4475    let Some(marker) = marker else {
4476        return false;
4477    };
4478    if marker.v != 1
4479        || marker.kind != "link.md-scoped-view"
4480        || marker.authoritative
4481        || !crate::ulid::is_ulid(&marker.brain)
4482        || marker.projection_sha256 != scoped_projection_sha256(&marker.brain)
4483    {
4484        return false;
4485    }
4486    store
4487        .read_bounded(Path::new("DB.md"), crate::parser::MAX_DBMD_FILE_BYTES)
4488        .is_ok_and(|bytes| content_sha256(&bytes) == marker.projection_sha256)
4489}
4490
4491fn scoped_view_metadata(head: &V2VerifiedHead, files: usize) -> LinkResult<Vec<u8>> {
4492    let mut bytes = serde_json::to_vec_pretty(&json!({
4493        "v": 1,
4494        "kind": "link.md-scoped-view",
4495        "authoritative": false,
4496        "brain": head.brain_id,
4497        "view_revision": head.view_revision,
4498        "head_seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
4499        "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
4500        "content_root": head.pointer.as_ref().and_then(|pointer| pointer.content_root.as_ref()),
4501        "visible_files": files,
4502        "projection_sha256": scoped_projection_sha256(&head.brain_id),
4503    }))
4504    .map_err(|_| invalid_feed("could not serialize scoped view metadata"))?;
4505    bytes.push(b'\n');
4506    Ok(bytes)
4507}
4508
4509fn refresh_scoped_view_marker(
4510    store: &Store,
4511    head: &V2VerifiedHead,
4512    files: usize,
4513) -> LinkResult<()> {
4514    if head.view_kind == "scoped" {
4515        store.write_atomic(
4516            Path::new(".dbmd/view.json"),
4517            &scoped_view_metadata(head, files)?,
4518        )?;
4519    }
4520    Ok(())
4521}
4522
4523fn ensure_v2_view_compatible(
4524    head: &V2VerifiedHead,
4525    baseline: Option<&V2SyncBaseline>,
4526) -> LinkResult<()> {
4527    let Some(baseline) = baseline else {
4528        return Ok(());
4529    };
4530    match (
4531        baseline.view_kind.as_deref(),
4532        baseline.view_revision.as_deref(),
4533    ) {
4534        (None, None) if head.view_kind == "full" => Ok(()),
4535        (Some(kind), Some(revision))
4536            if kind == head.view_kind && revision == head.view_revision =>
4537        {
4538            Ok(())
4539        }
4540        _ => Err(LinkError::ScopedViewChanged),
4541    }
4542}
4543
4544fn ensure_established_v2_checkout_opened(
4545    head: &V2VerifiedHead,
4546    baseline: Option<&V2SyncBaseline>,
4547    opened: bool,
4548) -> LinkResult<()> {
4549    if baseline.is_none() || opened {
4550        return Ok(());
4551    }
4552    if head.view_kind == "scoped" {
4553        return Err(LinkError::ScopedProjectionModified);
4554    }
4555    Err(LinkError::InvalidPack {
4556        message: "the established v2 checkout is no longer a valid db.md store; repair its DB.md before syncing".to_string(),
4557    })
4558}
4559
4560fn remove_scoped_projection(
4561    head: &V2VerifiedHead,
4562    baseline: Option<&V2SyncBaseline>,
4563    view: &mut V2LocalView,
4564) -> LinkResult<()> {
4565    if head.view_kind != "scoped" {
4566        return Ok(());
4567    }
4568    let expected = scoped_projection_sha256(&head.brain_id);
4569    if baseline
4570        .and_then(|state| state.projection_sha256.as_deref())
4571        .is_some_and(|pinned| pinned != expected)
4572    {
4573        return Err(LinkError::ScopedViewChanged);
4574    }
4575    if view.riding.get("DB.md").map(|(sha256, _)| sha256.as_str()) != Some(expected.as_str()) {
4576        return Err(LinkError::ScopedProjectionModified);
4577    }
4578    view.riding.remove("DB.md");
4579    view.eligibility.remove("DB.md");
4580    Ok(())
4581}
4582
4583fn local_view_for_v2_push(
4584    store: &Store,
4585    head: &V2VerifiedHead,
4586    baseline: Option<&V2SyncBaseline>,
4587    carried: Option<V2LocalView>,
4588) -> LinkResult<V2LocalView> {
4589    match carried {
4590        // The pull half already verified and removed the generated scoped
4591        // projection before constructing its in-memory handoff. Requiring it
4592        // a second time would turn every scoped bidirectional write into a
4593        // false tamper refusal.
4594        Some(view) => Ok(view),
4595        None => {
4596            let mut view = v2_local_files(store)?;
4597            remove_scoped_projection(head, baseline, &mut view)?;
4598            Ok(view)
4599        }
4600    }
4601}
4602
4603fn files_for_v2_view(
4604    head: &V2VerifiedHead,
4605    mut files: std::collections::BTreeMap<String, V2BaselineFile>,
4606) -> std::collections::BTreeMap<String, V2BaselineFile> {
4607    if head.view_kind == "scoped" {
4608        // Even when a grant explicitly includes canonical DB.md, a partial
4609        // checkout uses a generated marker. Canonical validation remains a
4610        // server-side operation over the complete brain.
4611        files.remove("DB.md");
4612    }
4613    files
4614}
4615
4616fn parse_v2_baseline(cfg: &HubConfig, brain: &str, bytes: &[u8]) -> LinkResult<V2SyncBaseline> {
4617    let baseline: V2SyncBaseline =
4618        serde_json::from_slice(bytes).map_err(|_| invalid_feed("v2 sync baseline is corrupt"))?;
4619    if baseline.v != 2
4620        || baseline.origin != normalized_origin(&cfg.hub)?
4621        || baseline.brain != brain
4622        || baseline
4623            .commit_hash
4624            .as_deref()
4625            .is_some_and(|hash| !is_sha256(hash))
4626        || baseline
4627            .content_root
4628            .as_deref()
4629            .is_some_and(|hash| !is_sha256(hash))
4630        || baseline
4631            .asset_root
4632            .as_deref()
4633            .is_some_and(|hash| !is_sha256(hash))
4634        || baseline
4635            .local_policy_digest
4636            .as_deref()
4637            .is_some_and(|hash| !is_sha256(hash))
4638        || baseline
4639            .view_kind
4640            .as_deref()
4641            .is_some_and(|kind| !matches!(kind, "full" | "scoped"))
4642        || baseline
4643            .view_revision
4644            .as_deref()
4645            .is_some_and(|hash| !is_sha256(hash))
4646        || baseline
4647            .projection_sha256
4648            .as_deref()
4649            .is_some_and(|hash| !is_sha256(hash))
4650        || (baseline.view_kind.as_deref() == Some("scoped")
4651            && (baseline.view_revision.is_none() || baseline.projection_sha256.is_none()))
4652        || baseline.files.len() > MAX_PUSH_FILES
4653        || baseline.assets.len() > MAX_PUSH_FILES
4654        || baseline.local_eligibility.len() > MAX_PUSH_FILES
4655        || baseline.remote_copy_remains.len() > MAX_PUSH_FILES
4656        || baseline.files.iter().any(|(path, file)| {
4657            crate::linkmd_v2::normalize_path(path).is_err()
4658                || !is_sha256(&file.sha256)
4659                || file.bytes > MAX_STORE_BYTES
4660        })
4661        || baseline.assets.iter().any(|(path, asset)| {
4662            crate::linkmd_v2::normalize_path(path).is_err()
4663                || !is_sha256(&asset.blob_sha256)
4664                || !is_sha256(&asset.leaf_hash)
4665                || asset.bytes > MAX_STORE_BYTES
4666                || !matches!(asset.disposition.as_str(), "hosted" | "withheld")
4667                || asset.wrappers.is_empty()
4668                || asset
4669                    .wrappers
4670                    .iter()
4671                    .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
4672        })
4673        || baseline
4674            .local_eligibility
4675            .keys()
4676            .chain(baseline.remote_copy_remains.keys())
4677            .any(|path| crate::linkmd_v2::normalize_path(path).is_err())
4678        || baseline
4679            .remote_copy_remains
4680            .values()
4681            .any(|hash| !is_sha256(hash))
4682        || baseline
4683            .checkout_id
4684            .as_deref()
4685            .is_some_and(|checkout_id| !is_sha256(checkout_id))
4686    {
4687        return Err(invalid_feed("v2 sync baseline failed validation"));
4688    }
4689    Ok(baseline)
4690}
4691
4692#[cfg(unix)]
4693fn load_v2_baseline(
4694    cfg: &HubConfig,
4695    brain: &str,
4696    checkout: &Path,
4697) -> LinkResult<Option<V2SyncBaseline>> {
4698    use std::os::fd::{AsRawFd as _, FromRawFd as _};
4699    let directory = open_trust_dir(cfg)?;
4700    let name_string = v2_baseline_name(cfg, brain, checkout)?;
4701    let _lock = lock_trust_name(&directory, &name_string)?;
4702    let name = c_name(name_string.as_bytes(), &name_string)?;
4703    let fd = unsafe {
4704        libc::openat(
4705            directory.as_raw_fd(),
4706            name.as_ptr(),
4707            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4708        )
4709    };
4710    if fd < 0 {
4711        let error = std::io::Error::last_os_error();
4712        return if error.kind() == std::io::ErrorKind::NotFound {
4713            Ok(None)
4714        } else {
4715            Err(LinkError::UnsafePath { path: name_string })
4716        };
4717    }
4718    let file = unsafe { std::fs::File::from_raw_fd(fd) };
4719    let mut bytes = Vec::new();
4720    file.take(MAX_FEED_RESPONSE_BYTES + 1)
4721        .read_to_end(&mut bytes)?;
4722    if bytes.len() as u64 > MAX_FEED_RESPONSE_BYTES {
4723        return Err(invalid_feed("v2 sync baseline is oversized"));
4724    }
4725    Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?))
4726}
4727
4728#[cfg(windows)]
4729fn load_v2_baseline(
4730    cfg: &HubConfig,
4731    brain: &str,
4732    checkout: &Path,
4733) -> LinkResult<Option<V2SyncBaseline>> {
4734    let directory = open_trust_dir(cfg)?;
4735    let name = v2_baseline_name(cfg, brain, checkout)?;
4736    let _lock = lock_trust_name(&directory, &name)?;
4737    let mut reader = crate::fsx::BoundedDirReader::from_root(&directory)?;
4738    match reader.read(Path::new(&name), MAX_FEED_RESPONSE_BYTES) {
4739        Ok(bytes) => Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?)),
4740        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
4741        Err(_) => Err(LinkError::UnsafePath { path: name }),
4742    }
4743}
4744
4745#[cfg(not(any(unix, windows)))]
4746fn load_v2_baseline(
4747    _cfg: &HubConfig,
4748    _brain: &str,
4749    _checkout: &Path,
4750) -> LinkResult<Option<V2SyncBaseline>> {
4751    Err(LinkError::UnsupportedPlatform {
4752        operation: "verified link.md v2 baseline",
4753    })
4754}
4755
4756#[cfg(unix)]
4757fn save_v2_baseline(
4758    cfg: &HubConfig,
4759    brain: &str,
4760    checkout: &Path,
4761    baseline: &V2SyncBaseline,
4762) -> LinkResult<()> {
4763    use std::os::fd::{AsRawFd as _, FromRawFd as _};
4764    let directory = open_trust_dir(cfg)?;
4765    let name_string = v2_baseline_name(cfg, brain, checkout)?;
4766    let _lock = lock_trust_name(&directory, &name_string)?;
4767    let name = c_name(name_string.as_bytes(), &name_string)?;
4768    let mut bytes = serde_json::to_vec(baseline)
4769        .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
4770    bytes.push(b'\n');
4771    let temp_string = format!(
4772        ".{name_string}.tmp.{}-{}",
4773        std::process::id(),
4774        std::time::SystemTime::now()
4775            .duration_since(std::time::UNIX_EPOCH)
4776            .unwrap_or_default()
4777            .as_nanos()
4778    );
4779    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
4780    let fd = unsafe {
4781        libc::openat(
4782            directory.as_raw_fd(),
4783            temp.as_ptr(),
4784            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4785            0o600,
4786        )
4787    };
4788    if fd < 0 {
4789        return Err(std::io::Error::last_os_error().into());
4790    }
4791    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
4792    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
4793        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4794        return Err(error.into());
4795    }
4796    drop(file);
4797    if unsafe {
4798        libc::renameat(
4799            directory.as_raw_fd(),
4800            temp.as_ptr(),
4801            directory.as_raw_fd(),
4802            name.as_ptr(),
4803        )
4804    } != 0
4805    {
4806        let error = std::io::Error::last_os_error();
4807        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4808        return Err(error.into());
4809    }
4810    directory.sync_all()?;
4811    Ok(())
4812}
4813
4814#[cfg(windows)]
4815fn save_v2_baseline(
4816    cfg: &HubConfig,
4817    brain: &str,
4818    checkout: &Path,
4819    baseline: &V2SyncBaseline,
4820) -> LinkResult<()> {
4821    let directory = open_trust_dir(cfg)?;
4822    let name = v2_baseline_name(cfg, brain, checkout)?;
4823    let _lock = lock_trust_name(&directory, &name)?;
4824    let mut bytes = serde_json::to_vec(baseline)
4825        .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
4826    bytes.push(b'\n');
4827    crate::fsx::write_atomic_beneath(&directory, Path::new(&name), &bytes, false, true)?;
4828    Ok(())
4829}
4830
4831#[cfg(not(any(unix, windows)))]
4832fn save_v2_baseline(
4833    _cfg: &HubConfig,
4834    _brain: &str,
4835    _checkout: &Path,
4836    _baseline: &V2SyncBaseline,
4837) -> LinkResult<()> {
4838    Err(LinkError::UnsupportedPlatform {
4839        operation: "verified link.md v2 baseline",
4840    })
4841}
4842
4843fn v2_baseline_from_head(
4844    cfg: &HubConfig,
4845    head: &V2VerifiedHead,
4846    files: std::collections::BTreeMap<String, V2BaselineFile>,
4847    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
4848    local: Option<&V2LocalView>,
4849    checkout_id: Option<&str>,
4850) -> LinkResult<V2SyncBaseline> {
4851    let mut local_eligibility = local
4852        .map(|view| view.eligibility.clone())
4853        .unwrap_or_default();
4854    if let Some(view) = local {
4855        for path in files.keys() {
4856            local_eligibility
4857                .entry(path.clone())
4858                .or_insert_with(|| !view.policy.keeps_home(path));
4859        }
4860    }
4861    let remote_copy_remains = local_eligibility
4862        .iter()
4863        .filter(|(_, riding)| !**riding)
4864        .filter_map(|(path, _)| {
4865            files
4866                .get(path)
4867                .map(|file| (path.clone(), file.sha256.clone()))
4868        })
4869        .collect();
4870    Ok(V2SyncBaseline {
4871        v: 2,
4872        origin: normalized_origin(&cfg.hub)?,
4873        brain: head.brain_id.clone(),
4874        checkout_id: Some(v2_checkout_id(checkout_id)?),
4875        head_seq: Some(head.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
4876        commit_hash: head
4877            .pointer
4878            .as_ref()
4879            .map(|pointer| pointer.commit_hash.clone()),
4880        content_root: head
4881            .pointer
4882            .as_ref()
4883            .and_then(|pointer| pointer.content_root.clone()),
4884        asset_root: head
4885            .pointer
4886            .as_ref()
4887            .and_then(|pointer| pointer.asset_root.clone()),
4888        assets,
4889        view_kind: Some(head.view_kind.clone()),
4890        view_revision: Some(head.view_revision.clone()),
4891        projection_sha256: (head.view_kind == "scoped")
4892            .then(|| scoped_projection_sha256(&head.brain_id)),
4893        files,
4894        local_policy_digest: local.map(|view| view.policy.digest.clone()),
4895        local_eligibility,
4896        remote_copy_remains,
4897    })
4898}
4899
4900fn v2_local_files(store: &Store) -> LinkResult<V2LocalView> {
4901    let policy = crate::linkmd_sync_policy::load(store)
4902        .map_err(|message| LinkError::InvalidPack { message })?;
4903    let asset_paths = crate::assets::read_manifest(store)
4904        .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
4905        .into_iter()
4906        .map(|asset| asset.path)
4907        .collect::<std::collections::BTreeSet<_>>();
4908    let mut result = std::collections::BTreeMap::new();
4909    let mut eligibility = std::collections::BTreeMap::new();
4910    let mut riding_links = Vec::<(String, Vec<String>)>::new();
4911    let mut total = 0_u64;
4912    let mut paths = vec![PathBuf::from("DB.md")];
4913    paths.extend(store.walk()?);
4914    for relative in paths {
4915        let path = relative.to_string_lossy().replace('\\', "/");
4916        // v2 catalogs and asset inventory are materialized/signed separately.
4917        if matches!(path.as_str(), "assets.jsonl" | "index.md" | "index.jsonl") {
4918            continue;
4919        }
4920        if asset_paths.contains(&path) {
4921            continue;
4922        }
4923        crate::linkmd_v2::normalize_path(&path).map_err(|error| LinkError::UnsafePath {
4924            path: error.to_string(),
4925        })?;
4926        let riding = !policy.keeps_home(&path);
4927        eligibility.insert(path.clone(), riding);
4928        if !riding {
4929            continue;
4930        }
4931        let remaining = MAX_STORE_BYTES.saturating_sub(total);
4932        let bytes = store.read_bounded(&relative, remaining)?;
4933        total = total
4934            .checked_add(bytes.len() as u64)
4935            .ok_or_else(|| LinkError::PushTooLarge {
4936                detail: "v2 local byte count overflow".to_string(),
4937            })?;
4938        if total > MAX_STORE_BYTES {
4939            return Err(LinkError::PushTooLarge {
4940                detail: format!("{total} uncompressed bytes"),
4941            });
4942        }
4943        if std::str::from_utf8(&bytes).is_err() {
4944            return Err(LinkError::NotUtf8 { path });
4945        }
4946        let text = std::str::from_utf8(&bytes).expect("UTF-8 was checked");
4947        riding_links.push((path.clone(), crate::store::extract_edge_targets(text)));
4948        result.insert(path, (content_sha256(&bytes), bytes.len() as u64));
4949    }
4950    let kept_home = eligibility
4951        .iter()
4952        .filter(|(_, riding)| !**riding)
4953        .map(|(path, _)| path.clone())
4954        .collect::<std::collections::BTreeSet<_>>();
4955    let mut withheld_links = riding_links
4956        .into_iter()
4957        .flat_map(|(source, targets)| {
4958            let kept_home = &kept_home;
4959            targets.into_iter().filter_map(move |target| {
4960                let target = format!("{target}.md");
4961                kept_home.contains(&target).then_some(V2WithheldLink {
4962                    source: source.clone(),
4963                    target,
4964                })
4965            })
4966        })
4967        .collect::<Vec<_>>();
4968    withheld_links.sort();
4969    withheld_links.dedup();
4970    Ok(V2LocalView {
4971        riding: result,
4972        eligibility,
4973        policy,
4974        withheld_links,
4975    })
4976}
4977
4978#[derive(Debug, Deserialize)]
4979struct V2DownloadItem {
4980    path: String,
4981    sha256: String,
4982    bytes: u64,
4983    url: String,
4984    method: String,
4985}
4986
4987#[derive(Debug, Deserialize)]
4988struct V2DownloadWindow {
4989    v: u8,
4990    commit: String,
4991    downloads: Vec<V2DownloadItem>,
4992}
4993
4994#[derive(Debug, Deserialize)]
4995struct V2BulkStreamHeader {
4996    v: u8,
4997    path: String,
4998    sha256: String,
4999    bytes: u64,
5000}
5001
5002fn parse_v2_bulk_stream(
5003    bytes: &[u8],
5004    expected: &[(&String, &V2BaselineFile)],
5005) -> LinkResult<Vec<(String, Vec<u8>)>> {
5006    if !bytes.starts_with(V2_BULK_STREAM_MAGIC) {
5007        return Err(invalid_feed("v2 bulk stream has an invalid magic"));
5008    }
5009    let mut cursor = V2_BULK_STREAM_MAGIC.len();
5010    let mut result = Vec::with_capacity(expected.len());
5011    for (expected_path, expected_file) in expected {
5012        let length_bytes = bytes
5013            .get(cursor..cursor + 4)
5014            .ok_or_else(|| invalid_feed("v2 bulk stream ended before a frame header"))?;
5015        cursor += 4;
5016        let header_len = u32::from_be_bytes(length_bytes.try_into().expect("four bytes")) as usize;
5017        if header_len == 0 || header_len > 4 * 1024 {
5018            return Err(invalid_feed("v2 bulk stream has an invalid frame length"));
5019        }
5020        let header_bytes = bytes
5021            .get(cursor..cursor + header_len)
5022            .ok_or_else(|| invalid_feed("v2 bulk stream ended inside a frame header"))?;
5023        cursor += header_len;
5024        let header: V2BulkStreamHeader = serde_json::from_slice(header_bytes)
5025            .map_err(|_| invalid_feed("v2 bulk stream has an invalid frame header"))?;
5026        if header.v != 2
5027            || &header.path != *expected_path
5028            || header.sha256 != expected_file.sha256
5029            || header.bytes != expected_file.bytes
5030            || header.bytes > V2_BULK_STREAM_CONTENT_BYTES
5031        {
5032            return Err(invalid_feed(
5033                "v2 bulk stream frame differs from its proven manifest entry",
5034            ));
5035        }
5036        let body_len = usize::try_from(header.bytes)
5037            .map_err(|_| invalid_feed("v2 bulk stream frame length overflows this platform"))?;
5038        let body = bytes
5039            .get(cursor..cursor + body_len)
5040            .ok_or_else(|| invalid_feed("v2 bulk stream ended inside file bytes"))?;
5041        cursor += body_len;
5042        if content_sha256(body) != header.sha256 {
5043            return Err(invalid_feed(
5044                "v2 bulk stream file differs from its proven manifest entry",
5045            ));
5046        }
5047        result.push((header.path, body.to_vec()));
5048    }
5049    if bytes.get(cursor..cursor + 4) != Some(&[0, 0, 0, 0]) {
5050        return Err(invalid_feed("v2 bulk stream has no exact end marker"));
5051    }
5052    cursor += 4;
5053    if cursor != bytes.len() {
5054        return Err(invalid_feed("v2 bulk stream carries trailing data"));
5055    }
5056    Ok(result)
5057}
5058
5059fn download_v2_bulk_stream(
5060    cfg: &HubConfig,
5061    brain: &str,
5062    pointer: &V2PointerBody,
5063    pending: &[(&String, &V2BaselineFile)],
5064) -> LinkResult<Vec<(String, Vec<u8>)>> {
5065    let claims = pending
5066        .iter()
5067        .map(|(path, file)| {
5068            Ok(json!({
5069                "path": path,
5070                "sha256": file.sha256,
5071                "bytes": file.bytes,
5072                "proof": file.proof.as_ref().ok_or_else(|| {
5073                    invalid_feed("v2 manifest omitted a bulk-stream proof")
5074                })?,
5075            }))
5076        })
5077        .collect::<LinkResult<Vec<_>>>()?;
5078    let raw = request_raw(
5079        cfg,
5080        "POST",
5081        &format!("/api/hub/brains/{brain}/v2/stream"),
5082        Some(&json!({
5083            "commit": pointer.commit_hash,
5084            "files": claims,
5085        })),
5086        Auth::Required,
5087        V2_BULK_STREAM_RESPONSE_BYTES,
5088    )?;
5089    let body = ensure_raw_ok(raw, "download v2 bulk stream")?;
5090    parse_v2_bulk_stream(&body, pending)
5091}
5092
5093fn prepare_v2_downloads(
5094    cfg: &HubConfig,
5095    brain: &str,
5096    pointer: &V2PointerBody,
5097    pending: &[(&String, &V2BaselineFile)],
5098) -> LinkResult<Vec<V2DownloadItem>> {
5099    let mut result = Vec::with_capacity(pending.len());
5100    for chunk in pending.chunks(128) {
5101        let claims = chunk
5102            .iter()
5103            .map(|(path, file)| {
5104                Ok(json!({
5105                    "path": path,
5106                    "sha256": file.sha256,
5107                    "bytes": file.bytes,
5108                    "proof": file.proof.as_ref().ok_or_else(|| {
5109                        invalid_feed("v2 manifest omitted a download proof")
5110                    })?,
5111                }))
5112            })
5113            .collect::<LinkResult<Vec<_>>>()?;
5114        let value = ensure_ok(
5115            request_capped(
5116                cfg,
5117                "POST",
5118                &format!("/api/hub/brains/{brain}/v2/downloads"),
5119                Some(&json!({
5120                    "commit": pointer.commit_hash,
5121                    "files": claims,
5122                })),
5123                Auth::Required,
5124                MAX_FEED_RESPONSE_BYTES,
5125            )?,
5126            "prepare v2 blob downloads",
5127        )?;
5128        let window: V2DownloadWindow = serde_json::from_value(value)
5129            .map_err(|_| invalid_feed("v2 download window has an invalid shape"))?;
5130        if window.v != 2
5131            || window.commit != pointer.commit_hash
5132            || window.downloads.len() != chunk.len()
5133        {
5134            return Err(invalid_feed(
5135                "v2 download window is not bound to the requested files",
5136            ));
5137        }
5138        let mut by_path = window
5139            .downloads
5140            .into_iter()
5141            .map(|item| (item.path.clone(), item))
5142            .collect::<std::collections::BTreeMap<_, _>>();
5143        if by_path.len() != chunk.len() {
5144            return Err(invalid_feed("v2 download window repeats a path"));
5145        }
5146        for (path, file) in chunk {
5147            let item = by_path
5148                .remove(*path)
5149                .ok_or_else(|| invalid_feed("v2 download window omitted a path"))?;
5150            if item.method != "GET"
5151                || item.sha256 != file.sha256
5152                || item.bytes != file.bytes
5153                || item.url.is_empty()
5154            {
5155                return Err(invalid_feed(
5156                    "v2 download capability differs from its proven file",
5157                ));
5158            }
5159            result.push(item);
5160        }
5161    }
5162    Ok(result)
5163}
5164
5165fn prepare_v2_asset_downloads(
5166    cfg: &HubConfig,
5167    brain: &str,
5168    pointer: &V2PointerBody,
5169    pending: &[(&String, &V2BaselineAsset)],
5170) -> LinkResult<Vec<V2DownloadItem>> {
5171    let mut result = Vec::with_capacity(pending.len());
5172    for chunk in pending.chunks(128) {
5173        let claims = chunk
5174            .iter()
5175            .map(|(path, asset)| {
5176                json!({
5177                    "path": path,
5178                    "sha256": asset.blob_sha256,
5179                    "bytes": asset.bytes,
5180                    "leaf_hash": asset.leaf_hash,
5181                })
5182            })
5183            .collect::<Vec<_>>();
5184        let value = ensure_ok(
5185            request_capped(
5186                cfg,
5187                "POST",
5188                &format!("/api/hub/brains/{brain}/v2/assets/downloads"),
5189                Some(&json!({
5190                    "commit": pointer.commit_hash,
5191                    "assets": claims,
5192                })),
5193                Auth::Required,
5194                MAX_FEED_RESPONSE_BYTES,
5195            )?,
5196            "prepare v2 asset downloads",
5197        )?;
5198        let window: V2DownloadWindow = serde_json::from_value(value)
5199            .map_err(|_| invalid_feed("v2 asset download window has an invalid shape"))?;
5200        if window.v != 2
5201            || window.commit != pointer.commit_hash
5202            || window.downloads.len() != chunk.len()
5203        {
5204            return Err(invalid_feed(
5205                "v2 asset download window is not bound to the requested assets",
5206            ));
5207        }
5208        let mut by_path = window
5209            .downloads
5210            .into_iter()
5211            .map(|item| (item.path.clone(), item))
5212            .collect::<std::collections::BTreeMap<_, _>>();
5213        if by_path.len() != chunk.len() {
5214            return Err(invalid_feed("v2 asset download window repeats a path"));
5215        }
5216        for (path, asset) in chunk {
5217            let item = by_path
5218                .remove(*path)
5219                .ok_or_else(|| invalid_feed("v2 asset download window omitted a path"))?;
5220            if item.method != "GET"
5221                || item.sha256 != asset.blob_sha256
5222                || item.bytes != asset.bytes
5223                || item.url.is_empty()
5224            {
5225                return Err(invalid_feed(
5226                    "v2 asset download capability differs from its signed leaf",
5227                ));
5228            }
5229            result.push(item);
5230        }
5231    }
5232    Ok(result)
5233}
5234
5235fn download_v2_blob(cfg: &HubConfig, item: &V2DownloadItem) -> LinkResult<Vec<u8>> {
5236    let bytes = get_presigned(cfg, &item.url)?;
5237    if bytes.len() as u64 != item.bytes || content_sha256(&bytes) != item.sha256 {
5238        return Err(invalid_feed("v2 blob differs from its proven path entry"));
5239    }
5240    Ok(bytes)
5241}
5242
5243#[derive(Debug, Clone)]
5244struct V2StagedFile {
5245    path: String,
5246    source: PathBuf,
5247    sha256: String,
5248    bytes: u64,
5249}
5250
5251#[cfg(unix)]
5252fn v2_download_cache_dir(
5253    cfg: &HubConfig,
5254    brain: &str,
5255    pointer: &V2PointerBody,
5256) -> LinkResult<PathBuf> {
5257    v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
5258}
5259
5260#[cfg(unix)]
5261fn v2_download_cache_dir_for(
5262    cfg: &HubConfig,
5263    brain: &str,
5264    transaction: &str,
5265) -> LinkResult<PathBuf> {
5266    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5267        return Err(invalid_feed("v2 download cache address is invalid"));
5268    }
5269    let path = cfg
5270        .state_dir
5271        .join("downloads")
5272        .join(brain)
5273        .join(transaction);
5274    let directory = open_or_create_dir_nofollow(&path)?;
5275    use std::os::fd::AsRawFd as _;
5276    if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
5277        return Err(std::io::Error::last_os_error().into());
5278    }
5279    directory.sync_all()?;
5280    Ok(path)
5281}
5282
5283#[cfg(windows)]
5284fn v2_download_cache_dir(
5285    cfg: &HubConfig,
5286    brain: &str,
5287    pointer: &V2PointerBody,
5288) -> LinkResult<PathBuf> {
5289    v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
5290}
5291
5292#[cfg(windows)]
5293fn v2_download_cache_dir_for(
5294    cfg: &HubConfig,
5295    brain: &str,
5296    transaction: &str,
5297) -> LinkResult<PathBuf> {
5298    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5299        return Err(invalid_feed("v2 download cache address is invalid"));
5300    }
5301    let path = cfg
5302        .state_dir
5303        .join("downloads")
5304        .join(brain)
5305        .join(transaction);
5306    crate::fsx::write_atomic(&path.join(".directory"), b"v2 download cache\n")?;
5307    crate::fsx::open_directory_nofollow(&path)?;
5308    Ok(path)
5309}
5310
5311#[cfg(unix)]
5312fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
5313    use std::os::fd::AsRawFd as _;
5314    let parent = cfg.state_dir.join("downloads").join(brain);
5315    let Ok(directory) = open_existing_dir_nofollow(&parent) else {
5316        return;
5317    };
5318    let Ok(name) = c_name(transaction.as_bytes(), transaction) else {
5319        return;
5320    };
5321    let _ = remove_tree_at(directory.as_raw_fd(), &name, &parent.display().to_string());
5322    let _ = directory.sync_all();
5323}
5324
5325#[cfg(windows)]
5326fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
5327    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5328        return;
5329    }
5330    let parent = cfg.state_dir.join("downloads").join(brain);
5331    let Ok(root) = crate::fsx::open_directory_nofollow(&parent) else {
5332        return;
5333    };
5334    let _ = crate::fsx::remove_tree_beneath(&root, Path::new(transaction));
5335}
5336
5337#[cfg(not(any(unix, windows)))]
5338fn cleanup_v2_download_cache(_cfg: &HubConfig, _brain: &str, _transaction: &str) {}
5339
5340#[cfg(not(any(unix, windows)))]
5341fn v2_download_cache_dir_for(
5342    _cfg: &HubConfig,
5343    _brain: &str,
5344    _transaction: &str,
5345) -> LinkResult<PathBuf> {
5346    Err(LinkError::UnsupportedPlatform {
5347        operation: "resumable v2 download staging",
5348    })
5349}
5350
5351#[cfg(any(unix, windows))]
5352fn cached_blob_is_exact(path: &Path, sha256: &str, bytes: u64) -> LinkResult<bool> {
5353    let file = match crate::fsx::open_regular_nofollow(path) {
5354        Ok(file) => file,
5355        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
5356        Err(error) => return Err(error.into()),
5357    };
5358    if file.metadata()?.len() != bytes {
5359        return Ok(false);
5360    }
5361    Ok(content_sha256_reader(file)? == sha256)
5362}
5363
5364#[cfg(any(unix, windows))]
5365fn cache_v2_blob_bytes(
5366    cache_dir: &Path,
5367    sha256: &str,
5368    expected_bytes: u64,
5369    bytes: &[u8],
5370) -> LinkResult<PathBuf> {
5371    if bytes.len() as u64 != expected_bytes || content_sha256(bytes) != sha256 {
5372        return Err(invalid_feed("v2 cached blob differs from its declaration"));
5373    }
5374    let path = cache_dir.join(sha256);
5375    if !cached_blob_is_exact(&path, sha256, expected_bytes)? {
5376        crate::fsx::write_atomic(&path, bytes)?;
5377    }
5378    Ok(path)
5379}
5380
5381#[cfg(not(any(unix, windows)))]
5382fn cache_v2_blob_bytes(
5383    _cache_dir: &Path,
5384    _sha256: &str,
5385    _expected_bytes: u64,
5386    _bytes: &[u8],
5387) -> LinkResult<PathBuf> {
5388    Err(LinkError::UnsupportedPlatform {
5389        operation: "resumable v2 download staging",
5390    })
5391}
5392
5393#[cfg(unix)]
5394fn download_presigned_to_cache(
5395    cfg: &HubConfig,
5396    url: &str,
5397    cache_dir: &Path,
5398    sha256: &str,
5399    expected_bytes: u64,
5400) -> LinkResult<PathBuf> {
5401    use std::os::fd::{AsRawFd as _, FromRawFd as _};
5402
5403    let target = cache_dir.join(sha256);
5404    if cached_blob_is_exact(&target, sha256, expected_bytes)? {
5405        return Ok(target);
5406    }
5407    let directory = open_existing_dir_nofollow(cache_dir)?;
5408    let mut nonce = [0_u8; 16];
5409    ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut nonce)
5410        .map_err(|_| invalid_feed("could not mint a download cache name"))?;
5411    let temp_string = format!(".download-{}", URL_SAFE_NO_PAD.encode(nonce));
5412    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
5413    let fd = unsafe {
5414        libc::openat(
5415            directory.as_raw_fd(),
5416            temp.as_ptr(),
5417            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5418            0o600,
5419        )
5420    };
5421    if fd < 0 {
5422        return Err(std::io::Error::last_os_error().into());
5423    }
5424    let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
5425    let response = match presigned_agent(cfg, url)?.get(url).call() {
5426        Ok(response) => response,
5427        Err(ureq::Error::Status(_, response)) => {
5428            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5429            return Err(LinkError::Http {
5430                what: "v2 direct download",
5431                status: response.status(),
5432                message: "object store rejected the download".to_string(),
5433                code: None,
5434                details: None,
5435            });
5436        }
5437        Err(ureq::Error::Transport(error)) => {
5438            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5439            return Err(LinkError::Transport {
5440                hub: cfg.hub.clone(),
5441                message: error.to_string(),
5442            });
5443        }
5444    };
5445    let mut reader = response
5446        .into_reader()
5447        .take(expected_bytes.saturating_add(1));
5448    let mut digest = Sha256::new();
5449    let mut total = 0_u64;
5450    let mut buffer = [0_u8; 64 * 1024];
5451    let write_result = (|| -> std::io::Result<()> {
5452        loop {
5453            let read = reader.read(&mut buffer)?;
5454            if read == 0 {
5455                break;
5456            }
5457            total = total.saturating_add(read as u64);
5458            digest.update(&buffer[..read]);
5459            output.write_all(&buffer[..read])?;
5460        }
5461        output.sync_all()
5462    })();
5463    if let Err(error) = write_result {
5464        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5465        return Err(error.into());
5466    }
5467    drop(output);
5468    if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
5469        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5470        return Err(invalid_feed(
5471            "v2 direct download failed integrity verification",
5472        ));
5473    }
5474    let target_name = c_name(sha256.as_bytes(), sha256)?;
5475    // Replace only a previously inspected cache leaf. Cache bytes are private
5476    // and content-addressed; final install re-verifies them again.
5477    if unsafe {
5478        libc::renameat(
5479            directory.as_raw_fd(),
5480            temp.as_ptr(),
5481            directory.as_raw_fd(),
5482            target_name.as_ptr(),
5483        )
5484    } != 0
5485    {
5486        let error = std::io::Error::last_os_error();
5487        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5488        return Err(error.into());
5489    }
5490    directory.sync_all()?;
5491    Ok(target)
5492}
5493
5494#[cfg(windows)]
5495fn download_presigned_to_cache(
5496    cfg: &HubConfig,
5497    url: &str,
5498    cache_dir: &Path,
5499    sha256: &str,
5500    expected_bytes: u64,
5501) -> LinkResult<PathBuf> {
5502    use std::fs::OpenOptions;
5503
5504    let target = cache_dir.join(sha256);
5505    if cached_blob_is_exact(&target, sha256, expected_bytes)? {
5506        return Ok(target);
5507    }
5508    // Keep the checked directory handle alive while the private cache path is
5509    // used. Windows opens omit FILE_SHARE_DELETE, so neither this directory nor
5510    // an ancestor can be swapped after validation.
5511    let _directory = crate::fsx::open_directory_nofollow(cache_dir)?;
5512    let temp = cache_dir.join(format!(".download-{}", crate::ulid::mint()));
5513    let mut output = OpenOptions::new()
5514        .write(true)
5515        .create_new(true)
5516        .open(&temp)?;
5517    let response = match presigned_agent(cfg, url)?.get(url).call() {
5518        Ok(response) => response,
5519        Err(ureq::Error::Status(_, response)) => {
5520            let _ = std::fs::remove_file(&temp);
5521            return Err(LinkError::Http {
5522                what: "v2 direct download",
5523                status: response.status(),
5524                message: "object store rejected the download".to_string(),
5525                code: None,
5526                details: None,
5527            });
5528        }
5529        Err(ureq::Error::Transport(error)) => {
5530            let _ = std::fs::remove_file(&temp);
5531            return Err(LinkError::Transport {
5532                hub: cfg.hub.clone(),
5533                message: error.to_string(),
5534            });
5535        }
5536    };
5537    let mut reader = response
5538        .into_reader()
5539        .take(expected_bytes.saturating_add(1));
5540    let mut digest = Sha256::new();
5541    let mut total = 0_u64;
5542    let mut buffer = [0_u8; 64 * 1024];
5543    let copied = (|| -> std::io::Result<()> {
5544        loop {
5545            let read = reader.read(&mut buffer)?;
5546            if read == 0 {
5547                break;
5548            }
5549            total = total.saturating_add(read as u64);
5550            digest.update(&buffer[..read]);
5551            output.write_all(&buffer[..read])?;
5552        }
5553        output.sync_all()
5554    })();
5555    if let Err(error) = copied {
5556        let _ = std::fs::remove_file(&temp);
5557        return Err(error.into());
5558    }
5559    drop(output);
5560    if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
5561        let _ = std::fs::remove_file(&temp);
5562        return Err(invalid_feed(
5563            "v2 direct download failed integrity verification",
5564        ));
5565    }
5566    if target.exists() {
5567        std::fs::remove_file(&target)?;
5568    }
5569    if let Err(error) = std::fs::rename(&temp, &target) {
5570        let _ = std::fs::remove_file(&temp);
5571        return Err(error.into());
5572    }
5573    Ok(target)
5574}
5575
5576#[cfg(not(any(unix, windows)))]
5577fn download_presigned_to_cache(
5578    _cfg: &HubConfig,
5579    _url: &str,
5580    _cache_dir: &Path,
5581    _sha256: &str,
5582    _expected_bytes: u64,
5583) -> LinkResult<PathBuf> {
5584    Err(LinkError::UnsupportedPlatform {
5585        operation: "resumable v2 download staging",
5586    })
5587}
5588
5589fn download_v2_blobs(
5590    cfg: &HubConfig,
5591    brain: &str,
5592    pointer: &V2PointerBody,
5593    pending: Vec<(&String, &V2BaselineFile)>,
5594) -> LinkResult<Vec<(String, Vec<u8>)>> {
5595    if pending.is_empty() {
5596        return Ok(Vec::new());
5597    }
5598    let expected_order = pending
5599        .iter()
5600        .map(|(path, _)| (*path).clone())
5601        .collect::<Vec<_>>();
5602    let mut streamed = std::collections::BTreeMap::new();
5603    let mut direct = Vec::new();
5604    let mut window = Vec::new();
5605    let mut window_bytes = 0_u64;
5606    let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
5607                 window_bytes: &mut u64,
5608                 streamed: &mut std::collections::BTreeMap<String, Vec<u8>>|
5609     -> LinkResult<()> {
5610        if window.is_empty() {
5611            return Ok(());
5612        }
5613        for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, window)? {
5614            if streamed.insert(path, bytes).is_some() {
5615                return Err(invalid_feed("v2 bulk streams repeated a path"));
5616            }
5617        }
5618        window.clear();
5619        *window_bytes = 0;
5620        Ok(())
5621    };
5622    for &(path, file) in &pending {
5623        if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
5624            flush(&mut window, &mut window_bytes, &mut streamed)?;
5625            direct.push((path, file));
5626            continue;
5627        }
5628        if window.len() == V2_BULK_STREAM_FILES
5629            || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
5630        {
5631            flush(&mut window, &mut window_bytes, &mut streamed)?;
5632        }
5633        window.push((path, file));
5634        window_bytes += file.bytes;
5635    }
5636    flush(&mut window, &mut window_bytes, &mut streamed)?;
5637
5638    let downloads = prepare_v2_downloads(cfg, brain, pointer, &direct)?;
5639    let next = std::sync::atomic::AtomicUsize::new(0);
5640    let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
5641    let mut results = std::iter::repeat_with(|| None)
5642        .take(downloads.len())
5643        .collect::<Vec<Option<LinkResult<(String, Vec<u8>)>>>>();
5644    std::thread::scope(|scope| {
5645        let (sender, receiver) = std::sync::mpsc::channel();
5646        for _ in 0..worker_count {
5647            let sender = sender.clone();
5648            let downloads = &downloads;
5649            let next = &next;
5650            scope.spawn(move || loop {
5651                let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5652                let Some(item) = downloads.get(index) else {
5653                    break;
5654                };
5655                let result = download_v2_blob(cfg, item).map(|bytes| (item.path.clone(), bytes));
5656                if sender.send((index, result)).is_err() {
5657                    break;
5658                }
5659            });
5660        }
5661        drop(sender);
5662        for (index, result) in receiver {
5663            results[index] = Some(result);
5664        }
5665    });
5666    for result in results.into_iter().map(|result| {
5667        result.ok_or_else(|| LinkError::Transport {
5668            hub: cfg.hub.clone(),
5669            message: "a bounded v2 blob worker stopped before reporting its result".to_string(),
5670        })?
5671    }) {
5672        let (path, bytes) = result?;
5673        if streamed.insert(path, bytes).is_some() {
5674            return Err(invalid_feed("v2 download lanes repeated a path"));
5675        }
5676    }
5677    expected_order
5678        .into_iter()
5679        .map(|path| {
5680            streamed
5681                .remove(&path)
5682                .map(|bytes| (path, bytes))
5683                .ok_or_else(|| invalid_feed("v2 download lanes omitted a proven path"))
5684        })
5685        .collect()
5686}
5687
5688/// Resume an exact-head materialization into the private global cache. Each
5689/// verified frame is durable before the next network window begins, so a
5690/// retry skips it and memory never grows with total changed bytes.
5691#[cfg(any(unix, windows))]
5692fn stage_v2_blobs(
5693    cfg: &HubConfig,
5694    brain: &str,
5695    pointer: &V2PointerBody,
5696    pending: Vec<(&String, &V2BaselineFile)>,
5697) -> LinkResult<Vec<V2StagedFile>> {
5698    let cache_dir = v2_download_cache_dir(cfg, brain, pointer)?;
5699    let mut staged = std::collections::BTreeMap::<String, V2StagedFile>::new();
5700    let mut direct = Vec::new();
5701    let mut window = Vec::new();
5702    let mut window_bytes = 0_u64;
5703    let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
5704                 window_bytes: &mut u64,
5705                 staged: &mut std::collections::BTreeMap<String, V2StagedFile>|
5706     -> LinkResult<()> {
5707        if window.is_empty() {
5708            return Ok(());
5709        }
5710        let missing = window
5711            .iter()
5712            .filter_map(|(path, file)| {
5713                let target = cache_dir.join(&file.sha256);
5714                match cached_blob_is_exact(&target, &file.sha256, file.bytes) {
5715                    Ok(true) => {
5716                        staged.insert(
5717                            (*path).clone(),
5718                            V2StagedFile {
5719                                path: (*path).clone(),
5720                                source: target,
5721                                sha256: file.sha256.clone(),
5722                                bytes: file.bytes,
5723                            },
5724                        );
5725                        None
5726                    }
5727                    Ok(false) => Some(Ok((*path, *file))),
5728                    Err(error) => Some(Err(error)),
5729                }
5730            })
5731            .collect::<LinkResult<Vec<_>>>()?;
5732        if !missing.is_empty() {
5733            for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, &missing)? {
5734                let file = missing
5735                    .iter()
5736                    .find_map(|(expected_path, file)| (*expected_path == &path).then_some(*file))
5737                    .ok_or_else(|| invalid_feed("v2 stream returned an unrequested cache path"))?;
5738                let source = cache_v2_blob_bytes(&cache_dir, &file.sha256, file.bytes, &bytes)?;
5739                staged.insert(
5740                    path.clone(),
5741                    V2StagedFile {
5742                        path,
5743                        source,
5744                        sha256: file.sha256.clone(),
5745                        bytes: file.bytes,
5746                    },
5747                );
5748            }
5749        }
5750        window.clear();
5751        *window_bytes = 0;
5752        Ok(())
5753    };
5754    for &(path, file) in &pending {
5755        if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
5756            flush(&mut window, &mut window_bytes, &mut staged)?;
5757            direct.push((path, file));
5758            continue;
5759        }
5760        if window.len() == V2_BULK_STREAM_FILES
5761            || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
5762        {
5763            flush(&mut window, &mut window_bytes, &mut staged)?;
5764        }
5765        window.push((path, file));
5766        window_bytes += file.bytes;
5767    }
5768    flush(&mut window, &mut window_bytes, &mut staged)?;
5769    for item in prepare_v2_downloads(cfg, brain, pointer, &direct)? {
5770        let source =
5771            download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
5772        staged.insert(
5773            item.path.clone(),
5774            V2StagedFile {
5775                path: item.path,
5776                source,
5777                sha256: item.sha256,
5778                bytes: item.bytes,
5779            },
5780        );
5781    }
5782    pending
5783        .into_iter()
5784        .map(|(path, _)| {
5785            staged
5786                .remove(path)
5787                .ok_or_else(|| invalid_feed("v2 download cache omitted a proven path"))
5788        })
5789        .collect()
5790}
5791
5792#[cfg(not(any(unix, windows)))]
5793fn stage_v2_blobs(
5794    _cfg: &HubConfig,
5795    _brain: &str,
5796    _pointer: &V2PointerBody,
5797    _pending: Vec<(&String, &V2BaselineFile)>,
5798) -> LinkResult<Vec<V2StagedFile>> {
5799    Err(LinkError::UnsupportedPlatform {
5800        operation: "resumable v2 download staging",
5801    })
5802}
5803
5804const V2_CONFLICT_BUNDLE_MAX: usize = 32;
5805const V2_CONFLICT_BUNDLE_TTL_SECS: u64 = 7 * 24 * 60 * 60;
5806const V2_CONFLICT_REMOTE_BYTES_MAX: u64 = 64 * 1024 * 1024;
5807
5808#[derive(Debug, Clone, Deserialize, Serialize)]
5809struct V2ConflictCoordinate {
5810    sha256: Option<String>,
5811    bytes: Option<u64>,
5812    file: Option<String>,
5813}
5814
5815#[derive(Debug, Clone, Deserialize, Serialize)]
5816struct V2ConflictFile {
5817    path: String,
5818    base: V2ConflictCoordinate,
5819    local: V2ConflictCoordinate,
5820    remote: V2ConflictCoordinate,
5821}
5822
5823#[derive(Debug, Clone, Deserialize, Serialize)]
5824struct V2ConflictPlan {
5825    v: u8,
5826    class: String,
5827    bundle: String,
5828    brain: String,
5829    origin: String,
5830    created_unix: u64,
5831    expires_unix: u64,
5832    base_seq: Option<u64>,
5833    base_commit: Option<String>,
5834    remote_seq: u64,
5835    remote_commit: Option<String>,
5836    remote_content_root: Option<String>,
5837    view_kind: String,
5838    view_revision: String,
5839    files: Vec<V2ConflictFile>,
5840}
5841
5842fn v2_take_remote_selection(
5843    files: &[V2ConflictFile],
5844    current: &std::collections::BTreeMap<String, V2BaselineFile>,
5845) -> LinkResult<(
5846    std::collections::BTreeMap<String, V2BaselineFile>,
5847    Vec<String>,
5848)> {
5849    let mut selected = std::collections::BTreeMap::new();
5850    let mut deleted = Vec::new();
5851    for file in files {
5852        match (&file.remote.sha256, file.remote.bytes) {
5853            (Some(sha256), Some(bytes)) => {
5854                let proven = current.get(&file.path).ok_or_else(|| {
5855                    invalid_feed("conflict remote coordinate disappeared from the exact head")
5856                })?;
5857                if proven.sha256 != *sha256 || proven.bytes != bytes {
5858                    return Err(invalid_feed(
5859                        "conflict remote coordinate differs from the exact head",
5860                    ));
5861                }
5862                if selected.insert(file.path.clone(), proven.clone()).is_some() {
5863                    return Err(invalid_feed("conflict plan repeats a remote coordinate"));
5864                }
5865            }
5866            (None, None) => {
5867                if current.contains_key(&file.path) {
5868                    return Err(invalid_feed(
5869                        "conflict remote deletion differs from the exact head",
5870                    ));
5871                }
5872                deleted.push(file.path.clone());
5873            }
5874            _ => return Err(invalid_feed("conflict remote coordinate is incomplete")),
5875        }
5876    }
5877    Ok((selected, deleted))
5878}
5879
5880fn v2_conflict_relative(bundle: &str, suffix: &str) -> PathBuf {
5881    PathBuf::from(".dbmd")
5882        .join("conflicts")
5883        .join(bundle)
5884        .join(suffix)
5885}
5886
5887fn read_historical_conflict_blob(
5888    cfg: &HubConfig,
5889    brain: &str,
5890    baseline: &V2SyncBaseline,
5891    path: &str,
5892    file: &V2BaselineFile,
5893) -> LinkResult<Option<Vec<u8>>> {
5894    let (Some(seq), Some(commit)) = (baseline.head_seq, baseline.commit_hash.as_deref()) else {
5895        return Ok(None);
5896    };
5897    if seq == 0 {
5898        return Ok(None);
5899    }
5900    let encoded_path: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
5901    let endpoint = format!(
5902        "/api/hub/brains/{brain}/v2/history/blob?seq={seq}&commit={commit}&path={encoded_path}&sha256={}",
5903        file.sha256
5904    );
5905    let raw = request_raw(cfg, "GET", &endpoint, None, Auth::Required, file.bytes)?;
5906    if raw.status == 404 || raw.status == 403 {
5907        return Ok(None);
5908    }
5909    let bytes = ensure_raw_ok(raw, "v2 conflict base")?;
5910    if bytes.len() as u64 != file.bytes || content_sha256(&bytes) != file.sha256 {
5911        return Err(invalid_feed(
5912            "v2 conflict base failed integrity verification",
5913        ));
5914    }
5915    Ok(Some(bytes))
5916}
5917
5918/// Persist a readable content conflict without modifying either side. The
5919/// plan is written last, so an interrupted bundle build is never actionable.
5920fn create_v2_conflict_bundle(
5921    cfg: &HubConfig,
5922    store: &Store,
5923    head: &V2VerifiedHead,
5924    baseline: Option<&V2SyncBaseline>,
5925    local: &std::collections::BTreeMap<String, (String, u64)>,
5926    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
5927    paths: &[String],
5928) -> LinkResult<(String, Vec<String>)> {
5929    let conflicts_root = Path::new(".dbmd/conflicts");
5930    store.create_dir_all(conflicts_root)?;
5931    let completed = store
5932        .directory_names(conflicts_root)?
5933        .into_iter()
5934        .filter(|name| name.to_str().is_some_and(crate::ulid::is_ulid))
5935        .count();
5936    if completed >= V2_CONFLICT_BUNDLE_MAX {
5937        return Err(LinkError::InvalidPack {
5938            message: format!(
5939                "private conflict cache has {completed} bundles; resolve or prune one before syncing"
5940            ),
5941        });
5942    }
5943
5944    // A conflict bundle is diagnostic state, not a second whole-brain cache.
5945    // Materialize a deterministic prefix whose remote artifacts fit one
5946    // bounded window. Resolving it exposes the next prefix on the next sync.
5947    let mut selected_paths = Vec::new();
5948    let mut selected_remote_bytes = 0_u64;
5949    for path in paths {
5950        let bytes = remote.get(path).map_or(0, |file| file.bytes);
5951        if !selected_paths.is_empty()
5952            && selected_remote_bytes.saturating_add(bytes) > V2_CONFLICT_REMOTE_BYTES_MAX
5953        {
5954            break;
5955        }
5956        selected_remote_bytes = selected_remote_bytes.saturating_add(bytes);
5957        selected_paths.push(path.clone());
5958        if selected_remote_bytes >= V2_CONFLICT_REMOTE_BYTES_MAX {
5959            break;
5960        }
5961    }
5962    if selected_paths.is_empty() {
5963        return Err(invalid_feed("content conflict set is empty"));
5964    }
5965    let bundle = crate::ulid::mint();
5966    let bundle_root = v2_conflict_relative(&bundle, "");
5967    store.create_dir_all(&bundle_root.join("files"))?;
5968    let pointer = head.pointer.as_ref();
5969    let remote_bytes = match pointer {
5970        Some(pointer) => download_v2_blobs(
5971            cfg,
5972            &head.brain_id,
5973            pointer,
5974            selected_paths
5975                .iter()
5976                .filter_map(|path| {
5977                    remote
5978                        .get(path)
5979                        .filter(|file| file.bytes <= V2_CONFLICT_REMOTE_BYTES_MAX)
5980                        .map(|file| (path, file))
5981                })
5982                .collect(),
5983        )?
5984        .into_iter()
5985        .collect::<std::collections::BTreeMap<_, _>>(),
5986        None => std::collections::BTreeMap::new(),
5987    };
5988
5989    let mut files = Vec::with_capacity(selected_paths.len());
5990    for (index, path) in selected_paths.iter().enumerate() {
5991        let base_file = baseline.and_then(|state| state.files.get(path));
5992        let base_bytes = match (baseline, base_file) {
5993            (Some(state), Some(file)) => {
5994                read_historical_conflict_blob(cfg, &head.brain_id, state, path, file)?
5995            }
5996            _ => None,
5997        };
5998        let local_file = local.get(path);
5999        let remote_file = remote.get(path);
6000        let remote_content = remote_bytes.get(path);
6001        let prefix = format!("files/{index:04}");
6002        let base_name = base_bytes.as_ref().map(|_| format!("{prefix}.base"));
6003        let local_name = local_file.as_ref().map(|_| format!("{prefix}.local"));
6004        let remote_name = remote_content.as_ref().map(|_| format!("{prefix}.remote"));
6005        if let (Some(name), Some(bytes)) = (&base_name, &base_bytes) {
6006            store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
6007        }
6008        if let (Some(name), Some((expected_hash, expected_bytes))) = (&local_name, local_file) {
6009            let bytes = store.read_bounded(Path::new(path), *expected_bytes)?;
6010            if bytes.len() as u64 != *expected_bytes || content_sha256(&bytes) != *expected_hash {
6011                return Err(LinkError::InvalidPack {
6012                    message: format!("local conflict path `{path}` changed while bundling"),
6013                });
6014            }
6015            store.write_atomic_new(&v2_conflict_relative(&bundle, name), &bytes)?;
6016        }
6017        if let (Some(name), Some(bytes)) = (&remote_name, remote_content) {
6018            store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
6019        }
6020        files.push(V2ConflictFile {
6021            path: path.clone(),
6022            base: V2ConflictCoordinate {
6023                sha256: base_file.map(|file| file.sha256.clone()),
6024                bytes: base_file.map(|file| file.bytes),
6025                file: base_name,
6026            },
6027            local: V2ConflictCoordinate {
6028                sha256: local_file.map(|(sha256, _)| sha256.clone()),
6029                bytes: local_file.map(|(_, bytes)| *bytes),
6030                file: local_name,
6031            },
6032            remote: V2ConflictCoordinate {
6033                sha256: remote_file.map(|file| file.sha256.clone()),
6034                bytes: remote_file.map(|file| file.bytes),
6035                file: remote_name,
6036            },
6037        });
6038    }
6039    let now = SystemTime::now()
6040        .duration_since(UNIX_EPOCH)
6041        .unwrap_or_default()
6042        .as_secs();
6043    let plan = V2ConflictPlan {
6044        v: 2,
6045        class: "content_resolution_required".to_string(),
6046        bundle: bundle.clone(),
6047        brain: head.brain_id.clone(),
6048        origin: normalized_origin(&cfg.hub)?,
6049        created_unix: now,
6050        expires_unix: now.saturating_add(V2_CONFLICT_BUNDLE_TTL_SECS),
6051        base_seq: baseline.and_then(|state| state.head_seq),
6052        base_commit: baseline.and_then(|state| state.commit_hash.clone()),
6053        remote_seq: pointer.map_or(0, |value| value.seq),
6054        remote_commit: pointer.map(|value| value.commit_hash.clone()),
6055        remote_content_root: pointer.and_then(|value| value.content_root.clone()),
6056        view_kind: head.view_kind.clone(),
6057        view_revision: head.view_revision.clone(),
6058        files,
6059    };
6060    let mut bytes = serde_json::to_vec_pretty(&plan)
6061        .map_err(|_| invalid_feed("could not serialize v2 conflict plan"))?;
6062    bytes.push(b'\n');
6063    store.write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), &bytes)?;
6064    Ok((bundle, selected_paths))
6065}
6066
6067fn v2_sync_pull_with_resolution(
6068    cfg: &HubConfig,
6069    requested_brain: &str,
6070    expected_head: V2VerifiedHead,
6071    out: Option<&Path>,
6072    take_remote: Option<&std::collections::BTreeSet<String>>,
6073) -> LinkResult<V2PulledSnapshot> {
6074    let dest = out
6075        .map(Path::to_path_buf)
6076        .unwrap_or_else(|| PathBuf::from(requested_brain));
6077    let _operation_lock = lock_v2_sync_operation(cfg, &expected_head.brain_id)?;
6078    recover_v2_pull(cfg, &expected_head.brain_id, &dest)?;
6079    let head = v2_verified_head(cfg, requested_brain)?
6080        .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
6081    if take_remote.is_some() && !same_v2_head(&expected_head, &head) {
6082        return Err(LinkError::RemoteAdvancedDuringSync);
6083    }
6084    let remote = files_for_v2_view(
6085        &head,
6086        v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6087    );
6088    let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
6089    let baseline = load_v2_baseline(cfg, &head.brain_id, &dest)?;
6090    ensure_v2_view_compatible(&head, baseline.as_ref())?;
6091    let local_store = Store::open_strict(&dest).ok();
6092    // A baseline proves this path is an established checkout. Never reinterpret
6093    // a damaged/edited store marker as an empty clone destination: doing so
6094    // would let the pull half erase the local evidence before the push half can
6095    // reject it (most importantly for the generated scoped-view DB.md).
6096    ensure_established_v2_checkout_opened(&head, baseline.as_ref(), local_store.is_some())?;
6097    let mut local_view = local_store.as_ref().map(v2_local_files).transpose()?;
6098    if head.view_kind == "scoped" && baseline.is_none() && local_view.is_some() {
6099        return Err(LinkError::ScopedViewChanged);
6100    }
6101    if let Some(view) = local_view.as_mut() {
6102        remove_scoped_projection(&head, baseline.as_ref(), view)?;
6103    }
6104    let empty_local = std::collections::BTreeMap::new();
6105    let local = local_view
6106        .as_ref()
6107        .map_or(&empty_local, |view| &view.riding);
6108    let kept_home = |path: &str| {
6109        local_view
6110            .as_ref()
6111            .is_some_and(|view| view.policy.keeps_home(path))
6112    };
6113    let empty_base = std::collections::BTreeMap::new();
6114    let base = baseline.as_ref().map_or(&empty_base, |state| &state.files);
6115    let empty_base_assets = std::collections::BTreeMap::new();
6116    let base_assets = baseline
6117        .as_ref()
6118        .map_or(&empty_base_assets, |state| &state.assets);
6119    let mut local_assets = local_store
6120        .as_ref()
6121        .map(v2_local_asset_records)
6122        .transpose()?
6123        .unwrap_or_default();
6124    let mut content_merge = merge_v2_pulled_records(
6125        base,
6126        &remote,
6127        local,
6128        |file, _| (file.sha256.clone(), file.bytes),
6129        |file, _| (file.sha256.clone(), file.bytes),
6130        kept_home,
6131    );
6132    if let Some(selected) = take_remote {
6133        for path in selected {
6134            if let Some(position) = content_merge
6135                .conflicts
6136                .iter()
6137                .position(|conflict| conflict == path)
6138            {
6139                content_merge.conflicts.remove(position);
6140                content_merge.accept_remote.insert(path.clone());
6141                match remote.get(path) {
6142                    Some(file) => {
6143                        content_merge
6144                            .records
6145                            .insert(path.clone(), (file.sha256.clone(), file.bytes));
6146                    }
6147                    None => {
6148                        content_merge.records.remove(path);
6149                    }
6150                }
6151            } else if !content_merge.accept_remote.contains(path) {
6152                return Err(LinkError::InvalidPack {
6153                    message: format!(
6154                        "take-remote path `{path}` is no longer at its conflict coordinate"
6155                    ),
6156                });
6157            }
6158        }
6159    }
6160    if !content_merge.conflicts.is_empty() {
6161        let mut conflicts = content_merge.conflicts.clone();
6162        conflicts.truncate(100);
6163        if let Some(store) = local_store.as_ref() {
6164            let (bundle, paths) = create_v2_conflict_bundle(
6165                cfg,
6166                store,
6167                &head,
6168                baseline.as_ref(),
6169                local,
6170                &remote,
6171                &conflicts,
6172            )?;
6173            return Err(LinkError::ConflictBundle { bundle, paths });
6174        }
6175        return Err(LinkError::Conflict { paths: conflicts });
6176    }
6177    let asset_merge = merge_v2_pulled_records(
6178        base_assets,
6179        &remote_assets,
6180        &local_assets,
6181        v2_asset_record,
6182        v2_asset_record,
6183        |_| false,
6184    );
6185    if !asset_merge.conflicts.is_empty() {
6186        let mut conflicts = asset_merge.conflicts.clone();
6187        conflicts.truncate(100);
6188        return Err(LinkError::Conflict { paths: conflicts });
6189    }
6190    let pointer = head.pointer.as_ref();
6191    let cache_transaction = pointer.map_or_else(
6192        || content_sha256(format!("empty\0{}", head.view_revision).as_bytes()),
6193        |value| value.commit_hash.clone(),
6194    );
6195    let cache_dir = v2_download_cache_dir_for(cfg, &head.brain_id, &cache_transaction)?;
6196    let mut changed = match pointer {
6197        Some(pointer) => stage_v2_blobs(
6198            cfg,
6199            &head.brain_id,
6200            pointer,
6201            remote
6202                .iter()
6203                .filter(|(path, file)| {
6204                    content_merge.accept_remote.contains(*path)
6205                        && local.get(*path).map(|value| value.0.as_str())
6206                            != Some(file.sha256.as_str())
6207                })
6208                .collect(),
6209        )?,
6210        None => Vec::new(),
6211    };
6212    let mut deleted = content_merge
6213        .accept_remote
6214        .iter()
6215        .filter(|path| !remote.contains_key(*path) && local.contains_key(*path))
6216        .cloned()
6217        .collect::<Vec<_>>();
6218    if local_assets != asset_merge.records {
6219        if asset_merge.records.is_empty() {
6220            deleted.push("assets.jsonl".to_string());
6221        } else {
6222            let bytes = v2_asset_record_manifest_bytes(&asset_merge.records)?;
6223            let sha256 = content_sha256(&bytes);
6224            let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
6225            changed.push(V2StagedFile {
6226                path: "assets.jsonl".to_string(),
6227                source,
6228                sha256,
6229                bytes: bytes.len() as u64,
6230            });
6231        }
6232    }
6233    if let Some(pointer) = pointer {
6234        let mut pending_assets = Vec::new();
6235        for (path, asset) in &remote_assets {
6236            if asset.disposition != "hosted"
6237                || kept_home(path)
6238                || !asset_merge.accept_remote.contains(path)
6239            {
6240                continue;
6241            }
6242            let already_current = local_store.as_ref().is_some_and(|store| {
6243                matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6244                    && store
6245                        .read_bounded(Path::new(path), asset.bytes)
6246                        .ok()
6247                        .is_some_and(|bytes| {
6248                            bytes.len() as u64 == asset.bytes
6249                                && content_sha256(&bytes) == asset.blob_sha256
6250                        })
6251            });
6252            if !already_current {
6253                pending_assets.push((path, asset));
6254            }
6255        }
6256        for item in prepare_v2_asset_downloads(cfg, &head.brain_id, pointer, &pending_assets)? {
6257            let source =
6258                download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
6259            changed.push(V2StagedFile {
6260                path: item.path,
6261                source,
6262                sha256: item.sha256,
6263                bytes: item.bytes,
6264            });
6265        }
6266    }
6267    for (path, prior) in base_assets {
6268        if remote_assets.contains_key(path)
6269            || kept_home(path)
6270            || !asset_merge.accept_remote.contains(path)
6271        {
6272            continue;
6273        }
6274        let unchanged = local_store.as_ref().is_some_and(|store| {
6275            matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6276                && store
6277                    .read_bounded(Path::new(path), prior.bytes)
6278                    .ok()
6279                    .is_some_and(|bytes| content_sha256(&bytes) == prior.blob_sha256)
6280        });
6281        if unchanged {
6282            deleted.push(path.clone());
6283        }
6284    }
6285    let extra_local = content_merge
6286        .records
6287        .keys()
6288        .filter(|path| !remote.contains_key(*path))
6289        .cloned()
6290        .collect::<Vec<_>>();
6291    if head.view_kind == "scoped" {
6292        for (path, bytes) in [
6293            ("DB.md".to_string(), scoped_projection_bytes(&head.brain_id)),
6294            (
6295                ".dbmd/view.json".to_string(),
6296                scoped_view_metadata(&head, remote.len())?,
6297            ),
6298        ] {
6299            let sha256 = content_sha256(&bytes);
6300            let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
6301            changed.push(V2StagedFile {
6302                path,
6303                source,
6304                sha256,
6305                bytes: bytes.len() as u64,
6306            });
6307        }
6308    }
6309    let install_changed = !changed.is_empty() || !deleted.is_empty();
6310    install_pulled_delta_sources(&dest, &changed, &deleted, true, baseline.as_ref(), &head)?;
6311    let finalized = (|| -> LinkResult<(bool, V2LocalView, std::collections::BTreeMap<String, crate::AssetRecord>)> {
6312        let installed_store =
6313            Store::open_strict(&dest).map_err(|error| LinkError::InvalidPack {
6314                message: format!("installed v2 checkout is not a valid db.md store: {error}"),
6315            })?;
6316        let installed_local = if install_changed {
6317            let mut scanned = v2_local_files(&installed_store)?;
6318            remove_scoped_projection(&head, baseline.as_ref(), &mut scanned)?;
6319            scanned
6320        } else {
6321            local_view
6322                .take()
6323                .ok_or_else(|| invalid_feed("an unchanged pull has no installed local view"))?
6324        };
6325        if installed_local.riding != content_merge.records {
6326            return Err(LinkError::InvalidPack {
6327                message: "local content changed while installing the v2 pull".to_string(),
6328            });
6329        }
6330        let installed_assets = if install_changed {
6331            v2_local_asset_records(&installed_store)?
6332        } else {
6333            std::mem::take(&mut local_assets)
6334        };
6335        if installed_assets != asset_merge.records {
6336            return Err(LinkError::InvalidPack {
6337                message: "local assets changed while installing the v2 pull".to_string(),
6338            });
6339        }
6340        let local_dirty = !v2_riding_matches_remote(&installed_local.riding, &remote, |path| {
6341            installed_local.policy.keeps_home(path)
6342        })
6343            || !v2_asset_records_match_remote(&installed_assets, &remote_assets);
6344        let final_head = v2_verified_head(cfg, requested_brain)?
6345            .ok_or_else(|| invalid_feed("v2 head disappeared during pull"))?;
6346        if !same_v2_head(&head, &final_head) {
6347            return Err(LinkError::RemoteAdvancedDuringSync);
6348        }
6349        accept_v2_head(cfg, &final_head)?;
6350        save_v2_baseline(
6351            cfg,
6352            &head.brain_id,
6353            &dest,
6354            &v2_baseline_from_head(
6355                cfg,
6356                &head,
6357                remote.clone(),
6358                remote_assets.clone(),
6359                Some(&installed_local),
6360                baseline
6361                    .as_ref()
6362                    .and_then(|current| current.checkout_id.as_deref()),
6363            )?,
6364        )?;
6365        complete_v2_pull(&dest)?;
6366        Ok((local_dirty, installed_local, installed_assets))
6367    })();
6368    let (local_dirty, installed_local, installed_assets) = match finalized {
6369        Ok(value) => value,
6370        Err(error) => {
6371            if let Err(recovery) = recover_v2_pull(cfg, &head.brain_id, &dest) {
6372                return Err(LinkError::InvalidPack {
6373                    message: format!("{error}; durable pull recovery also failed: {recovery}"),
6374                });
6375            }
6376            return Err(error);
6377        }
6378    };
6379    cleanup_v2_download_cache(cfg, &head.brain_id, &cache_transaction);
6380    let report = PullReport {
6381        brain: head.brain_id.clone(),
6382        slug: requested_brain.to_string(),
6383        head_seq: pointer.map_or(0, |value| value.seq),
6384        files: remote.len() + remote_assets.len(),
6385        dest: dest.to_string_lossy().into_owned(),
6386        extra_local,
6387        sync_status: if local_dirty {
6388            "local_dirty_after_install".to_string()
6389        } else {
6390            "synced".to_string()
6391        },
6392    };
6393    Ok(V2PulledSnapshot {
6394        report,
6395        head,
6396        files: remote,
6397        assets: remote_assets,
6398        local: installed_local,
6399        local_assets: installed_assets,
6400    })
6401}
6402
6403fn v2_sync_pull(
6404    cfg: &HubConfig,
6405    requested_brain: &str,
6406    head: V2VerifiedHead,
6407    out: Option<&Path>,
6408) -> LinkResult<PullReport> {
6409    Ok(v2_sync_pull_with_resolution(cfg, requested_brain, head, out, None)?.report)
6410}
6411
6412fn v2_expected(remote: Option<&V2BaselineFile>) -> Value {
6413    match remote {
6414        Some(file) => json!({ "kind": "blob", "hash": file.sha256 }),
6415        None => json!({ "kind": "absent" }),
6416    }
6417}
6418
6419fn v2_asset_expected(remote: Option<&V2BaselineAsset>) -> Value {
6420    match remote {
6421        Some(asset) => json!({ "kind": "asset", "hash": asset.leaf_hash }),
6422        None => json!({ "kind": "absent" }),
6423    }
6424}
6425
6426fn v2_content_withdrawal_operation(
6427    store: &Store,
6428    local_view: &V2LocalView,
6429    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6430    path: &str,
6431    reason: &str,
6432) -> LinkResult<Value> {
6433    if (!(path.starts_with("records/") || path.starts_with("sources/")) || !path.ends_with(".md"))
6434        || path == "DB.md"
6435    {
6436        return Err(LinkError::InvalidPack {
6437            message: format!("content withdrawal path `{path}` is not a record or source"),
6438        });
6439    }
6440    if !local_view.policy.keeps_home(path)
6441        || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6442    {
6443        return Err(LinkError::InvalidPack {
6444            message: format!(
6445                "withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
6446            ),
6447        });
6448    }
6449    let current = remote.get(path).ok_or_else(|| LinkError::InvalidPack {
6450        message: format!("withdrawal path `{path}` has no readable hosted coordinate"),
6451    })?;
6452    verify_v2_upload_source(store, path, &current.sha256, current.bytes)?;
6453    Ok(json!({
6454        "op": "withdraw_from_hosting",
6455        "path": path,
6456        "expected": { "kind": "blob", "hash": current.sha256 },
6457        "reason": reason,
6458    }))
6459}
6460
6461fn v2_asset_withdrawal_operation(
6462    store: &Store,
6463    local_view: &V2LocalView,
6464    path: &str,
6465    local: &crate::AssetRecord,
6466    current: &V2BaselineAsset,
6467    reason: &str,
6468) -> LinkResult<Value> {
6469    if !local_view.policy.keeps_home(path)
6470        || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6471    {
6472        return Err(LinkError::InvalidPack {
6473            message: format!(
6474                "asset withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
6475            ),
6476        });
6477    }
6478    verify_v2_upload_source(store, path, &local.sha256, local.bytes)?;
6479    if current.disposition != "hosted" || v2_asset_record(current, path) != *local {
6480        return Err(LinkError::InvalidPack {
6481            message: format!(
6482                "asset withdrawal path `{path}` must exactly match its currently hosted signed leaf"
6483            ),
6484        });
6485    }
6486    Ok(json!({
6487        "op": "asset_withdraw",
6488        "path": path,
6489        "expected": v2_asset_expected(Some(current)),
6490        "reason": reason,
6491    }))
6492}
6493
6494// A source is immutable, but its exact bytes may move from an intake namespace
6495// to an archive namespace under the distinct `promote_source` capability. A
6496// plain filesystem move appears to sync as one absent old path plus one new
6497// path. Preserve that intent as a protocol rename only when the hash has one
6498// unambiguous source delete and one unambiguous absent-destination source put.
6499// Ambiguous duplicate bytes stay as put/delete and fail closed at the hub.
6500fn infer_exact_source_promotions(operations: Vec<Value>) -> Vec<Value> {
6501    let mut deletes = std::collections::BTreeMap::<String, Vec<(usize, String)>>::new();
6502    let mut puts = std::collections::BTreeMap::<String, Vec<(usize, String, u64)>>::new();
6503    for (index, operation) in operations.iter().enumerate() {
6504        match operation.get("op").and_then(Value::as_str) {
6505            Some("delete") => {
6506                let Some(path) = operation.get("path").and_then(Value::as_str) else {
6507                    continue;
6508                };
6509                let Some(hash) = operation
6510                    .get("expected")
6511                    .and_then(|value| value.get("hash"))
6512                    .and_then(Value::as_str)
6513                else {
6514                    continue;
6515                };
6516                if path.starts_with("sources/") {
6517                    deletes
6518                        .entry(hash.to_string())
6519                        .or_default()
6520                        .push((index, path.to_string()));
6521                }
6522            }
6523            Some("put") => {
6524                let Some(path) = operation.get("path").and_then(Value::as_str) else {
6525                    continue;
6526                };
6527                let Some(hash) = operation.get("blob").and_then(Value::as_str) else {
6528                    continue;
6529                };
6530                let Some(bytes) = operation.get("bytes").and_then(Value::as_u64) else {
6531                    continue;
6532                };
6533                let destination_absent = operation
6534                    .get("expected")
6535                    .and_then(|value| value.get("kind"))
6536                    .and_then(Value::as_str)
6537                    == Some("absent");
6538                if path.starts_with("sources/") && destination_absent {
6539                    puts.entry(hash.to_string()).or_default().push((
6540                        index,
6541                        path.to_string(),
6542                        bytes,
6543                    ));
6544                }
6545            }
6546            _ => {}
6547        }
6548    }
6549    let mut rename_at = std::collections::BTreeMap::<usize, Value>::new();
6550    let mut consumed_puts = std::collections::BTreeSet::<usize>::new();
6551    for (hash, source) in deletes {
6552        let Some(destination) = puts.get(&hash) else {
6553            continue;
6554        };
6555        if source.len() != 1 || destination.len() != 1 {
6556            continue;
6557        }
6558        let (delete_index, from) = &source[0];
6559        let (put_index, to, bytes) = &destination[0];
6560        if from == to {
6561            continue;
6562        }
6563        rename_at.insert(
6564            *delete_index,
6565            json!({
6566                "op": "rename",
6567                "from": from,
6568                "to": to,
6569                "expected_from": { "kind": "blob", "hash": hash },
6570                "expected_to": { "kind": "absent" },
6571                "blob": hash,
6572                "bytes": bytes,
6573            }),
6574        );
6575        consumed_puts.insert(*put_index);
6576    }
6577    operations
6578        .into_iter()
6579        .enumerate()
6580        .filter_map(|(index, operation)| {
6581            if let Some(rename) = rename_at.remove(&index) {
6582                Some(rename)
6583            } else if consumed_puts.contains(&index) {
6584                None
6585            } else {
6586                Some(operation)
6587            }
6588        })
6589        .collect()
6590}
6591
6592fn v2_asset_value(record: &crate::AssetRecord, disposition: &str) -> Value {
6593    json!({
6594        "blob_sha256": record.sha256,
6595        "bytes": record.bytes,
6596        "media_type": record.media_type,
6597        "wrappers": record.wrappers,
6598        "required": record.required,
6599        "disposition": disposition,
6600    })
6601}
6602
6603/// Apply the exact operation subset generated by this client to an already
6604/// verified manifest. This avoids downloading the same 50k-path listing again
6605/// after an unrebased commit; a rebased response still re-reads the manifest.
6606fn apply_generated_v2_operations(
6607    operations: &[Value],
6608    local_assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
6609    candidate: &mut std::collections::BTreeMap<String, V2BaselineFile>,
6610    candidate_assets: &mut std::collections::BTreeMap<String, V2BaselineAsset>,
6611) -> LinkResult<bool> {
6612    let mut asset_changed = false;
6613    for operation in operations {
6614        match operation.get("op").and_then(Value::as_str) {
6615            Some("put") => {
6616                let path = operation
6617                    .get("path")
6618                    .and_then(Value::as_str)
6619                    .ok_or_else(|| invalid_feed("v2 put has no path"))?;
6620                let sha256 = operation
6621                    .get("blob")
6622                    .and_then(Value::as_str)
6623                    .ok_or_else(|| invalid_feed("v2 put has no blob"))?;
6624                let bytes = operation
6625                    .get("bytes")
6626                    .and_then(Value::as_u64)
6627                    .ok_or_else(|| invalid_feed("v2 put has no byte count"))?;
6628                candidate.insert(
6629                    path.to_string(),
6630                    V2BaselineFile {
6631                        sha256: sha256.to_string(),
6632                        bytes,
6633                        proof: None,
6634                    },
6635                );
6636            }
6637            Some("rename") => {
6638                let from = operation
6639                    .get("from")
6640                    .and_then(Value::as_str)
6641                    .ok_or_else(|| invalid_feed("v2 rename has no source path"))?;
6642                let to = operation
6643                    .get("to")
6644                    .and_then(Value::as_str)
6645                    .ok_or_else(|| invalid_feed("v2 rename has no destination path"))?;
6646                let sha256 = operation
6647                    .get("blob")
6648                    .and_then(Value::as_str)
6649                    .ok_or_else(|| invalid_feed("v2 rename has no blob"))?;
6650                let bytes = operation
6651                    .get("bytes")
6652                    .and_then(Value::as_u64)
6653                    .ok_or_else(|| invalid_feed("v2 rename has no byte count"))?;
6654                let expected_from = operation
6655                    .get("expected_from")
6656                    .and_then(|expected| expected.get("hash"))
6657                    .and_then(Value::as_str);
6658                let expected_to_absent = operation
6659                    .get("expected_to")
6660                    .and_then(|expected| expected.get("kind"))
6661                    .and_then(Value::as_str)
6662                    == Some("absent");
6663                if from == to
6664                    || !from.starts_with("sources/")
6665                    || !to.starts_with("sources/")
6666                    || expected_from != Some(sha256)
6667                    || !expected_to_absent
6668                    || candidate.contains_key(to)
6669                {
6670                    return Err(invalid_feed("generated v2 source rename is malformed"));
6671                }
6672                let source = candidate
6673                    .remove(from)
6674                    .ok_or_else(|| invalid_feed("v2 rename source is absent"))?;
6675                if source.sha256 != sha256 || source.bytes != bytes {
6676                    return Err(invalid_feed(
6677                        "v2 rename source differs from its exact-byte claim",
6678                    ));
6679                }
6680                candidate.insert(
6681                    to.to_string(),
6682                    V2BaselineFile {
6683                        sha256: sha256.to_string(),
6684                        bytes,
6685                        proof: None,
6686                    },
6687                );
6688            }
6689            Some("delete" | "withdraw_from_hosting") => {
6690                let path = operation
6691                    .get("path")
6692                    .and_then(Value::as_str)
6693                    .ok_or_else(|| invalid_feed("v2 delete has no path"))?;
6694                candidate.remove(path);
6695            }
6696            Some("asset_delete") => {
6697                let path = operation
6698                    .get("path")
6699                    .and_then(Value::as_str)
6700                    .ok_or_else(|| invalid_feed("v2 asset delete has no path"))?;
6701                candidate_assets.remove(path);
6702                asset_changed = true;
6703            }
6704            Some("asset_put" | "asset_resume" | "asset_withdraw") => {
6705                let path = operation
6706                    .get("path")
6707                    .and_then(Value::as_str)
6708                    .ok_or_else(|| invalid_feed("v2 asset write has no path"))?;
6709                let record = local_assets
6710                    .get(path)
6711                    .ok_or_else(|| invalid_feed("v2 asset write has no local record"))?;
6712                let disposition =
6713                    if operation.get("op").and_then(Value::as_str) == Some("asset_withdraw") {
6714                        "withheld"
6715                    } else {
6716                        operation
6717                            .get("asset")
6718                            .and_then(|asset| asset.get("disposition"))
6719                            .and_then(Value::as_str)
6720                            .ok_or_else(|| invalid_feed("v2 asset write has no disposition"))?
6721                    };
6722                candidate_assets.insert(
6723                    path.to_string(),
6724                    V2BaselineAsset {
6725                        blob_sha256: record.sha256.clone(),
6726                        bytes: record.bytes,
6727                        media_type: record.media_type.clone(),
6728                        wrappers: record.wrappers.clone(),
6729                        required: record.required,
6730                        disposition: disposition.to_string(),
6731                        // The server assigns the signed leaf address. Callers
6732                        // that need an exact asset manifest re-read it below.
6733                        leaf_hash: String::new(),
6734                    },
6735                );
6736                asset_changed = true;
6737            }
6738            _ => return Err(invalid_feed("dbmd generated an unsupported v2 operation")),
6739        }
6740    }
6741    Ok(asset_changed)
6742}
6743
6744fn v2_riding_matches_remote(
6745    local: &std::collections::BTreeMap<String, (String, u64)>,
6746    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6747    keeps_home: impl Fn(&str) -> bool,
6748) -> bool {
6749    remote.iter().all(|(path, file)| {
6750        keeps_home(path)
6751            || local.get(path).map(|value| value.0.as_str()) == Some(file.sha256.as_str())
6752    }) && local.iter().all(|(path, (hash, _))| {
6753        remote.get(path).map(|file| file.sha256.as_str()) == Some(hash.as_str())
6754    })
6755}
6756
6757#[derive(Debug, Clone)]
6758struct V2ResolutionOverride {
6759    expected_remote: Option<String>,
6760    selected_local: Option<String>,
6761}
6762
6763#[derive(Debug, Clone)]
6764struct V2UploadSource {
6765    path: String,
6766    bytes: u64,
6767}
6768
6769struct V2SyncPushOptions<'a> {
6770    resume_local_policy: bool,
6771    bulk_confirmation: Option<&'a V2BulkConfirmation>,
6772    resolution: Option<&'a std::collections::BTreeMap<String, V2ResolutionOverride>>,
6773    pulled: Option<V2PulledSnapshot>,
6774    withdrawal_paths: &'a [String],
6775    withdrawal_reason: Option<&'a str>,
6776}
6777
6778fn verify_v2_upload_source(
6779    store: &Store,
6780    path: &str,
6781    sha256: &str,
6782    expected_bytes: u64,
6783) -> LinkResult<()> {
6784    let file = store.open_regular(Path::new(path))?;
6785    if file.metadata()?.len() != expected_bytes || content_sha256_reader(file)? != sha256 {
6786        return Err(LinkError::InvalidPack {
6787            message: format!("local path `{path}` changed during sync planning"),
6788        });
6789    }
6790    Ok(())
6791}
6792
6793fn put_presigned_source(
6794    cfg: &HubConfig,
6795    raw: &str,
6796    headers: &Value,
6797    store: &Store,
6798    source: &V2UploadSource,
6799) -> LinkResult<()> {
6800    let http = presigned_agent(cfg, raw)?;
6801    let mut attempt = 0;
6802    let result = loop {
6803        let file = store.open_regular(Path::new(&source.path))?;
6804        if file.metadata()?.len() != source.bytes {
6805            return Err(LinkError::InvalidPack {
6806                message: format!("local path `{}` changed before upload", source.path),
6807            });
6808        }
6809        // The reservation already states content-length. Setting it here as
6810        // well emits the header twice, which an edge rejects as a malformed
6811        // request, so it is only supplied when the reservation omits it.
6812        let mut req = http.put(raw);
6813        let mut has_content_length = false;
6814        if let Some(map) = headers.as_object() {
6815            for (name, value) in map {
6816                if let Some(value) = value.as_str() {
6817                    has_content_length |= name.eq_ignore_ascii_case("content-length");
6818                    req = req.set(name, value);
6819                }
6820            }
6821        }
6822        if !has_content_length {
6823            req = req.set("Content-Length", &source.bytes.to_string());
6824        }
6825        match req.send(file) {
6826            Err(ureq::Error::Transport(error))
6827                if is_pre_request_transport(error.kind()) && attempt + 1 < UPLOAD_ATTEMPTS =>
6828            {
6829                std::thread::sleep(std::time::Duration::from_millis(upload_retry_backoff_ms(
6830                    attempt,
6831                )));
6832                attempt += 1;
6833            }
6834            // A migration stages tens of thousands of immutable objects, and an
6835            // edge or object store may refuse a single one under sustained
6836            // load. The upload is content-addressed and precondition-guarded,
6837            // so replaying it is safe; failing the whole push on one refusal is
6838            // not. 412 already means "someone else stored these exact bytes".
6839            Err(ureq::Error::Status(status, _))
6840                if status != 412
6841                    && is_retryable_upload_status(status)
6842                    && attempt + 1 < UPLOAD_ATTEMPTS =>
6843            {
6844                std::thread::sleep(std::time::Duration::from_millis(upload_retry_backoff_ms(
6845                    attempt,
6846                )));
6847                attempt += 1;
6848            }
6849            result => break result,
6850        }
6851    };
6852    match result {
6853        Ok(response) if (200..300).contains(&response.status()) => Ok(()),
6854        Ok(response) => {
6855            // The object store states its own reason (checksum mismatch,
6856            // precondition, expiry). Carrying a bounded excerpt turns an
6857            // unactionable refusal into one an operator can diagnose; the
6858            // coordinate is the store path, never file content.
6859            let status = response.status();
6860            let detail = response
6861                .into_string()
6862                .ok()
6863                .map(|body| body.chars().take(400).collect::<String>())
6864                .filter(|body| !body.trim().is_empty());
6865            Err(LinkError::Http {
6866                what: "v2 changed-byte upload",
6867                status,
6868                message: match detail {
6869                    Some(body) => format!(
6870                        "object store rejected the upload of `{}`: {}",
6871                        source.path,
6872                        body.replace('\n', " ")
6873                    ),
6874                    None => format!("object store rejected the upload of `{}`", source.path),
6875                },
6876                code: None,
6877                details: None,
6878            })
6879        }
6880        Err(error) => match error {
6881            ureq::Error::Status(412, _) => Ok(()),
6882            ureq::Error::Status(_, response) => {
6883                let status = response.status();
6884                let detail = response
6885                    .into_string()
6886                    .ok()
6887                    .map(|body| body.chars().take(400).collect::<String>())
6888                    .filter(|body| !body.trim().is_empty());
6889                Err(LinkError::Http {
6890                    what: "v2 changed-byte upload",
6891                    status,
6892                    message: match detail {
6893                        Some(body) => format!(
6894                            "object store rejected the upload of `{}`: {}",
6895                            source.path,
6896                            body.replace('\n', " ")
6897                        ),
6898                        None => {
6899                            format!("object store rejected the upload of `{}`", source.path)
6900                        }
6901                    },
6902                    code: None,
6903                    details: None,
6904                })
6905            }
6906            ureq::Error::Transport(error) => Err(LinkError::Transport {
6907                hub: "the object store".to_string(),
6908                message: error.to_string(),
6909            }),
6910        },
6911    }
6912}
6913
6914fn v2_sync_push(
6915    cfg: &HubConfig,
6916    requested_brain: &str,
6917    store: &Store,
6918    head: V2VerifiedHead,
6919    options: V2SyncPushOptions<'_>,
6920) -> LinkResult<Value> {
6921    let V2SyncPushOptions {
6922        resume_local_policy,
6923        bulk_confirmation,
6924        resolution,
6925        pulled,
6926        withdrawal_paths,
6927        withdrawal_reason,
6928    } = options;
6929    let _operation_lock = lock_v2_sync_operation(cfg, &head.brain_id)?;
6930    let head = v2_verified_head(cfg, requested_brain)?
6931        .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
6932    let (mut remote, mut remote_assets, carried_local, carried_local_assets) =
6933        match pulled.filter(|snapshot| same_v2_head(&snapshot.head, &head)) {
6934            Some(snapshot) => (
6935                snapshot.files,
6936                snapshot.assets,
6937                Some(snapshot.local),
6938                Some(snapshot.local_assets),
6939            ),
6940            None => (
6941                files_for_v2_view(
6942                    &head,
6943                    v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6944                ),
6945                v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6946                None,
6947                None,
6948            ),
6949        };
6950    let baseline = load_v2_baseline(cfg, &head.brain_id, &store.root)?;
6951    ensure_v2_view_compatible(&head, baseline.as_ref())?;
6952    if head.view_kind == "scoped" && baseline.is_none() {
6953        return Err(LinkError::ScopedViewChanged);
6954    }
6955    let local_view = local_view_for_v2_push(store, &head, baseline.as_ref(), carried_local)?;
6956    let local = &local_view.riding;
6957    let local_assets = match carried_local_assets {
6958        Some(assets) => assets,
6959        None => v2_local_asset_records(store)?,
6960    };
6961    if withdrawal_paths.len() > MAX_PUSH_FILES {
6962        return Err(LinkError::PushTooLarge {
6963            detail: "too many explicit withdrawal paths".to_string(),
6964        });
6965    }
6966    let withdrawal_reason = if withdrawal_paths.is_empty() {
6967        None
6968    } else {
6969        let reason = withdrawal_reason
6970            .map(str::trim)
6971            .filter(|reason| !reason.is_empty() && reason.len() <= 1000)
6972            .ok_or_else(|| LinkError::InvalidPack {
6973                message: "explicit withdrawal requires a non-empty --withdraw-reason of at most 1000 bytes".to_string(),
6974            })?;
6975        Some(reason)
6976    };
6977    let mut withdrawals = withdrawal_paths
6978        .iter()
6979        .map(|path| {
6980            crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
6981                path: error.to_string(),
6982            })
6983        })
6984        .collect::<LinkResult<Vec<_>>>()?;
6985    withdrawals.sort();
6986    withdrawals.dedup();
6987    if withdrawals.len() != withdrawal_paths.len() {
6988        return Err(LinkError::InvalidPack {
6989            message: "explicit withdrawal paths must be unique".to_string(),
6990        });
6991    }
6992    let withdrawal_set = withdrawals.iter().cloned().collect::<BTreeSet<_>>();
6993    let mut consumed_withdrawals = BTreeSet::new();
6994    if let Some(previous) = baseline.as_ref() {
6995        if previous.local_policy_digest.as_deref() != Some(&local_view.policy.digest)
6996            && !resume_local_policy
6997        {
6998            let mut newly_eligible = previous
6999                .local_eligibility
7000                .iter()
7001                .filter(|(path, riding)| !**riding && !local_view.policy.keeps_home(path))
7002                .map(|(path, _)| path.clone())
7003                .collect::<Vec<_>>();
7004            if !newly_eligible.is_empty() {
7005                newly_eligible.truncate(100);
7006                return Err(LinkError::LocalPolicyTransition {
7007                    paths: newly_eligible,
7008                });
7009            }
7010        }
7011    }
7012    let base = match baseline.as_ref() {
7013        Some(state) => &state.files,
7014        None if remote.is_empty() => &remote,
7015        None => {
7016            let mut conflicts = remote
7017                .iter()
7018                .filter(|(path, file)| {
7019                    local.get(*path).map(|value| value.0.as_str()) != Some(file.sha256.as_str())
7020                })
7021                .map(|(path, _)| path.clone())
7022                .collect::<Vec<_>>();
7023            if !conflicts.is_empty() {
7024                conflicts.truncate(100);
7025                let (bundle, paths) =
7026                    create_v2_conflict_bundle(cfg, store, &head, None, local, &remote, &conflicts)?;
7027                return Err(LinkError::ConflictBundle { bundle, paths });
7028            }
7029            &remote
7030        }
7031    };
7032    let all_paths = base
7033        .keys()
7034        .chain(remote.keys())
7035        .chain(local.keys())
7036        .cloned()
7037        .collect::<std::collections::BTreeSet<_>>();
7038    let mut conflicts = Vec::new();
7039    let mut operations = Vec::new();
7040    let mut upload_sources = std::collections::BTreeMap::<String, V2UploadSource>::new();
7041    for path in all_paths {
7042        let base_hash = base.get(&path).map(|file| file.sha256.as_str());
7043        let remote_file = remote.get(&path);
7044        let remote_hash = remote_file.map(|file| file.sha256.as_str());
7045        let local_file = local.get(&path);
7046        let local_hash = local_file.map(|file| file.0.as_str());
7047        if local_hash == base_hash {
7048            continue;
7049        }
7050        if resolution.is_some_and(|allowed| !allowed.contains_key(&path)) {
7051            continue;
7052        }
7053        if local_view.policy.keeps_home(&path) {
7054            // Kept-home is a local transfer exclusion, never an implicit
7055            // delete of the company's already-hosted coordinate.
7056            continue;
7057        }
7058        if remote_hash != base_hash && local_hash != remote_hash {
7059            let explicitly_resolved = resolution
7060                .and_then(|allowed| allowed.get(&path))
7061                .is_some_and(|selected| {
7062                    selected.expected_remote.as_deref() == remote_hash
7063                        && selected.selected_local.as_deref() == local_hash
7064                });
7065            if !explicitly_resolved {
7066                conflicts.push(path);
7067                continue;
7068            }
7069        }
7070        match local_file {
7071            Some((sha256, byte_count)) => {
7072                verify_v2_upload_source(store, &path, sha256, *byte_count)?;
7073                operations.push(json!({
7074                    "op": "put",
7075                    "path": path,
7076                    "expected": v2_expected(remote_file),
7077                    "blob": sha256,
7078                    "bytes": byte_count,
7079                }));
7080                upload_sources
7081                    .entry(sha256.clone())
7082                    .or_insert_with(|| V2UploadSource {
7083                        path: path.clone(),
7084                        bytes: *byte_count,
7085                    });
7086            }
7087            None => {
7088                let Some(current) = remote_file else {
7089                    continue;
7090                };
7091                operations.push(json!({
7092                    "op": "delete",
7093                    "path": path,
7094                    "expected": { "kind": "blob", "hash": current.sha256 },
7095                }));
7096            }
7097        }
7098    }
7099    operations = infer_exact_source_promotions(operations);
7100    for path in &withdrawals {
7101        if local_assets.contains_key(path) {
7102            continue;
7103        }
7104        operations.push(v2_content_withdrawal_operation(
7105            store,
7106            &local_view,
7107            &remote,
7108            path,
7109            withdrawal_reason.expect("non-empty withdrawal set has a reason"),
7110        )?);
7111        consumed_withdrawals.insert(path.clone());
7112    }
7113    if !conflicts.is_empty() {
7114        conflicts.truncate(100);
7115        let (bundle, paths) = create_v2_conflict_bundle(
7116            cfg,
7117            store,
7118            &head,
7119            baseline.as_ref(),
7120            local,
7121            &remote,
7122            &conflicts,
7123        )?;
7124        return Err(LinkError::ConflictBundle { bundle, paths });
7125    }
7126    let base_assets = match baseline.as_ref() {
7127        Some(state) => &state.assets,
7128        None if remote_assets.is_empty() => &remote_assets,
7129        None => {
7130            let mismatched = remote_assets.iter().any(|(path, remote)| {
7131                local_assets.get(path) != Some(&v2_asset_record(remote, path))
7132            }) || local_assets.len() != remote_assets.len();
7133            if mismatched {
7134                return Err(LinkError::Conflict {
7135                    paths: vec!["assets.jsonl".to_string()],
7136                });
7137            }
7138            &remote_assets
7139        }
7140    };
7141    let asset_paths = base_assets
7142        .keys()
7143        .chain(remote_assets.keys())
7144        .chain(local_assets.keys())
7145        .cloned()
7146        .collect::<std::collections::BTreeSet<_>>();
7147    let mut asset_policy_transitions = Vec::new();
7148    for path in asset_paths {
7149        let base_record = base_assets
7150            .get(&path)
7151            .map(|asset| v2_asset_record(asset, &path));
7152        let remote = remote_assets.get(&path);
7153        let remote_record = remote.map(|asset| v2_asset_record(asset, &path));
7154        let local_record = local_assets.get(&path);
7155        if withdrawal_set.contains(&path) {
7156            let record = local_record.ok_or_else(|| LinkError::InvalidPack {
7157                message: format!("asset withdrawal path `{path}` is absent from assets.jsonl"),
7158            })?;
7159            let current = remote.ok_or_else(|| LinkError::InvalidPack {
7160                message: format!(
7161                    "asset withdrawal path `{path}` has no readable hosted coordinate"
7162                ),
7163            })?;
7164            operations.push(v2_asset_withdrawal_operation(
7165                store,
7166                &local_view,
7167                &path,
7168                record,
7169                current,
7170                withdrawal_reason.expect("non-empty withdrawal set has a reason"),
7171            )?);
7172            consumed_withdrawals.insert(path.clone());
7173            continue;
7174        }
7175        let mut raw_present = false;
7176        let mut disposition = "withheld";
7177        let mut resumes_hosting = false;
7178        if let Some(record) = local_record {
7179            crate::linkmd_v2::normalize_path(&record.path)
7180                .map_err(|error| invalid_feed(error.to_string()))?;
7181            let kept_home = local_view.policy.keeps_home(&path);
7182            raw_present = matches!(store.regular_file_exists(Path::new(&path)), Ok(true));
7183            disposition = if kept_home || !raw_present {
7184                "withheld"
7185            } else {
7186                "hosted"
7187            };
7188            if !raw_present && record.required && !kept_home {
7189                return Err(LinkError::InvalidPack {
7190                    message: format!("required asset {path} is missing"),
7191                });
7192            }
7193            resumes_hosting = v2_asset_resumes_hosting(remote, &path, record, disposition);
7194        }
7195        if local_record == base_record.as_ref() && !resumes_hosting {
7196            continue;
7197        }
7198        if remote_record != base_record && local_record != remote_record.as_ref() {
7199            conflicts.push(path);
7200            continue;
7201        }
7202        let Some(record) = local_record else {
7203            if let Some(remote) = remote {
7204                operations.push(json!({
7205                    "op": "asset_delete",
7206                    "path": path,
7207                    "expected": v2_asset_expected(Some(remote)),
7208                }));
7209            }
7210            continue;
7211        };
7212        let raw = if raw_present {
7213            verify_v2_upload_source(store, &path, &record.sha256, record.bytes)?;
7214            Some(())
7215        } else {
7216            None
7217        };
7218        let op = if resumes_hosting {
7219            if !resume_local_policy {
7220                asset_policy_transitions.push(path);
7221                continue;
7222            }
7223            "asset_resume"
7224        } else {
7225            "asset_put"
7226        };
7227        operations.push(json!({
7228            "op": op,
7229            "path": path,
7230            "expected": v2_asset_expected(remote),
7231            "asset": v2_asset_value(record, disposition),
7232        }));
7233        if disposition == "hosted" {
7234            raw.expect("hosted asset was checked present");
7235            upload_sources
7236                .entry(record.sha256.clone())
7237                .or_insert_with(|| V2UploadSource {
7238                    path: path.clone(),
7239                    bytes: record.bytes,
7240                });
7241        }
7242    }
7243    if consumed_withdrawals != withdrawal_set {
7244        let missing = withdrawal_set
7245            .difference(&consumed_withdrawals)
7246            .next()
7247            .expect("different withdrawal sets have one member");
7248        return Err(LinkError::InvalidPack {
7249            message: format!(
7250                "withdrawal path `{missing}` is not a readable content or asset coordinate"
7251            ),
7252        });
7253    }
7254    if !conflicts.is_empty() {
7255        conflicts.truncate(100);
7256        return Err(LinkError::Conflict { paths: conflicts });
7257    }
7258    if !asset_policy_transitions.is_empty() {
7259        asset_policy_transitions.truncate(100);
7260        return Err(LinkError::LocalPolicyTransition {
7261            paths: asset_policy_transitions,
7262        });
7263    }
7264    let touched_sources = operations
7265        .iter()
7266        .filter_map(
7267            |operation| match operation.get("op").and_then(Value::as_str) {
7268                Some("put" | "restore") => operation.get("path").and_then(Value::as_str),
7269                Some("rename") => operation.get("to").and_then(Value::as_str),
7270                _ => None,
7271            },
7272        )
7273        .collect::<std::collections::BTreeSet<_>>();
7274    let withheld_links = local_view
7275        .withheld_links
7276        .iter()
7277        .filter(|link| touched_sources.contains(link.source.as_str()))
7278        .collect::<Vec<_>>();
7279    let checkout_pseudonym = v2_checkout_id(
7280        baseline
7281            .as_ref()
7282            .and_then(|current| current.checkout_id.as_deref()),
7283    )?;
7284    let checkout_id = if withheld_links.is_empty() {
7285        None
7286    } else {
7287        Some(checkout_pseudonym.clone())
7288    };
7289    if operations.is_empty() {
7290        let final_head = v2_verified_head(cfg, requested_brain)?
7291            .ok_or_else(|| invalid_feed("v2 head disappeared during sync"))?;
7292        if !same_v2_head(&head, &final_head) {
7293            return Err(LinkError::RemoteAdvancedDuringSync);
7294        }
7295        let mut final_local = v2_local_files(store)?;
7296        remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
7297        let final_assets = v2_local_asset_records(store)?;
7298        let local_changed = final_local.riding != local_view.riding || final_assets != local_assets;
7299        let remote_ahead = !v2_riding_matches_remote(&final_local.riding, &remote, |path| {
7300            final_local.policy.keeps_home(path)
7301        }) || !v2_asset_records_match_remote(&final_assets, &remote_assets);
7302        let next = v2_baseline_from_head(
7303            cfg,
7304            &head,
7305            remote,
7306            remote_assets,
7307            Some(&final_local),
7308            Some(&checkout_pseudonym),
7309        )?;
7310        let split_count = next.remote_copy_remains.len();
7311        accept_v2_head(cfg, &final_head)?;
7312        if !local_changed && !remote_ahead {
7313            refresh_scoped_view_marker(store, &head, next.files.len())?;
7314            save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
7315        }
7316        return Ok(json!({
7317            "v": 2,
7318            "outcome": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "no_change" },
7319            "sync_status": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "synced" },
7320            "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
7321            "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
7322            "local_policy": {
7323                "remote_copy_remains": split_count,
7324            },
7325        }));
7326    }
7327    let includes_contract = operations
7328        .iter()
7329        .any(|operation| operation.get("path").and_then(Value::as_str) == Some("DB.md"));
7330    let rebase = if head.pointer.is_none() || includes_contract {
7331        "strict"
7332    } else {
7333        "disjoint"
7334    };
7335    let base_value = head.pointer.as_ref().map(|pointer| {
7336        json!({
7337            "seq": pointer.seq,
7338            "commit_hash": pointer.commit_hash,
7339            "content_root": pointer.content_root,
7340            "asset_root": pointer.asset_root,
7341        })
7342    });
7343    // The mutation coordinate is stable for the exact observed base and local
7344    // operation set. A lost response can therefore be retried without a
7345    // duplicate commit/proposal even across process restarts.
7346    let entropy = format!(
7347        "{}\0{}\0{}\0{}\0{}\0{}",
7348        normalized_origin(&cfg.hub)?,
7349        head.brain_id,
7350        serde_json::to_string(&base_value).unwrap_or_default(),
7351        serde_json::to_string(&operations).unwrap_or_default(),
7352        serde_json::to_string(&withheld_links).unwrap_or_default(),
7353        checkout_id.as_deref().unwrap_or("")
7354    );
7355    let mutation_id = format!("dbmd-{}", content_sha256(entropy.as_bytes()));
7356    let changed_bytes = upload_sources.values().try_fold(0_u64, |total, source| {
7357        total
7358            .checked_add(source.bytes)
7359            .ok_or_else(|| LinkError::PushTooLarge {
7360                detail: "v2 changed-byte total overflow".to_string(),
7361            })
7362    })?;
7363    let inline = changed_bytes <= 3 * 1024 * 1024;
7364    let inline_blobs = if inline {
7365        upload_sources
7366            .iter()
7367            .map(|(sha256, source)| {
7368                let bytes = store.read_bounded(Path::new(&source.path), source.bytes)?;
7369                if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != *sha256 {
7370                    return Err(LinkError::InvalidPack {
7371                        message: format!("local path `{}` changed before upload", source.path),
7372                    });
7373                }
7374                Ok(json!({
7375                    "sha256": sha256,
7376                    "bytes": source.bytes,
7377                    "content_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
7378                }))
7379            })
7380            .collect::<LinkResult<Vec<_>>>()?
7381    } else {
7382        Vec::new()
7383    };
7384    let mut body = json!({
7385        "mutation_id": mutation_id,
7386        "base": base_value,
7387        "rebase": rebase,
7388        "reason": "dbmd sync",
7389        "operations": operations,
7390        "blobs": inline_blobs,
7391    });
7392    if !withheld_links.is_empty() {
7393        body["withheld_links"] = serde_json::to_value(&withheld_links)
7394            .map_err(|_| invalid_feed("could not serialize withheld-link observations"))?;
7395        body["checkout_id"] =
7396            Value::String(checkout_id.expect("non-empty withheld links have a checkout pseudonym"));
7397    }
7398    if let Some(confirmation) = bulk_confirmation {
7399        if !crate::ulid::is_ulid(&confirmation.id) || !is_sha256(&confirmation.digest) {
7400            return Err(LinkError::InvalidPack {
7401                message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
7402                    .to_string(),
7403            });
7404        }
7405        // A preview is bound to the exact observed head. Confirmation can
7406        // therefore never use disjoint rebase semantics, even when the normal
7407        // small-mutation path could.
7408        body["rebase"] = Value::String("strict".to_string());
7409        body["bulk_preview_id"] = Value::String(confirmation.id.clone());
7410        body["bulk_preview_digest"] = Value::String(confirmation.digest.clone());
7411    }
7412    if !inline || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
7413        let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
7414        for operation in &operations {
7415            let Some(kind) = operation.get("op").and_then(Value::as_str) else {
7416                return Err(invalid_feed("v2 upload operation has no kind"));
7417            };
7418            let hash = match kind {
7419                "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
7420                "asset_put" | "asset_resume" => operation
7421                    .get("asset")
7422                    .and_then(|asset| asset.get("blob_sha256"))
7423                    .and_then(Value::as_str),
7424                _ => None,
7425            };
7426            let Some(hash) = hash else { continue };
7427            let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
7428            if kind == "rename" {
7429                for field in ["from", "to"] {
7430                    coordinates.insert(
7431                        operation
7432                            .get(field)
7433                            .and_then(Value::as_str)
7434                            .ok_or_else(|| invalid_feed("v2 rename upload has no coordinate"))?
7435                            .to_string(),
7436                    );
7437                }
7438            } else {
7439                let path = operation
7440                    .get("path")
7441                    .and_then(Value::as_str)
7442                    .ok_or_else(|| invalid_feed("v2 upload has no coordinate"))?;
7443                coordinates.insert(if kind.starts_with("asset_") {
7444                    format!("assets/{path}")
7445                } else {
7446                    path.to_string()
7447                });
7448            }
7449        }
7450        let declarations = upload_sources
7451            .iter()
7452            .map(|(sha256, source)| {
7453                json!({
7454                    "sha256": sha256,
7455                    "bytes": source.bytes,
7456                    "coordinates": coordinates_by_hash
7457                        .get(sha256)
7458                        .into_iter()
7459                        .flatten()
7460                        .collect::<Vec<_>>(),
7461                })
7462            })
7463            .collect::<Vec<_>>();
7464        let mut references = Vec::with_capacity(upload_sources.len());
7465        let mut seen = std::collections::BTreeSet::new();
7466        let mut reserved_count = 0usize;
7467        // A reservation's presigned URL is short-lived, so each batch's bytes
7468        // ride before the next batch is reserved. Reserving every blob first
7469        // and uploading afterwards expires the earliest URLs on a real store.
7470        for batch in batch_upload_declarations(declarations) {
7471            let batch_len = batch.len();
7472            let reserved = ensure_ok(
7473                request(
7474                    cfg,
7475                    "POST",
7476                    &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
7477                    Some(&json!({ "blobs": batch })),
7478                    Auth::Required,
7479                )?,
7480                "prepare v2 changed-byte uploads",
7481            )?;
7482            let items = reserved
7483                .get("uploads")
7484                .and_then(Value::as_array)
7485                .ok_or_else(|| invalid_feed("v2 upload reservation response has no items"))?;
7486            if items.len() != batch_len {
7487                return Err(invalid_feed(
7488                    "v2 upload reservation response changed the requested set",
7489                ));
7490            }
7491            reserved_count += items.len();
7492            for item in items {
7493                let sha256 = item
7494                    .get("sha256")
7495                    .and_then(Value::as_str)
7496                    .ok_or_else(|| invalid_feed("v2 upload reservation has no hash"))?;
7497                let source = upload_sources
7498                    .get(sha256)
7499                    .ok_or_else(|| invalid_feed("v2 upload reservation introduced a blob"))?;
7500                let declared_bytes = item
7501                    .get("bytes")
7502                    .and_then(Value::as_u64)
7503                    .ok_or_else(|| invalid_feed("v2 upload reservation has no byte length"))?;
7504                let reservation_id = item
7505                    .get("reservation_id")
7506                    .and_then(Value::as_str)
7507                    .ok_or_else(|| invalid_feed("v2 upload reservation has no opaque id"))?;
7508                let expected_coordinates = coordinates_by_hash.get(sha256).ok_or_else(|| {
7509                    invalid_feed("v2 upload reservation has no coordinate binding")
7510                })?;
7511                let returned_coordinates = item
7512                    .get("coordinates")
7513                    .and_then(Value::as_array)
7514                    .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinates"))?;
7515                if declared_bytes != source.bytes
7516                    || !crate::ulid::is_ulid(reservation_id)
7517                    || !seen.insert(sha256.to_string())
7518                    || returned_coordinates.len() != expected_coordinates.len()
7519                    || returned_coordinates
7520                        .iter()
7521                        .zip(expected_coordinates)
7522                        .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
7523                {
7524                    return Err(invalid_feed("v2 upload reservation item is inconsistent"));
7525                }
7526                match item.get("status").and_then(Value::as_str) {
7527                    Some("upload") => {
7528                        let url = item
7529                            .get("url")
7530                            .and_then(Value::as_str)
7531                            .ok_or_else(|| invalid_feed("v2 upload reservation has no URL"))?;
7532                        put_presigned_source(
7533                            cfg,
7534                            url,
7535                            item.get("headers").unwrap_or(&Value::Null),
7536                            store,
7537                            source,
7538                        )?;
7539                        verify_v2_upload_source(store, &source.path, sha256, source.bytes)?;
7540                    }
7541                    Some("already_present") => {}
7542                    _ => return Err(invalid_feed("v2 upload reservation has an unknown status")),
7543                }
7544                references.push(json!({
7545                    "sha256": sha256,
7546                    "bytes": source.bytes,
7547                    "reservation_id": reservation_id,
7548                }));
7549            }
7550        }
7551        if reserved_count != upload_sources.len() {
7552            return Err(invalid_feed(
7553                "v2 upload reservation response changed the requested set",
7554            ));
7555        }
7556        body["blobs"] = Value::Array(references);
7557    }
7558    if body.to_string().len() > MAX_PUSH_BYTES {
7559        return Err(LinkError::PushTooLarge {
7560            detail: "v2 operation metadata exceeds the bounded commit request".to_string(),
7561        });
7562    }
7563    let path = format!("/api/hub/brains/{requested_brain}/v2/commits");
7564    let mut candidate_hub_signer: Option<String> = None;
7565    let mut response = request(cfg, "POST", &path, Some(&body), Auth::Required)?;
7566    let bulk_preview_required = !(200..300).contains(&response.status)
7567        && response.body.as_ref().is_some_and(|value| {
7568            value.get("code").and_then(Value::as_str) == Some("bulk_preview_required")
7569                || value
7570                    .get("details")
7571                    .and_then(|details| details.get("code"))
7572                    .and_then(Value::as_str)
7573                    == Some("bulk_preview_required")
7574        });
7575    if bulk_preview_required && bulk_confirmation.is_none() {
7576        body["rebase"] = Value::String("strict".to_string());
7577        body["preview_only"] = Value::Bool(true);
7578        let preview = ensure_ok(
7579            request(cfg, "POST", &path, Some(&body), Auth::Required)?,
7580            "v2 bulk preview",
7581        )?;
7582        let preview_code = preview.get("code").and_then(Value::as_str);
7583        let required = preview.get("required").and_then(Value::as_bool);
7584        if preview.get("v").and_then(Value::as_u64) != Some(2)
7585            || preview.get("mutation_id").and_then(Value::as_str) != Some(mutation_id.as_str())
7586            || !matches!(
7587                preview_code,
7588                Some("bulk_preview_created" | "bulk_preview_not_required")
7589            )
7590            || required.is_none()
7591        {
7592            return Err(invalid_feed(
7593                "bulk preview response is not bound to the requested mutation",
7594            ));
7595        }
7596        if required == Some(true) {
7597            let preview_id = preview.get("bulk_preview_id").and_then(Value::as_str);
7598            let preview_digest = preview.get("bulk_preview_digest").and_then(Value::as_str);
7599            if preview_code != Some("bulk_preview_created")
7600                || preview_id.is_none_or(|value| !crate::ulid::is_ulid(value))
7601                || preview_digest.is_none_or(|value| !is_sha256(value))
7602                || preview.get("expires_at").and_then(Value::as_str).is_none()
7603                || !preview.get("impact").is_some_and(Value::is_object)
7604            {
7605                return Err(invalid_feed("bulk preview receipt is malformed"));
7606            }
7607            return Err(LinkError::BulkPreviewRequired { preview });
7608        }
7609        if preview_code != Some("bulk_preview_not_required") {
7610            return Err(invalid_feed("bulk preview requirement is inconsistent"));
7611        }
7612        // The rolling activity window can expire between the refusal and
7613        // preview. Retry once, still pinned strictly to the observed head.
7614        body.as_object_mut()
7615            .expect("v2 commit request is an object")
7616            .remove("preview_only");
7617        response = request(cfg, "POST", &path, Some(&body), Auth::Required)?;
7618    }
7619    let mut result = ensure_ok(response, "v2 sync push")?;
7620    if result.get("code").and_then(Value::as_str) == Some("proposal_queued") {
7621        if let Some(object) = result.as_object_mut() {
7622            object.insert(
7623                "sync_status".to_string(),
7624                Value::String("proposal_pending".to_string()),
7625            );
7626        }
7627        return Ok(result);
7628    }
7629    if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
7630        let request_id = result
7631            .get("request_id")
7632            .and_then(Value::as_str)
7633            .ok_or_else(|| invalid_feed("self-custody response has no request id"))?
7634            .to_string();
7635        let challenge = result
7636            .get("signing_challenge")
7637            .ok_or_else(|| invalid_feed("self-custody response has no signing challenge"))?;
7638        let mut expected_candidate = remote.clone();
7639        let mut expected_candidate_assets = remote_assets.clone();
7640        apply_generated_v2_operations(
7641            &operations,
7642            &local_assets,
7643            &mut expected_candidate,
7644            &mut expected_candidate_assets,
7645        )?;
7646        let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
7647            cfg,
7648            &head,
7649            &expected_candidate,
7650            &expected_candidate_assets,
7651            &mutation_id,
7652            &body,
7653            challenge,
7654        )?;
7655        body["signing_challenge_id"] = Value::String(challenge_id);
7656        body["signature_base64url"] = Value::String(signature);
7657        candidate_hub_signer = Some(actor_signer);
7658        result = ensure_ok(
7659            request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
7660            "v2 self-custody commit",
7661        )?;
7662    }
7663    let refreshed = v2_verified_head(cfg, requested_brain)?
7664        .ok_or_else(|| invalid_feed("v2 head disappeared after commit"))?;
7665    if candidate_hub_signer
7666        .as_ref()
7667        .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
7668    {
7669        return Err(invalid_feed(
7670            "self-custody actor signer differs from the committed hub pointer signer",
7671        ));
7672    }
7673    let accepted_hash = result.get("commit_hash").and_then(Value::as_str);
7674    if refreshed
7675        .pointer
7676        .as_ref()
7677        .map(|pointer| pointer.commit_hash.as_str())
7678        != accepted_hash
7679    {
7680        return Err(LinkError::RemoteAdvancedDuringSync);
7681    }
7682    ensure_v2_view_compatible(&refreshed, baseline.as_ref())?;
7683    let rebased = result
7684        .get("rebased")
7685        .and_then(Value::as_bool)
7686        .ok_or_else(|| invalid_feed("v2 commit receipt has no rebase result"))?;
7687    let (refreshed_files, refreshed_assets) = if rebased {
7688        (
7689            files_for_v2_view(
7690                &refreshed,
7691                v2_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
7692            ),
7693            v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
7694        )
7695    } else {
7696        let asset_changed = apply_generated_v2_operations(
7697            &operations,
7698            &local_assets,
7699            &mut remote,
7700            &mut remote_assets,
7701        )?;
7702        let assets = if asset_changed {
7703            // Asset leaf hashes are server-generated signed coordinates; only
7704            // asset-changing commits need this usually tiny post-commit read.
7705            v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?
7706        } else {
7707            remote_assets
7708        };
7709        (remote, assets)
7710    };
7711    let mut final_local = v2_local_files(store)?;
7712    remove_scoped_projection(&refreshed, baseline.as_ref(), &mut final_local)?;
7713    let final_assets = v2_local_asset_records(store)?;
7714    let local_dirty = final_local.riding != local_view.riding
7715        || !v2_riding_matches_remote(&final_local.riding, &refreshed_files, |path| {
7716            final_local.policy.keeps_home(path)
7717        })
7718        || final_assets != local_assets
7719        || !v2_asset_records_match_remote(&final_assets, &refreshed_assets);
7720    let next = v2_baseline_from_head(
7721        cfg,
7722        &refreshed,
7723        refreshed_files,
7724        refreshed_assets,
7725        Some(&final_local),
7726        Some(&checkout_pseudonym),
7727    )?;
7728    let split_count = next.remote_copy_remains.len();
7729    accept_v2_head(cfg, &refreshed)?;
7730    if !local_dirty {
7731        refresh_scoped_view_marker(store, &refreshed, next.files.len())?;
7732        save_v2_baseline(cfg, &refreshed.brain_id, &store.root, &next)?;
7733    }
7734    if let Some(object) = result.as_object_mut() {
7735        object.insert(
7736            "local_policy".to_string(),
7737            json!({ "remote_copy_remains": split_count }),
7738        );
7739        object.insert(
7740            "sync_status".to_string(),
7741            Value::String(if local_dirty {
7742                "remote_committed_local_dirty".to_string()
7743            } else {
7744                "synced".to_string()
7745            }),
7746        );
7747    }
7748    Ok(result)
7749}
7750
7751/// Negotiate v2 and send only local changes. A v1 hub retains the existing
7752/// whole-snapshot behavior until its brain advertises the new profile.
7753pub fn sync_push_incremental(cfg: &HubConfig, brain: &str, store: &Store) -> LinkResult<Value> {
7754    sync_push_incremental_with_policy(cfg, brain, store, false)
7755}
7756
7757/// The explicit `.sevralocal` adoption form. Ordinary sync never uploads a
7758/// path that became eligible because local policy was weakened or removed.
7759pub fn sync_push_incremental_with_policy(
7760    cfg: &HubConfig,
7761    brain: &str,
7762    store: &Store,
7763    resume_local_policy: bool,
7764) -> LinkResult<Value> {
7765    sync_push_incremental_with_options(cfg, brain, store, resume_local_policy, None)
7766}
7767
7768/// Incremental push with both local-policy adoption and an optional exact
7769/// permissioned bulk-preview confirmation.
7770pub fn sync_push_incremental_with_options(
7771    cfg: &HubConfig,
7772    brain: &str,
7773    store: &Store,
7774    resume_local_policy: bool,
7775    bulk_confirmation: Option<&V2BulkConfirmation>,
7776) -> LinkResult<Value> {
7777    sync_push_incremental_with_controls(
7778        cfg,
7779        brain,
7780        store,
7781        resume_local_policy,
7782        bulk_confirmation,
7783        &[],
7784        None,
7785    )
7786}
7787
7788/// Incremental push with explicit permissioned current-host withdrawal.
7789pub fn sync_push_incremental_with_controls(
7790    cfg: &HubConfig,
7791    brain: &str,
7792    store: &Store,
7793    resume_local_policy: bool,
7794    bulk_confirmation: Option<&V2BulkConfirmation>,
7795    withdrawal_paths: &[String],
7796    withdrawal_reason: Option<&str>,
7797) -> LinkResult<Value> {
7798    require_safe_ref(brain)?;
7799    if let Some(head) = v2_verified_head(cfg, brain)? {
7800        return v2_sync_push(
7801            cfg,
7802            brain,
7803            store,
7804            head,
7805            V2SyncPushOptions {
7806                resume_local_policy,
7807                bulk_confirmation,
7808                resolution: None,
7809                pulled: None,
7810                withdrawal_paths,
7811                withdrawal_reason,
7812            },
7813        );
7814    }
7815    if !withdrawal_paths.is_empty() {
7816        return Err(LinkError::InvalidPack {
7817            message: "explicit withdrawal requires a link.md v2 brain".to_string(),
7818        });
7819    }
7820    legacy_sync_push_incremental(cfg, brain, store, resume_local_policy, bulk_confirmation)
7821}
7822
7823/// Whether this exact hub/brain/path coordinate has an accepted v2 sync
7824/// baseline. CLI dispatch uses this to keep checkout identity sticky even when
7825/// a damaged `DB.md` makes strict store-open fail.
7826pub fn has_v2_sync_baseline(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<bool> {
7827    require_safe_ref(brain)?;
7828    Ok(load_v2_baseline(cfg, brain, checkout)?.is_some())
7829}
7830
7831#[cfg(windows)]
7832fn legacy_sync_push_incremental(
7833    _cfg: &HubConfig,
7834    _brain: &str,
7835    _store: &Store,
7836    _resume_local_policy: bool,
7837    _bulk_confirmation: Option<&V2BulkConfirmation>,
7838) -> LinkResult<Value> {
7839    Err(LinkError::UnsupportedPlatform {
7840        operation: "legacy v1 whole-snapshot push on Windows; upgrade the brain to link.md v2",
7841    })
7842}
7843
7844#[cfg(not(windows))]
7845fn legacy_sync_push_incremental(
7846    cfg: &HubConfig,
7847    brain: &str,
7848    store: &Store,
7849    resume_local_policy: bool,
7850    bulk_confirmation: Option<&V2BulkConfirmation>,
7851) -> LinkResult<Value> {
7852    if resume_local_policy || bulk_confirmation.is_some() {
7853        return Err(LinkError::InvalidPack {
7854            message: "v2 sync options require a link.md v2 brain".to_string(),
7855        });
7856    }
7857    let files = collect_push_files(store)?;
7858    sync_push(cfg, brain, &files)
7859}
7860
7861/// One explicit resolution for a previously preserved private conflict bundle.
7862#[derive(Debug, Clone)]
7863pub enum V2ConflictChoice {
7864    KeepLocal,
7865    TakeRemote,
7866    From(PathBuf),
7867}
7868
7869fn load_v2_conflict_plan(store: &Store, bundle: &str) -> LinkResult<V2ConflictPlan> {
7870    if !crate::ulid::is_ulid(bundle) {
7871        return Err(LinkError::InvalidPack {
7872            message: "conflict bundle must be a lowercase ULID".to_string(),
7873        });
7874    }
7875    let bytes = store.read_bounded(&v2_conflict_relative(bundle, "plan.json"), 1024 * 1024)?;
7876    let plan: V2ConflictPlan = serde_json::from_slice(&bytes)
7877        .map_err(|_| invalid_feed("private conflict plan is corrupt"))?;
7878    if plan.v != 2
7879        || plan.class != "content_resolution_required"
7880        || plan.bundle != bundle
7881        || !crate::ulid::is_ulid(&plan.brain)
7882        || plan.files.is_empty()
7883        || plan.files.len() > 100
7884        || plan.files.iter().any(|file| {
7885            crate::linkmd_v2::normalize_path(&file.path).is_err()
7886                || [&file.base, &file.local, &file.remote]
7887                    .into_iter()
7888                    .any(|coordinate| {
7889                        coordinate
7890                            .sha256
7891                            .as_deref()
7892                            .is_some_and(|hash| !is_sha256(hash))
7893                            || coordinate.file.as_deref().is_some_and(|name| {
7894                                name.starts_with('/')
7895                                    || name
7896                                        .split('/')
7897                                        .any(|part| part.is_empty() || part == "." || part == "..")
7898                            })
7899                    })
7900        })
7901    {
7902        return Err(invalid_feed("private conflict plan failed validation"));
7903    }
7904    Ok(plan)
7905}
7906
7907/// Inspect or prune private conflict-control state. Ordinary pruning removes
7908/// only expired completed bundles and interrupted bundles with no `plan.json`;
7909/// corrupt completed plans fail closed. `all` is an explicit local discard of
7910/// every well-addressed bundle and never changes hosted brain data.
7911pub fn sync_conflicts(checkout: &Path, prune: bool, all: bool) -> LinkResult<Value> {
7912    require_hardened_filesystem("private conflict maintenance")?;
7913    if all && !prune {
7914        return Err(LinkError::InvalidPack {
7915            message: "discarding all conflict bundles requires prune=true".to_string(),
7916        });
7917    }
7918    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
7919        message: format!("conflict checkout is not a valid db.md store: {error}"),
7920    })?;
7921    let _transaction = store.transaction()?;
7922    let root = Path::new(".dbmd/conflicts");
7923    let names = match store.directory_names(root) {
7924        Ok(names) => names,
7925        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
7926        Err(error) => return Err(error.into()),
7927    };
7928    let now = SystemTime::now()
7929        .duration_since(UNIX_EPOCH)
7930        .unwrap_or_default()
7931        .as_secs();
7932    let mut bundles = Vec::new();
7933    let mut pruned = 0_u64;
7934    for name in names {
7935        let Some(bundle) = name.to_str().filter(|value| crate::ulid::is_ulid(value)) else {
7936            continue;
7937        };
7938        let plan_path = v2_conflict_relative(bundle, "plan.json");
7939        let plan_exists = store.regular_file_exists(&plan_path)?;
7940        let expired = if plan_exists {
7941            match load_v2_conflict_plan(&store, bundle) {
7942                Ok(plan) => plan.expires_unix < now,
7943                Err(error) if all => {
7944                    let _ = error;
7945                    true
7946                }
7947                Err(error) => return Err(error),
7948            }
7949        } else {
7950            true
7951        };
7952        if prune && (all || expired) {
7953            store.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
7954            pruned += 1;
7955            continue;
7956        }
7957        bundles.push(json!({
7958            "bundle": bundle,
7959            "complete": plan_exists,
7960            "expired": expired,
7961        }));
7962    }
7963    Ok(json!({
7964        "v": 2,
7965        "class": "private_conflict_state",
7966        "bundles": bundles.len(),
7967        "pruned": pruned,
7968        "items": bundles,
7969    }))
7970}
7971
7972/// Resolve one exact private conflict plan. Drift never lowers a precondition:
7973/// the remote head/view and every original local coordinate are rechecked
7974/// before either an explicit local install or a fresh normal commit.
7975pub fn sync_resolve_conflict(
7976    cfg: &HubConfig,
7977    checkout: &Path,
7978    bundle: &str,
7979    choice: V2ConflictChoice,
7980    bulk_confirmation: Option<&V2BulkConfirmation>,
7981) -> LinkResult<Value> {
7982    require_hardened_filesystem("conflict resolution")?;
7983    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
7984        message: format!("conflict checkout is not a valid db.md store: {error}"),
7985    })?;
7986    let plan = load_v2_conflict_plan(&store, bundle)?;
7987    if plan.origin != normalized_origin(&cfg.hub)? {
7988        return Err(invalid_feed(
7989            "conflict bundle belongs to another hub origin",
7990        ));
7991    }
7992    let now = SystemTime::now()
7993        .duration_since(UNIX_EPOCH)
7994        .unwrap_or_default()
7995        .as_secs();
7996    if now > plan.expires_unix {
7997        return Err(LinkError::InvalidPack {
7998            message: "conflict bundle expired; rerun sync to obtain current coordinates"
7999                .to_string(),
8000        });
8001    }
8002    let head = v2_verified_head(cfg, &plan.brain)?
8003        .ok_or_else(|| invalid_feed("conflict brain no longer advertises v2"))?;
8004    let pointer = head.pointer.as_ref();
8005    if pointer.map_or(0, |value| value.seq) != plan.remote_seq
8006        || pointer.map(|value| value.commit_hash.as_str()) != plan.remote_commit.as_deref()
8007        || pointer.and_then(|value| value.content_root.as_deref())
8008            != plan.remote_content_root.as_deref()
8009        || head.view_kind != plan.view_kind
8010        || head.view_revision != plan.view_revision
8011    {
8012        return Err(LinkError::RemoteAdvancedDuringSync);
8013    }
8014
8015    // A resolution never overwrites a post-bundle local edit.
8016    for file in &plan.files {
8017        let actual = match store.regular_file_exists(Path::new(&file.path))? {
8018            true => Some(content_sha256(&store.read_bounded(
8019                Path::new(&file.path),
8020                file.local.bytes.unwrap_or(MAX_STORE_BYTES),
8021            )?)),
8022            false => None,
8023        };
8024        if actual.as_deref() != file.local.sha256.as_deref() {
8025            return Err(LinkError::InvalidPack {
8026                message: format!(
8027                    "local conflict path `{}` changed after the bundle was created",
8028                    file.path
8029                ),
8030            });
8031        }
8032    }
8033
8034    let from_source = match &choice {
8035        V2ConflictChoice::From(source) => Some(source.clone()),
8036        _ => None,
8037    };
8038    let result = match choice {
8039        V2ConflictChoice::TakeRemote => {
8040            if bulk_confirmation.is_some() {
8041                return Err(LinkError::InvalidPack {
8042                    message: "bulk confirmation applies to keep-local/from commits, not a local take-remote install".to_string(),
8043                });
8044            }
8045            // Re-read the exact current manifest so every selected remote byte
8046            // retains its signed inclusion proof. The private bundle is useful
8047            // for inspection, but is never promoted into live state as trust.
8048            let current_remote =
8049                files_for_v2_view(&head, v2_manifest(cfg, &plan.brain, head.pointer.as_ref())?);
8050            let _ = v2_take_remote_selection(&plan.files, &current_remote)?;
8051            let selected = plan
8052                .files
8053                .iter()
8054                .map(|file| file.path.clone())
8055                .collect::<std::collections::BTreeSet<_>>();
8056            serde_json::to_value(
8057                v2_sync_pull_with_resolution(
8058                    cfg,
8059                    &plan.brain,
8060                    head,
8061                    Some(checkout),
8062                    Some(&selected),
8063                )?
8064                .report,
8065            )
8066            .map_err(|_| invalid_feed("could not serialize conflict pull receipt"))?
8067        }
8068        V2ConflictChoice::KeepLocal | V2ConflictChoice::From(_) => {
8069            if let Some(source) = from_source.as_ref() {
8070                if plan.files.len() != 1 {
8071                    return Err(LinkError::InvalidPack {
8072                        message: "--from requires a bundle with exactly one conflict".to_string(),
8073                    });
8074                }
8075                let candidate = crate::fsx::read_bounded_nofollow(source, MAX_STORE_BYTES)?;
8076                if std::str::from_utf8(&candidate).is_err() {
8077                    return Err(LinkError::NotUtf8 {
8078                        path: source.display().to_string(),
8079                    });
8080                }
8081                store.write_atomic(Path::new(&plan.files[0].path), &candidate)?;
8082            }
8083            let refreshed_store =
8084                Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8085                    message: format!("resolved checkout is not a valid db.md store: {error}"),
8086                })?;
8087            let mut overrides = std::collections::BTreeMap::new();
8088            for file in &plan.files {
8089                let selected_local = match refreshed_store
8090                    .regular_file_exists(Path::new(&file.path))?
8091                {
8092                    true => Some(content_sha256(
8093                        &refreshed_store.read_bounded(Path::new(&file.path), MAX_STORE_BYTES)?,
8094                    )),
8095                    false => None,
8096                };
8097                overrides.insert(
8098                    file.path.clone(),
8099                    V2ResolutionOverride {
8100                        expected_remote: file.remote.sha256.clone(),
8101                        selected_local,
8102                    },
8103                );
8104            }
8105            v2_sync_push(
8106                cfg,
8107                &plan.brain,
8108                &refreshed_store,
8109                head,
8110                V2SyncPushOptions {
8111                    resume_local_policy: true,
8112                    bulk_confirmation,
8113                    resolution: Some(&overrides),
8114                    pulled: None,
8115                    withdrawal_paths: &[],
8116                    withdrawal_reason: None,
8117                },
8118            )?
8119        }
8120    };
8121
8122    if result.get("code").and_then(Value::as_str) != Some("proposal_queued") {
8123        let installed = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8124            message: format!("resolved checkout is not a valid db.md store: {error}"),
8125        })?;
8126        installed.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
8127    }
8128    Ok(json!({
8129        "v": 2,
8130        "class": "auto_converged",
8131        "bundle": bundle,
8132        "receipt": result,
8133    }))
8134}
8135
8136/// Converge one established permissioned-v2 checkout in both directions.
8137///
8138/// The pull half first performs the ordinary three-way conflict check and
8139/// crash-safe install against the accepted baseline. The push half then opens
8140/// that exact installed checkout, takes its local transaction guard, computes
8141/// a fresh three-way delta, and submits it with mandatory preconditions. A
8142/// remote movement between halves is therefore a normal rebase/conflict input,
8143/// never a blind overwrite. Legacy v1 remains explicit pull-only/push-only so
8144/// its whole-pack replacement semantics are never presented as granular
8145/// convergence.
8146pub fn sync_converge(
8147    cfg: &HubConfig,
8148    brain: &str,
8149    checkout: &Path,
8150    resume_local_policy: bool,
8151) -> LinkResult<Value> {
8152    sync_converge_with_options(cfg, brain, checkout, resume_local_policy, None)
8153}
8154
8155/// Bidirectional convergence with optional exact bulk-preview confirmation.
8156pub fn sync_converge_with_options(
8157    cfg: &HubConfig,
8158    brain: &str,
8159    checkout: &Path,
8160    resume_local_policy: bool,
8161    bulk_confirmation: Option<&V2BulkConfirmation>,
8162) -> LinkResult<Value> {
8163    sync_converge_with_controls(
8164        cfg,
8165        brain,
8166        checkout,
8167        resume_local_policy,
8168        bulk_confirmation,
8169        &[],
8170        None,
8171    )
8172}
8173
8174/// Bidirectional convergence with optional exact withdrawal intents.
8175pub fn sync_converge_with_controls(
8176    cfg: &HubConfig,
8177    brain: &str,
8178    checkout: &Path,
8179    resume_local_policy: bool,
8180    bulk_confirmation: Option<&V2BulkConfirmation>,
8181    withdrawal_paths: &[String],
8182    withdrawal_reason: Option<&str>,
8183) -> LinkResult<Value> {
8184    require_hardened_filesystem("bidirectional sync")?;
8185    require_safe_ref(brain)?;
8186    let head = v2_verified_head(cfg, brain)?.ok_or_else(|| LinkError::InvalidPack {
8187        message:
8188            "bidirectional sync requires link.md v2; use --pull-only or --push-only for a legacy brain"
8189                .to_string(),
8190    })?;
8191    let pulled = v2_sync_pull_with_resolution(cfg, brain, head, Some(checkout), None)?;
8192    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8193        message: format!("installed v2 checkout is not a valid db.md store: {error}"),
8194    })?;
8195    let _transaction = store.transaction()?;
8196    let pulled_report = pulled.report.clone();
8197    let pulled_head = pulled.head.clone();
8198    let mut result = v2_sync_push(
8199        cfg,
8200        brain,
8201        &store,
8202        pulled_head,
8203        V2SyncPushOptions {
8204            resume_local_policy,
8205            bulk_confirmation,
8206            resolution: None,
8207            pulled: Some(pulled),
8208            withdrawal_paths,
8209            withdrawal_reason,
8210        },
8211    )?;
8212    if let Some(object) = result.as_object_mut() {
8213        object.insert("pulled_files".to_string(), json!(pulled_report.files));
8214        object.insert("checkout".to_string(), Value::String(pulled_report.dest));
8215        object.insert(
8216            "mode".to_string(),
8217            Value::String("bidirectional".to_string()),
8218        );
8219    }
8220    Ok(result)
8221}
8222
8223/// Pull the granted slice of `brain` to `out` (default: `./<slug>`). Every
8224/// exported path is safety-gated before it touches disk; files are written
8225/// atomically; nothing local is ever deleted (locals the export lacks are
8226/// *reported* in `extra_local` instead). Returns the report; rebuilding the
8227/// local index catalog afterwards is the caller's (cheap, optional) step.
8228pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
8229    require_hardened_filesystem("sync pull")?;
8230    require_safe_ref(brain)?;
8231    if let Some(head) = v2_verified_head(cfg, brain)? {
8232        return v2_sync_pull(cfg, brain, head, out);
8233    }
8234    legacy_sync_pull(cfg, brain, out)
8235}
8236
8237#[cfg(windows)]
8238fn legacy_sync_pull(_cfg: &HubConfig, _brain: &str, _out: Option<&Path>) -> LinkResult<PullReport> {
8239    Err(LinkError::UnsupportedPlatform {
8240        operation: "legacy v1 whole-snapshot pull on Windows; upgrade the brain to link.md v2",
8241    })
8242}
8243
8244#[cfg(not(windows))]
8245fn legacy_sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
8246    let remote = verified_remote_head(cfg, brain, false)?;
8247    if !remote.head.verified {
8248        return Err(invalid_feed(
8249            "the grant exposes head movement but no signed snapshot; refusing an unverifiable pull",
8250        ));
8251    }
8252    let snapshot_hash = remote.head.feed_hash.as_deref().unwrap_or("none");
8253    let path = format!(
8254        "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={snapshot_hash}",
8255        remote.head.seq
8256    );
8257    let body = ensure_ok(
8258        request(cfg, "GET", &path, None, Auth::Required)?,
8259        "sync pull",
8260    )?;
8261    if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
8262        || body.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
8263    {
8264        return Err(invalid_feed(
8265            "export response is not bound to the verified snapshot token",
8266        ));
8267    }
8268
8269    let remote_slug = body
8270        .get("slug")
8271        .and_then(Value::as_str)
8272        .filter(|slug| is_safe_slug(slug));
8273    let slug = remote_slug
8274        .or_else(|| is_safe_slug(brain).then_some(brain))
8275        .unwrap_or("brain")
8276        .to_string();
8277    let brain_id = body
8278        .get("brain")
8279        .and_then(Value::as_str)
8280        .unwrap_or(&remote.head.brain)
8281        .to_string();
8282    if brain_id != remote.head.brain {
8283        return Err(invalid_feed(
8284            "export response names a different brain than the verified head",
8285        ));
8286    }
8287    let head_seq = remote.head.seq;
8288    let dest: PathBuf = match out {
8289        Some(p) => p.to_path_buf(),
8290        None => PathBuf::from(&slug),
8291    };
8292    let entries = if head_seq == 0 {
8293        let files = body
8294            .get("files")
8295            .and_then(Value::as_array)
8296            .ok_or_else(|| invalid_feed("empty snapshot export did not carry an empty manifest"))?;
8297        if !files.is_empty() || body.get("url").is_some() {
8298            return Err(invalid_feed(
8299                "empty signed feed cannot authorize non-empty exported content",
8300            ));
8301        }
8302        Vec::new()
8303    } else {
8304        let signed_head = remote
8305            .head_entry
8306            .as_ref()
8307            .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
8308        let expected = &signed_head.entry.pack_sha256;
8309        if !is_sha256(expected) {
8310            return Err(invalid_feed(
8311                "signed head carries an invalid snapshot pack digest",
8312            ));
8313        }
8314        if let Some(url) = body.get("url").and_then(Value::as_str) {
8315            if body.get("sha256").and_then(Value::as_str) != Some(expected.as_str()) {
8316                return Err(invalid_feed(
8317                    "export pack digest does not match the signed head entry",
8318                ));
8319            }
8320            let bytes = get_presigned(cfg, url)?;
8321            let actual = format!("{:x}", Sha256::digest(&bytes));
8322            if actual != *expected {
8323                return Err(LinkError::InvalidPack {
8324                    message: "downloaded pack does not match the signed snapshot digest"
8325                        .to_string(),
8326                });
8327            }
8328            let entries = parse_store_pack(bytes)?;
8329            if signed_head.entry.kind == "push" {
8330                verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
8331            }
8332            entries
8333        } else {
8334            if signed_head.entry.kind != "push" {
8335                return Err(invalid_feed(
8336                    "delta snapshots must export the exact signed pack",
8337                ));
8338            }
8339            let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
8340                invalid_feed("verified snapshot export carried neither a pack nor files")
8341            })?;
8342            let mut entries = Vec::with_capacity(files.len());
8343            for file in files {
8344                let path = file
8345                    .get("path")
8346                    .and_then(Value::as_str)
8347                    .ok_or_else(|| invalid_feed("exported file path is not a string"))?;
8348                let content = file
8349                    .get("content")
8350                    .and_then(Value::as_str)
8351                    .ok_or_else(|| invalid_feed("exported inline content is not UTF-8 text"))?;
8352                entries.push((path.to_string(), content.as_bytes().to_vec()));
8353            }
8354            verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
8355            entries
8356        }
8357    };
8358
8359    // Gate the complete manifest before the first filesystem mutation.
8360    let mut seen = std::collections::HashSet::new();
8361    for (path, _) in &entries {
8362        if !safe_store_rel_path(path) {
8363            return Err(LinkError::UnsafePath { path: path.clone() });
8364        }
8365        if !seen.insert(path) {
8366            return Err(LinkError::InvalidPack {
8367                message: format!("duplicate path `{path}`"),
8368            });
8369        }
8370    }
8371    // Compute divergence against the still-live tree. The staged clone keeps
8372    // these extra locals byte-for-byte while overlaying the signed snapshot.
8373    let pulled: std::collections::BTreeSet<&str> =
8374        entries.iter().map(|(p, _)| p.as_str()).collect();
8375    let mut extra_local = Vec::new();
8376    if let Ok(store) = Store::open(&dest) {
8377        if let Ok(walked) = store.walk() {
8378            for rel in walked {
8379                let rel_str = rel.to_string_lossy().replace('\\', "/");
8380                if !pulled.contains(rel_str.as_str()) {
8381                    extra_local.push(rel_str);
8382                }
8383            }
8384        }
8385    }
8386    #[cfg(unix)]
8387    install_pulled_snapshot(&dest, &entries)?;
8388
8389    Ok(PullReport {
8390        brain: brain_id,
8391        slug,
8392        head_seq,
8393        files: entries.len(),
8394        dest: dest.to_string_lossy().into_owned(),
8395        extra_local,
8396        sync_status: "synced".to_string(),
8397    })
8398}
8399
8400#[cfg(unix)]
8401fn c_name(bytes: &[u8], display: &str) -> LinkResult<std::ffi::CString> {
8402    std::ffi::CString::new(bytes).map_err(|_| LinkError::UnsafePath {
8403        path: display.to_string(),
8404    })
8405}
8406
8407#[cfg(unix)]
8408fn open_dir_at(
8409    parent: std::os::fd::RawFd,
8410    name: &std::ffi::CStr,
8411    display: &str,
8412) -> LinkResult<std::fs::File> {
8413    use std::os::fd::FromRawFd as _;
8414    let fd = unsafe {
8415        libc::openat(
8416            parent,
8417            name.as_ptr(),
8418            libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
8419        )
8420    };
8421    if fd < 0 {
8422        return Err(LinkError::UnsafePath {
8423            path: display.to_string(),
8424        });
8425    }
8426    Ok(unsafe { std::fs::File::from_raw_fd(fd) })
8427}
8428
8429/// Open (and, where absent, create) a directory path without following a
8430/// symlink in any component. The returned directory capability remains bound
8431/// to the opened inode even if an attacker renames or replaces an ancestor.
8432#[cfg(unix)]
8433fn open_dir_path_nofollow(path: &Path, create: bool) -> LinkResult<std::fs::File> {
8434    use std::os::fd::AsRawFd as _;
8435
8436    // macOS exposes a few system-owned compatibility symlinks at the root.
8437    // Normalize only those fixed OS aliases; never canonicalize an arbitrary
8438    // caller-controlled ancestor.
8439    #[cfg(target_os = "macos")]
8440    let normalized = [("/var", "/private/var"), ("/tmp", "/private/tmp")]
8441        .into_iter()
8442        .find_map(|(alias, real)| {
8443            path.strip_prefix(alias)
8444                .ok()
8445                .map(|rest| Path::new(real).join(rest))
8446        })
8447        .unwrap_or_else(|| path.to_path_buf());
8448    #[cfg(not(target_os = "macos"))]
8449    let normalized = path.to_path_buf();
8450
8451    let start = if normalized.is_absolute() {
8452        std::fs::File::open("/")?
8453    } else {
8454        std::fs::File::open(".")?
8455    };
8456    let mut directory = start;
8457    for component in normalized.components() {
8458        use std::path::Component;
8459        let name = match component {
8460            Component::RootDir | Component::CurDir => continue,
8461            Component::Normal(name) => name,
8462            Component::ParentDir | Component::Prefix(_) => {
8463                return Err(LinkError::UnsafePath {
8464                    path: path.display().to_string(),
8465                });
8466            }
8467        };
8468        use std::os::unix::ffi::OsStrExt as _;
8469        let name = c_name(name.as_bytes(), &path.display().to_string())?;
8470        if create {
8471            let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
8472            if made != 0 {
8473                let error = std::io::Error::last_os_error();
8474                if error.raw_os_error() != Some(libc::EEXIST) {
8475                    return Err(error.into());
8476                }
8477            }
8478        }
8479        directory = open_dir_at(directory.as_raw_fd(), &name, &path.display().to_string())?;
8480    }
8481    Ok(directory)
8482}
8483
8484#[cfg(unix)]
8485fn open_or_create_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
8486    open_dir_path_nofollow(path, true)
8487}
8488
8489#[cfg(unix)]
8490fn open_existing_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
8491    open_dir_path_nofollow(path, false)
8492}
8493
8494#[cfg(unix)]
8495fn entry_is_dir_at(parent: std::os::fd::RawFd, name: &std::ffi::CStr) -> LinkResult<Option<bool>> {
8496    let mut stat: libc::stat = unsafe { std::mem::zeroed() };
8497    let result =
8498        unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) };
8499    if result == 0 {
8500        return Ok(Some((stat.st_mode & libc::S_IFMT) == libc::S_IFDIR));
8501    }
8502    let error = std::io::Error::last_os_error();
8503    if error.kind() == std::io::ErrorKind::NotFound {
8504        Ok(None)
8505    } else {
8506        Err(error.into())
8507    }
8508}
8509
8510#[cfg(unix)]
8511fn create_dir_exclusive_at(
8512    parent: std::os::fd::RawFd,
8513    name: &std::ffi::CStr,
8514    display: &str,
8515) -> LinkResult<std::fs::File> {
8516    let made = unsafe { libc::mkdirat(parent, name.as_ptr(), 0o700) };
8517    if made != 0 {
8518        return Err(LinkError::UnsafePath {
8519            path: display.to_string(),
8520        });
8521    }
8522    open_dir_at(parent, name, display)
8523}
8524
8525#[cfg(unix)]
8526fn directory_entry_names(directory: &std::fs::File) -> LinkResult<Vec<std::ffi::CString>> {
8527    use std::os::fd::AsRawFd as _;
8528
8529    let duplicate = unsafe { libc::dup(directory.as_raw_fd()) };
8530    if duplicate < 0 {
8531        return Err(std::io::Error::last_os_error().into());
8532    }
8533    let stream = unsafe { libc::fdopendir(duplicate) };
8534    if stream.is_null() {
8535        let error = std::io::Error::last_os_error();
8536        unsafe {
8537            libc::close(duplicate);
8538        }
8539        return Err(error.into());
8540    }
8541    let mut names = Vec::new();
8542    loop {
8543        let entry = unsafe { libc::readdir(stream) };
8544        if entry.is_null() {
8545            break;
8546        }
8547        let raw = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) };
8548        if raw.to_bytes() != b"." && raw.to_bytes() != b".." {
8549            names.push(raw.to_owned());
8550        }
8551    }
8552    if unsafe { libc::closedir(stream) } != 0 {
8553        return Err(std::io::Error::last_os_error().into());
8554    }
8555    Ok(names)
8556}
8557
8558/// Remove an entry tree relative to a held directory capability. Symlinks are
8559/// unlinked, never traversed, even when an old mirror contains hostile names.
8560#[cfg(unix)]
8561fn remove_tree_at(
8562    parent: std::os::fd::RawFd,
8563    name: &std::ffi::CStr,
8564    display: &str,
8565) -> LinkResult<()> {
8566    use std::os::fd::AsRawFd as _;
8567
8568    match entry_is_dir_at(parent, name)? {
8569        None => return Ok(()),
8570        Some(false) => {
8571            if unsafe { libc::unlinkat(parent, name.as_ptr(), 0) } != 0 {
8572                return Err(std::io::Error::last_os_error().into());
8573            }
8574        }
8575        Some(true) => {
8576            let directory = open_dir_at(parent, name, display)?;
8577            for child in directory_entry_names(&directory)? {
8578                let child_display =
8579                    format!("{display}/{}", String::from_utf8_lossy(child.to_bytes()));
8580                remove_tree_at(directory.as_raw_fd(), &child, &child_display)?;
8581            }
8582            drop(directory);
8583            if unsafe { libc::unlinkat(parent, name.as_ptr(), libc::AT_REMOVEDIR) } != 0 {
8584                return Err(std::io::Error::last_os_error().into());
8585            }
8586        }
8587    }
8588    Ok(())
8589}
8590
8591/// Clone a live destination into a private sibling stage without following
8592/// any symlink. Regular files are streamed between held descriptors; symlinks
8593/// are reproduced as links, never opened. Special files are refused.
8594#[cfg(unix)]
8595fn clone_tree_contents(
8596    source: &std::fs::File,
8597    destination: &std::fs::File,
8598    display: &str,
8599) -> LinkResult<()> {
8600    use std::os::fd::{AsRawFd as _, FromRawFd as _};
8601
8602    for name in directory_entry_names(source)? {
8603        let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
8604        let mut stat: libc::stat = unsafe { std::mem::zeroed() };
8605        if unsafe {
8606            libc::fstatat(
8607                source.as_raw_fd(),
8608                name.as_ptr(),
8609                &mut stat,
8610                libc::AT_SYMLINK_NOFOLLOW,
8611            )
8612        } != 0
8613        {
8614            return Err(std::io::Error::last_os_error().into());
8615        }
8616        match stat.st_mode & libc::S_IFMT {
8617            libc::S_IFDIR => {
8618                if unsafe {
8619                    libc::mkdirat(destination.as_raw_fd(), name.as_ptr(), stat.st_mode & 0o777)
8620                } != 0
8621                {
8622                    return Err(std::io::Error::last_os_error().into());
8623                }
8624                let source_child = open_dir_at(source.as_raw_fd(), &name, &child_display)?;
8625                let destination_child =
8626                    open_dir_at(destination.as_raw_fd(), &name, &child_display)?;
8627                clone_tree_contents(&source_child, &destination_child, &child_display)?;
8628                destination_child.sync_all()?;
8629            }
8630            libc::S_IFREG => {
8631                let source_fd = unsafe {
8632                    libc::openat(
8633                        source.as_raw_fd(),
8634                        name.as_ptr(),
8635                        libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
8636                    )
8637                };
8638                if source_fd < 0 {
8639                    return Err(std::io::Error::last_os_error().into());
8640                }
8641                let destination_fd = unsafe {
8642                    libc::openat(
8643                        destination.as_raw_fd(),
8644                        name.as_ptr(),
8645                        libc::O_WRONLY
8646                            | libc::O_CREAT
8647                            | libc::O_EXCL
8648                            | libc::O_CLOEXEC
8649                            | libc::O_NOFOLLOW,
8650                        (stat.st_mode & 0o777) as libc::c_uint,
8651                    )
8652                };
8653                if destination_fd < 0 {
8654                    unsafe {
8655                        libc::close(source_fd);
8656                    }
8657                    return Err(std::io::Error::last_os_error().into());
8658                }
8659                let mut input = unsafe { std::fs::File::from_raw_fd(source_fd) };
8660                let mut output = unsafe { std::fs::File::from_raw_fd(destination_fd) };
8661                std::io::copy(&mut input, &mut output)?;
8662                output.sync_all()?;
8663            }
8664            libc::S_IFLNK => {
8665                let mut target = vec![0_u8; 4097];
8666                let length = unsafe {
8667                    libc::readlinkat(
8668                        source.as_raw_fd(),
8669                        name.as_ptr(),
8670                        target.as_mut_ptr().cast(),
8671                        target.len(),
8672                    )
8673                };
8674                if length < 0 || length as usize >= target.len() {
8675                    return Err(LinkError::UnsafePath {
8676                        path: child_display,
8677                    });
8678                }
8679                target.truncate(length as usize);
8680                let target = c_name(&target, &child_display)?;
8681                if unsafe {
8682                    libc::symlinkat(target.as_ptr(), destination.as_raw_fd(), name.as_ptr())
8683                } != 0
8684                {
8685                    return Err(std::io::Error::last_os_error().into());
8686                }
8687            }
8688            _ => {
8689                return Err(LinkError::UnsafePath {
8690                    path: child_display,
8691                });
8692            }
8693        }
8694    }
8695    destination.sync_all()?;
8696    Ok(())
8697}
8698
8699#[cfg(target_os = "linux")]
8700fn install_stage_at(
8701    parent: std::os::fd::RawFd,
8702    stage: &std::ffi::CStr,
8703    dest: &std::ffi::CStr,
8704    dest_exists: bool,
8705) -> LinkResult<()> {
8706    let flags = if dest_exists {
8707        libc::RENAME_EXCHANGE
8708    } else {
8709        libc::RENAME_NOREPLACE
8710    };
8711    // `libc::renameat2` is not exported for the musl targets we ship. Invoke
8712    // the kernel ABI directly, as `fsx::renameat_noreplace` does, so the same
8713    // atomic exchange/no-replace boundary compiles for glibc and musl.
8714    let result = unsafe {
8715        libc::syscall(
8716            libc::SYS_renameat2,
8717            parent,
8718            stage.as_ptr(),
8719            parent,
8720            dest.as_ptr(),
8721            flags,
8722        )
8723    };
8724    if result == 0 {
8725        Ok(())
8726    } else {
8727        Err(std::io::Error::last_os_error().into())
8728    }
8729}
8730
8731#[cfg(target_os = "macos")]
8732fn install_stage_at(
8733    parent: std::os::fd::RawFd,
8734    stage: &std::ffi::CStr,
8735    dest: &std::ffi::CStr,
8736    dest_exists: bool,
8737) -> LinkResult<()> {
8738    let flags = if dest_exists {
8739        libc::RENAME_SWAP
8740    } else {
8741        libc::RENAME_EXCL
8742    };
8743    let result =
8744        unsafe { libc::renameatx_np(parent, stage.as_ptr(), parent, dest.as_ptr(), flags) };
8745    if result == 0 {
8746        Ok(())
8747    } else {
8748        Err(std::io::Error::last_os_error().into())
8749    }
8750}
8751
8752#[cfg(unix)]
8753fn write_pull_entries_beneath_dir(
8754    root: &std::fs::File,
8755    entries: &[(String, Vec<u8>)],
8756) -> LinkResult<()> {
8757    use std::os::fd::{AsRawFd as _, FromRawFd as _};
8758
8759    for (path, content) in entries {
8760        let components: Vec<&str> = path.split('/').collect();
8761        let (leaf, parents) = components
8762            .split_last()
8763            .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
8764        let mut directory = root.try_clone()?;
8765        for component in parents {
8766            let name = c_name(component.as_bytes(), path)?;
8767            let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
8768            if made != 0 {
8769                let error = std::io::Error::last_os_error();
8770                if error.raw_os_error() != Some(libc::EEXIST) {
8771                    return Err(error.into());
8772                }
8773            }
8774            directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
8775        }
8776
8777        let leaf_name = c_name(leaf.as_bytes(), path)?;
8778        let mut existing: libc::stat = unsafe { std::mem::zeroed() };
8779        let inspected = unsafe {
8780            libc::fstatat(
8781                directory.as_raw_fd(),
8782                leaf_name.as_ptr(),
8783                &mut existing,
8784                libc::AT_SYMLINK_NOFOLLOW,
8785            )
8786        };
8787        if inspected == 0 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK {
8788            return Err(LinkError::UnsafePath { path: path.clone() });
8789        }
8790
8791        let nonce = std::time::SystemTime::now()
8792            .duration_since(std::time::UNIX_EPOCH)
8793            .unwrap_or_default()
8794            .as_nanos();
8795        let temp_name = format!(
8796            ".dbmd-pull-{}-{nonce}-{}",
8797            std::process::id(),
8798            content_sha256(format!("{path}\0{}", content.len()).as_bytes())
8799        );
8800        let temp = c_name(temp_name.as_bytes(), path)?;
8801        let fd = unsafe {
8802            libc::openat(
8803                directory.as_raw_fd(),
8804                temp.as_ptr(),
8805                libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
8806                0o600,
8807            )
8808        };
8809        if fd < 0 {
8810            return Err(std::io::Error::last_os_error().into());
8811        }
8812        let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
8813        if let Err(error) = file.write_all(content).and_then(|_| file.sync_all()) {
8814            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8815            return Err(error.into());
8816        }
8817        drop(file);
8818        let renamed = unsafe {
8819            libc::renameat(
8820                directory.as_raw_fd(),
8821                temp.as_ptr(),
8822                directory.as_raw_fd(),
8823                leaf_name.as_ptr(),
8824            )
8825        };
8826        if renamed != 0 {
8827            let error = std::io::Error::last_os_error();
8828            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8829            return Err(error.into());
8830        }
8831        directory.sync_all()?;
8832    }
8833    root.sync_all()?;
8834    Ok(())
8835}
8836
8837#[cfg(unix)]
8838fn write_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
8839    use std::os::fd::{AsRawFd as _, FromRawFd as _};
8840
8841    let path = &entry.path;
8842    let components: Vec<&str> = path.split('/').collect();
8843    let (leaf, parents) = components
8844        .split_last()
8845        .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
8846    let mut directory = root.try_clone()?;
8847    for component in parents {
8848        let name = c_name(component.as_bytes(), path)?;
8849        let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
8850        if made != 0 {
8851            let error = std::io::Error::last_os_error();
8852            if error.raw_os_error() != Some(libc::EEXIST) {
8853                return Err(error.into());
8854            }
8855        }
8856        directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
8857    }
8858    let leaf_name = c_name(leaf.as_bytes(), path)?;
8859    let mut existing: libc::stat = unsafe { std::mem::zeroed() };
8860    if unsafe {
8861        libc::fstatat(
8862            directory.as_raw_fd(),
8863            leaf_name.as_ptr(),
8864            &mut existing,
8865            libc::AT_SYMLINK_NOFOLLOW,
8866        )
8867    } == 0
8868        && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK
8869    {
8870        return Err(LinkError::UnsafePath { path: path.clone() });
8871    }
8872    let nonce = SystemTime::now()
8873        .duration_since(UNIX_EPOCH)
8874        .unwrap_or_default()
8875        .as_nanos();
8876    let temp_name = format!(
8877        ".dbmd-pull-{}-{nonce}-{}",
8878        std::process::id(),
8879        content_sha256(path.as_bytes())
8880    );
8881    let temp = c_name(temp_name.as_bytes(), path)?;
8882    let fd = unsafe {
8883        libc::openat(
8884            directory.as_raw_fd(),
8885            temp.as_ptr(),
8886            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
8887            0o600,
8888        )
8889    };
8890    if fd < 0 {
8891        return Err(std::io::Error::last_os_error().into());
8892    }
8893    let mut input = crate::fsx::open_regular_nofollow(&entry.source)?;
8894    let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
8895    let mut digest = Sha256::new();
8896    let mut total = 0_u64;
8897    let mut buffer = [0_u8; 64 * 1024];
8898    let copied = (|| -> std::io::Result<()> {
8899        loop {
8900            let read = input.read(&mut buffer)?;
8901            if read == 0 {
8902                break;
8903            }
8904            total = total.saturating_add(read as u64);
8905            if total > entry.bytes {
8906                return Err(std::io::Error::new(
8907                    std::io::ErrorKind::InvalidData,
8908                    "staged sync source grew beyond its verified length",
8909                ));
8910            }
8911            digest.update(&buffer[..read]);
8912            output.write_all(&buffer[..read])?;
8913        }
8914        Ok(())
8915    })();
8916    if let Err(error) = copied {
8917        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8918        return Err(error.into());
8919    }
8920    drop(output);
8921    if total != entry.bytes || format!("{:x}", digest.finalize()) != entry.sha256 {
8922        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8923        return Err(invalid_feed(
8924            "private staged sync source failed final integrity verification",
8925        ));
8926    }
8927    if unsafe {
8928        libc::renameat(
8929            directory.as_raw_fd(),
8930            temp.as_ptr(),
8931            directory.as_raw_fd(),
8932            leaf_name.as_ptr(),
8933        )
8934    } != 0
8935    {
8936        let error = std::io::Error::last_os_error();
8937        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8938        return Err(error.into());
8939    }
8940    Ok(())
8941}
8942
8943#[cfg(unix)]
8944fn sync_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
8945    use std::os::fd::{AsRawFd as _, FromRawFd as _};
8946
8947    let path = &entry.path;
8948    let components: Vec<&str> = path.split('/').collect();
8949    let (leaf, parents) = components
8950        .split_last()
8951        .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
8952    let mut directory = root.try_clone()?;
8953    for component in parents {
8954        directory = open_dir_at(
8955            directory.as_raw_fd(),
8956            &c_name(component.as_bytes(), path)?,
8957            path,
8958        )?;
8959    }
8960    let leaf = c_name(leaf.as_bytes(), path)?;
8961    let fd = unsafe {
8962        libc::openat(
8963            directory.as_raw_fd(),
8964            leaf.as_ptr(),
8965            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
8966        )
8967    };
8968    if fd < 0 {
8969        return Err(std::io::Error::last_os_error().into());
8970    }
8971    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
8972    if file.metadata()?.len() != entry.bytes || content_sha256_reader(&mut file)? != entry.sha256 {
8973        return Err(invalid_feed(
8974            "private pull stage changed before its durability barrier",
8975        ));
8976    }
8977    file.sync_all()?;
8978    Ok(())
8979}
8980
8981#[cfg(unix)]
8982fn run_pull_source_workers(
8983    root: &std::fs::File,
8984    entries: &[V2StagedFile],
8985    operation: fn(&std::fs::File, &V2StagedFile) -> LinkResult<()>,
8986) -> LinkResult<()> {
8987    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
8988
8989    let next = AtomicUsize::new(0);
8990    let failed = AtomicBool::new(false);
8991    let worker_count = entries.len().min(V2_PULL_INSTALL_WORKERS);
8992    let mut first_error = None;
8993    std::thread::scope(|scope| {
8994        let (sender, receiver) = std::sync::mpsc::channel();
8995        for _ in 0..worker_count {
8996            let sender = sender.clone();
8997            let next = &next;
8998            let failed = &failed;
8999            scope.spawn(move || {
9000                while !failed.load(Ordering::Acquire) {
9001                    let index = next.fetch_add(1, Ordering::Relaxed);
9002                    let Some(entry) = entries.get(index) else {
9003                        break;
9004                    };
9005                    let result = operation(root, entry);
9006                    if result.is_err() {
9007                        failed.store(true, Ordering::Release);
9008                    }
9009                    if sender.send(result).is_err() {
9010                        break;
9011                    }
9012                }
9013            });
9014        }
9015        drop(sender);
9016        for result in receiver {
9017            if let Err(error) = result {
9018                if first_error.is_none() {
9019                    first_error = Some(error);
9020                }
9021            }
9022        }
9023    });
9024    if let Some(error) = first_error {
9025        return Err(error);
9026    }
9027    if next.load(Ordering::Relaxed) < entries.len() {
9028        return Err(invalid_feed(
9029            "a bounded pull worker stopped before reporting every file",
9030        ));
9031    }
9032    Ok(())
9033}
9034
9035#[cfg(unix)]
9036fn sync_pull_directory_tree(root: &std::fs::File, display: &str) -> LinkResult<()> {
9037    use std::os::fd::AsRawFd as _;
9038
9039    for name in directory_entry_names(root)? {
9040        if entry_is_dir_at(root.as_raw_fd(), &name)? == Some(true) {
9041            let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
9042            let child = open_dir_at(root.as_raw_fd(), &name, &child_display)?;
9043            sync_pull_directory_tree(&child, &child_display)?;
9044        }
9045    }
9046    root.sync_all()?;
9047    Ok(())
9048}
9049
9050#[cfg(unix)]
9051fn write_pull_sources_beneath_dir(
9052    root: &std::fs::File,
9053    entries: &[V2StagedFile],
9054) -> LinkResult<()> {
9055    // The stage is still private and unreachable as the checkout here. Write
9056    // and verify every leaf first so the filesystem can coalesce dirty data,
9057    // then re-open/re-hash/fsync leaves through the held root capability. Only
9058    // after every file barrier succeeds do we flush each directory once. This
9059    // preserves the same crash durability as file-fsync + directory-fsync per
9060    // path without serializing 50,000 redundant directory barriers.
9061    run_pull_source_workers(root, entries, write_pull_source_beneath_dir)?;
9062    run_pull_source_workers(root, entries, sync_pull_source_beneath_dir)?;
9063    sync_pull_directory_tree(root, "v2 pull stage")
9064}
9065
9066#[cfg(unix)]
9067fn remove_pull_paths_beneath_dir(root: &std::fs::File, paths: &[String]) -> LinkResult<()> {
9068    use std::os::fd::AsRawFd as _;
9069    for path in paths {
9070        if !safe_store_rel_path(path) {
9071            return Err(LinkError::UnsafePath { path: path.clone() });
9072        }
9073        let components = path.split('/').collect::<Vec<_>>();
9074        let Some((leaf, parents)) = components.split_last() else {
9075            return Err(LinkError::UnsafePath { path: path.clone() });
9076        };
9077        let mut directory = root.try_clone()?;
9078        let mut missing = false;
9079        for component in parents {
9080            let name = c_name(component.as_bytes(), path)?;
9081            match entry_is_dir_at(directory.as_raw_fd(), &name)? {
9082                None => {
9083                    missing = true;
9084                    break;
9085                }
9086                Some(false) => return Err(LinkError::UnsafePath { path: path.clone() }),
9087                Some(true) => {
9088                    directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9089                }
9090            }
9091        }
9092        if missing {
9093            continue;
9094        }
9095        let leaf = c_name(leaf.as_bytes(), path)?;
9096        match entry_is_dir_at(directory.as_raw_fd(), &leaf)? {
9097            None => {}
9098            Some(true) => return Err(LinkError::UnsafePath { path: path.clone() }),
9099            Some(false) => {
9100                if unsafe { libc::unlinkat(directory.as_raw_fd(), leaf.as_ptr(), 0) } != 0 {
9101                    return Err(std::io::Error::last_os_error().into());
9102                }
9103                directory.sync_all()?;
9104            }
9105        }
9106    }
9107    Ok(())
9108}
9109
9110#[cfg(unix)]
9111fn install_pulled_delta(
9112    dest: &Path,
9113    entries: &[(String, Vec<u8>)],
9114    deleted: &[String],
9115    rebuild_indexes: bool,
9116) -> LinkResult<()> {
9117    use ring::rand::SecureRandom as _;
9118    use std::os::fd::AsRawFd as _;
9119    use std::os::unix::ffi::OsStrExt as _;
9120
9121    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
9122    let name = dest
9123        .file_name()
9124        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
9125        .ok_or_else(|| LinkError::UnsafePath {
9126            path: dest.display().to_string(),
9127        })?;
9128    let parent_dir = open_or_create_dir_nofollow(parent)?;
9129    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
9130    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
9131        None => false,
9132        Some(true) => true,
9133        Some(false) => {
9134            return Err(LinkError::UnsafePath {
9135                path: dest.display().to_string(),
9136            });
9137        }
9138    };
9139
9140    let mut nonce = [0_u8; 16];
9141    ring::rand::SystemRandom::new()
9142        .fill(&mut nonce)
9143        .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
9144    let stage_label = format!(
9145        ".{}.dbmd-pull-stage-{}",
9146        name.to_string_lossy(),
9147        URL_SAFE_NO_PAD.encode(nonce)
9148    );
9149    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
9150    let stage_dir = create_dir_exclusive_at(
9151        parent_dir.as_raw_fd(),
9152        &stage_name,
9153        &dest.display().to_string(),
9154    )?;
9155
9156    let prepared = (|| -> LinkResult<()> {
9157        if dest_exists {
9158            let live = open_dir_at(
9159                parent_dir.as_raw_fd(),
9160                &dest_name,
9161                &dest.display().to_string(),
9162            )?;
9163            clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
9164        }
9165        remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
9166        write_pull_entries_beneath_dir(&stage_dir, entries)?;
9167        if rebuild_indexes {
9168            let stage_store =
9169                Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
9170                    .map_err(|error| LinkError::InvalidPack {
9171                        message: format!("v2 staging tree is not a valid db.md store: {error}"),
9172                    })?;
9173            crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
9174                LinkError::InvalidPack {
9175                    message: format!("could not materialize v2 local catalogs: {error}"),
9176                }
9177            })?;
9178        }
9179        stage_dir.sync_all()?;
9180        Ok(())
9181    })();
9182    if let Err(error) = prepared {
9183        let _ = remove_tree_at(
9184            parent_dir.as_raw_fd(),
9185            &stage_name,
9186            &dest.display().to_string(),
9187        );
9188        return Err(error);
9189    }
9190
9191    if let Err(error) =
9192        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
9193    {
9194        let _ = remove_tree_at(
9195            parent_dir.as_raw_fd(),
9196            &stage_name,
9197            &dest.display().to_string(),
9198        );
9199        return Err(error);
9200    }
9201    parent_dir.sync_all()?;
9202    if dest_exists {
9203        // Commit already happened atomically. Cleanup is best-effort so a
9204        // failure cannot be reported as a failed pull after the live tree
9205        // changed; a crash may leave only this private old-tree sibling.
9206        let _ = remove_tree_at(
9207            parent_dir.as_raw_fd(),
9208            &stage_name,
9209            &dest.display().to_string(),
9210        );
9211        let _ = parent_dir.sync_all();
9212    }
9213    Ok(())
9214}
9215
9216#[cfg(unix)]
9217fn install_pulled_delta_sources(
9218    dest: &Path,
9219    entries: &[V2StagedFile],
9220    deleted: &[String],
9221    rebuild_indexes: bool,
9222    _previous: Option<&V2SyncBaseline>,
9223    _next: &V2VerifiedHead,
9224) -> LinkResult<()> {
9225    use ring::rand::SecureRandom as _;
9226    use std::os::fd::AsRawFd as _;
9227    use std::os::unix::ffi::OsStrExt as _;
9228
9229    // An established checkout is updated only at the changed coordinates
9230    // through the durable pull journal shared with Windows. Initial clones
9231    // still use a private complete stage and one atomic directory install.
9232    if let Ok(store) = Store::open_strict(dest) {
9233        return install_established_v2_delta(
9234            store,
9235            entries,
9236            deleted,
9237            rebuild_indexes,
9238            _previous,
9239            _next,
9240        );
9241    }
9242
9243    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
9244    let name = dest
9245        .file_name()
9246        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
9247        .ok_or_else(|| LinkError::UnsafePath {
9248            path: dest.display().to_string(),
9249        })?;
9250    let parent_dir = open_or_create_dir_nofollow(parent)?;
9251    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
9252    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
9253        None => false,
9254        Some(true) => true,
9255        Some(false) => {
9256            return Err(LinkError::UnsafePath {
9257                path: dest.display().to_string(),
9258            })
9259        }
9260    };
9261    let mut nonce = [0_u8; 16];
9262    ring::rand::SystemRandom::new()
9263        .fill(&mut nonce)
9264        .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
9265    let stage_label = format!(
9266        ".{}.dbmd-pull-stage-{}",
9267        name.to_string_lossy(),
9268        URL_SAFE_NO_PAD.encode(nonce)
9269    );
9270    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
9271    let stage_dir = create_dir_exclusive_at(
9272        parent_dir.as_raw_fd(),
9273        &stage_name,
9274        &dest.display().to_string(),
9275    )?;
9276    let prepared = (|| -> LinkResult<()> {
9277        if dest_exists {
9278            let live = open_dir_at(
9279                parent_dir.as_raw_fd(),
9280                &dest_name,
9281                &dest.display().to_string(),
9282            )?;
9283            clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
9284        }
9285        remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
9286        write_pull_sources_beneath_dir(&stage_dir, entries)?;
9287        if rebuild_indexes {
9288            let stage_store =
9289                Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
9290                    .map_err(|error| LinkError::InvalidPack {
9291                        message: format!("v2 staging tree is not a valid db.md store: {error}"),
9292                    })?;
9293            crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
9294                LinkError::InvalidPack {
9295                    message: format!("could not materialize v2 local catalogs: {error}"),
9296                }
9297            })?;
9298        }
9299        stage_dir.sync_all()?;
9300        Ok(())
9301    })();
9302    if let Err(error) = prepared {
9303        let _ = remove_tree_at(
9304            parent_dir.as_raw_fd(),
9305            &stage_name,
9306            &dest.display().to_string(),
9307        );
9308        return Err(error);
9309    }
9310    if let Err(error) =
9311        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
9312    {
9313        let _ = remove_tree_at(
9314            parent_dir.as_raw_fd(),
9315            &stage_name,
9316            &dest.display().to_string(),
9317        );
9318        return Err(error);
9319    }
9320    parent_dir.sync_all()?;
9321    if dest_exists {
9322        let _ = remove_tree_at(
9323            parent_dir.as_raw_fd(),
9324            &stage_name,
9325            &dest.display().to_string(),
9326        );
9327        let _ = parent_dir.sync_all();
9328    }
9329    Ok(())
9330}
9331
9332#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
9333struct V2PullCoordinate {
9334    head_seq: Option<u64>,
9335    commit_hash: Option<String>,
9336    view_kind: Option<String>,
9337    view_revision: Option<String>,
9338}
9339
9340#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
9341struct V2PullFileCoordinate {
9342    sha256: String,
9343    bytes: u64,
9344}
9345
9346#[derive(Debug, Clone, Deserialize, Serialize)]
9347struct V2PullJournalEntry {
9348    path: String,
9349    old: Option<V2PullFileCoordinate>,
9350    new: Option<V2PullFileCoordinate>,
9351    backup: Option<String>,
9352}
9353
9354#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
9355#[serde(rename_all = "snake_case")]
9356enum V2PullPhase {
9357    Preparing,
9358    Ready,
9359}
9360
9361#[derive(Debug, Clone, Deserialize, Serialize)]
9362struct V2PullJournal {
9363    v: u8,
9364    phase: V2PullPhase,
9365    brain: String,
9366    previous: V2PullCoordinate,
9367    next: V2PullCoordinate,
9368    backup_dir: String,
9369    entries: Vec<V2PullJournalEntry>,
9370}
9371
9372const V2_PULL_JOURNAL: &str = ".dbmd/pull-journal.json";
9373
9374fn v2_pull_baseline_coordinate(baseline: Option<&V2SyncBaseline>) -> V2PullCoordinate {
9375    V2PullCoordinate {
9376        head_seq: baseline.and_then(|value| value.head_seq),
9377        commit_hash: baseline.and_then(|value| value.commit_hash.clone()),
9378        view_kind: baseline.and_then(|value| value.view_kind.clone()),
9379        view_revision: baseline.and_then(|value| value.view_revision.clone()),
9380    }
9381}
9382
9383fn v2_pull_head_coordinate(head: &V2VerifiedHead) -> V2PullCoordinate {
9384    V2PullCoordinate {
9385        head_seq: head.pointer.as_ref().map(|value| value.seq),
9386        commit_hash: head.pointer.as_ref().map(|value| value.commit_hash.clone()),
9387        view_kind: Some(head.view_kind.clone()),
9388        view_revision: Some(head.view_revision.clone()),
9389    }
9390}
9391
9392fn v2_pull_file_coordinate(
9393    store: &Store,
9394    path: &str,
9395    limit: u64,
9396) -> LinkResult<Option<V2PullFileCoordinate>> {
9397    let file = match store.open_regular(Path::new(path)) {
9398        Ok(file) => file,
9399        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
9400        Err(error) => return Err(error.into()),
9401    };
9402    let bytes = file.metadata()?.len();
9403    if bytes > limit || bytes > MAX_STORE_BYTES {
9404        return Err(invalid_feed(
9405            "pull transaction file exceeds its declared bound",
9406        ));
9407    }
9408    Ok(Some(V2PullFileCoordinate {
9409        sha256: content_sha256_reader(file)?,
9410        bytes,
9411    }))
9412}
9413
9414fn v2_pull_journal_bytes(journal: &V2PullJournal) -> LinkResult<Vec<u8>> {
9415    let mut bytes = serde_json::to_vec_pretty(journal)
9416        .map_err(|_| invalid_feed("could not serialize v2 pull journal"))?;
9417    bytes.push(b'\n');
9418    Ok(bytes)
9419}
9420
9421fn validate_v2_pull_journal(journal: &V2PullJournal) -> LinkResult<()> {
9422    let backup_prefix = ".dbmd/pull-backup-";
9423    let suffix = journal
9424        .backup_dir
9425        .strip_prefix(backup_prefix)
9426        .ok_or_else(|| invalid_feed("v2 pull journal backup address is invalid"))?;
9427    let mut paths = std::collections::BTreeSet::new();
9428    if journal.v != 1
9429        || !crate::ulid::is_ulid(&journal.brain)
9430        || !crate::ulid::is_ulid(suffix)
9431        || journal.entries.is_empty()
9432        || journal.entries.len() > MAX_PUSH_FILES + 4
9433        || journal.previous == journal.next
9434    {
9435        return Err(invalid_feed("v2 pull journal failed validation"));
9436    }
9437    for (index, entry) in journal.entries.iter().enumerate() {
9438        if !safe_store_rel_path(&entry.path)
9439            || entry.path == V2_PULL_JOURNAL
9440            || entry.path.starts_with(backup_prefix)
9441            || !paths.insert(entry.path.clone())
9442            || (entry.old.is_none() && entry.new.is_none())
9443            || entry
9444                .old
9445                .iter()
9446                .chain(entry.new.iter())
9447                .any(|value| !is_sha256(&value.sha256) || value.bytes > MAX_STORE_BYTES)
9448            || entry.backup.as_deref()
9449                != entry
9450                    .old
9451                    .as_ref()
9452                    .map(|_| format!("{index:08x}"))
9453                    .as_deref()
9454        {
9455            return Err(invalid_feed("v2 pull journal entry failed validation"));
9456        }
9457    }
9458    Ok(())
9459}
9460
9461fn load_v2_pull_journal(store: &Store) -> LinkResult<Option<V2PullJournal>> {
9462    #[cfg(unix)]
9463    {
9464        use std::os::unix::fs::PermissionsExt as _;
9465        match store.regular_metadata(Path::new(V2_PULL_JOURNAL)) {
9466            Ok(metadata) if metadata.permissions().mode() & 0o077 != 0 => {
9467                return Err(invalid_feed(
9468                    "v2 pull journal is accessible to group/other; set mode 0600",
9469                ));
9470            }
9471            Ok(_) => {}
9472            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
9473            Err(error) => return Err(error.into()),
9474        }
9475    }
9476    let bytes = match store.read_bounded(Path::new(V2_PULL_JOURNAL), 64 * 1024 * 1024) {
9477        Ok(bytes) => bytes,
9478        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
9479        Err(error) => return Err(error.into()),
9480    };
9481    let journal: V2PullJournal =
9482        serde_json::from_slice(&bytes).map_err(|_| invalid_feed("v2 pull journal is corrupt"))?;
9483    validate_v2_pull_journal(&journal)?;
9484    Ok(Some(journal))
9485}
9486
9487fn cleanup_v2_pull_journal(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
9488    // The journal is the only recovery authority. Remove it first only after
9489    // rollback or baseline commit is durable; an orphan private backup is safe
9490    // and can be pruned, while a journal without its backups is not recoverable.
9491    match store.remove_file(Path::new(V2_PULL_JOURNAL)) {
9492        Ok(()) => {}
9493        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
9494        Err(error) => return Err(error.into()),
9495    }
9496    match store.remove_private_tree(Path::new(&journal.backup_dir)) {
9497        Ok(()) => Ok(()),
9498        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
9499        Err(error) => Err(error.into()),
9500    }
9501}
9502
9503fn prune_orphan_v2_pull_backups(store: &Store) -> LinkResult<()> {
9504    let names = match store.directory_names(Path::new(".dbmd")) {
9505        Ok(names) => names,
9506        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
9507        Err(error) => return Err(error.into()),
9508    };
9509    for name in names {
9510        let Some(name) = name.to_str() else {
9511            continue;
9512        };
9513        let Some(suffix) = name.strip_prefix("pull-backup-") else {
9514            continue;
9515        };
9516        if crate::ulid::is_ulid(suffix) {
9517            store.remove_private_tree(&Path::new(".dbmd").join(name))?;
9518        }
9519    }
9520    Ok(())
9521}
9522
9523fn rollback_v2_pull(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
9524    // Validate every live coordinate and every backup before the first restore.
9525    for entry in &journal.entries {
9526        let limit = entry
9527            .old
9528            .as_ref()
9529            .into_iter()
9530            .chain(entry.new.iter())
9531            .map(|value| value.bytes)
9532            .max()
9533            .unwrap_or(0);
9534        let current = v2_pull_file_coordinate(store, &entry.path, limit)?;
9535        if current != entry.old && current != entry.new {
9536            return Err(LinkError::InvalidPack {
9537                message: format!(
9538                    "cannot recover interrupted pull because `{}` changed afterward",
9539                    entry.path
9540                ),
9541            });
9542        }
9543        if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
9544            let path = Path::new(&journal.backup_dir).join(backup);
9545            let file = store.open_regular(&path)?;
9546            if file.metadata()?.len() != old.bytes || content_sha256_reader(file)? != old.sha256 {
9547                return Err(invalid_feed("v2 pull recovery backup failed verification"));
9548            }
9549        }
9550    }
9551    for entry in journal.entries.iter().rev() {
9552        match (&entry.old, &entry.backup) {
9553            (Some(old), Some(backup)) => {
9554                let bytes =
9555                    store.read_bounded(&Path::new(&journal.backup_dir).join(backup), old.bytes)?;
9556                store.write_atomic(Path::new(&entry.path), &bytes)?;
9557            }
9558            (None, None) if store.regular_file_exists(Path::new(&entry.path))? => {
9559                store.remove_file(Path::new(&entry.path))?;
9560            }
9561            (None, None) => {}
9562            _ => return Err(invalid_feed("v2 pull recovery entry is inconsistent")),
9563        }
9564    }
9565    crate::index::Index::rebuild_all(store).map_err(|error| LinkError::InvalidPack {
9566        message: format!("could not rebuild catalogs after pull recovery: {error}"),
9567    })?;
9568    cleanup_v2_pull_journal(store, journal)
9569}
9570
9571fn recover_v2_pull(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<()> {
9572    let Ok(store) = Store::open_strict(dest) else {
9573        return Ok(());
9574    };
9575    if let Some(journal) = load_v2_pull_journal(&store)? {
9576        if journal.brain != brain {
9577            return Err(invalid_feed("v2 pull journal belongs to another brain"));
9578        }
9579        if journal.phase == V2PullPhase::Preparing {
9580            cleanup_v2_pull_journal(&store, &journal)?;
9581        } else {
9582            let baseline = load_v2_baseline(cfg, brain, dest)?;
9583            let current = v2_pull_baseline_coordinate(baseline.as_ref());
9584            if current == journal.next {
9585                cleanup_v2_pull_journal(&store, &journal)?;
9586            } else {
9587                if current != journal.previous {
9588                    return Err(invalid_feed(
9589                        "cannot recover interrupted pull because its baseline changed afterward",
9590                    ));
9591                }
9592                rollback_v2_pull(&store, &journal)?;
9593            }
9594        }
9595    }
9596    // Cleanup removes the journal first after the state is durably old or new.
9597    // A hard kill in the tiny interval before deleting its backup directory can
9598    // therefore leave only an inert, private orphan. The per-brain operation
9599    // lock is held by the caller, so no live pull can own one here.
9600    prune_orphan_v2_pull_backups(&store)
9601}
9602
9603fn complete_v2_pull(dest: &Path) -> LinkResult<()> {
9604    let store = Store::open_strict(dest).map_err(|error| LinkError::InvalidPack {
9605        message: format!("installed v2 checkout is not a valid db.md store: {error}"),
9606    })?;
9607    if let Some(journal) = load_v2_pull_journal(&store)? {
9608        cleanup_v2_pull_journal(&store, &journal)?;
9609    }
9610    Ok(())
9611}
9612
9613#[cfg(windows)]
9614fn install_windows_initial_sources(
9615    dest: &Path,
9616    entries: &[V2StagedFile],
9617    rebuild_indexes: bool,
9618) -> LinkResult<()> {
9619    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
9620    let name = dest.file_name().ok_or_else(|| LinkError::UnsafePath {
9621        path: dest.display().to_string(),
9622    })?;
9623    let parent_capability = crate::fsx::open_or_create_directory_nofollow(parent)?;
9624    if crate::fsx::directory_exists_beneath(&parent_capability, Path::new(name))? {
9625        return Err(LinkError::UnsafePath {
9626            path: dest.display().to_string(),
9627        });
9628    }
9629    let stage_name = format!(
9630        ".{}.dbmd-pull-stage-{}",
9631        name.to_string_lossy(),
9632        crate::ulid::mint()
9633    );
9634    let stage_path = parent.join(&stage_name);
9635    let stage_capability =
9636        crate::fsx::open_directory_beneath(&parent_capability, Path::new(&stage_name), true)?;
9637    let stage = Store::from_root_and_config(&stage_path, crate::Config::default())?;
9638    let prepared = (|| -> LinkResult<()> {
9639        for entry in entries {
9640            let bytes = crate::fsx::read_bounded_nofollow(&entry.source, entry.bytes)?;
9641            if bytes.len() as u64 != entry.bytes || content_sha256(&bytes) != entry.sha256 {
9642                return Err(invalid_feed(
9643                    "private staged sync source failed final integrity verification",
9644                ));
9645            }
9646            stage.write_atomic(Path::new(&entry.path), &bytes)?;
9647        }
9648        let strict = Store::from_held_root_strict(&stage_path, stage_capability.try_clone()?)
9649            .map_err(|error| LinkError::InvalidPack {
9650                message: format!("v2 staging tree is not a valid db.md store: {error}"),
9651            })?;
9652        if rebuild_indexes {
9653            crate::index::Index::rebuild_all(&strict).map_err(|error| LinkError::InvalidPack {
9654                message: format!("could not materialize v2 local catalogs: {error}"),
9655            })?;
9656        }
9657        Ok(())
9658    })();
9659    if let Err(error) = prepared {
9660        let _ = crate::fsx::remove_tree_beneath(&parent_capability, Path::new(&stage_name));
9661        return Err(error);
9662    }
9663    crate::fsx::rename_directory_beneath(
9664        &parent_capability,
9665        Path::new(&stage_name),
9666        Path::new(name),
9667    )?;
9668    Ok(())
9669}
9670
9671fn install_established_v2_delta(
9672    store: Store,
9673    entries: &[V2StagedFile],
9674    deleted: &[String],
9675    rebuild_indexes: bool,
9676    previous: Option<&V2SyncBaseline>,
9677    next: &V2VerifiedHead,
9678) -> LinkResult<()> {
9679    if load_v2_pull_journal(&store)?.is_some() {
9680        return Err(invalid_feed(
9681            "an interrupted pull must be recovered before installing",
9682        ));
9683    }
9684    let mut sources = std::collections::BTreeMap::new();
9685    for entry in entries {
9686        if sources.insert(entry.path.clone(), entry).is_some() || deleted.contains(&entry.path) {
9687            return Err(invalid_feed("pull mutation repeats a path"));
9688        }
9689    }
9690    let mut paths = sources.keys().cloned().collect::<Vec<_>>();
9691    paths.extend(deleted.iter().cloned());
9692    paths.sort();
9693    paths.dedup();
9694    if paths.is_empty() {
9695        return Ok(());
9696    }
9697    let backup_dir = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
9698    let mut journal = V2PullJournal {
9699        v: 1,
9700        phase: V2PullPhase::Preparing,
9701        brain: next.brain_id.clone(),
9702        previous: v2_pull_baseline_coordinate(previous),
9703        next: v2_pull_head_coordinate(next),
9704        backup_dir: backup_dir.clone(),
9705        entries: Vec::with_capacity(paths.len()),
9706    };
9707    for path in &paths {
9708        let old = v2_pull_file_coordinate(&store, path, MAX_STORE_BYTES)?;
9709        let new = sources.get(path).map(|entry| V2PullFileCoordinate {
9710            sha256: entry.sha256.clone(),
9711            bytes: entry.bytes,
9712        });
9713        if old == new {
9714            continue;
9715        }
9716        let index = journal.entries.len();
9717        journal.entries.push(V2PullJournalEntry {
9718            path: path.clone(),
9719            backup: old.as_ref().map(|_| format!("{index:08x}")),
9720            old,
9721            new,
9722        });
9723    }
9724    if journal.entries.is_empty() {
9725        return Ok(());
9726    }
9727    let backup_bytes = journal.entries.iter().try_fold(0_u64, |total, entry| {
9728        entry
9729            .old
9730            .as_ref()
9731            .map_or(Some(total), |old| total.checked_add(old.bytes))
9732    });
9733    if backup_bytes.is_none_or(|bytes| bytes > MAX_STORE_BYTES) {
9734        return Err(LinkError::InvalidPack {
9735            message: "pull recovery preimages exceed the 512 MB transaction limit".to_string(),
9736        });
9737    }
9738    validate_v2_pull_journal(&journal)?;
9739    store.write_private_atomic_new(
9740        Path::new(V2_PULL_JOURNAL),
9741        &v2_pull_journal_bytes(&journal)?,
9742    )?;
9743    let prepared = (|| -> LinkResult<()> {
9744        store.create_private_dir_all(Path::new(&backup_dir))?;
9745        for entry in &journal.entries {
9746            if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
9747                let bytes = store.read_bounded(Path::new(&entry.path), old.bytes)?;
9748                if content_sha256(&bytes) != old.sha256 {
9749                    return Err(invalid_feed("live pull source changed during backup"));
9750                }
9751                store.write_private_atomic_new(&Path::new(&backup_dir).join(backup), &bytes)?;
9752            }
9753        }
9754        journal.phase = V2PullPhase::Ready;
9755        store.write_private_atomic(
9756            Path::new(V2_PULL_JOURNAL),
9757            &v2_pull_journal_bytes(&journal)?,
9758        )?;
9759        Ok(())
9760    })();
9761    if let Err(error) = prepared {
9762        let cleanup = cleanup_v2_pull_journal(&store, &journal);
9763        return match cleanup {
9764            Ok(()) => Err(error),
9765            Err(cleanup) => Err(LinkError::InvalidPack {
9766                message: format!("{error}; recovery metadata cleanup also failed: {cleanup}"),
9767            }),
9768        };
9769    }
9770    let installed = (|| -> LinkResult<()> {
9771        for entry in &journal.entries {
9772            if v2_pull_file_coordinate(&store, &entry.path, MAX_STORE_BYTES)? != entry.old {
9773                return Err(LinkError::InvalidPack {
9774                    message: format!("local path `{}` changed during pull", entry.path),
9775                });
9776            }
9777            if let Some(source) = sources.get(&entry.path) {
9778                let bytes = crate::fsx::read_bounded_nofollow(&source.source, source.bytes)?;
9779                if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != source.sha256 {
9780                    return Err(invalid_feed(
9781                        "private staged sync source failed final integrity verification",
9782                    ));
9783                }
9784                store.write_atomic(Path::new(&entry.path), &bytes)?;
9785            } else if entry.old.is_some() {
9786                store.remove_file(Path::new(&entry.path))?;
9787            }
9788        }
9789        if rebuild_indexes {
9790            crate::index::Index::rebuild_all(&store).map_err(|error| LinkError::InvalidPack {
9791                message: format!("could not materialize v2 local catalogs: {error}"),
9792            })?;
9793        }
9794        Ok(())
9795    })();
9796    if let Err(error) = installed {
9797        return match rollback_v2_pull(&store, &journal) {
9798            Ok(()) => Err(error),
9799            Err(rollback) => Err(LinkError::InvalidPack {
9800                message: format!("{error}; durable pull rollback also failed: {rollback}"),
9801            }),
9802        };
9803    }
9804    Ok(())
9805}
9806
9807#[cfg(windows)]
9808fn install_pulled_delta_sources(
9809    dest: &Path,
9810    entries: &[V2StagedFile],
9811    deleted: &[String],
9812    rebuild_indexes: bool,
9813    previous: Option<&V2SyncBaseline>,
9814    next: &V2VerifiedHead,
9815) -> LinkResult<()> {
9816    match Store::open_strict(dest) {
9817        Ok(store) => {
9818            install_established_v2_delta(store, entries, deleted, rebuild_indexes, previous, next)
9819        }
9820        Err(_) => install_windows_initial_sources(dest, entries, rebuild_indexes),
9821    }
9822}
9823
9824#[cfg(not(any(unix, windows)))]
9825fn install_pulled_delta_sources(
9826    _dest: &Path,
9827    _entries: &[V2StagedFile],
9828    _deleted: &[String],
9829    _rebuild_indexes: bool,
9830    _previous: Option<&V2SyncBaseline>,
9831    _next: &V2VerifiedHead,
9832) -> LinkResult<()> {
9833    Err(LinkError::UnsupportedPlatform {
9834        operation: "atomic v2 pull install",
9835    })
9836}
9837
9838#[cfg(unix)]
9839fn install_pulled_snapshot(dest: &Path, entries: &[(String, Vec<u8>)]) -> LinkResult<()> {
9840    install_pulled_delta(dest, entries, &[], false)
9841}
9842
9843#[cfg(not(windows))]
9844fn is_safe_slug(slug: &str) -> bool {
9845    !slug.is_empty()
9846        && slug.len() <= 63
9847        && !slug.starts_with('-')
9848        && !slug.ends_with('-')
9849        && slug
9850            .bytes()
9851            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
9852}
9853
9854fn le_u16(bytes: &[u8], at: usize) -> Option<u16> {
9855    Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
9856}
9857
9858fn le_u32(bytes: &[u8], at: usize) -> Option<u32> {
9859    Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
9860}
9861
9862fn le_u64(bytes: &[u8], at: usize) -> Option<u64> {
9863    Some(u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?))
9864}
9865
9866fn preflight_zip_central_directory(
9867    bytes: &[u8],
9868    offset: usize,
9869    size: usize,
9870    count: u64,
9871) -> LinkResult<()> {
9872    const CENTRAL_ENTRY_SIG: &[u8; 4] = b"PK\x01\x02";
9873    let end = offset
9874        .checked_add(size)
9875        .filter(|end| *end <= bytes.len())
9876        .ok_or_else(|| LinkError::InvalidPack {
9877            message: "ZIP central directory is out of bounds".to_string(),
9878        })?;
9879    let mut cursor = offset;
9880    for _ in 0..count {
9881        if bytes.get(cursor..cursor + 4) != Some(CENTRAL_ENTRY_SIG.as_slice()) {
9882            return Err(LinkError::InvalidPack {
9883                message: "ZIP central directory entry count is inconsistent".to_string(),
9884            });
9885        }
9886        if le_u16(bytes, cursor + 34) != Some(0) {
9887            return Err(LinkError::InvalidPack {
9888                message: "multi-disk ZIP archives are not supported".to_string(),
9889            });
9890        }
9891        let variable = [28, 30, 32].into_iter().try_fold(0usize, |total, at| {
9892            total.checked_add(le_u16(bytes, cursor + at)? as usize)
9893        });
9894        cursor = cursor
9895            .checked_add(46)
9896            .and_then(|fixed| fixed.checked_add(variable?))
9897            .filter(|cursor| *cursor <= end)
9898            .ok_or_else(|| LinkError::InvalidPack {
9899                message: "ZIP central directory entry is truncated".to_string(),
9900            })?;
9901    }
9902    if cursor != end {
9903        return Err(LinkError::InvalidPack {
9904            message: "ZIP central directory size is inconsistent".to_string(),
9905        });
9906    }
9907    Ok(())
9908}
9909
9910/// Read only the bounded ZIP trailer before `ZipArchive::new` allocates one
9911/// metadata object per central-directory entry. Supports ordinary EOCD and the
9912/// Zip64 locator/record emitted for >65,535-entry archives.
9913fn preflight_zip_entry_count(bytes: &[u8], max_entries: usize) -> LinkResult<()> {
9914    const EOCD_SIG: &[u8; 4] = b"PK\x05\x06";
9915    const ZIP64_LOCATOR_SIG: &[u8; 4] = b"PK\x06\x07";
9916    const ZIP64_EOCD_SIG: &[u8; 4] = b"PK\x06\x06";
9917    let search_start = bytes.len().saturating_sub(22 + u16::MAX as usize);
9918    let eocd = bytes[search_start..]
9919        .windows(4)
9920        .rposition(|window| window == EOCD_SIG)
9921        .map(|offset| search_start + offset)
9922        .ok_or_else(|| LinkError::InvalidPack {
9923            message: "ZIP has no end-of-central-directory record".to_string(),
9924        })?;
9925    let invalid_end = || LinkError::InvalidPack {
9926        message: "ZIP has an invalid end-of-central-directory structure".to_string(),
9927    };
9928    let comment_len = le_u16(bytes, eocd + 20).ok_or_else(invalid_end)? as usize;
9929    if eocd
9930        .checked_add(22)
9931        .and_then(|end| end.checked_add(comment_len))
9932        != Some(bytes.len())
9933    {
9934        // Do not fall back to an earlier signature. ZipArchive does, and a
9935        // fake low-count EOCD appended after a real Zip64 entry-count bomb
9936        // would otherwise bypass this allocation preflight.
9937        return Err(invalid_end());
9938    }
9939    let disk = le_u16(bytes, eocd + 4);
9940    let central_disk = le_u16(bytes, eocd + 6);
9941    if disk != Some(0) || central_disk != Some(0) {
9942        return Err(LinkError::InvalidPack {
9943            message: "multi-disk ZIP archives are not supported".to_string(),
9944        });
9945    }
9946    let entries_on_disk = le_u16(bytes, eocd + 8).ok_or_else(invalid_end)?;
9947    let ordinary = le_u16(bytes, eocd + 10).ok_or_else(invalid_end)?;
9948    if entries_on_disk != ordinary {
9949        return Err(LinkError::InvalidPack {
9950            message: "multi-disk ZIP archives are not supported".to_string(),
9951        });
9952    }
9953    let zip64_locator = eocd
9954        .checked_sub(20)
9955        .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_LOCATOR_SIG.as_slice()));
9956    let (count, central_offset, central_size) = if ordinary != u16::MAX || zip64_locator.is_none() {
9957        let central_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)? as usize;
9958        let central_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)? as usize;
9959        if central_offset
9960            .checked_add(central_size)
9961            .filter(|end| *end == eocd)
9962            .is_none()
9963        {
9964            return Err(invalid_end());
9965        }
9966        (ordinary as u64, central_offset, central_size)
9967    } else {
9968        let Some(locator) = zip64_locator else {
9969            return Err(invalid_end());
9970        };
9971        if le_u32(bytes, locator + 4) != Some(0) || le_u32(bytes, locator + 16) != Some(1) {
9972            return Err(LinkError::InvalidPack {
9973                message: "multi-disk ZIP64 archives are not supported".to_string(),
9974            });
9975        }
9976        let record = le_u64(bytes, locator + 8)
9977            .and_then(|offset| usize::try_from(offset).ok())
9978            .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_EOCD_SIG.as_slice()))
9979            .ok_or_else(|| LinkError::InvalidPack {
9980                message: "ZIP64 archive has an invalid end record".to_string(),
9981            })?;
9982        let record_size = le_u64(bytes, record + 4)
9983            .and_then(|size| usize::try_from(size).ok())
9984            .filter(|size| *size >= 44)
9985            .ok_or_else(invalid_end)?;
9986        if record
9987            .checked_add(12)
9988            .and_then(|end| end.checked_add(record_size))
9989            != Some(locator)
9990            || le_u32(bytes, record + 16) != Some(0)
9991            || le_u32(bytes, record + 20) != Some(0)
9992        {
9993            return Err(invalid_end());
9994        }
9995        let zip64_on_disk = le_u64(bytes, record + 24).ok_or_else(invalid_end)?;
9996        let zip64_total = le_u64(bytes, record + 32).ok_or_else(invalid_end)?;
9997        let central_size = le_u64(bytes, record + 40)
9998            .and_then(|size| usize::try_from(size).ok())
9999            .ok_or_else(invalid_end)?;
10000        let central_offset = le_u64(bytes, record + 48)
10001            .and_then(|offset| usize::try_from(offset).ok())
10002            .ok_or_else(invalid_end)?;
10003        if zip64_on_disk != zip64_total
10004            || central_offset
10005                .checked_add(central_size)
10006                .filter(|end| *end == record)
10007                .is_none()
10008        {
10009            return Err(invalid_end());
10010        }
10011        let legacy_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)?;
10012        let legacy_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)?;
10013        if (legacy_size != u32::MAX && legacy_size as usize != central_size)
10014            || (legacy_offset != u32::MAX && legacy_offset as usize != central_offset)
10015        {
10016            return Err(invalid_end());
10017        }
10018        (zip64_total, central_offset, central_size)
10019    };
10020    if count == 0 || count > max_entries as u64 {
10021        return Err(LinkError::InvalidPack {
10022            message: format!("invalid file count {count}"),
10023        });
10024    }
10025    preflight_zip_central_directory(bytes, central_offset, central_size, count)?;
10026    Ok(())
10027}
10028
10029fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
10030    preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)?;
10031    let mut archive =
10032        zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
10033            message: format!("ZIP parse failed: {err}"),
10034        })?;
10035    if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
10036        return Err(LinkError::InvalidPack {
10037            message: format!("invalid file count {}", archive.len()),
10038        });
10039    }
10040    let mut total = 0u64;
10041    let mut seen = std::collections::HashSet::new();
10042    let mut entries = Vec::with_capacity(archive.len());
10043    for index in 0..archive.len() {
10044        let mut file = archive
10045            .by_index(index)
10046            .map_err(|err| LinkError::InvalidPack {
10047                message: format!("ZIP entry failed: {err}"),
10048            })?;
10049        if file.is_dir() {
10050            continue;
10051        }
10052        let path = file.name().to_string();
10053        if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
10054            return Err(LinkError::UnsafePath { path });
10055        }
10056        if file
10057            .unix_mode()
10058            .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
10059        {
10060            return Err(LinkError::InvalidPack {
10061                message: format!("non-file entry `{path}`"),
10062            });
10063        }
10064        if !seen.insert(path.clone()) {
10065            return Err(LinkError::InvalidPack {
10066                message: format!("duplicate path `{path}`"),
10067            });
10068        }
10069        let remaining = MAX_STORE_BYTES.saturating_sub(total);
10070        if file.size() > remaining {
10071            return Err(LinkError::InvalidPack {
10072                message: "expanded content exceeds the 512 MB limit".to_string(),
10073            });
10074        }
10075        let mut content = Vec::new();
10076        (&mut file)
10077            .take(remaining + 1)
10078            .read_to_end(&mut content)
10079            .map_err(|err| LinkError::InvalidPack {
10080                message: format!("could not decompress `{path}`: {err}"),
10081            })?;
10082        if content.len() as u64 > remaining {
10083            return Err(LinkError::InvalidPack {
10084                message: "expanded content exceeds the 512 MB limit".to_string(),
10085            });
10086        }
10087        if content.len() as u64 != file.size() {
10088            return Err(LinkError::InvalidPack {
10089                message: format!("length mismatch for `{path}`"),
10090            });
10091        }
10092        total += content.len() as u64;
10093        entries.push((path, content));
10094    }
10095    if entries.is_empty() {
10096        return Err(LinkError::InvalidPack {
10097            message: "pack contains no files".to_string(),
10098        });
10099    }
10100    Ok(entries)
10101}
10102
10103fn verify_snapshot_manifest(entries: &[(String, Vec<u8>)], signed: &[FeedFile]) -> LinkResult<()> {
10104    let mut expected = std::collections::BTreeMap::new();
10105    for file in signed {
10106        if !safe_store_rel_path(&file.path) {
10107            return Err(LinkError::UnsafePath {
10108                path: file.path.clone(),
10109            });
10110        }
10111        if !is_sha256(&file.sha256)
10112            || expected
10113                .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
10114                .is_some()
10115        {
10116            return Err(invalid_feed(
10117                "signed snapshot manifest contains an invalid or duplicate file",
10118            ));
10119        }
10120    }
10121    if expected.len() != entries.len() {
10122        return Err(invalid_feed(
10123            "downloaded pack file set differs from the signed snapshot manifest",
10124        ));
10125    }
10126    for (path, bytes) in entries {
10127        let Some((sha256, declared_bytes)) = expected.get(path.as_str()) else {
10128            return Err(invalid_feed(format!(
10129                "downloaded pack contains unsigned path `{path}`"
10130            )));
10131        };
10132        if *declared_bytes != bytes.len() as u64
10133            || *sha256 != format!("{:x}", Sha256::digest(bytes))
10134        {
10135            return Err(invalid_feed(format!(
10136                "downloaded file `{path}` differs from its signed manifest"
10137            )));
10138        }
10139    }
10140    Ok(())
10141}
10142
10143/// Collect the files a push sends: the store's owned text — `DB.md`,
10144/// `assets.jsonl` when present, and every content `.md` under `records/` and
10145/// `sources/` (the store walk, which already excludes hidden dirs like
10146/// `.dbmd/`, the `log/` archive, and derived `index.*` catalogs; the hub
10147/// derives its own index, and local history stays local). Returns
10148/// `(store-relative path, content)` pairs, path-sorted.
10149pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
10150    require_hardened_filesystem("sync push")?;
10151    preflight_push_ownership(store)?;
10152    let mut out: Vec<(String, String)> = Vec::new();
10153    let mut total = 0u64;
10154
10155    let mut read_text = |rel: &str| -> LinkResult<String> {
10156        let bytes = store.read_bounded(Path::new(rel), MAX_STORE_BYTES.saturating_sub(total))?;
10157        total = total
10158            .checked_add(bytes.len() as u64)
10159            .ok_or_else(|| LinkError::PushTooLarge {
10160                detail: "uncompressed byte count overflow".to_string(),
10161            })?;
10162        if total > MAX_STORE_BYTES {
10163            return Err(LinkError::PushTooLarge {
10164                detail: format!("{total} uncompressed bytes"),
10165            });
10166        }
10167        String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
10168            path: rel.to_string(),
10169        })
10170    };
10171
10172    out.push(("DB.md".to_string(), read_text("DB.md")?));
10173    if store
10174        .regular_file_exists(Path::new("assets.jsonl"))
10175        .unwrap_or(false)
10176    {
10177        out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
10178    }
10179
10180    for rel in store.walk()? {
10181        let rel_str = rel.to_string_lossy().replace('\\', "/");
10182        if !safe_store_rel_path(&rel_str) {
10183            // A locally-legal name outside the hub's portable charset cannot
10184            // travel this wire; refusing beats silently dropping it.
10185            return Err(LinkError::UnsafePath { path: rel_str });
10186        }
10187        let content = read_text(&rel_str)?;
10188        out.push((rel_str, content));
10189    }
10190
10191    out.sort_by(|a, b| a.0.cmp(&b.0));
10192    Ok(out)
10193}
10194
10195/// Refuse to build a destructive whole-store snapshot from an ambiguous local
10196/// tree. Ordinary read-only walks safely prune foreign paths, but a push that
10197/// silently omitted them could delete the hosted copies of those paths.
10198fn preflight_push_ownership(store: &Store) -> LinkResult<()> {
10199    if let Some(nested) = store.nested_store_roots()?.into_iter().next() {
10200        return Err(LinkError::from(std::io::Error::new(
10201            std::io::ErrorKind::PermissionDenied,
10202            format!("cannot push: nested db.md store at {}", nested.display()),
10203        )));
10204    }
10205
10206    if let Some(symlink) = store.unowned_symlinks()?.into_iter().next() {
10207        return Err(LinkError::from(std::io::Error::new(
10208            std::io::ErrorKind::PermissionDenied,
10209            format!(
10210                "cannot push: {} is a symlink outside the store ownership model",
10211                symlink.display()
10212            ),
10213        )));
10214    }
10215    Ok(())
10216}
10217
10218/// Push `files` to `brain` as a whole-store snapshot — the hub's push
10219/// semantics: the hosted copy becomes exactly this set (pull first if the
10220/// hosted side may have records the local copy lacks). Client-side caps
10221/// mirror the hub's JSON-path limits so an oversized push fails before the
10222/// upload.
10223pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
10224    require_safe_ref(brain)?;
10225    let remote = verified_remote_head(cfg, brain, false)?;
10226    if files.len() > MAX_PUSH_FILES {
10227        return Err(LinkError::PushTooLarge {
10228            detail: format!("{} files", files.len()),
10229        });
10230    }
10231    let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
10232    if raw_total > MAX_STORE_BYTES {
10233        return Err(LinkError::PushTooLarge {
10234            detail: format!("{raw_total} uncompressed bytes"),
10235        });
10236    }
10237
10238    // Self-custody (a brain key is configured): the JSON fast path is
10239    // hub-signed by construction, so every push goes through the pack flow
10240    // with a locally signed entry — the hub verifies and can never sign.
10241    if cfg.brain_key.is_none() {
10242        let body = json!({
10243            "files": files
10244                .iter()
10245                .map(|(p, c)| json!({ "path": p, "content": c }))
10246                .collect::<Vec<_>>(),
10247        });
10248        if body.to_string().len() <= MAX_PUSH_BYTES {
10249            let path = format!("/api/hub/brains/{brain}/push");
10250            let pushed = ensure_ok(
10251                request(cfg, "POST", &path, Some(&body), Auth::Required)?,
10252                "sync push",
10253            )?;
10254            return Ok(pushed);
10255        }
10256    }
10257
10258    let pack = build_store_pack(files)?;
10259    if pack.len() as u64 > MAX_PACK_BYTES {
10260        return Err(LinkError::PushTooLarge {
10261            detail: format!("{} pack bytes", pack.len()),
10262        });
10263    }
10264    let sha256 = format!("{:x}", Sha256::digest(&pack));
10265    let mut meta = json!({ "sha256": sha256, "bytes": pack.len() });
10266    if let Some(key) = &cfg.brain_key {
10267        if !remote.head.verified {
10268            return Err(invalid_feed(
10269                "self-custody push requires a fully verified, unscoped feed head",
10270            ));
10271        }
10272        let identity = remote
10273            .identity
10274            .as_ref()
10275            .ok_or_else(|| invalid_feed("verified brain has no current identity"))?;
10276        let current_multikey = format!("ed25519:{}", identity.fingerprint);
10277        if key.multikey != current_multikey || key.public_key_spki != identity.public_key_spki {
10278            return Err(invalid_feed(
10279                "configured brain key is not the verified current brain identity",
10280            ));
10281        }
10282        // The verified state pins seq + prev; a concurrent writer surfaces as
10283        // the hub's 422 on commit (re-run to retry against the new head).
10284        let next_seq = remote
10285            .head
10286            .seq
10287            .checked_add(1)
10288            .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
10289        let mut manifest: Vec<WireFeedFile> = files
10290            .iter()
10291            .map(|(path, content)| WireFeedFile {
10292                path: path.clone(),
10293                sha256: format!("{:x}", Sha256::digest(content.as_bytes())),
10294                bytes: content.len() as u64,
10295            })
10296            .collect();
10297        manifest.sort_by(|a, b| a.path.cmp(&b.path));
10298        let ts = crate::now()
10299            .with_timezone(&chrono::Utc)
10300            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
10301            .to_string();
10302        let entry = self_custody_entry(
10303            key,
10304            next_seq,
10305            ts,
10306            &sha256,
10307            &manifest,
10308            remote.head.feed_hash.as_deref(),
10309        )?;
10310        meta["entry"] = Value::String(entry);
10311    }
10312    let presigned = ensure_ok(
10313        request(
10314            cfg,
10315            "POST",
10316            &format!("/api/hub/brains/{brain}/packs/presign"),
10317            Some(&meta),
10318            Auth::Required,
10319        )?,
10320        "prepare pack upload",
10321    )?;
10322    let url = presigned
10323        .get("url")
10324        .and_then(Value::as_str)
10325        .ok_or_else(|| LinkError::InvalidPack {
10326            message: "the hub returned no upload URL".to_string(),
10327        })?;
10328    put_presigned(
10329        cfg,
10330        url,
10331        presigned.get("headers").unwrap_or(&Value::Null),
10332        &pack,
10333    )?;
10334    let committed = ensure_ok(
10335        request(
10336            cfg,
10337            "POST",
10338            &format!("/api/hub/brains/{brain}/packs/commit"),
10339            Some(&meta),
10340            Auth::Required,
10341        )?,
10342        "commit pack",
10343    )?;
10344    Ok(committed)
10345}
10346
10347fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
10348    const LOCAL_HEADER: u32 = 0x0403_4b50;
10349    const CENTRAL_HEADER: u32 = 0x0201_4b50;
10350    const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
10351    const VERSION_20: u16 = 20;
10352    const MADE_BY_UNIX_20: u16 = (3 << 8) | VERSION_20;
10353    const UTF8_FLAG: u16 = 1 << 11;
10354    const STORED: u16 = 0;
10355    const DOS_TIME_MIDNIGHT: u16 = 0;
10356    const DOS_DATE_1980_01_01: u16 = (1 << 5) | 1;
10357    const UNIX_REGULAR_0600: u32 = 0o100600 << 16;
10358
10359    struct CentralEntry<'a> {
10360        name: &'a [u8],
10361        crc32: u32,
10362        size: u32,
10363        local_offset: u32,
10364    }
10365
10366    fn push_u16(out: &mut Vec<u8>, value: u16) {
10367        out.extend_from_slice(&value.to_le_bytes());
10368    }
10369
10370    fn push_u32(out: &mut Vec<u8>, value: u32) {
10371        out.extend_from_slice(&value.to_le_bytes());
10372    }
10373
10374    if files.is_empty() {
10375        return Err(LinkError::InvalidPack {
10376            message: "cannot create an empty snapshot pack".to_string(),
10377        });
10378    }
10379    if files.len() > u16::MAX as usize {
10380        return Err(LinkError::PushTooLarge {
10381            detail: format!(
10382                "{} files (canonical ZIP32 packs cap at {})",
10383                files.len(),
10384                u16::MAX
10385            ),
10386        });
10387    }
10388
10389    let mut sorted: Vec<_> = files.iter().collect();
10390    sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
10391    let mut previous: Option<&str> = None;
10392    for (path, content) in &sorted {
10393        if !safe_store_rel_path(path) {
10394            return Err(LinkError::UnsafePath {
10395                path: (*path).clone(),
10396            });
10397        }
10398        if previous == Some(path.as_str()) {
10399            return Err(LinkError::InvalidPack {
10400                message: format!("duplicate path `{path}`"),
10401            });
10402        }
10403        previous = Some(path.as_str());
10404        u32::try_from(content.len()).map_err(|_| LinkError::PushTooLarge {
10405            detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
10406        })?;
10407    }
10408
10409    let mut out = Vec::new();
10410    let mut central = Vec::with_capacity(sorted.len());
10411    for (path, content) in sorted {
10412        let name = path.as_bytes();
10413        let name_len = u16::try_from(name.len()).map_err(|_| LinkError::InvalidPack {
10414            message: format!("ZIP entry name is too long: `{path}`"),
10415        })?;
10416        let bytes = content.as_bytes();
10417        let size = u32::try_from(bytes.len()).map_err(|_| LinkError::PushTooLarge {
10418            detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
10419        })?;
10420        let local_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
10421            detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
10422        })?;
10423        let crc32 = crc32fast::hash(bytes);
10424
10425        // Canonical local header: sizes and CRC are known up front. Bit 3 is
10426        // deliberately clear, so there is no trailing data descriptor.
10427        push_u32(&mut out, LOCAL_HEADER);
10428        push_u16(&mut out, VERSION_20);
10429        push_u16(&mut out, UTF8_FLAG);
10430        push_u16(&mut out, STORED);
10431        push_u16(&mut out, DOS_TIME_MIDNIGHT);
10432        push_u16(&mut out, DOS_DATE_1980_01_01);
10433        push_u32(&mut out, crc32);
10434        push_u32(&mut out, size);
10435        push_u32(&mut out, size);
10436        push_u16(&mut out, name_len);
10437        push_u16(&mut out, 0); // no local extra data
10438        out.extend_from_slice(name);
10439        out.extend_from_slice(bytes);
10440
10441        central.push(CentralEntry {
10442            name,
10443            crc32,
10444            size,
10445            local_offset,
10446        });
10447    }
10448
10449    let central_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
10450        detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
10451    })?;
10452    for entry in &central {
10453        push_u32(&mut out, CENTRAL_HEADER);
10454        push_u16(&mut out, MADE_BY_UNIX_20);
10455        push_u16(&mut out, VERSION_20);
10456        push_u16(&mut out, UTF8_FLAG);
10457        push_u16(&mut out, STORED);
10458        push_u16(&mut out, DOS_TIME_MIDNIGHT);
10459        push_u16(&mut out, DOS_DATE_1980_01_01);
10460        push_u32(&mut out, entry.crc32);
10461        push_u32(&mut out, entry.size);
10462        push_u32(&mut out, entry.size);
10463        push_u16(&mut out, entry.name.len() as u16);
10464        push_u16(&mut out, 0); // no central extra data
10465        push_u16(&mut out, 0); // no file comment
10466        push_u16(&mut out, 0); // disk number
10467        push_u16(&mut out, 0); // internal attributes
10468        push_u32(&mut out, UNIX_REGULAR_0600);
10469        push_u32(&mut out, entry.local_offset);
10470        out.extend_from_slice(entry.name);
10471    }
10472    let central_size = u32::try_from(out.len())
10473        .ok()
10474        .and_then(|end| end.checked_sub(central_offset))
10475        .ok_or_else(|| LinkError::PushTooLarge {
10476            detail: "canonical ZIP32 central directory exceeds its limit".to_string(),
10477        })?;
10478    let entry_count = central.len() as u16;
10479
10480    push_u32(&mut out, END_OF_CENTRAL_DIRECTORY);
10481    push_u16(&mut out, 0); // this disk
10482    push_u16(&mut out, 0); // central directory disk
10483    push_u16(&mut out, entry_count);
10484    push_u16(&mut out, entry_count);
10485    push_u32(&mut out, central_size);
10486    push_u32(&mut out, central_offset);
10487    push_u16(&mut out, 0); // no archive comment
10488
10489    if out.len() > u32::MAX as usize {
10490        return Err(LinkError::PushTooLarge {
10491            detail: "canonical ZIP32 pack exceeds 4 GiB".to_string(),
10492        });
10493    }
10494    Ok(out)
10495}
10496
10497// ─────────────────────────────────────────────────────────────────────────────
10498// grant — issue / list / revoke capabilities (owner-side)
10499// ─────────────────────────────────────────────────────────────────────────────
10500
10501/// The two capabilities a v0 hub enforces.
10502#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10503pub enum Capability {
10504    /// Read the granted slice.
10505    Read,
10506    /// Read and push (whole-store; a path-scoped grant is read-only).
10507    Write,
10508}
10509
10510impl Capability {
10511    /// The wire form.
10512    pub fn as_str(self) -> &'static str {
10513        match self {
10514            Capability::Read => "read",
10515            Capability::Write => "write",
10516        }
10517    }
10518}
10519
10520/// Issue (or refresh) a grant on `brain` to `grantee` — a hub principal named
10521/// by email in v0 (the protocol's near-term simplification; key-named
10522/// grantees arrive with the signing layer). `scope` is a store-path prefix
10523/// (the hub's enforcement unit); `until` an ISO 8601 expiry, absent = until
10524/// revoked.
10525pub fn grant_issue(
10526    cfg: &HubConfig,
10527    brain: &str,
10528    grantee: &str,
10529    can: Capability,
10530    scope: Option<&str>,
10531    until: Option<&str>,
10532) -> LinkResult<Value> {
10533    require_safe_ref(brain)?;
10534    // Grantee shape decides the axis: a base64url Ed25519 SPKI is a bare
10535    // multikey holder (link.md §6 cross-party keys — no hub account; the
10536    // printed `publicKeySpki` from `dbmd key generate`); anything else is a
10537    // hub principal named by email.
10538    let is_key_grantee = URL_SAFE_NO_PAD
10539        .decode(grantee)
10540        .map(|der| der.len() == 44 && der.starts_with(&ED25519_SPKI_PREFIX))
10541        .unwrap_or(false);
10542    if let Some(head) = v2_verified_head(cfg, brain)? {
10543        if is_key_grantee {
10544            let scope = scope.unwrap_or("");
10545            let preset = match can {
10546                Capability::Read => "viewer",
10547                Capability::Write => "editor",
10548            };
10549            let entropy = format!(
10550                "{}\0{}\0{}\0{}\0{}\0{}\0{}",
10551                normalized_origin(&cfg.hub)?,
10552                head.brain_id,
10553                head.control_revision,
10554                grantee,
10555                preset,
10556                scope,
10557                until.unwrap_or("")
10558            );
10559            let mut body = json!({
10560                "context": "external",
10561                "expected_control_revision": head.control_revision,
10562                "mutation_id": format!("dbmd-grant-{}", content_sha256(entropy.as_bytes())),
10563                "preset": preset,
10564                "principal_kind": "key",
10565                "public_key": grantee,
10566                "scope": scope,
10567                "scope_kind": "prefix",
10568            });
10569            if let Some(value) = until {
10570                body["expires_at"] = json!(value);
10571            }
10572            let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
10573            let response = ensure_ok(
10574                request(cfg, "POST", &path, Some(&body), Auth::Required)?,
10575                "v2 grant issue",
10576            )?;
10577            let expected_fingerprint = identity_fingerprint(grantee)?;
10578            if response.get("v").and_then(Value::as_u64) != Some(2)
10579                || response
10580                    .get("id")
10581                    .and_then(Value::as_str)
10582                    .is_none_or(|id| !crate::ulid::is_ulid(id))
10583                || response.get("principal_kind").and_then(Value::as_str) != Some("key")
10584                || response.get("principal_id").and_then(Value::as_str)
10585                    != Some(expected_fingerprint.as_str())
10586                || response
10587                    .get("control_revision")
10588                    .and_then(Value::as_str)
10589                    .is_none_or(|value| !is_sha256(value))
10590            {
10591                return Err(invalid_feed(
10592                    "v2 grant issue response is not authority-bound",
10593                ));
10594            }
10595            return Ok(response);
10596        }
10597        // Account invitations remain a product workflow. The compatibility
10598        // endpoint resolves email/account identity, then commits a native v2
10599        // grant with the same control-plane recovery boundary. The v2 head
10600        // above is already the authenticated authority checkpoint; never
10601        // fall through to the retired v1 feed verifier for this request.
10602        let mut body = json!({ "email": grantee, "capability": can.as_str() });
10603        if let Some(value) = scope {
10604            body["scopePrefix"] = json!(value);
10605        }
10606        if let Some(value) = until {
10607            body["expiresAt"] = json!(value);
10608        }
10609        let path = format!("/api/hub/brains/{}/grants", head.brain_id);
10610        return ensure_ok(
10611            request(cfg, "POST", &path, Some(&body), Auth::Required)?,
10612            "account grant issue",
10613        );
10614    }
10615    let _ = verified_remote_head(cfg, brain, false)?;
10616    let mut body = if is_key_grantee {
10617        json!({ "keySpki": grantee, "capability": can.as_str() })
10618    } else {
10619        json!({ "email": grantee, "capability": can.as_str() })
10620    };
10621    if let Some(s) = scope {
10622        body["scopePrefix"] = json!(s);
10623    }
10624    if let Some(u) = until {
10625        body["expiresAt"] = json!(u);
10626    }
10627    let path = format!("/api/hub/brains/{brain}/grants");
10628    ensure_ok(
10629        request(cfg, "POST", &path, Some(&body), Auth::Required)?,
10630        "grant issue",
10631    )
10632}
10633
10634/// List the active grants (and pending invites) on `brain`. Owner-side.
10635pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
10636    require_safe_ref(brain)?;
10637    if let Some(head) = v2_verified_head(cfg, brain)? {
10638        let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
10639        let response = ensure_ok(
10640            request(cfg, "GET", &path, None, Auth::Required)?,
10641            "v2 grant list",
10642        )?;
10643        if response.get("v").and_then(Value::as_u64) != Some(2)
10644            || response.get("control_revision").and_then(Value::as_str)
10645                != Some(head.control_revision.as_str())
10646            || !response.get("grants").is_some_and(Value::is_array)
10647        {
10648            return Err(invalid_feed(
10649                "v2 grant list is not bound to the verified authority",
10650            ));
10651        }
10652        return Ok(response);
10653    }
10654    let _ = verified_remote_head(cfg, brain, false)?;
10655    let path = format!("/api/hub/brains/{brain}/grants");
10656    ensure_ok(
10657        request(cfg, "GET", &path, None, Auth::Required)?,
10658        "grant list",
10659    )
10660}
10661
10662/// Revoke a grant (or cancel a pending invite) by id. Owner-side; revocation
10663/// is soft on the hub (the audit trail survives).
10664pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
10665    require_safe_ref(brain)?;
10666    require_safe_grant_id(grant_id)?;
10667    if let Some(head) = v2_verified_head(cfg, brain)? {
10668        let entropy = format!(
10669            "{}\0{}\0{}\0{}",
10670            normalized_origin(&cfg.hub)?,
10671            head.brain_id,
10672            head.control_revision,
10673            grant_id
10674        );
10675        let body = json!({
10676            "expected_control_revision": head.control_revision,
10677            "mutation_id": format!("dbmd-revoke-{}", content_sha256(entropy.as_bytes())),
10678        });
10679        let path = format!("/api/hub/brains/{}/v2/grants/{grant_id}", head.brain_id);
10680        let response = ensure_ok(
10681            request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
10682            "v2 grant revoke",
10683        )?;
10684        if response.get("v").and_then(Value::as_u64) != Some(2)
10685            || response.get("id").and_then(Value::as_str) != Some(grant_id)
10686            || response.get("revoked").and_then(Value::as_bool) != Some(true)
10687            || response
10688                .get("control_revision")
10689                .and_then(Value::as_str)
10690                .is_none_or(|value| !is_sha256(value))
10691        {
10692            return Err(invalid_feed(
10693                "v2 grant revocation response is not authority-bound",
10694            ));
10695        }
10696        return Ok(response);
10697    }
10698    let _ = verified_remote_head(cfg, brain, false)?;
10699    let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
10700    ensure_ok(
10701        request(cfg, "DELETE", &path, None, Auth::Required)?,
10702        "grant revoke",
10703    )
10704}
10705
10706// ─────────────────────────────────────────────────────────────────────────────
10707// permissioned v2 proposals — inspect, accept, reject
10708// ─────────────────────────────────────────────────────────────────────────────
10709
10710#[derive(Debug)]
10711struct VerifiedV2Proposal {
10712    value: Value,
10713    changes: Value,
10714    blobs: Vec<(String, u64, String)>,
10715}
10716
10717fn require_proposal_id(id: &str) -> LinkResult<()> {
10718    if crate::ulid::is_ulid(id) {
10719        Ok(())
10720    } else {
10721        Err(invalid_feed("proposal id is not a lowercase ULID"))
10722    }
10723}
10724
10725fn verified_v2_proposal(
10726    cfg: &HubConfig,
10727    head: &V2VerifiedHead,
10728    proposal_id: &str,
10729) -> LinkResult<VerifiedV2Proposal> {
10730    require_proposal_id(proposal_id)?;
10731    if head.view_kind != "full" {
10732        return Err(invalid_feed(
10733            "proposal review requires a full readable view",
10734        ));
10735    }
10736    let path = format!(
10737        "/api/hub/brains/{}/v2/proposals/{proposal_id}",
10738        head.brain_id
10739    );
10740    let value = ensure_ok(
10741        request_capped(
10742            cfg,
10743            "GET",
10744            &path,
10745            None,
10746            Auth::Required,
10747            MAX_FEED_RESPONSE_BYTES,
10748        )?,
10749        "v2 proposal",
10750    )?;
10751    verify_v2_proposal_value(head, proposal_id, value)
10752}
10753
10754fn verify_v2_proposal_value(
10755    head: &V2VerifiedHead,
10756    proposal_id: &str,
10757    value: Value,
10758) -> LinkResult<VerifiedV2Proposal> {
10759    if value.get("v").and_then(Value::as_u64) != Some(2) {
10760        return Err(invalid_feed("proposal response has an invalid version"));
10761    }
10762    let proposal = value
10763        .get("proposal")
10764        .and_then(Value::as_object)
10765        .ok_or_else(|| invalid_feed("proposal response has no proposal"))?;
10766    if proposal.get("id").and_then(Value::as_str) != Some(proposal_id) {
10767        return Err(invalid_feed("proposal response changed its id"));
10768    }
10769    let payload_hash = proposal
10770        .get("payload_sha256")
10771        .and_then(Value::as_str)
10772        .filter(|hash| is_sha256(hash))
10773        .ok_or_else(|| invalid_feed("proposal has no payload address"))?;
10774    let clear_hash = proposal
10775        .get("clear_sha256")
10776        .and_then(Value::as_str)
10777        .filter(|hash| is_sha256(hash))
10778        .ok_or_else(|| invalid_feed("proposal has no clear payload digest"))?;
10779    let submission_hash = proposal
10780        .get("submission_claim_sha256")
10781        .and_then(Value::as_str)
10782        .filter(|hash| is_sha256(hash))
10783        .ok_or_else(|| invalid_feed("proposal has no submission claim address"))?;
10784    let submission = STANDARD
10785        .decode(
10786            proposal
10787                .get("submission_claim_base64")
10788                .and_then(Value::as_str)
10789                .ok_or_else(|| invalid_feed("proposal has no submission claim"))?,
10790        )
10791        .map_err(|_| invalid_feed("proposal submission claim is not base64"))?;
10792    let submission_value: Value = serde_json::from_slice(&submission)
10793        .map_err(|_| invalid_feed("proposal submission claim is not JSON"))?;
10794    if crate::linkmd_v2::canonical_bytes(&submission_value)
10795        .map_err(|error| invalid_feed(error.to_string()))?
10796        != submission
10797        || crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &submission)
10798            .map_err(|error| invalid_feed(error.to_string()))?
10799            != submission_hash
10800    {
10801        return Err(invalid_feed(
10802            "proposal submission claim is not canonical or addressed",
10803        ));
10804    }
10805    let envelope = submission_value
10806        .as_object()
10807        .ok_or_else(|| invalid_feed("proposal submission claim is not an object"))?;
10808    let claim = envelope
10809        .get("claim")
10810        .ok_or_else(|| invalid_feed("proposal submission claim body is missing"))?;
10811    let claim_object = claim
10812        .as_object()
10813        .ok_or_else(|| invalid_feed("proposal submission claim body is not an object"))?;
10814    let actor_root = claim_object
10815        .get("actor_root")
10816        .and_then(Value::as_object)
10817        .ok_or_else(|| invalid_feed("proposal submission actor root is missing"))?;
10818    let public_key = envelope
10819        .get("public_key")
10820        .and_then(Value::as_str)
10821        .ok_or_else(|| invalid_feed("proposal submission signer is missing"))?;
10822    let fingerprint = envelope
10823        .get("fingerprint")
10824        .and_then(Value::as_str)
10825        .ok_or_else(|| invalid_feed("proposal submission fingerprint is missing"))?;
10826    let signature = envelope
10827        .get("sig")
10828        .and_then(Value::as_str)
10829        .ok_or_else(|| invalid_feed("proposal submission signature is missing"))?;
10830    let claim_bytes = crate::linkmd_v2::canonical_bytes(claim)
10831        .map_err(|error| invalid_feed(error.to_string()))?;
10832    let der = verify_v2_spki_signature(public_key, &claim_bytes, signature)?;
10833    let signer = format!("{fingerprint}:{public_key}");
10834    let actor_class = actor_root.get("actor_class").and_then(Value::as_str);
10835    let grants = actor_root.get("grants").and_then(Value::as_array);
10836    let grants_are_canonical = grants.is_some_and(|items| {
10837        let mut prior: Option<&str> = None;
10838        items.iter().all(|item| {
10839            let Some(grant) = item.as_str() else {
10840                return false;
10841            };
10842            if !crate::ulid::is_ulid(grant) || prior.is_some_and(|value| value >= grant) {
10843                return false;
10844            }
10845            prior = Some(grant);
10846            true
10847        })
10848    });
10849    let optional_actor_field = |name: &str| {
10850        actor_root.get(name).is_some_and(|value| {
10851            value.is_null() || value.as_str().is_some_and(|text| !text.is_empty())
10852        })
10853    };
10854    let submitted_at = claim_object.get("submitted_at").and_then(Value::as_str);
10855    if claim_object.get("v").and_then(Value::as_u64) != Some(2)
10856        || format!("{:x}", Sha256::digest(&der)) != fingerprint
10857        || head
10858            .trust
10859            .hub_signer
10860            .as_ref()
10861            .is_some_and(|known| known != &signer)
10862        || !matches!(
10863            actor_class,
10864            Some(
10865                "user"
10866                    | "owned_agent"
10867                    | "foreign_key"
10868                    | "curation"
10869                    | "inbox"
10870                    | "restore"
10871                    | "migration"
10872                    | "operator_recovery"
10873            )
10874        )
10875        || actor_root
10876            .get("principal")
10877            .and_then(Value::as_str)
10878            .is_none_or(|value| value.is_empty())
10879        || actor_root
10880            .get("credential")
10881            .and_then(Value::as_str)
10882            .is_none_or(|value| value.is_empty())
10883        || !optional_actor_field("organization")
10884        || !optional_actor_field("role")
10885        || !grants_are_canonical
10886        || claim.get("brain").and_then(Value::as_str) != Some(head.brain_id.as_str())
10887        || claim.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
10888        || !claim_object
10889            .get("mutation_id")
10890            .and_then(Value::as_str)
10891            .is_some_and(|value| {
10892                !value.is_empty()
10893                    && value.len() <= 128
10894                    && value.chars().enumerate().all(|(index, char)| {
10895                        char.is_ascii_alphanumeric()
10896                            || (index > 0 && matches!(char, '.' | '_' | ':' | '-'))
10897                    })
10898            })
10899        || claim.get("payload_sha256").and_then(Value::as_str) != Some(payload_hash)
10900        || claim.get("clear_sha256").and_then(Value::as_str) != Some(clear_hash)
10901        || !claim_object
10902            .get("control_revision")
10903            .and_then(Value::as_str)
10904            .is_some_and(is_sha256)
10905        || submitted_at.is_none_or(|value| {
10906            chrono::DateTime::parse_from_rfc3339(value).is_err()
10907                || proposal.get("submitted_at").and_then(Value::as_str) != Some(value)
10908        })
10909        || !proposal
10910            .get("state")
10911            .and_then(Value::as_str)
10912            .is_some_and(|value| matches!(value, "pending" | "accepted" | "rejected" | "expired"))
10913        || proposal
10914            .get("expires_at")
10915            .and_then(Value::as_str)
10916            .is_none_or(|value| chrono::DateTime::parse_from_rfc3339(value).is_err())
10917        || proposal
10918            .get("proposer")
10919            .and_then(Value::as_object)
10920            .and_then(|value| value.get("class"))
10921            .and_then(Value::as_str)
10922            != actor_class
10923    {
10924        return Err(invalid_feed(
10925            "proposal submission claim does not bind the verified proposal",
10926        ));
10927    }
10928    let changes_b64 = proposal
10929        .get("changes_base64")
10930        .and_then(Value::as_str)
10931        .ok_or_else(|| invalid_feed("proposal has no changeset"))?;
10932    let changes_bytes = STANDARD
10933        .decode(changes_b64)
10934        .map_err(|_| invalid_feed("proposal changeset is not base64"))?;
10935    let changes: Value = serde_json::from_slice(&changes_bytes)
10936        .map_err(|_| invalid_feed("proposal changeset is not JSON"))?;
10937    if crate::linkmd_v2::canonical_bytes(&changes)
10938        .map_err(|error| invalid_feed(error.to_string()))?
10939        != changes_bytes
10940        || changes.get("v").and_then(Value::as_u64) != Some(2)
10941        || !changes.get("operations").is_some_and(Value::is_array)
10942    {
10943        return Err(invalid_feed("proposal changeset is not canonical v2"));
10944    }
10945    let blob_values = proposal
10946        .get("blobs")
10947        .and_then(Value::as_array)
10948        .ok_or_else(|| invalid_feed("proposal has no blob declarations"))?;
10949    let mut blobs = Vec::with_capacity(blob_values.len());
10950    let mut descriptor_blobs = Vec::with_capacity(blob_values.len());
10951    let mut prior_hash: Option<String> = None;
10952    for item in blob_values {
10953        let hash = item
10954            .get("sha256")
10955            .and_then(Value::as_str)
10956            .filter(|hash| is_sha256(hash))
10957            .ok_or_else(|| invalid_feed("proposal blob has no address"))?;
10958        let bytes = item
10959            .get("bytes")
10960            .and_then(Value::as_u64)
10961            .filter(|bytes| *bytes <= MAX_STORE_BYTES)
10962            .ok_or_else(|| invalid_feed("proposal blob has an invalid size"))?;
10963        if prior_hash.as_deref().is_some_and(|prior| prior >= hash) {
10964            return Err(invalid_feed(
10965                "proposal blob declarations are not unique and sorted",
10966            ));
10967        }
10968        prior_hash = Some(hash.to_string());
10969        let endpoint = item
10970            .get("endpoint")
10971            .and_then(Value::as_str)
10972            .ok_or_else(|| invalid_feed("proposal blob has no endpoint"))?;
10973        let expected_endpoint = format!(
10974            "/api/hub/brains/{}/v2/proposals/{proposal_id}/blob?sha256={hash}",
10975            head.brain_id
10976        );
10977        if endpoint != expected_endpoint {
10978            return Err(invalid_feed("proposal blob endpoint is not origin-bound"));
10979        }
10980        descriptor_blobs.push(json!({ "bytes": bytes, "sha256": hash }));
10981        blobs.push((hash.to_string(), bytes, endpoint.to_string()));
10982    }
10983    let descriptor = json!({
10984        "base": proposal.get("base").cloned().unwrap_or(Value::Null),
10985        "blobs": descriptor_blobs,
10986        "changes_base64": changes_b64,
10987        "rebase": proposal.get("rebase").cloned().unwrap_or(Value::Null),
10988        "v": 2,
10989    });
10990    let descriptor_bytes = crate::linkmd_v2::canonical_bytes(&descriptor)
10991        .map_err(|error| invalid_feed(error.to_string()))?;
10992    if content_sha256(&descriptor_bytes) != clear_hash {
10993        return Err(invalid_feed(
10994            "proposal clear payload differs from its signed submission claim",
10995        ));
10996    }
10997    Ok(VerifiedV2Proposal {
10998        value,
10999        changes,
11000        blobs,
11001    })
11002}
11003
11004pub fn proposal_list(
11005    cfg: &HubConfig,
11006    brain: &str,
11007    state: &str,
11008    after: Option<&str>,
11009    limit: usize,
11010) -> LinkResult<Value> {
11011    require_safe_ref(brain)?;
11012    if !matches!(state, "pending" | "accepted" | "rejected" | "expired") {
11013        return Err(invalid_feed("proposal state is invalid"));
11014    }
11015    if after.is_some_and(|value| !crate::ulid::is_ulid(value)) {
11016        return Err(invalid_feed("proposal cursor is invalid"));
11017    }
11018    let head = v2_verified_head(cfg, brain)?
11019        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11020    let path = format!(
11021        "/api/hub/brains/{}/v2/proposals?state={state}&limit={}{}",
11022        head.brain_id,
11023        limit.clamp(1, 100),
11024        after.map_or_else(String::new, |value| format!("&after={value}"))
11025    );
11026    ensure_ok(
11027        request_capped(
11028            cfg,
11029            "GET",
11030            &path,
11031            None,
11032            Auth::Required,
11033            MAX_FEED_RESPONSE_BYTES,
11034        )?,
11035        "v2 proposal list",
11036    )
11037}
11038
11039pub fn proposal_show(cfg: &HubConfig, brain: &str, proposal_id: &str) -> LinkResult<Value> {
11040    require_safe_ref(brain)?;
11041    let head = v2_verified_head(cfg, brain)?
11042        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11043    Ok(verified_v2_proposal(cfg, &head, proposal_id)?.value)
11044}
11045
11046pub fn proposal_reject(
11047    cfg: &HubConfig,
11048    brain: &str,
11049    proposal_id: &str,
11050    mutation_id: &str,
11051    reason: &str,
11052) -> LinkResult<Value> {
11053    require_safe_ref(brain)?;
11054    require_proposal_id(proposal_id)?;
11055    let head = v2_verified_head(cfg, brain)?
11056        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11057    let _ = verified_v2_proposal(cfg, &head, proposal_id)?;
11058    let body = json!({
11059        "mutation_id": mutation_id,
11060        "control_revision": head.control_revision,
11061        "reason": reason,
11062    });
11063    let path = format!(
11064        "/api/hub/brains/{}/v2/proposals/{proposal_id}",
11065        head.brain_id
11066    );
11067    ensure_ok(
11068        request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
11069        "v2 proposal rejection",
11070    )
11071}
11072
11073pub fn proposal_accept_exact(
11074    cfg: &HubConfig,
11075    brain: &str,
11076    proposal_id: &str,
11077    mutation_id: &str,
11078    reason: &str,
11079) -> LinkResult<Value> {
11080    require_safe_ref(brain)?;
11081    require_proposal_id(proposal_id)?;
11082    let head = v2_verified_head(cfg, brain)?
11083        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11084    let proposal = verified_v2_proposal(cfg, &head, proposal_id)?;
11085    let operations = proposal
11086        .changes
11087        .get("operations")
11088        .and_then(Value::as_array)
11089        .cloned()
11090        .ok_or_else(|| invalid_feed("proposal changeset has no operations"))?;
11091    if operations.is_empty() || operations.len() > MAX_PUSH_FILES {
11092        return Err(invalid_feed("proposal operation count is invalid"));
11093    }
11094    let mut downloaded = std::collections::BTreeMap::new();
11095    for (hash, bytes, endpoint) in &proposal.blobs {
11096        let body = ensure_raw_ok(
11097            request_raw(cfg, "GET", endpoint, None, Auth::Required, *bytes)?,
11098            "v2 proposal blob",
11099        )?;
11100        if body.len() as u64 != *bytes || content_sha256(&body) != *hash {
11101            return Err(invalid_feed("proposal blob does not match its declaration"));
11102        }
11103        downloaded.insert(hash.clone(), body);
11104    }
11105    let remote = files_for_v2_view(
11106        &head,
11107        v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
11108    );
11109    let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
11110    let mut expected_candidate = remote.clone();
11111    let mut expected_candidate_assets = remote_assets;
11112    for operation in &operations {
11113        let op = operation
11114            .get("op")
11115            .and_then(Value::as_str)
11116            .ok_or_else(|| invalid_feed("proposal operation has no kind"))?;
11117        match op {
11118            "put" | "restore" => {
11119                let path = operation
11120                    .get("path")
11121                    .and_then(Value::as_str)
11122                    .ok_or_else(|| invalid_feed("proposal write has no path"))?;
11123                crate::linkmd_v2::normalize_path(path)
11124                    .map_err(|error| invalid_feed(error.to_string()))?;
11125                let hash = operation
11126                    .get("blob")
11127                    .and_then(Value::as_str)
11128                    .filter(|hash| is_sha256(hash))
11129                    .ok_or_else(|| invalid_feed("proposal write has no blob"))?;
11130                let bytes = operation
11131                    .get("bytes")
11132                    .and_then(Value::as_u64)
11133                    .ok_or_else(|| invalid_feed("proposal write has no size"))?;
11134                expected_candidate.insert(
11135                    path.to_string(),
11136                    V2BaselineFile {
11137                        sha256: hash.to_string(),
11138                        bytes,
11139                        proof: None,
11140                    },
11141                );
11142            }
11143            "delete" | "withdraw_from_hosting" => {
11144                let path = operation
11145                    .get("path")
11146                    .and_then(Value::as_str)
11147                    .ok_or_else(|| invalid_feed("proposal removal has no path"))?;
11148                crate::linkmd_v2::normalize_path(path)
11149                    .map_err(|error| invalid_feed(error.to_string()))?;
11150                expected_candidate.remove(path);
11151            }
11152            "rename" => {
11153                let from = operation
11154                    .get("from")
11155                    .and_then(Value::as_str)
11156                    .ok_or_else(|| invalid_feed("proposal rename has no source"))?;
11157                let to = operation
11158                    .get("to")
11159                    .and_then(Value::as_str)
11160                    .ok_or_else(|| invalid_feed("proposal rename has no destination"))?;
11161                crate::linkmd_v2::normalize_path(from)
11162                    .and_then(|_| crate::linkmd_v2::normalize_path(to))
11163                    .map_err(|error| invalid_feed(error.to_string()))?;
11164                let hash = operation
11165                    .get("blob")
11166                    .and_then(Value::as_str)
11167                    .filter(|hash| is_sha256(hash))
11168                    .ok_or_else(|| invalid_feed("proposal rename has no blob"))?;
11169                let bytes = operation
11170                    .get("bytes")
11171                    .and_then(Value::as_u64)
11172                    .ok_or_else(|| invalid_feed("proposal rename has no size"))?;
11173                expected_candidate.remove(from);
11174                expected_candidate.insert(
11175                    to.to_string(),
11176                    V2BaselineFile {
11177                        sha256: hash.to_string(),
11178                        bytes,
11179                        proof: None,
11180                    },
11181                );
11182            }
11183            "asset_delete" => {
11184                let path = operation
11185                    .get("path")
11186                    .and_then(Value::as_str)
11187                    .ok_or_else(|| invalid_feed("proposal asset delete has no path"))?;
11188                expected_candidate_assets.remove(path);
11189            }
11190            "asset_withdraw" => {
11191                let path = operation
11192                    .get("path")
11193                    .and_then(Value::as_str)
11194                    .ok_or_else(|| invalid_feed("proposal asset withdrawal has no path"))?;
11195                let asset = expected_candidate_assets
11196                    .get_mut(path)
11197                    .ok_or_else(|| invalid_feed("proposal withdraws an unknown asset"))?;
11198                asset.disposition = "withheld".to_string();
11199                asset.leaf_hash.clear();
11200            }
11201            "asset_put" | "asset_resume" => {
11202                let path = operation
11203                    .get("path")
11204                    .and_then(Value::as_str)
11205                    .ok_or_else(|| invalid_feed("proposal asset write has no path"))?;
11206                let asset = operation
11207                    .get("asset")
11208                    .and_then(Value::as_object)
11209                    .ok_or_else(|| invalid_feed("proposal asset write has no value"))?;
11210                let blob_sha256 = asset
11211                    .get("blob_sha256")
11212                    .and_then(Value::as_str)
11213                    .filter(|hash| is_sha256(hash))
11214                    .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
11215                let bytes = asset
11216                    .get("bytes")
11217                    .and_then(Value::as_u64)
11218                    .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
11219                let media_type = asset
11220                    .get("media_type")
11221                    .and_then(Value::as_str)
11222                    .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
11223                let wrappers = asset
11224                    .get("wrappers")
11225                    .and_then(Value::as_array)
11226                    .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
11227                    .iter()
11228                    .map(|wrapper| {
11229                        wrapper
11230                            .as_str()
11231                            .map(str::to_string)
11232                            .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
11233                    })
11234                    .collect::<LinkResult<Vec<_>>>()?;
11235                let required = asset
11236                    .get("required")
11237                    .and_then(Value::as_bool)
11238                    .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
11239                let disposition = asset
11240                    .get("disposition")
11241                    .and_then(Value::as_str)
11242                    .filter(|value| matches!(*value, "hosted" | "withheld"))
11243                    .ok_or_else(|| invalid_feed("proposal asset disposition is invalid"))?;
11244                expected_candidate_assets.insert(
11245                    path.to_string(),
11246                    V2BaselineAsset {
11247                        blob_sha256: blob_sha256.to_string(),
11248                        bytes,
11249                        media_type: media_type.to_string(),
11250                        wrappers,
11251                        required,
11252                        disposition: disposition.to_string(),
11253                        leaf_hash: String::new(),
11254                    },
11255                );
11256            }
11257            _ => return Err(invalid_feed("proposal operation kind is unsupported")),
11258        }
11259    }
11260    let base = head.pointer.as_ref().map(|pointer| {
11261        json!({
11262            "seq": pointer.seq,
11263            "commit_hash": pointer.commit_hash,
11264            "content_root": pointer.content_root,
11265            "asset_root": pointer.asset_root,
11266        })
11267    });
11268    let mut body = json!({
11269        "mutation_id": mutation_id,
11270        "base": base,
11271        "rebase": "strict",
11272        "reason": reason,
11273        "operations": operations,
11274        "blobs": downloaded
11275            .iter()
11276            .map(|(sha256, bytes)| json!({
11277                "sha256": sha256,
11278                "bytes": bytes.len(),
11279                "content_base64": STANDARD.encode(bytes),
11280            }))
11281            .collect::<Vec<_>>(),
11282        "proposal_id": proposal_id,
11283        "proposal_mode": "exact",
11284    });
11285    let changed_bytes = downloaded.values().try_fold(0_usize, |total, bytes| {
11286        total
11287            .checked_add(bytes.len())
11288            .ok_or_else(|| LinkError::PushTooLarge {
11289                detail: "proposal changed-byte total overflow".to_string(),
11290            })
11291    })?;
11292    if changed_bytes > 3 * 1024 * 1024 || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
11293        let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
11294        for operation in &operations {
11295            let Some(kind) = operation.get("op").and_then(Value::as_str) else {
11296                return Err(invalid_feed("proposal upload operation has no kind"));
11297            };
11298            let hash = match kind {
11299                "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
11300                "asset_put" | "asset_resume" => operation
11301                    .get("asset")
11302                    .and_then(|asset| asset.get("blob_sha256"))
11303                    .and_then(Value::as_str),
11304                _ => None,
11305            };
11306            let Some(hash) = hash else { continue };
11307            let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
11308            if kind == "rename" {
11309                for field in ["from", "to"] {
11310                    coordinates.insert(
11311                        operation
11312                            .get(field)
11313                            .and_then(Value::as_str)
11314                            .ok_or_else(|| invalid_feed("proposal rename has no coordinate"))?
11315                            .to_string(),
11316                    );
11317                }
11318            } else {
11319                let path = operation
11320                    .get("path")
11321                    .and_then(Value::as_str)
11322                    .ok_or_else(|| invalid_feed("proposal upload has no coordinate"))?;
11323                coordinates.insert(if kind.starts_with("asset_") {
11324                    format!("assets/{path}")
11325                } else {
11326                    path.to_string()
11327                });
11328            }
11329        }
11330        let declarations = downloaded
11331            .iter()
11332            .map(|(sha256, bytes)| {
11333                json!({
11334                    "sha256": sha256,
11335                    "bytes": bytes.len(),
11336                    "coordinates": coordinates_by_hash
11337                        .get(sha256)
11338                        .into_iter()
11339                        .flatten()
11340                        .collect::<Vec<_>>(),
11341                })
11342            })
11343            .collect::<Vec<_>>();
11344        let mut items: Vec<Value> = Vec::with_capacity(downloaded.len());
11345        for batch in batch_upload_declarations(declarations) {
11346            let reserved = ensure_ok(
11347                request(
11348                    cfg,
11349                    "POST",
11350                    &format!("/api/hub/brains/{}/v2/uploads", head.brain_id),
11351                    Some(&json!({ "blobs": batch })),
11352                    Auth::Required,
11353                )?,
11354                "prepare proposal blob transport",
11355            )?;
11356            let reserved_items = reserved
11357                .get("uploads")
11358                .and_then(Value::as_array)
11359                .ok_or_else(|| invalid_feed("proposal upload reservation has no items"))?;
11360            items.extend(reserved_items.iter().cloned());
11361        }
11362        if items.len() != downloaded.len() {
11363            return Err(invalid_feed("proposal upload reservation changed the set"));
11364        }
11365        let mut references = Vec::with_capacity(items.len());
11366        for item in items {
11367            let hash = item
11368                .get("sha256")
11369                .and_then(Value::as_str)
11370                .ok_or_else(|| invalid_feed("proposal upload reservation has no hash"))?;
11371            let bytes = downloaded
11372                .get(hash)
11373                .ok_or_else(|| invalid_feed("proposal upload reservation introduced a blob"))?;
11374            let reservation_id = item
11375                .get("reservation_id")
11376                .and_then(Value::as_str)
11377                .filter(|id| crate::ulid::is_ulid(id))
11378                .ok_or_else(|| invalid_feed("proposal upload reservation has no id"))?;
11379            let expected_coordinates = coordinates_by_hash
11380                .get(hash)
11381                .ok_or_else(|| invalid_feed("proposal upload has no coordinate binding"))?;
11382            let returned_coordinates = item
11383                .get("coordinates")
11384                .and_then(Value::as_array)
11385                .ok_or_else(|| invalid_feed("proposal upload reservation has no coordinates"))?;
11386            if returned_coordinates.len() != expected_coordinates.len()
11387                || returned_coordinates
11388                    .iter()
11389                    .zip(expected_coordinates)
11390                    .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
11391            {
11392                return Err(invalid_feed(
11393                    "proposal upload reservation changed its coordinates",
11394                ));
11395            }
11396            match item.get("status").and_then(Value::as_str) {
11397                Some("upload") => put_presigned(
11398                    cfg,
11399                    item.get("url")
11400                        .and_then(Value::as_str)
11401                        .ok_or_else(|| invalid_feed("proposal upload has no URL"))?,
11402                    item.get("headers").unwrap_or(&Value::Null),
11403                    bytes,
11404                )?,
11405                Some("already_present") => {}
11406                _ => return Err(invalid_feed("proposal upload status is invalid")),
11407            }
11408            references.push(json!({
11409                "sha256": hash,
11410                "bytes": bytes.len(),
11411                "reservation_id": reservation_id,
11412            }));
11413        }
11414        body["blobs"] = Value::Array(references);
11415    }
11416    if body.to_string().len() > MAX_PUSH_BYTES {
11417        return Err(LinkError::PushTooLarge {
11418            detail: "proposal operation metadata exceeds the commit request cap".to_string(),
11419        });
11420    }
11421    let path = format!("/api/hub/brains/{}/v2/commits", head.brain_id);
11422    let mut result = ensure_ok(
11423        request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11424        "exact proposal acceptance",
11425    )?;
11426    let mut candidate_hub_signer = None;
11427    if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
11428        let request_id = result
11429            .get("request_id")
11430            .and_then(Value::as_str)
11431            .ok_or_else(|| invalid_feed("proposal acceptance has no request id"))?
11432            .to_string();
11433        let challenge = result
11434            .get("signing_challenge")
11435            .ok_or_else(|| invalid_feed("proposal acceptance has no signing challenge"))?;
11436        let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
11437            cfg,
11438            &head,
11439            &expected_candidate,
11440            &expected_candidate_assets,
11441            mutation_id,
11442            &body,
11443            challenge,
11444        )?;
11445        body["signing_challenge_id"] = Value::String(challenge_id);
11446        body["signature_base64url"] = Value::String(signature);
11447        candidate_hub_signer = Some(actor_signer);
11448        result = ensure_ok(
11449            request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
11450            "signed exact proposal acceptance",
11451        )?;
11452    }
11453    let refreshed = v2_verified_head(cfg, brain)?
11454        .ok_or_else(|| invalid_feed("v2 head disappeared after proposal acceptance"))?;
11455    if candidate_hub_signer
11456        .as_ref()
11457        .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
11458        || refreshed
11459            .pointer
11460            .as_ref()
11461            .map(|pointer| pointer.commit_hash.as_str())
11462            != result.get("commit_hash").and_then(Value::as_str)
11463        || result.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
11464        || result.get("proposal_state").and_then(Value::as_str) != Some("accepted")
11465    {
11466        return Err(LinkError::RemoteAdvancedDuringSync);
11467    }
11468    accept_v2_head(cfg, &refreshed)?;
11469    Ok(result)
11470}
11471
11472// ─────────────────────────────────────────────────────────────────────────────
11473// propose — write without trust: evidence into the owner's inbox
11474// ─────────────────────────────────────────────────────────────────────────────
11475
11476/// Submit `body` to the published site `handle`, addressed to its app page
11477/// `app` (a page that declares the `write-inbox` capability). Deliberately
11478/// unauthenticated — this is the cross-party door; the submission lands as
11479/// *evidence* in the owner's `sources/inbox/`, never as truth, and the
11480/// owner's curator accepts or rejects it. Returns the hub's `{id, path}`
11481/// receipt.
11482pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
11483    require_valid_handle(handle)?;
11484    if body.len() as u64 > MAX_PROPOSE_BYTES {
11485        return Err(LinkError::ProposeTooLarge {
11486            bytes: body.len() as u64,
11487        });
11488    }
11489    let payload = json!({ "app": app, "body": body });
11490    // A ULID-shaped target is a bare brain address (link.md §7.4's
11491    // generalization): the brain inbox door, open on public brains, where a
11492    // configured credential earns a bigger actor-class budget. Anything else
11493    // is a published-site handle: that door is unauthenticated by design.
11494    let (path, auth) = if crate::ulid::is_ulid(handle) {
11495        (format!("/api/hub/brains/{handle}/inbox"), Auth::Optional)
11496    } else {
11497        (format!("/api/hub/sites/{handle}/inbox"), Auth::None)
11498    };
11499    ensure_ok(
11500        request(cfg, "POST", &path, Some(&payload), auth)?,
11501        "propose",
11502    )
11503}
11504
11505// ─────────────────────────────────────────────────────────────────────────────
11506// subscribe — follow feed-head movement
11507// ─────────────────────────────────────────────────────────────────────────────
11508
11509/// One observation of a brain's feed head.
11510#[derive(Debug, serde::Serialize)]
11511pub struct Head {
11512    /// The brain id.
11513    pub brain: String,
11514    /// The hub's durable feed cursor — advances on every accepted write.
11515    pub seq: u64,
11516    /// The hub's `updatedAt` for the brain, when present.
11517    #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
11518    pub updated_at: Option<String>,
11519    /// SHA-256 of the exact signed head entry.
11520    #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
11521    pub feed_hash: Option<String>,
11522    /// Whether the head entry's content hash, identity, and Ed25519 signature
11523    /// were verified locally. Path-scoped grants get head movement only.
11524    pub verified: bool,
11525}
11526
11527struct BoundedVecVisitor<T, const MAX: usize> {
11528    label: &'static str,
11529    marker: std::marker::PhantomData<T>,
11530}
11531
11532impl<'de, T, const MAX: usize> serde::de::Visitor<'de> for BoundedVecVisitor<T, MAX>
11533where
11534    T: Deserialize<'de>,
11535{
11536    type Value = Vec<T>;
11537
11538    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
11539        write!(formatter, "at most {MAX} {}", self.label)
11540    }
11541
11542    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
11543    where
11544        A: serde::de::SeqAccess<'de>,
11545    {
11546        if sequence.size_hint().is_some_and(|size| size > MAX) {
11547            return Err(serde::de::Error::custom(format!(
11548                "{} exceeds the {MAX}-item limit",
11549                self.label
11550            )));
11551        }
11552        let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX));
11553        while let Some(value) = sequence.next_element()? {
11554            if values.len() == MAX {
11555                return Err(serde::de::Error::custom(format!(
11556                    "{} exceeds the {MAX}-item limit",
11557                    self.label
11558                )));
11559            }
11560            values.push(value);
11561        }
11562        Ok(values)
11563    }
11564}
11565
11566fn deserialize_bounded_vec<'de, D, T, const MAX: usize>(
11567    deserializer: D,
11568    label: &'static str,
11569) -> Result<Vec<T>, D::Error>
11570where
11571    D: serde::Deserializer<'de>,
11572    T: Deserialize<'de>,
11573{
11574    deserializer.deserialize_seq(BoundedVecVisitor::<T, MAX> {
11575        label,
11576        marker: std::marker::PhantomData,
11577    })
11578}
11579
11580fn deserialize_feed_files<'de, D>(deserializer: D) -> Result<Vec<FeedFile>, D::Error>
11581where
11582    D: serde::Deserializer<'de>,
11583{
11584    deserialize_bounded_vec::<D, FeedFile, MAX_PUSH_FILES>(deserializer, "feed files")
11585}
11586
11587fn deserialize_removed_paths<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
11588where
11589    D: serde::Deserializer<'de>,
11590{
11591    deserialize_bounded_vec::<D, String, MAX_PUSH_FILES>(deserializer, "removed paths")
11592}
11593
11594fn deserialize_previous_identities<'de, D>(
11595    deserializer: D,
11596) -> Result<Vec<PreviousIdentity>, D::Error>
11597where
11598    D: serde::Deserializer<'de>,
11599{
11600    deserialize_bounded_vec::<D, PreviousIdentity, MAX_IDENTITY_ROTATIONS>(
11601        deserializer,
11602        "previous identities",
11603    )
11604}
11605
11606fn deserialize_rotations<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
11607where
11608    D: serde::Deserializer<'de>,
11609{
11610    deserialize_bounded_vec::<D, String, MAX_IDENTITY_ROTATIONS>(
11611        deserializer,
11612        "rotation statements",
11613    )
11614}
11615
11616fn deserialize_feed_items<'de, D>(deserializer: D) -> Result<Vec<FeedItem>, D::Error>
11617where
11618    D: serde::Deserializer<'de>,
11619{
11620    deserialize_bounded_vec::<D, FeedItem, FEED_PAGE_LIMIT>(deserializer, "feed entries")
11621}
11622
11623#[derive(Debug, Clone, Deserialize, Serialize)]
11624struct FeedFile {
11625    path: String,
11626    sha256: String,
11627    bytes: u64,
11628}
11629
11630#[cfg(test)]
11631#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11632enum V1DisclosureError {
11633    DuplicateFile,
11634    DuplicateRemoved,
11635    PushManifestMismatch,
11636    EditMissingChange,
11637    EditFalseFile,
11638    RemovedMismatch,
11639}
11640
11641/// Validate wire-profile-v1 `files`/`removed` disclosure semantics against
11642/// the exact previous and resulting pack manifests. `edit` accepts the
11643/// historical superset form while still requiring every actual changed path.
11644#[cfg(test)]
11645fn verify_v1_manifest_disclosure(
11646    kind: &str,
11647    previous: &[FeedFile],
11648    resulting: &[FeedFile],
11649    files: &[FeedFile],
11650    removed: &[String],
11651) -> Result<(), V1DisclosureError> {
11652    fn as_map(
11653        files: &[FeedFile],
11654    ) -> Result<std::collections::BTreeMap<&str, (&str, u64)>, V1DisclosureError> {
11655        let mut result = std::collections::BTreeMap::new();
11656        for file in files {
11657            if result
11658                .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
11659                .is_some()
11660            {
11661                return Err(V1DisclosureError::DuplicateFile);
11662            }
11663        }
11664        Ok(result)
11665    }
11666    let previous = as_map(previous)?;
11667    let resulting = as_map(resulting)?;
11668    let disclosed = as_map(files)?;
11669    let removed_set: std::collections::BTreeSet<&str> =
11670        removed.iter().map(String::as_str).collect();
11671    if removed_set.len() != removed.len() {
11672        return Err(V1DisclosureError::DuplicateRemoved);
11673    }
11674    let expected_removed: std::collections::BTreeSet<&str> = previous
11675        .keys()
11676        .copied()
11677        .filter(|path| !resulting.contains_key(path))
11678        .collect();
11679    if removed_set != expected_removed {
11680        return Err(V1DisclosureError::RemovedMismatch);
11681    }
11682    if kind == "push" {
11683        return if disclosed == resulting {
11684            Ok(())
11685        } else {
11686            Err(V1DisclosureError::PushManifestMismatch)
11687        };
11688    }
11689    if kind != "edit" {
11690        return Err(V1DisclosureError::EditFalseFile);
11691    }
11692    if disclosed
11693        .iter()
11694        .any(|(path, value)| resulting.get(path) != Some(value))
11695    {
11696        return Err(V1DisclosureError::EditFalseFile);
11697    }
11698    for (path, value) in &resulting {
11699        if previous.get(path) != Some(value) && !disclosed.contains_key(path) {
11700            return Err(V1DisclosureError::EditMissingChange);
11701        }
11702    }
11703    Ok(())
11704}
11705
11706#[derive(Debug, Clone, Deserialize, Serialize)]
11707struct FeedEntry {
11708    v: u8,
11709    seq: u64,
11710    ts: String,
11711    brain: String,
11712    public_key: String,
11713    kind: String,
11714    op: String,
11715    pack_sha256: String,
11716    #[serde(deserialize_with = "deserialize_feed_files")]
11717    files: Vec<FeedFile>,
11718    #[serde(deserialize_with = "deserialize_removed_paths")]
11719    removed: Vec<String>,
11720    prev_entry_hash: Option<String>,
11721    sig: String,
11722}
11723
11724#[derive(Serialize)]
11725struct UnsignedFeedEntry<'a> {
11726    v: u8,
11727    seq: u64,
11728    ts: &'a str,
11729    brain: &'a str,
11730    public_key: &'a str,
11731    kind: &'a str,
11732    op: &'a str,
11733    pack_sha256: &'a str,
11734    files: &'a [FeedFile],
11735    removed: &'a [String],
11736    prev_entry_hash: &'a Option<String>,
11737}
11738
11739#[derive(Debug, Clone, Deserialize, Serialize)]
11740struct FeedItem {
11741    hash: String,
11742    entry: FeedEntry,
11743}
11744
11745#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
11746struct FeedIdentity {
11747    fingerprint: String,
11748    #[serde(rename = "publicKeySpki")]
11749    public_key_spki: String,
11750    /// Rotation history (link.md §9.1): identities this brain previously
11751    /// signed as. Entries verify against current OR previous — rotation
11752    /// never invalidates history.
11753    #[serde(default, deserialize_with = "deserialize_previous_identities")]
11754    previous: Vec<PreviousIdentity>,
11755    /// Exact normative rotation statements, oldest first. A list of previous
11756    /// public keys without these old-key signatures is not a trust chain.
11757    #[serde(default, deserialize_with = "deserialize_rotations")]
11758    rotations: Vec<String>,
11759}
11760
11761#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
11762struct PreviousIdentity {
11763    fingerprint: String,
11764    #[serde(rename = "publicKeySpki")]
11765    public_key_spki: String,
11766}
11767
11768#[derive(Debug, Deserialize)]
11769struct FeedResponse {
11770    #[serde(rename = "headSeq")]
11771    head_seq: u64,
11772    #[serde(rename = "feedHash")]
11773    feed_hash: Option<String>,
11774    identity: Option<FeedIdentity>,
11775    #[serde(deserialize_with = "deserialize_feed_items")]
11776    entries: Vec<FeedItem>,
11777    #[serde(rename = "scopeLimited")]
11778    scope_limited: bool,
11779}
11780
11781#[derive(Debug, Deserialize, Serialize)]
11782#[serde(deny_unknown_fields)]
11783struct RotationStatement {
11784    v: u8,
11785    op: String,
11786    brain: String,
11787    public_key: String,
11788    new_brain: String,
11789    new_public_key: String,
11790    prior_head_seq: u64,
11791    prior_feed_hash: Option<String>,
11792    ts: String,
11793    sig: String,
11794}
11795
11796#[derive(Debug, Clone, Deserialize, Serialize)]
11797struct TrustState {
11798    v: u8,
11799    origin: String,
11800    /// The exact caller-visible ref whose resolution this checkpoint pins.
11801    /// Slugs/handles are mutable names at the hub; once observed, they may not
11802    /// silently resolve to a different canonical brain.
11803    #[serde(default)]
11804    requested: String,
11805    /// Canonical hub brain id returned for `requested`.
11806    brain: String,
11807    /// Federation home origin when this ref was learned from a registry.
11808    /// Once observed, the registry cannot silently relocate the same handle.
11809    #[serde(default, skip_serializing_if = "Option::is_none")]
11810    home: Option<String>,
11811    anchor: String,
11812    current: String,
11813    #[serde(rename = "headSeq")]
11814    head_seq: u64,
11815    #[serde(rename = "feedHash")]
11816    feed_hash: Option<String>,
11817    /// Exact accepted old-key-signed rotation statements, oldest first. v2
11818    /// checkpoints require this vector to be an immutable prefix of every
11819    /// subsequently served identity chain.
11820    #[serde(default)]
11821    rotations: Vec<String>,
11822    /// Hub pointer-signing identity pinned on first v2 observation. This is
11823    /// separate from the brain key, which signs commits.
11824    #[serde(default, skip_serializing_if = "Option::is_none")]
11825    hub_signer: Option<String>,
11826    /// Explicit transport profile. Older accepted non-empty v2 checkpoints
11827    /// are recognized by their pinned hub signer during one-way migration.
11828    #[serde(default, skip_serializing_if = "Option::is_none")]
11829    protocol_profile: Option<String>,
11830}
11831
11832fn accepted_as_v2(state: &TrustState) -> bool {
11833    state.protocol_profile.as_deref() == Some("link-v2") || state.hub_signer.is_some()
11834}
11835
11836fn has_accepted_v2_ref(cfg: &HubConfig, requested: &str) -> LinkResult<bool> {
11837    let directory = open_trust_dir(cfg)?;
11838    if load_trust_in(cfg, &directory, requested)?.is_some_and(|state| accepted_as_v2(&state)) {
11839        return Ok(true);
11840    }
11841    let Some(alias) = load_alias_in(cfg, &directory, requested)? else {
11842        return Ok(false);
11843    };
11844    Ok(load_trust_in(cfg, &directory, &alias.brain)?.is_some_and(|state| accepted_as_v2(&state)))
11845}
11846
11847#[derive(Debug, Clone, Deserialize, Serialize)]
11848struct AliasBinding {
11849    v: u8,
11850    origin: String,
11851    requested: String,
11852    brain: String,
11853    #[serde(default, skip_serializing_if = "Option::is_none")]
11854    home: Option<String>,
11855}
11856
11857struct VerifiedRemote {
11858    head: Head,
11859    identity: Option<FeedIdentity>,
11860    head_entry: Option<FeedItem>,
11861    /// Present when the caller requested and verified the complete chain.
11862    entries: Vec<FeedItem>,
11863    anchor: Option<String>,
11864}
11865
11866fn invalid_feed(message: impl Into<String>) -> LinkError {
11867    LinkError::InvalidFeed {
11868        message: message.into(),
11869    }
11870}
11871
11872fn is_sha256(value: &str) -> bool {
11873    value.len() == 64
11874        && value
11875            .bytes()
11876            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
11877}
11878
11879fn identity_fingerprint(public_key_spki: &str) -> LinkResult<String> {
11880    let der = URL_SAFE_NO_PAD
11881        .decode(public_key_spki)
11882        .map_err(|_| invalid_feed("identity public key is not base64url"))?;
11883    if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
11884        return Err(invalid_feed(
11885            "identity public key is not a valid Ed25519 SPKI",
11886        ));
11887    }
11888    Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(&der)))
11889}
11890
11891/// Verify the hub's current identity and every old-key-signed rotation that
11892/// leads from the original TOFU anchor to it. `previous` alone is metadata,
11893/// never authority.
11894fn verify_identity_chain(
11895    identity: &FeedIdentity,
11896    pinned: Option<&TrustState>,
11897) -> LinkResult<String> {
11898    if identity.previous.len() > MAX_IDENTITY_ROTATIONS
11899        || identity.rotations.len() > MAX_IDENTITY_ROTATIONS
11900    {
11901        return Err(invalid_feed(
11902            "identity rotation history exceeds the client cap",
11903        ));
11904    }
11905    if identity.fingerprint != identity_fingerprint(&identity.public_key_spki)? {
11906        return Err(invalid_feed(
11907            "current identity fingerprint does not match its public key",
11908        ));
11909    }
11910    for previous in &identity.previous {
11911        if previous.fingerprint != identity_fingerprint(&previous.public_key_spki)? {
11912            return Err(invalid_feed(
11913                "previous identity fingerprint does not match its public key",
11914            ));
11915        }
11916    }
11917    if identity.rotations.len() != identity.previous.len() {
11918        return Err(invalid_feed(
11919            "identity history is missing an old-key-signed rotation statement",
11920        ));
11921    }
11922
11923    // The HTTP identity lists prior identities newest first. Rotation
11924    // statements are chronological, so verification walks the reversed list
11925    // and ends at the current identity.
11926    let mut chain: Vec<(&str, &str)> = identity
11927        .previous
11928        .iter()
11929        .rev()
11930        .map(|p| (p.fingerprint.as_str(), p.public_key_spki.as_str()))
11931        .collect();
11932    chain.push((&identity.fingerprint, &identity.public_key_spki));
11933
11934    for (index, raw) in identity.rotations.iter().enumerate() {
11935        let statement: RotationStatement = serde_json::from_str(raw)
11936            .map_err(|_| invalid_feed("rotation statement did not parse exactly"))?;
11937        let (old_fingerprint, old_spki) = chain[index];
11938        let (new_fingerprint, new_spki) = chain[index + 1];
11939        if statement.v != 1
11940            || statement.op != "rotate"
11941            || statement.brain != format!("ed25519:{old_fingerprint}")
11942            || statement.public_key != old_spki
11943            || statement.new_brain != format!("ed25519:{new_fingerprint}")
11944            || statement.new_public_key != new_spki
11945            || (statement.prior_head_seq == 0 && statement.prior_feed_hash.is_some())
11946            || (statement.prior_head_seq > 0
11947                && statement
11948                    .prior_feed_hash
11949                    .as_deref()
11950                    .is_none_or(|hash| !is_sha256(hash)))
11951        {
11952            return Err(invalid_feed(
11953                "rotation statement does not connect adjacent identities",
11954            ));
11955        }
11956        let unsigned = serde_json::to_string(&UnsignedRotation {
11957            v: statement.v,
11958            op: &statement.op,
11959            brain: &statement.brain,
11960            public_key: &statement.public_key,
11961            new_brain: &statement.new_brain,
11962            new_public_key: &statement.new_public_key,
11963            prior_head_seq: statement.prior_head_seq,
11964            prior_feed_hash: statement.prior_feed_hash.as_deref(),
11965            ts: statement.ts.clone(),
11966        })
11967        .map_err(|_| invalid_feed("could not canonicalize rotation statement"))?;
11968        let exact = format!(
11969            "{},\"sig\":\"{}\"}}",
11970            &unsigned[..unsigned.len() - 1],
11971            statement.sig
11972        );
11973        if exact != *raw {
11974            return Err(invalid_feed(
11975                "rotation statement is not in normative serialization",
11976            ));
11977        }
11978        let der = URL_SAFE_NO_PAD
11979            .decode(old_spki)
11980            .map_err(|_| invalid_feed("rotation public key is not base64url"))?;
11981        let signature = URL_SAFE_NO_PAD
11982            .decode(&statement.sig)
11983            .map_err(|_| invalid_feed("rotation signature is not base64url"))?;
11984        UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
11985            .verify(unsigned.as_bytes(), &signature)
11986            .map_err(|_| invalid_feed("rotation signature verification failed"))?;
11987        if index > 0 {
11988            let prior: RotationStatement = serde_json::from_str(&identity.rotations[index - 1])
11989                .map_err(|_| invalid_feed("prior rotation statement did not parse"))?;
11990            if statement.prior_head_seq < prior.prior_head_seq {
11991                return Err(invalid_feed("rotation feed boundaries move backward"));
11992            }
11993        }
11994    }
11995
11996    let anchor = format!("ed25519:{}", chain[0].0);
11997    let current = format!("ed25519:{}", identity.fingerprint);
11998    if let Some(pin) = pinned {
11999        if pin.anchor != anchor {
12000            return Err(invalid_feed(
12001                "served identity chain does not descend from the pinned anchor",
12002            ));
12003        }
12004        if !chain
12005            .iter()
12006            .any(|(fingerprint, _)| pin.current == format!("ed25519:{fingerprint}"))
12007        {
12008            return Err(invalid_feed(
12009                "served identity chain forked away from the last pinned identity",
12010            ));
12011        }
12012        if pin.current == current && pin.anchor != current && identity.rotations.is_empty() {
12013            return Err(invalid_feed("served identity discarded its rotation chain"));
12014        }
12015        if pin.v >= 2
12016            && (identity.rotations.len() < pin.rotations.len()
12017                || identity.rotations[..pin.rotations.len()] != pin.rotations)
12018        {
12019            return Err(invalid_feed(
12020                "served identity rewrote the locally accepted rotation history",
12021            ));
12022        }
12023    }
12024    Ok(anchor)
12025}
12026
12027fn verify_rotation_feed_boundaries(
12028    identity: &FeedIdentity,
12029    pinned: Option<&TrustState>,
12030    observed: &[FeedItem],
12031    advertised_seq: u64,
12032) -> LinkResult<()> {
12033    let mut chain: Vec<String> = identity
12034        .previous
12035        .iter()
12036        .rev()
12037        .map(|previous| format!("ed25519:{}", previous.fingerprint))
12038        .collect();
12039    chain.push(format!("ed25519:{}", identity.fingerprint));
12040    let pinned_index = pinned.and_then(|pin| chain.iter().position(|key| key == &pin.current));
12041
12042    for (index, raw) in identity.rotations.iter().enumerate() {
12043        let rotation: RotationStatement = serde_json::from_str(raw)
12044            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
12045        if rotation.prior_head_seq > advertised_seq {
12046            return Err(invalid_feed(
12047                "rotation claims a feed boundary beyond the advertised head",
12048            ));
12049        }
12050        if let (Some(pin), Some(pin_index)) = (pinned, pinned_index) {
12051            if index >= pin_index && rotation.prior_head_seq < pin.head_seq {
12052                return Err(invalid_feed(
12053                    "newly disclosed rotation predates the local feed checkpoint",
12054                ));
12055            }
12056        }
12057        let actual = if rotation.prior_head_seq == 0 {
12058            None
12059        } else if pinned.is_some_and(|pin| pin.head_seq == rotation.prior_head_seq) {
12060            pinned.and_then(|pin| pin.feed_hash.as_deref())
12061        } else {
12062            observed
12063                .iter()
12064                .find(|item| item.entry.seq == rotation.prior_head_seq)
12065                .map(|item| item.hash.as_str())
12066        };
12067        if let Some(actual) = actual {
12068            if rotation.prior_feed_hash.as_deref() != Some(actual) {
12069                return Err(invalid_feed(
12070                    "rotation statement does not commit the verified feed boundary",
12071                ));
12072            }
12073        } else if rotation.prior_head_seq == 0 {
12074            // The empty-feed boundary is represented by (0, null); there is
12075            // no entry hash to find in `observed`.
12076        } else if pinned.is_some_and(|pin| {
12077            pinned_index.is_some_and(|pin_index| index >= pin_index)
12078                || rotation.prior_head_seq >= pin.head_seq
12079        }) {
12080            return Err(invalid_feed(
12081                "rotation feed boundary was not present in the verified chain",
12082            ));
12083        }
12084    }
12085    Ok(())
12086}
12087
12088/// Once a client has checkpointed identity K, identities retired before K may
12089/// verify old history but can never regain authority over a later sequence.
12090/// This is also the safe migration rule for legacy v1 checkpoints that did not
12091/// persist the exact accepted rotation statements.
12092fn reject_retired_signer_after_checkpoint(
12093    identity: &FeedIdentity,
12094    pinned: Option<&TrustState>,
12095    item: &FeedItem,
12096) -> LinkResult<()> {
12097    let Some(pin) = pinned else {
12098        return Ok(());
12099    };
12100    if item.entry.seq <= pin.head_seq {
12101        return Ok(());
12102    }
12103    let mut chain: Vec<String> = identity
12104        .previous
12105        .iter()
12106        .rev()
12107        .map(|previous| format!("ed25519:{}", previous.fingerprint))
12108        .collect();
12109    chain.push(format!("ed25519:{}", identity.fingerprint));
12110    let pinned_index = chain
12111        .iter()
12112        .position(|key| key == &pin.current)
12113        .ok_or_else(|| invalid_feed("pinned identity is absent from the served chain"))?;
12114    let signer_index = chain
12115        .iter()
12116        .position(|key| key == &item.entry.brain)
12117        .ok_or_else(|| invalid_feed("feed signer is absent from the served identity chain"))?;
12118    if signer_index < pinned_index {
12119        return Err(invalid_feed(
12120            "a retired identity attempted to sign after the local checkpoint",
12121        ));
12122    }
12123    Ok(())
12124}
12125
12126fn trust_file_name(cfg: &HubConfig, brain: &str) -> LinkResult<String> {
12127    let origin = normalized_origin(&cfg.hub)?;
12128    let key = format!(
12129        "{:x}",
12130        Sha256::digest(format!("{origin}\0{brain}").as_bytes())
12131    );
12132    Ok(format!("{key}.json"))
12133}
12134
12135fn alias_file_name(cfg: &HubConfig, alias: &str) -> LinkResult<String> {
12136    let origin = normalized_origin(&cfg.hub)?;
12137    let key = format!(
12138        "{:x}",
12139        Sha256::digest(format!("{origin}\0alias\0{alias}").as_bytes())
12140    );
12141    Ok(format!("alias-{key}.json"))
12142}
12143
12144#[cfg(any(unix, windows))]
12145struct TrustLock {
12146    _file: std::fs::File,
12147}
12148
12149#[cfg(unix)]
12150fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
12151    use std::os::fd::{AsRawFd as _, FromRawFd as _};
12152
12153    let lock_string = format!(".{state_name}.lock");
12154    let lock_name = c_name(lock_string.as_bytes(), &lock_string)?;
12155    let fd = unsafe {
12156        libc::openat(
12157            directory.as_raw_fd(),
12158            lock_name.as_ptr(),
12159            libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
12160            0o600,
12161        )
12162    };
12163    if fd < 0 {
12164        return Err(std::io::Error::last_os_error().into());
12165    }
12166    let file = unsafe { std::fs::File::from_raw_fd(fd) };
12167    if !file.metadata()?.is_file() {
12168        return Err(LinkError::UnsafePath { path: lock_string });
12169    }
12170    if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
12171        return Err(std::io::Error::last_os_error().into());
12172    }
12173    Ok(TrustLock { _file: file })
12174}
12175
12176#[cfg(windows)]
12177fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
12178    let lock_name = format!(".{state_name}.lock");
12179    let file = crate::fsx::lock_exclusive_beneath(directory, Path::new(&lock_name))?;
12180    Ok(TrustLock { _file: file })
12181}
12182
12183#[cfg(any(unix, windows))]
12184fn lock_trust_many(
12185    cfg: &HubConfig,
12186    directory: &std::fs::File,
12187    refs: &[&str],
12188) -> LinkResult<Vec<TrustLock>> {
12189    let mut names = refs
12190        .iter()
12191        .map(|reference| trust_file_name(cfg, reference))
12192        .collect::<LinkResult<Vec<_>>>()?;
12193    names.sort();
12194    names.dedup();
12195    names
12196        .iter()
12197        .map(|name| lock_trust_name(directory, name))
12198        .collect()
12199}
12200
12201#[cfg(not(any(unix, windows)))]
12202fn lock_trust_many(
12203    _cfg: &HubConfig,
12204    _directory: &TrustDirectory,
12205    _refs: &[&str],
12206) -> LinkResult<Vec<()>> {
12207    Err(LinkError::UnsupportedPlatform {
12208        operation: "verified link.md state",
12209    })
12210}
12211
12212#[cfg(any(unix, windows))]
12213type TrustDirectory = std::fs::File;
12214
12215#[cfg(not(any(unix, windows)))]
12216struct TrustDirectory;
12217
12218#[cfg(unix)]
12219fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
12220    use std::os::fd::AsRawFd as _;
12221
12222    let directory = open_or_create_dir_nofollow(&cfg.state_dir.join("trust"))?;
12223    if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
12224        return Err(std::io::Error::last_os_error().into());
12225    }
12226    directory.sync_all()?;
12227    Ok(directory)
12228}
12229
12230#[cfg(windows)]
12231fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
12232    let marker = cfg.state_dir.join("trust").join(".directory");
12233    crate::fsx::write_atomic(&marker, b"link.md trust directory\n")?;
12234    Ok(crate::fsx::open_directory_nofollow(
12235        marker.parent().expect("trust marker has a parent"),
12236    )?)
12237}
12238
12239#[cfg(not(any(unix, windows)))]
12240fn open_trust_dir(_cfg: &HubConfig) -> LinkResult<TrustDirectory> {
12241    Err(LinkError::UnsupportedPlatform {
12242        operation: "verified link.md state",
12243    })
12244}
12245
12246#[cfg(unix)]
12247fn load_trust_in(
12248    cfg: &HubConfig,
12249    directory: &TrustDirectory,
12250    requested: &str,
12251) -> LinkResult<Option<TrustState>> {
12252    use std::os::fd::{AsRawFd as _, FromRawFd as _};
12253
12254    let name_string = trust_file_name(cfg, requested)?;
12255    let name = c_name(name_string.as_bytes(), &name_string)?;
12256    let fd = unsafe {
12257        libc::openat(
12258            directory.as_raw_fd(),
12259            name.as_ptr(),
12260            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
12261        )
12262    };
12263    if fd < 0 {
12264        let error = std::io::Error::last_os_error();
12265        if error.kind() == std::io::ErrorKind::NotFound {
12266            return Ok(None);
12267        }
12268        return Err(LinkError::UnsafePath { path: name_string });
12269    }
12270    let file = unsafe { std::fs::File::from_raw_fd(fd) };
12271    if !file.metadata()?.is_file() {
12272        return Err(LinkError::UnsafePath { path: name_string });
12273    }
12274    let mut bytes = Vec::new();
12275    file.take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
12276    if bytes.len() > 1024 * 1024 {
12277        return Err(invalid_feed("local identity/feed checkpoint is oversized"));
12278    }
12279    let mut state: TrustState = serde_json::from_slice(&bytes)
12280        .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
12281    if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
12282        return Err(invalid_feed(
12283            "local identity/feed checkpoint does not match this hub and brain",
12284        ));
12285    }
12286    if state.v == 1 {
12287        // Legacy files were keyed by canonical brain id. They can migrate only
12288        // when the caller used that exact id; old slug lookups had no durable
12289        // alias binding and therefore cannot be guessed safely.
12290        if state.brain != requested {
12291            return Err(invalid_feed(
12292                "legacy checkpoint is not bound to the requested brain id",
12293            ));
12294        }
12295        state.requested = requested.to_string();
12296    } else if state.requested != requested {
12297        return Err(invalid_feed(
12298            "local identity/feed checkpoint is bound to a different requested ref",
12299        ));
12300    }
12301    Ok(Some(state))
12302}
12303
12304#[cfg(windows)]
12305fn load_trust_in(
12306    cfg: &HubConfig,
12307    directory: &TrustDirectory,
12308    requested: &str,
12309) -> LinkResult<Option<TrustState>> {
12310    let name = trust_file_name(cfg, requested)?;
12311    let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
12312    let bytes = match reader.read(Path::new(&name), 1024 * 1024) {
12313        Ok(bytes) => bytes,
12314        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
12315        Err(_) => return Err(LinkError::UnsafePath { path: name }),
12316    };
12317    let mut state: TrustState = serde_json::from_slice(&bytes)
12318        .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
12319    if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
12320        return Err(invalid_feed(
12321            "local identity/feed checkpoint does not match this hub and brain",
12322        ));
12323    }
12324    if state.v == 1 {
12325        if state.brain != requested {
12326            return Err(invalid_feed(
12327                "legacy checkpoint is not bound to the requested brain id",
12328            ));
12329        }
12330        state.requested = requested.to_string();
12331    } else if state.requested != requested {
12332        return Err(invalid_feed(
12333            "local identity/feed checkpoint is bound to a different requested ref",
12334        ));
12335    }
12336    Ok(Some(state))
12337}
12338
12339#[cfg(not(any(unix, windows)))]
12340fn load_trust_in(
12341    _cfg: &HubConfig,
12342    _directory: &TrustDirectory,
12343    _brain: &str,
12344) -> LinkResult<Option<TrustState>> {
12345    Err(LinkError::UnsupportedPlatform {
12346        operation: "verified link.md state",
12347    })
12348}
12349
12350#[cfg(all(test, any(unix, windows)))]
12351fn load_trust(cfg: &HubConfig, requested: &str) -> LinkResult<Option<TrustState>> {
12352    let directory = open_trust_dir(cfg)?;
12353    load_trust_in(cfg, &directory, requested)
12354}
12355
12356#[cfg(unix)]
12357fn save_trust_in(
12358    cfg: &HubConfig,
12359    directory: &TrustDirectory,
12360    state: &TrustState,
12361) -> LinkResult<()> {
12362    use std::os::fd::{AsRawFd as _, FromRawFd as _};
12363
12364    let name_string = trust_file_name(cfg, &state.requested)?;
12365    let name = c_name(name_string.as_bytes(), &name_string)?;
12366    let mut bytes = serde_json::to_vec(state)
12367        .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
12368    bytes.push(b'\n');
12369
12370    let nonce = std::time::SystemTime::now()
12371        .duration_since(std::time::UNIX_EPOCH)
12372        .unwrap_or_default()
12373        .as_nanos();
12374    let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
12375    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
12376    let fd = unsafe {
12377        libc::openat(
12378            directory.as_raw_fd(),
12379            temp.as_ptr(),
12380            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
12381            0o600,
12382        )
12383    };
12384    if fd < 0 {
12385        return Err(std::io::Error::last_os_error().into());
12386    }
12387    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
12388    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
12389        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
12390        return Err(error.into());
12391    }
12392    drop(file);
12393    if unsafe {
12394        libc::renameat(
12395            directory.as_raw_fd(),
12396            temp.as_ptr(),
12397            directory.as_raw_fd(),
12398            name.as_ptr(),
12399        )
12400    } != 0
12401    {
12402        let error = std::io::Error::last_os_error();
12403        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
12404        return Err(error.into());
12405    }
12406    directory.sync_all()?;
12407    Ok(())
12408}
12409
12410#[cfg(windows)]
12411fn save_trust_in(
12412    cfg: &HubConfig,
12413    directory: &TrustDirectory,
12414    state: &TrustState,
12415) -> LinkResult<()> {
12416    let name = trust_file_name(cfg, &state.requested)?;
12417    let mut bytes = serde_json::to_vec(state)
12418        .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
12419    bytes.push(b'\n');
12420    crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
12421    Ok(())
12422}
12423
12424#[cfg(not(any(unix, windows)))]
12425fn save_trust_in(
12426    _cfg: &HubConfig,
12427    _directory: &TrustDirectory,
12428    _state: &TrustState,
12429) -> LinkResult<()> {
12430    Err(LinkError::UnsupportedPlatform {
12431        operation: "verified link.md state",
12432    })
12433}
12434
12435#[cfg(unix)]
12436fn load_alias_in(
12437    cfg: &HubConfig,
12438    directory: &TrustDirectory,
12439    requested: &str,
12440) -> LinkResult<Option<AliasBinding>> {
12441    use std::os::fd::{AsRawFd as _, FromRawFd as _};
12442
12443    let name_string = alias_file_name(cfg, requested)?;
12444    let name = c_name(name_string.as_bytes(), &name_string)?;
12445    let fd = unsafe {
12446        libc::openat(
12447            directory.as_raw_fd(),
12448            name.as_ptr(),
12449            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
12450        )
12451    };
12452    if fd < 0 {
12453        let error = std::io::Error::last_os_error();
12454        if error.kind() == std::io::ErrorKind::NotFound {
12455            return Ok(None);
12456        }
12457        return Err(LinkError::UnsafePath { path: name_string });
12458    }
12459    let file = unsafe { std::fs::File::from_raw_fd(fd) };
12460    if !file.metadata()?.is_file() {
12461        return Err(LinkError::UnsafePath { path: name_string });
12462    }
12463    let mut bytes = Vec::new();
12464    file.take(64 * 1024 + 1).read_to_end(&mut bytes)?;
12465    if bytes.len() > 64 * 1024 {
12466        return Err(invalid_feed("local alias binding is oversized"));
12467    }
12468    let alias: AliasBinding = serde_json::from_slice(&bytes)
12469        .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
12470    if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
12471    {
12472        return Err(invalid_feed(
12473            "local alias binding does not match this hub and requested ref",
12474        ));
12475    }
12476    Ok(Some(alias))
12477}
12478
12479#[cfg(windows)]
12480fn load_alias_in(
12481    cfg: &HubConfig,
12482    directory: &TrustDirectory,
12483    requested: &str,
12484) -> LinkResult<Option<AliasBinding>> {
12485    let name = alias_file_name(cfg, requested)?;
12486    let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
12487    let bytes = match reader.read(Path::new(&name), 64 * 1024) {
12488        Ok(bytes) => bytes,
12489        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
12490        Err(_) => return Err(LinkError::UnsafePath { path: name }),
12491    };
12492    let alias: AliasBinding = serde_json::from_slice(&bytes)
12493        .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
12494    if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
12495    {
12496        return Err(invalid_feed(
12497            "local alias binding does not match this hub and requested ref",
12498        ));
12499    }
12500    Ok(Some(alias))
12501}
12502
12503#[cfg(not(any(unix, windows)))]
12504fn load_alias_in(
12505    _cfg: &HubConfig,
12506    _directory: &TrustDirectory,
12507    _requested: &str,
12508) -> LinkResult<Option<AliasBinding>> {
12509    Err(LinkError::UnsupportedPlatform {
12510        operation: "verified link.md state",
12511    })
12512}
12513
12514#[cfg(unix)]
12515fn save_alias_in(
12516    cfg: &HubConfig,
12517    directory: &TrustDirectory,
12518    alias: &AliasBinding,
12519) -> LinkResult<()> {
12520    use std::os::fd::{AsRawFd as _, FromRawFd as _};
12521
12522    let name_string = alias_file_name(cfg, &alias.requested)?;
12523    let name = c_name(name_string.as_bytes(), &name_string)?;
12524    let mut bytes = serde_json::to_vec(alias)
12525        .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
12526    bytes.push(b'\n');
12527    let nonce = std::time::SystemTime::now()
12528        .duration_since(std::time::UNIX_EPOCH)
12529        .unwrap_or_default()
12530        .as_nanos();
12531    let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
12532    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
12533    let fd = unsafe {
12534        libc::openat(
12535            directory.as_raw_fd(),
12536            temp.as_ptr(),
12537            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
12538            0o600,
12539        )
12540    };
12541    if fd < 0 {
12542        return Err(std::io::Error::last_os_error().into());
12543    }
12544    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
12545    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
12546        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
12547        return Err(error.into());
12548    }
12549    drop(file);
12550    if unsafe {
12551        libc::renameat(
12552            directory.as_raw_fd(),
12553            temp.as_ptr(),
12554            directory.as_raw_fd(),
12555            name.as_ptr(),
12556        )
12557    } != 0
12558    {
12559        let error = std::io::Error::last_os_error();
12560        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
12561        return Err(error.into());
12562    }
12563    directory.sync_all()?;
12564    Ok(())
12565}
12566
12567#[cfg(windows)]
12568fn save_alias_in(
12569    cfg: &HubConfig,
12570    directory: &TrustDirectory,
12571    alias: &AliasBinding,
12572) -> LinkResult<()> {
12573    let name = alias_file_name(cfg, &alias.requested)?;
12574    let mut bytes = serde_json::to_vec(alias)
12575        .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
12576    bytes.push(b'\n');
12577    crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
12578    Ok(())
12579}
12580
12581#[cfg(not(any(unix, windows)))]
12582fn save_alias_in(
12583    _cfg: &HubConfig,
12584    _directory: &TrustDirectory,
12585    _alias: &AliasBinding,
12586) -> LinkResult<()> {
12587    Err(LinkError::UnsupportedPlatform {
12588        operation: "verified link.md state",
12589    })
12590}
12591
12592/// Load the canonical checkpoint shared by every spelling of one brain and
12593/// the separate alias binding. This also performs the one-way migration from
12594/// pre-v3 checkpoints that stored a full trust state under the alias itself.
12595/// Callers hold both alias and canonical locks before entering.
12596fn load_canonical_pin(
12597    cfg: &HubConfig,
12598    directory: &TrustDirectory,
12599    requested: &str,
12600    resolved_brain: &str,
12601) -> LinkResult<(Option<TrustState>, Option<AliasBinding>)> {
12602    let mut canonical = load_trust_in(cfg, directory, resolved_brain)?;
12603    if requested == resolved_brain {
12604        return Ok((canonical, None));
12605    }
12606
12607    let mut alias = load_alias_in(cfg, directory, requested)?;
12608    if let Some(binding) = &alias {
12609        if binding.brain != resolved_brain {
12610            return Err(LinkError::AliasRebindRequired {
12611                alias: requested.to_string(),
12612                from: binding.brain.clone(),
12613                to: resolved_brain.to_string(),
12614            });
12615        }
12616        return Ok((canonical, alias));
12617    }
12618
12619    // A v2 build stored the complete checkpoint under the requested slug.
12620    // Promote it to the canonical ULID key before creating the lightweight
12621    // alias binding. Never silently merge two independently advanced states.
12622    if let Some(legacy) = load_trust_in(cfg, directory, requested)? {
12623        if legacy.brain != resolved_brain {
12624            return Err(invalid_feed(
12625                "legacy alias checkpoint names a different canonical brain",
12626            ));
12627        }
12628        if let Some(existing) = &canonical {
12629            if existing.brain != legacy.brain
12630                || existing.anchor != legacy.anchor
12631                || existing.current != legacy.current
12632                || existing.head_seq != legacy.head_seq
12633                || existing.feed_hash != legacy.feed_hash
12634                || existing.rotations != legacy.rotations
12635            {
12636                return Err(invalid_feed(
12637                    "legacy alias checkpoint conflicts with the canonical checkpoint",
12638                ));
12639            }
12640        } else {
12641            let mut promoted = legacy.clone();
12642            promoted.requested = resolved_brain.to_string();
12643            promoted.home = None;
12644            save_trust_in(cfg, directory, &promoted)?;
12645            canonical = Some(promoted);
12646        }
12647        alias = Some(AliasBinding {
12648            v: 1,
12649            origin: normalized_origin(&cfg.hub)?,
12650            requested: requested.to_string(),
12651            brain: resolved_brain.to_string(),
12652            home: legacy.home,
12653        });
12654        save_alias_in(cfg, directory, alias.as_ref().expect("alias just created"))?;
12655    }
12656    Ok((canonical, alias))
12657}
12658
12659/// Explicitly replace one mutable alias binding after the operator reviews the
12660/// exact deleted/re-created brain ids. Canonical checkpoints are never removed
12661/// or rewritten, so old history remains pinned and the new brain establishes
12662/// its own independently verified trust chain.
12663pub fn rebind_v2_alias(cfg: &HubConfig, alias: &str, from: &str, to: &str) -> LinkResult<Value> {
12664    require_hardened_filesystem("verified alias rebind")?;
12665    require_safe_ref(alias)?;
12666    require_safe_ref(from)?;
12667    require_safe_ref(to)?;
12668    if crate::ulid::is_ulid(alias)
12669        || !crate::ulid::is_ulid(from)
12670        || !crate::ulid::is_ulid(to)
12671        || from == to
12672    {
12673        return Err(LinkError::InvalidPack {
12674            message:
12675                "alias rebind requires one non-ULID alias and two different exact canonical ULIDs"
12676                    .to_string(),
12677        });
12678    }
12679
12680    let verified = v2_verified_head(cfg, to)?.ok_or_else(|| LinkError::InvalidPack {
12681        message: "the proposed replacement is not a readable link.md v2 brain".to_string(),
12682    })?;
12683    accept_v2_head(cfg, &verified)?;
12684
12685    let alias_response = ensure_ok(
12686        request(
12687            cfg,
12688            "GET",
12689            &format!("/api/hub/brains/{alias}/v2/head"),
12690            None,
12691            Auth::Required,
12692        )?,
12693        "resolve alias for explicit rebind",
12694    )?;
12695    let resolved: V2HeadResponse = serde_json::from_value(alias_response)
12696        .map_err(|_| invalid_feed("alias rebind response has an invalid shape"))?;
12697    if resolved.v != 2 || resolved.brain_id != to {
12698        return Err(LinkError::RemoteAdvancedDuringSync);
12699    }
12700
12701    let directory = open_trust_dir(cfg)?;
12702    let _locks = lock_trust_many(cfg, &directory, &[alias, from, to])?;
12703    let binding = load_alias_in(cfg, &directory, alias)?.ok_or_else(|| LinkError::InvalidPack {
12704        message: "the requested alias has no existing local binding to replace".to_string(),
12705    })?;
12706    if binding.brain != from {
12707        return Err(LinkError::AliasRebindRequired {
12708            alias: alias.to_string(),
12709            from: binding.brain,
12710            to: to.to_string(),
12711        });
12712    }
12713    save_alias_in(
12714        cfg,
12715        &directory,
12716        &AliasBinding {
12717            v: 1,
12718            origin: normalized_origin(&cfg.hub)?,
12719            requested: alias.to_string(),
12720            brain: to.to_string(),
12721            home: binding.home,
12722        },
12723    )?;
12724    Ok(json!({
12725        "v": 2,
12726        "alias": alias,
12727        "from": from,
12728        "to": to,
12729        "outcome": "alias_rebound",
12730    }))
12731}
12732
12733fn save_canonical_pin_and_alias(
12734    cfg: &HubConfig,
12735    directory: &TrustDirectory,
12736    requested: &str,
12737    resolved_brain: &str,
12738    mut state: TrustState,
12739    existing_alias: Option<&AliasBinding>,
12740) -> LinkResult<()> {
12741    state.requested = resolved_brain.to_string();
12742    state.brain = resolved_brain.to_string();
12743    state.home = None;
12744    save_trust_in(cfg, directory, &state)?;
12745    if requested != resolved_brain {
12746        save_alias_in(
12747            cfg,
12748            directory,
12749            &AliasBinding {
12750                v: 1,
12751                origin: normalized_origin(&cfg.hub)?,
12752                requested: requested.to_string(),
12753                brain: resolved_brain.to_string(),
12754                home: existing_alias.and_then(|alias| alias.home.clone()),
12755            },
12756        )?;
12757    }
12758    Ok(())
12759}
12760
12761fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
12762    const ED25519_SPKI_PREFIX: &[u8] = &[
12763        0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
12764    ];
12765    let entry = &item.entry;
12766    let public_der = URL_SAFE_NO_PAD
12767        .decode(&entry.public_key)
12768        .map_err(|_| invalid_feed("public key is not base64url"))?;
12769    if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
12770        || !public_der.starts_with(ED25519_SPKI_PREFIX)
12771    {
12772        return Err(invalid_feed("entry public key is not a valid Ed25519 SPKI"));
12773    }
12774    let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
12775    if entry.brain != format!("ed25519:{fingerprint}") {
12776        return Err(invalid_feed(
12777            "brain fingerprint does not match its public key",
12778        ));
12779    }
12780    // Verify the complete rotation authority before using any previous key.
12781    let _ = verify_identity_chain(identity, None)?;
12782    let mut chain: Vec<(&str, &str)> = identity
12783        .previous
12784        .iter()
12785        .rev()
12786        .map(|previous| {
12787            (
12788                previous.fingerprint.as_str(),
12789                previous.public_key_spki.as_str(),
12790            )
12791        })
12792        .collect();
12793    chain.push((&identity.fingerprint, &identity.public_key_spki));
12794    let signer_index = chain.iter().position(|(known_fingerprint, spki)| {
12795        *known_fingerprint == fingerprint && *spki == entry.public_key
12796    });
12797    let Some(signer_index) = signer_index else {
12798        return Err(invalid_feed(
12799            "entry signer is not this brain's identity (current or rotated-from)",
12800        ));
12801    };
12802    let lower_boundary = if signer_index == 0 {
12803        None
12804    } else {
12805        let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
12806            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
12807        Some(prior.prior_head_seq)
12808    };
12809    let upper_boundary = if signer_index == identity.rotations.len() {
12810        None
12811    } else {
12812        let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
12813            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
12814        Some(next.prior_head_seq)
12815    };
12816    if lower_boundary.is_some_and(|boundary| entry.seq <= boundary)
12817        || upper_boundary.is_some_and(|boundary| entry.seq > boundary)
12818    {
12819        return Err(invalid_feed(
12820            "entry signer is outside its authenticated rotation epoch",
12821        ));
12822    }
12823    let unsigned = UnsignedFeedEntry {
12824        v: entry.v,
12825        seq: entry.seq,
12826        ts: &entry.ts,
12827        brain: &entry.brain,
12828        public_key: &entry.public_key,
12829        kind: &entry.kind,
12830        op: &entry.op,
12831        pack_sha256: &entry.pack_sha256,
12832        files: &entry.files,
12833        removed: &entry.removed,
12834        prev_entry_hash: &entry.prev_entry_hash,
12835    };
12836    let message =
12837        serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
12838    let signature = URL_SAFE_NO_PAD
12839        .decode(&entry.sig)
12840        .map_err(|_| invalid_feed("signature is not base64url"))?;
12841    UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
12842        .verify(&message, &signature)
12843        .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
12844
12845    let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
12846    exact.push(b'\n');
12847    let actual_hash = format!("{:x}", Sha256::digest(&exact));
12848    if actual_hash != item.hash {
12849        return Err(invalid_feed("entry SHA-256 does not match"));
12850    }
12851    Ok(())
12852}
12853
12854// ─────────────────────────────────────────────────────────────────────────────
12855// key rotation — link.md §9.1: the new key, signed by the old one
12856// ─────────────────────────────────────────────────────────────────────────────
12857
12858/// The unsigned rotation statement in its normative field order.
12859#[derive(Serialize)]
12860struct UnsignedRotation<'a> {
12861    v: u8,
12862    op: &'a str,
12863    brain: &'a str,
12864    public_key: &'a str,
12865    new_brain: &'a str,
12866    new_public_key: &'a str,
12867    prior_head_seq: u64,
12868    prior_feed_hash: Option<&'a str>,
12869    ts: String,
12870}
12871
12872/// Durable intent for an in-flight key rotation. The hub's recovery contract
12873/// identifies an ambiguous retry by the exact statement bytes, so the
12874/// statement cannot be reconstructed from the key and feed boundary later:
12875/// its timestamp and signature would differ.
12876#[derive(Debug, Deserialize, Serialize)]
12877#[serde(deny_unknown_fields)]
12878struct RotationJournal {
12879    v: u8,
12880    origin: String,
12881    brain: String,
12882    old_brain: String,
12883    new_brain: String,
12884    prior_head_seq: u64,
12885    prior_feed_hash: Option<String>,
12886    statement: String,
12887}
12888
12889fn rotation_journal_path(key_path: &Path) -> PathBuf {
12890    let mut path = key_path.as_os_str().to_os_string();
12891    path.push(".rotation.json");
12892    PathBuf::from(path)
12893}
12894
12895fn read_rotation_journal(path: &Path) -> LinkResult<RotationJournal> {
12896    #[cfg(unix)]
12897    let file = {
12898        use std::os::fd::{AsRawFd as _, FromRawFd as _};
12899        use std::os::unix::ffi::OsStrExt as _;
12900        let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
12901            .map_err(|error| {
12902                bad_agent_key(&format!("cannot open the rotation journal parent: {error}"))
12903            })?;
12904        let leaf_name = path
12905            .file_name()
12906            .ok_or_else(|| bad_agent_key("the rotation journal path has no file name"))?;
12907        let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
12908        let fd = unsafe {
12909            libc::openat(
12910                parent.as_raw_fd(),
12911                leaf.as_ptr(),
12912                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
12913            )
12914        };
12915        if fd < 0 {
12916            return Err(bad_agent_key(
12917                "the rotation journal must be an existing regular file without symlink ancestors",
12918            ));
12919        }
12920        unsafe { std::fs::File::from_raw_fd(fd) }
12921    };
12922    #[cfg(not(unix))]
12923    let file = std::fs::File::open(path)
12924        .map_err(|error| bad_agent_key(&format!("cannot read the rotation journal: {error}")))?;
12925    let metadata = file
12926        .metadata()
12927        .map_err(|error| bad_agent_key(&format!("cannot inspect the rotation journal: {error}")))?;
12928    if !metadata.is_file() || metadata.len() > MAX_REGISTRY_CARD_BYTES {
12929        return Err(bad_agent_key(
12930            "the rotation journal must be a bounded regular file",
12931        ));
12932    }
12933    #[cfg(unix)]
12934    {
12935        use std::os::unix::fs::PermissionsExt as _;
12936        if metadata.permissions().mode() & 0o077 != 0 {
12937            return Err(bad_agent_key(
12938                "the rotation journal is accessible to group/other; set mode 0600",
12939            ));
12940        }
12941    }
12942    serde_json::from_reader(file)
12943        .map_err(|_| bad_agent_key("the rotation journal is not valid exact JSON"))
12944}
12945
12946fn remove_rotation_journal(path: &Path) {
12947    #[cfg(unix)]
12948    {
12949        use std::os::fd::AsRawFd as _;
12950        use std::os::unix::ffi::OsStrExt as _;
12951        let Ok(parent) =
12952            open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
12953        else {
12954            return;
12955        };
12956        let Some(leaf_name) = path.file_name() else {
12957            return;
12958        };
12959        let Ok(leaf) = c_name(leaf_name.as_bytes(), &path.display().to_string()) else {
12960            return;
12961        };
12962        if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), 0) } == 0 {
12963            let _ = parent.sync_all();
12964        }
12965    }
12966    #[cfg(not(unix))]
12967    {
12968        let _ = std::fs::remove_file(path);
12969    }
12970}
12971
12972fn validate_rotation_journal(
12973    journal: &RotationJournal,
12974    cfg: &HubConfig,
12975    canonical_brain: &str,
12976    old_key: &AgentSigningKey,
12977    new_key: &AgentSigningKey,
12978    head: &Head,
12979) -> LinkResult<()> {
12980    if journal.v != 1
12981        || journal.origin != normalized_origin(&cfg.hub)?
12982        || journal.brain != canonical_brain
12983        || journal.old_brain != old_key.multikey
12984        || journal.new_brain != new_key.multikey
12985        || journal.prior_head_seq != head.seq
12986        || journal.prior_feed_hash != head.feed_hash
12987    {
12988        return Err(invalid_feed(
12989            "rotation journal does not match the verified key and feed boundary",
12990        ));
12991    }
12992    let statement: RotationStatement = serde_json::from_str(&journal.statement)
12993        .map_err(|_| invalid_feed("rotation journal statement did not parse exactly"))?;
12994    if statement.prior_head_seq != journal.prior_head_seq
12995        || statement.prior_feed_hash != journal.prior_feed_hash
12996        || statement.brain != old_key.multikey
12997        || statement.public_key != old_key.public_key_spki
12998        || statement.new_brain != new_key.multikey
12999        || statement.new_public_key != new_key.public_key_spki
13000    {
13001        return Err(invalid_feed(
13002            "rotation journal statement does not match its durable intent",
13003        ));
13004    }
13005    let identity = FeedIdentity {
13006        fingerprint: new_key.multikey.trim_start_matches("ed25519:").to_string(),
13007        public_key_spki: new_key.public_key_spki.clone(),
13008        previous: vec![PreviousIdentity {
13009            fingerprint: old_key.multikey.trim_start_matches("ed25519:").to_string(),
13010            public_key_spki: old_key.public_key_spki.clone(),
13011        }],
13012        rotations: vec![journal.statement.clone()],
13013    };
13014    verify_identity_chain(&identity, None)?;
13015    Ok(())
13016}
13017
13018/// What `dbmd key rotate` returns.
13019#[derive(Debug, Serialize)]
13020pub struct RotationReport {
13021    /// The brain id rotated.
13022    pub brain: String,
13023    /// The NEW identity the hub now serves.
13024    pub multikey: String,
13025    /// Where the new PKCS#8 secret landed (0600).
13026    #[serde(rename = "keyFile")]
13027    pub key_file: String,
13028    /// Prior identities (newest first) the feed still verifies against.
13029    pub previous: Vec<String>,
13030}
13031
13032/// Rotate a self-custodied brain's key: mint a fresh keypair, build the
13033/// §9.1 statement — the new key plus exact prior feed boundary, signed by the
13034/// OLD key in normative serialization — and send it to the hub. The new secret
13035/// is durably created at 0600 before the POST; an existing output is reused for
13036/// idempotent retry/reconciliation. The old key is left untouched.
13037pub fn rotate_brain_key(
13038    cfg: &HubConfig,
13039    brain: &str,
13040    old_key: &AgentSigningKey,
13041    out: &Path,
13042) -> LinkResult<RotationReport> {
13043    require_hardened_filesystem("key rotation")?;
13044    require_safe_ref(brain)?;
13045    // The new private key must be durable *before* the hub can accept its
13046    // public half. An existing file is the retry/reconciliation path after an
13047    // ambiguous network failure: reuse it, never generate another identity.
13048    let new_key = if out.exists() {
13049        load_signing_key(out)?
13050    } else {
13051        let rng = ring::rand::SystemRandom::new();
13052        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
13053            .map_err(|_| bad_agent_key("key generation failed"))?;
13054        let pair = agent_keypair(pkcs8.as_ref())?;
13055        let (public_key_spki, multikey) = public_identity_for(&pair);
13056        write_secret_new(
13057            out,
13058            format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
13059        )?;
13060        AgentSigningKey {
13061            pkcs8: pkcs8.as_ref().to_vec(),
13062            multikey,
13063            public_key_spki,
13064        }
13065    };
13066    let new_spki = new_key.public_key_spki.clone();
13067    let new_multikey = new_key.multikey.clone();
13068    let journal_path = rotation_journal_path(out);
13069    let before_v2 = v2_verified_head(cfg, brain)?;
13070    let (canonical_brain, served_identity, observed_head, v2_profile) =
13071        if let Some(head) = before_v2 {
13072            let observed = Head {
13073                brain: head.brain_id.clone(),
13074                seq: head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
13075                updated_at: head
13076                    .pointer
13077                    .as_ref()
13078                    .map(|pointer| pointer.signed_at.clone()),
13079                feed_hash: head
13080                    .pointer
13081                    .as_ref()
13082                    .map(|pointer| pointer.feed_hash.clone()),
13083                verified: true,
13084            };
13085            let identity = v2_identity(&head.identity);
13086            let canonical = head.brain_id.clone();
13087            accept_v2_head(cfg, &head)?;
13088            (canonical, identity, observed, true)
13089        } else {
13090            let remote = verified_remote_head(cfg, brain, false)?;
13091            let identity = remote
13092                .identity
13093                .clone()
13094                .ok_or_else(|| invalid_feed("cannot rotate a brain with no signed identity"))?;
13095            (remote.head.brain.clone(), identity, remote.head, false)
13096        };
13097    let served_multikey = format!("ed25519:{}", served_identity.fingerprint);
13098    let already_rotated = served_multikey == new_multikey;
13099    // A completed invocation removes the journal only after the server has
13100    // acknowledged its recovery boundary. If the verified identity advanced
13101    // but the journal remains, the prior response was ambiguous: replay the
13102    // exact signed statement so a v2 hub can repair/confirm its checkpoint.
13103    if already_rotated && !journal_path.exists() {
13104        remove_rotation_journal(&journal_path);
13105        return Ok(RotationReport {
13106            brain: brain.to_string(),
13107            multikey: new_multikey,
13108            key_file: out.display().to_string(),
13109            previous: served_identity
13110                .previous
13111                .iter()
13112                .map(|identity| format!("ed25519:{}", identity.fingerprint))
13113                .collect(),
13114        });
13115    }
13116    if !already_rotated && served_multikey != old_key.multikey {
13117        return Err(invalid_feed(
13118            "the supplied old key is not the brain's verified current identity",
13119        ));
13120    }
13121
13122    let journal = if journal_path.exists() {
13123        read_rotation_journal(&journal_path)?
13124    } else {
13125        let ts = crate::now()
13126            .with_timezone(&chrono::Utc)
13127            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
13128            .to_string();
13129        let unsigned = serde_json::to_string(&UnsignedRotation {
13130            v: 1,
13131            op: "rotate",
13132            brain: &old_key.multikey,
13133            public_key: &old_key.public_key_spki,
13134            new_brain: &new_multikey,
13135            new_public_key: &new_spki,
13136            prior_head_seq: observed_head.seq,
13137            prior_feed_hash: observed_head.feed_hash.as_deref(),
13138            ts,
13139        })
13140        .expect("serialize rotation");
13141        let old_pair = agent_keypair(&old_key.pkcs8)?;
13142        let sig = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
13143        let statement = format!("{},\"sig\":\"{}\"}}", &unsigned[..unsigned.len() - 1], sig);
13144        let journal = RotationJournal {
13145            v: 1,
13146            origin: normalized_origin(&cfg.hub)?,
13147            brain: canonical_brain.clone(),
13148            old_brain: old_key.multikey.clone(),
13149            new_brain: new_multikey.clone(),
13150            prior_head_seq: observed_head.seq,
13151            prior_feed_hash: observed_head.feed_hash.clone(),
13152            statement,
13153        };
13154        let mut exact = serde_json::to_vec(&journal)
13155            .map_err(|_| invalid_feed("could not serialize rotation journal"))?;
13156        exact.push(b'\n');
13157        if write_secret_new(&journal_path, &exact).is_err() {
13158            // A concurrent retry may have won the O_EXCL race. Only an exact,
13159            // fully validated journal is allowed to recover that race.
13160            read_rotation_journal(&journal_path)?
13161        } else {
13162            journal
13163        }
13164    };
13165    validate_rotation_journal(
13166        &journal,
13167        cfg,
13168        &canonical_brain,
13169        old_key,
13170        &new_key,
13171        &observed_head,
13172    )?;
13173
13174    let body = json!({ "statement": journal.statement });
13175    let path = format!("/api/hub/brains/{brain}/rotate");
13176    let attempted = request(cfg, "POST", &path, Some(&body), Auth::Required);
13177    let attempted_failure = match attempted {
13178        Ok(response) if (200..300).contains(&response.status) => None,
13179        Ok(response) => Some(ensure_ok(response, "key rotate").unwrap_err()),
13180        Err(error) => Some(error),
13181    };
13182
13183    // A 2xx body is not authority, and a failed response may have followed a
13184    // committed mutation. In both cases success means the normal verifier sees
13185    // an append-only rotation chain ending at the durable new key.
13186    let identity = if v2_profile {
13187        match v2_verified_head(cfg, brain) {
13188            Ok(Some(after)) => {
13189                let identity = v2_identity(&after.identity);
13190                accept_v2_head(cfg, &after)?;
13191                identity
13192            }
13193            Ok(None) => {
13194                return Err(attempted_failure.unwrap_or_else(|| {
13195                    invalid_feed("rotated v2 brain no longer serves a v2 head")
13196                }));
13197            }
13198            Err(error) => return Err(attempted_failure.unwrap_or(error)),
13199        }
13200    } else {
13201        match verified_remote_head(cfg, brain, false) {
13202            Ok(after) => after
13203                .identity
13204                .ok_or_else(|| invalid_feed("rotated brain has no verified identity"))?,
13205            Err(error) => return Err(attempted_failure.unwrap_or(error)),
13206        }
13207    };
13208    if format!("ed25519:{}", identity.fingerprint) != new_multikey
13209        || identity.public_key_spki != new_spki
13210    {
13211        return Err(attempted_failure.unwrap_or_else(|| {
13212            invalid_feed("hub acknowledged rotation without committing the verified new identity")
13213        }));
13214    }
13215    if v2_profile {
13216        if let Some(error) = attempted_failure {
13217            // Seeing the new public identity is insufficient: v2 rotation is
13218            // successful only after the endpoint has forced and acknowledged
13219            // the recovery checkpoint that contains it. Keep the journal for
13220            // an exact retry after any non-2xx or transport ambiguity.
13221            return Err(error);
13222        }
13223    }
13224    let previous = identity
13225        .previous
13226        .iter()
13227        .map(|prior| format!("ed25519:{}", prior.fingerprint))
13228        .collect();
13229    remove_rotation_journal(&journal_path);
13230
13231    Ok(RotationReport {
13232        brain: brain.to_string(),
13233        multikey: new_multikey,
13234        key_file: out.display().to_string(),
13235        previous,
13236    })
13237}
13238
13239// ─────────────────────────────────────────────────────────────────────────────
13240// mirror — verified replication: the whole feed + files, re-servable
13241// ─────────────────────────────────────────────────────────────────────────────
13242
13243/// What `dbmd mirror` materialized.
13244#[derive(Debug, Serialize)]
13245pub struct MirrorReport {
13246    /// The brain id.
13247    pub brain: String,
13248    /// The mirrored feed head.
13249    #[serde(rename = "headSeq")]
13250    pub head_seq: u64,
13251    /// The head entry hash (the feed's advertised converged state).
13252    #[serde(rename = "feedHash")]
13253    pub feed_hash: Option<String>,
13254    /// Signed feed entries verified and stored.
13255    pub entries: u64,
13256    /// The brain's multikey, pinned in `.dbmd/config` (TOFU).
13257    pub pinned: String,
13258    /// Store files materialized by the pull.
13259    pub files: usize,
13260}
13261
13262/// The mirror state directory, relative to the mirror root.
13263pub const MIRROR_REL_DIR: &str = ".dbmd/mirror";
13264
13265/// Fully re-verified mirror material suitable for a read-only re-server.
13266#[derive(Debug)]
13267pub struct VerifiedMirrorMaterial {
13268    pub brain: String,
13269    pub head_seq: u64,
13270    pub feed_hash: Option<String>,
13271    pub identity: serde_json::Value,
13272    /// Sequence, exact normative entry JSON without newline, and entry hash.
13273    pub entries: Vec<(u64, String, String)>,
13274    pub pack_sha256: Option<String>,
13275}
13276
13277#[derive(Deserialize)]
13278#[serde(deny_unknown_fields)]
13279struct StoredMirrorHead {
13280    brain: String,
13281    #[serde(rename = "headSeq")]
13282    head_seq: u64,
13283    #[serde(rename = "feedHash")]
13284    feed_hash: Option<String>,
13285}
13286
13287/// Re-verify mirror metadata, every signature/hash/rotation boundary, and the
13288/// exact snapshot pack before `dbmd serve` exposes any bytes.
13289pub fn verify_mirror_material(
13290    head_bytes: &[u8],
13291    identity_bytes: &[u8],
13292    feed_bytes: &[Vec<u8>],
13293    snapshot_pack: Option<&[u8]>,
13294    expected_anchor: &str,
13295) -> LinkResult<VerifiedMirrorMaterial> {
13296    let snapshot_hash = snapshot_pack
13297        .filter(|pack| !pack.is_empty())
13298        .map(content_sha256);
13299    verify_mirror_material_with_pack_hash(
13300        head_bytes,
13301        identity_bytes,
13302        feed_bytes,
13303        snapshot_hash.as_deref(),
13304        expected_anchor,
13305    )
13306}
13307
13308/// Re-verify mirror metadata against a snapshot digest computed from a held
13309/// no-follow file capability. This lets `dbmd serve` authenticate and retain a
13310/// large pack without ever buffering the pack in process memory.
13311pub fn verify_mirror_material_with_pack_hash(
13312    head_bytes: &[u8],
13313    identity_bytes: &[u8],
13314    feed_bytes: &[Vec<u8>],
13315    snapshot_pack_sha256: Option<&str>,
13316    expected_anchor: &str,
13317) -> LinkResult<VerifiedMirrorMaterial> {
13318    let head: StoredMirrorHead = serde_json::from_slice(head_bytes)
13319        .map_err(|_| invalid_feed("stored mirror head did not parse exactly"))?;
13320    require_safe_ref(&head.brain)?;
13321    if head.head_seq > MAX_FEED_REPLAY_ENTRIES || feed_bytes.len() as u64 != head.head_seq {
13322        return Err(invalid_feed(
13323            "stored mirror feed count does not match its bounded head sequence",
13324        ));
13325    }
13326    let aggregate = feed_bytes
13327        .iter()
13328        .try_fold(0u64, |total, bytes| total.checked_add(bytes.len() as u64))
13329        .ok_or_else(|| invalid_feed("stored mirror feed size overflow"))?;
13330    if aggregate > MAX_FEED_REPLAY_BYTES {
13331        return Err(invalid_feed(
13332            "stored mirror feed metadata exceeds the aggregate limit",
13333        ));
13334    }
13335    let identity: FeedIdentity = serde_json::from_slice(identity_bytes)
13336        .map_err(|_| invalid_feed("stored mirror identity did not parse exactly"))?;
13337    let anchor = verify_identity_chain(&identity, None)?;
13338    if anchor != expected_anchor {
13339        return Err(invalid_feed(
13340            "stored mirror identity does not descend from the explicitly trusted anchor",
13341        ));
13342    }
13343
13344    let mut entries = Vec::with_capacity(feed_bytes.len());
13345    let mut items = Vec::with_capacity(feed_bytes.len());
13346    let mut previous_hash = None;
13347    let mut pack_sha256 = None;
13348    for (index, bytes) in feed_bytes.iter().enumerate() {
13349        let exact = bytes
13350            .strip_suffix(b"\n")
13351            .ok_or_else(|| invalid_feed("stored feed entry lacks its exact trailing newline"))?;
13352        if exact.ends_with(b"\n") {
13353            return Err(invalid_feed("stored feed entry has extra trailing bytes"));
13354        }
13355        let entry: FeedEntry = serde_json::from_slice(exact)
13356            .map_err(|_| invalid_feed("stored feed entry did not parse exactly"))?;
13357        let expected_seq = index as u64 + 1;
13358        if entry.seq != expected_seq || entry.prev_entry_hash != previous_hash {
13359            return Err(invalid_feed(
13360                "stored mirror feed is not contiguous and hash-chained",
13361            ));
13362        }
13363        let canonical = serde_json::to_vec(&entry)
13364            .map_err(|_| invalid_feed("could not canonicalize stored feed entry"))?;
13365        if canonical != exact {
13366            return Err(invalid_feed(
13367                "stored feed entry is not in normative serialization",
13368            ));
13369        }
13370        let hash = content_sha256(bytes);
13371        let item = FeedItem {
13372            hash: hash.clone(),
13373            entry,
13374        };
13375        verify_feed_item(&item, &identity)?;
13376        previous_hash = Some(hash.clone());
13377        if expected_seq == head.head_seq {
13378            pack_sha256 = Some(item.entry.pack_sha256.clone());
13379        }
13380        entries.push((
13381            expected_seq,
13382            std::str::from_utf8(exact)
13383                .map_err(|_| invalid_feed("stored feed entry is not UTF-8"))?
13384                .to_string(),
13385            hash,
13386        ));
13387        items.push(item);
13388    }
13389    if previous_hash != head.feed_hash {
13390        return Err(invalid_feed(
13391            "stored mirror feed does not converge on its advertised head",
13392        ));
13393    }
13394    verify_rotation_feed_boundaries(&identity, None, &items, head.head_seq)?;
13395    match (head.head_seq, snapshot_pack_sha256, pack_sha256.as_deref()) {
13396        (0, None, None) => {}
13397        (_, Some(actual), Some(expected)) if actual == expected => {}
13398        _ => {
13399            return Err(LinkError::InvalidPack {
13400                message: "stored snapshot pack does not match the signed head digest".to_string(),
13401            });
13402        }
13403    }
13404    let identity_value = serde_json::to_value(&identity)
13405        .map_err(|_| invalid_feed("could not serialize verified mirror identity"))?;
13406    Ok(VerifiedMirrorMaterial {
13407        brain: head.brain,
13408        head_seq: head.head_seq,
13409        feed_hash: head.feed_hash,
13410        identity: identity_value,
13411        entries,
13412        pack_sha256,
13413    })
13414}
13415
13416/// SHA-256 hex of one feed entry's stored bytes (`exact JSON + "\n"`) — the
13417/// entry hash every consumer recomputes (SPEC §5.3).
13418pub fn feed_entry_hash(exact_sans_newline: &str) -> String {
13419    format!(
13420        "{:x}",
13421        Sha256::digest(format!("{exact_sans_newline}\n").as_bytes())
13422    )
13423}
13424
13425/// SHA-256 hex for content a signed manifest names. Exposed for the thin
13426/// `dbmd serve` adapter; cryptographic verification remains centralized here.
13427pub fn content_sha256(bytes: &[u8]) -> String {
13428    format!("{:x}", Sha256::digest(bytes))
13429}
13430
13431/// SHA-256 a stream with a fixed-size working buffer.
13432pub fn content_sha256_reader(mut reader: impl Read) -> std::io::Result<String> {
13433    let mut digest = Sha256::new();
13434    let mut buffer = [0u8; 64 * 1024];
13435    loop {
13436        let read = reader.read(&mut buffer)?;
13437        if read == 0 {
13438            break;
13439        }
13440        digest.update(&buffer[..read]);
13441    }
13442    Ok(format!("{:x}", digest.finalize()))
13443}
13444
13445/// Replicate a brain with full verification (link.md §5.4 over the WHOLE
13446/// chain, not just the head): every entry's signature, hash, sequence
13447/// contiguity, prev-hash linkage, rotation chain, and exact signed pack are
13448/// checked in a sibling staging directory. Only then is the old mirror swapped
13449/// out through an atomic directory exchange. Every stage, install, and cleanup
13450/// operation is relative to one held no-follow parent-directory capability, so
13451/// renaming an ancestor cannot redirect any write or deletion.
13452#[cfg_attr(windows, allow(unreachable_code, unused_variables))]
13453pub fn mirror(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<MirrorReport> {
13454    require_hardened_filesystem("mirror")?;
13455    require_safe_ref(brain)?;
13456    #[cfg(windows)]
13457    {
13458        let _ = (cfg, dest);
13459        return Err(LinkError::UnsupportedPlatform {
13460            operation: "atomic whole-mirror replacement on Windows",
13461        });
13462    }
13463    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
13464    let name = dest
13465        .file_name()
13466        .and_then(|name| name.to_str())
13467        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
13468        .ok_or_else(|| LinkError::UnsafePath {
13469            path: dest.display().to_string(),
13470        })?;
13471    #[cfg(unix)]
13472    let parent_dir = open_or_create_dir_nofollow(parent)?;
13473    #[cfg(unix)]
13474    use std::os::fd::AsRawFd as _;
13475    #[cfg(unix)]
13476    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
13477    #[cfg(unix)]
13478    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
13479        None => false,
13480        Some(true) => true,
13481        Some(false) => {
13482            return Err(LinkError::UnsafePath {
13483                path: dest.display().to_string(),
13484            });
13485        }
13486    };
13487
13488    // A fixed backup was used by pre-hardening builds. Never interpret or
13489    // delete an attacker-planted entry at that name; require manual recovery.
13490    #[cfg(unix)]
13491    let legacy_backup_name = c_name(
13492        format!(".{name}.dbmd-backup").as_bytes(),
13493        &dest.display().to_string(),
13494    )?;
13495    #[cfg(unix)]
13496    if entry_is_dir_at(parent_dir.as_raw_fd(), &legacy_backup_name)?.is_some() {
13497        return Err(LinkError::UnsafePath {
13498            path: parent
13499                .join(format!(".{name}.dbmd-backup"))
13500                .display()
13501                .to_string(),
13502        });
13503    }
13504
13505    let nonce = std::time::SystemTime::now()
13506        .duration_since(std::time::UNIX_EPOCH)
13507        .unwrap_or_default()
13508        .as_nanos();
13509    let stage_label = format!(".{name}.dbmd-stage-{}-{nonce}", std::process::id());
13510    #[cfg(unix)]
13511    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
13512    #[cfg(unix)]
13513    let stage_dir = create_dir_exclusive_at(
13514        parent_dir.as_raw_fd(),
13515        &stage_name,
13516        &dest.display().to_string(),
13517    )?;
13518
13519    let assembled = (|| -> LinkResult<MirrorReport> {
13520        let remote = verified_remote_head(cfg, brain, true)?;
13521        let brain_id = remote.head.brain.clone();
13522        let identity = remote
13523            .identity
13524            .as_ref()
13525            .ok_or_else(|| invalid_feed("brain has no signed identity"))?;
13526        let anchor = remote
13527            .anchor
13528            .clone()
13529            .ok_or_else(|| invalid_feed("brain has no verified identity anchor"))?;
13530        let pack = download_verified_snapshot_pack(cfg, &brain_id, &remote)?;
13531        let snapshot_entries = parse_store_pack(pack.clone())?;
13532        let snapshot_count = snapshot_entries.len();
13533        let mut staged_entries = snapshot_entries;
13534        staged_entries.push((format!("{MIRROR_REL_DIR}/snapshot.pack"), pack));
13535        for item in &remote.entries {
13536            let mut exact = serde_json::to_vec(&item.entry)
13537                .map_err(|_| invalid_feed("could not serialize signed feed entry"))?;
13538            exact.push(b'\n');
13539            if format!("{:x}", Sha256::digest(&exact)) != item.hash {
13540                return Err(invalid_feed(
13541                    "serialized mirror entry differs from its verified hash",
13542                ));
13543            }
13544            staged_entries.push((
13545                format!("{MIRROR_REL_DIR}/feed/{}.json", item.entry.seq),
13546                exact,
13547            ));
13548        }
13549        let mut identity_bytes = serde_json::to_vec(identity)
13550            .map_err(|_| invalid_feed("could not serialize verified identity chain"))?;
13551        identity_bytes.push(b'\n');
13552        staged_entries.push((format!("{MIRROR_REL_DIR}/identity.json"), identity_bytes));
13553        let mut head_bytes = serde_json::to_vec(&json!({
13554            "brain": brain_id,
13555            "headSeq": remote.head.seq,
13556            "feedHash": remote.head.feed_hash,
13557        }))
13558        .map_err(|_| invalid_feed("could not serialize verified mirror head"))?;
13559        head_bytes.push(b'\n');
13560        staged_entries.push((format!("{MIRROR_REL_DIR}/head.json"), head_bytes));
13561        staged_entries.push((
13562            CONFIG_REL_PATH.to_string(),
13563            format!("hub = {}\npin = {anchor}\n", cfg.hub).into_bytes(),
13564        ));
13565        #[cfg(unix)]
13566        write_pull_entries_beneath_dir(&stage_dir, &staged_entries)?;
13567
13568        Ok(MirrorReport {
13569            brain: brain_id,
13570            head_seq: remote.head.seq,
13571            feed_hash: remote.head.feed_hash,
13572            entries: remote.entries.len() as u64,
13573            pinned: anchor,
13574            files: snapshot_count,
13575        })
13576    })();
13577
13578    let report = match assembled {
13579        Ok(report) => report,
13580        Err(error) => {
13581            #[cfg(unix)]
13582            let _ = remove_tree_at(
13583                parent_dir.as_raw_fd(),
13584                &stage_name,
13585                &dest.display().to_string(),
13586            );
13587            return Err(error);
13588        }
13589    };
13590
13591    #[cfg(unix)]
13592    if let Err(error) =
13593        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
13594    {
13595        let _ = remove_tree_at(
13596            parent_dir.as_raw_fd(),
13597            &stage_name,
13598            &dest.display().to_string(),
13599        );
13600        return Err(error);
13601    }
13602    // An exchange leaves the old mirror at the unique stage name. Cleanup is
13603    // capability-relative and never follows symlinks in hostile old content.
13604    #[cfg(unix)]
13605    if dest_exists {
13606        remove_tree_at(
13607            parent_dir.as_raw_fd(),
13608            &stage_name,
13609            &dest.display().to_string(),
13610        )?;
13611    }
13612    #[cfg(unix)]
13613    parent_dir.sync_all()?;
13614    Ok(report)
13615}
13616
13617fn verified_remote_head(
13618    cfg: &HubConfig,
13619    brain: &str,
13620    require_full_chain: bool,
13621) -> LinkResult<VerifiedRemote> {
13622    require_hardened_filesystem("verified link.md state")?;
13623    require_safe_ref(brain)?;
13624    // Refuse an unsafe state root before sending credentials or consulting an
13625    // untrusted card, and retain this exact directory inode for the complete
13626    // checkpoint transaction.
13627    let trust_directory = open_trust_dir(cfg)?;
13628    let path = format!("/api/hub/brains/{brain}");
13629    let body = ensure_ok(
13630        request(cfg, "GET", &path, None, Auth::Required)?,
13631        "subscribe",
13632    )?;
13633    let resolved_brain = body
13634        .get("id")
13635        .and_then(Value::as_str)
13636        .filter(|id| crate::ulid::is_ulid(id))
13637        .ok_or_else(|| invalid_feed("brain card has no canonical ULID id"))?
13638        .to_string();
13639    if crate::ulid::is_ulid(brain) && resolved_brain != brain {
13640        return Err(invalid_feed(
13641            "brain card id differs from the explicitly requested brain id",
13642        ));
13643    }
13644    // The card supplies the canonical ULID. Lock alias + canonical keys in
13645    // deterministic filename order, then hold both through verify + save.
13646    // Concurrent aliases for the same brain therefore converge on one
13647    // checkpoint instead of establishing independent TOFU universes.
13648    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &resolved_brain])?;
13649    let (pinned, alias_binding) =
13650        load_canonical_pin(cfg, &trust_directory, brain, &resolved_brain)?;
13651    let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
13652    let advertised_hash = body
13653        .get("feedHash")
13654        .and_then(Value::as_str)
13655        .map(str::to_string);
13656    let updated_at = body
13657        .get("updatedAt")
13658        .and_then(Value::as_str)
13659        .map(str::to_string);
13660    if let Some(pin) = &pinned {
13661        if seq < pin.head_seq {
13662            return Err(invalid_feed(format!(
13663                "feed rollback: hub advertised sequence {seq}, local checkpoint is {}",
13664                pin.head_seq
13665            )));
13666        }
13667        if seq == pin.head_seq && advertised_hash != pin.feed_hash {
13668            return Err(invalid_feed(
13669                "feed equivocation: the checkpoint sequence now has a different hash",
13670            ));
13671        }
13672    }
13673    if seq == 0 {
13674        if advertised_hash.is_some() {
13675            return Err(invalid_feed("an empty feed advertised a head hash"));
13676        }
13677        let identity: FeedIdentity = serde_json::from_value(
13678            body.get("identity")
13679                .cloned()
13680                .ok_or_else(|| invalid_feed("empty brain card has no signed identity"))?,
13681        )
13682        .map_err(|_| invalid_feed("empty brain card has an invalid identity"))?;
13683        let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
13684        // A valid old-key-signed rotation is still inconsistent if it commits
13685        // to history that the same card now claims never existed. Run the
13686        // identical feed-boundary proof used by non-empty heads before this
13687        // identity can become a durable TOFU checkpoint.
13688        verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], seq)?;
13689        save_canonical_pin_and_alias(
13690            cfg,
13691            &trust_directory,
13692            brain,
13693            &resolved_brain,
13694            TrustState {
13695                v: 2,
13696                origin: normalized_origin(&cfg.hub)?,
13697                requested: resolved_brain.clone(),
13698                brain: resolved_brain.clone(),
13699                home: None,
13700                anchor: anchor.clone(),
13701                current: format!("ed25519:{}", identity.fingerprint),
13702                head_seq: 0,
13703                feed_hash: None,
13704                rotations: identity.rotations.clone(),
13705                hub_signer: None,
13706                protocol_profile: None,
13707            },
13708            alias_binding.as_ref(),
13709        )?;
13710        return Ok(VerifiedRemote {
13711            head: Head {
13712                brain: resolved_brain,
13713                seq,
13714                updated_at,
13715                feed_hash: None,
13716                verified: true,
13717            },
13718            identity: Some(identity),
13719            head_entry: None,
13720            entries: Vec::new(),
13721            anchor: Some(anchor),
13722        });
13723    }
13724    if advertised_hash.as_ref().is_none_or(|hash| !is_sha256(hash)) {
13725        return Err(invalid_feed(
13726            "non-empty feed did not advertise a valid SHA-256 head",
13727        ));
13728    }
13729
13730    // On first contact the signed head itself is the TOFU checkpoint. A full
13731    // history replay cannot add authority before an anchor exists; mirrors
13732    // still request the complete chain because they promise an archival copy.
13733    let replay_head_only = !require_full_chain
13734        && pinned
13735            .as_ref()
13736            .is_none_or(|checkpoint| checkpoint.head_seq == seq);
13737    let mut after = if replay_head_only {
13738        seq - 1
13739    } else if require_full_chain || pinned.is_none() {
13740        0
13741    } else {
13742        pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq)
13743    };
13744    let mut expected_seq = after + 1;
13745    let mut previous_hash = if require_full_chain || pinned.is_none() || replay_head_only {
13746        None
13747    } else {
13748        pinned
13749            .as_ref()
13750            .and_then(|checkpoint| checkpoint.feed_hash.clone())
13751    };
13752    let mut identity: Option<FeedIdentity> = None;
13753    let mut anchor: Option<String> = None;
13754    let mut head_entry: Option<FeedItem> = None;
13755    let mut all_entries = Vec::new();
13756    let mut observed_entries = Vec::new();
13757    let replay_count = seq
13758        .checked_sub(after)
13759        .ok_or_else(|| invalid_feed("feed replay range moved backward"))?;
13760    if replay_count > MAX_FEED_REPLAY_ENTRIES {
13761        return Err(invalid_feed(format!(
13762            "feed replay requires {replay_count} entries, over the client cap"
13763        )));
13764    }
13765    let mut replay_bytes = 0u64;
13766
13767    loop {
13768        let feed_bytes = ensure_raw_ok(
13769            request_raw(
13770                cfg,
13771                "GET",
13772                &format!("/api/hub/brains/{brain}/feed?after={after}&limit=100"),
13773                None,
13774                Auth::Required,
13775                MAX_FEED_RESPONSE_BYTES,
13776            )?,
13777            "subscribe feed",
13778        )?;
13779        let feed: FeedResponse = serde_json::from_slice(&feed_bytes)
13780            .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
13781        if feed.head_seq != seq || feed.feed_hash != advertised_hash {
13782            return Err(invalid_feed("brain card and feed head disagree"));
13783        }
13784        if feed.entries.len() > FEED_PAGE_LIMIT {
13785            return Err(invalid_feed("feed page exceeds the requested entry limit"));
13786        }
13787        if feed.scope_limited {
13788            if require_full_chain {
13789                return Err(invalid_feed(
13790                    "path-scoped grants cannot verify a full snapshot chain",
13791                ));
13792            }
13793            return Ok(VerifiedRemote {
13794                head: Head {
13795                    brain: resolved_brain,
13796                    seq,
13797                    updated_at,
13798                    feed_hash: advertised_hash,
13799                    verified: false,
13800                },
13801                identity: None,
13802                head_entry: None,
13803                entries: Vec::new(),
13804                anchor: None,
13805            });
13806        }
13807        let page_identity = feed
13808            .identity
13809            .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
13810        let page_anchor = verify_identity_chain(&page_identity, pinned.as_ref())?;
13811        if identity
13812            .as_ref()
13813            .is_some_and(|existing| existing != &page_identity)
13814        {
13815            return Err(invalid_feed("identity changed while reading the feed"));
13816        }
13817        if anchor
13818            .as_ref()
13819            .is_some_and(|existing| existing != &page_anchor)
13820        {
13821            return Err(invalid_feed(
13822                "identity anchor changed while reading the feed",
13823            ));
13824        }
13825        identity = Some(page_identity.clone());
13826        if anchor.is_none() {
13827            anchor = Some(page_anchor);
13828        }
13829        if feed.entries.is_empty() {
13830            return Err(invalid_feed("feed page was empty before the signed head"));
13831        }
13832
13833        for item in feed.entries {
13834            if item.entry.seq != expected_seq {
13835                return Err(invalid_feed(format!(
13836                    "expected entry {expected_seq}, feed served {}",
13837                    item.entry.seq
13838                )));
13839            }
13840            if item.entry.seq > seq {
13841                return Err(invalid_feed("feed advanced past the card snapshot"));
13842            }
13843            if !replay_head_only && item.entry.prev_entry_hash != previous_hash {
13844                return Err(invalid_feed(format!(
13845                    "entry {} does not chain to the local checkpoint",
13846                    item.entry.seq
13847                )));
13848            }
13849            verify_feed_item(&item, &page_identity)?;
13850            reject_retired_signer_after_checkpoint(&page_identity, pinned.as_ref(), &item)?;
13851            replay_bytes = replay_bytes.saturating_add(
13852                serde_json::to_vec(&item)
13853                    .map_err(|_| invalid_feed("could not size feed entry"))?
13854                    .len() as u64,
13855            );
13856            if replay_bytes > MAX_FEED_REPLAY_BYTES {
13857                return Err(invalid_feed("feed replay metadata exceeds the client cap"));
13858            }
13859            previous_hash = Some(item.hash.clone());
13860            after = item.entry.seq;
13861            expected_seq = expected_seq
13862                .checked_add(1)
13863                .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
13864            if require_full_chain {
13865                all_entries.push(item.clone());
13866            }
13867            observed_entries.push(item.clone());
13868            head_entry = Some(item);
13869        }
13870        if after == seq {
13871            break;
13872        }
13873    }
13874
13875    if head_entry.as_ref().map(|item| &item.hash) != advertised_hash.as_ref() {
13876        return Err(invalid_feed(
13877            "verified chain does not converge on the advertised head",
13878        ));
13879    }
13880    let identity = identity.ok_or_else(|| invalid_feed("feed has no brain identity"))?;
13881    let anchor = anchor.ok_or_else(|| invalid_feed("feed has no identity anchor"))?;
13882    verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &observed_entries, seq)?;
13883    save_canonical_pin_and_alias(
13884        cfg,
13885        &trust_directory,
13886        brain,
13887        &resolved_brain,
13888        TrustState {
13889            v: 2,
13890            origin: normalized_origin(&cfg.hub)?,
13891            requested: resolved_brain.clone(),
13892            brain: resolved_brain.clone(),
13893            home: None,
13894            anchor: anchor.clone(),
13895            current: format!("ed25519:{}", identity.fingerprint),
13896            head_seq: seq,
13897            feed_hash: advertised_hash.clone(),
13898            rotations: identity.rotations.clone(),
13899            hub_signer: pinned.as_ref().and_then(|state| state.hub_signer.clone()),
13900            protocol_profile: pinned
13901                .as_ref()
13902                .and_then(|state| state.protocol_profile.clone()),
13903        },
13904        alias_binding.as_ref(),
13905    )?;
13906    Ok(VerifiedRemote {
13907        head: Head {
13908            brain: resolved_brain,
13909            seq,
13910            updated_at,
13911            feed_hash: advertised_hash,
13912            verified: true,
13913        },
13914        identity: Some(identity),
13915        head_entry,
13916        entries: all_entries,
13917        anchor: Some(anchor),
13918    })
13919}
13920
13921/// Read and locally verify the brain's current signed feed head. Identity
13922/// rotation is accepted only through an old-key-signed chain rooted at the
13923/// local TOFU anchor; sequence and hash checkpoints reject rollback and
13924/// equivocation across invocations.
13925pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
13926    if let Some(verified) = v2_verified_head(cfg, brain)? {
13927        let observation = Head {
13928            brain: verified.brain_id.clone(),
13929            seq: verified.pointer.as_ref().map_or(0, |pointer| pointer.seq),
13930            updated_at: verified
13931                .pointer
13932                .as_ref()
13933                .map(|pointer| pointer.signed_at.clone()),
13934            feed_hash: verified
13935                .pointer
13936                .as_ref()
13937                .map(|pointer| pointer.feed_hash.clone()),
13938            verified: true,
13939        };
13940        accept_v2_head(cfg, &verified)?;
13941        return Ok(observation);
13942    }
13943    Ok(verified_remote_head(cfg, brain, false)?.head)
13944}
13945
13946#[cfg(test)]
13947mod tests {
13948    use super::*;
13949
13950    const TEST_BRAIN_ID: &str = "01j5qc3v9k4ym8rwbn2tqe6f7d";
13951
13952    fn upload_declaration(index: usize, coordinate_len: usize) -> Value {
13953        json!({
13954            "sha256": "a".repeat(64),
13955            "bytes": 10,
13956            "coordinates": [format!("records/notes/{}-{}.md", "n".repeat(coordinate_len), index)],
13957        })
13958    }
13959
13960    #[test]
13961    fn upload_reservations_batch_by_count_and_by_size() {
13962        // A real store's first push declares one blob per changed file, which
13963        // does not fit in one request. Every batch has to stay inside both
13964        // bounds, and concatenating them has to reproduce the original order.
13965        let declarations: Vec<Value> = (0..5_000).map(|i| upload_declaration(i, 8)).collect();
13966        let batches = batch_upload_declarations(declarations.clone());
13967
13968        assert!(batches.len() > 1, "5,000 blobs must not ride one request");
13969        for batch in &batches {
13970            assert!(batch.len() <= MAX_UPLOAD_RESERVATION_BLOBS);
13971            let bytes = serde_json::to_string(&json!({ "blobs": batch }))
13972                .expect("batch serializes")
13973                .len();
13974            assert!(
13975                bytes <= MAX_UPLOAD_RESERVATION_BYTES + 1_024,
13976                "batch body {bytes} exceeds the reservation budget"
13977            );
13978        }
13979        let flattened: Vec<Value> = batches.into_iter().flatten().collect();
13980        assert_eq!(
13981            flattened, declarations,
13982            "batching must preserve the set and order"
13983        );
13984    }
13985
13986    #[test]
13987    fn wide_coordinate_sets_shrink_batches_below_the_count_bound() {
13988        // Byte budget, not just count. One hash can carry many coordinates
13989        // (the same bytes reachable at several paths), so a batch sized only by
13990        // count would blow the body budget on a deduplicated store.
13991        let declarations: Vec<Value> = (0..2_000)
13992            .map(|index| {
13993                json!({
13994                    "sha256": "a".repeat(64),
13995                    "bytes": 10,
13996                    "coordinates": (0..24)
13997                        .map(|slot| format!(
13998                            "records/workflowy-nodes/2019/02/a-fairly-long-node-title-{index}-{slot}-abcd1234.md"
13999                        ))
14000                        .collect::<Vec<_>>(),
14001                })
14002            })
14003            .collect();
14004        let batches = batch_upload_declarations(declarations);
14005        assert!(
14006            batches[0].len() < MAX_UPLOAD_RESERVATION_BLOBS,
14007            "wide coordinate sets must bound the batch by size"
14008        );
14009        for batch in &batches {
14010            let bytes = serde_json::to_string(&json!({ "blobs": batch }))
14011                .expect("batch serializes")
14012                .len();
14013            assert!(bytes <= MAX_UPLOAD_RESERVATION_BYTES + 2_048);
14014        }
14015    }
14016
14017    #[test]
14018    fn a_small_push_still_rides_exactly_one_request() {
14019        let declarations: Vec<Value> = (0..3).map(|i| upload_declaration(i, 8)).collect();
14020        assert_eq!(batch_upload_declarations(declarations).len(), 1);
14021        assert!(batch_upload_declarations(Vec::new()).is_empty());
14022    }
14023
14024    #[test]
14025    fn exact_source_move_becomes_one_provenance_preserving_rename() {
14026        let hash = "a".repeat(64);
14027        let operations = vec![
14028            json!({
14029                "op": "put",
14030                "path": "sources/curated/item.md",
14031                "expected": { "kind": "absent" },
14032                "blob": hash,
14033                "bytes": 19,
14034            }),
14035            json!({
14036                "op": "delete",
14037                "path": "sources/inbox/item.md",
14038                "expected": { "kind": "blob", "hash": hash },
14039            }),
14040        ];
14041
14042        assert_eq!(
14043            infer_exact_source_promotions(operations),
14044            vec![json!({
14045                "op": "rename",
14046                "from": "sources/inbox/item.md",
14047                "to": "sources/curated/item.md",
14048                "expected_from": { "kind": "blob", "hash": hash },
14049                "expected_to": { "kind": "absent" },
14050                "blob": hash,
14051                "bytes": 19,
14052            })]
14053        );
14054    }
14055
14056    #[test]
14057    fn duplicate_source_bytes_never_guess_which_evidence_was_promoted() {
14058        let hash = "b".repeat(64);
14059        let operations = vec![
14060            json!({
14061                "op": "delete",
14062                "path": "sources/inbox/a.md",
14063                "expected": { "kind": "blob", "hash": hash },
14064            }),
14065            json!({
14066                "op": "delete",
14067                "path": "sources/inbox/b.md",
14068                "expected": { "kind": "blob", "hash": hash },
14069            }),
14070            json!({
14071                "op": "put",
14072                "path": "sources/curated/item.md",
14073                "expected": { "kind": "absent" },
14074                "blob": hash,
14075                "bytes": 19,
14076            }),
14077        ];
14078
14079        assert_eq!(
14080            infer_exact_source_promotions(operations.clone()),
14081            operations,
14082            "an ambiguous filesystem diff must reach the hub unchanged and fail closed"
14083        );
14084    }
14085
14086    #[test]
14087    fn accepted_source_promotion_advances_the_local_baseline_exactly() {
14088        let hash = "c".repeat(64);
14089        let mut candidate = std::collections::BTreeMap::from([(
14090            "sources/inbox/item.md".to_string(),
14091            V2BaselineFile {
14092                sha256: hash.clone(),
14093                bytes: 19,
14094                proof: None,
14095            },
14096        )]);
14097        let mut candidate_assets = std::collections::BTreeMap::new();
14098        let operations = vec![
14099            json!({
14100                "op": "rename",
14101                "from": "sources/inbox/item.md",
14102                "to": "sources/curated/item.md",
14103                "expected_from": { "kind": "blob", "hash": hash },
14104                "expected_to": { "kind": "absent" },
14105                "blob": hash,
14106                "bytes": 19,
14107            }),
14108            json!({
14109                "op": "put",
14110                "path": "records/rsvps/item.md",
14111                "expected": { "kind": "absent" },
14112                "blob": "d".repeat(64),
14113                "bytes": 23,
14114            }),
14115        ];
14116
14117        assert!(!apply_generated_v2_operations(
14118            &operations,
14119            &std::collections::BTreeMap::new(),
14120            &mut candidate,
14121            &mut candidate_assets,
14122        )
14123        .unwrap());
14124        assert!(!candidate.contains_key("sources/inbox/item.md"));
14125        assert_eq!(
14126            candidate
14127                .get("sources/curated/item.md")
14128                .map(|file| (&file.sha256, file.bytes)),
14129            Some((&hash, 19))
14130        );
14131        assert_eq!(
14132            candidate
14133                .get("records/rsvps/item.md")
14134                .map(|file| (file.sha256.as_str(), file.bytes)),
14135            Some((
14136                "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
14137                23
14138            ))
14139        );
14140    }
14141
14142    fn merge_fixture(
14143        base: Option<&str>,
14144        remote: Option<&str>,
14145        local: Option<&str>,
14146        keep_local: bool,
14147    ) -> V2PulledMerge<String> {
14148        let map = |value: Option<&str>| {
14149            value
14150                .map(|value| [("records/a.md".to_string(), value.to_string())])
14151                .into_iter()
14152                .flatten()
14153                .collect::<std::collections::BTreeMap<_, _>>()
14154        };
14155        merge_v2_pulled_records(
14156            &map(base),
14157            &map(remote),
14158            &map(local),
14159            |value, _| value.clone(),
14160            |value, _| value.clone(),
14161            |_| keep_local,
14162        )
14163    }
14164
14165    #[test]
14166    fn v2_pull_three_way_merge_never_discards_a_local_only_change() {
14167        let path = "records/a.md".to_string();
14168
14169        let local_add = merge_fixture(None, None, Some("local"), false);
14170        assert_eq!(
14171            local_add.records.get(&path).map(String::as_str),
14172            Some("local")
14173        );
14174        assert!(local_add.accept_remote.is_empty());
14175        assert!(local_add.conflicts.is_empty());
14176
14177        let local_edit = merge_fixture(Some("old"), Some("old"), Some("local"), false);
14178        assert_eq!(
14179            local_edit.records.get(&path).map(String::as_str),
14180            Some("local")
14181        );
14182        assert!(local_edit.accept_remote.is_empty());
14183        assert!(local_edit.conflicts.is_empty());
14184
14185        let local_delete = merge_fixture(Some("old"), Some("old"), None, false);
14186        assert!(!local_delete.records.contains_key(&path));
14187        assert!(local_delete.accept_remote.is_empty());
14188        assert!(local_delete.conflicts.is_empty());
14189
14190        let remote_edit = merge_fixture(Some("old"), Some("remote"), Some("old"), false);
14191        assert_eq!(
14192            remote_edit.records.get(&path).map(String::as_str),
14193            Some("remote")
14194        );
14195        assert!(remote_edit.accept_remote.contains(&path));
14196        assert!(remote_edit.conflicts.is_empty());
14197
14198        let remote_delete = merge_fixture(Some("old"), None, Some("old"), false);
14199        assert!(!remote_delete.records.contains_key(&path));
14200        assert!(remote_delete.accept_remote.contains(&path));
14201        assert!(remote_delete.conflicts.is_empty());
14202
14203        let same_edit = merge_fixture(Some("old"), Some("same"), Some("same"), false);
14204        assert_eq!(
14205            same_edit.records.get(&path).map(String::as_str),
14206            Some("same")
14207        );
14208        assert!(same_edit.accept_remote.contains(&path));
14209        assert!(same_edit.conflicts.is_empty());
14210
14211        let conflict = merge_fixture(Some("old"), Some("remote"), Some("local"), false);
14212        assert_eq!(conflict.conflicts, vec![path.clone()]);
14213        assert_eq!(
14214            conflict.records.get(&path).map(String::as_str),
14215            Some("local")
14216        );
14217        assert!(conflict.accept_remote.is_empty());
14218
14219        let kept_home = merge_fixture(Some("old"), Some("remote"), Some("local"), true);
14220        assert_eq!(
14221            kept_home.records.get(&path).map(String::as_str),
14222            Some("local")
14223        );
14224        assert!(kept_home.accept_remote.is_empty());
14225        assert!(kept_home.conflicts.is_empty());
14226    }
14227
14228    #[test]
14229    fn v2_asset_hosting_resume_is_an_explicit_local_policy_transition() {
14230        let path = "sources/report.pdf";
14231        let record = crate::AssetRecord {
14232            path: path.to_string(),
14233            sha256: "a".repeat(64),
14234            bytes: 42,
14235            media_type: "application/pdf".to_string(),
14236            wrappers: vec!["gzip".to_string()],
14237            required: true,
14238        };
14239        let mut remote = V2BaselineAsset {
14240            blob_sha256: record.sha256.clone(),
14241            bytes: record.bytes,
14242            media_type: record.media_type.clone(),
14243            wrappers: record.wrappers.clone(),
14244            required: record.required,
14245            disposition: "withheld".to_string(),
14246            leaf_hash: "b".repeat(64),
14247        };
14248
14249        assert!(v2_asset_resumes_hosting(
14250            Some(&remote),
14251            path,
14252            &record,
14253            "hosted"
14254        ));
14255        assert!(!v2_asset_resumes_hosting(
14256            Some(&remote),
14257            path,
14258            &record,
14259            "withheld"
14260        ));
14261
14262        remote.disposition = "hosted".to_string();
14263        assert!(!v2_asset_resumes_hosting(
14264            Some(&remote),
14265            path,
14266            &record,
14267            "hosted"
14268        ));
14269
14270        remote.disposition = "withheld".to_string();
14271        remote.blob_sha256 = "c".repeat(64);
14272        assert!(!v2_asset_resumes_hosting(
14273            Some(&remote),
14274            path,
14275            &record,
14276            "hosted"
14277        ));
14278        assert!(!v2_asset_resumes_hosting(None, path, &record, "hosted"));
14279    }
14280
14281    #[test]
14282    fn take_remote_resolution_rebinds_to_the_exact_current_manifest() {
14283        let path = "records/team/alpha.md".to_string();
14284        let deleted_path = "records/team/deleted.md".to_string();
14285        let coordinate = |sha256: Option<String>, bytes: Option<u64>| V2ConflictCoordinate {
14286            sha256,
14287            bytes,
14288            file: None,
14289        };
14290        let files = vec![
14291            V2ConflictFile {
14292                path: path.clone(),
14293                base: coordinate(None, None),
14294                local: coordinate(Some("b".repeat(64)), Some(7)),
14295                remote: coordinate(Some("a".repeat(64)), Some(5)),
14296            },
14297            V2ConflictFile {
14298                path: deleted_path.clone(),
14299                base: coordinate(Some("c".repeat(64)), Some(9)),
14300                local: coordinate(Some("d".repeat(64)), Some(11)),
14301                remote: coordinate(None, None),
14302            },
14303        ];
14304        let proven = V2BaselineFile {
14305            sha256: "a".repeat(64),
14306            bytes: 5,
14307            proof: None,
14308        };
14309        let current = [(path.clone(), proven.clone())]
14310            .into_iter()
14311            .collect::<std::collections::BTreeMap<_, _>>();
14312
14313        let (selected, deleted) = v2_take_remote_selection(&files, &current).unwrap();
14314        assert_eq!(selected.get(&path).unwrap().sha256, proven.sha256);
14315        assert_eq!(deleted, vec![deleted_path.clone()]);
14316
14317        let changed = [(
14318            path.clone(),
14319            V2BaselineFile {
14320                sha256: "e".repeat(64),
14321                bytes: 5,
14322                proof: None,
14323            },
14324        )]
14325        .into_iter()
14326        .collect::<std::collections::BTreeMap<_, _>>();
14327        assert!(v2_take_remote_selection(&files, &changed).is_err());
14328
14329        let resurrected = [
14330            (path, proven),
14331            (
14332                deleted_path,
14333                V2BaselineFile {
14334                    sha256: "f".repeat(64),
14335                    bytes: 13,
14336                    proof: None,
14337                },
14338            ),
14339        ]
14340        .into_iter()
14341        .collect::<std::collections::BTreeMap<_, _>>();
14342        assert!(v2_take_remote_selection(&files, &resurrected).is_err());
14343    }
14344
14345    #[cfg(target_os = "linux")]
14346    #[test]
14347    fn linux_snapshot_install_is_atomic_for_create_and_exchange() {
14348        use std::os::fd::AsRawFd as _;
14349
14350        let sandbox = tempfile::TempDir::new().unwrap();
14351        let parent = std::fs::File::open(sandbox.path()).unwrap();
14352        let stage = std::ffi::CString::new("stage").unwrap();
14353        let destination = std::ffi::CString::new("brain").unwrap();
14354
14355        std::fs::create_dir(sandbox.path().join("stage")).unwrap();
14356        std::fs::write(sandbox.path().join("stage/value"), b"created").unwrap();
14357        install_stage_at(
14358            parent.as_raw_fd(),
14359            stage.as_c_str(),
14360            destination.as_c_str(),
14361            false,
14362        )
14363        .unwrap();
14364        assert!(!sandbox.path().join("stage").exists());
14365        assert_eq!(
14366            std::fs::read(sandbox.path().join("brain/value")).unwrap(),
14367            b"created"
14368        );
14369
14370        std::fs::create_dir(sandbox.path().join("stage")).unwrap();
14371        std::fs::write(sandbox.path().join("stage/value"), b"replacement").unwrap();
14372        install_stage_at(
14373            parent.as_raw_fd(),
14374            stage.as_c_str(),
14375            destination.as_c_str(),
14376            true,
14377        )
14378        .unwrap();
14379        assert_eq!(
14380            std::fs::read(sandbox.path().join("brain/value")).unwrap(),
14381            b"replacement"
14382        );
14383        assert_eq!(
14384            std::fs::read(sandbox.path().join("stage/value")).unwrap(),
14385            b"created",
14386            "RENAME_EXCHANGE must leave the predecessor at the unique stage name"
14387        );
14388    }
14389
14390    struct SignedRemoteFixture {
14391        card: String,
14392        feed: String,
14393        key: AgentSigningKey,
14394        identity: FeedIdentity,
14395    }
14396
14397    fn signed_remote_fixture() -> SignedRemoteFixture {
14398        let rng = ring::rand::SystemRandom::new();
14399        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
14400        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
14401        let (public_key, multikey) = public_identity_for(&pair);
14402        let identity = FeedIdentity {
14403            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
14404            public_key_spki: public_key.clone(),
14405            previous: Vec::new(),
14406            rotations: Vec::new(),
14407        };
14408        let mut entry = FeedEntry {
14409            v: 1,
14410            seq: 1,
14411            ts: "2026-07-30T12:00:00.000Z".to_string(),
14412            brain: multikey.clone(),
14413            public_key: public_key.clone(),
14414            kind: "push".to_string(),
14415            op: "snapshot".to_string(),
14416            pack_sha256: "a".repeat(64),
14417            files: Vec::new(),
14418            removed: Vec::new(),
14419            prev_entry_hash: None,
14420            sig: String::new(),
14421        };
14422        let unsigned = UnsignedFeedEntry {
14423            v: entry.v,
14424            seq: entry.seq,
14425            ts: &entry.ts,
14426            brain: &entry.brain,
14427            public_key: &entry.public_key,
14428            kind: &entry.kind,
14429            op: &entry.op,
14430            pack_sha256: &entry.pack_sha256,
14431            files: &entry.files,
14432            removed: &entry.removed,
14433            prev_entry_hash: &entry.prev_entry_hash,
14434        };
14435        entry.sig =
14436            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
14437        let mut exact = serde_json::to_vec(&entry).unwrap();
14438        exact.push(b'\n');
14439        let hash = content_sha256(&exact);
14440        let card = json!({
14441            "id": TEST_BRAIN_ID,
14442            "headSeq": 1,
14443            "feedHash": hash,
14444            "identity": identity.clone(),
14445        })
14446        .to_string();
14447        let feed = json!({
14448            "headSeq": 1,
14449            "feedHash": hash,
14450            "identity": identity.clone(),
14451            "entries": [{"hash": hash, "entry": entry}],
14452            "scopeLimited": false,
14453        })
14454        .to_string();
14455        SignedRemoteFixture {
14456            card,
14457            feed,
14458            key: AgentSigningKey {
14459                pkcs8: pkcs8.as_ref().to_vec(),
14460                multikey,
14461                public_key_spki: public_key,
14462            },
14463            identity,
14464        }
14465    }
14466
14467    #[test]
14468    fn v1_edit_disclosure_accepts_minimal_and_superset_forms() {
14469        let file = |path: &str, byte: char| FeedFile {
14470            path: path.to_string(),
14471            sha256: byte.to_string().repeat(64),
14472            bytes: 1,
14473        };
14474        let a0 = file("records/a.md", 'a');
14475        let a1 = file("records/a.md", 'b');
14476        let stable = file("records/stable.md", 'c');
14477        let added = file("records/added.md", 'd');
14478        let removed_file = file("records/removed.md", 'e');
14479        let previous = vec![a0, stable.clone(), removed_file.clone()];
14480        let resulting = vec![a1.clone(), stable.clone(), added.clone()];
14481        let removed = vec![removed_file.path.clone()];
14482
14483        assert_eq!(
14484            verify_v1_manifest_disclosure(
14485                "edit",
14486                &previous,
14487                &resulting,
14488                &[a1.clone(), added.clone()],
14489                &removed,
14490            ),
14491            Ok(())
14492        );
14493        assert_eq!(
14494            verify_v1_manifest_disclosure(
14495                "edit",
14496                &previous,
14497                &resulting,
14498                &[stable.clone(), added.clone(), a1.clone()],
14499                &removed,
14500            ),
14501            Ok(())
14502        );
14503        assert_eq!(
14504            verify_v1_manifest_disclosure(
14505                "edit",
14506                &previous,
14507                &resulting,
14508                std::slice::from_ref(&added),
14509                &removed,
14510            ),
14511            Err(V1DisclosureError::EditMissingChange)
14512        );
14513        assert_eq!(
14514            verify_v1_manifest_disclosure(
14515                "edit",
14516                &previous,
14517                &resulting,
14518                &[file("records/a.md", 'f'), added.clone()],
14519                &removed,
14520            ),
14521            Err(V1DisclosureError::EditFalseFile)
14522        );
14523        assert_eq!(
14524            verify_v1_manifest_disclosure(
14525                "edit",
14526                &previous,
14527                &resulting,
14528                &[a1.clone(), added.clone()],
14529                &[],
14530            ),
14531            Err(V1DisclosureError::RemovedMismatch)
14532        );
14533        assert_eq!(
14534            verify_v1_manifest_disclosure(
14535                "push",
14536                &previous,
14537                &resulting,
14538                &[added.clone(), stable, a1],
14539                &removed,
14540            ),
14541            Ok(())
14542        );
14543        assert_eq!(
14544            verify_v1_manifest_disclosure("push", &previous, &resulting, &[added], &removed,),
14545            Err(V1DisclosureError::PushManifestMismatch)
14546        );
14547    }
14548
14549    #[test]
14550    fn wire_sequences_are_rejected_at_their_endpoint_count_limits() {
14551        let fixture = signed_remote_fixture();
14552        let feed: Value = serde_json::from_str(&fixture.feed).unwrap();
14553        let item = feed["entries"][0].to_string();
14554        let oversized_page = format!(
14555            "{{\"headSeq\":1,\"feedHash\":null,\"identity\":null,\"entries\":[{}],\"scopeLimited\":false}}",
14556            std::iter::repeat_n(item.as_str(), FEED_PAGE_LIMIT + 1)
14557                .collect::<Vec<_>>()
14558                .join(",")
14559        );
14560        assert!(serde_json::from_str::<FeedResponse>(&oversized_page).is_err());
14561
14562        let oversized_identity = format!(
14563            "{{\"fingerprint\":\"fp\",\"publicKeySpki\":\"spki\",\"previous\":[],\"rotations\":[{}]}}",
14564            std::iter::repeat_n("\"rotation\"", MAX_IDENTITY_ROTATIONS + 1)
14565                .collect::<Vec<_>>()
14566                .join(",")
14567        );
14568        assert!(serde_json::from_str::<FeedIdentity>(&oversized_identity).is_err());
14569
14570        let file = r#"{"path":"a","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bytes":1}"#;
14571        let oversized_entry = format!(
14572            "{{\"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\"}}",
14573            "a".repeat(64),
14574            std::iter::repeat_n(file, MAX_PUSH_FILES + 1)
14575                .collect::<Vec<_>>()
14576                .join(",")
14577        );
14578        assert!(serde_json::from_str::<FeedEntry>(&oversized_entry).is_err());
14579    }
14580
14581    #[test]
14582    fn bulk_confirmation_parser_accepts_only_the_exact_wire_shape() {
14583        let id = "01arz3ndektsv4rrffq69g5fav";
14584        let digest = "a".repeat(64);
14585        assert_eq!(
14586            V2BulkConfirmation::parse(&format!("{id}:{digest}")).unwrap(),
14587            V2BulkConfirmation {
14588                id: id.to_string(),
14589                digest,
14590            }
14591        );
14592        for invalid in [
14593            "",
14594            "01arz3ndektsv4rrffq69g5fav",
14595            "01ARZ3NDEKTSV4RRFFQ69G5FAV:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
14596            "01arz3ndektsv4rrffq69g5fav:ABCDEF",
14597            "01arz3ndektsv4rrffq69g5fav:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
14598        ] {
14599            assert!(matches!(
14600                V2BulkConfirmation::parse(invalid),
14601                Err(LinkError::InvalidPack { .. })
14602            ));
14603        }
14604    }
14605
14606    fn scripted_json_hub(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle<()>) {
14607        use std::io::{BufRead as _, BufReader, Read as _, Write as _};
14608        use std::net::TcpListener;
14609
14610        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
14611        let url = format!("http://{}", listener.local_addr().unwrap());
14612        let handle = std::thread::spawn(move || {
14613            for (status, body) in responses {
14614                let (stream, _) = listener.accept().unwrap();
14615                let mut reader = BufReader::new(stream);
14616                let mut line = String::new();
14617                reader.read_line(&mut line).unwrap();
14618                let mut content_length = 0usize;
14619                loop {
14620                    line.clear();
14621                    reader.read_line(&mut line).unwrap();
14622                    if line == "\r\n" || line == "\n" || line.is_empty() {
14623                        break;
14624                    }
14625                    if let Some((name, value)) = line.split_once(':') {
14626                        if name.eq_ignore_ascii_case("content-length") {
14627                            content_length = value.trim().parse().unwrap();
14628                        }
14629                    }
14630                }
14631                let mut request_body = vec![0_u8; content_length];
14632                reader.read_exact(&mut request_body).unwrap();
14633                let response = format!(
14634                    "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
14635                    body.len()
14636                );
14637                reader.get_mut().write_all(response.as_bytes()).unwrap();
14638            }
14639        });
14640        (url, handle)
14641    }
14642
14643    fn routed_json_hub(
14644        requests: usize,
14645        mut respond: impl FnMut(&str) -> (u16, String) + Send + 'static,
14646    ) -> (String, std::thread::JoinHandle<()>) {
14647        use std::io::{BufRead as _, BufReader, Read as _, Write as _};
14648        use std::net::TcpListener;
14649
14650        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
14651        let url = format!("http://{}", listener.local_addr().unwrap());
14652        let handle = std::thread::spawn(move || {
14653            for _ in 0..requests {
14654                let (stream, _) = listener.accept().unwrap();
14655                let mut reader = BufReader::new(stream);
14656                let mut line = String::new();
14657                reader.read_line(&mut line).unwrap();
14658                let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
14659                let mut content_length = 0usize;
14660                loop {
14661                    line.clear();
14662                    reader.read_line(&mut line).unwrap();
14663                    if line == "\r\n" || line == "\n" || line.is_empty() {
14664                        break;
14665                    }
14666                    if let Some((name, value)) = line.split_once(':') {
14667                        if name.eq_ignore_ascii_case("content-length") {
14668                            content_length = value.trim().parse().unwrap();
14669                        }
14670                    }
14671                }
14672                let mut request_body = vec![0_u8; content_length];
14673                reader.read_exact(&mut request_body).unwrap();
14674                let (status, body) = respond(&path);
14675                let response = format!(
14676                    "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
14677                    body.len()
14678                );
14679                reader.get_mut().write_all(response.as_bytes()).unwrap();
14680            }
14681        });
14682        (url, handle)
14683    }
14684
14685    fn test_hub_config(hub: String, state_dir: PathBuf) -> HubConfig {
14686        HubConfig {
14687            hub,
14688            key: Some("test-key".to_string()),
14689            agent_key: None,
14690            brain_key: None,
14691            state_dir,
14692            store_selected: false,
14693        }
14694    }
14695
14696    #[test]
14697    fn linkmd_sig_v2_is_bound_to_the_exact_hub_origin() {
14698        use ring::signature::KeyPair as _;
14699
14700        let rng = ring::rand::SystemRandom::new();
14701        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
14702        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
14703        let (spki, multikey) = public_identity_for(&pair);
14704        let key = AgentSigningKey {
14705            pkcs8: pkcs8.as_ref().to_vec(),
14706            multikey,
14707            public_key_spki: spki,
14708        };
14709        let header = linkmd_sig_header(
14710            &key,
14711            "https://hub-a.example",
14712            "post",
14713            "/api/hub/brains/brain/push?mode=exact",
14714            Some("{\"ok\":true}"),
14715        )
14716        .unwrap();
14717        assert!(header.starts_with("LinkMD-Sig v2,"));
14718        let ts = header
14719            .split(",ts=")
14720            .nth(1)
14721            .unwrap()
14722            .split(',')
14723            .next()
14724            .unwrap();
14725        let signature = URL_SAFE_NO_PAD
14726            .decode(header.rsplit(",sig=").next().unwrap())
14727            .unwrap();
14728        let body_hash = format!("{:x}", Sha256::digest(b"{\"ok\":true}"));
14729        let accepted = format!(
14730            "v2\nhttps://hub-a.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
14731        );
14732        let replayed = format!(
14733            "v2\nhttps://hub-b.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
14734        );
14735        let public = pair.public_key().as_ref();
14736        assert!(UnparsedPublicKey::new(&ED25519, public)
14737            .verify(accepted.as_bytes(), &signature)
14738            .is_ok());
14739        assert!(
14740            UnparsedPublicKey::new(&ED25519, public)
14741                .verify(replayed.as_bytes(), &signature)
14742                .is_err(),
14743            "a proof captured at hub A must not authenticate at hub B"
14744        );
14745    }
14746
14747    #[test]
14748    fn explicit_brain_id_rejects_a_substituted_card_before_feed_trust() {
14749        let other = "01j5qc3v9k4ym8rwbn2tqe6f7e";
14750        let card = json!({
14751            "id": other,
14752            "headSeq": 0,
14753            "identity": signed_remote_fixture().identity,
14754        })
14755        .to_string();
14756        let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
14757        let state = tempfile::tempdir().unwrap();
14758        let cfg = test_hub_config(hub, state.path().to_path_buf());
14759        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
14760        assert!(
14761            error.contains("differs from the explicitly requested"),
14762            "{error}"
14763        );
14764        server.join().unwrap();
14765    }
14766
14767    #[test]
14768    fn empty_brain_identity_is_pinned_and_cannot_be_substituted_later() {
14769        let first = signed_remote_fixture().identity;
14770        let second = signed_remote_fixture().identity;
14771        let card = |identity: FeedIdentity| {
14772            json!({
14773                "id": TEST_BRAIN_ID,
14774                "headSeq": 0,
14775                "identity": identity,
14776            })
14777            .to_string()
14778        };
14779        let (hub, server) = scripted_json_hub(vec![
14780            (404, "{}".to_string()),
14781            (200, card(first)),
14782            (404, "{}".to_string()),
14783            (200, card(second)),
14784        ]);
14785        let state = tempfile::tempdir().unwrap();
14786        let cfg = test_hub_config(hub, state.path().to_path_buf());
14787        assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
14788        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
14789        assert!(
14790            error.contains("pinned anchor") || error.contains("forked away"),
14791            "{error}"
14792        );
14793        server.join().unwrap();
14794    }
14795
14796    #[test]
14797    fn empty_head_rejects_a_rotation_that_commits_to_hidden_history() {
14798        let old = signed_remote_fixture();
14799        let new = signed_remote_fixture();
14800        let old_pair = ring::signature::Ed25519KeyPair::from_pkcs8(&old.key.pkcs8).unwrap();
14801        let unsigned = serde_json::to_string(&UnsignedRotation {
14802            v: 1,
14803            op: "rotate",
14804            brain: &old.key.multikey,
14805            public_key: &old.key.public_key_spki,
14806            new_brain: &new.key.multikey,
14807            new_public_key: &new.key.public_key_spki,
14808            prior_head_seq: 1,
14809            prior_feed_hash: Some(&"a".repeat(64)),
14810            ts: "2026-07-30T12:00:00.000Z".to_string(),
14811        })
14812        .unwrap();
14813        let signature = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
14814        let rotation = format!(
14815            "{},\"sig\":\"{}\"}}",
14816            &unsigned[..unsigned.len() - 1],
14817            signature
14818        );
14819        let identity = FeedIdentity {
14820            fingerprint: new.key.multikey.trim_start_matches("ed25519:").to_string(),
14821            public_key_spki: new.key.public_key_spki,
14822            previous: vec![PreviousIdentity {
14823                fingerprint: old.key.multikey.trim_start_matches("ed25519:").to_string(),
14824                public_key_spki: old.key.public_key_spki,
14825            }],
14826            rotations: vec![rotation],
14827        };
14828        let card = json!({
14829            "id": TEST_BRAIN_ID,
14830            "headSeq": 0,
14831            "feedHash": null,
14832            "identity": identity,
14833        })
14834        .to_string();
14835        let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
14836        let state = tempfile::tempdir().unwrap();
14837        let cfg = test_hub_config(hub, state.path().to_path_buf());
14838        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
14839        assert!(
14840            error.contains("rotation claims a feed boundary beyond the advertised head"),
14841            "{error}"
14842        );
14843        assert!(
14844            load_trust(&cfg, TEST_BRAIN_ID).unwrap().is_none(),
14845            "an inconsistent empty-head identity must not become the TOFU checkpoint"
14846        );
14847        server.join().unwrap();
14848    }
14849
14850    #[test]
14851    fn trust_checkpoint_rejects_a_later_fork() {
14852        let fixture = signed_remote_fixture();
14853        let mut fork: Value = serde_json::from_str(&fixture.card).unwrap();
14854        fork["feedHash"] = Value::String("b".repeat(64));
14855        let (hub, server) = scripted_json_hub(vec![
14856            (404, "{}".to_string()),
14857            (200, fixture.card),
14858            (200, fixture.feed),
14859            (404, "{}".to_string()),
14860            (200, fork.to_string()),
14861        ]);
14862        let state = tempfile::tempdir().unwrap();
14863        let cfg = test_hub_config(hub, state.path().to_path_buf());
14864        assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
14865        assert!(head(&cfg, TEST_BRAIN_ID).is_err());
14866        server.join().unwrap();
14867    }
14868
14869    #[test]
14870    fn alias_and_canonical_id_share_one_identity_checkpoint() {
14871        let trusted = signed_remote_fixture();
14872        let attacker = signed_remote_fixture();
14873        let (hub, server) = scripted_json_hub(vec![
14874            (404, "{}".to_string()),
14875            (200, trusted.card),
14876            (200, trusted.feed),
14877            (404, "{}".to_string()),
14878            (200, attacker.card),
14879        ]);
14880        let state = tempfile::tempdir().unwrap();
14881        let cfg = test_hub_config(hub, state.path().to_path_buf());
14882        assert!(head(&cfg, "trusted-slug").unwrap().verified);
14883        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
14884        assert!(
14885            error.contains("equivocation")
14886                || error.contains("pinned")
14887                || error.contains("identity"),
14888            "{error}"
14889        );
14890        server.join().unwrap();
14891    }
14892
14893    #[test]
14894    fn moved_alias_requires_exact_explicit_rebind_without_rewriting_history() {
14895        let state = tempfile::tempdir().unwrap();
14896        let cfg = test_hub_config(
14897            "https://hub.example".to_string(),
14898            state.path().to_path_buf(),
14899        );
14900        let directory = open_trust_dir(&cfg).unwrap();
14901        let old = TEST_BRAIN_ID;
14902        let new = "01j5qc3v9k4ym8rwbn2tqe6f7e";
14903        save_alias_in(
14904            &cfg,
14905            &directory,
14906            &AliasBinding {
14907                v: 1,
14908                origin: normalized_origin(&cfg.hub).unwrap(),
14909                requested: "company-brain".to_string(),
14910                brain: old.to_string(),
14911                home: Some("company-brain".to_string()),
14912            },
14913        )
14914        .unwrap();
14915
14916        let error = load_canonical_pin(&cfg, &directory, "company-brain", new).unwrap_err();
14917        assert!(matches!(
14918            error,
14919            LinkError::AliasRebindRequired {
14920                alias,
14921                from,
14922                to
14923            } if alias == "company-brain" && from == old && to == new
14924        ));
14925        let unchanged = load_alias_in(&cfg, &directory, "company-brain")
14926            .unwrap()
14927            .unwrap();
14928        assert_eq!(unchanged.brain, old);
14929        assert_eq!(unchanged.home.as_deref(), Some("company-brain"));
14930    }
14931
14932    #[test]
14933    fn concurrent_aliases_cannot_establish_conflicting_tofu_pins() {
14934        let alpha = signed_remote_fixture();
14935        let beta = signed_remote_fixture();
14936        let alpha_card = alpha.card.clone();
14937        let alpha_feed = alpha.feed.clone();
14938        let beta_card = beta.card.clone();
14939        let beta_feed = beta.feed.clone();
14940        let (hub, server) = routed_json_hub(5, move |path| {
14941            if path.ends_with("/v2/head") {
14942                (404, "{}".to_string())
14943            } else if path.contains("/alpha/feed?") {
14944                (200, alpha_feed.clone())
14945            } else if path.contains("/beta/feed?") {
14946                (200, beta_feed.clone())
14947            } else if path.ends_with("/alpha") {
14948                (200, alpha_card.clone())
14949            } else if path.ends_with("/beta") {
14950                (200, beta_card.clone())
14951            } else {
14952                (500, r#"{"error":"unexpected path"}"#.to_string())
14953            }
14954        });
14955        let state = tempfile::tempdir().unwrap();
14956        let cfg = test_hub_config(hub, state.path().to_path_buf());
14957        let alpha_cfg = cfg.clone();
14958        let beta_cfg = cfg;
14959        let first = std::thread::spawn(move || head(&alpha_cfg, "alpha"));
14960        let second = std::thread::spawn(move || head(&beta_cfg, "beta"));
14961        let results = [first.join().unwrap(), second.join().unwrap()];
14962        assert_eq!(
14963            results.iter().filter(|result| result.is_ok()).count(),
14964            1,
14965            "only one alias identity may establish canonical TOFU: {results:?}"
14966        );
14967        assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
14968        server.join().unwrap();
14969    }
14970
14971    #[cfg(unix)]
14972    #[test]
14973    fn trust_transaction_survives_an_ancestor_swap_without_writing_outside() {
14974        use std::os::unix::fs::symlink;
14975
14976        let fixture = signed_remote_fixture();
14977        let card = json!({
14978            "id": TEST_BRAIN_ID,
14979            "headSeq": 0,
14980            "feedHash": Value::Null,
14981            "identity": fixture.identity,
14982        })
14983        .to_string();
14984        let work = tempfile::tempdir().unwrap();
14985        let outside = tempfile::tempdir().unwrap();
14986        let state = work.path().join("state");
14987        let moved = work.path().join("state-held");
14988        let swap_state = state.clone();
14989        let swap_moved = moved.clone();
14990        let outside_path = outside.path().to_path_buf();
14991        let (hub, server) = routed_json_hub(1, move |_| {
14992            // The client has already opened state/trust before this response.
14993            std::fs::rename(&swap_state, &swap_moved).unwrap();
14994            symlink(&outside_path, &swap_state).unwrap();
14995            (200, card.clone())
14996        });
14997        let cfg = test_hub_config(hub, state);
14998
14999        let verified = verified_remote_head(&cfg, TEST_BRAIN_ID, false).unwrap();
15000        assert_eq!(verified.head.seq, 0);
15001        assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
15002        assert!(std::fs::read_dir(moved.join("trust"))
15003            .unwrap()
15004            .flatten()
15005            .any(|entry| entry.path().extension().is_some_and(|ext| ext == "json")));
15006        server.join().unwrap();
15007    }
15008
15009    #[test]
15010    fn self_custody_push_refuses_an_unrelated_key_before_requesting_an_upload() {
15011        let remote = signed_remote_fixture();
15012        let unrelated = signed_remote_fixture().key;
15013        let (hub, server) = scripted_json_hub(vec![(200, remote.card), (200, remote.feed)]);
15014        let state = tempfile::tempdir().unwrap();
15015        let mut cfg = test_hub_config(hub, state.path().to_path_buf());
15016        cfg.brain_key = Some(unrelated);
15017        let error = sync_push(
15018            &cfg,
15019            TEST_BRAIN_ID,
15020            &[("DB.md".to_string(), "signed local content".to_string())],
15021        )
15022        .unwrap_err()
15023        .to_string();
15024        assert!(
15025            error.contains("not the verified current brain identity"),
15026            "{error}"
15027        );
15028        server.join().unwrap();
15029    }
15030
15031    #[test]
15032    fn rotation_ignores_a_forged_2xx_body_and_requires_verified_postcondition() {
15033        let remote = signed_remote_fixture();
15034        let new = signed_remote_fixture().key;
15035        let state = tempfile::tempdir().unwrap();
15036        let new_file = state.path().join("new.key");
15037        std::fs::write(
15038            &new_file,
15039            format!("{}\n", URL_SAFE_NO_PAD.encode(&new.pkcs8)),
15040        )
15041        .unwrap();
15042        #[cfg(unix)]
15043        {
15044            use std::os::unix::fs::PermissionsExt as _;
15045            std::fs::set_permissions(&new_file, std::fs::Permissions::from_mode(0o600)).unwrap();
15046        }
15047        let forged = json!({
15048            "brain": TEST_BRAIN_ID,
15049            "identity": {
15050                "fingerprint": new.multikey.trim_start_matches("ed25519:"),
15051                "publicKeySpki": new.public_key_spki,
15052            }
15053        })
15054        .to_string();
15055        let (hub, server) = scripted_json_hub(vec![
15056            (404, "{}".to_string()),
15057            (200, remote.card.clone()),
15058            (200, remote.feed.clone()),
15059            (200, forged),
15060            (200, remote.card),
15061            (200, remote.feed),
15062        ]);
15063        let cfg = test_hub_config(hub, state.path().to_path_buf());
15064        let error = rotate_brain_key(&cfg, TEST_BRAIN_ID, &remote.key, &new_file)
15065            .unwrap_err()
15066            .to_string();
15067        assert!(
15068            error.contains("without committing the verified new identity"),
15069            "{error}"
15070        );
15071        server.join().unwrap();
15072    }
15073
15074    #[test]
15075    fn resolve_record_is_derived_from_signed_pack_not_a_query_response() {
15076        let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
15077        let raw = format!(
15078            "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
15079        );
15080        let pack = build_store_pack(&[
15081            (
15082                "DB.md".to_string(),
15083                "---\ntype: db-md\nscope: company\nowner: Test\n---\n".to_string(),
15084            ),
15085            ("records/clients/truth.md".to_string(), raw.clone()),
15086        ])
15087        .unwrap();
15088        let by_id = resolve_from_verified_pack(
15089            "01j5qc3v9k4ym8rwbn2tqe6f7d",
15090            &AddressTarget::Id(record_id.to_string()),
15091            pack.clone(),
15092        )
15093        .unwrap();
15094        assert_eq!(by_id["document"]["summary"], "Signed truth");
15095        assert_eq!(by_id["document"]["body"], "# Signed truth\n");
15096        assert_eq!(
15097            by_id["document"]["contentSha"],
15098            content_sha256(raw.as_bytes())
15099        );
15100
15101        let by_path = resolve_from_verified_pack(
15102            "01j5qc3v9k4ym8rwbn2tqe6f7d",
15103            &AddressTarget::Path("records/clients/truth.md".to_string()),
15104            pack,
15105        )
15106        .unwrap();
15107        assert_eq!(by_path["document"]["id"], record_id);
15108        assert_eq!(by_path["document"]["path"], "records/clients/truth.md");
15109
15110        let wrong_id = resolve_from_verified_record_bytes(
15111            TEST_BRAIN_ID,
15112            &AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7f".to_string()),
15113            "records/clients/truth.md".to_string(),
15114            raw.as_bytes().to_vec(),
15115        )
15116        .unwrap_err()
15117        .to_string();
15118        assert!(wrong_id.contains("id differs"), "{wrong_id}");
15119
15120        let wrong_path = resolve_from_verified_record_bytes(
15121            TEST_BRAIN_ID,
15122            &AddressTarget::Path("records/clients/other.md".to_string()),
15123            "records/clients/truth.md".to_string(),
15124            raw.into_bytes(),
15125        )
15126        .unwrap_err()
15127        .to_string();
15128        assert!(wrong_path.contains("path differs"), "{wrong_path}");
15129    }
15130
15131    #[test]
15132    fn v2_record_locators_return_one_proof_bound_to_the_signed_content_root() {
15133        let path = "records/clients/truth.md";
15134        let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
15135        let raw = format!(
15136            "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
15137        );
15138        let sha256 = content_sha256(raw.as_bytes());
15139        let mut nonce = 0_u128;
15140        let tree = crate::linkmd_v2::build_content_tree(
15141            &[crate::linkmd_v2::ContentFile {
15142                path: path.to_string(),
15143                blob_hash: sha256.clone(),
15144                bytes: raw.len() as u64,
15145            }],
15146            None,
15147            &mut || {
15148                nonce += 1;
15149                format!("{nonce:032x}")
15150            },
15151        )
15152        .unwrap();
15153        let root = tree.root.clone().unwrap();
15154        let mut directory_root = root.clone();
15155        let mut proof = Vec::new();
15156        for component in path.split('/') {
15157            let inclusion =
15158                crate::linkmd_v2::create_proof(&directory_root, component, &tree.nodes).unwrap();
15159            let child = match &inclusion {
15160                crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry.child_hash.clone(),
15161                crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
15162                    panic!("fixture path must have an inclusion proof")
15163                }
15164            };
15165            proof.push(json!({
15166                "directory_root": directory_root,
15167                "component": component,
15168                "proof": inclusion,
15169            }));
15170            directory_root = child;
15171        }
15172        let commit_hash = "c".repeat(64);
15173        let pointer = V2PointerBody {
15174            v: 2,
15175            brain: TEST_BRAIN_ID.to_string(),
15176            seq: 1,
15177            commit_hash: commit_hash.clone(),
15178            feed_hash: "f".repeat(64),
15179            content_root: Some(root.clone()),
15180            asset_root: None,
15181            materializer: "dbmd-projection-v1".to_string(),
15182            signer_epoch: 1,
15183            control_revision: "d".repeat(64),
15184            backup_preparation: "e".repeat(64),
15185            prior_pointer_hash: None,
15186            signed_at: "2026-08-21T12:00:00.000Z".to_string(),
15187        };
15188        let manifest = json!({
15189            "v": 2,
15190            "commit": commit_hash,
15191            "content_root": root,
15192            "files": [{
15193                "path": path,
15194                "sha256": sha256,
15195                "bytes": raw.len(),
15196                "proof": proof,
15197            }],
15198            "next_cursor": Value::Null,
15199        })
15200        .to_string();
15201
15202        let path_manifest = manifest.clone();
15203        let (hub, server) = routed_json_hub(1, move |request| {
15204            assert_eq!(
15205                request,
15206                format!(
15207                    "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Ftruth.md",
15208                    "c".repeat(64)
15209                )
15210            );
15211            (200, path_manifest.clone())
15212        });
15213        let state = tempfile::tempdir().unwrap();
15214        let cfg = test_hub_config(hub, state.path().to_path_buf());
15215        let by_path = v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, path)
15216            .unwrap()
15217            .unwrap();
15218        assert_eq!(by_path.sha256, content_sha256(raw.as_bytes()));
15219        assert!(by_path.proof.is_some());
15220        server.join().unwrap();
15221
15222        let id_manifest = manifest;
15223        let (hub, server) = routed_json_hub(1, move |request| {
15224            assert_eq!(
15225                request,
15226                format!(
15227                    "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&id={record_id}",
15228                    "c".repeat(64)
15229                )
15230            );
15231            (200, id_manifest.clone())
15232        });
15233        let state = tempfile::tempdir().unwrap();
15234        let cfg = test_hub_config(hub, state.path().to_path_buf());
15235        let (located_path, by_id) =
15236            v2_manifest_file_by_id(&cfg, TEST_BRAIN_ID, &pointer, record_id).unwrap();
15237        assert_eq!(located_path, path);
15238        assert_eq!(by_id.sha256, content_sha256(raw.as_bytes()));
15239        server.join().unwrap();
15240    }
15241
15242    #[test]
15243    fn canonical_store_pack_matches_the_cross_language_zip32_golden() {
15244        let unsorted = vec![
15245            ("records/a.md".to_string(), "alpha\n".to_string()),
15246            ("DB.md".to_string(), "# db\n".to_string()),
15247        ];
15248        let sorted = vec![
15249            ("DB.md".to_string(), "# db\n".to_string()),
15250            ("records/a.md".to_string(), "alpha\n".to_string()),
15251        ];
15252        let pack = build_store_pack(&unsorted).unwrap();
15253
15254        // This digest is shared with the hub implementation. It locks every
15255        // byte of the wire profile: raw UTF-8 order, STORED payloads, fixed DOS
15256        // epoch, explicit CRC/sizes, Unix regular-0600 attributes, and the
15257        // absence of descriptors/extras/comments/ZIP64.
15258        assert_eq!(pack.len(), 219);
15259        assert_eq!(
15260            content_sha256(&pack),
15261            "972fb2045becaa21588baaf4b349e62a430687fa2c21167b53f4ca0efa6c9408"
15262        );
15263        assert_eq!(pack, build_store_pack(&sorted).unwrap());
15264        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x07\x08"));
15265        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x06"));
15266        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x07"));
15267
15268        assert_eq!(
15269            parse_store_pack(pack).unwrap(),
15270            vec![
15271                ("DB.md".to_string(), b"# db\n".to_vec()),
15272                ("records/a.md".to_string(), b"alpha\n".to_vec()),
15273            ]
15274        );
15275    }
15276
15277    #[test]
15278    fn canonical_store_pack_validates_every_path_before_writing() {
15279        let duplicate = vec![
15280            ("DB.md".to_string(), "first".to_string()),
15281            ("DB.md".to_string(), "second".to_string()),
15282        ];
15283        assert!(build_store_pack(&duplicate)
15284            .unwrap_err()
15285            .to_string()
15286            .contains("duplicate path"));
15287        assert!(matches!(
15288            build_store_pack(&[("../escape.md".to_string(), "no".to_string())]),
15289            Err(LinkError::UnsafePath { .. })
15290        ));
15291    }
15292
15293    #[test]
15294    fn zip64_preflight_rejects_an_entry_count_bomb_before_zip_parsing() {
15295        const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
15296        // One byte stands in for the central directory. The trailer itself is
15297        // structurally valid; the count is the sole reason for refusal.
15298        let mut bytes = vec![0_u8];
15299        let zip64_offset = bytes.len() as u64;
15300        bytes.extend_from_slice(b"PK\x06\x06");
15301        bytes.extend_from_slice(&44_u64.to_le_bytes());
15302        bytes.extend_from_slice(&[0_u8; 12]);
15303        bytes.extend_from_slice(&COUNT.to_le_bytes());
15304        bytes.extend_from_slice(&COUNT.to_le_bytes());
15305        bytes.extend_from_slice(&1_u64.to_le_bytes());
15306        bytes.extend_from_slice(&0_u64.to_le_bytes());
15307        bytes.extend_from_slice(b"PK\x06\x07");
15308        bytes.extend_from_slice(&0_u32.to_le_bytes());
15309        bytes.extend_from_slice(&zip64_offset.to_le_bytes());
15310        bytes.extend_from_slice(&1_u32.to_le_bytes());
15311        bytes.extend_from_slice(b"PK\x05\x06");
15312        bytes.extend_from_slice(&0_u16.to_le_bytes());
15313        bytes.extend_from_slice(&0_u16.to_le_bytes());
15314        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
15315        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
15316        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
15317        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
15318        bytes.extend_from_slice(&0_u16.to_le_bytes());
15319
15320        let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
15321            .unwrap_err()
15322            .to_string();
15323        assert!(error.contains("invalid file count"), "{error}");
15324    }
15325
15326    #[test]
15327    fn zip_preflight_rejects_a_fake_trailing_eocd_before_zip_parsing() {
15328        const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
15329        let mut bytes = vec![0_u8];
15330        let zip64_offset = bytes.len() as u64;
15331        bytes.extend_from_slice(b"PK\x06\x06");
15332        bytes.extend_from_slice(&44_u64.to_le_bytes());
15333        bytes.extend_from_slice(&[0_u8; 12]);
15334        bytes.extend_from_slice(&COUNT.to_le_bytes());
15335        bytes.extend_from_slice(&COUNT.to_le_bytes());
15336        bytes.extend_from_slice(&1_u64.to_le_bytes());
15337        bytes.extend_from_slice(&0_u64.to_le_bytes());
15338        bytes.extend_from_slice(b"PK\x06\x07");
15339        bytes.extend_from_slice(&0_u32.to_le_bytes());
15340        bytes.extend_from_slice(&zip64_offset.to_le_bytes());
15341        bytes.extend_from_slice(&1_u32.to_le_bytes());
15342        bytes.extend_from_slice(b"PK\x05\x06");
15343        bytes.extend_from_slice(&0_u16.to_le_bytes());
15344        bytes.extend_from_slice(&0_u16.to_le_bytes());
15345        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
15346        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
15347        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
15348        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
15349        bytes.extend_from_slice(&0_u16.to_le_bytes());
15350        // The old parser trusted this last low-count signature, while
15351        // ZipArchive fell back to the real Zip64 directory and allocated for
15352        // COUNT entries. Its central-directory offsets are deliberately fake.
15353        let fake_eocd = bytes.len() as u32;
15354        bytes.extend_from_slice(b"PK\x05\x06");
15355        bytes.extend_from_slice(&0_u16.to_le_bytes());
15356        bytes.extend_from_slice(&0_u16.to_le_bytes());
15357        bytes.extend_from_slice(&1_u16.to_le_bytes());
15358        bytes.extend_from_slice(&1_u16.to_le_bytes());
15359        bytes.extend_from_slice(&0_u32.to_le_bytes());
15360        bytes.extend_from_slice(&fake_eocd.to_le_bytes());
15361        bytes.extend_from_slice(&0_u16.to_le_bytes());
15362
15363        let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
15364            .unwrap_err()
15365            .to_string();
15366        assert!(error.contains("central directory"), "{error}");
15367    }
15368
15369    #[test]
15370    fn strict_http_status_handling_rejects_redirects_without_panicking() {
15371        let error = ensure_ok(
15372            HubResponse {
15373                status: 302,
15374                body: Some(json!({"redirect": "/elsewhere"})),
15375            },
15376            "mutation",
15377        )
15378        .unwrap_err();
15379        assert!(matches!(error, LinkError::Http { status: 302, .. }));
15380
15381        let error = ensure_raw_ok(
15382            RawHubResponse {
15383                status: 302,
15384                body: br#"{"redirect":"/elsewhere"}"#.to_vec(),
15385            },
15386            "feed",
15387        )
15388        .unwrap_err();
15389        assert!(matches!(error, LinkError::Http { status: 302, .. }));
15390    }
15391
15392    #[cfg(unix)]
15393    #[test]
15394    fn collect_push_files_refuses_external_symlink_and_nested_store() {
15395        use std::os::unix::fs::symlink;
15396
15397        let root = tempfile::tempdir().unwrap();
15398        std::fs::write(
15399            root.path().join("DB.md"),
15400            "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
15401        )
15402        .unwrap();
15403        std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
15404
15405        let external = tempfile::tempdir().unwrap();
15406        let secret = external.path().join("secret.md");
15407        std::fs::write(&secret, "TOP SECRET").unwrap();
15408        symlink(&secret, root.path().join("records/notes/secret.md")).unwrap();
15409
15410        let store = Store::open_strict(root.path()).unwrap();
15411        let err = collect_push_files(&store).unwrap_err().to_string();
15412        assert!(err.contains("cannot push"), "{err}");
15413        assert!(
15414            !err.contains("TOP SECRET"),
15415            "external bytes must never leak"
15416        );
15417
15418        std::fs::remove_file(root.path().join("records/notes/secret.md")).unwrap();
15419        let nested = root.path().join("records/nested");
15420        std::fs::create_dir_all(&nested).unwrap();
15421        std::fs::write(
15422            nested.join("DB.md"),
15423            "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
15424        )
15425        .unwrap();
15426        let err = collect_push_files(&store).unwrap_err().to_string();
15427        assert!(err.contains("nested db.md store"), "{err}");
15428    }
15429
15430    #[cfg(unix)]
15431    #[test]
15432    fn remote_push_uses_opened_root_after_path_replacement() {
15433        use std::os::unix::fs::symlink;
15434
15435        let sandbox = tempfile::tempdir().unwrap();
15436        let root = sandbox.path().join("store");
15437        std::fs::create_dir_all(root.join("records/notes")).unwrap();
15438        std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
15439        std::fs::write(
15440            root.join("records/notes/owned.md"),
15441            "---\ntype: note\nsummary: owned\n---\nowned upload\n",
15442        )
15443        .unwrap();
15444        let store = Store::open_strict(&root).unwrap();
15445        let detached = sandbox.path().join("detached");
15446        std::fs::rename(&root, &detached).unwrap();
15447
15448        let replacement = sandbox.path().join("replacement");
15449        std::fs::create_dir_all(replacement.join("records/notes")).unwrap();
15450        std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
15451        std::fs::write(
15452            replacement.join("records/notes/secret.md"),
15453            "---\ntype: note\nsummary: secret\n---\nreplacement sentinel\n",
15454        )
15455        .unwrap();
15456        symlink(&replacement, &root).unwrap();
15457
15458        let files = collect_push_files(&store).unwrap();
15459        let wire_text = files
15460            .iter()
15461            .map(|(path, content)| format!("{path}\n{content}"))
15462            .collect::<Vec<_>>()
15463            .join("\n");
15464        assert!(wire_text.contains("owned upload"));
15465        assert!(!wire_text.contains("replacement sentinel"));
15466        assert!(!wire_text.contains("records/notes/secret.md"));
15467
15468        let remote = signed_remote_fixture();
15469        let (hub, server) = scripted_json_hub(vec![
15470            (200, remote.card),
15471            (200, remote.feed),
15472            (200, json!({"ok": true}).to_string()),
15473        ]);
15474        let state = tempfile::tempdir().unwrap();
15475        let cfg = test_hub_config(hub, state.path().to_path_buf());
15476        let pushed = sync_push(&cfg, TEST_BRAIN_ID, &files).unwrap();
15477        assert_eq!(pushed, json!({"ok": true}));
15478        server.join().unwrap();
15479    }
15480
15481    #[test]
15482    fn signed_feed_item_verifies_identity_hash_and_signature() {
15483        use ring::rand::SystemRandom;
15484        use ring::signature::{Ed25519KeyPair, KeyPair};
15485
15486        const PREFIX: &[u8] = &[
15487            0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
15488        ];
15489        let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
15490        let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15491        let mut spki = PREFIX.to_vec();
15492        spki.extend_from_slice(pair.public_key().as_ref());
15493        let public_key = URL_SAFE_NO_PAD.encode(&spki);
15494        let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
15495        let mut entry = FeedEntry {
15496            v: 1,
15497            seq: 1,
15498            ts: "2026-07-14T00:00:00.000Z".to_string(),
15499            brain: format!("ed25519:{fingerprint}"),
15500            public_key: public_key.clone(),
15501            kind: "push".to_string(),
15502            op: "snapshot".to_string(),
15503            pack_sha256: "a".repeat(64),
15504            files: vec![FeedFile {
15505                path: "DB.md".to_string(),
15506                sha256: "b".repeat(64),
15507                bytes: 3,
15508            }],
15509            removed: vec![],
15510            prev_entry_hash: None,
15511            sig: String::new(),
15512        };
15513        let unsigned = UnsignedFeedEntry {
15514            v: entry.v,
15515            seq: entry.seq,
15516            ts: &entry.ts,
15517            brain: &entry.brain,
15518            public_key: &entry.public_key,
15519            kind: &entry.kind,
15520            op: &entry.op,
15521            pack_sha256: &entry.pack_sha256,
15522            files: &entry.files,
15523            removed: &entry.removed,
15524            prev_entry_hash: &entry.prev_entry_hash,
15525        };
15526        entry.sig =
15527            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
15528        let mut exact = serde_json::to_vec(&entry).unwrap();
15529        exact.push(b'\n');
15530        let item = FeedItem {
15531            hash: format!("{:x}", Sha256::digest(&exact)),
15532            entry,
15533        };
15534        let identity = FeedIdentity {
15535            fingerprint,
15536            public_key_spki: public_key,
15537            previous: Vec::new(),
15538            rotations: Vec::new(),
15539        };
15540        assert!(verify_feed_item(&item, &identity).is_ok());
15541        let mut tampered = item;
15542        tampered.entry.pack_sha256 = "c".repeat(64);
15543        assert!(verify_feed_item(&tampered, &identity).is_err());
15544    }
15545
15546    #[test]
15547    fn v2_commit_requires_exact_fields_and_a_valid_genesis_bridge() {
15548        let rng = ring::rand::SystemRandom::new();
15549        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15550        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15551        let (spki, multikey) = public_identity_for(&pair);
15552        let identity = V2HeadIdentity {
15553            custody: "self".to_string(),
15554            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
15555            public_key_spki: spki.clone(),
15556            previous: Vec::new(),
15557            rotations: Vec::new(),
15558        };
15559        let unsigned = json!({
15560            "actor_ref": "a".repeat(64),
15561            "asset_root": Value::Null,
15562            "brain": multikey,
15563            "changes_sha256": "b".repeat(64),
15564            "control_revision": "c".repeat(64),
15565            "materializer": "dbmd-projection-v1",
15566            "op": "changeset",
15567            "parent_asset_root": Value::Null,
15568            "parent_commit": Value::Null,
15569            "parent_root": Value::Null,
15570            "prev_entry_hash": Value::Null,
15571            "public_key": spki,
15572            "seq": 1,
15573            "signer_epoch": 1,
15574            "state_root": "d".repeat(64),
15575            "ts": "2026-08-19T12:00:00.000Z",
15576            "v": 2,
15577            "v1_bridge": {
15578                "feed_hash": "e".repeat(64),
15579                "head_seq": 7,
15580                "pack_sha256": "f".repeat(64),
15581            },
15582        });
15583        let sign_value = |value: Value| {
15584            let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
15585            let mut object = value.as_object().unwrap().clone();
15586            object.insert(
15587                "sig".to_string(),
15588                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
15589            );
15590            crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
15591        };
15592        assert!(verified_v2_commit_object(&sign_value(unsigned.clone()), &identity).is_ok());
15593
15594        let mut extra = unsigned.clone();
15595        extra
15596            .as_object_mut()
15597            .unwrap()
15598            .insert("future".to_string(), Value::Bool(true));
15599        assert!(verified_v2_commit_object(&sign_value(extra), &identity).is_err());
15600
15601        let mut missing = unsigned.clone();
15602        missing.as_object_mut().unwrap().remove("v1_bridge");
15603        assert!(verified_v2_commit_object(&sign_value(missing), &identity).is_err());
15604
15605        let mut invalid_bridge = unsigned;
15606        invalid_bridge.as_object_mut().unwrap().insert(
15607            "v1_bridge".to_string(),
15608            json!({"feed_hash": "e".repeat(64), "head_seq": 0, "pack_sha256": "f".repeat(64)}),
15609        );
15610        assert!(verified_v2_commit_object(&sign_value(invalid_bridge), &identity).is_err());
15611    }
15612
15613    #[test]
15614    fn shared_v2_commit_bridge_vector_matches_the_typescript_signer() {
15615        let vector: Value = serde_json::from_str(include_str!(
15616            "../tests/vectors/linkmd-v2-commit-bridge.json"
15617        ))
15618        .unwrap();
15619        let identity_value = vector.get("identity").unwrap();
15620        let identity = V2HeadIdentity {
15621            custody: "self".to_string(),
15622            fingerprint: identity_value
15623                .get("fingerprint")
15624                .and_then(Value::as_str)
15625                .unwrap()
15626                .to_string(),
15627            public_key_spki: identity_value
15628                .get("public_key_spki")
15629                .and_then(Value::as_str)
15630                .unwrap()
15631                .to_string(),
15632            previous: Vec::new(),
15633            rotations: Vec::new(),
15634        };
15635        let private = URL_SAFE_NO_PAD
15636            .decode(
15637                identity_value
15638                    .get("private_key_pkcs8")
15639                    .and_then(Value::as_str)
15640                    .unwrap(),
15641            )
15642            .unwrap();
15643        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&private)
15644            .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(&private))
15645            .unwrap();
15646        let base = vector.get("body").unwrap().as_object().unwrap();
15647
15648        for item in vector.get("valid").unwrap().as_array().unwrap() {
15649            let mut body = base.clone();
15650            body.insert(
15651                "v1_bridge".to_string(),
15652                item.get("v1_bridge").unwrap().clone(),
15653            );
15654            body.insert(
15655                "sig".to_string(),
15656                item.get("signature_base64url").unwrap().clone(),
15657            );
15658            let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
15659            assert!(verified_v2_commit_object(&signed, &identity).is_ok());
15660            assert_eq!(
15661                crate::linkmd_v2::domain_hash_bytes("v2/commit", &signed).unwrap(),
15662                item.get("commit_hash").and_then(Value::as_str).unwrap()
15663            );
15664            assert_eq!(
15665                format!("{:x}", Sha256::digest(&signed)),
15666                item.get("feed_hash").and_then(Value::as_str).unwrap()
15667            );
15668        }
15669
15670        for item in vector.get("invalid").unwrap().as_array().unwrap() {
15671            let mut body = base.clone();
15672            if let Some(remove) = item.get("remove").and_then(Value::as_array) {
15673                for field in remove {
15674                    body.remove(field.as_str().unwrap());
15675                }
15676            }
15677            if let Some(set) = item.get("set").and_then(Value::as_object) {
15678                for (field, value) in set {
15679                    body.insert(field.clone(), value.clone());
15680                }
15681            }
15682            let message = crate::linkmd_v2::canonical_bytes(&Value::Object(body.clone())).unwrap();
15683            body.insert(
15684                "sig".to_string(),
15685                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
15686            );
15687            let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
15688            assert!(
15689                verified_v2_commit_object(&signed, &identity).is_err(),
15690                "accepted invalid shared vector {}",
15691                item.get("reason").and_then(Value::as_str).unwrap()
15692            );
15693        }
15694    }
15695
15696    #[test]
15697    fn shared_v2_kept_home_changeset_vector_matches_the_typescript_hub() {
15698        let vector: Value = serde_json::from_str(include_str!(
15699            "../tests/vectors/linkmd-v2-changeset-withheld.json"
15700        ))
15701        .unwrap();
15702        assert_eq!(
15703            vector.get("profile").and_then(Value::as_str),
15704            Some("link.md-v2-changeset-withheld")
15705        );
15706        let canonical =
15707            crate::linkmd_v2::canonical_bytes(vector.get("changeset").unwrap()).unwrap();
15708        let expected = STANDARD
15709            .decode(
15710                vector
15711                    .get("canonical_base64")
15712                    .and_then(Value::as_str)
15713                    .unwrap(),
15714            )
15715            .unwrap();
15716        assert_eq!(canonical, expected);
15717        assert_eq!(
15718            crate::linkmd_v2::domain_hash_bytes("v2/changeset", &canonical).unwrap(),
15719            vector.get("domain_hash").and_then(Value::as_str).unwrap()
15720        );
15721    }
15722
15723    #[test]
15724    fn v2_profile_transition_reverifies_the_exact_signed_v1_bridge() {
15725        let remote = signed_remote_fixture();
15726        let legacy: FeedResponse = serde_json::from_str(&remote.feed).unwrap();
15727        let legacy_item = legacy.entries.first().unwrap();
15728        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&remote.key.pkcs8).unwrap();
15729        let body = json!({
15730            "actor_ref": "a".repeat(64),
15731            "asset_root": Value::Null,
15732            "brain": remote.key.multikey,
15733            "changes_sha256": "b".repeat(64),
15734            "control_revision": "c".repeat(64),
15735            "materializer": "dbmd-projection-v1",
15736            "op": "changeset",
15737            "parent_asset_root": Value::Null,
15738            "parent_commit": Value::Null,
15739            "parent_root": Value::Null,
15740            "prev_entry_hash": Value::Null,
15741            "public_key": remote.key.public_key_spki,
15742            "seq": 1,
15743            "signer_epoch": 1,
15744            "state_root": "d".repeat(64),
15745            "ts": "2026-08-19T12:00:00.000Z",
15746            "v": 2,
15747            "v1_bridge": {
15748                "feed_hash": legacy_item.hash,
15749                "head_seq": legacy_item.entry.seq,
15750                "pack_sha256": legacy_item.entry.pack_sha256,
15751            },
15752        });
15753        let message = crate::linkmd_v2::canonical_bytes(&body).unwrap();
15754        let mut signed = body.as_object().unwrap().clone();
15755        signed.insert(
15756            "sig".to_string(),
15757            Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
15758        );
15759        let raw = crate::linkmd_v2::canonical_bytes(&Value::Object(signed)).unwrap();
15760        let commit_hash = crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw).unwrap();
15761        let feed_hash = content_sha256(&raw);
15762        let pointer = V2PointerBody {
15763            v: 2,
15764            brain: TEST_BRAIN_ID.to_string(),
15765            seq: 1,
15766            commit_hash: commit_hash.clone(),
15767            feed_hash: feed_hash.clone(),
15768            content_root: Some("d".repeat(64)),
15769            asset_root: None,
15770            materializer: "dbmd-projection-v1".to_string(),
15771            signer_epoch: 1,
15772            control_revision: "c".repeat(64),
15773            backup_preparation: "e".repeat(64),
15774            prior_pointer_hash: None,
15775            signed_at: "2026-08-19T12:00:00.000Z".to_string(),
15776        };
15777        let v2_page = json!({
15778            "v": 2,
15779            "head_seq": 1,
15780            "head_commit_hash": commit_hash,
15781            "head_feed_hash": feed_hash,
15782            "entries": [{
15783                "seq": 1,
15784                "commit_hash": pointer.commit_hash,
15785                "feed_hash": pointer.feed_hash,
15786                "bytes_base64": STANDARD.encode(&raw),
15787            }],
15788            "next_after": 1,
15789            "complete": true,
15790        })
15791        .to_string();
15792        let identity = V2HeadIdentity {
15793            custody: "self".to_string(),
15794            fingerprint: remote.identity.fingerprint.clone(),
15795            public_key_spki: remote.identity.public_key_spki.clone(),
15796            previous: Vec::new(),
15797            rotations: Vec::new(),
15798        };
15799        let checkpoint = TrustState {
15800            v: 2,
15801            origin: "unused".to_string(),
15802            requested: TEST_BRAIN_ID.to_string(),
15803            brain: TEST_BRAIN_ID.to_string(),
15804            home: None,
15805            anchor: remote.key.multikey.clone(),
15806            current: remote.key.multikey,
15807            head_seq: legacy_item.entry.seq,
15808            feed_hash: Some(legacy_item.hash.clone()),
15809            rotations: Vec::new(),
15810            hub_signer: None,
15811            protocol_profile: None,
15812        };
15813        let (hub, server) = scripted_json_hub(vec![(200, v2_page), (200, remote.feed)]);
15814        let state = tempfile::tempdir().unwrap();
15815        let cfg = test_hub_config(hub, state.path().to_path_buf());
15816        verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &checkpoint).unwrap();
15817        server.join().unwrap();
15818
15819        let mut wrong = checkpoint;
15820        wrong.feed_hash = Some("0".repeat(64));
15821        let (hub, server) = scripted_json_hub(vec![(
15822            200,
15823            json!({
15824                "v": 2,
15825                "head_seq": 1,
15826                "head_commit_hash": pointer.commit_hash,
15827                "head_feed_hash": pointer.feed_hash,
15828                "entries": [{
15829                    "seq": 1,
15830                    "commit_hash": pointer.commit_hash,
15831                    "feed_hash": pointer.feed_hash,
15832                    "bytes_base64": STANDARD.encode(&raw),
15833                }],
15834                "next_after": 1,
15835                "complete": true,
15836            })
15837            .to_string(),
15838        )]);
15839        let state = tempfile::tempdir().unwrap();
15840        let cfg = test_hub_config(hub, state.path().to_path_buf());
15841        assert!(verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &wrong,).is_err());
15842        server.join().unwrap();
15843    }
15844
15845    #[test]
15846    fn v2_commit_signer_epoch_follows_the_authenticated_rotation_boundary() {
15847        let rng = ring::rand::SystemRandom::new();
15848        let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15849        let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
15850        let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15851        let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
15852        let (old_spki, old_multikey) = public_identity_for(&old);
15853        let (new_spki, new_multikey) = public_identity_for(&new);
15854        let rotation_unsigned = serde_json::to_string(&UnsignedRotation {
15855            v: 1,
15856            op: "rotate",
15857            brain: &old_multikey,
15858            public_key: &old_spki,
15859            new_brain: &new_multikey,
15860            new_public_key: &new_spki,
15861            prior_head_seq: 1,
15862            prior_feed_hash: Some(&"9".repeat(64)),
15863            ts: "2026-08-19T12:01:00.000Z".to_string(),
15864        })
15865        .unwrap();
15866        let rotation_sig = URL_SAFE_NO_PAD.encode(old.sign(rotation_unsigned.as_bytes()).as_ref());
15867        let rotation = format!(
15868            "{},\"sig\":\"{}\"}}",
15869            &rotation_unsigned[..rotation_unsigned.len() - 1],
15870            rotation_sig
15871        );
15872        let identity = V2HeadIdentity {
15873            custody: "self".to_string(),
15874            fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
15875            public_key_spki: new_spki.clone(),
15876            previous: vec![V2PreviousIdentity {
15877                fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
15878                public_key_spki: old_spki.clone(),
15879            }],
15880            rotations: vec![rotation],
15881        };
15882        let commit = |seq: u64,
15883                      epoch: u64,
15884                      multikey: &str,
15885                      spki: &str,
15886                      pair: &ring::signature::Ed25519KeyPair| {
15887            let value = json!({
15888                "actor_ref": "a".repeat(64),
15889                "asset_root": Value::Null,
15890                "brain": multikey,
15891                "changes_sha256": "b".repeat(64),
15892                "control_revision": "c".repeat(64),
15893                "materializer": "dbmd-projection-v1",
15894                "op": "changeset",
15895                "parent_asset_root": Value::Null,
15896                "parent_commit": if seq == 1 { Value::Null } else { Value::String("d".repeat(64)) },
15897                "parent_root": if seq == 1 { Value::Null } else { Value::String("e".repeat(64)) },
15898                "prev_entry_hash": if seq == 1 { Value::Null } else { Value::String("f".repeat(64)) },
15899                "public_key": spki,
15900                "seq": seq,
15901                "signer_epoch": epoch,
15902                "state_root": "1".repeat(64),
15903                "ts": "2026-08-19T12:00:00.000Z",
15904                "v": 2,
15905                "v1_bridge": Value::Null,
15906            });
15907            let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
15908            let mut object = value.as_object().unwrap().clone();
15909            object.insert(
15910                "sig".to_string(),
15911                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
15912            );
15913            crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
15914        };
15915
15916        assert!(verified_v2_commit_object(
15917            &commit(1, 1, &old_multikey, &old_spki, &old),
15918            &identity,
15919        )
15920        .is_ok());
15921        assert!(verified_v2_commit_object(
15922            &commit(2, 2, &new_multikey, &new_spki, &new),
15923            &identity,
15924        )
15925        .is_ok());
15926        assert!(verified_v2_commit_object(
15927            &commit(2, 1, &old_multikey, &old_spki, &old),
15928            &identity,
15929        )
15930        .is_err());
15931        assert!(verified_v2_commit_object(
15932            &commit(1, 2, &new_multikey, &new_spki, &new),
15933            &identity,
15934        )
15935        .is_err());
15936    }
15937
15938    #[test]
15939    fn a_self_custody_entry_verifies_like_any_hub_entry() {
15940        let rng = ring::rand::SystemRandom::new();
15941        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15942        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15943        let (spki, multikey) = public_identity_for(&pair);
15944        let key = AgentSigningKey {
15945            pkcs8: pkcs8.as_ref().to_vec(),
15946            multikey: multikey.clone(),
15947            public_key_spki: spki.clone(),
15948        };
15949        let files = vec![WireFeedFile {
15950            path: "DB.md".to_string(),
15951            sha256: "a".repeat(64),
15952            bytes: 3,
15953        }];
15954        let raw = self_custody_entry(
15955            &key,
15956            1,
15957            "2026-07-23T12:00:00.000Z".to_string(),
15958            &"c".repeat(64),
15959            &files,
15960            None,
15961        )
15962        .unwrap();
15963        // The exact client serialization parses as a feed entry and passes the
15964        // SAME verifier every subscribe read runs — the self-custody path
15965        // produces first-class wire-profile-v1 entries.
15966        let entry: FeedEntry = serde_json::from_str(&raw).unwrap();
15967        let hash = format!("{:x}", Sha256::digest(format!("{raw}\n").as_bytes()));
15968        let item = FeedItem { hash, entry };
15969        let identity = FeedIdentity {
15970            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
15971            public_key_spki: spki,
15972            previous: Vec::new(),
15973            rotations: Vec::new(),
15974        };
15975        assert!(verify_feed_item(&item, &identity).is_ok());
15976    }
15977
15978    #[test]
15979    fn identity_rotation_requires_old_key_signature_and_preserves_the_pin() {
15980        let rng = ring::rand::SystemRandom::new();
15981        let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15982        let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
15983        let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15984        let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
15985        let (old_spki, old_multikey) = public_identity_for(&old);
15986        let (new_spki, new_multikey) = public_identity_for(&new);
15987        let unsigned = serde_json::to_string(&UnsignedRotation {
15988            v: 1,
15989            op: "rotate",
15990            brain: &old_multikey,
15991            public_key: &old_spki,
15992            new_brain: &new_multikey,
15993            new_public_key: &new_spki,
15994            prior_head_seq: 1,
15995            prior_feed_hash: Some(&"a".repeat(64)),
15996            ts: "2026-07-30T12:00:00.000Z".to_string(),
15997        })
15998        .unwrap();
15999        let signature = URL_SAFE_NO_PAD.encode(old.sign(unsigned.as_bytes()).as_ref());
16000        let rotation = format!(
16001            "{},\"sig\":\"{}\"}}",
16002            &unsigned[..unsigned.len() - 1],
16003            signature
16004        );
16005        let identity = FeedIdentity {
16006            fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
16007            public_key_spki: new_spki,
16008            previous: vec![PreviousIdentity {
16009                fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
16010                public_key_spki: old_spki,
16011            }],
16012            rotations: vec![rotation],
16013        };
16014        let pin = TrustState {
16015            v: 2,
16016            origin: "https://hub.example".to_string(),
16017            requested: "brain".to_string(),
16018            brain: "brain".to_string(),
16019            home: None,
16020            anchor: old_multikey.clone(),
16021            current: old_multikey.clone(),
16022            head_seq: 1,
16023            feed_hash: Some("a".repeat(64)),
16024            rotations: Vec::new(),
16025            hub_signer: None,
16026            protocol_profile: None,
16027        };
16028        assert_eq!(
16029            verify_identity_chain(&identity, Some(&pin)).unwrap(),
16030            old_multikey
16031        );
16032        let mut accepted = pin.clone();
16033        accepted.current = new_multikey.clone();
16034        accepted.rotations = identity.rotations.clone();
16035        let alternate_unsigned = serde_json::to_string(&UnsignedRotation {
16036            v: 1,
16037            op: "rotate",
16038            brain: &old_multikey,
16039            public_key: &identity.previous[0].public_key_spki,
16040            new_brain: &new_multikey,
16041            new_public_key: &identity.public_key_spki,
16042            prior_head_seq: 1,
16043            prior_feed_hash: Some(&"a".repeat(64)),
16044            ts: "2026-07-30T12:00:01.000Z".to_string(),
16045        })
16046        .unwrap();
16047        let alternate_signature =
16048            URL_SAFE_NO_PAD.encode(old.sign(alternate_unsigned.as_bytes()).as_ref());
16049        let mut rewritten = identity.clone();
16050        rewritten.rotations[0] = format!(
16051            "{},\"sig\":\"{}\"}}",
16052            &alternate_unsigned[..alternate_unsigned.len() - 1],
16053            alternate_signature
16054        );
16055        assert!(
16056            verify_identity_chain(&rewritten, Some(&accepted)).is_err(),
16057            "an alternate valid statement must not rewrite accepted history"
16058        );
16059
16060        let mut stale_entry = FeedEntry {
16061            v: 1,
16062            seq: 2,
16063            ts: "2026-07-30T12:01:00.000Z".to_string(),
16064            brain: pin.current.clone(),
16065            public_key: identity.previous[0].public_key_spki.clone(),
16066            kind: "push".to_string(),
16067            op: "snapshot".to_string(),
16068            pack_sha256: "b".repeat(64),
16069            files: Vec::new(),
16070            removed: Vec::new(),
16071            prev_entry_hash: pin.feed_hash.clone(),
16072            sig: String::new(),
16073        };
16074        let stale_unsigned = UnsignedFeedEntry {
16075            v: stale_entry.v,
16076            seq: stale_entry.seq,
16077            ts: &stale_entry.ts,
16078            brain: &stale_entry.brain,
16079            public_key: &stale_entry.public_key,
16080            kind: &stale_entry.kind,
16081            op: &stale_entry.op,
16082            pack_sha256: &stale_entry.pack_sha256,
16083            files: &stale_entry.files,
16084            removed: &stale_entry.removed,
16085            prev_entry_hash: &stale_entry.prev_entry_hash,
16086        };
16087        stale_entry.sig = URL_SAFE_NO_PAD.encode(
16088            old.sign(&serde_json::to_vec(&stale_unsigned).unwrap())
16089                .as_ref(),
16090        );
16091        let mut stale_exact = serde_json::to_vec(&stale_entry).unwrap();
16092        stale_exact.push(b'\n');
16093        let stale_item = FeedItem {
16094            hash: content_sha256(&stale_exact),
16095            entry: stale_entry,
16096        };
16097        assert!(
16098            reject_retired_signer_after_checkpoint(&identity, Some(&accepted), &stale_item)
16099                .is_err(),
16100            "a key retired before the checkpoint must never append after it"
16101        );
16102        assert!(
16103            verify_feed_item(&stale_item, &identity).is_err(),
16104            "an old key must never append after its signed rotation boundary"
16105        );
16106
16107        let mut missing = identity.clone();
16108        missing.rotations.clear();
16109        assert!(verify_identity_chain(&missing, Some(&pin)).is_err());
16110
16111        let mut tampered = identity;
16112        tampered.rotations[0] = tampered.rotations[0].replace("\"sig\":\"", "\"sig\":\"A");
16113        assert!(verify_identity_chain(&tampered, Some(&pin)).is_err());
16114    }
16115
16116    #[cfg(unix)]
16117    #[test]
16118    fn key_creation_refuses_a_planted_symlink_without_touching_its_target() {
16119        use std::os::unix::fs::symlink;
16120
16121        let dir = tempfile::tempdir().unwrap();
16122        let target = dir.path().join("valuable.txt");
16123        let planted = dir.path().join("agent.key");
16124        std::fs::write(&target, "do not overwrite").unwrap();
16125        symlink(&target, &planted).unwrap();
16126
16127        assert!(matches!(
16128            generate_agent_key(&planted),
16129            Err(LinkError::BadAgentKey { .. })
16130        ));
16131        assert_eq!(std::fs::read_to_string(target).unwrap(), "do not overwrite");
16132    }
16133
16134    #[cfg(unix)]
16135    #[test]
16136    fn key_creation_refuses_a_symlinked_parent_without_writing_through_it() {
16137        use std::os::unix::fs::symlink;
16138
16139        let root = tempfile::tempdir().unwrap();
16140        let outside = tempfile::tempdir().unwrap();
16141        symlink(outside.path(), root.path().join("redirect")).unwrap();
16142
16143        assert!(generate_agent_key(&root.path().join("redirect/agent.key")).is_err());
16144        assert!(!outside.path().join("agent.key").exists());
16145    }
16146
16147    // ── Address parsing ─────────────────────────────────────────────────────
16148
16149    #[test]
16150    fn address_bare_brain_with_and_without_sigil() {
16151        for raw in ["@acme-ops", "acme-ops"] {
16152            let a = Address::parse(raw).expect(raw);
16153            assert_eq!(a.brain, "acme-ops");
16154            assert_eq!(a.target, None);
16155        }
16156    }
16157
16158    #[test]
16159    fn address_ulid_target_parses_as_id() {
16160        let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
16161        assert_eq!(a.brain, "acme");
16162        assert_eq!(
16163            a.target,
16164            Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
16165        );
16166    }
16167
16168    #[test]
16169    fn address_md_path_target_parses_as_path() {
16170        let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
16171        assert_eq!(
16172            a.target,
16173            Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
16174        );
16175    }
16176
16177    #[test]
16178    fn address_rejects_malformed_forms() {
16179        for raw in [
16180            "",
16181            "@",
16182            "@/x",
16183            "@acme/",
16184            "@acme/../etc/passwd",
16185            "@acme/records/.hidden.md",
16186            "@ACME",             // uppercase is not a hub ref shape
16187            "@acme/notes/x.txt", // target is neither ULID nor .md path
16188            "@a b",              // whitespace
16189        ] {
16190            assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
16191        }
16192    }
16193
16194    // ── Path safety ─────────────────────────────────────────────────────────
16195
16196    #[test]
16197    fn safe_paths_accept_store_shapes_and_reject_escapes() {
16198        for ok in [
16199            "DB.md",
16200            "assets.jsonl",
16201            "records/clients/lumio.md",
16202            "sources/emails/2026/07/x.md",
16203        ] {
16204            assert!(safe_store_rel_path(ok), "should accept {ok:?}");
16205        }
16206        for bad in [
16207            "",
16208            "/etc/passwd",
16209            "../up.md",
16210            "records/../../up.md",
16211            "records//x.md",
16212            ".dbmd/config",
16213            "records/.hidden/x.md",
16214            "records/a b.md",
16215            "records\\win.md",
16216        ] {
16217            assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
16218        }
16219    }
16220
16221    #[cfg(unix)]
16222    #[test]
16223    fn opened_destination_capability_survives_an_ancestor_path_swap() {
16224        use std::os::unix::fs::symlink;
16225
16226        let work = tempfile::tempdir().unwrap();
16227        let outside = tempfile::tempdir().unwrap();
16228        let original = work.path().join("destination");
16229        let moved = work.path().join("destination-moved");
16230        let directory = open_or_create_dir_nofollow(&original).unwrap();
16231
16232        std::fs::rename(&original, &moved).unwrap();
16233        symlink(outside.path(), &original).unwrap();
16234        write_pull_entries_beneath_dir(
16235            &directory,
16236            &[("records/note.md".to_string(), b"held inode".to_vec())],
16237        )
16238        .unwrap();
16239
16240        assert_eq!(
16241            std::fs::read(moved.join("records/note.md")).unwrap(),
16242            b"held inode"
16243        );
16244        assert!(!outside.path().join("records/note.md").exists());
16245    }
16246
16247    // ── Config resolution (flag + file precedence; env is covered by the CLI
16248    //    integration tests, where a child process isolates it) ───────────────
16249
16250    #[test]
16251    fn hub_config_flag_beats_file_and_requires_some_source() {
16252        let dir = tempfile::tempdir().unwrap();
16253        std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
16254        std::fs::write(
16255            dir.path().join(CONFIG_REL_PATH),
16256            "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
16257        )
16258        .unwrap();
16259
16260        let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
16261        assert_eq!(from_flag.hub, "https://flag.example.com");
16262
16263        let from_file = hub_config(None, dir.path()).unwrap();
16264        assert_eq!(from_file.hub, "https://file.example.com");
16265
16266        let none = hub_config(None, tempfile::tempdir().unwrap().path());
16267        assert!(matches!(none, Err(LinkError::NoHub)));
16268    }
16269
16270    #[test]
16271    fn https_guard_allows_loopback_only_for_plain_http() {
16272        assert!(assert_safe_hub("https://hub.example.com").is_ok());
16273        assert!(assert_safe_hub("http://localhost:3000").is_ok());
16274        assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
16275        assert!(assert_safe_hub("http://[::1]:3000").is_ok());
16276        assert!(matches!(
16277            assert_safe_hub("http://hub.example.com"),
16278            Err(LinkError::UnsafeHub { .. })
16279        ));
16280        assert!(matches!(
16281            assert_safe_hub("hub.example.com"),
16282            Err(LinkError::UnsafeHub { .. })
16283        ));
16284        assert!(matches!(
16285            assert_safe_hub("http://localhost:80@127.0.0.1:1"),
16286            Err(LinkError::UnsafeHub { .. })
16287        ));
16288        assert!(matches!(
16289            assert_safe_hub("https://hub.example.com@attacker.example"),
16290            Err(LinkError::UnsafeHub { .. })
16291        ));
16292        assert!(matches!(
16293            assert_safe_hub("https://hub.example.com/base"),
16294            Err(LinkError::UnsafeHub { .. })
16295        ));
16296    }
16297
16298    #[test]
16299    fn registry_ssrf_classifier_rejects_local_private_and_documentation_ranges() {
16300        for blocked in [
16301            "127.0.0.1",
16302            "10.0.0.1",
16303            "100.64.0.1",
16304            "169.254.169.254",
16305            "172.16.0.1",
16306            "192.168.0.1",
16307            "192.88.99.1",
16308            "198.18.0.1",
16309            "203.0.113.1",
16310            "::1",
16311            "fe80::1",
16312            "fd00::1",
16313            "2001:db8::1",
16314            "2001:1::1",
16315            "2002:7f00:1::",
16316            "3fff::1",
16317        ] {
16318            assert!(
16319                !is_public_registry_ip(blocked.parse().unwrap()),
16320                "must block {blocked}"
16321            );
16322        }
16323        assert!(is_public_registry_ip("1.1.1.1".parse().unwrap()));
16324        assert!(is_public_registry_ip(
16325            "2606:4700:4700::1111".parse().unwrap()
16326        ));
16327        assert!(is_public_registry_ip("3fff:1000::1".parse().unwrap()));
16328    }
16329
16330    #[test]
16331    fn registry_dns_answer_is_pinned_and_cannot_rebind_or_change_authority() {
16332        use ureq::Resolver as _;
16333
16334        let pinned: std::net::SocketAddr = "1.1.1.1:443".parse().unwrap();
16335        let resolver = PinnedRegistryResolver {
16336            netloc: "home.example:443".to_string(),
16337            addresses: vec![pinned],
16338        };
16339        assert_eq!(resolver.resolve("home.example:443").unwrap(), vec![pinned]);
16340        assert!(resolver.resolve("127.0.0.1:443").is_err());
16341        assert_eq!(
16342            resolver.resolve("home.example:443").unwrap(),
16343            vec![pinned],
16344            "subsequent connects reuse the validated answer instead of DNS"
16345        );
16346    }
16347
16348    #[test]
16349    fn production_object_urls_and_store_selected_hubs_cannot_reach_private_ips() {
16350        let cfg = HubConfig {
16351            hub: "https://hub.example".to_string(),
16352            key: None,
16353            agent_key: None,
16354            brain_key: None,
16355            state_dir: tempfile::tempdir().unwrap().keep(),
16356            store_selected: false,
16357        };
16358        assert!(
16359            presigned_agent(&cfg, "https://127.0.0.1/private").is_err(),
16360            "a production hub must not turn its presigned URL into an SSRF primitive"
16361        );
16362
16363        let store_selected = HubConfig {
16364            hub: "https://127.0.0.1".to_string(),
16365            store_selected: true,
16366            ..cfg
16367        };
16368        assert!(
16369            hub_agent(&store_selected).is_err(),
16370            "bytes in a cloned store must not select a private-network hub"
16371        );
16372    }
16373
16374    #[test]
16375    fn presigned_download_ceiling_cannot_overflow_at_u64_max() {
16376        assert_eq!(
16377            one_past_bounded_limit(MAX_PACK_BYTES),
16378            Some(MAX_PACK_BYTES + 1),
16379            "the presigned reader consumes exactly one refusal byte beyond its fixed pack cap"
16380        );
16381        assert_eq!(
16382            presigned_download_read_limit(),
16383            MAX_PACK_BYTES + 1,
16384            "the presigned reader is capped by the client constant, not a hub response"
16385        );
16386        assert_eq!(
16387            one_past_bounded_limit(u64::MAX),
16388            None,
16389            "the former attacker-controlled u64::MAX + 1 shape must fail without overflow"
16390        );
16391    }
16392
16393    #[test]
16394    fn https_guard_matches_the_scheme_case_insensitively() {
16395        // RFC 3986 schemes are case-insensitive: an uppercase-scheme HTTPS
16396        // hub is still HTTPS, never a misleading non-HTTPS refusal.
16397        assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
16398        assert!(assert_safe_hub("Https://hub.example.com").is_ok());
16399        // And an uppercase plain-HTTP hub is still refused outside loopback.
16400        assert!(matches!(
16401            assert_safe_hub("HTTP://hub.example.com"),
16402            Err(LinkError::UnsafeHub { .. })
16403        ));
16404    }
16405
16406    #[test]
16407    fn clean_key_refuses_paste_artifacts_without_echoing() {
16408        assert_eq!(clean_key("  vc_account_abc  ").unwrap(), "vc_account_abc");
16409        for bad in ["vc account", "vc\naccount", "ключ", ""] {
16410            let err = clean_key(bad).unwrap_err();
16411            assert!(matches!(err, LinkError::BadKey));
16412            assert!(
16413                !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
16414                "error must not echo the key"
16415            );
16416        }
16417    }
16418
16419    // ── Verb entry gates: refs must never reshape the request path ──────────
16420
16421    /// A config whose hub passes the loopback guard but is never listened on:
16422    /// every refusal below must come from the entry gate BEFORE a request
16423    /// exists — a dial on this dead port would surface `Transport` instead.
16424    fn dead_hub() -> HubConfig {
16425        HubConfig {
16426            hub: "http://127.0.0.1:9".to_string(),
16427            key: Some("k".to_string()),
16428            agent_key: None,
16429            brain_key: None,
16430            state_dir: PathBuf::from("."),
16431            store_selected: false,
16432        }
16433    }
16434
16435    #[test]
16436    fn request_retries_a_connection_failure_before_sending() {
16437        use std::io::{Read as _, Write as _};
16438        use std::net::TcpListener;
16439        use std::thread;
16440        use std::time::Duration;
16441
16442        let probe = TcpListener::bind("127.0.0.1:0").unwrap();
16443        let address = probe.local_addr().unwrap();
16444        drop(probe);
16445        let server = thread::spawn(move || {
16446            thread::sleep(Duration::from_millis(40));
16447            let listener = TcpListener::bind(address).unwrap();
16448            let (mut stream, _) = listener.accept().unwrap();
16449            let mut request_bytes = [0_u8; 1024];
16450            let _ = stream.read(&mut request_bytes).unwrap();
16451            stream
16452                .write_all(
16453                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
16454                )
16455                .unwrap();
16456        });
16457        let cfg = HubConfig {
16458            hub: format!("http://{address}"),
16459            key: None,
16460            agent_key: None,
16461            brain_key: None,
16462            state_dir: tempfile::tempdir().unwrap().keep(),
16463            store_selected: false,
16464        };
16465
16466        let response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
16467        assert_eq!(response.status, 200);
16468        assert_eq!(response.body, Some(json!({ "ok": true })));
16469        server.join().unwrap();
16470    }
16471
16472    #[test]
16473    fn endpoint_cap_refuses_a_body_before_json_parsing() {
16474        let (hub, server) = scripted_json_hub(vec![(200, "x".repeat(2_048))]);
16475        let cfg = HubConfig {
16476            hub,
16477            key: None,
16478            agent_key: None,
16479            brain_key: None,
16480            state_dir: tempfile::tempdir().unwrap().keep(),
16481            store_selected: false,
16482        };
16483
16484        assert!(matches!(
16485            request_capped(&cfg, "GET", "/bounded", None, Auth::None, 1_024),
16486            Err(LinkError::ResponseTooLarge { .. })
16487        ));
16488        server.join().unwrap();
16489    }
16490
16491    #[test]
16492    fn overall_deadline_stops_a_dribbled_response_body() {
16493        use std::io::{Read as _, Write as _};
16494        use std::net::TcpListener;
16495        use std::time::{Duration, Instant};
16496
16497        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
16498        let url = format!("http://{}/dribble", listener.local_addr().unwrap());
16499        let server = std::thread::spawn(move || {
16500            let (mut stream, _) = listener.accept().unwrap();
16501            let mut request = [0_u8; 1024];
16502            let _ = stream.read(&mut request);
16503            stream
16504                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 32\r\nConnection: close\r\n\r\n")
16505                .unwrap();
16506            for byte in [b'x'; 32] {
16507                if stream.write_all(&[byte]).is_err() {
16508                    break;
16509                }
16510                std::thread::sleep(Duration::from_millis(40));
16511            }
16512        });
16513        let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
16514        let started = Instant::now();
16515        let response = http.get(&url).call().unwrap();
16516        let mut body = Vec::new();
16517        let error = response
16518            .into_reader()
16519            .read_to_end(&mut body)
16520            .expect_err("per-read progress must not reset the overall deadline");
16521        assert!(
16522            started.elapsed() < Duration::from_millis(700),
16523            "dribbled body exceeded the wall-clock budget: {error}"
16524        );
16525        server.join().unwrap();
16526    }
16527
16528    #[test]
16529    fn overall_deadline_stops_a_stalled_upload() {
16530        use std::net::TcpListener;
16531        use std::time::{Duration, Instant};
16532
16533        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
16534        let url = format!("http://{}/upload", listener.local_addr().unwrap());
16535        let server = std::thread::spawn(move || {
16536            let (_stream, _) = listener.accept().unwrap();
16537            // Never consume the request body. Once the kernel send buffer fills,
16538            // the client must leave on its absolute write deadline.
16539            std::thread::sleep(Duration::from_millis(600));
16540        });
16541        let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
16542        let body = vec![0x5a; 32 * 1024 * 1024];
16543        let started = Instant::now();
16544        let error = http
16545            .put(&url)
16546            .send_bytes(&body)
16547            .expect_err("stalled request-body writes must time out");
16548        assert!(
16549            started.elapsed() < Duration::from_millis(700),
16550            "stalled upload exceeded the wall-clock budget: {error}"
16551        );
16552        server.join().unwrap();
16553    }
16554
16555    #[test]
16556    fn verb_entry_gates_accept_the_hub_ref_shapes() {
16557        for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
16558            assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
16559            assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
16560            assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
16561        }
16562    }
16563
16564    #[test]
16565    fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
16566        let cfg = dead_hub();
16567        for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
16568            assert!(
16569                matches!(
16570                    sync_pull(&cfg, bad, None),
16571                    Err(LinkError::BadAddress { .. })
16572                ),
16573                "sync_pull must refuse {bad:?}"
16574            );
16575            assert!(
16576                matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
16577                "sync_push must refuse {bad:?}"
16578            );
16579            assert!(
16580                matches!(
16581                    grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
16582                    Err(LinkError::BadAddress { .. })
16583                ),
16584                "grant_issue must refuse {bad:?}"
16585            );
16586            assert!(
16587                matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
16588                "grant_list must refuse {bad:?}"
16589            );
16590            assert!(
16591                matches!(
16592                    grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
16593                    Err(LinkError::BadAddress { .. })
16594                ),
16595                "grant_revoke must refuse brain {bad:?}"
16596            );
16597            assert!(
16598                matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
16599                "head must refuse {bad:?}"
16600            );
16601        }
16602    }
16603
16604    #[test]
16605    fn grant_revoke_refuses_url_reshaping_grant_ids() {
16606        let cfg = dead_hub();
16607        for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
16608            assert!(
16609                matches!(
16610                    grant_revoke(&cfg, "acme", bad),
16611                    Err(LinkError::BadGrantId { .. })
16612                ),
16613                "grant_revoke must refuse grant id {bad:?}"
16614            );
16615        }
16616    }
16617
16618    #[test]
16619    fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
16620        let cfg = dead_hub();
16621        for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
16622            assert!(
16623                matches!(
16624                    propose(&cfg, bad, "intake", "hi"),
16625                    Err(LinkError::BadAddress { .. })
16626                ),
16627                "propose must refuse handle {bad:?}"
16628            );
16629        }
16630        let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
16631        assert!(matches!(
16632            propose(&cfg, "acme-site", "intake", &oversize),
16633            Err(LinkError::ProposeTooLarge { .. })
16634        ));
16635        // A clean handle + in-cap body passes both gates: the failure is now
16636        // the (dead) wire, proving the gates refuse shape, not the verb.
16637        assert!(matches!(
16638            propose(&cfg, "acme-site", "intake", "hi"),
16639            Err(LinkError::Transport { .. })
16640        ));
16641    }
16642
16643    #[test]
16644    fn resolve_refuses_a_hand_built_unsafe_address() {
16645        let cfg = dead_hub();
16646        for brain in ["../up", "a/b", "a?x", "a#f"] {
16647            let addr = Address {
16648                brain: brain.to_string(),
16649                target: None,
16650            };
16651            assert!(
16652                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
16653                "resolve must refuse brain {brain:?}"
16654            );
16655        }
16656        for target in [
16657            AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
16658            AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), // not the minted shape
16659            AddressTarget::Path("../up.md".to_string()),
16660            AddressTarget::Path("records/x.md#frag".to_string()),
16661        ] {
16662            let addr = Address {
16663                brain: "acme".to_string(),
16664                target: Some(target.clone()),
16665            };
16666            assert!(
16667                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
16668                "resolve must refuse target {target:?}"
16669            );
16670        }
16671    }
16672
16673    #[test]
16674    fn v2_final_barrier_refuses_to_advance_a_remote_ahead_checkout() {
16675        let mut local = std::collections::BTreeMap::new();
16676        local.insert("records/a.md".to_string(), ("a".repeat(64), 0));
16677        local.insert("records/b.md".to_string(), ("b".repeat(64), 0));
16678        let mut remote = std::collections::BTreeMap::new();
16679        remote.insert(
16680            "records/a.md".to_string(),
16681            V2BaselineFile {
16682                sha256: "c".repeat(64),
16683                bytes: 1,
16684                proof: None,
16685            },
16686        );
16687        remote.insert(
16688            "records/b.md".to_string(),
16689            V2BaselineFile {
16690                sha256: "b".repeat(64),
16691                bytes: 1,
16692                proof: None,
16693            },
16694        );
16695        assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
16696    }
16697
16698    #[test]
16699    fn v2_final_barrier_ignores_only_explicit_kept_home_paths() {
16700        let local = std::collections::BTreeMap::new();
16701        let mut remote = std::collections::BTreeMap::new();
16702        remote.insert(
16703            "private/local.md".to_string(),
16704            V2BaselineFile {
16705                sha256: "d".repeat(64),
16706                bytes: 1,
16707                proof: None,
16708            },
16709        );
16710        assert!(v2_riding_matches_remote(&local, &remote, |path| path == "private/local.md"));
16711        assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
16712    }
16713
16714    fn scoped_test_head(revision: &str) -> V2VerifiedHead {
16715        V2VerifiedHead {
16716            requested: TEST_BRAIN_ID.to_string(),
16717            brain_id: TEST_BRAIN_ID.to_string(),
16718            view_kind: "scoped".to_string(),
16719            view_revision: revision.to_string(),
16720            control_revision: revision.to_string(),
16721            identity: V2HeadIdentity {
16722                custody: "hub".to_string(),
16723                fingerprint: "test".to_string(),
16724                public_key_spki: "test".to_string(),
16725                previous: Vec::new(),
16726                rotations: Vec::new(),
16727            },
16728            pointer: None,
16729            trust: TrustState {
16730                v: 2,
16731                origin: "https://hub.example".to_string(),
16732                requested: TEST_BRAIN_ID.to_string(),
16733                brain: TEST_BRAIN_ID.to_string(),
16734                home: None,
16735                anchor: "ed25519:test".to_string(),
16736                current: "ed25519:test".to_string(),
16737                head_seq: 0,
16738                feed_hash: None,
16739                rotations: Vec::new(),
16740                hub_signer: None,
16741                protocol_profile: Some("link-v2".to_string()),
16742            },
16743            alias: None,
16744        }
16745    }
16746
16747    #[test]
16748    fn accepted_v2_checkpoint_never_downgrades_after_profile_migration() {
16749        let mut trust = scoped_test_head(&"a".repeat(64)).trust;
16750        assert!(accepted_as_v2(&trust));
16751
16752        trust.protocol_profile = None;
16753        trust.hub_signer = Some("ed25519:hub".to_string());
16754        assert!(accepted_as_v2(&trust));
16755
16756        trust.hub_signer = None;
16757        assert!(!accepted_as_v2(&trust));
16758    }
16759
16760    fn scoped_test_baseline(revision: &str) -> V2SyncBaseline {
16761        V2SyncBaseline {
16762            v: 2,
16763            origin: "https://hub.example".to_string(),
16764            brain: TEST_BRAIN_ID.to_string(),
16765            checkout_id: Some("c".repeat(64)),
16766            head_seq: Some(0),
16767            commit_hash: None,
16768            content_root: None,
16769            asset_root: None,
16770            assets: std::collections::BTreeMap::new(),
16771            view_kind: Some("scoped".to_string()),
16772            view_revision: Some(revision.to_string()),
16773            projection_sha256: Some(scoped_projection_sha256(TEST_BRAIN_ID)),
16774            files: std::collections::BTreeMap::new(),
16775            local_policy_digest: None,
16776            local_eligibility: std::collections::BTreeMap::new(),
16777            remote_copy_remains: std::collections::BTreeMap::new(),
16778        }
16779    }
16780
16781    #[test]
16782    fn scoped_projection_is_a_valid_local_store_marker_but_never_rides() {
16783        let directory = tempfile::tempdir().unwrap();
16784        std::fs::write(
16785            directory.path().join("DB.md"),
16786            scoped_projection_bytes(TEST_BRAIN_ID),
16787        )
16788        .unwrap();
16789        let store = Store::open_strict(directory.path()).unwrap();
16790        let head = scoped_test_head(&"a".repeat(64));
16791        let baseline = scoped_test_baseline(&"a".repeat(64));
16792        let mut view = v2_local_files(&store).unwrap();
16793        remove_scoped_projection(&head, Some(&baseline), &mut view).unwrap();
16794        assert!(!view.riding.contains_key("DB.md"));
16795        assert!(!view.eligibility.contains_key("DB.md"));
16796    }
16797
16798    #[test]
16799    fn scoped_push_accepts_a_pull_verified_handoff_without_double_checking_projection() {
16800        let directory = tempfile::tempdir().unwrap();
16801        std::fs::write(
16802            directory.path().join("DB.md"),
16803            scoped_projection_bytes(TEST_BRAIN_ID),
16804        )
16805        .unwrap();
16806        let store = Store::open_strict(directory.path()).unwrap();
16807        let head = scoped_test_head(&"a".repeat(64));
16808        let baseline = scoped_test_baseline(&"a".repeat(64));
16809
16810        let mut carried = v2_local_files(&store).unwrap();
16811        remove_scoped_projection(&head, Some(&baseline), &mut carried).unwrap();
16812        let handed_off =
16813            local_view_for_v2_push(&store, &head, Some(&baseline), Some(carried)).unwrap();
16814        assert!(!handed_off.riding.contains_key("DB.md"));
16815
16816        let freshly_scanned = local_view_for_v2_push(&store, &head, Some(&baseline), None).unwrap();
16817        assert!(!freshly_scanned.riding.contains_key("DB.md"));
16818
16819        std::fs::write(
16820            directory.path().join("DB.md"),
16821            b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
16822        )
16823        .unwrap();
16824        let tampered = Store::open_strict(directory.path()).unwrap();
16825        assert!(matches!(
16826            local_view_for_v2_push(&tampered, &head, Some(&baseline), None),
16827            Err(LinkError::ScopedProjectionModified)
16828        ));
16829    }
16830
16831    #[test]
16832    fn v2_local_view_reports_only_exact_linked_kept_home_markdown() {
16833        let directory = tempfile::tempdir().unwrap();
16834        std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
16835        std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
16836        std::fs::write(
16837            directory.path().join("DB.md"),
16838            b"---\nname: Kept home test\n---\n",
16839        )
16840        .unwrap();
16841        std::fs::write(
16842            directory.path().join("records/notes/a.md"),
16843            b"---\ntype: note\n---\nSee [[sources/private/secret]].\n",
16844        )
16845        .unwrap();
16846        std::fs::write(
16847            directory.path().join("sources/private/secret.md"),
16848            b"---\ntype: note\n---\nlocal only\n",
16849        )
16850        .unwrap();
16851        std::fs::write(
16852            directory.path().join("sources/private/unlinked.md"),
16853            b"---\ntype: note\n---\nnot disclosed\n",
16854        )
16855        .unwrap();
16856        std::fs::write(
16857            directory.path().join(".sevralocal"),
16858            b"sources/private/**\n",
16859        )
16860        .unwrap();
16861
16862        let store = Store::open_strict(directory.path()).unwrap();
16863        let view = v2_local_files(&store).unwrap();
16864        assert!(!view.riding.contains_key("sources/private/secret.md"));
16865        assert_eq!(
16866            view.withheld_links,
16867            vec![V2WithheldLink {
16868                source: "records/notes/a.md".to_string(),
16869                target: "sources/private/secret.md".to_string(),
16870            }]
16871        );
16872    }
16873
16874    #[test]
16875    fn explicit_content_withdrawal_requires_exact_current_kept_home_bytes() {
16876        let directory = tempfile::tempdir().unwrap();
16877        std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
16878        std::fs::write(
16879            directory.path().join("DB.md"),
16880            b"---\nname: Withdrawal test\n---\n",
16881        )
16882        .unwrap();
16883        let source = b"---\ntype: note\n---\nlocal evidence\n";
16884        std::fs::write(directory.path().join("sources/private/evidence.md"), source).unwrap();
16885        std::fs::write(
16886            directory.path().join(".sevralocal"),
16887            b"sources/private/**\n",
16888        )
16889        .unwrap();
16890        let store = Store::open_strict(directory.path()).unwrap();
16891        let view = v2_local_files(&store).unwrap();
16892        let mut remote = std::collections::BTreeMap::new();
16893        remote.insert(
16894            "sources/private/evidence.md".to_string(),
16895            V2BaselineFile {
16896                sha256: content_sha256(source),
16897                bytes: source.len() as u64,
16898                proof: None,
16899            },
16900        );
16901        assert_eq!(
16902            v2_content_withdrawal_operation(
16903                &store,
16904                &view,
16905                &remote,
16906                "sources/private/evidence.md",
16907                "approved retention change",
16908            )
16909            .unwrap(),
16910            json!({
16911                "op": "withdraw_from_hosting",
16912                "path": "sources/private/evidence.md",
16913                "expected": { "kind": "blob", "hash": content_sha256(source) },
16914                "reason": "approved retention change",
16915            })
16916        );
16917
16918        std::fs::write(
16919            directory.path().join("sources/private/evidence.md"),
16920            b"changed after review",
16921        )
16922        .unwrap();
16923        assert!(matches!(
16924            v2_content_withdrawal_operation(
16925                &store,
16926                &view,
16927                &remote,
16928                "sources/private/evidence.md",
16929                "approved retention change",
16930            ),
16931            Err(LinkError::InvalidPack { .. })
16932        ));
16933    }
16934
16935    #[test]
16936    fn explicit_asset_withdrawal_requires_the_exact_hosted_leaf_and_local_bytes() {
16937        let directory = tempfile::tempdir().unwrap();
16938        std::fs::create_dir_all(directory.path().join("sources/files")).unwrap();
16939        std::fs::write(
16940            directory.path().join("DB.md"),
16941            b"---\nname: Asset withdrawal test\n---\n",
16942        )
16943        .unwrap();
16944        let bytes = b"private binary";
16945        std::fs::write(directory.path().join("sources/files/private.pdf"), bytes).unwrap();
16946        std::fs::write(
16947            directory.path().join(".sevralocal"),
16948            b"sources/files/private.pdf\n",
16949        )
16950        .unwrap();
16951        let store = Store::open_strict(directory.path()).unwrap();
16952        let view = v2_local_files(&store).unwrap();
16953        let local = crate::AssetRecord {
16954            path: "sources/files/private.pdf".to_string(),
16955            sha256: content_sha256(bytes),
16956            bytes: bytes.len() as u64,
16957            media_type: "application/pdf".to_string(),
16958            wrappers: vec!["sources/files/private.md".to_string()],
16959            required: true,
16960        };
16961        let current = V2BaselineAsset {
16962            blob_sha256: local.sha256.clone(),
16963            bytes: local.bytes,
16964            media_type: local.media_type.clone(),
16965            wrappers: local.wrappers.clone(),
16966            required: local.required,
16967            disposition: "hosted".to_string(),
16968            leaf_hash: "d".repeat(64),
16969        };
16970        assert_eq!(
16971            v2_asset_withdrawal_operation(
16972                &store,
16973                &view,
16974                &local.path,
16975                &local,
16976                &current,
16977                "approved retention change",
16978            )
16979            .unwrap(),
16980            json!({
16981                "op": "asset_withdraw",
16982                "path": local.path,
16983                "expected": { "kind": "asset", "hash": "d".repeat(64) },
16984                "reason": "approved retention change",
16985            })
16986        );
16987
16988        let mut mismatched = current.clone();
16989        mismatched.required = false;
16990        assert!(matches!(
16991            v2_asset_withdrawal_operation(
16992                &store,
16993                &view,
16994                &local.path,
16995                &local,
16996                &mismatched,
16997                "approved retention change",
16998            ),
16999            Err(LinkError::InvalidPack { .. })
17000        ));
17001    }
17002
17003    #[test]
17004    fn checkout_pseudonym_is_random_then_stable_from_private_baseline() {
17005        let first = v2_checkout_id(None).unwrap();
17006        assert_eq!(first, v2_checkout_id(Some(&first)).unwrap());
17007        assert_ne!(first, v2_checkout_id(None).unwrap());
17008        assert!(is_sha256(&first));
17009    }
17010
17011    #[test]
17012    fn scoped_projection_edit_and_scope_transition_fail_closed() {
17013        let directory = tempfile::tempdir().unwrap();
17014        std::fs::write(
17015            directory.path().join("DB.md"),
17016            b"---\ntype: db-md\nscope: company\nowner: someone\n---\n",
17017        )
17018        .unwrap();
17019        let store = Store::open_strict(directory.path()).unwrap();
17020        let head = scoped_test_head(&"a".repeat(64));
17021        let baseline = scoped_test_baseline(&"a".repeat(64));
17022        let mut view = v2_local_files(&store).unwrap();
17023        assert!(matches!(
17024            remove_scoped_projection(&head, Some(&baseline), &mut view),
17025            Err(LinkError::ScopedProjectionModified)
17026        ));
17027
17028        let changed = scoped_test_head(&"b".repeat(64));
17029        assert!(matches!(
17030            ensure_v2_view_compatible(&changed, Some(&baseline)),
17031            Err(LinkError::ScopedViewChanged)
17032        ));
17033
17034        let mut same_view_new_control = head.clone();
17035        same_view_new_control.control_revision = "c".repeat(64);
17036        assert!(ensure_v2_view_compatible(&same_view_new_control, Some(&baseline)).is_ok());
17037        assert!(!same_v2_head(&head, &same_view_new_control));
17038    }
17039
17040    #[test]
17041    fn established_checkout_never_becomes_an_empty_clone_when_db_md_is_invalid() {
17042        let scoped = scoped_test_head(&"a".repeat(64));
17043        let scoped_baseline = scoped_test_baseline(&"a".repeat(64));
17044        assert!(matches!(
17045            ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), false),
17046            Err(LinkError::ScopedProjectionModified)
17047        ));
17048
17049        let mut full = scoped.clone();
17050        full.view_kind = "full".to_string();
17051        let mut full_baseline = scoped_baseline.clone();
17052        full_baseline.view_kind = Some("full".to_string());
17053        full_baseline.projection_sha256 = None;
17054        assert!(matches!(
17055            ensure_established_v2_checkout_opened(&full, Some(&full_baseline), false),
17056            Err(LinkError::InvalidPack { .. })
17057        ));
17058
17059        assert!(ensure_established_v2_checkout_opened(&scoped, None, false).is_ok());
17060        assert!(
17061            ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), true).is_ok()
17062        );
17063    }
17064
17065    #[test]
17066    fn scoped_view_metadata_is_explicitly_non_authoritative() {
17067        let head = scoped_test_head(&"a".repeat(64));
17068        let value: Value =
17069            serde_json::from_slice(&scoped_view_metadata(&head, 7).unwrap()).unwrap();
17070        assert_eq!(value["kind"], "link.md-scoped-view");
17071        assert_eq!(value["authoritative"], false);
17072        assert_eq!(value["visible_files"], 7);
17073        assert_eq!(value["brain"], TEST_BRAIN_ID);
17074    }
17075
17076    #[test]
17077    fn local_scoped_marker_requires_the_exact_generated_projection() {
17078        let directory = tempfile::tempdir().unwrap();
17079        std::fs::create_dir(directory.path().join(".dbmd")).unwrap();
17080        std::fs::write(
17081            directory.path().join("DB.md"),
17082            scoped_projection_bytes(TEST_BRAIN_ID),
17083        )
17084        .unwrap();
17085        let head = scoped_test_head(&"a".repeat(64));
17086        std::fs::write(
17087            directory.path().join(".dbmd/view.json"),
17088            scoped_view_metadata(&head, 0).unwrap(),
17089        )
17090        .unwrap();
17091        let store = Store::open_strict(directory.path()).unwrap();
17092        assert!(has_verified_local_scoped_view(&store));
17093
17094        std::fs::write(
17095            directory.path().join("DB.md"),
17096            b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
17097        )
17098        .unwrap();
17099        let altered = Store::open_strict(directory.path()).unwrap();
17100        assert!(!has_verified_local_scoped_view(&altered));
17101    }
17102
17103    fn signed_proposal_fixture() -> (V2VerifiedHead, String, Value) {
17104        use ring::signature::KeyPair as _;
17105
17106        let proposal_id = "01k2r7bm9w5x6e8nq3tjhv4cya".to_string();
17107        let rng = ring::rand::SystemRandom::new();
17108        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17109        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
17110        let public_der = [ED25519_SPKI_PREFIX.as_slice(), pair.public_key().as_ref()].concat();
17111        let public_key = URL_SAFE_NO_PAD.encode(&public_der);
17112        let fingerprint = format!("{:x}", Sha256::digest(&public_der));
17113        let blob = b"new";
17114        let blob_hash = content_sha256(blob);
17115        let changes = json!({
17116            "mutation_id": "sync:proposal-fixture",
17117            "operations": [{
17118                "blob": blob_hash,
17119                "bytes": blob.len(),
17120                "expected": null,
17121                "op": "put",
17122                "path": "records/new.md",
17123            }],
17124            "reason": "fixture",
17125            "v": 2,
17126        });
17127        let changes_bytes = crate::linkmd_v2::canonical_bytes(&changes).unwrap();
17128        let changes_base64 = STANDARD.encode(&changes_bytes);
17129        let descriptor = json!({
17130            "base": null,
17131            "blobs": [{ "bytes": blob.len(), "sha256": blob_hash }],
17132            "changes_base64": changes_base64,
17133            "rebase": "strict",
17134            "v": 2,
17135        });
17136        let clear_hash = content_sha256(&crate::linkmd_v2::canonical_bytes(&descriptor).unwrap());
17137        let payload_hash = "b".repeat(64);
17138        let submitted_at = "2026-08-19T12:00:00.000Z";
17139        let claim = json!({
17140            "actor_root": {
17141                "actor_class": "foreign_key",
17142                "credential": "ed25519:fixture",
17143                "grants": ["01k2r7bm9w5x6e8nq3tjhv4cyb"],
17144                "organization": "01k2r7bm9w5x6e8nq3tjhv4cyc",
17145                "principal": "key:fixture",
17146                "role": null,
17147            },
17148            "brain": TEST_BRAIN_ID,
17149            "clear_sha256": clear_hash,
17150            "control_revision": "c".repeat(64),
17151            "mutation_id": "sync:proposal-fixture",
17152            "payload_sha256": payload_hash,
17153            "proposal_id": proposal_id,
17154            "submitted_at": submitted_at,
17155            "v": 2,
17156        });
17157        let claim_bytes = crate::linkmd_v2::canonical_bytes(&claim).unwrap();
17158        let envelope = json!({
17159            "claim": claim,
17160            "fingerprint": fingerprint,
17161            "public_key": public_key,
17162            "sig": URL_SAFE_NO_PAD.encode(pair.sign(&claim_bytes).as_ref()),
17163        });
17164        let envelope_bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
17165        let submission_hash =
17166            crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &envelope_bytes).unwrap();
17167        let mut head = scoped_test_head(&"c".repeat(64));
17168        head.view_kind = "full".to_string();
17169        head.trust.hub_signer = Some(format!("{fingerprint}:{public_key}"));
17170        let value = json!({
17171            "proposal": {
17172                "base": null,
17173                "blobs": [{
17174                    "bytes": blob.len(),
17175                    "endpoint": format!(
17176                        "/api/hub/brains/{TEST_BRAIN_ID}/v2/proposals/{proposal_id}/blob?sha256={blob_hash}"
17177                    ),
17178                    "sha256": blob_hash,
17179                }],
17180                "changes_base64": changes_base64,
17181                "clear_sha256": clear_hash,
17182                "expires_at": "2026-08-26T12:00:00.000Z",
17183                "id": proposal_id,
17184                "payload_sha256": payload_hash,
17185                "proposer": { "class": "foreign_key" },
17186                "rebase": "strict",
17187                "state": "pending",
17188                "submission_claim_base64": STANDARD.encode(envelope_bytes),
17189                "submission_claim_sha256": submission_hash,
17190                "submitted_at": submitted_at,
17191            },
17192            "v": 2,
17193        });
17194        (head, proposal_id, value)
17195    }
17196
17197    #[test]
17198    fn v2_proposal_verifier_accepts_exact_signed_payload() {
17199        let (head, proposal_id, value) = signed_proposal_fixture();
17200        let verified = verify_v2_proposal_value(&head, &proposal_id, value).unwrap();
17201        assert_eq!(verified.blobs.len(), 1);
17202        assert_eq!(verified.changes["operations"][0]["path"], "records/new.md");
17203    }
17204
17205    #[test]
17206    fn v2_proposal_verifier_refuses_payload_endpoint_and_signature_tampering() {
17207        let (head, proposal_id, value) = signed_proposal_fixture();
17208
17209        let mut changed = value.clone();
17210        changed["proposal"]["changes_base64"] = Value::String(STANDARD.encode(b"{}"));
17211        assert!(verify_v2_proposal_value(&head, &proposal_id, changed).is_err());
17212
17213        let mut redirected = value.clone();
17214        redirected["proposal"]["blobs"][0]["endpoint"] =
17215            Value::String("https://attacker.example/blob".to_string());
17216        assert!(verify_v2_proposal_value(&head, &proposal_id, redirected).is_err());
17217
17218        let mut forged = value;
17219        let encoded = forged["proposal"]["submission_claim_base64"]
17220            .as_str()
17221            .unwrap();
17222        let mut envelope: Value =
17223            serde_json::from_slice(&STANDARD.decode(encoded).unwrap()).unwrap();
17224        envelope["sig"] = Value::String(URL_SAFE_NO_PAD.encode([0_u8; 64]));
17225        let bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
17226        forged["proposal"]["submission_claim_base64"] = Value::String(STANDARD.encode(&bytes));
17227        forged["proposal"]["submission_claim_sha256"] = Value::String(
17228            crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &bytes).unwrap(),
17229        );
17230        assert!(verify_v2_proposal_value(&head, &proposal_id, forged).is_err());
17231    }
17232
17233    #[cfg(unix)]
17234    #[test]
17235    fn v2_atomic_install_materializes_only_local_catalogs_before_swap() {
17236        let sandbox = tempfile::tempdir().unwrap();
17237        let destination = sandbox.path().join("brain");
17238        let entries = vec![
17239            (
17240                "DB.md".to_string(),
17241                scoped_projection_bytes(TEST_BRAIN_ID),
17242            ),
17243            (
17244                "records/contacts/a.md".to_string(),
17245                b"---\ntype: contact\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Visible contact\n---\n\n# A\n"
17246                    .to_vec(),
17247            ),
17248        ];
17249        install_pulled_delta(&destination, &entries, &[], true).unwrap();
17250        assert!(destination.join("index.md").is_file());
17251        assert!(destination.join("records/index.md").is_file());
17252        assert!(destination.join("records/contacts/index.md").is_file());
17253        assert!(destination.join("records/contacts/index.jsonl").is_file());
17254    }
17255
17256    #[cfg(unix)]
17257    #[test]
17258    fn v2_bounded_parallel_source_install_is_exact_and_atomic() {
17259        let sandbox = tempfile::tempdir().unwrap();
17260        let destination = sandbox.path().join("brain");
17261        let cache = sandbox.path().join("cache");
17262        std::fs::create_dir(&cache).unwrap();
17263        let db = scoped_projection_bytes(TEST_BRAIN_ID);
17264        let shared = b"---\ntype: note\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Shared\n---\n\n# Shared\n";
17265        let db_source = cache.join("db");
17266        let shared_source = cache.join("shared");
17267        crate::fsx::write_atomic(&db_source, &db).unwrap();
17268        crate::fsx::write_atomic(&shared_source, shared).unwrap();
17269        let mut entries = vec![V2StagedFile {
17270            path: "DB.md".to_string(),
17271            source: db_source,
17272            sha256: content_sha256(&db),
17273            bytes: db.len() as u64,
17274        }];
17275        for index in 0..512 {
17276            entries.push(V2StagedFile {
17277                path: format!("records/items/{index:05}.md"),
17278                source: shared_source.clone(),
17279                sha256: content_sha256(shared),
17280                bytes: shared.len() as u64,
17281            });
17282        }
17283        install_pulled_delta_sources(
17284            &destination,
17285            &entries,
17286            &[],
17287            false,
17288            None,
17289            &scoped_test_head(&"c".repeat(64)),
17290        )
17291        .unwrap();
17292        assert_eq!(std::fs::read(destination.join("DB.md")).unwrap(), db);
17293        for index in 0..512 {
17294            assert_eq!(
17295                std::fs::read(destination.join(format!("records/items/{index:05}.md"))).unwrap(),
17296                shared
17297            );
17298        }
17299        assert!(
17300            std::fs::read_dir(sandbox.path())
17301                .unwrap()
17302                .all(|entry| !entry
17303                    .unwrap()
17304                    .file_name()
17305                    .to_string_lossy()
17306                    .contains("pull-stage")),
17307            "the private stage must be atomically installed or removed"
17308        );
17309    }
17310
17311    #[cfg(unix)]
17312    #[test]
17313    fn v2_established_pull_touches_only_delta_and_rolls_back_exactly() {
17314        use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
17315
17316        let sandbox = tempfile::tempdir().unwrap();
17317        let root = sandbox.path().join("brain");
17318        std::fs::create_dir_all(root.join("records/items")).unwrap();
17319        let db = scoped_projection_bytes(TEST_BRAIN_ID);
17320        let old = b"---\ntype: note\n---\n\nold\n";
17321        let new = b"---\ntype: note\n---\n\nnew\n";
17322        let removed = b"---\ntype: note\n---\n\nremove me\n";
17323        std::fs::write(root.join("DB.md"), &db).unwrap();
17324        std::fs::write(root.join("records/items/change.md"), old).unwrap();
17325        std::fs::write(root.join("records/items/delete.md"), removed).unwrap();
17326        for index in 0..512 {
17327            std::fs::write(
17328                root.join(format!("records/items/untouched-{index:04}.md")),
17329                old,
17330            )
17331            .unwrap();
17332        }
17333        let untouched = root.join("records/items/untouched-0256.md");
17334        let untouched_inode = std::fs::metadata(&untouched).unwrap().ino();
17335        let source = sandbox.path().join("changed-source");
17336        crate::fsx::write_atomic(&source, new).unwrap();
17337        let same_source = sandbox.path().join("unchanged-source");
17338        crate::fsx::write_atomic(&same_source, old).unwrap();
17339        let same_entry = V2StagedFile {
17340            path: "records/items/change.md".to_string(),
17341            source: same_source,
17342            sha256: content_sha256(old),
17343            bytes: old.len() as u64,
17344        };
17345        let entry = V2StagedFile {
17346            path: "records/items/change.md".to_string(),
17347            source,
17348            sha256: content_sha256(new),
17349            bytes: new.len() as u64,
17350        };
17351        let head = scoped_test_head(&"c".repeat(64));
17352
17353        // A no-op established pull neither clones the checkout nor arms a
17354        // journal. Inode identity is a direct regression guard against the
17355        // former brain-wide private-copy implementation.
17356        install_established_v2_delta(
17357            Store::open_strict(&root).unwrap(),
17358            &[same_entry],
17359            &["records/items/already-absent.md".to_string()],
17360            true,
17361            None,
17362            &head,
17363        )
17364        .unwrap();
17365        assert_eq!(
17366            std::fs::metadata(&untouched).unwrap().ino(),
17367            untouched_inode
17368        );
17369        assert!(!root.join(V2_PULL_JOURNAL).exists());
17370
17371        install_established_v2_delta(
17372            Store::open_strict(&root).unwrap(),
17373            &[entry],
17374            &["records/items/delete.md".to_string()],
17375            false,
17376            None,
17377            &head,
17378        )
17379        .unwrap();
17380        assert_eq!(
17381            std::fs::read(root.join("records/items/change.md")).unwrap(),
17382            new
17383        );
17384        assert!(!root.join("records/items/delete.md").exists());
17385        assert_eq!(
17386            std::fs::metadata(&untouched).unwrap().ino(),
17387            untouched_inode
17388        );
17389        assert!(root.join(V2_PULL_JOURNAL).is_file());
17390        assert_eq!(
17391            std::fs::metadata(root.join(V2_PULL_JOURNAL))
17392                .unwrap()
17393                .permissions()
17394                .mode()
17395                & 0o777,
17396            0o600
17397        );
17398        let journal = load_v2_pull_journal(&Store::open_strict(&root).unwrap())
17399            .unwrap()
17400            .unwrap();
17401        assert_eq!(
17402            std::fs::metadata(root.join(&journal.backup_dir))
17403                .unwrap()
17404                .permissions()
17405                .mode()
17406                & 0o777,
17407            0o700
17408        );
17409        for entry in &journal.entries {
17410            if let Some(backup) = &entry.backup {
17411                assert_eq!(
17412                    std::fs::metadata(root.join(&journal.backup_dir).join(backup))
17413                        .unwrap()
17414                        .permissions()
17415                        .mode()
17416                        & 0o777,
17417                    0o600
17418                );
17419            }
17420        }
17421
17422        let cfg = test_hub_config(
17423            "https://example.test".to_string(),
17424            sandbox.path().join("state"),
17425        );
17426        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
17427        assert_eq!(
17428            std::fs::read(root.join("records/items/change.md")).unwrap(),
17429            old
17430        );
17431        assert_eq!(
17432            std::fs::read(root.join("records/items/delete.md")).unwrap(),
17433            removed
17434        );
17435        assert_eq!(
17436            std::fs::metadata(&untouched).unwrap().ino(),
17437            untouched_inode
17438        );
17439        assert!(!root.join(V2_PULL_JOURNAL).exists());
17440    }
17441
17442    #[test]
17443    fn v2_bulk_stream_is_exact_ordered_and_tamper_evident() {
17444        let body = b"bounded bytes";
17445        let path = "records/example.md".to_string();
17446        let file = V2BaselineFile {
17447            sha256: content_sha256(body),
17448            bytes: body.len() as u64,
17449            proof: None,
17450        };
17451        let header = serde_json::to_vec(&json!({
17452            "bytes": body.len(),
17453            "path": path,
17454            "sha256": file.sha256,
17455            "v": 2,
17456        }))
17457        .unwrap();
17458        let mut stream = V2_BULK_STREAM_MAGIC.to_vec();
17459        stream.extend_from_slice(&(header.len() as u32).to_be_bytes());
17460        stream.extend_from_slice(&header);
17461        stream.extend_from_slice(body);
17462        stream.extend_from_slice(&0_u32.to_be_bytes());
17463        let parsed = parse_v2_bulk_stream(&stream, &[(&path, &file)]).unwrap();
17464        assert_eq!(parsed, vec![(path.clone(), body.to_vec())]);
17465
17466        let mut tampered = stream.clone();
17467        let body_offset = V2_BULK_STREAM_MAGIC.len() + 4 + header.len();
17468        tampered[body_offset] ^= 1;
17469        assert!(parse_v2_bulk_stream(&tampered, &[(&path, &file)]).is_err());
17470
17471        let mut trailing = stream;
17472        trailing.push(0);
17473        assert!(parse_v2_bulk_stream(&trailing, &[(&path, &file)]).is_err());
17474    }
17475
17476    #[test]
17477    fn conflict_cache_prunes_only_expired_or_incomplete_state_by_default() {
17478        let sandbox = tempfile::TempDir::new().unwrap();
17479        let root = sandbox.path().join("brain");
17480        std::fs::create_dir_all(&root).unwrap();
17481        std::fs::write(
17482            root.join("DB.md"),
17483            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
17484        )
17485        .unwrap();
17486        let store = Store::open_strict(&root).unwrap();
17487        let incomplete = crate::ulid::mint();
17488        store
17489            .create_dir_all(&v2_conflict_relative(&incomplete, "files"))
17490            .unwrap();
17491        let expired = crate::ulid::mint();
17492        store
17493            .create_dir_all(&v2_conflict_relative(&expired, "files"))
17494            .unwrap();
17495        let plan = V2ConflictPlan {
17496            v: 2,
17497            class: "content_resolution_required".to_string(),
17498            bundle: expired.clone(),
17499            brain: TEST_BRAIN_ID.to_string(),
17500            origin: "https://example.test".to_string(),
17501            created_unix: 0,
17502            expires_unix: 0,
17503            base_seq: None,
17504            base_commit: None,
17505            remote_seq: 0,
17506            remote_commit: None,
17507            remote_content_root: None,
17508            view_kind: "full".to_string(),
17509            view_revision: "a".repeat(64),
17510            files: vec![V2ConflictFile {
17511                path: "records/value.md".to_string(),
17512                base: V2ConflictCoordinate {
17513                    sha256: None,
17514                    bytes: None,
17515                    file: None,
17516                },
17517                local: V2ConflictCoordinate {
17518                    sha256: None,
17519                    bytes: None,
17520                    file: None,
17521                },
17522                remote: V2ConflictCoordinate {
17523                    sha256: None,
17524                    bytes: None,
17525                    file: None,
17526                },
17527            }],
17528        };
17529        let mut bytes = serde_json::to_vec(&plan).unwrap();
17530        bytes.push(b'\n');
17531        store
17532            .write_atomic_new(&v2_conflict_relative(&expired, "plan.json"), &bytes)
17533            .unwrap();
17534
17535        let listed = sync_conflicts(&root, false, false).unwrap();
17536        assert_eq!(listed["bundles"], 2);
17537        assert_eq!(listed["pruned"], 0);
17538        let pruned = sync_conflicts(&root, true, false).unwrap();
17539        assert_eq!(pruned["bundles"], 0);
17540        assert_eq!(pruned["pruned"], 2);
17541        assert!(!root.join(".dbmd/conflicts").join(incomplete).exists());
17542        assert!(!root.join(".dbmd/conflicts").join(expired).exists());
17543    }
17544
17545    #[test]
17546    fn corrupt_completed_conflict_state_requires_explicit_discard_all() {
17547        let sandbox = tempfile::TempDir::new().unwrap();
17548        let root = sandbox.path().join("brain");
17549        std::fs::create_dir_all(&root).unwrap();
17550        std::fs::write(
17551            root.join("DB.md"),
17552            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
17553        )
17554        .unwrap();
17555        let store = Store::open_strict(&root).unwrap();
17556        let bundle = crate::ulid::mint();
17557        store
17558            .create_dir_all(&v2_conflict_relative(&bundle, "files"))
17559            .unwrap();
17560        store
17561            .write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), b"not-json\n")
17562            .unwrap();
17563
17564        assert!(sync_conflicts(&root, true, false).is_err());
17565        assert!(sync_conflicts(&root, false, true).is_err());
17566        let pruned = sync_conflicts(&root, true, true).unwrap();
17567        assert_eq!(pruned["pruned"], 1);
17568        assert!(!root.join(".dbmd/conflicts").join(bundle).exists());
17569    }
17570
17571    #[test]
17572    fn ready_pull_journal_rolls_back_exact_preimages() {
17573        let sandbox = tempfile::TempDir::new().unwrap();
17574        let root = sandbox.path().join("brain");
17575        std::fs::create_dir_all(root.join("records")).unwrap();
17576        std::fs::write(
17577            root.join("DB.md"),
17578            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
17579        )
17580        .unwrap();
17581        let path = "records/value.md";
17582        let old = b"---\ntype: note\n---\n\nold\n";
17583        let new = b"---\ntype: note\n---\n\nnew\n";
17584        std::fs::write(root.join(path), old).unwrap();
17585        let store = Store::open_strict(&root).unwrap();
17586        let bundle = crate::ulid::mint();
17587        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
17588        store
17589            .create_private_dir_all(Path::new(&backup_dir))
17590            .unwrap();
17591        store
17592            .write_private_atomic_new(&Path::new(&backup_dir).join("00000000"), old)
17593            .unwrap();
17594        let journal = V2PullJournal {
17595            v: 1,
17596            phase: V2PullPhase::Ready,
17597            brain: TEST_BRAIN_ID.to_string(),
17598            previous: V2PullCoordinate {
17599                head_seq: None,
17600                commit_hash: None,
17601                view_kind: None,
17602                view_revision: None,
17603            },
17604            next: V2PullCoordinate {
17605                head_seq: Some(2),
17606                commit_hash: Some("c".repeat(64)),
17607                view_kind: Some("full".to_string()),
17608                view_revision: Some("d".repeat(64)),
17609            },
17610            backup_dir: backup_dir.clone(),
17611            entries: vec![V2PullJournalEntry {
17612                path: path.to_string(),
17613                old: Some(V2PullFileCoordinate {
17614                    sha256: content_sha256(old),
17615                    bytes: old.len() as u64,
17616                }),
17617                new: Some(V2PullFileCoordinate {
17618                    sha256: content_sha256(new),
17619                    bytes: new.len() as u64,
17620                }),
17621                backup: Some("00000000".to_string()),
17622            }],
17623        };
17624        validate_v2_pull_journal(&journal).unwrap();
17625        store
17626            .write_private_atomic_new(
17627                Path::new(V2_PULL_JOURNAL),
17628                &v2_pull_journal_bytes(&journal).unwrap(),
17629            )
17630            .unwrap();
17631        store.write_atomic(Path::new(path), new).unwrap();
17632
17633        let cfg = test_hub_config(
17634            "https://example.test".to_string(),
17635            sandbox.path().join("state"),
17636        );
17637        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
17638        assert_eq!(std::fs::read(root.join(path)).unwrap(), old);
17639        assert!(!root.join(V2_PULL_JOURNAL).exists());
17640        assert!(!root.join(backup_dir).exists());
17641    }
17642
17643    #[test]
17644    fn preparing_pull_journal_discards_only_private_staging() {
17645        let sandbox = tempfile::TempDir::new().unwrap();
17646        let root = sandbox.path().join("brain");
17647        std::fs::create_dir_all(root.join(".dbmd")).unwrap();
17648        std::fs::write(
17649            root.join("DB.md"),
17650            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
17651        )
17652        .unwrap();
17653        let store = Store::open_strict(&root).unwrap();
17654        let bundle = crate::ulid::mint();
17655        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
17656        store
17657            .create_private_dir_all(Path::new(&backup_dir))
17658            .unwrap();
17659        let journal = V2PullJournal {
17660            v: 1,
17661            phase: V2PullPhase::Preparing,
17662            brain: TEST_BRAIN_ID.to_string(),
17663            previous: V2PullCoordinate {
17664                head_seq: None,
17665                commit_hash: None,
17666                view_kind: None,
17667                view_revision: None,
17668            },
17669            next: V2PullCoordinate {
17670                head_seq: Some(1),
17671                commit_hash: Some("a".repeat(64)),
17672                view_kind: Some("full".to_string()),
17673                view_revision: Some("b".repeat(64)),
17674            },
17675            backup_dir: backup_dir.clone(),
17676            entries: vec![V2PullJournalEntry {
17677                path: "records/new.md".to_string(),
17678                old: None,
17679                new: Some(V2PullFileCoordinate {
17680                    sha256: "c".repeat(64),
17681                    bytes: 1,
17682                }),
17683                backup: None,
17684            }],
17685        };
17686        store
17687            .write_private_atomic_new(
17688                Path::new(V2_PULL_JOURNAL),
17689                &v2_pull_journal_bytes(&journal).unwrap(),
17690            )
17691            .unwrap();
17692        let cfg = test_hub_config(
17693            "https://example.test".to_string(),
17694            sandbox.path().join("state"),
17695        );
17696
17697        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
17698
17699        assert!(root.join("DB.md").is_file());
17700        assert!(!root.join(V2_PULL_JOURNAL).exists());
17701        assert!(!root.join(backup_dir).exists());
17702    }
17703
17704    #[test]
17705    fn committed_baseline_keeps_installed_bytes_and_prunes_orphans() {
17706        let sandbox = tempfile::TempDir::new().unwrap();
17707        let root = sandbox.path().join("brain");
17708        std::fs::create_dir_all(root.join("records")).unwrap();
17709        std::fs::write(
17710            root.join("DB.md"),
17711            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
17712        )
17713        .unwrap();
17714        let new = b"---\ntype: note\n---\n\nnew\n";
17715        std::fs::write(root.join("records/value.md"), new).unwrap();
17716        let store = Store::open_strict(&root).unwrap();
17717        let bundle = crate::ulid::mint();
17718        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
17719        store
17720            .create_private_dir_all(Path::new(&backup_dir))
17721            .unwrap();
17722        let orphan = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
17723        store.create_private_dir_all(Path::new(&orphan)).unwrap();
17724        let next = V2PullCoordinate {
17725            head_seq: Some(2),
17726            commit_hash: Some("c".repeat(64)),
17727            view_kind: Some("full".to_string()),
17728            view_revision: Some("d".repeat(64)),
17729        };
17730        let journal = V2PullJournal {
17731            v: 1,
17732            phase: V2PullPhase::Ready,
17733            brain: TEST_BRAIN_ID.to_string(),
17734            previous: V2PullCoordinate {
17735                head_seq: Some(1),
17736                commit_hash: Some("a".repeat(64)),
17737                view_kind: Some("full".to_string()),
17738                view_revision: Some("b".repeat(64)),
17739            },
17740            next: next.clone(),
17741            backup_dir: backup_dir.clone(),
17742            entries: vec![V2PullJournalEntry {
17743                path: "records/value.md".to_string(),
17744                old: Some(V2PullFileCoordinate {
17745                    sha256: "e".repeat(64),
17746                    bytes: new.len() as u64,
17747                }),
17748                new: Some(V2PullFileCoordinate {
17749                    sha256: content_sha256(new),
17750                    bytes: new.len() as u64,
17751                }),
17752                backup: Some("00000000".to_string()),
17753            }],
17754        };
17755        store
17756            .write_private_atomic_new(
17757                Path::new(V2_PULL_JOURNAL),
17758                &v2_pull_journal_bytes(&journal).unwrap(),
17759            )
17760            .unwrap();
17761        let cfg = test_hub_config(
17762            "https://example.test".to_string(),
17763            sandbox.path().join("state"),
17764        );
17765        save_v2_baseline(
17766            &cfg,
17767            TEST_BRAIN_ID,
17768            &root,
17769            &V2SyncBaseline {
17770                v: 2,
17771                origin: "https://example.test".to_string(),
17772                brain: TEST_BRAIN_ID.to_string(),
17773                checkout_id: Some("c".repeat(64)),
17774                head_seq: next.head_seq,
17775                commit_hash: next.commit_hash.clone(),
17776                content_root: Some("f".repeat(64)),
17777                asset_root: None,
17778                assets: Default::default(),
17779                view_kind: next.view_kind.clone(),
17780                view_revision: next.view_revision.clone(),
17781                projection_sha256: None,
17782                files: Default::default(),
17783                local_policy_digest: None,
17784                local_eligibility: Default::default(),
17785                remote_copy_remains: Default::default(),
17786            },
17787        )
17788        .unwrap();
17789
17790        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
17791
17792        assert_eq!(std::fs::read(root.join("records/value.md")).unwrap(), new);
17793        assert!(!root.join(V2_PULL_JOURNAL).exists());
17794        assert!(!root.join(backup_dir).exists());
17795        assert!(!root.join(orphan).exists());
17796    }
17797}