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/// Ceiling for a staged change manifest. Mirrors the hub's bound so an
136/// oversized push is refused before it stages anything.
137const MAX_STAGED_CHANGE_BYTES: usize = 64 * 1024 * 1024;
138
139/// Canonical raw ZIP32 uses a u16 entry count and stores each UTF-8 name twice.
140const MAX_PUSH_FILES: usize = u16::MAX as usize;
141const MAX_STORE_PATH_BYTES: usize = 1_024;
142const MAX_STORE_BYTES: u64 = 512 * 1024 * 1024;
143/// Assets travel outside the Markdown store/pack lane and have their own
144/// protocol ceiling. Keep this distinct from `MAX_STORE_BYTES`: applying the
145/// pack bound to a signed asset leaf makes an otherwise valid checkout unable
146/// to load its own saved sync baseline after the first commit.
147const MAX_ASSET_BYTES: u64 = 2 * 1024 * 1024 * 1024;
148/// Exact worst case for the canonical STORED profile:
149/// payload + (local 30 + central 46 + name twice) per entry + EOCD 22.
150const MAX_PACK_BYTES: u64 =
151    MAX_STORE_BYTES + MAX_PUSH_FILES as u64 * (76 + 2 * MAX_STORE_PATH_BYTES) as u64 + 22;
152/// An upload reservation body carries one entry per changed blob, so a real
153/// store's first push does not fit in a single request: the hub bounds the body
154/// and the hosting platform rejects an oversized payload before the route sees
155/// it. Reservations are therefore batched. The budget is deliberately well
156/// under the hub's own ceiling, and both bounds are enforced because a single
157/// hash can carry many coordinates and a path may be long.
158/// The count matches the reservation window a hub grants per request; batching
159/// above it only fails deeper in, after the body has already been accepted.
160const MAX_UPLOAD_RESERVATION_BYTES: usize = 1024 * 1024;
161const MAX_UPLOAD_RESERVATION_BLOBS: usize = 1_000;
162
163/// A legitimate brain should rotate rarely. Bound adversarial identity
164/// histories before allocating and repeatedly verifying an unbounded chain.
165const MAX_IDENTITY_ROTATIONS: usize = 1_024;
166
167/// Split blob declarations into request-sized batches, by serialized size and
168/// by count. Order is preserved so a caller can concatenate the responses.
169fn batch_upload_declarations(declarations: Vec<Value>) -> Vec<Vec<Value>> {
170    let mut batches: Vec<Vec<Value>> = Vec::new();
171    let mut current: Vec<Value> = Vec::new();
172    let mut current_bytes = 0usize;
173    for declaration in declarations {
174        let declared_bytes = serde_json::to_string(&declaration)
175            .map(|text| text.len())
176            .unwrap_or(MAX_UPLOAD_RESERVATION_BYTES)
177            + 1;
178        if !current.is_empty()
179            && (current.len() >= MAX_UPLOAD_RESERVATION_BLOBS
180                || current_bytes + declared_bytes > MAX_UPLOAD_RESERVATION_BYTES)
181        {
182            batches.push(std::mem::take(&mut current));
183            current_bytes = 0;
184        }
185        current_bytes += declared_bytes;
186        current.push(declaration);
187    }
188    if !current.is_empty() {
189        batches.push(current);
190    }
191    batches
192}
193/// A client never needs to replay an unbounded feed in one invocation. Large
194/// histories are mirrored incrementally; a first mirror beyond this cap needs a
195/// checkpoint/export rather than allocating attacker-controlled metadata.
196const MAX_FEED_REPLAY_ENTRIES: u64 = 100_000;
197const MAX_FEED_REPLAY_BYTES: u64 = 64 * 1024 * 1024;
198const FEED_PAGE_LIMIT: usize = 100;
199
200/// The hub's inbox cap on one `propose` submission body, mirrored client-side
201/// so an oversized body fails before the upload, not after (the same
202/// fail-before-upload contract as the push caps). Public so the CLI can
203/// pre-check a `--body-file` from file metadata without reading it.
204pub const MAX_PROPOSE_BYTES: u64 = 16 * 1024;
205
206/// Bounded connect so a dead hub fails fast; a generous read window so a
207/// large export on a slow link still completes.
208const CONNECT_TIMEOUT_SECS: u64 = 10;
209const READ_TIMEOUT_SECS: u64 = 120;
210/// Hard wall-clock budget for one HTTP attempt, spanning socket writes,
211/// response headers, and the complete response body. DNS performed for pinned
212/// agents has its own interruptible deadline below.
213const OVERALL_REQUEST_TIMEOUT_SECS: u64 = 120;
214/// A commit of a whole store is brain-sized work the hub is allowed to spend
215/// its full route budget on, so the client has to outwait the server rather
216/// than the other way round. At the default 120 seconds a first push of a real
217/// store abandoned a commit the hub was still completing and reported a
218/// transport failure for work that had not failed. This sits above the hub's
219/// own genesis-commit ceiling so the client always learns the outcome.
220const COMMIT_REQUEST_TIMEOUT_SECS: u64 = 900;
221/// A commit is idempotent by mutation coordinate, so a lost response is worth
222/// asking after — and the work it lost the receipt for may still be finishing,
223/// which is why the waits are long enough to let a brain-sized commit land.
224const COMMIT_ATTEMPTS: usize = 4;
225const COMMIT_RETRY_BACKOFF_MS: [u64; COMMIT_ATTEMPTS - 1] = [5_000, 20_000, 45_000];
226const CONNECT_ATTEMPTS: usize = 3;
227const CONNECT_RETRY_BACKOFF_MS: [u64; CONNECT_ATTEMPTS - 1] = [100, 300];
228/// Safe reads are idempotent, including authenticated GETs and the explicitly
229/// marked read-only POSTs whose proof lists do not fit in a URL. Retry the
230/// whole read when a connection dies after headers or part-way through its
231/// body; connect-only retries cannot recover that common large-export failure.
232const SAFE_READ_ATTEMPTS: usize = 4;
233const SAFE_READ_RETRY_BACKOFF_MS: [u64; SAFE_READ_ATTEMPTS - 1] = [200, 1_000, 3_000];
234
235/// Staging a real store means tens of thousands of immutable object PUTs in a
236/// row. A single refusal under sustained load must not fail the migration, so
237/// the upload lane retries further and waits longer than a connect blip does.
238const UPLOAD_ATTEMPTS: usize = 6;
239const UPLOAD_RETRY_BACKOFF_MS: [u64; UPLOAD_ATTEMPTS - 1] = [200, 600, 1_500, 3_000, 6_000];
240/// Retries share one wall-clock budget. Without this outer deadline, one
241/// black-holed object can consume the per-attempt timeout six times, outlive
242/// its presigned capability, and make a large migration appear hung.
243const UPLOAD_TOTAL_TIMEOUT_SECS: u64 = 300;
244
245fn upload_retry_backoff_ms(attempt: usize) -> u64 {
246    UPLOAD_RETRY_BACKOFF_MS[attempt.min(UPLOAD_RETRY_BACKOFF_MS.len() - 1)]
247}
248
249fn upload_deadline_error() -> LinkError {
250    LinkError::Transport {
251        hub: "the object store".to_string(),
252        message: "network error (upload deadline exceeded)".to_string(),
253    }
254}
255
256fn upload_attempt_timeout(deadline: std::time::Instant) -> LinkResult<std::time::Duration> {
257    let remaining = deadline.saturating_duration_since(std::time::Instant::now());
258    if remaining.is_zero() {
259        return Err(upload_deadline_error());
260    }
261    Ok(remaining.min(std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS)))
262}
263
264fn wait_for_upload_retry(deadline: std::time::Instant, attempt: usize) -> bool {
265    if attempt + 1 >= UPLOAD_ATTEMPTS {
266        return false;
267    }
268    let pause = std::time::Duration::from_millis(upload_retry_backoff_ms(attempt));
269    if deadline.saturating_duration_since(std::time::Instant::now()) <= pause {
270        return false;
271    }
272    std::thread::sleep(pause);
273    true
274}
275
276/// A migration opens dozens of staging windows back to back, so it is ordinary
277/// for a shared limiter or a briefly busy hub to answer one of them with 429 or
278/// 5xx. Failing the push there throws away every byte already staged, and the
279/// waits have to reach past a whole limiter window to be worth anything.
280const RESERVATION_ATTEMPTS: usize = 7;
281const RESERVATION_BACKOFF_MS: [u64; RESERVATION_ATTEMPTS - 1] =
282    [500, 2_000, 5_000, 15_000, 30_000, 60_000];
283
284/// Answers a busy or briefly unhealthy hub gives, as opposed to a refusal that
285/// would repeat identically. A 4xx other than these states something about the
286/// request itself and is never retried.
287fn is_retryable_hub_status(status: u16) -> bool {
288    matches!(status, 408 | 429 | 500 | 502 | 503 | 504)
289}
290
291/// Statuses worth replaying an immutable, content-addressed upload for: edge
292/// and object-store refusals that are load-shaped rather than a statement about
293/// these bytes. A checksum or precondition failure is never retried.
294fn is_retryable_upload_status(status: u16) -> bool {
295    matches!(status, 400 | 408 | 429 | 500 | 502 | 503 | 504)
296}
297/// Cold checkouts may need every visible blob, but short-lived direct object
298/// capabilities must not create an unbounded connection storm. Keep the
299/// verified downloads inside this fixed worker count.
300const V2_BLOB_DOWNLOAD_WORKERS: usize = 16;
301/// File durability barriers are independent inside the private pull stage.
302/// Keep enough in flight to hide storage flush latency without approaching
303/// ordinary process descriptor limits.
304#[cfg(unix)]
305const V2_PULL_INSTALL_WORKERS: usize = 16;
306/// One authenticated link.md v2 bulk frame remains small enough for bounded
307/// retries and serverless streaming while amortizing tens of thousands of
308/// immutable blobs over a few hundred requests instead of one request each.
309const V2_BULK_STREAM_FILES: usize = 256;
310const V2_BULK_STREAM_CONTENT_BYTES: u64 = 8 * 1024 * 1024;
311const V2_BULK_STREAM_RESPONSE_BYTES: u64 = 10 * 1024 * 1024;
312const V2_BULK_STREAM_MAGIC: &[u8; 8] = b"LMD2STRM";
313/// A read capability lives for five minutes. Ask only for the files the
314/// bounded worker pool can start immediately; collecting hundreds of signed
315/// asset URLs before consuming the first one deterministically expires the
316/// oldest capabilities on a company-sized brain.
317const V2_DOWNLOAD_CAPABILITY_FILES: usize = V2_BLOB_DOWNLOAD_WORKERS;
318const V2_DOWNLOAD_CAPABILITY_BYTES: u64 = 512 * 1024 * 1024;
319const V2_DOWNLOAD_CAPABILITY_ATTEMPTS: usize = 4;
320const V2_DOWNLOAD_CAPABILITY_BACKOFF_MS: [u64; V2_DOWNLOAD_CAPABILITY_ATTEMPTS - 1] =
321    [200, 1_000, 3_000];
322
323/// Everything that can go wrong on the wire or at its edges. Each variant maps
324/// onto one stable CLI error code; messages are single-line and never echo the
325/// credential.
326#[derive(Debug, thiserror::Error)]
327pub enum LinkError {
328    /// No hub URL was configured anywhere (flag, env, `.dbmd/config`).
329    #[error(
330        "no hub configured — pass --hub <URL>, set {HUB_URL_ENV}, or add `hub = <URL>` to {CONFIG_REL_PATH}"
331    )]
332    NoHub,
333
334    /// The verb needs a credential and none was present.
335    #[error("no hub credential — set {HUB_KEY_ENV} (credentials never live in {CONFIG_REL_PATH})")]
336    NoCredential,
337
338    /// The credential contains whitespace / non-ASCII (a paste artifact). The
339    /// key is deliberately not echoed.
340    #[error(
341        "the hub credential in {HUB_KEY_ENV} contains whitespace or non-ASCII characters — re-copy it (the key is not shown here on purpose)"
342    )]
343    BadKey,
344
345    /// A store selected the destination while an ambient credential was
346    /// present, but the operator did not bind that credential to the same
347    /// origin. This is a hard refusal, not an anonymous fallback: silently
348    /// dropping a credential can turn an intended private operation into a
349    /// confusing public one.
350    #[error(
351        "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}"
352    )]
353    UnboundCredential,
354
355    /// The agent signing key file named by [`AGENT_KEY_FILE_ENV`] is missing,
356    /// unreadable, or not a valid Ed25519 PKCS#8 — key material is never
357    /// echoed.
358    #[error("invalid agent signing key ({message}) — mint one with `dbmd key generate`")]
359    BadAgentKey {
360        /// What failed, without any key material.
361        message: String,
362    },
363
364    /// A non-HTTPS hub outside loopback: the bearer key would travel in cleartext.
365    #[error("refusing non-HTTPS hub {hub} — the credential would travel in cleartext (localhost is exempt)")]
366    UnsafeHub {
367        /// The offending hub URL.
368        hub: String,
369    },
370
371    /// TCP/TLS-level failure: the hub never answered.
372    #[error("hub unreachable at {hub}: {message}")]
373    Transport {
374        /// The hub base URL.
375        hub: String,
376        /// The transport-layer error text.
377        message: String,
378    },
379
380    /// The hub answered with an HTTP error status.
381    #[error("{what} failed (HTTP {status}): {message}")]
382    Http {
383        /// What the client was doing (e.g. `"resolve"`, `"sync pull"`).
384        what: &'static str,
385        /// The HTTP status code.
386        status: u16,
387        /// The hub's own `error` string when it sent one, else a placeholder.
388        message: String,
389        /// The hub's machine `code` field when it sent one.
390        code: Option<String>,
391        /// Permission-filtered structured refusal details, when supplied.
392        details: Option<Value>,
393    },
394
395    /// A 2xx whose body is not JSON — a captive portal, a proxy, or a wrong
396    /// URL — refused here rather than deserializing into nothing downstream.
397    #[error("{what}: the hub answered HTTP {status} with a non-JSON body — check the hub URL")]
398    NotJson {
399        /// What the client was doing.
400        what: &'static str,
401        /// The (2xx) status that carried the non-JSON body.
402        status: u16,
403    },
404
405    /// The hub response exceeded the selected endpoint's byte cap.
406    #[error("hub response exceeded the {limit_bytes}-byte endpoint cap — refusing to buffer it")]
407    ResponseTooLarge {
408        /// The endpoint-specific cap applied before JSON parsing.
409        limit_bytes: u64,
410    },
411
412    /// A malformed `@brain/id` address.
413    #[error("invalid address `{given}`: {reason}")]
414    BadAddress {
415        /// The raw address as typed.
416        given: String,
417        /// Why it did not parse.
418        reason: String,
419    },
420
421    /// A grant id whose shape cannot travel as a URL path segment.
422    #[error(
423        "invalid grant id `{given}` — grant ids come from `grant list` (lowercase letters, digits, hyphens)"
424    )]
425    BadGrantId {
426        /// The raw id as typed.
427        given: String,
428    },
429
430    /// An exported file path that would escape or pollute the destination
431    /// (absolute, `..`, a dot-leading segment, or an illegal character). The
432    /// hub is not trusted with local path layout.
433    #[error("refusing unsafe path from the hub: `{path}`")]
434    UnsafePath {
435        /// The offending path as received.
436        path: String,
437    },
438
439    /// The store exceeds the hub's bounded whole-snapshot caps.
440    #[error(
441        "push too large ({detail}) — one snapshot caps at {} MB uncompressed, {} MB as a pack, and {MAX_PUSH_FILES} files",
442        MAX_STORE_BYTES / (1024 * 1024),
443        MAX_PACK_BYTES / (1024 * 1024)
444    )]
445    PushTooLarge {
446        /// Which cap was hit, human-readable.
447        detail: String,
448    },
449
450    /// The propose body exceeds the hub's inbox cap.
451    #[error(
452        "propose body too large ({bytes} bytes) — the hub's inbox caps one submission at {} KB",
453        MAX_PROPOSE_BYTES / 1024
454    )]
455    ProposeTooLarge {
456        /// The offending body size in bytes.
457        bytes: u64,
458    },
459
460    /// A store file that is not valid UTF-8 cannot travel the JSON push path.
461    #[error("store file `{path}` is not valid UTF-8 — the JSON push path carries text only")]
462    NotUtf8 {
463        /// The store-relative path of the offending file.
464        path: String,
465    },
466
467    /// A downloaded pack failed validation before any local write.
468    #[error("invalid store pack: {message}")]
469    InvalidPack {
470        /// Hash, ZIP, path, count, or expansion failure.
471        message: String,
472    },
473
474    /// A signed feed entry, hash chain, or advertised feed head did not verify.
475    #[error("invalid signed feed: {message}")]
476    InvalidFeed {
477        /// The failed integrity condition, without untrusted secret material.
478        message: String,
479    },
480
481    /// A mutable alias moved to another canonical brain. Ordinary sync fails
482    /// closed; only the exact old/new ids can authorize replacing that one
483    /// alias binding while preserving both canonical trust histories.
484    #[error(
485        "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}`"
486    )]
487    AliasRebindRequired {
488        alias: String,
489        from: String,
490        to: String,
491    },
492
493    /// Local and remote both changed one or more coordinates since the last
494    /// verified sync baseline. No side was overwritten.
495    #[error("sync conflict on {paths:?} — resolve the named files and retry")]
496    Conflict {
497        /// Bounded, portable paths safe to show to the agent/operator.
498        paths: Vec<String>,
499    },
500
501    /// A readable v2 conflict was preserved as a private, exact-head bundle.
502    /// The bundle is local control state; none of its bytes ride with the
503    /// brain, and resolving it always creates a fresh explicit mutation.
504    #[error(
505        "sync conflict preserved in private bundle `{bundle}` for {paths:?} — run `dbmd sync resolve {bundle} --keep-local`, `--take-remote`, or `--from <safe-file>`"
506    )]
507    ConflictBundle {
508        /// Local ULID naming `.dbmd/conflicts/<bundle>/plan.json`.
509        bundle: String,
510        /// Bounded, readable conflict paths.
511        paths: Vec<String>,
512    },
513
514    /// `.sevralocal` newly made paths eligible to ride. Uploading them is an
515    /// explicit adoption boundary, never an accidental side effect of editing
516    /// or removing a local policy file.
517    #[error(
518        "local sync policy newly exposes {paths:?} — review and retry with --resume-local-policy"
519    )]
520    LocalPolicyTransition {
521        /// Bounded local-only paths. They are never sent in telemetry.
522        paths: Vec<String>,
523    },
524
525    /// The exact mutation crosses a permissioned bulk-impact boundary. The
526    /// hub has not committed it; `preview` is the bounded, permission-filtered
527    /// receipt an agent must inspect before explicitly confirming the same
528    /// request.
529    #[error(
530        "bulk change requires explicit confirmation — review the preview and retry the same sync with --confirm-bulk <id>:<digest>"
531    )]
532    BulkPreviewRequired {
533        /// Stable structured preview returned by the hub.
534        preview: Value,
535    },
536
537    /// A scoped checkout's generated store marker was edited or removed.
538    /// It is local projection metadata and is never accepted as brain data.
539    #[error(
540        "the generated DB.md for this scoped view was modified — clone a fresh scoped checkout"
541    )]
542    ScopedProjectionModified,
543
544    /// The effective permission slice changed after this checkout was pinned.
545    /// Reusing the directory could conceal removals or accidentally adopt files
546    /// revealed by a wider grant, so a new checkout is required.
547    #[error(
548        "the checkout's permission scope changed — clone into a new directory to accept the new view"
549    )]
550    ScopedViewChanged,
551
552    /// A v2 identity/head was previously accepted for this ref, but the hub
553    /// now hides it. Never reinterpret that as a v1 downgrade.
554    #[error("the previously verified brain is unavailable — access may have been revoked or the brain removed")]
555    BrainUnavailable,
556
557    /// The verified remote head advanced while a pull or post-commit barrier
558    /// was in flight. The old baseline is deliberately retained.
559    #[error(
560        "the remote brain advanced during sync — retry to converge from the new verified head"
561    )]
562    RemoteAdvancedDuringSync,
563
564    /// This build cannot provide the no-follow, directory-handle-relative
565    /// filesystem semantics required for trust/key/snapshot state.
566    #[error(
567        "{operation} is unavailable on this platform — use the official macOS/Linux build or WSL"
568    )]
569    UnsupportedPlatform {
570        /// The operation that requires hardened local filesystem primitives.
571        operation: &'static str,
572    },
573
574    /// Local filesystem failure while materializing a pull or reading a push.
575    #[error(transparent)]
576    Io(#[from] std::io::Error),
577
578    /// A store-level failure (walking the local store for a push).
579    #[error(transparent)]
580    Store(#[from] crate::StoreError),
581}
582
583/// Result alias for link.md client operations.
584pub type LinkResult<T> = std::result::Result<T, LinkError>;
585
586/// Exact approval token returned by a permissioned v2 bulk preview.
587#[derive(Debug, Clone, PartialEq, Eq)]
588pub struct V2BulkConfirmation {
589    /// Opaque preview receipt id.
590    pub id: String,
591    /// Digest binding the receipt to principal, head, control revision, and
592    /// the exact strict mutation.
593    pub digest: String,
594}
595
596impl V2BulkConfirmation {
597    /// Parse the CLI/wire handoff form `<id>:<digest>` and reject anything
598    /// outside the protocol's exact lowercase shapes before network I/O.
599    pub fn parse(value: &str) -> LinkResult<Self> {
600        let (id, digest) = value
601            .split_once(':')
602            .ok_or_else(|| LinkError::InvalidPack {
603                message: "bulk confirmation must be <id>:<digest>".to_string(),
604            })?;
605        if !crate::ulid::is_ulid(id) || !is_sha256(digest) {
606            return Err(LinkError::InvalidPack {
607                message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
608                    .to_string(),
609            });
610        }
611        Ok(Self {
612            id: id.to_string(),
613            digest: digest.to_string(),
614        })
615    }
616}
617
618/// Security-sensitive link.md state and destination writes require a hardened
619/// descriptor/handle-relative filesystem implementation. Unix uses
620/// `openat`/`O_NOFOLLOW`; Windows uses held directory handles, reparse-point
621/// refusal, exclusive share modes, and write-through replacement.
622fn require_hardened_filesystem(operation: &'static str) -> LinkResult<()> {
623    #[cfg(any(target_os = "linux", target_os = "macos", windows))]
624    {
625        let _ = operation;
626        Ok(())
627    }
628    #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
629    {
630        Err(LinkError::UnsupportedPlatform { operation })
631    }
632}
633
634// ─────────────────────────────────────────────────────────────────────────────
635// Addressing — `@brain[/id]`, the reserved shape (SPEC § Addressing)
636// ─────────────────────────────────────────────────────────────────────────────
637
638/// What the part after `@brain/` names.
639#[derive(Debug, Clone, PartialEq, Eq)]
640pub enum AddressTarget {
641    /// A record `id` — the db.md lowercase ULID (the reserved `@brain/id` shape).
642    Id(String),
643    /// A store-relative `.md` path — a client-side convenience the hub's
644    /// resolve endpoint also accepts (`?path=`). Not part of the reserved
645    /// shape; unambiguous because a ULID is never a path.
646    Path(String),
647}
648
649/// Why a brain reference failed [`is_safe_ref`] — shared by [`Address::parse`]
650/// and the per-verb entry gates so the two surfaces never drift.
651const BAD_BRAIN_REASON: &str =
652    "the brain reference must be a brain id (lowercase ULID) or a slug (lowercase letters, digits, hyphens)";
653
654/// Why an address target failed its shape check — shared by [`Address::parse`]
655/// and the [`resolve`] entry gate.
656const BAD_TARGET_REASON: &str =
657    "the part after `/` must be a record id (lowercase ULID) or a store-relative `.md` path";
658
659/// A parsed `@brain[/target]` address. `brain` is a hub brain reference — the
660/// brain's ULID id (works for any caller, including cross-party on a public
661/// brain) or a slug (which a hub resolves only against the caller's own
662/// brains; slugs are unique per owner, not globally).
663#[derive(Debug, Clone, PartialEq, Eq)]
664pub struct Address {
665    /// The brain reference (leading `@` stripped).
666    pub brain: String,
667    /// The record target, when the address names one.
668    pub target: Option<AddressTarget>,
669}
670
671impl Address {
672    /// Parse `@brain`, `@brain/<ulid>`, or `@brain/<store-path>.md`. The `@`
673    /// sigil is optional (an agent piping ids around should not have to quote
674    /// it back on). Whitespace and empty segments are malformed.
675    pub fn parse(raw: &str) -> LinkResult<Address> {
676        let bad = |reason: &str| LinkError::BadAddress {
677            given: raw.to_string(),
678            reason: reason.to_string(),
679        };
680
681        let trimmed = raw.trim();
682        let body = trimmed.strip_prefix('@').unwrap_or(trimmed);
683        if body.is_empty() {
684            return Err(bad("empty address"));
685        }
686
687        let (brain, rest) = match body.split_once('/') {
688            Some((b, r)) => (b, Some(r)),
689            None => (body, None),
690        };
691
692        if brain.is_empty() {
693            return Err(bad("missing brain reference before `/`"));
694        }
695        if !is_safe_ref(brain) {
696            return Err(bad(BAD_BRAIN_REASON));
697        }
698
699        let target = match rest {
700            None => None,
701            Some("") => return Err(bad("trailing `/` with no record id or path")),
702            Some(r) if crate::ulid::is_ulid(r) => Some(AddressTarget::Id(r.to_string())),
703            Some(r) => {
704                if !safe_store_rel_path(r) || !r.ends_with(".md") {
705                    return Err(bad(BAD_TARGET_REASON));
706                }
707                Some(AddressTarget::Path(r.to_string()))
708            }
709        };
710
711        Ok(Address {
712            brain: brain.to_string(),
713            target,
714        })
715    }
716}
717
718/// A brain reference safe to embed in a URL path segment: the shapes a hub
719/// accepts (ULID id or slug), which are also exactly URL-path-clean.
720fn is_safe_ref(s: &str) -> bool {
721    !s.is_empty()
722        && s.len() <= 64
723        && s.bytes()
724            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
725}
726
727/// A published-site handle (the `propose` target). Same lexical shape as a
728/// slug.
729pub fn is_valid_handle(s: &str) -> bool {
730    is_safe_ref(s)
731}
732
733/// True when `p` is a store-relative path this client will read from or write
734/// to disk: relative, no `..`, no empty or dot-leading segment (which shields
735/// `.dbmd/` and `.git/`), and only the hub-portable character set. Applied to
736/// every path an export hands us (the hub is not trusted with local layout)
737/// and to every path a push sends (mirroring the hub's own gate).
738pub fn safe_store_rel_path(p: &str) -> bool {
739    if p.is_empty() || p.len() > MAX_STORE_PATH_BYTES || p.starts_with('/') {
740        return false;
741    }
742    if !p
743        .bytes()
744        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/'))
745    {
746        return false;
747    }
748    p.split('/')
749        .all(|seg| !seg.is_empty() && seg != "." && seg != ".." && !seg.starts_with('.'))
750}
751
752/// Entry gate for every verb that embeds a caller-supplied brain reference in
753/// a URL path segment. `resolve` reaches the same check through
754/// [`Address::parse`]; the raw-ref verbs (`sync`, `grant`, `subscribe`) call
755/// this directly, so a ref carrying `/`, `..`, `?`, `#`, or any other
756/// URL-reshaping byte is refused before a request exists (the `url` crate
757/// normalizes dot segments, so an unvalidated ref would redirect the
758/// authenticated request to a different hub path).
759fn require_safe_ref(brain: &str) -> LinkResult<()> {
760    if is_safe_ref(brain) {
761        Ok(())
762    } else {
763        Err(LinkError::BadAddress {
764            given: brain.to_string(),
765            reason: BAD_BRAIN_REASON.to_string(),
766        })
767    }
768}
769
770/// Entry gate for the published-site handle `propose` embeds in its URL path.
771fn require_valid_handle(handle: &str) -> LinkResult<()> {
772    if is_valid_handle(handle) {
773        Ok(())
774    } else {
775        Err(LinkError::BadAddress {
776            given: handle.to_string(),
777            reason: "the site handle must be lowercase letters, digits, hyphens".to_string(),
778        })
779    }
780}
781
782/// Entry gate for the grant id `grant revoke` embeds in its URL path. Hub
783/// grant ids are lowercase ULIDs; the gate accepts the same URL-path-clean
784/// shape as a brain ref rather than pinning one mint scheme.
785fn require_safe_grant_id(id: &str) -> LinkResult<()> {
786    if is_safe_ref(id) {
787        Ok(())
788    } else {
789        Err(LinkError::BadGrantId {
790            given: id.to_string(),
791        })
792    }
793}
794
795// ─────────────────────────────────────────────────────────────────────────────
796// Configuration — flag > env > .dbmd/config; credential from env only
797// ─────────────────────────────────────────────────────────────────────────────
798
799/// The resolved client configuration for one invocation.
800#[derive(Debug, Clone)]
801pub struct HubConfig {
802    /// The hub base URL, trailing slash stripped, HTTPS-or-loopback enforced.
803    pub hub: String,
804    /// The bearer credential, when the environment carries one.
805    pub key: Option<String>,
806    /// The agent signing key, when [`AGENT_KEY_FILE_ENV`] names one. Wins
807    /// over the bearer for authenticated requests (link.md §8).
808    pub agent_key: Option<AgentSigningKey>,
809    /// The self-custodied brain signing key, when [`BRAIN_KEY_FILE_ENV`]
810    /// names one — `sync --push` then signs feed entries locally (§2.4).
811    pub brain_key: Option<AgentSigningKey>,
812    /// User-owned global toolkit state root. Identity pins and monotonic feed
813    /// checkpoints live below `<state_dir>/trust/`, never under a store.
814    pub state_dir: PathBuf,
815    /// True only when the origin came from untrusted store-local configuration.
816    /// Such origins are resolved and public-IP-pinned for every request.
817    store_selected: bool,
818}
819
820/// A loaded agent signing key: the PKCS#8 secret plus its derived public
821/// multikey. Debug never prints key material.
822#[derive(Clone)]
823pub struct AgentSigningKey {
824    pkcs8: Vec<u8>,
825    /// The key's public identity, `ed25519:<base64url sha256(SPKI)>`.
826    pub multikey: String,
827    /// The full public key, `base64url(SPKI DER)` — what feed entries carry.
828    pub public_key_spki: String,
829}
830
831impl std::fmt::Debug for AgentSigningKey {
832    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
833        f.debug_struct("AgentSigningKey")
834            .field("multikey", &self.multikey)
835            .field("pkcs8", &"<redacted>")
836            .finish()
837    }
838}
839
840impl HubConfig {
841    /// The credential, or the canonical "not configured" error. Verbs that
842    /// authenticate call this; `propose` never does.
843    pub fn require_key(&self) -> LinkResult<&str> {
844        self.key.as_deref().ok_or(LinkError::NoCredential)
845    }
846}
847
848/// Resolve the client configuration: `flag_hub` beats [`HUB_URL_ENV`] beats
849/// the `hub =` line in `<dir>/.dbmd/config`; no fallback default exists. The
850/// credential comes from [`HUB_KEY_ENV`] alone and is validated as a clean
851/// header token (never echoed on failure).
852pub fn hub_config(flag_hub: Option<&str>, dir: &Path) -> LinkResult<HubConfig> {
853    let explicit_hub = flag_hub
854        .map(str::to_string)
855        .or_else(|| env_nonempty(HUB_URL_ENV));
856    let selected_by_store = explicit_hub.is_none();
857    let hub = explicit_hub
858        .or_else(|| config_file_hub(&dir.join(CONFIG_REL_PATH)))
859        .ok_or(LinkError::NoHub)?;
860    let hub = hub.trim().trim_end_matches('/').to_string();
861    assert_safe_hub(&hub)?;
862    if selected_by_store {
863        let parsed =
864            url::Url::parse(&hub).map_err(|_| LinkError::UnsafeHub { hub: hub.clone() })?;
865        // A cloned store is not an operator opt-in to local-network access.
866        // Local/private development hubs must be selected explicitly by flag or
867        // environment, never by bytes inside the store.
868        if !parsed.scheme().eq_ignore_ascii_case("https")
869            || (parsed.path() != "/" && !parsed.path().is_empty())
870        {
871            return Err(LinkError::UnsafeHub { hub });
872        }
873    }
874
875    let key = match env_nonempty(HUB_KEY_ENV) {
876        Some(raw) => Some(clean_key(&raw)?),
877        None => None,
878    };
879
880    let agent_key = match env_nonempty(AGENT_KEY_FILE_ENV) {
881        Some(path) => Some(load_agent_key(Path::new(&path))?),
882        None => None,
883    };
884
885    let brain_key = match env_nonempty(BRAIN_KEY_FILE_ENV) {
886        Some(path) => Some(load_agent_key(Path::new(&path))?),
887        None => None,
888    };
889
890    // A cloned store controls `.dbmd/config`. It must never be able to point
891    // the process at an attacker origin and harvest the user's ambient account
892    // bearer, signed agent identity, or self-custodied brain signatures/content.
893    // Explicit --hub/DBMD_HUB_URL selection already pairs target + credential
894    // in the invocation environment; a store-selected target additionally
895    // needs an exact origin binding.
896    if selected_by_store && (key.is_some() || agent_key.is_some() || brain_key.is_some()) {
897        let bound = env_nonempty(HUB_CREDENTIAL_ORIGIN_ENV)
898            .and_then(|value| normalized_origin(&value).ok());
899        let selected_origin = normalized_origin(&hub)?;
900        if bound.as_deref() != Some(selected_origin.as_str()) {
901            return Err(LinkError::UnboundCredential);
902        }
903    }
904
905    Ok(HubConfig {
906        hub,
907        key,
908        agent_key,
909        brain_key,
910        state_dir: toolkit_state_dir()?,
911        store_selected: selected_by_store,
912    })
913}
914
915fn toolkit_state_dir() -> LinkResult<PathBuf> {
916    if let Some(path) = env_nonempty(STATE_DIR_ENV) {
917        let path = PathBuf::from(path);
918        if !path.is_absolute() {
919            return Err(LinkError::UnsafePath {
920                path: path.display().to_string(),
921            });
922        }
923        return Ok(path);
924    }
925    #[cfg(windows)]
926    if let Some(base) = env_nonempty("LOCALAPPDATA") {
927        let base = PathBuf::from(base);
928        if base.is_absolute() {
929            return Ok(base.join("dbmd").join("state"));
930        }
931    }
932    #[cfg(windows)]
933    {
934        Err(LinkError::Io(std::io::Error::new(
935            std::io::ErrorKind::NotFound,
936            format!("cannot locate user state; set {STATE_DIR_ENV} or LOCALAPPDATA"),
937        )))
938    }
939    #[cfg(not(windows))]
940    if let Some(base) = env_nonempty("XDG_STATE_HOME") {
941        let base = PathBuf::from(base);
942        if base.is_absolute() {
943            return Ok(base.join("dbmd"));
944        }
945    }
946    #[cfg(not(windows))]
947    let home = PathBuf::from(env_nonempty("HOME").ok_or_else(|| {
948        LinkError::Io(std::io::Error::new(
949            std::io::ErrorKind::NotFound,
950            format!("cannot locate user state; set {STATE_DIR_ENV}"),
951        ))
952    })?);
953    #[cfg(not(windows))]
954    if !home.is_absolute() {
955        return Err(LinkError::UnsafePath {
956            path: home.display().to_string(),
957        });
958    }
959    #[cfg(target_os = "macos")]
960    {
961        Ok(home
962            .join("Library")
963            .join("Application Support")
964            .join("dbmd")
965            .join("state"))
966    }
967    #[cfg(all(not(target_os = "macos"), not(windows)))]
968    {
969        Ok(home.join(".local").join("state").join("dbmd"))
970    }
971}
972
973fn normalized_origin(value: &str) -> LinkResult<String> {
974    let parsed = url::Url::parse(value).map_err(|_| LinkError::UnsafeHub {
975        hub: value.to_string(),
976    })?;
977    if !(parsed.scheme().eq_ignore_ascii_case("https")
978        || parsed.scheme().eq_ignore_ascii_case("http"))
979        || !parsed.username().is_empty()
980        || parsed.password().is_some()
981        || (parsed.path() != "/" && !parsed.path().is_empty())
982        || parsed.query().is_some()
983        || parsed.fragment().is_some()
984    {
985        return Err(LinkError::UnsafeHub {
986            hub: value.to_string(),
987        });
988    }
989    let host = parsed.host_str().ok_or_else(|| LinkError::UnsafeHub {
990        hub: value.to_string(),
991    })?;
992    let host = if host.contains(':') {
993        format!("[{host}]")
994    } else {
995        host.to_ascii_lowercase()
996    };
997    let port = parsed
998        .port_or_known_default()
999        .ok_or_else(|| LinkError::UnsafeHub {
1000            hub: value.to_string(),
1001        })?;
1002    let default = (parsed.scheme().eq_ignore_ascii_case("https") && port == 443)
1003        || (parsed.scheme().eq_ignore_ascii_case("http") && port == 80);
1004    Ok(format!(
1005        "{}://{}{}",
1006        parsed.scheme().to_ascii_lowercase(),
1007        host,
1008        if default {
1009            String::new()
1010        } else {
1011            format!(":{port}")
1012        }
1013    ))
1014}
1015
1016// ─────────────────────────────────────────────────────────────────────────────
1017// Agent signing keys — link.md §8 `LinkMD-Sig` proof of possession
1018// ─────────────────────────────────────────────────────────────────────────────
1019
1020/// The DER prefix that wraps a raw Ed25519 public key into a
1021/// SubjectPublicKeyInfo (RFC 8410).
1022const ED25519_SPKI_PREFIX: [u8; 12] = [
1023    0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
1024];
1025
1026fn bad_agent_key(message: &str) -> LinkError {
1027    LinkError::BadAgentKey {
1028        message: message.to_string(),
1029    }
1030}
1031
1032fn agent_keypair(pkcs8: &[u8]) -> LinkResult<ring::signature::Ed25519KeyPair> {
1033    // `from_pkcs8` wants ring's own v2 encoding (private + public); keys from
1034    // other tools are often PKCS#8 v1, which `maybe_unchecked` accepts by
1035    // deriving the public half itself.
1036    ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8)
1037        .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(pkcs8))
1038        .map_err(|_| bad_agent_key("not an Ed25519 PKCS#8 key"))
1039}
1040
1041/// Derive `(publicKeySpki b64u, multikey)` from a keypair.
1042fn public_identity_for(pair: &ring::signature::Ed25519KeyPair) -> (String, String) {
1043    use ring::signature::KeyPair as _;
1044    let mut spki = Vec::with_capacity(44);
1045    spki.extend_from_slice(&ED25519_SPKI_PREFIX);
1046    spki.extend_from_slice(pair.public_key().as_ref());
1047    (
1048        URL_SAFE_NO_PAD.encode(&spki),
1049        format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&spki))),
1050    )
1051}
1052
1053/// Load and validate a signing-key file (agent or brain — same format):
1054/// one base64url line of PKCS#8. Public so `dbmd key rotate` can load the
1055/// old key explicitly.
1056pub fn load_signing_key(path: &Path) -> LinkResult<AgentSigningKey> {
1057    load_agent_key(path)
1058}
1059
1060/// Load and validate the agent key file: one base64url line of PKCS#8.
1061fn load_agent_key(path: &Path) -> LinkResult<AgentSigningKey> {
1062    #[cfg(unix)]
1063    let file = {
1064        use std::os::fd::{AsRawFd as _, FromRawFd as _};
1065        use std::os::unix::ffi::OsStrExt as _;
1066        let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
1067            .map_err(|e| bad_agent_key(&format!("cannot open the key parent: {e}")))?;
1068        let leaf = path
1069            .file_name()
1070            .ok_or_else(|| bad_agent_key("the key path has no file name"))?;
1071        let leaf = c_name(leaf.as_bytes(), &path.display().to_string())?;
1072        let fd = unsafe {
1073            libc::openat(
1074                parent.as_raw_fd(),
1075                leaf.as_ptr(),
1076                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1077            )
1078        };
1079        if fd < 0 {
1080            return Err(bad_agent_key(
1081                "the key path must be an existing regular file without symlink ancestors",
1082            ));
1083        }
1084        unsafe { std::fs::File::from_raw_fd(fd) }
1085    };
1086    #[cfg(not(unix))]
1087    let file = std::fs::File::open(path)
1088        .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
1089    let metadata = file
1090        .metadata()
1091        .map_err(|e| bad_agent_key(&format!("cannot inspect the key file: {e}")))?;
1092    if !metadata.is_file() {
1093        return Err(bad_agent_key("the key path must be a regular file"));
1094    }
1095    #[cfg(unix)]
1096    {
1097        use std::os::unix::fs::PermissionsExt as _;
1098        if metadata.permissions().mode() & 0o077 != 0 {
1099            return Err(bad_agent_key(
1100                "the key file is accessible to group/other; set mode 0600",
1101            ));
1102        }
1103    }
1104    let mut text = String::new();
1105    file.take(1024 * 1024 + 1)
1106        .read_to_string(&mut text)
1107        .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
1108    if text.len() > 1024 * 1024 {
1109        return Err(bad_agent_key("the key file exceeds the size limit"));
1110    }
1111    let pkcs8 = URL_SAFE_NO_PAD
1112        .decode(text.trim())
1113        .map_err(|_| bad_agent_key("the key file is not one base64url line"))?;
1114    let (public_key_spki, multikey) = public_identity_for(&agent_keypair(&pkcs8)?);
1115    Ok(AgentSigningKey {
1116        pkcs8,
1117        multikey,
1118        public_key_spki,
1119    })
1120}
1121
1122/// Durably create a new secret file without an exists→write race and without
1123/// ever exposing a default-mode (commonly 0644) key between write and chmod.
1124/// `create_new` also refuses a planted symlink. The file and its parent
1125/// directory are synced before the caller can publish the corresponding
1126/// public identity remotely.
1127fn write_secret_new(path: &Path, bytes: &[u8]) -> LinkResult<()> {
1128    #[cfg(unix)]
1129    let (mut file, parent, leaf) = {
1130        use std::os::fd::{AsRawFd as _, FromRawFd as _};
1131        use std::os::unix::ffi::OsStrExt as _;
1132        let parent = open_or_create_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))?;
1133        let leaf_name = path
1134            .file_name()
1135            .ok_or_else(|| bad_agent_key("the output key path has no file name"))?;
1136        let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
1137        let fd = unsafe {
1138            libc::openat(
1139                parent.as_raw_fd(),
1140                leaf.as_ptr(),
1141                libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1142                0o600,
1143            )
1144        };
1145        if fd < 0 {
1146            let error = std::io::Error::last_os_error();
1147            if error.kind() == std::io::ErrorKind::AlreadyExists {
1148                return Err(bad_agent_key(
1149                    "the output file already exists — refusing to overwrite a key",
1150                ));
1151            }
1152            return Err(error.into());
1153        }
1154        (unsafe { std::fs::File::from_raw_fd(fd) }, parent, leaf)
1155    };
1156    #[cfg(not(unix))]
1157    let mut file = std::fs::OpenOptions::new()
1158        .write(true)
1159        .create_new(true)
1160        .open(path)
1161        .map_err(|error| {
1162            if error.kind() == std::io::ErrorKind::AlreadyExists {
1163                bad_agent_key("the output file already exists — refusing to overwrite a key")
1164            } else {
1165                LinkError::Io(error)
1166            }
1167        })?;
1168    if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_all()) {
1169        drop(file);
1170        #[cfg(unix)]
1171        let _ =
1172            unsafe { libc::unlinkat(std::os::fd::AsRawFd::as_raw_fd(&parent), leaf.as_ptr(), 0) };
1173        #[cfg(not(unix))]
1174        let _ = std::fs::remove_file(path);
1175        return Err(LinkError::Io(error));
1176    }
1177    drop(file);
1178    #[cfg(unix)]
1179    parent.sync_all()?;
1180    Ok(())
1181}
1182
1183/// What `dbmd key generate` returns: the public identity to register plus
1184/// where the secret landed.
1185#[derive(Debug, Serialize)]
1186pub struct GeneratedAgentKey {
1187    /// `ed25519:<fingerprint>` — the grantable/registerable identity.
1188    pub multikey: String,
1189    /// base64url SPKI DER — what a hub's register endpoint takes.
1190    #[serde(rename = "publicKeySpki")]
1191    pub public_key_spki: String,
1192    /// Where the PKCS#8 secret was written (mode 0600).
1193    #[serde(rename = "keyFile")]
1194    pub key_file: String,
1195}
1196
1197/// Mint a fresh Ed25519 agent keypair. The secret is written to `out`
1198/// (base64url PKCS#8, one line, 0600, refusing to overwrite); only public
1199/// identity is returned. The private key never enters a store and never
1200/// travels — requests carry per-request signatures instead (link.md §8).
1201pub fn generate_agent_key(out: &Path) -> LinkResult<GeneratedAgentKey> {
1202    require_hardened_filesystem("key generation")?;
1203    let rng = ring::rand::SystemRandom::new();
1204    let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
1205        .map_err(|_| bad_agent_key("key generation failed"))?;
1206    let pair = agent_keypair(pkcs8.as_ref())?;
1207    let (spki_b64u, multikey) = public_identity_for(&pair);
1208
1209    write_secret_new(
1210        out,
1211        format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
1212    )?;
1213
1214    Ok(GeneratedAgentKey {
1215        multikey,
1216        public_key_spki: spki_b64u,
1217        key_file: out.display().to_string(),
1218    })
1219}
1220
1221/// Build the origin-bound `LinkMD-Sig` v2 header for one request:
1222/// `canonical = "v2" LF origin LF METHOD LF path+query LF ts LF
1223/// (sha256hex(body) | "-")`.
1224///
1225/// The origin is derived from the already-validated hub URL, never from
1226/// attacker-controlled request metadata. Binding it closes the v1 replay
1227/// class where a proof captured by one hub could be replayed to another hub
1228/// serving the same path inside the timestamp window.
1229fn linkmd_sig_header(
1230    key: &AgentSigningKey,
1231    origin: &str,
1232    method: &str,
1233    path: &str,
1234    body: Option<&str>,
1235) -> LinkResult<String> {
1236    let ts = std::time::SystemTime::now()
1237        .duration_since(std::time::UNIX_EPOCH)
1238        .map_err(|_| bad_agent_key("system clock is before the epoch"))?
1239        .as_secs();
1240    let body_hash = match body {
1241        Some(b) => format!("{:x}", Sha256::digest(b.as_bytes())),
1242        None => "-".to_string(),
1243    };
1244    let canonical = format!(
1245        "v2\n{}\n{}\n{}\n{}\n{}",
1246        origin,
1247        method.to_uppercase(),
1248        path,
1249        ts,
1250        body_hash
1251    );
1252    let pair = agent_keypair(&key.pkcs8)?;
1253    let sig = URL_SAFE_NO_PAD.encode(pair.sign(canonical.as_bytes()).as_ref());
1254    let fingerprint = key.multikey.trim_start_matches("ed25519:");
1255    Ok(format!(
1256        "LinkMD-Sig v2,key=ed25519:{fingerprint},ts={ts},sig={sig}"
1257    ))
1258}
1259
1260// ─────────────────────────────────────────────────────────────────────────────
1261// Self-custody feed entries — the client signs what the hub only verifies
1262// ─────────────────────────────────────────────────────────────────────────────
1263
1264/// One `files` element of a wire-profile-v1 feed entry (SPEC §5.1: fields in
1265/// exactly this order).
1266#[derive(Serialize)]
1267struct WireFeedFile {
1268    path: String,
1269    sha256: String,
1270    bytes: u64,
1271}
1272
1273/// The unsigned entry in the normative §5.1 field order — serde serializes
1274/// struct fields in declaration order, which IS the wire contract.
1275#[derive(Serialize)]
1276struct UnsignedWireEntry<'a> {
1277    v: u8,
1278    seq: u64,
1279    ts: String,
1280    brain: &'a str,
1281    public_key: &'a str,
1282    kind: &'a str,
1283    op: &'a str,
1284    pack_sha256: &'a str,
1285    files: &'a [WireFeedFile],
1286    removed: &'a [String],
1287    prev_entry_hash: Option<&'a str>,
1288}
1289
1290/// Build and sign a wire-profile-v1 `push` feed entry with a self-custodied
1291/// brain key: serialize the unsigned entry compactly in the normative order,
1292/// Ed25519-sign those exact bytes, splice `sig` on as the final field. The
1293/// returned string is the exact serialization the hub stores verbatim (plus
1294/// one trailing newline) and every independent reader re-derives.
1295fn self_custody_entry(
1296    key: &AgentSigningKey,
1297    seq: u64,
1298    ts: String,
1299    pack_sha256: &str,
1300    files: &[WireFeedFile],
1301    prev_entry_hash: Option<&str>,
1302) -> LinkResult<String> {
1303    let removed: [String; 0] = [];
1304    let unsigned = serde_json::to_string(&UnsignedWireEntry {
1305        v: 1,
1306        seq,
1307        ts,
1308        brain: &key.multikey,
1309        public_key: &key.public_key_spki,
1310        kind: "push",
1311        op: "snapshot",
1312        pack_sha256,
1313        files,
1314        removed: &removed,
1315        prev_entry_hash,
1316    })
1317    .expect("serialize feed entry");
1318    let pair = agent_keypair(&key.pkcs8)?;
1319    let sig = URL_SAFE_NO_PAD.encode(pair.sign(unsigned.as_bytes()).as_ref());
1320    Ok(format!(
1321        "{},\"sig\":\"{}\"}}",
1322        &unsigned[..unsigned.len() - 1],
1323        sig
1324    ))
1325}
1326
1327/// An env var, treated as absent when unset or empty (an empty
1328/// `DBMD_HUB_KEY=` falls through rather than becoming an empty credential).
1329fn env_nonempty(name: &str) -> Option<String> {
1330    std::env::var(name).ok().filter(|v| !v.trim().is_empty())
1331}
1332
1333/// Read the `hub = <URL>` line out of a `.dbmd/config` file. The format is
1334/// deliberately minimal: `key = value` lines, `#` comments, unknown keys
1335/// ignored (forward-compatible). A missing or unreadable file is simply "not
1336/// configured here".
1337fn config_file_hub(path: &Path) -> Option<String> {
1338    const MAX_CONFIG_BYTES: u64 = 64 * 1024;
1339    #[cfg(unix)]
1340    let file = {
1341        use std::os::fd::{AsRawFd as _, FromRawFd as _};
1342        use std::os::unix::ffi::OsStrExt as _;
1343        let parent =
1344            open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new("."))).ok()?;
1345        let leaf = c_name(path.file_name()?.as_bytes(), &path.display().to_string()).ok()?;
1346        let fd = unsafe {
1347            libc::openat(
1348                parent.as_raw_fd(),
1349                leaf.as_ptr(),
1350                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1351            )
1352        };
1353        if fd < 0 {
1354            return None;
1355        }
1356        unsafe { std::fs::File::from_raw_fd(fd) }
1357    };
1358    #[cfg(not(unix))]
1359    let file = std::fs::File::open(path).ok()?;
1360    let metadata = file.metadata().ok()?;
1361    if !metadata.is_file() || metadata.len() > MAX_CONFIG_BYTES {
1362        return None;
1363    }
1364    let mut bytes = Vec::with_capacity(metadata.len() as usize);
1365    file.take(MAX_CONFIG_BYTES + 1)
1366        .read_to_end(&mut bytes)
1367        .ok()?;
1368    if bytes.len() as u64 > MAX_CONFIG_BYTES {
1369        return None;
1370    }
1371    let text = String::from_utf8(bytes).ok()?;
1372    for line in text.lines() {
1373        let line = line.trim();
1374        if line.is_empty() || line.starts_with('#') {
1375            continue;
1376        }
1377        if let Some((k, v)) = line.split_once('=') {
1378            if k.trim() == "hub" {
1379                let v = v.trim();
1380                if !v.is_empty() {
1381                    return Some(v.to_string());
1382                }
1383            }
1384        }
1385    }
1386    None
1387}
1388
1389/// The bearer key must never travel in cleartext; only loopback hosts may
1390/// skip TLS (local development against a hub on localhost).
1391fn assert_safe_hub(hub: &str) -> LinkResult<()> {
1392    let parsed = url::Url::parse(hub).map_err(|_| LinkError::UnsafeHub {
1393        hub: hub.to_string(),
1394    })?;
1395    if !(parsed.scheme().eq_ignore_ascii_case("https")
1396        || parsed.scheme().eq_ignore_ascii_case("http"))
1397        || !parsed.username().is_empty()
1398        || parsed.password().is_some()
1399        || (parsed.path() != "/" && !parsed.path().is_empty())
1400        || parsed.query().is_some()
1401        || parsed.fragment().is_some()
1402    {
1403        return Err(LinkError::UnsafeHub {
1404            hub: hub.to_string(),
1405        });
1406    }
1407    let loopback = match parsed.host() {
1408        Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
1409        Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
1410        Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
1411        None => false,
1412    };
1413    if parsed.scheme().eq_ignore_ascii_case("https") || loopback {
1414        Ok(())
1415    } else {
1416        Err(LinkError::UnsafeHub {
1417            hub: hub.to_string(),
1418        })
1419    }
1420}
1421
1422/// Trim paste artifacts and refuse anything outside the printable-ASCII token
1423/// range WITHOUT echoing the key — an HTTP library rejecting a bad header
1424/// value tends to echo the whole header line, credential included, so the
1425/// gate sits here instead.
1426fn clean_key(raw: &str) -> LinkResult<String> {
1427    let k = raw.trim();
1428    if k.is_empty() || k.bytes().any(|b| !(0x21..=0x7e).contains(&b)) {
1429        return Err(LinkError::BadKey);
1430    }
1431    Ok(k.to_string())
1432}
1433
1434// ─────────────────────────────────────────────────────────────────────────────
1435// Transport — one blocking agent, capped reads, the JSON-or-refuse contract
1436// ─────────────────────────────────────────────────────────────────────────────
1437
1438/// One hub response: the status plus the parsed JSON body when there was one.
1439#[derive(Debug)]
1440pub struct HubResponse {
1441    /// The HTTP status code.
1442    pub status: u16,
1443    /// The parsed JSON body, `None` when the body was empty or not JSON.
1444    pub body: Option<Value>,
1445}
1446
1447struct RawHubResponse {
1448    status: u16,
1449    body: Vec<u8>,
1450}
1451
1452/// Whether a request carries the bearer credential.
1453#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1454enum Auth {
1455    /// Send `authorization: Bearer <key>`; error without a key.
1456    Required,
1457    /// Send no credential — the propose door is unauthenticated by design.
1458    None,
1459    /// Send the configured credential when one exists, otherwise nothing —
1460    /// brain-addressed propose works anonymously on public brains, and an
1461    /// authenticated caller earns a bigger actor-class budget.
1462    Optional,
1463}
1464
1465fn agent_builder_with_timeout(overall: std::time::Duration) -> ureq::AgentBuilder {
1466    ureq::AgentBuilder::new()
1467        .user_agent(concat!("dbmd/", env!("CARGO_PKG_VERSION")))
1468        // Never follow a redirect while a bearer, a store pack, or a signed
1469        // response is in flight. Callers see the 3xx as a non-success instead
1470        // of letting an origin steer sensitive material elsewhere.
1471        .redirects(0)
1472        .timeout_connect(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
1473        .timeout_read(std::time::Duration::from_secs(READ_TIMEOUT_SECS))
1474        .timeout_write(overall)
1475        .timeout(overall)
1476}
1477
1478fn hub_agent(cfg: &HubConfig) -> LinkResult<ureq::Agent> {
1479    hub_agent_with_timeout(
1480        cfg,
1481        std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS),
1482    )
1483}
1484
1485fn hub_agent_with_timeout(
1486    cfg: &HubConfig,
1487    overall: std::time::Duration,
1488) -> LinkResult<ureq::Agent> {
1489    if !cfg.store_selected {
1490        return Ok(agent_builder_with_timeout(overall).build());
1491    }
1492    let parsed = url::Url::parse(&cfg.hub).map_err(|_| LinkError::UnsafeHub {
1493        hub: cfg.hub.clone(),
1494    })?;
1495    pinned_public_agent_pooled(
1496        &parsed,
1497        false,
1498        "store-selected hub",
1499        AgentShape {
1500            overall,
1501            ..AgentShape::default()
1502        },
1503    )
1504}
1505
1506/// Perform one hub request. `path` is the binding path (starts with `/`);
1507/// `body` posts JSON. Transport failures, oversized bodies, and non-UTF-8 are
1508/// all surfaced as typed [`LinkError`]s; HTTP error statuses are returned in
1509/// the [`HubResponse`] for [`ensure_ok`] to shape.
1510fn request_raw(
1511    cfg: &HubConfig,
1512    method: &str,
1513    path: &str,
1514    body: Option<&Value>,
1515    auth: Auth,
1516    max_response_bytes: u64,
1517) -> LinkResult<RawHubResponse> {
1518    let http = hub_agent(cfg)?;
1519    request_raw_with_agent(
1520        cfg,
1521        &http,
1522        method,
1523        path,
1524        body,
1525        RawRequestOptions {
1526            auth,
1527            max_response_bytes,
1528            request_id: None,
1529            retry_transport: false,
1530        },
1531    )
1532}
1533
1534/// A body-bearing request that reads immutable state without changing hub
1535/// authority. Some proof lists are too large for a URL, so link.md transports
1536/// them with POST even though repeating the request is side-effect free.
1537fn request_raw_retryable_read(
1538    cfg: &HubConfig,
1539    method: &str,
1540    path: &str,
1541    body: Option<&Value>,
1542    auth: Auth,
1543    max_response_bytes: u64,
1544) -> LinkResult<RawHubResponse> {
1545    let http = hub_agent(cfg)?;
1546    request_raw_with_agent(
1547        cfg,
1548        &http,
1549        method,
1550        path,
1551        body,
1552        RawRequestOptions {
1553            auth,
1554            max_response_bytes,
1555            request_id: None,
1556            retry_transport: true,
1557        },
1558    )
1559}
1560
1561struct RawRequestOptions<'a> {
1562    auth: Auth,
1563    max_response_bytes: u64,
1564    request_id: Option<&'a str>,
1565    retry_transport: bool,
1566}
1567
1568fn request_raw_with_agent(
1569    cfg: &HubConfig,
1570    http: &ureq::Agent,
1571    method: &str,
1572    path: &str,
1573    body: Option<&Value>,
1574    options: RawRequestOptions<'_>,
1575) -> LinkResult<RawHubResponse> {
1576    let url = format!("{}{}", cfg.hub, path);
1577    let encoded_body = body.map(Value::to_string);
1578    let origin = normalized_origin(&cfg.hub)?;
1579    let safe_read = (method == "GET" && encoded_body.is_none()) || options.retry_transport;
1580    let mut read_attempt = 0;
1581    loop {
1582        // An agent signing key outranks the bearer: possession proofs put
1583        // nothing reusable on the wire, so when both are configured the
1584        // stronger one wins. Regenerate its timestamp for a whole-request GET
1585        // retry; a long stalled read must not age the replacement signature
1586        // outside the hub's acceptance window. This applies only to GETs and
1587        // the explicitly marked read-only POST bindings above.
1588        let credential = match options.auth {
1589            Auth::Required => Some(match &cfg.agent_key {
1590                Some(key) => {
1591                    linkmd_sig_header(key, &origin, method, path, encoded_body.as_deref())?
1592                }
1593                None => format!("Bearer {}", cfg.require_key()?),
1594            }),
1595            Auth::Optional => match &cfg.agent_key {
1596                Some(key) => Some(linkmd_sig_header(
1597                    key,
1598                    &origin,
1599                    method,
1600                    path,
1601                    encoded_body.as_deref(),
1602                )?),
1603                None => cfg.key.as_deref().map(|k| format!("Bearer {k}")),
1604            },
1605            Auth::None => None,
1606        };
1607        let result = with_connect_retries(|| {
1608            let mut req = http.request(method, &url);
1609            if let Some(value) = &credential {
1610                req = req.set("authorization", value);
1611            }
1612            if let Some(value) = options.request_id {
1613                req = req.set("x-request-id", value);
1614            }
1615            match &encoded_body {
1616                Some(value) => req
1617                    .set("content-type", "application/json")
1618                    .send_string(value)
1619                    .map_err(Box::new),
1620                None => req.call().map_err(Box::new),
1621            }
1622        });
1623        let resp = match result {
1624            Ok(resp) => resp,
1625            Err(error) => match *error {
1626                ureq::Error::Status(_, resp) => resp,
1627                ureq::Error::Transport(error) => {
1628                    if safe_read && read_attempt + 1 < SAFE_READ_ATTEMPTS {
1629                        std::thread::sleep(std::time::Duration::from_millis(
1630                            SAFE_READ_RETRY_BACKOFF_MS[read_attempt],
1631                        ));
1632                        read_attempt += 1;
1633                        continue;
1634                    }
1635                    return Err(LinkError::Transport {
1636                        hub: cfg.hub.clone(),
1637                        message: error.to_string(),
1638                    });
1639                }
1640            },
1641        };
1642
1643        let status = resp.status();
1644        let buf = match read_response_body(resp, options.max_response_bytes + 1, &cfg.hub) {
1645            Ok(buf) => buf,
1646            Err(LinkError::Transport { .. })
1647                if safe_read && read_attempt + 1 < SAFE_READ_ATTEMPTS =>
1648            {
1649                std::thread::sleep(std::time::Duration::from_millis(
1650                    SAFE_READ_RETRY_BACKOFF_MS[read_attempt],
1651                ));
1652                read_attempt += 1;
1653                continue;
1654            }
1655            Err(error) => return Err(error),
1656        };
1657        if buf.len() as u64 > options.max_response_bytes {
1658            return Err(LinkError::ResponseTooLarge {
1659                limit_bytes: options.max_response_bytes,
1660            });
1661        }
1662        return Ok(RawHubResponse { status, body: buf });
1663    }
1664}
1665
1666fn request_capped(
1667    cfg: &HubConfig,
1668    method: &str,
1669    path: &str,
1670    body: Option<&Value>,
1671    auth: Auth,
1672    max_response_bytes: u64,
1673) -> LinkResult<HubResponse> {
1674    let raw = request_raw(cfg, method, path, body, auth, max_response_bytes)?;
1675    let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
1676    Ok(HubResponse {
1677        status: raw.status,
1678        body: parsed,
1679    })
1680}
1681
1682/// One request the hub is allowed to take brain-sized time over. Used for the
1683/// commit lane, where abandoning the attempt early does not cancel the work —
1684/// it only loses the receipt for it.
1685///
1686/// Which is why a lost connection is asked again rather than reported. The
1687/// mutation coordinate is derived from the observed base and the exact
1688/// operation set precisely so a repeat is the SAME effect: the hub answers a
1689/// mutation it already applied with that commit's receipt instead of a second
1690/// commit. Measured on a 32,338-file store: the commit landed durably and the
1691/// push reported a transport failure, because nobody went back for the
1692/// receipt.
1693fn request_patient(
1694    cfg: &HubConfig,
1695    method: &str,
1696    path: &str,
1697    body: Option<&Value>,
1698    auth: Auth,
1699) -> LinkResult<HubResponse> {
1700    let http = hub_agent_with_timeout(
1701        cfg,
1702        std::time::Duration::from_secs(COMMIT_REQUEST_TIMEOUT_SECS),
1703    )?;
1704    let mut attempt = 0;
1705    loop {
1706        let sent = request_raw_with_agent(
1707            cfg,
1708            &http,
1709            method,
1710            path,
1711            body,
1712            RawRequestOptions {
1713                auth,
1714                max_response_bytes: MAX_RESPONSE_BYTES,
1715                request_id: None,
1716                retry_transport: false,
1717            },
1718        );
1719        match sent {
1720            Err(LinkError::Transport { .. }) if attempt + 1 < COMMIT_ATTEMPTS => {
1721                std::thread::sleep(std::time::Duration::from_millis(
1722                    COMMIT_RETRY_BACKOFF_MS[attempt.min(COMMIT_RETRY_BACKOFF_MS.len() - 1)],
1723                ));
1724                attempt += 1;
1725            }
1726            Err(error) => return Err(error),
1727            Ok(raw) => {
1728                return Ok(HubResponse {
1729                    status: raw.status,
1730                    body: serde_json::from_slice(&raw.body).ok(),
1731                })
1732            }
1733        }
1734    }
1735}
1736
1737fn request(
1738    cfg: &HubConfig,
1739    method: &str,
1740    path: &str,
1741    body: Option<&Value>,
1742    auth: Auth,
1743) -> LinkResult<HubResponse> {
1744    request_capped(cfg, method, path, body, auth, MAX_RESPONSE_BYTES)
1745}
1746
1747/// Repeat one challenge-response mutation with the exact request identity the
1748/// hub bound into its actor claim. A new request id would create a new actor
1749/// hash and therefore a different canonical commit candidate, making the
1750/// otherwise correct self-custody signature unusable.
1751fn request_with_request_id(
1752    cfg: &HubConfig,
1753    method: &str,
1754    path: &str,
1755    body: Option<&Value>,
1756    auth: Auth,
1757    request_id: &str,
1758) -> LinkResult<HubResponse> {
1759    if request_id.is_empty()
1760        || request_id.len() > 128
1761        || !request_id
1762            .bytes()
1763            .all(|byte| byte.is_ascii_alphanumeric() || b"-_.:".contains(&byte))
1764    {
1765        return Err(invalid_feed("hub returned an unsafe request id"));
1766    }
1767    // Both callers are commit responses, so this shares the commit lane's
1768    // patience: the hub is finishing the same brain-sized work either way.
1769    let http = hub_agent_with_timeout(
1770        cfg,
1771        std::time::Duration::from_secs(COMMIT_REQUEST_TIMEOUT_SECS),
1772    )?;
1773    let raw = request_raw_with_agent(
1774        cfg,
1775        &http,
1776        method,
1777        path,
1778        body,
1779        RawRequestOptions {
1780            auth,
1781            max_response_bytes: MAX_RESPONSE_BYTES,
1782            request_id: Some(request_id),
1783            retry_transport: false,
1784        },
1785    )?;
1786    Ok(HubResponse {
1787        status: raw.status,
1788        body: serde_json::from_slice(&raw.body).ok(),
1789    })
1790}
1791
1792fn ensure_raw_ok(r: RawHubResponse, what: &'static str) -> LinkResult<Vec<u8>> {
1793    if (200..300).contains(&r.status) {
1794        return Ok(r.body);
1795    }
1796    ensure_ok(
1797        HubResponse {
1798            status: r.status,
1799            body: serde_json::from_slice(&r.body).ok(),
1800        },
1801        what,
1802    )
1803    .and_then(|_| Err(invalid_feed("a non-2xx response was accepted unexpectedly")))
1804}
1805
1806/// These failures happen before any HTTP request reaches the hub, so retrying
1807/// cannot duplicate a mutation. Mid-stream I/O is deliberately excluded: once
1808/// bytes may have crossed the wire, the caller must rely on the verb's own
1809/// idempotency contract instead of guessing.
1810fn is_pre_request_transport(kind: ureq::ErrorKind) -> bool {
1811    matches!(
1812        kind,
1813        ureq::ErrorKind::Dns | ureq::ErrorKind::ConnectionFailed | ureq::ErrorKind::ProxyConnect
1814    )
1815}
1816
1817fn with_connect_retries(
1818    mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
1819) -> Result<ureq::Response, Box<ureq::Error>> {
1820    let mut attempt = 0;
1821    loop {
1822        match send() {
1823            Err(error)
1824                if matches!(
1825                    error.as_ref(),
1826                    ureq::Error::Transport(transport)
1827                        if is_pre_request_transport(transport.kind())
1828                ) && attempt + 1 < CONNECT_ATTEMPTS =>
1829            {
1830                std::thread::sleep(std::time::Duration::from_millis(
1831                    CONNECT_RETRY_BACKOFF_MS[attempt],
1832                ));
1833                attempt += 1;
1834            }
1835            result => return result,
1836        }
1837    }
1838}
1839
1840fn hub_is_loopback(hub: &str) -> bool {
1841    url::Url::parse(hub).ok().is_some_and(|parsed| {
1842        parsed.host().is_some_and(|host| match host {
1843            url::Host::Domain(host) => host.eq_ignore_ascii_case("localhost"),
1844            url::Host::Ipv4(ip) => ip.is_loopback(),
1845            url::Host::Ipv6(ip) => ip.is_loopback(),
1846        })
1847    })
1848}
1849
1850/// Every safety property a presigned object-store URL must carry, checked
1851/// before anything is sent to it. Split out from agent construction so a batch
1852/// can share one connection pool without ever skipping a per-URL check.
1853fn checked_presigned_url(cfg: &HubConfig, raw: &str) -> LinkResult<(url::Url, bool)> {
1854    let parsed = url::Url::parse(raw).map_err(|_| LinkError::InvalidPack {
1855        message: "the hub returned an invalid object-store URL".to_string(),
1856    })?;
1857    let allow_private = hub_is_loopback(&cfg.hub)
1858        || env_nonempty(ALLOW_PRIVATE_OBJECT_URL_ENV).as_deref() == Some("1");
1859    if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1860        || !parsed.username().is_empty()
1861        || parsed.password().is_some()
1862        || parsed.fragment().is_some()
1863    {
1864        return Err(LinkError::InvalidPack {
1865            message: "the hub returned an unsafe object-store URL".to_string(),
1866        });
1867    }
1868    Ok((parsed, allow_private))
1869}
1870
1871fn presigned_agent(cfg: &HubConfig, raw: &str) -> LinkResult<ureq::Agent> {
1872    let (parsed, allow_private) = checked_presigned_url(cfg, raw)?;
1873    pinned_public_agent(&parsed, allow_private, "object-store URL").map_err(|_| {
1874        LinkError::InvalidPack {
1875            message: "the hub returned an object-store URL with an unsafe network target"
1876                .to_string(),
1877        }
1878    })
1879}
1880
1881/// One pinned agent for a whole staging batch.
1882///
1883/// Every object in a batch is presigned against the same object-store
1884/// authority, so one agent can serve them all — and must: building one per
1885/// object costs a DNS resolution and a fresh TLS handshake per object, which is
1886/// the difference between a real store staging in minutes and in an hour. The
1887/// shared authority is verified rather than assumed, and a batch that ever
1888/// spans two of them falls back to a per-object agent.
1889fn shared_staging_agent(cfg: &HubConfig, urls: &[&str]) -> Option<ureq::Agent> {
1890    let (first, allow_private) = checked_presigned_url(cfg, urls.first()?).ok()?;
1891    let authority = (
1892        first.host_str()?.to_string(),
1893        first.port_or_known_default()?,
1894    );
1895    for raw in &urls[1..] {
1896        let (parsed, _) = checked_presigned_url(cfg, raw).ok()?;
1897        if (parsed.host_str()?, parsed.port_or_known_default()?)
1898            != (authority.0.as_str(), authority.1)
1899        {
1900            return None;
1901        }
1902    }
1903    pinned_public_agent_pooled(
1904        &first,
1905        allow_private,
1906        "object-store URL",
1907        AgentShape {
1908            idle_per_host: V2_UPLOAD_CONCURRENCY,
1909            ..AgentShape::default()
1910        },
1911    )
1912    .ok()
1913}
1914
1915/// Turn an object-store transport failure into a useful but credential-safe
1916/// diagnostic. `ureq::Transport`'s Display implementation includes the full
1917/// request URL; for a presigned URL that means the short-lived signature and
1918/// every signed query coordinate. The error kind is enough to distinguish a
1919/// network failure without copying bearer material into terminals or logs.
1920fn object_store_transport_error(error: ureq::Transport) -> LinkError {
1921    LinkError::Transport {
1922        hub: "the object store".to_string(),
1923        message: format!("network error ({:?})", error.kind()),
1924    }
1925}
1926
1927fn put_presigned(cfg: &HubConfig, raw: &str, headers: &Value, bytes: &[u8]) -> LinkResult<()> {
1928    let http = presigned_agent(cfg, raw)?;
1929    let deadline = std::time::Instant::now()
1930        .checked_add(std::time::Duration::from_secs(UPLOAD_TOTAL_TIMEOUT_SECS))
1931        .ok_or_else(upload_deadline_error)?;
1932    let mut attempt = 0;
1933    let result = loop {
1934        // The reservation states content-length itself. This client sends the
1935        // body from memory, so it never needs to add one, and adding one under
1936        // a different capitalization would leave both on the wire.
1937        let mut req = http.put(raw).timeout(upload_attempt_timeout(deadline)?);
1938        if let Some(map) = headers.as_object() {
1939            for (name, value) in map {
1940                if let Some(value) = value.as_str() {
1941                    req = req.set(name, value);
1942                }
1943            }
1944        }
1945        match req.send_bytes(bytes) {
1946            // An upload is idempotent here: fixed content address, exact
1947            // length and checksum, write-once precondition. A dropped
1948            // connection or a single overloaded refusal is worth replaying
1949            // rather than failing a whole migration on. 412 already means
1950            // "someone else stored these exact bytes".
1951            Err(ureq::Error::Transport(_)) if wait_for_upload_retry(deadline, attempt) => {
1952                attempt += 1;
1953            }
1954            Err(ureq::Error::Status(status, _))
1955                if status != 412
1956                    && is_retryable_upload_status(status)
1957                    && wait_for_upload_retry(deadline, attempt) =>
1958            {
1959                attempt += 1;
1960            }
1961            result => break result,
1962        }
1963    };
1964    match result {
1965        Ok(resp) if (200..300).contains(&resp.status()) => {
1966            drain_presigned_response(resp);
1967            Ok(())
1968        }
1969        Ok(resp) => Err(presigned_upload_refusal(resp)),
1970        Err(error) => match error {
1971            // Immutable uploads use If-None-Match. A concurrent writer may
1972            // win the same content address; the commit/confirm endpoint reads
1973            // and hashes that object before accepting it, so 412 safely means
1974            // "continue to verification", never "trust the upload".
1975            ureq::Error::Status(412, _) => Ok(()),
1976            ureq::Error::Status(_, resp) => Err(presigned_upload_refusal(resp)),
1977            ureq::Error::Transport(err) => Err(object_store_transport_error(err)),
1978        },
1979    }
1980}
1981
1982/// Read a response body, typed as the transport failure it is.
1983///
1984/// A connection that fails while the body is still arriving is the same class
1985/// of problem as one that fails before it arrives at all — but a bare `?` on
1986/// the read turned it into an anonymous io error that no retry path recognised
1987/// and no message attributed to a peer. On a migration making tens of
1988/// thousands of requests, one stalled body ended the whole push and reported
1989/// only "timed out reading response".
1990fn read_response_body(response: ureq::Response, limit: u64, peer: &str) -> LinkResult<Vec<u8>> {
1991    let mut buf = Vec::new();
1992    response
1993        .into_reader()
1994        .take(limit)
1995        .read_to_end(&mut buf)
1996        .map_err(|error| LinkError::Transport {
1997            hub: peer.to_string(),
1998            message: error.to_string(),
1999        })?;
2000    Ok(buf)
2001}
2002
2003/// Draining a response is what returns its connection to the pool: the client
2004/// cannot recycle a socket whose body may still have bytes pending, so a body
2005/// that is dropped instead of read costs the next object a fresh handshake.
2006/// An object-store PUT answers with almost nothing, so this is nearly free.
2007fn drain_presigned_response(response: ureq::Response) {
2008    let mut reader = response.into_reader().take(64 * 1024);
2009    let _ = std::io::copy(&mut reader, &mut std::io::sink());
2010}
2011
2012/// Carry the object store's own reason for a refusal. An unexplained status is
2013/// unactionable; the body states checksum mismatch, precondition, or expiry.
2014fn presigned_upload_refusal(response: ureq::Response) -> LinkError {
2015    let status = response.status();
2016    let detail = response
2017        .into_string()
2018        .ok()
2019        .map(|body| body.chars().take(400).collect::<String>())
2020        .filter(|body| !body.trim().is_empty());
2021    LinkError::Http {
2022        what: "pack upload",
2023        status,
2024        message: match detail {
2025            Some(body) => format!(
2026                "object store rejected the upload: {}",
2027                body.replace('\n', " ")
2028            ),
2029            None => "object store rejected the upload".to_string(),
2030        },
2031        code: None,
2032        details: None,
2033    }
2034}
2035
2036fn one_past_bounded_limit(max_bytes: u64) -> Option<u64> {
2037    max_bytes.checked_add(1)
2038}
2039
2040fn presigned_download_read_limit() -> u64 {
2041    one_past_bounded_limit(MAX_PACK_BYTES)
2042        .expect("the fixed presigned-download ceiling must leave room for the refusal byte")
2043}
2044
2045fn get_presigned(cfg: &HubConfig, raw: &str) -> LinkResult<Vec<u8>> {
2046    let http = presigned_agent(cfg, raw)?;
2047    let resp = match with_connect_retries(|| http.get(raw).call().map_err(Box::new)) {
2048        Ok(resp) => resp,
2049        Err(error) => match *error {
2050            ureq::Error::Status(_, resp) => {
2051                return Err(LinkError::Http {
2052                    what: "pack download",
2053                    status: resp.status(),
2054                    message: "object store rejected the download".to_string(),
2055                    code: None,
2056                    details: None,
2057                });
2058            }
2059            ureq::Error::Transport(err) => {
2060                return Err(LinkError::Transport {
2061                    hub: "the object store".to_string(),
2062                    message: err.to_string(),
2063                });
2064            }
2065        },
2066    };
2067    if !(200..300).contains(&resp.status()) {
2068        return Err(LinkError::Http {
2069            what: "pack download",
2070            status: resp.status(),
2071            message: "object store rejected the download".to_string(),
2072            code: None,
2073            details: None,
2074        });
2075    }
2076    let bytes = read_response_body(resp, presigned_download_read_limit(), "the object store")?;
2077    if bytes.len() as u64 > MAX_PACK_BYTES {
2078        return Err(LinkError::InvalidPack {
2079            message: "download exceeds the compressed-size limit".to_string(),
2080        });
2081    }
2082    Ok(bytes)
2083}
2084
2085/// Unwrap a successful JSON body, or shape the failure: a >=400 surfaces the
2086/// hub's own `error` + `code`; a 2xx without JSON is refused as not a hub
2087/// answer.
2088fn ensure_ok(r: HubResponse, what: &'static str) -> LinkResult<Value> {
2089    if !(200..300).contains(&r.status) {
2090        let message = r
2091            .body
2092            .as_ref()
2093            .and_then(|b| b.get("error"))
2094            .and_then(Value::as_str)
2095            .unwrap_or("unknown error")
2096            .to_string();
2097        let code = r
2098            .body
2099            .as_ref()
2100            .and_then(|b| b.get("code"))
2101            .and_then(Value::as_str)
2102            .map(str::to_string);
2103        let details = r.body.as_ref().and_then(|b| b.get("details")).cloned();
2104        return Err(LinkError::Http {
2105            what,
2106            status: r.status,
2107            message,
2108            code,
2109            details,
2110        });
2111    }
2112    r.body.ok_or(LinkError::NotJson {
2113        what,
2114        status: r.status,
2115    })
2116}
2117
2118// ─────────────────────────────────────────────────────────────────────────────
2119// resolve — handle → brain card; @brain/id → the record
2120// ─────────────────────────────────────────────────────────────────────────────
2121
2122/// Resolve an address. A bare `@brain` returns the brain card (metadata +
2123/// index stats — the v0 form of the card; keys arrive with the protocol's
2124/// signing layer). `@brain/<id>` and `@brain/<path>.md` return the full
2125/// record, frontmatter + body.
2126fn is_public_registry_ip(ip: std::net::IpAddr) -> bool {
2127    match ip {
2128        std::net::IpAddr::V4(ip) => {
2129            let [a, b, c, _] = ip.octets();
2130            !(a == 0
2131                || a == 10
2132                || a == 127
2133                || (a == 100 && (64..=127).contains(&b))
2134                || (a == 169 && b == 254)
2135                || (a == 172 && (16..=31).contains(&b))
2136                || (a == 192 && b == 0 && c == 0)
2137                || (a == 192 && b == 0 && c == 2)
2138                || (a == 192 && b == 88 && c == 99)
2139                || (a == 192 && b == 168)
2140                || (a == 198 && (b == 18 || b == 19))
2141                || (a == 198 && b == 51 && c == 100)
2142                || (a == 203 && b == 0 && c == 113)
2143                || a >= 224)
2144        }
2145        std::net::IpAddr::V6(ip) => {
2146            let segments = ip.segments();
2147            // Conservatively accept only global unicast 2000::/3, excluding
2148            // special-purpose/transition blocks. In particular, 6to4 embeds
2149            // an IPv4 destination and must not tunnel a validated public
2150            // connect to 127/8 or RFC1918.
2151            (segments[0] & 0xe000) == 0x2000
2152                && !(segments[0] == 0x2001 && (segments[1] & 0xfe00) == 0)
2153                && !(segments[0] == 0x2001 && segments[1] == 0x0db8)
2154                && segments[0] != 0x2002
2155                && !(segments[0] == 0x3fff && (segments[1] & 0xf000) == 0)
2156        }
2157    }
2158}
2159
2160#[derive(Clone)]
2161struct PinnedRegistryResolver {
2162    netloc: String,
2163    addresses: Vec<std::net::SocketAddr>,
2164}
2165
2166impl ureq::Resolver for PinnedRegistryResolver {
2167    fn resolve(&self, requested: &str) -> std::io::Result<Vec<std::net::SocketAddr>> {
2168        if requested == self.netloc {
2169            Ok(self.addresses.clone())
2170        } else {
2171            Err(std::io::Error::new(
2172                std::io::ErrorKind::PermissionDenied,
2173                "registry request attempted to resolve an unvalidated authority",
2174            ))
2175        }
2176    }
2177}
2178
2179fn pinned_public_agent(
2180    url: &url::Url,
2181    allow_private: bool,
2182    label: &str,
2183) -> LinkResult<ureq::Agent> {
2184    pinned_public_agent_pooled(url, allow_private, label, AgentShape::default())
2185}
2186
2187/// How one agent is built: `idle_per_host` sizes the connection pool (the
2188/// default of one is right for a lone request but throttles a concurrent batch
2189/// back to a single warm connection), and `overall` is the wall-clock budget
2190/// for one attempt.
2191struct AgentShape {
2192    idle_per_host: usize,
2193    overall: std::time::Duration,
2194}
2195
2196impl Default for AgentShape {
2197    fn default() -> Self {
2198        Self {
2199            idle_per_host: 1,
2200            overall: std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS),
2201        }
2202    }
2203}
2204
2205fn pinned_public_agent_pooled(
2206    url: &url::Url,
2207    allow_private: bool,
2208    label: &str,
2209    shape: AgentShape,
2210) -> LinkResult<ureq::Agent> {
2211    let host = url
2212        .host_str()
2213        .ok_or_else(|| invalid_feed(format!("{label} has no host")))?;
2214    let port = url
2215        .port_or_known_default()
2216        .ok_or_else(|| invalid_feed(format!("{label} has no port")))?;
2217    let addresses = resolve_addresses_with_deadline(
2218        host,
2219        port,
2220        std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS),
2221    )
2222    .map_err(|error| invalid_feed(format!("{label} DNS resolution failed: {error}")))?;
2223    if addresses.is_empty() {
2224        return Err(invalid_feed(format!("{label} DNS returned no addresses")));
2225    }
2226    if !allow_private
2227        && addresses
2228            .iter()
2229            .any(|address| !is_public_registry_ip(address.ip()))
2230    {
2231        return Err(invalid_feed(format!(
2232            "{label} resolves to a non-public address"
2233        )));
2234    }
2235    let netloc = if host.contains(':') {
2236        format!("[{host}]:{port}")
2237    } else {
2238        format!("{host}:{port}")
2239    };
2240    Ok(agent_builder_with_timeout(shape.overall)
2241        .max_idle_connections_per_host(shape.idle_per_host.max(1))
2242        .resolver(PinnedRegistryResolver { netloc, addresses })
2243        .build())
2244}
2245
2246/// Resolve one authority without allowing libc DNS to hold the caller forever.
2247/// `ToSocketAddrs` itself has no timeout API, so resolution runs in a detached
2248/// worker and only its result channel is awaited. A late resolver result has no
2249/// side effects and is discarded after the deadline.
2250fn resolve_addresses_with_deadline(
2251    host: &str,
2252    port: u16,
2253    timeout: std::time::Duration,
2254) -> std::io::Result<Vec<std::net::SocketAddr>> {
2255    use std::net::ToSocketAddrs as _;
2256
2257    let host = host.to_string();
2258    let (send, receive) = std::sync::mpsc::sync_channel(1);
2259    std::thread::Builder::new()
2260        .name("dbmd-dns".to_string())
2261        .spawn(move || {
2262            let result = (host.as_str(), port)
2263                .to_socket_addrs()
2264                .map(|addresses| addresses.collect());
2265            let _ = send.send(result);
2266        })
2267        .map_err(|error| std::io::Error::other(format!("cannot start resolver: {error}")))?;
2268    match receive.recv_timeout(timeout) {
2269        Ok(result) => result,
2270        Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(std::io::Error::new(
2271            std::io::ErrorKind::TimedOut,
2272            "resolution exceeded its deadline",
2273        )),
2274        Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(std::io::Error::other(
2275            "resolver stopped without returning a result",
2276        )),
2277    }
2278}
2279
2280fn registry_agent(url: &url::Url) -> LinkResult<ureq::Agent> {
2281    let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
2282    pinned_public_agent(url, allow_private, "registry home")
2283}
2284
2285/// GET an absolute URL as JSON with NO credential — used to fetch a brain card
2286/// from a FOREIGN home during registry resolution. DNS is resolved once,
2287/// every answer is required to be public, and that exact answer set is pinned
2288/// into a no-redirect HTTP agent to close private-network SSRF and rebinding.
2289fn get_json_absolute(url: &str) -> LinkResult<Value> {
2290    let parsed = url::Url::parse(url).map_err(|_| invalid_feed("invalid registry home URL"))?;
2291    let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
2292    if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
2293        || !parsed.username().is_empty()
2294        || parsed.password().is_some()
2295        || parsed.query().is_some()
2296        || parsed.fragment().is_some()
2297    {
2298        return Err(invalid_feed("unsafe registry home URL"));
2299    }
2300    let http = registry_agent(&parsed)?;
2301    let resp = match with_connect_retries(|| http.get(url).call().map_err(Box::new)) {
2302        Ok(resp) => resp,
2303        Err(error) => match *error {
2304            ureq::Error::Status(status, resp) => {
2305                let _ = resp;
2306                return Err(LinkError::Http {
2307                    what: "registry home fetch",
2308                    status,
2309                    message: "the home node rejected the card request".to_string(),
2310                    code: None,
2311                    details: None,
2312                });
2313            }
2314            ureq::Error::Transport(err) => {
2315                return Err(LinkError::Transport {
2316                    hub: url.to_string(),
2317                    message: err.to_string(),
2318                });
2319            }
2320        },
2321    };
2322    if !(200..300).contains(&resp.status()) {
2323        return Err(LinkError::Http {
2324            what: "registry home fetch",
2325            status: resp.status(),
2326            message: "the home node returned a redirect or error".to_string(),
2327            code: None,
2328            details: None,
2329        });
2330    }
2331    let buf = read_response_body(resp, MAX_REGISTRY_CARD_BYTES + 1, url)?;
2332    if buf.len() as u64 > MAX_REGISTRY_CARD_BYTES {
2333        return Err(LinkError::ResponseTooLarge {
2334            limit_bytes: MAX_REGISTRY_CARD_BYTES,
2335        });
2336    }
2337    serde_json::from_slice(&buf).map_err(|_| LinkError::InvalidFeed {
2338        message: "the home node returned invalid JSON".to_string(),
2339    })
2340}
2341
2342/// Resolve a bare `@handle` through the federation registry (link.md §7.1,
2343/// E5): look the handle up in the hub's registry, fetch the brain card from
2344/// the returned HOME node, and PIN — the card's identity fingerprint must
2345/// equal the registry's, or resolution fails. Returns the card enriched with
2346/// the resolved `home`, or `Ok(None)` when the registry has no such handle
2347/// (so the caller can fall back to a direct lookup).
2348pub fn resolve_registry(cfg: &HubConfig, handle: &str) -> LinkResult<Option<Value>> {
2349    require_safe_ref(handle)?;
2350    // Hold the validated trust-directory capability before any network I/O.
2351    // Every lock/load/save below remains relative to this exact inode even if
2352    // an attacker swaps an ancestor while the registry or home is answering.
2353    let trust_directory = open_trust_dir(cfg)?;
2354    let reg = request_capped(
2355        cfg,
2356        "GET",
2357        &format!("/api/hub/registry/{handle}"),
2358        None,
2359        Auth::None,
2360        MAX_REGISTRY_CARD_BYTES,
2361    )?;
2362    if reg.status == 404 {
2363        return Ok(None);
2364    }
2365    let body = ensure_ok(reg, "registry resolve")?;
2366    let home = body
2367        .get("home")
2368        .and_then(Value::as_str)
2369        .ok_or_else(|| invalid_feed("registry entry has no home"))?;
2370    let brain = body
2371        .get("brain")
2372        .and_then(Value::as_str)
2373        .ok_or_else(|| invalid_feed("registry entry has no brain"))?;
2374    if !crate::ulid::is_ulid(brain) {
2375        return Err(invalid_feed(
2376            "registry entry brain is not a canonical lowercase ULID",
2377        ));
2378    }
2379    let want_fp = body
2380        .get("identity")
2381        .and_then(|i| i.get("fingerprint"))
2382        .and_then(Value::as_str)
2383        .ok_or_else(|| invalid_feed("registry entry has no identity fingerprint"))?;
2384
2385    let home = home.trim_end_matches('/');
2386    let origin = normalized_origin(home)?;
2387    if origin != home {
2388        return Err(invalid_feed(
2389            "registry home must be an origin without a path, query, or fragment",
2390        ));
2391    }
2392    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[handle, brain])?;
2393    let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, handle, brain)?;
2394    if let Some(binding) = &alias_binding {
2395        if binding
2396            .home
2397            .as_deref()
2398            .is_some_and(|pinned_home| pinned_home != home)
2399        {
2400            return Err(invalid_feed(
2401                "registry relocated a pinned handle to a different home",
2402            ));
2403        }
2404    }
2405    let card = get_json_absolute(&format!("{home}/api/hub/brains/{brain}"))?;
2406    if card.get("id").and_then(Value::as_str) != Some(brain) {
2407        return Err(invalid_feed(
2408            "the home node served a card for a different brain",
2409        ));
2410    }
2411    let identity: FeedIdentity = serde_json::from_value(
2412        card.get("identity")
2413            .cloned()
2414            .ok_or_else(|| invalid_feed("the home node served no identity"))?,
2415    )
2416    .map_err(|_| invalid_feed("the home node served an invalid identity"))?;
2417    let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
2418    let got_fp = card
2419        .get("identity")
2420        .and_then(|i| i.get("fingerprint"))
2421        .and_then(Value::as_str)
2422        .unwrap_or_default();
2423    if got_fp != want_fp {
2424        return Err(invalid_feed(
2425            "the home node served an identity that does not match the registry — refusing",
2426        ));
2427    }
2428    let current = format!("ed25519:{}", identity.fingerprint);
2429    let advertised_seq = card
2430        .get("headSeq")
2431        .and_then(Value::as_u64)
2432        .ok_or_else(|| invalid_feed("the home node served an invalid head sequence"))?;
2433    let advertised_hash = card.get("feedHash").and_then(Value::as_str);
2434    if (advertised_seq == 0 && !card.get("feedHash").is_some_and(Value::is_null))
2435        || (advertised_seq > 0 && advertised_hash.is_none_or(|hash| !is_sha256(hash)))
2436    {
2437        return Err(invalid_feed(
2438            "the home node served an invalid feed head boundary",
2439        ));
2440    }
2441    // This is required even on first contact. Otherwise a syntactically valid
2442    // identity may claim that a rotation happened after a head the home has
2443    // never reached, then become the permanent TOFU anchor.
2444    verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], advertised_seq)?;
2445    let registry_alias = AliasBinding {
2446        v: 1,
2447        origin: normalized_origin(&cfg.hub)?,
2448        requested: handle.to_string(),
2449        brain: brain.to_string(),
2450        home: Some(home.to_string()),
2451    };
2452    save_canonical_pin_and_alias(
2453        cfg,
2454        &trust_directory,
2455        handle,
2456        brain,
2457        TrustState {
2458            v: 2,
2459            origin: normalized_origin(&cfg.hub)?,
2460            requested: brain.to_string(),
2461            brain: brain.to_string(),
2462            home: None,
2463            anchor,
2464            current,
2465            head_seq: pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq),
2466            feed_hash: pinned
2467                .as_ref()
2468                .and_then(|checkpoint| checkpoint.feed_hash.clone()),
2469            rotations: identity.rotations.clone(),
2470            hub_signer: None,
2471            protocol_profile: None,
2472        },
2473        Some(&registry_alias),
2474    )?;
2475    let mut out = card;
2476    if let Value::Object(map) = &mut out {
2477        map.insert("home".to_string(), Value::String(home.to_string()));
2478        map.insert(
2479            "resolvedVia".to_string(),
2480            Value::String("registry".to_string()),
2481        );
2482    }
2483    Ok(Some(out))
2484}
2485
2486pub fn resolve(cfg: &HubConfig, addr: &Address) -> LinkResult<Value> {
2487    // `Address::parse` refuses these shapes already, but `Address` has public
2488    // fields — re-assert at the wire so a hand-built address can never
2489    // reshape the request path.
2490    require_safe_ref(&addr.brain)?;
2491    if let Some(target) = &addr.target {
2492        let (given, ok) = match target {
2493            AddressTarget::Id(id) => (id, crate::ulid::is_ulid(id)),
2494            AddressTarget::Path(p) => (p, safe_store_rel_path(p) && p.ends_with(".md")),
2495        };
2496        if !ok {
2497            return Err(LinkError::BadAddress {
2498                given: given.clone(),
2499                reason: BAD_TARGET_REASON.to_string(),
2500            });
2501        }
2502    }
2503
2504    // A record is never accepted from the hub's mutable query/index response.
2505    // Under v2, an id lookup may suggest a path, but the client accepts only
2506    // bytes whose exact path proof reaches the verified content root and whose
2507    // parsed id still matches. Under v1, the signed snapshot pack remains the
2508    // binding. Both profiles therefore reject a forged query response.
2509    if let Some(target) = &addr.target {
2510        if let Some(head) = v2_verified_head(cfg, &addr.brain)? {
2511            let pointer = head.pointer.as_ref().ok_or_else(|| LinkError::Http {
2512                what: "resolve",
2513                status: 404,
2514                message: "record not found".to_string(),
2515                code: Some("NOT_FOUND".to_string()),
2516                details: None,
2517            })?;
2518            let (path, file) = match target {
2519                AddressTarget::Path(path) => {
2520                    let file =
2521                        v2_manifest_file(cfg, &head.brain_id, pointer, path)?.ok_or_else(|| {
2522                            LinkError::Http {
2523                                what: "resolve",
2524                                status: 404,
2525                                message: "record not found".to_string(),
2526                                code: Some("NOT_FOUND".to_string()),
2527                                details: None,
2528                            }
2529                        })?;
2530                    (path.clone(), file)
2531                }
2532                AddressTarget::Id(id) => v2_manifest_file_by_id(cfg, &head.brain_id, pointer, id)?,
2533            };
2534            let mut downloaded =
2535                download_v2_blobs(cfg, &head.brain_id, pointer, vec![(&path, &file)])?;
2536            let (_, bytes) = downloaded
2537                .pop()
2538                .ok_or_else(|| invalid_feed("v2 record download returned no bytes"))?;
2539            let resolved = resolve_from_verified_record_bytes(&head.brain_id, target, path, bytes)?;
2540            accept_v2_head(cfg, &head)?;
2541            return Ok(resolved);
2542        }
2543        let remote = verified_remote_head(cfg, &addr.brain, false)?;
2544        if !remote.head.verified {
2545            return Err(invalid_feed(
2546                "a path-scoped feed cannot prove a record against the full signed snapshot",
2547            ));
2548        }
2549        if remote.head.seq == 0 {
2550            return Err(LinkError::Http {
2551                what: "resolve",
2552                status: 404,
2553                message: "record not found".to_string(),
2554                code: Some("NOT_FOUND".to_string()),
2555                details: None,
2556            });
2557        }
2558        let brain = remote.head.brain.clone();
2559        let pack = download_verified_snapshot_pack(cfg, &brain, &remote)?;
2560        return resolve_from_verified_pack(&brain, target, pack);
2561    }
2562
2563    let path = format!("/api/hub/brains/{}", addr.brain);
2564    // Direct first: the caller's own slug and hub-hosted public handles resolve
2565    // here unchanged. Only a bare `@handle` the hub can't resolve directly
2566    // (404) falls through to the federation registry — how a handle reaches a
2567    // brain on ANOTHER node (link.md §7.1, E5).
2568    let direct = request(cfg, "GET", &path, None, Auth::Required)?;
2569    if direct.status == 404 && addr.target.is_none() && !crate::ulid::is_ulid(&addr.brain) {
2570        if let Some(card) = resolve_registry(cfg, &addr.brain)? {
2571            return Ok(card);
2572        }
2573    }
2574    let mut resolved = ensure_ok(direct, "resolve")?;
2575    if resolved.get("storageProfile").and_then(Value::as_str) == Some("v2") {
2576        let v2 = v2_verified_head(cfg, &addr.brain)?
2577            .ok_or_else(|| invalid_feed("v2 resolve card has no verified v2 head"))?;
2578        if resolved.get("id").and_then(Value::as_str) != Some(v2.brain_id.as_str()) {
2579            return Err(invalid_feed(
2580                "resolve card is not bound to the verified v2 brain",
2581            ));
2582        }
2583        let card_identity: FeedIdentity = serde_json::from_value(
2584            resolved
2585                .get("identity")
2586                .cloned()
2587                .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2588        )
2589        .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2590        if card_identity != v2_identity(&v2.identity) {
2591            return Err(invalid_feed(
2592                "resolve card identity differs from the verified v2 identity",
2593            ));
2594        }
2595        accept_v2_head(cfg, &v2)?;
2596        if let Value::Object(card) = &mut resolved {
2597            card.insert(
2598                "headSeq".to_string(),
2599                json!(v2.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
2600            );
2601            card.insert(
2602                "feedHash".to_string(),
2603                v2.pointer
2604                    .as_ref()
2605                    .map(|pointer| Value::String(pointer.feed_hash.clone()))
2606                    .unwrap_or(Value::Null),
2607            );
2608            card.insert(
2609                "storageProfile".to_string(),
2610                Value::String("v2".to_string()),
2611            );
2612            if let Some(pointer) = &v2.pointer {
2613                card.insert(
2614                    "updatedAt".to_string(),
2615                    Value::String(pointer.signed_at.clone()),
2616                );
2617            }
2618        }
2619        return Ok(resolved);
2620    }
2621    // A successful direct response is accepted only after the same centralized
2622    // identity/rotation/feed checkpoint verification used by sync and
2623    // subscribe. No verb gets a weaker ad-hoc pinning path.
2624    let remote = verified_remote_head(cfg, &addr.brain, false)?;
2625    if resolved.get("id").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2626        || resolved.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2627        || resolved.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
2628    {
2629        return Err(invalid_feed(
2630            "resolve card is not bound to the exact verified feed checkpoint",
2631        ));
2632    }
2633    let card_identity: FeedIdentity = serde_json::from_value(
2634        resolved
2635            .get("identity")
2636            .cloned()
2637            .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2638    )
2639    .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2640    if remote.identity.as_ref() != Some(&card_identity) {
2641        return Err(invalid_feed(
2642            "resolve card identity differs from the verified feed identity",
2643        ));
2644    }
2645    Ok(resolved)
2646}
2647
2648/// Resolve one record strictly from the exact signed snapshot pack. The hub's
2649/// mutable query/index result is intentionally not consulted: a signed pack
2650/// digest is the only cryptographic binding between a feed checkpoint and
2651/// record bytes in wire profile v1.
2652fn resolve_from_verified_pack(
2653    brain: &str,
2654    target: &AddressTarget,
2655    pack: Vec<u8>,
2656) -> LinkResult<Value> {
2657    let entries = parse_store_pack(pack)?;
2658    let mut matched: Option<(String, Vec<u8>)> = None;
2659
2660    for (path, bytes) in entries {
2661        let is_candidate = match target {
2662            AddressTarget::Path(want) => &path == want,
2663            AddressTarget::Id(_) => {
2664                path.ends_with(".md")
2665                    && (path.starts_with("records/") || path.starts_with("sources/"))
2666            }
2667        };
2668        if !is_candidate {
2669            continue;
2670        }
2671        let text = std::str::from_utf8(&bytes)
2672            .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2673        let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2674            .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2675        if let AddressTarget::Id(want) = target {
2676            let frontmatter =
2677                crate::parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(&path))
2678                    .map_err(|_| {
2679                        invalid_feed(format!("signed snapshot record `{path}` is malformed"))
2680                    })?;
2681            if frontmatter.id.as_deref() != Some(want) {
2682                continue;
2683            }
2684        }
2685        if matched.is_some() {
2686            return Err(invalid_feed(
2687                "signed snapshot contains more than one record for the requested target",
2688            ));
2689        }
2690        matched = Some((path, bytes));
2691    }
2692
2693    let (path, bytes) = matched.ok_or_else(|| LinkError::Http {
2694        what: "resolve",
2695        status: 404,
2696        message: "record not found".to_string(),
2697        code: Some("NOT_FOUND".to_string()),
2698        details: None,
2699    })?;
2700    resolve_from_verified_record_bytes(brain, target, path, bytes)
2701}
2702
2703fn resolve_from_verified_record_bytes(
2704    brain: &str,
2705    target: &AddressTarget,
2706    path: String,
2707    bytes: Vec<u8>,
2708) -> LinkResult<Value> {
2709    match target {
2710        AddressTarget::Path(expected) if expected != &path => {
2711            return Err(invalid_feed(
2712                "verified record path differs from the requested path",
2713            ));
2714        }
2715        AddressTarget::Id(_) if !(path.starts_with("records/") || path.starts_with("sources/")) => {
2716            return Err(invalid_feed(
2717                "verified id resolved outside records or sources",
2718            ));
2719        }
2720        _ => {}
2721    }
2722    let text = std::str::from_utf8(&bytes)
2723        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2724    let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2725        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2726    let frontmatter: Value = serde_norway::from_str(&parsed.frontmatter_yaml)
2727        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2728    let Value::Object(fields) = frontmatter else {
2729        return Err(invalid_feed(format!(
2730            "signed snapshot record `{path}` frontmatter is not a mapping"
2731        )));
2732    };
2733    if let AddressTarget::Id(expected) = target {
2734        if fields.get("id").and_then(Value::as_str) != Some(expected.as_str()) {
2735            return Err(invalid_feed(
2736                "verified record id differs from the requested id",
2737            ));
2738        }
2739    }
2740    let mut document = serde_json::Map::new();
2741    document.insert("path".to_string(), Value::String(path));
2742    for (key, value) in fields {
2743        document.insert(key, value);
2744    }
2745    document.insert("body".to_string(), Value::String(parsed.body));
2746    document.insert(
2747        "contentSha".to_string(),
2748        Value::String(content_sha256(&bytes)),
2749    );
2750    Ok(json!({
2751        "brain": brain,
2752        "document": Value::Object(document),
2753    }))
2754}
2755
2756// ─────────────────────────────────────────────────────────────────────────────
2757// sync — pull the granted slice as files; push the local store as a snapshot
2758// ─────────────────────────────────────────────────────────────────────────────
2759
2760/// What a pull materialized.
2761#[derive(Debug, Clone, serde::Serialize)]
2762pub struct PullReport {
2763    /// The brain id the hub reported.
2764    pub brain: String,
2765    /// The brain's slug.
2766    pub slug: String,
2767    /// The hub's feed head at export time.
2768    #[serde(rename = "headSeq")]
2769    pub head_seq: u64,
2770    /// How many files were written.
2771    pub files: usize,
2772    /// Where they were written (as given or derived from the slug).
2773    pub dest: String,
2774    /// Local content files that the export did not carry — present so a
2775    /// caller sees divergence; nothing is ever deleted locally.
2776    #[serde(rename = "extraLocal")]
2777    pub extra_local: Vec<String>,
2778    /// Exact convergence result from the post-install barrier.
2779    #[serde(rename = "syncStatus")]
2780    pub sync_status: String,
2781}
2782
2783struct V2PulledSnapshot {
2784    report: PullReport,
2785    head: V2VerifiedHead,
2786    files: std::collections::BTreeMap<String, V2BaselineFile>,
2787    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
2788    local: V2LocalView,
2789    local_assets: std::collections::BTreeMap<String, crate::AssetRecord>,
2790}
2791
2792fn download_verified_snapshot_pack(
2793    cfg: &HubConfig,
2794    brain: &str,
2795    remote: &VerifiedRemote,
2796) -> LinkResult<Vec<u8>> {
2797    let feed_hash = remote
2798        .head
2799        .feed_hash
2800        .as_deref()
2801        .ok_or_else(|| invalid_feed("non-empty snapshot has no verified feed hash"))?;
2802    let signed_head = remote
2803        .head_entry
2804        .as_ref()
2805        .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
2806    let expected = &signed_head.entry.pack_sha256;
2807    if !is_sha256(expected) {
2808        return Err(invalid_feed(
2809            "signed head carries an invalid snapshot pack digest",
2810        ));
2811    }
2812    let path = format!(
2813        "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={feed_hash}",
2814        remote.head.seq
2815    );
2816    let body = ensure_ok(
2817        request(cfg, "GET", &path, None, Auth::Required)?,
2818        "sync pull",
2819    )?;
2820    if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2821        || body.get("feedHash").and_then(Value::as_str) != Some(feed_hash)
2822        || body.get("brain").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2823        || body.get("sha256").and_then(Value::as_str) != Some(expected.as_str())
2824    {
2825        return Err(invalid_feed(
2826            "export response is not bound to the exact verified snapshot",
2827        ));
2828    }
2829    let url = body
2830        .get("url")
2831        .and_then(Value::as_str)
2832        .ok_or_else(|| invalid_feed("verified snapshot export carried no exact pack URL"))?;
2833    let bytes = get_presigned(cfg, url)?;
2834    if content_sha256(&bytes) != *expected {
2835        return Err(LinkError::InvalidPack {
2836            message: "downloaded pack does not match the signed snapshot digest".to_string(),
2837        });
2838    }
2839    let entries = parse_store_pack(bytes.clone())?;
2840    if signed_head.entry.kind == "push" {
2841        verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
2842    }
2843    Ok(bytes)
2844}
2845
2846#[derive(Debug, Clone, Deserialize, Serialize)]
2847struct V2PointerBody {
2848    v: u8,
2849    brain: String,
2850    seq: u64,
2851    commit_hash: String,
2852    feed_hash: String,
2853    content_root: Option<String>,
2854    asset_root: Option<String>,
2855    materializer: String,
2856    signer_epoch: u64,
2857    control_revision: String,
2858    backup_preparation: String,
2859    prior_pointer_hash: Option<String>,
2860    signed_at: String,
2861}
2862
2863#[derive(Debug, Clone, Deserialize)]
2864struct V2SignedPointer {
2865    pointer: V2PointerBody,
2866    hub_public_key: String,
2867    hub_fingerprint: String,
2868    sig: String,
2869}
2870
2871#[derive(Debug, Clone, Deserialize)]
2872struct V2HeadIdentity {
2873    #[serde(default)]
2874    custody: String,
2875    fingerprint: String,
2876    public_key_spki: String,
2877    #[serde(default)]
2878    previous: Vec<V2PreviousIdentity>,
2879    #[serde(default)]
2880    rotations: Vec<String>,
2881}
2882
2883#[derive(Debug, Clone, Deserialize)]
2884struct V2PreviousIdentity {
2885    fingerprint: String,
2886    public_key_spki: String,
2887}
2888
2889#[derive(Debug, Deserialize)]
2890struct V2HeadResponse {
2891    v: u8,
2892    brain_id: String,
2893    profile: String,
2894    view: Option<V2HeadView>,
2895    pointer: Option<V2SignedPointer>,
2896    identity: Option<V2HeadIdentity>,
2897}
2898
2899#[derive(Debug, Clone, Deserialize)]
2900struct V2HeadView {
2901    kind: String,
2902    #[serde(default)]
2903    id: Option<String>,
2904    control_revision: String,
2905}
2906
2907#[derive(Debug, Clone)]
2908struct V2VerifiedHead {
2909    requested: String,
2910    brain_id: String,
2911    view_kind: String,
2912    /// Stable identity of the exact current-content path projection.
2913    view_revision: String,
2914    /// Use-time authority revision independently bound into writes/signatures.
2915    control_revision: String,
2916    identity: V2HeadIdentity,
2917    pointer: Option<V2PointerBody>,
2918    trust: TrustState,
2919    alias: Option<AliasBinding>,
2920}
2921
2922fn verify_v2_spki_signature(
2923    public_key: &str,
2924    message: &[u8],
2925    signature: &str,
2926) -> LinkResult<Vec<u8>> {
2927    let der = URL_SAFE_NO_PAD
2928        .decode(public_key)
2929        .map_err(|_| invalid_feed("v2 signer public key is not base64url"))?;
2930    if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
2931        return Err(invalid_feed("v2 signer public key is not Ed25519 SPKI"));
2932    }
2933    let sig = URL_SAFE_NO_PAD
2934        .decode(signature)
2935        .map_err(|_| invalid_feed("v2 signature is not base64url"))?;
2936    UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
2937        .verify(message, &sig)
2938        .map_err(|_| invalid_feed("v2 Ed25519 signature failed"))?;
2939    Ok(der)
2940}
2941
2942fn verify_v2_pointer(pointer: &V2SignedPointer, expected_brain: &str) -> LinkResult<String> {
2943    if pointer.pointer.v != 2
2944        || pointer.pointer.brain != expected_brain
2945        || pointer.pointer.seq == 0
2946        || !is_sha256(&pointer.pointer.commit_hash)
2947        || !is_sha256(&pointer.pointer.feed_hash)
2948        || pointer
2949            .pointer
2950            .content_root
2951            .as_deref()
2952            .is_some_and(|hash| !is_sha256(hash))
2953        || !is_sha256(&pointer.pointer.backup_preparation)
2954    {
2955        return Err(invalid_feed("v2 pointer fields are invalid"));
2956    }
2957    let value = serde_json::to_value(&pointer.pointer)
2958        .map_err(|_| invalid_feed("v2 pointer could not be canonicalized"))?;
2959    let message = crate::linkmd_v2::canonical_bytes(&value)
2960        .map_err(|error| invalid_feed(error.to_string()))?;
2961    let der = verify_v2_spki_signature(&pointer.hub_public_key, &message, &pointer.sig)?;
2962    let fingerprint = format!("{:x}", Sha256::digest(&der));
2963    if fingerprint != pointer.hub_fingerprint {
2964        return Err(invalid_feed("v2 hub signer fingerprint mismatch"));
2965    }
2966    Ok(format!(
2967        "{}:{}",
2968        pointer.hub_fingerprint, pointer.hub_public_key
2969    ))
2970}
2971
2972fn v2_identity(identity: &V2HeadIdentity) -> FeedIdentity {
2973    FeedIdentity {
2974        fingerprint: identity.fingerprint.clone(),
2975        public_key_spki: identity.public_key_spki.clone(),
2976        previous: identity
2977            .previous
2978            .iter()
2979            .map(|previous| PreviousIdentity {
2980                fingerprint: previous.fingerprint.clone(),
2981                public_key_spki: previous.public_key_spki.clone(),
2982            })
2983            .collect(),
2984        rotations: identity.rotations.clone(),
2985    }
2986}
2987
2988fn verified_v2_commit_object(
2989    raw: &[u8],
2990    identity: &V2HeadIdentity,
2991) -> LinkResult<serde_json::Map<String, Value>> {
2992    let mut value: Value =
2993        serde_json::from_slice(raw).map_err(|_| invalid_feed("v2 commit is not JSON"))?;
2994    let canonical = crate::linkmd_v2::canonical_bytes(&value)
2995        .map_err(|error| invalid_feed(error.to_string()))?;
2996    if canonical != raw {
2997        return Err(invalid_feed("v2 commit is not canonical JSON"));
2998    }
2999    let object = value
3000        .as_object_mut()
3001        .ok_or_else(|| invalid_feed("v2 commit is not an object"))?;
3002    let sig = object
3003        .remove("sig")
3004        .and_then(|value| value.as_str().map(str::to_string))
3005        .ok_or_else(|| invalid_feed("v2 commit has no signature"))?;
3006    const FIELDS: [&str; 18] = [
3007        "actor_ref",
3008        "asset_root",
3009        "brain",
3010        "changes_sha256",
3011        "control_revision",
3012        "materializer",
3013        "op",
3014        "parent_asset_root",
3015        "parent_commit",
3016        "parent_root",
3017        "prev_entry_hash",
3018        "public_key",
3019        "seq",
3020        "signer_epoch",
3021        "state_root",
3022        "ts",
3023        "v",
3024        "v1_bridge",
3025    ];
3026    if object.len() != FIELDS.len() || FIELDS.iter().any(|field| !object.contains_key(*field)) {
3027        return Err(invalid_feed("v2 commit has a non-normative field set"));
3028    }
3029    let seq = object
3030        .get("seq")
3031        .and_then(Value::as_u64)
3032        .filter(|seq| *seq > 0)
3033        .ok_or_else(|| invalid_feed("v2 commit has an invalid sequence"))?;
3034    let signer_epoch = object
3035        .get("signer_epoch")
3036        .and_then(Value::as_u64)
3037        .filter(|epoch| *epoch > 0)
3038        .ok_or_else(|| invalid_feed("v2 commit has an invalid signer epoch"))?;
3039    let hash_or_null = |field: &str| {
3040        object
3041            .get(field)
3042            .is_some_and(|value| value.is_null() || value.as_str().is_some_and(is_sha256))
3043    };
3044    if object.get("v").and_then(Value::as_u64) != Some(2)
3045        || object.get("op").and_then(Value::as_str) != Some("changeset")
3046        || !object
3047            .get("changes_sha256")
3048            .and_then(Value::as_str)
3049            .is_some_and(is_sha256)
3050        || !object
3051            .get("actor_ref")
3052            .and_then(Value::as_str)
3053            .is_some_and(is_sha256)
3054        || !object
3055            .get("control_revision")
3056            .and_then(Value::as_str)
3057            .is_some_and(is_sha256)
3058        || !object
3059            .get("state_root")
3060            .and_then(Value::as_str)
3061            .is_some_and(is_sha256)
3062        || !hash_or_null("parent_commit")
3063        || !hash_or_null("parent_root")
3064        || !hash_or_null("parent_asset_root")
3065        || !hash_or_null("asset_root")
3066        || !hash_or_null("prev_entry_hash")
3067        || !object
3068            .get("materializer")
3069            .and_then(Value::as_str)
3070            .is_some_and(|value| !value.is_empty() && value.len() <= 128)
3071        || !object
3072            .get("ts")
3073            .and_then(Value::as_str)
3074            .is_some_and(|value| value.len() == 24 && value.ends_with('Z'))
3075    {
3076        return Err(invalid_feed("v2 commit fields are invalid"));
3077    }
3078    if (seq == 1
3079        && [
3080            "parent_commit",
3081            "parent_root",
3082            "parent_asset_root",
3083            "prev_entry_hash",
3084        ]
3085        .iter()
3086        .any(|field| !object.get(*field).is_some_and(Value::is_null)))
3087        || (seq > 1
3088            && ["parent_commit", "parent_root", "prev_entry_hash"]
3089                .iter()
3090                .any(|field| object.get(*field).and_then(Value::as_str).is_none()))
3091    {
3092        return Err(invalid_feed("v2 commit parent shape is invalid"));
3093    }
3094    match object.get("v1_bridge") {
3095        Some(Value::Null) => {}
3096        Some(Value::Object(bridge))
3097            if seq == 1
3098                && bridge.len() == 3
3099                && bridge
3100                    .get("head_seq")
3101                    .and_then(Value::as_u64)
3102                    .is_some_and(|v| v > 0)
3103                && bridge
3104                    .get("feed_hash")
3105                    .and_then(Value::as_str)
3106                    .is_some_and(is_sha256)
3107                && bridge
3108                    .get("pack_sha256")
3109                    .and_then(Value::as_str)
3110                    .is_some_and(is_sha256) => {}
3111        _ => return Err(invalid_feed("v2 commit has an invalid v1 bridge")),
3112    }
3113    let public_key = object
3114        .get("public_key")
3115        .and_then(Value::as_str)
3116        .ok_or_else(|| invalid_feed("v2 commit has no public key"))?;
3117    let der = URL_SAFE_NO_PAD
3118        .decode(public_key)
3119        .map_err(|_| invalid_feed("v2 brain public key is not base64url"))?;
3120    let expected_multikey = format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&der)));
3121    if object.get("brain").and_then(Value::as_str) != Some(expected_multikey.as_str()) {
3122        return Err(invalid_feed("v2 commit brain identity mismatch"));
3123    }
3124    // Verify the old-key-signed chain before trusting any prior public key.
3125    verify_identity_chain(&v2_identity(identity), None)?;
3126    // Old commits remain verifiable after brain-key rotation. `previous` is
3127    // newest-first, while signer epochs and rotation statements are oldest-first.
3128    let mut chain: Vec<(&str, &str)> = identity
3129        .previous
3130        .iter()
3131        .rev()
3132        .map(|previous| {
3133            (
3134                previous.fingerprint.as_str(),
3135                previous.public_key_spki.as_str(),
3136            )
3137        })
3138        .collect();
3139    chain.push((&identity.fingerprint, &identity.public_key_spki));
3140    let signer_index = chain.iter().position(|(fingerprint, spki)| {
3141        *fingerprint == expected_multikey.trim_start_matches("ed25519:") && *spki == public_key
3142    });
3143    let Some(signer_index) = signer_index else {
3144        return Err(invalid_feed("v2 commit uses an unrecognized brain key"));
3145    };
3146    if signer_epoch != signer_index as u64 + 1 {
3147        return Err(invalid_feed("v2 commit signer epoch differs from its key"));
3148    }
3149    let lower_boundary = if signer_index == 0 {
3150        None
3151    } else {
3152        let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
3153            .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
3154        Some(prior.prior_head_seq)
3155    };
3156    let upper_boundary = if signer_index == identity.rotations.len() {
3157        None
3158    } else {
3159        let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
3160            .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
3161        Some(next.prior_head_seq)
3162    };
3163    if lower_boundary.is_some_and(|boundary| seq <= boundary)
3164        || upper_boundary.is_some_and(|boundary| seq > boundary)
3165    {
3166        return Err(invalid_feed(
3167            "v2 commit signer is outside its authenticated rotation epoch",
3168        ));
3169    }
3170    let unsigned = crate::linkmd_v2::canonical_bytes(&Value::Object(object.clone()))
3171        .map_err(|error| invalid_feed(error.to_string()))?;
3172    verify_v2_spki_signature(public_key, &unsigned, &sig)?;
3173    Ok(object.clone())
3174}
3175
3176#[derive(Debug, Deserialize)]
3177struct V2FeedWireEntry {
3178    seq: u64,
3179    commit_hash: String,
3180    feed_hash: String,
3181    bytes_base64: String,
3182}
3183
3184#[derive(Debug, Deserialize)]
3185struct V2FeedPage {
3186    v: u8,
3187    head_seq: u64,
3188    head_commit_hash: String,
3189    head_feed_hash: String,
3190    entries: Vec<V2FeedWireEntry>,
3191    next_after: u64,
3192    complete: bool,
3193}
3194
3195fn replay_v2_feed(
3196    cfg: &HubConfig,
3197    brain: &str,
3198    pointer: &V2PointerBody,
3199    identity: &V2HeadIdentity,
3200    start_after: u64,
3201    start_feed: Option<String>,
3202) -> LinkResult<()> {
3203    let mut after = start_after;
3204    let mut prior_feed = start_feed;
3205    let mut final_object = None;
3206    let mut replayed_entries = 0_u64;
3207    let mut replayed_bytes = 0_u64;
3208    while after < pointer.seq {
3209        let path = format!("/api/hub/brains/{brain}/v2/feed?after={after}&limit=100");
3210        let value = ensure_ok(
3211            request_capped(
3212                cfg,
3213                "GET",
3214                &path,
3215                None,
3216                Auth::Required,
3217                MAX_FEED_REPLAY_BYTES,
3218            )?,
3219            "v2 feed replay",
3220        )?;
3221        let page: V2FeedPage = serde_json::from_value(value)
3222            .map_err(|_| invalid_feed("v2 feed page has an invalid shape"))?;
3223        if page.v != 2
3224            || page.head_seq != pointer.seq
3225            || page.head_commit_hash != pointer.commit_hash
3226            || page.head_feed_hash != pointer.feed_hash
3227            || page.entries.is_empty()
3228            || page.entries.len() > FEED_PAGE_LIMIT
3229        {
3230            return Err(invalid_feed("v2 feed page differs from the signed head"));
3231        }
3232        for entry in page.entries {
3233            if entry.seq != after + 1
3234                || !is_sha256(&entry.commit_hash)
3235                || !is_sha256(&entry.feed_hash)
3236            {
3237                return Err(invalid_feed("v2 feed sequence is not contiguous"));
3238            }
3239            let raw = base64::engine::general_purpose::STANDARD
3240                .decode(&entry.bytes_base64)
3241                .map_err(|_| invalid_feed("v2 feed bytes are not canonical base64"))?;
3242            replayed_entries = replayed_entries
3243                .checked_add(1)
3244                .ok_or_else(|| invalid_feed("v2 feed replay count overflow"))?;
3245            replayed_bytes = replayed_bytes
3246                .checked_add(raw.len() as u64)
3247                .ok_or_else(|| invalid_feed("v2 feed replay byte count overflow"))?;
3248            if replayed_entries > MAX_FEED_REPLAY_ENTRIES || replayed_bytes > MAX_FEED_REPLAY_BYTES
3249            {
3250                return Err(invalid_feed("v2 feed replay exceeds its safety bound"));
3251            }
3252            if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3253                .map_err(|error| invalid_feed(error.to_string()))?
3254                != entry.commit_hash
3255                || content_sha256(&raw) != entry.feed_hash
3256            {
3257                return Err(invalid_feed("v2 feed entry address mismatch"));
3258            }
3259            let object = verified_v2_commit_object(&raw, identity)?;
3260            if object.get("seq").and_then(Value::as_u64) != Some(entry.seq)
3261                || object.get("prev_entry_hash").and_then(Value::as_str) != prior_feed.as_deref()
3262            {
3263                return Err(invalid_feed(
3264                    "v2 feed entry does not extend its predecessor",
3265                ));
3266            }
3267            after = entry.seq;
3268            prior_feed = Some(entry.feed_hash);
3269            final_object = Some((entry.commit_hash, object));
3270        }
3271        if page.next_after != after || (page.complete != (after == pointer.seq)) {
3272            return Err(invalid_feed("v2 feed page cursor is inconsistent"));
3273        }
3274    }
3275    let (final_hash, object) =
3276        final_object.ok_or_else(|| invalid_feed("v2 feed replay made no progress"))?;
3277    if final_hash != pointer.commit_hash
3278        || prior_feed.as_deref() != Some(pointer.feed_hash.as_str())
3279        || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
3280        || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
3281        || object.get("control_revision").and_then(Value::as_str)
3282            != Some(pointer.control_revision.as_str())
3283        || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
3284    {
3285        return Err(invalid_feed(
3286            "v2 replay did not converge on the signed pointer",
3287        ));
3288    }
3289    Ok(())
3290}
3291
3292fn verify_v1_to_v2_bridge(
3293    cfg: &HubConfig,
3294    brain: &str,
3295    pointer: &V2PointerBody,
3296    identity: &V2HeadIdentity,
3297    checkpoint: &TrustState,
3298) -> LinkResult<()> {
3299    let value = ensure_ok(
3300        request_capped(
3301            cfg,
3302            "GET",
3303            &format!("/api/hub/brains/{brain}/v2/feed?after=0&limit=1"),
3304            None,
3305            Auth::Required,
3306            MAX_FEED_RESPONSE_BYTES,
3307        )?,
3308        "v2 genesis bridge",
3309    )?;
3310    let page: V2FeedPage = serde_json::from_value(value)
3311        .map_err(|_| invalid_feed("v2 genesis bridge page has an invalid shape"))?;
3312    if page.v != 2
3313        || page.head_seq != pointer.seq
3314        || page.head_commit_hash != pointer.commit_hash
3315        || page.head_feed_hash != pointer.feed_hash
3316        || page.entries.len() != 1
3317        || page.entries[0].seq != 1
3318        || !is_sha256(&page.entries[0].commit_hash)
3319        || !is_sha256(&page.entries[0].feed_hash)
3320    {
3321        return Err(invalid_feed(
3322            "v2 genesis bridge page differs from the signed head",
3323        ));
3324    }
3325    let first = &page.entries[0];
3326    let raw = STANDARD
3327        .decode(&first.bytes_base64)
3328        .map_err(|_| invalid_feed("v2 genesis bridge bytes are not canonical base64"))?;
3329    if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3330        .map_err(|error| invalid_feed(error.to_string()))?
3331        != first.commit_hash
3332        || content_sha256(&raw) != first.feed_hash
3333    {
3334        return Err(invalid_feed("v2 genesis bridge address mismatch"));
3335    }
3336    let object = verified_v2_commit_object(&raw, identity)?;
3337    if checkpoint.head_seq == 0 {
3338        if checkpoint.feed_hash.is_some() || object.get("v1_bridge") != Some(&Value::Null) {
3339            return Err(invalid_feed(
3340                "empty v1 checkpoint did not transition through an empty v2 genesis",
3341            ));
3342        }
3343        return Ok(());
3344    }
3345    let bridge = object
3346        .get("v1_bridge")
3347        .and_then(Value::as_object)
3348        .ok_or_else(|| invalid_feed("v2 genesis omitted the pinned v1 boundary"))?;
3349    let checkpoint_feed = checkpoint
3350        .feed_hash
3351        .as_deref()
3352        .ok_or_else(|| invalid_feed("non-empty v1 checkpoint has no feed hash"))?;
3353    if bridge.get("head_seq").and_then(Value::as_u64) != Some(checkpoint.head_seq)
3354        || bridge.get("feed_hash").and_then(Value::as_str) != Some(checkpoint_feed)
3355    {
3356        return Err(invalid_feed(
3357            "v2 genesis bridge differs from the pinned v1 checkpoint",
3358        ));
3359    }
3360    let legacy_raw = ensure_raw_ok(
3361        request_raw(
3362            cfg,
3363            "GET",
3364            &format!(
3365                "/api/hub/brains/{brain}/feed?after={}&limit=1",
3366                checkpoint.head_seq - 1
3367            ),
3368            None,
3369            Auth::Required,
3370            MAX_FEED_RESPONSE_BYTES,
3371        )?,
3372        "v1 bridge boundary",
3373    )?;
3374    let legacy: FeedResponse = serde_json::from_slice(&legacy_raw)
3375        .map_err(|_| invalid_feed("v1 bridge boundary has an invalid feed shape"))?;
3376    let legacy_identity = legacy
3377        .identity
3378        .ok_or_else(|| invalid_feed("v1 bridge boundary has no identity"))?;
3379    let item = legacy
3380        .entries
3381        .first()
3382        .filter(|_| legacy.entries.len() == 1)
3383        .ok_or_else(|| invalid_feed("v1 bridge boundary did not return one exact entry"))?;
3384    if legacy.scope_limited
3385        || legacy.head_seq != checkpoint.head_seq
3386        || legacy.feed_hash.as_deref() != Some(checkpoint_feed)
3387        || item.entry.seq != checkpoint.head_seq
3388        || item.hash != checkpoint_feed
3389        || legacy_identity != v2_identity(identity)
3390        || bridge.get("pack_sha256").and_then(Value::as_str)
3391            != Some(item.entry.pack_sha256.as_str())
3392    {
3393        return Err(invalid_feed(
3394            "v1 bridge boundary differs from its signed legacy head",
3395        ));
3396    }
3397    let anchor = verify_identity_chain(&legacy_identity, Some(checkpoint))?;
3398    if anchor != checkpoint.anchor {
3399        return Err(invalid_feed("v1 bridge changed the pinned identity anchor"));
3400    }
3401    verify_feed_item(item, &legacy_identity)?;
3402    verify_rotation_feed_boundaries(
3403        &legacy_identity,
3404        Some(checkpoint),
3405        std::slice::from_ref(item),
3406        checkpoint.head_seq,
3407    )?;
3408    Ok(())
3409}
3410
3411fn verify_v2_commit(
3412    cfg: &HubConfig,
3413    brain: &str,
3414    pointer: &V2PointerBody,
3415    identity: &V2HeadIdentity,
3416    pinned: Option<&TrustState>,
3417) -> LinkResult<()> {
3418    let path = format!(
3419        "/api/hub/brains/{brain}/v2/commit?commit={}",
3420        pointer.commit_hash
3421    );
3422    let raw = ensure_raw_ok(
3423        request_raw(cfg, "GET", &path, None, Auth::Required, MAX_RESPONSE_BYTES)?,
3424        "v2 commit",
3425    )?;
3426    if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3427        .map_err(|error| invalid_feed(error.to_string()))?
3428        != pointer.commit_hash
3429        || content_sha256(&raw) != pointer.feed_hash
3430    {
3431        return Err(invalid_feed("v2 commit address differs from the pointer"));
3432    }
3433    let object = verified_v2_commit_object(&raw, identity)?;
3434    if object.get("seq").and_then(Value::as_u64) != Some(pointer.seq)
3435        || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
3436        || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
3437        || object.get("control_revision").and_then(Value::as_str)
3438            != Some(pointer.control_revision.as_str())
3439        || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
3440    {
3441        return Err(invalid_feed("v2 commit fields differ from the pointer"));
3442    }
3443    if let Some(checkpoint) = pinned.filter(|checkpoint| accepted_as_v2(checkpoint)) {
3444        if pointer.seq == checkpoint.head_seq + 1
3445            && object.get("prev_entry_hash").and_then(Value::as_str)
3446                != checkpoint.feed_hash.as_deref()
3447        {
3448            return Err(invalid_feed(
3449                "v2 commit does not extend the pinned feed hash",
3450            ));
3451        }
3452        if pointer.seq > checkpoint.head_seq + 1 {
3453            return replay_v2_feed(
3454                cfg,
3455                brain,
3456                pointer,
3457                identity,
3458                checkpoint.head_seq,
3459                checkpoint.feed_hash.clone(),
3460            );
3461        }
3462    } else {
3463        if let Some(checkpoint) = pinned {
3464            verify_v1_to_v2_bridge(cfg, brain, pointer, identity, checkpoint)?;
3465        }
3466        if pointer.seq > 1 {
3467            return replay_v2_feed(cfg, brain, pointer, identity, 0, None);
3468        }
3469    }
3470    Ok(())
3471}
3472
3473fn v2_verified_head(cfg: &HubConfig, brain: &str) -> LinkResult<Option<V2VerifiedHead>> {
3474    require_hardened_filesystem("verified link.md v2 state")?;
3475    require_safe_ref(brain)?;
3476    // Open and pin the private trust directory before a credential can leave
3477    // the process. A hostile checkout must not turn an unsafe state root into
3478    // a network oracle or receive a bearer before checkpoint safety is known.
3479    let trust_directory = open_trust_dir(cfg)?;
3480    let path = format!("/api/hub/brains/{brain}/v2/head");
3481    let response = request(cfg, "GET", &path, None, Auth::Required)?;
3482    if response.status == 404 {
3483        if has_accepted_v2_ref(cfg, brain)? {
3484            return Err(LinkError::BrainUnavailable);
3485        }
3486        return Ok(None);
3487    }
3488    let body = ensure_ok(response, "v2 head")?;
3489    let head: V2HeadResponse = serde_json::from_value(body)
3490        .map_err(|_| invalid_feed("v2 head response has an invalid shape"))?;
3491    if head.v != 2 || !crate::ulid::is_ulid(&head.brain_id) {
3492        return Err(invalid_feed("v2 head has no canonical brain id"));
3493    }
3494    if crate::ulid::is_ulid(brain) && head.brain_id != brain {
3495        return Err(invalid_feed("v2 head resolved a different brain id"));
3496    }
3497    if head.profile == "v1" {
3498        return Ok(None);
3499    }
3500    if head.profile != "v2" && head.profile != "v2-empty" {
3501        return Err(invalid_feed("v2 head advertised an unknown profile"));
3502    }
3503    let view = head
3504        .view
3505        .as_ref()
3506        .ok_or_else(|| invalid_feed("v2 head has no permission view"))?;
3507    if !matches!(view.kind.as_str(), "full" | "scoped")
3508        || !is_sha256(&view.control_revision)
3509        || view.id.as_deref().is_some_and(|id| !is_sha256(id))
3510    {
3511        return Err(invalid_feed("v2 head has an invalid permission view"));
3512    }
3513    let view_kind = view.kind.clone();
3514    // `id` is the stable materialized namespace. Older v2 hubs did not expose
3515    // it, so retain the fail-closed control-revision fallback for compatibility.
3516    let view_revision = view
3517        .id
3518        .clone()
3519        .unwrap_or_else(|| view.control_revision.clone());
3520    let control_revision = view.control_revision.clone();
3521    let identity = head
3522        .identity
3523        .as_ref()
3524        .ok_or_else(|| invalid_feed("v2 head has no brain identity"))?;
3525    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &head.brain_id])?;
3526    let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, brain, &head.brain_id)?;
3527    let feed_identity = v2_identity(identity);
3528    let anchor = verify_identity_chain(&feed_identity, pinned.as_ref())?;
3529    let (seq, feed_hash, hub_signer) = match &head.pointer {
3530        None => {
3531            if head.profile != "v2-empty" {
3532                return Err(invalid_feed("initialized v2 head has no pointer"));
3533            }
3534            (
3535                0,
3536                None,
3537                pinned.as_ref().and_then(|state| state.hub_signer.clone()),
3538            )
3539        }
3540        Some(signed) => {
3541            let signer = verify_v2_pointer(signed, &head.brain_id)?;
3542            if pinned
3543                .as_ref()
3544                .and_then(|state| state.hub_signer.as_ref())
3545                .is_some_and(|known| known != &signer)
3546            {
3547                return Err(invalid_feed(
3548                    "v2 hub pointer signer changed without a trust transition",
3549                ));
3550            }
3551            if let Some(checkpoint) = pinned.as_ref().filter(|state| accepted_as_v2(state)) {
3552                if signed.pointer.seq < checkpoint.head_seq
3553                    || (signed.pointer.seq == checkpoint.head_seq
3554                        && checkpoint.feed_hash.as_deref()
3555                            != Some(signed.pointer.feed_hash.as_str()))
3556                {
3557                    return Err(invalid_feed("v2 pointer rolled back or equivocated"));
3558                }
3559            }
3560            verify_v2_commit(
3561                cfg,
3562                &head.brain_id,
3563                &signed.pointer,
3564                identity,
3565                pinned.as_ref(),
3566            )?;
3567            (
3568                signed.pointer.seq,
3569                Some(signed.pointer.feed_hash.clone()),
3570                Some(signer),
3571            )
3572        }
3573    };
3574    let trust = TrustState {
3575        v: 2,
3576        origin: normalized_origin(&cfg.hub)?,
3577        requested: head.brain_id.clone(),
3578        brain: head.brain_id.clone(),
3579        home: None,
3580        anchor,
3581        current: format!("ed25519:{}", identity.fingerprint),
3582        head_seq: seq,
3583        feed_hash,
3584        rotations: identity.rotations.clone(),
3585        hub_signer,
3586        protocol_profile: Some("link-v2".to_string()),
3587    };
3588    Ok(Some(V2VerifiedHead {
3589        requested: brain.to_string(),
3590        brain_id: head.brain_id,
3591        view_kind,
3592        view_revision,
3593        control_revision,
3594        identity: identity.clone(),
3595        pointer: head.pointer.map(|signed| signed.pointer),
3596        trust,
3597        alias: alias_binding,
3598    }))
3599}
3600
3601fn accept_v2_head(cfg: &HubConfig, head: &V2VerifiedHead) -> LinkResult<()> {
3602    let directory = open_trust_dir(cfg)?;
3603    let _locks = lock_trust_many(cfg, &directory, &[&head.requested, &head.brain_id])?;
3604    let (current, alias) = load_canonical_pin(cfg, &directory, &head.requested, &head.brain_id)?;
3605    if let Some(current) = current {
3606        let common_invalid = head.trust.anchor != current.anchor
3607            || !head.trust.rotations.starts_with(&current.rotations);
3608        let profile_invalid = if accepted_as_v2(&current) {
3609            head.trust.head_seq < current.head_seq
3610                || (head.trust.head_seq == current.head_seq
3611                    && head.trust.feed_hash != current.feed_hash)
3612                || current
3613                    .hub_signer
3614                    .as_ref()
3615                    .is_some_and(|known| head.trust.hub_signer.as_ref() != Some(known))
3616        } else {
3617            head.trust.protocol_profile.as_deref() != Some("link-v2")
3618                || head.trust.hub_signer.is_none()
3619        };
3620        if common_invalid || profile_invalid {
3621            return Err(invalid_feed(
3622                "v2 head cannot advance the currently accepted trust checkpoint",
3623            ));
3624        }
3625    }
3626    save_canonical_pin_and_alias(
3627        cfg,
3628        &directory,
3629        &head.requested,
3630        &head.brain_id,
3631        head.trust.clone(),
3632        alias.as_ref().or(head.alias.as_ref()),
3633    )
3634}
3635
3636#[derive(Debug, Clone, Deserialize, Serialize)]
3637struct V2BaselineFile {
3638    sha256: String,
3639    bytes: u64,
3640    #[serde(skip)]
3641    proof: Option<Vec<V2ProofStep>>,
3642}
3643
3644#[derive(Debug, Clone, Deserialize, Serialize)]
3645struct V2SyncBaseline {
3646    v: u8,
3647    origin: String,
3648    brain: String,
3649    #[serde(default)]
3650    checkout_id: Option<String>,
3651    #[serde(default)]
3652    head_seq: Option<u64>,
3653    commit_hash: Option<String>,
3654    content_root: Option<String>,
3655    #[serde(default)]
3656    asset_root: Option<String>,
3657    #[serde(default)]
3658    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
3659    #[serde(default)]
3660    view_kind: Option<String>,
3661    #[serde(default)]
3662    view_revision: Option<String>,
3663    /// The authority revision observed with this materialized view. Older
3664    /// baselines omit it and deliberately take one full verified refresh
3665    /// before they become eligible for unchanged-head reuse.
3666    #[serde(default)]
3667    control_revision: Option<String>,
3668    #[serde(default)]
3669    projection_sha256: Option<String>,
3670    files: std::collections::BTreeMap<String, V2BaselineFile>,
3671    #[serde(default)]
3672    local_policy_digest: Option<String>,
3673    #[serde(default)]
3674    local_eligibility: std::collections::BTreeMap<String, bool>,
3675    #[serde(default)]
3676    remote_copy_remains: std::collections::BTreeMap<String, String>,
3677}
3678
3679struct V2LocalView {
3680    riding: std::collections::BTreeMap<String, (String, u64)>,
3681    eligibility: std::collections::BTreeMap<String, bool>,
3682    policy: crate::linkmd_sync_policy::SyncPolicy,
3683    withheld_links: Vec<V2WithheldLink>,
3684}
3685
3686#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
3687struct V2WithheldLink {
3688    source: String,
3689    target: String,
3690}
3691
3692#[derive(Debug, Clone, Deserialize, Serialize)]
3693struct V2ProofStep {
3694    directory_root: String,
3695    component: String,
3696    proof: crate::linkmd_v2::HamtProof,
3697}
3698
3699#[derive(Debug, Deserialize)]
3700struct V2ManifestFile {
3701    path: String,
3702    sha256: String,
3703    bytes: u64,
3704    proof: Vec<V2ProofStep>,
3705}
3706
3707#[derive(Debug, Deserialize)]
3708struct V2ManifestPage {
3709    v: u8,
3710    commit: String,
3711    content_root: Option<String>,
3712    files: Vec<V2ManifestFile>,
3713    next_cursor: Option<String>,
3714}
3715
3716#[derive(Debug, Clone, Deserialize, Serialize)]
3717struct V2BaselineAsset {
3718    blob_sha256: String,
3719    bytes: u64,
3720    media_type: String,
3721    wrappers: Vec<String>,
3722    required: bool,
3723    disposition: String,
3724    leaf_hash: String,
3725}
3726
3727#[derive(Debug, Deserialize)]
3728struct V2AssetManifestItem {
3729    path: String,
3730    blob_sha256: String,
3731    bytes: u64,
3732    media_type: String,
3733    wrappers: Vec<String>,
3734    required: bool,
3735    disposition: String,
3736    leaf_hash: String,
3737    proof: crate::linkmd_v2::HamtProof,
3738}
3739
3740#[derive(Debug, Deserialize)]
3741struct V2AssetManifestPage {
3742    v: u8,
3743    commit: String,
3744    asset_root: Option<String>,
3745    assets: Vec<V2AssetManifestItem>,
3746    next_cursor: Option<String>,
3747}
3748
3749#[derive(Debug, Deserialize)]
3750struct V2SigningCandidate {
3751    seq: u64,
3752    content_root: Option<String>,
3753    asset_root: Option<String>,
3754    signing_bytes_base64: String,
3755    changes_base64: String,
3756    actor_claim_base64: String,
3757}
3758
3759#[derive(Debug, Deserialize)]
3760struct V2SigningCandidatePage {
3761    v: u8,
3762    challenge_id: String,
3763    mutation_id: String,
3764    request_hash: String,
3765    parent: V2SigningParent,
3766    candidate: V2SigningCandidate,
3767    files: Vec<V2ManifestFile>,
3768    #[serde(default)]
3769    assets: Vec<V2AssetManifestItem>,
3770    next_cursor: Option<String>,
3771    expires_at: String,
3772}
3773
3774#[derive(Debug, Deserialize)]
3775struct V2SigningParent {
3776    seq: u64,
3777    commit_hash: Option<String>,
3778}
3779
3780fn verify_v2_file_proof(root: &str, file: &V2ManifestFile) -> LinkResult<()> {
3781    let normalized = crate::linkmd_v2::normalize_path(&file.path)
3782        .map_err(|error| invalid_feed(error.to_string()))?;
3783    let components = normalized.split('/').collect::<Vec<_>>();
3784    if components.len() != file.proof.len() || !is_sha256(&file.sha256) {
3785        return Err(invalid_feed("v2 file proof has the wrong shape"));
3786    }
3787    let mut directory_root = root.to_string();
3788    for (index, step) in file.proof.iter().enumerate() {
3789        if step.directory_root != directory_root || step.component != components[index] {
3790            return Err(invalid_feed(
3791                "v2 file proof path chain differs from its manifest",
3792            ));
3793        }
3794        if !crate::linkmd_v2::verify_proof(&directory_root, &step.component, &step.proof)
3795            .map_err(|error| invalid_feed(error.to_string()))?
3796        {
3797            return Err(invalid_feed("v2 file proof failed verification"));
3798        }
3799        let entry = match &step.proof {
3800            crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry,
3801            crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
3802                return Err(invalid_feed("v2 manifest carried a non-inclusion proof"));
3803            }
3804        };
3805        if index + 1 == components.len() {
3806            if entry.kind != crate::linkmd_v2::EntryKind::Blob
3807                || entry.child_hash != file.sha256
3808                || entry.bytes != Some(file.bytes)
3809            {
3810                return Err(invalid_feed("v2 file proof leaf differs from its manifest"));
3811            }
3812        } else if entry.kind != crate::linkmd_v2::EntryKind::Tree {
3813            return Err(invalid_feed("v2 file proof traversed a non-directory"));
3814        } else {
3815            directory_root = entry.child_hash.clone();
3816        }
3817    }
3818    Ok(())
3819}
3820
3821fn v2_manifest(
3822    cfg: &HubConfig,
3823    brain: &str,
3824    pointer: Option<&V2PointerBody>,
3825) -> LinkResult<std::collections::BTreeMap<String, V2BaselineFile>> {
3826    let Some(pointer) = pointer else {
3827        return Ok(std::collections::BTreeMap::new());
3828    };
3829    let Some(root) = pointer.content_root.as_deref() else {
3830        return Ok(std::collections::BTreeMap::new());
3831    };
3832    let mut files = std::collections::BTreeMap::new();
3833    let mut after = String::new();
3834    loop {
3835        let encoded_after: String =
3836            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3837        let path = format!(
3838            "/api/hub/brains/{brain}/v2/files?commit={}&limit=500&after={encoded_after}",
3839            pointer.commit_hash
3840        );
3841        let value = ensure_ok(
3842            request_capped(
3843                cfg,
3844                "GET",
3845                &path,
3846                None,
3847                Auth::Required,
3848                MAX_FEED_RESPONSE_BYTES,
3849            )?,
3850            "v2 file manifest",
3851        )?;
3852        let page: V2ManifestPage = serde_json::from_value(value)
3853            .map_err(|_| invalid_feed("v2 file manifest has an invalid shape"))?;
3854        if page.v != 2
3855            || page.commit != pointer.commit_hash
3856            || page.content_root.as_deref() != Some(root)
3857            || page.files.len() > 500
3858        {
3859            return Err(invalid_feed(
3860                "v2 file manifest is not bound to the verified head",
3861            ));
3862        }
3863        for file in page.files {
3864            verify_v2_file_proof(root, &file)?;
3865            if files
3866                .insert(
3867                    file.path.clone(),
3868                    V2BaselineFile {
3869                        sha256: file.sha256,
3870                        bytes: file.bytes,
3871                        proof: Some(file.proof),
3872                    },
3873                )
3874                .is_some()
3875            {
3876                return Err(invalid_feed("v2 file manifest repeats a path"));
3877            }
3878            if files.len() > MAX_PUSH_FILES {
3879                return Err(invalid_feed(
3880                    "v2 file manifest exceeds the file-count bound",
3881                ));
3882            }
3883        }
3884        match page.next_cursor {
3885            None => break,
3886            Some(next) if next > after => after = next,
3887            Some(_) => return Err(invalid_feed("v2 file manifest cursor did not advance")),
3888        }
3889    }
3890    Ok(files)
3891}
3892
3893/// Fetch one exact path proof instead of enumerating the whole permission
3894/// view. This keeps `resolve @brain/path` proportional to path depth even for
3895/// a very large brain. The returned leaf is accepted only after its complete
3896/// proof chain reaches the content root signed by `pointer`.
3897fn v2_manifest_file(
3898    cfg: &HubConfig,
3899    brain: &str,
3900    pointer: &V2PointerBody,
3901    path: &str,
3902) -> LinkResult<Option<V2BaselineFile>> {
3903    let Some(root) = pointer.content_root.as_deref() else {
3904        return Ok(None);
3905    };
3906    crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
3907        path: error.to_string(),
3908    })?;
3909    let encoded: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
3910    let response = request_capped(
3911        cfg,
3912        "GET",
3913        &format!(
3914            "/api/hub/brains/{brain}/v2/files?commit={}&path={encoded}",
3915            pointer.commit_hash
3916        ),
3917        None,
3918        Auth::Required,
3919        MAX_FEED_RESPONSE_BYTES,
3920    )?;
3921    // Exact v2 file reads deliberately collapse absent and unreadable paths
3922    // into the same 404. Preserve that non-oracle as a normal `None`; callers
3923    // such as `resolve` then produce the stable, local `NOT_FOUND` contract.
3924    if response.status == 404 {
3925        return Ok(None);
3926    }
3927    let value = ensure_ok(response, "v2 exact file proof")?;
3928    let mut page: V2ManifestPage = serde_json::from_value(value)
3929        .map_err(|_| invalid_feed("v2 exact file proof has an invalid shape"))?;
3930    if page.v != 2
3931        || page.commit != pointer.commit_hash
3932        || page.content_root.as_deref() != Some(root)
3933        || page.next_cursor.is_some()
3934        || page.files.len() != 1
3935        || page.files[0].path != path
3936    {
3937        return Err(invalid_feed(
3938            "v2 exact file proof is not bound to the requested signed path",
3939        ));
3940    }
3941    let file = page.files.pop().expect("exactly one file was checked");
3942    verify_v2_file_proof(root, &file)?;
3943    Ok(Some(V2BaselineFile {
3944        sha256: file.sha256,
3945        bytes: file.bytes,
3946        proof: Some(file.proof),
3947    }))
3948}
3949
3950/// Resolve an id through the hub's synchronously maintained, content-light
3951/// validation certificate, then require the same exact signed path proof as a
3952/// path lookup. The certificate is only an efficient locator: the downloaded
3953/// bytes are parsed again and their id is checked by the caller.
3954fn v2_manifest_file_by_id(
3955    cfg: &HubConfig,
3956    brain: &str,
3957    pointer: &V2PointerBody,
3958    id: &str,
3959) -> LinkResult<(String, V2BaselineFile)> {
3960    let root = pointer
3961        .content_root
3962        .as_deref()
3963        .ok_or_else(|| LinkError::Http {
3964            what: "resolve",
3965            status: 404,
3966            message: "record not found".to_string(),
3967            code: Some("NOT_FOUND".to_string()),
3968            details: None,
3969        })?;
3970    if !crate::ulid::is_ulid(id) {
3971        return Err(LinkError::BadAddress {
3972            given: id.to_string(),
3973            reason: BAD_TARGET_REASON.to_string(),
3974        });
3975    }
3976    let encoded: String = url::form_urlencoded::byte_serialize(id.as_bytes()).collect();
3977    let value = ensure_ok(
3978        request_capped(
3979            cfg,
3980            "GET",
3981            &format!(
3982                "/api/hub/brains/{brain}/v2/files?commit={}&id={encoded}",
3983                pointer.commit_hash
3984            ),
3985            None,
3986            Auth::Required,
3987            MAX_FEED_RESPONSE_BYTES,
3988        )?,
3989        "v2 exact id proof",
3990    )?;
3991    let mut page: V2ManifestPage = serde_json::from_value(value)
3992        .map_err(|_| invalid_feed("v2 exact id proof has an invalid shape"))?;
3993    if page.v != 2
3994        || page.commit != pointer.commit_hash
3995        || page.content_root.as_deref() != Some(root)
3996        || page.next_cursor.is_some()
3997        || page.files.len() != 1
3998    {
3999        return Err(invalid_feed(
4000            "v2 exact id proof is not bound to one signed path",
4001        ));
4002    }
4003    let file = page.files.pop().expect("exactly one file was checked");
4004    if !safe_store_rel_path(&file.path)
4005        || !file.path.ends_with(".md")
4006        || !(file.path.starts_with("records/") || file.path.starts_with("sources/"))
4007    {
4008        return Err(invalid_feed("v2 exact id proof has an invalid record path"));
4009    }
4010    verify_v2_file_proof(root, &file)?;
4011    Ok((
4012        file.path,
4013        V2BaselineFile {
4014            sha256: file.sha256,
4015            bytes: file.bytes,
4016            proof: Some(file.proof),
4017        },
4018    ))
4019}
4020
4021fn verify_v2_asset_proof(root: &str, item: &V2AssetManifestItem) -> LinkResult<()> {
4022    crate::linkmd_v2::normalize_path(&item.path)
4023        .map_err(|error| invalid_feed(error.to_string()))?;
4024    if !is_sha256(&item.blob_sha256)
4025        || !is_sha256(&item.leaf_hash)
4026        || item.bytes > MAX_ASSET_BYTES
4027        || item.wrappers.is_empty()
4028        || !matches!(item.disposition.as_str(), "hosted" | "withheld")
4029        || item
4030            .wrappers
4031            .iter()
4032            .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
4033    {
4034        return Err(invalid_feed("v2 asset manifest item is invalid"));
4035    }
4036    let leaf = json!({
4037        "blob_sha256": item.blob_sha256,
4038        "bytes": item.bytes,
4039        "disposition": item.disposition,
4040        "media_type": item.media_type,
4041        "path": item.path,
4042        "required": item.required,
4043        "v": 2,
4044        "wrappers": item.wrappers,
4045    });
4046    if crate::linkmd_v2::domain_hash("v2/asset-leaf", &leaf)
4047        .map_err(|error| invalid_feed(error.to_string()))?
4048        != item.leaf_hash
4049        || !crate::linkmd_v2::verify_proof_with_domain(
4050            root,
4051            &item.path,
4052            &item.proof,
4053            crate::linkmd_v2::ASSET_TREE_HASH_DOMAIN,
4054        )
4055        .map_err(|error| invalid_feed(error.to_string()))?
4056    {
4057        return Err(invalid_feed("v2 asset inclusion proof failed"));
4058    }
4059    match &item.proof {
4060        crate::linkmd_v2::HamtProof::Inclusion { entry, .. }
4061            if entry.name == item.path
4062                && entry.kind == crate::linkmd_v2::EntryKind::Blob
4063                && entry.child_hash == item.leaf_hash
4064                && entry.bytes == Some(item.bytes) =>
4065        {
4066            Ok(())
4067        }
4068        _ => Err(invalid_feed(
4069            "v2 asset proof leaf differs from its manifest",
4070        )),
4071    }
4072}
4073
4074fn v2_asset_manifest(
4075    cfg: &HubConfig,
4076    brain: &str,
4077    pointer: Option<&V2PointerBody>,
4078) -> LinkResult<std::collections::BTreeMap<String, V2BaselineAsset>> {
4079    let Some(pointer) = pointer else {
4080        return Ok(std::collections::BTreeMap::new());
4081    };
4082    let Some(root) = pointer.asset_root.as_deref() else {
4083        return Ok(std::collections::BTreeMap::new());
4084    };
4085    let mut assets = std::collections::BTreeMap::new();
4086    let mut after = String::new();
4087    loop {
4088        let encoded_after: String =
4089            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4090        let path = format!(
4091            "/api/hub/brains/{brain}/v2/assets?commit={}&limit=500&after={encoded_after}",
4092            pointer.commit_hash
4093        );
4094        let value = ensure_ok(
4095            request_capped(
4096                cfg,
4097                "GET",
4098                &path,
4099                None,
4100                Auth::Required,
4101                MAX_FEED_RESPONSE_BYTES,
4102            )?,
4103            "v2 asset manifest",
4104        )?;
4105        let page: V2AssetManifestPage = serde_json::from_value(value)
4106            .map_err(|_| invalid_feed("v2 asset manifest has an invalid shape"))?;
4107        if page.v != 2
4108            || page.commit != pointer.commit_hash
4109            || page.asset_root.as_deref() != Some(root)
4110            || page.assets.len() > 500
4111        {
4112            return Err(invalid_feed(
4113                "v2 asset manifest is not bound to the verified head",
4114            ));
4115        }
4116        for item in page.assets {
4117            verify_v2_asset_proof(root, &item)?;
4118            let path = item.path.clone();
4119            if assets
4120                .insert(
4121                    path,
4122                    V2BaselineAsset {
4123                        blob_sha256: item.blob_sha256,
4124                        bytes: item.bytes,
4125                        media_type: item.media_type,
4126                        wrappers: item.wrappers,
4127                        required: item.required,
4128                        disposition: item.disposition,
4129                        leaf_hash: item.leaf_hash,
4130                    },
4131                )
4132                .is_some()
4133            {
4134                return Err(invalid_feed("v2 asset manifest repeats a path"));
4135            }
4136            if assets.len() > MAX_PUSH_FILES {
4137                return Err(invalid_feed(
4138                    "v2 asset manifest exceeds the item-count bound",
4139                ));
4140            }
4141        }
4142        match page.next_cursor {
4143            None => break,
4144            Some(next) if next > after => after = next,
4145            Some(_) => return Err(invalid_feed("v2 asset manifest cursor did not advance")),
4146        }
4147    }
4148    Ok(assets)
4149}
4150
4151fn v2_asset_record(asset: &V2BaselineAsset, path: &str) -> crate::AssetRecord {
4152    crate::AssetRecord {
4153        path: path.to_string(),
4154        sha256: asset.blob_sha256.clone(),
4155        bytes: asset.bytes,
4156        media_type: asset.media_type.clone(),
4157        wrappers: asset.wrappers.clone(),
4158        required: asset.required,
4159    }
4160}
4161
4162fn v2_asset_resumes_hosting(
4163    remote: Option<&V2BaselineAsset>,
4164    path: &str,
4165    record: &crate::AssetRecord,
4166    disposition: &str,
4167) -> bool {
4168    remote.is_some_and(|asset| {
4169        asset.disposition == "withheld"
4170            && disposition == "hosted"
4171            && v2_asset_record(asset, path) == *record
4172    })
4173}
4174
4175fn v2_asset_record_manifest_bytes(
4176    assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
4177) -> LinkResult<Vec<u8>> {
4178    let mut bytes = Vec::new();
4179    for (path, asset) in assets {
4180        if asset.path != *path {
4181            return Err(invalid_feed(
4182                "local asset manifest key differs from its record path",
4183            ));
4184        }
4185        serde_json::to_writer(&mut bytes, asset)
4186            .map_err(|_| invalid_feed("could not materialize merged v2 assets.jsonl"))?;
4187        bytes.push(b'\n');
4188    }
4189    Ok(bytes)
4190}
4191
4192fn v2_local_asset_records(
4193    store: &Store,
4194) -> LinkResult<std::collections::BTreeMap<String, crate::AssetRecord>> {
4195    let assets = crate::assets::read_manifest(store)
4196        .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?;
4197    if assets.iter().any(|asset| asset.bytes > MAX_ASSET_BYTES) {
4198        return Err(LinkError::InvalidPack {
4199            message: "local asset exceeds the link.md v2 per-blob limit".to_string(),
4200        });
4201    }
4202    Ok(assets
4203        .into_iter()
4204        .map(|asset| (asset.path.clone(), asset))
4205        .collect())
4206}
4207
4208fn v2_asset_records_match_remote(
4209    local: &std::collections::BTreeMap<String, crate::AssetRecord>,
4210    remote: &std::collections::BTreeMap<String, V2BaselineAsset>,
4211) -> bool {
4212    local.len() == remote.len()
4213        && remote
4214            .iter()
4215            .all(|(path, asset)| local.get(path) == Some(&v2_asset_record(asset, path)))
4216}
4217
4218#[derive(Debug, Clone, PartialEq, Eq)]
4219struct V2PulledMerge<T> {
4220    records: std::collections::BTreeMap<String, T>,
4221    accept_remote: std::collections::BTreeSet<String>,
4222    conflicts: Vec<String>,
4223}
4224
4225/// Compute the local checkout that a pull may install without discarding a
4226/// local-only edit. Remote-only changes are accepted, identical concurrent
4227/// changes converge, and divergent concurrent changes are reported. The
4228/// caller can exempt kept-home coordinates from both remote installation and
4229/// conflict disclosure.
4230fn merge_v2_pulled_records<Base, Remote, Record, BaseRecord, RemoteRecord, KeepLocal>(
4231    base: &std::collections::BTreeMap<String, Base>,
4232    remote: &std::collections::BTreeMap<String, Remote>,
4233    local: &std::collections::BTreeMap<String, Record>,
4234    base_record: BaseRecord,
4235    remote_record: RemoteRecord,
4236    keep_local: KeepLocal,
4237) -> V2PulledMerge<Record>
4238where
4239    Record: Clone + Eq,
4240    BaseRecord: Fn(&Base, &str) -> Record,
4241    RemoteRecord: Fn(&Remote, &str) -> Record,
4242    KeepLocal: Fn(&str) -> bool,
4243{
4244    let paths = base
4245        .keys()
4246        .chain(remote.keys())
4247        .chain(local.keys())
4248        .cloned()
4249        .collect::<std::collections::BTreeSet<_>>();
4250    let mut records = local.clone();
4251    let mut accept_remote = std::collections::BTreeSet::new();
4252    let mut conflicts = Vec::new();
4253    for path in paths {
4254        if keep_local(&path) {
4255            continue;
4256        }
4257        let base_value = base.get(&path).map(|value| base_record(value, &path));
4258        let remote_value = remote.get(&path).map(|value| remote_record(value, &path));
4259        let local_value = local.get(&path).cloned();
4260        if local_value != base_value && remote_value != base_value && local_value != remote_value {
4261            conflicts.push(path);
4262            continue;
4263        }
4264        if local_value == base_value || local_value == remote_value {
4265            accept_remote.insert(path.clone());
4266            match remote_value {
4267                Some(value) => {
4268                    records.insert(path, value);
4269                }
4270                None => {
4271                    records.remove(&path);
4272                }
4273            }
4274        }
4275    }
4276    V2PulledMerge {
4277        records,
4278        accept_remote,
4279        conflicts,
4280    }
4281}
4282
4283fn sign_verified_v2_candidate(
4284    cfg: &HubConfig,
4285    head: &V2VerifiedHead,
4286    expected: &std::collections::BTreeMap<String, V2BaselineFile>,
4287    expected_assets: &std::collections::BTreeMap<String, V2BaselineAsset>,
4288    mutation_id: &str,
4289    request_body: &Value,
4290    challenge_value: &Value,
4291) -> LinkResult<(String, String, String)> {
4292    if head.view_kind != "full" {
4293        return Err(invalid_feed(
4294            "a scoped self-custody writer must use the proposal workflow",
4295        ));
4296    }
4297    if head.identity.custody != "self" {
4298        return Err(invalid_feed(
4299            "a hub-custodied brain unexpectedly requested an external signature",
4300        ));
4301    }
4302    let key = cfg
4303        .brain_key
4304        .as_ref()
4305        .ok_or_else(|| bad_agent_key("this self-custodied brain requires DBMD_BRAIN_KEY_FILE"))?;
4306    if key.multikey != format!("ed25519:{}", head.identity.fingerprint)
4307        || key.public_key_spki != head.identity.public_key_spki
4308    {
4309        return Err(bad_agent_key(
4310            "DBMD_BRAIN_KEY_FILE does not match the verified brain identity",
4311        ));
4312    }
4313    let challenge_id = challenge_value
4314        .get("id")
4315        .and_then(Value::as_str)
4316        .filter(|id| crate::ulid::is_ulid(id))
4317        .ok_or_else(|| invalid_feed("self-custody challenge has no canonical id"))?;
4318    let expected_endpoint = format!(
4319        "/api/hub/brains/{}/v2/signing-challenges/{challenge_id}",
4320        head.brain_id
4321    );
4322    if challenge_value
4323        .get("candidate_endpoint")
4324        .and_then(Value::as_str)
4325        != Some(expected_endpoint.as_str())
4326    {
4327        return Err(invalid_feed(
4328            "self-custody challenge candidate endpoint is not origin-bound",
4329        ));
4330    }
4331
4332    let mut files = std::collections::BTreeMap::new();
4333    let mut after = String::new();
4334    type CandidateCoordinate = (
4335        String,
4336        String,
4337        String,
4338        String,
4339        Option<String>,
4340        Option<String>,
4341        u64,
4342        Option<String>,
4343    );
4344    let mut pinned: Option<CandidateCoordinate> = None;
4345    loop {
4346        let encoded_after: String =
4347            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4348        let path = format!("{expected_endpoint}?limit=500&after={encoded_after}");
4349        let value = ensure_ok(
4350            request_capped(
4351                cfg,
4352                "GET",
4353                &path,
4354                None,
4355                Auth::Required,
4356                MAX_FEED_RESPONSE_BYTES,
4357            )?,
4358            "v2 self-custody candidate",
4359        )?;
4360        let page: V2SigningCandidatePage = serde_json::from_value(value)
4361            .map_err(|_| invalid_feed("self-custody candidate has an invalid shape"))?;
4362        if page.v != 2
4363            || page.challenge_id != challenge_id
4364            || page.mutation_id != mutation_id
4365            || page.candidate.seq != page.parent.seq + 1
4366            || page.files.len() > 500
4367            || page.expires_at.is_empty()
4368        {
4369            return Err(invalid_feed(
4370                "self-custody candidate is not bound to this mutation",
4371            ));
4372        }
4373        let coordinate = (
4374            page.request_hash.clone(),
4375            page.candidate.signing_bytes_base64.clone(),
4376            page.candidate.changes_base64.clone(),
4377            page.candidate.actor_claim_base64.clone(),
4378            page.candidate.content_root.clone(),
4379            page.candidate.asset_root.clone(),
4380            page.parent.seq,
4381            page.parent.commit_hash.clone(),
4382        );
4383        if pinned.as_ref().is_some_and(|prior| prior != &coordinate) {
4384            return Err(invalid_feed(
4385                "self-custody candidate changed between manifest pages",
4386            ));
4387        }
4388        pinned = Some(coordinate);
4389        let root = page
4390            .candidate
4391            .content_root
4392            .as_deref()
4393            .ok_or_else(|| invalid_feed("self-custody candidate has no content root"))?;
4394        for file in page.files {
4395            verify_v2_file_proof(root, &file)?;
4396            if files
4397                .insert(
4398                    file.path.clone(),
4399                    V2BaselineFile {
4400                        sha256: file.sha256,
4401                        bytes: file.bytes,
4402                        proof: Some(file.proof),
4403                    },
4404                )
4405                .is_some()
4406            {
4407                return Err(invalid_feed(
4408                    "self-custody candidate repeats a manifest path",
4409                ));
4410            }
4411            if files.len() > MAX_PUSH_FILES {
4412                return Err(invalid_feed(
4413                    "self-custody candidate exceeds the file-count bound",
4414                ));
4415            }
4416        }
4417        match page.next_cursor {
4418            None => break,
4419            Some(next) if next > after => after = next,
4420            Some(_) => {
4421                return Err(invalid_feed(
4422                    "self-custody candidate cursor did not advance",
4423                ))
4424            }
4425        }
4426    }
4427    if files.len() != expected.len()
4428        || files.iter().any(|(path, file)| {
4429            expected.get(path).is_none_or(|expected| {
4430                expected.sha256 != file.sha256 || expected.bytes != file.bytes
4431            })
4432        })
4433    {
4434        return Err(invalid_feed(
4435            "self-custody candidate contains an unexpected file mutation",
4436        ));
4437    }
4438    let mut assets = std::collections::BTreeMap::new();
4439    after.clear();
4440    loop {
4441        let encoded_after: String =
4442            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4443        let path = format!("{expected_endpoint}?kind=assets&limit=500&after={encoded_after}");
4444        let value = ensure_ok(
4445            request_capped(
4446                cfg,
4447                "GET",
4448                &path,
4449                None,
4450                Auth::Required,
4451                MAX_FEED_RESPONSE_BYTES,
4452            )?,
4453            "v2 self-custody asset candidate",
4454        )?;
4455        let page: V2SigningCandidatePage = serde_json::from_value(value)
4456            .map_err(|_| invalid_feed("self-custody asset candidate has an invalid shape"))?;
4457        let coordinate = (
4458            page.request_hash.clone(),
4459            page.candidate.signing_bytes_base64.clone(),
4460            page.candidate.changes_base64.clone(),
4461            page.candidate.actor_claim_base64.clone(),
4462            page.candidate.content_root.clone(),
4463            page.candidate.asset_root.clone(),
4464            page.parent.seq,
4465            page.parent.commit_hash.clone(),
4466        );
4467        if page.v != 2
4468            || page.challenge_id != challenge_id
4469            || page.mutation_id != mutation_id
4470            || page.assets.len() > 500
4471            || pinned.as_ref() != Some(&coordinate)
4472        {
4473            return Err(invalid_feed(
4474                "self-custody asset candidate changed or is not bound",
4475            ));
4476        }
4477        let root = page.candidate.asset_root.as_deref();
4478        if !page.assets.is_empty() && root.is_none() {
4479            return Err(invalid_feed("asset candidate has no asset root"));
4480        }
4481        for item in page.assets {
4482            verify_v2_asset_proof(root.expect("non-empty assets checked"), &item)?;
4483            if assets
4484                .insert(
4485                    item.path.clone(),
4486                    V2BaselineAsset {
4487                        blob_sha256: item.blob_sha256,
4488                        bytes: item.bytes,
4489                        media_type: item.media_type,
4490                        wrappers: item.wrappers,
4491                        required: item.required,
4492                        disposition: item.disposition,
4493                        leaf_hash: item.leaf_hash,
4494                    },
4495                )
4496                .is_some()
4497            {
4498                return Err(invalid_feed("self-custody candidate repeats an asset"));
4499            }
4500        }
4501        match page.next_cursor {
4502            None => break,
4503            Some(next) if next > after => after = next,
4504            Some(_) => {
4505                return Err(invalid_feed(
4506                    "self-custody asset candidate cursor did not advance",
4507                ))
4508            }
4509        }
4510    }
4511    if assets.len() != expected_assets.len()
4512        || assets.iter().any(|(path, asset)| {
4513            expected_assets.get(path).is_none_or(|expected| {
4514                asset.blob_sha256 != expected.blob_sha256
4515                    || asset.bytes != expected.bytes
4516                    || asset.media_type != expected.media_type
4517                    || asset.wrappers != expected.wrappers
4518                    || asset.required != expected.required
4519                    || asset.disposition != expected.disposition
4520            })
4521        })
4522    {
4523        return Err(invalid_feed(
4524            "self-custody candidate contains an unexpected asset mutation",
4525        ));
4526    }
4527    let Some((
4528        request_hash,
4529        signing_b64,
4530        changes_b64,
4531        actor_b64,
4532        root,
4533        asset_root,
4534        parent_seq,
4535        parent,
4536    )) = pinned
4537    else {
4538        return Err(invalid_feed("self-custody candidate has no manifest"));
4539    };
4540    let current_seq = head.pointer.as_ref().map_or(0, |pointer| pointer.seq);
4541    let current_commit = head
4542        .pointer
4543        .as_ref()
4544        .map(|pointer| pointer.commit_hash.clone());
4545    if parent_seq != current_seq || parent != current_commit {
4546        return Err(LinkError::RemoteAdvancedDuringSync);
4547    }
4548    let changes = STANDARD
4549        .decode(changes_b64)
4550        .map_err(|_| invalid_feed("self-custody changeset is not base64"))?;
4551    let mut expected_changes = json!({
4552        "mutation_id": mutation_id,
4553        "operations": request_body.get("operations").cloned().unwrap_or(Value::Null),
4554        "reason": request_body.get("reason").cloned().unwrap_or(Value::Null),
4555        "v": 2,
4556    });
4557    if let Some(withheld_links) = request_body.get("withheld_links") {
4558        expected_changes["withheld_links"] = withheld_links.clone();
4559    }
4560    if let Some(checkout_id) = request_body.get("checkout_id") {
4561        expected_changes["checkout_id"] = checkout_id.clone();
4562    }
4563    let expected_changes_bytes = crate::linkmd_v2::canonical_bytes(&expected_changes)
4564        .map_err(|error| invalid_feed(error.to_string()))?;
4565    if changes != expected_changes_bytes {
4566        return Err(invalid_feed(
4567            "self-custody changeset differs from the requested mutation",
4568        ));
4569    }
4570    let changes_hash = crate::linkmd_v2::domain_hash_bytes("v2/changeset", &changes)
4571        .map_err(|error| invalid_feed(error.to_string()))?;
4572    let request_value = json!({
4573        "base": request_body.get("base").cloned().unwrap_or(Value::Null),
4574        "brain": head.brain_id,
4575        "changes_sha256": changes_hash,
4576        "rebase": request_body.get("rebase").cloned().unwrap_or(Value::Null),
4577        "v": 2,
4578        "v1_bridge": Value::Null,
4579    });
4580    let expected_request_hash = crate::linkmd_v2::domain_hash("v2/request", &request_value)
4581        .map_err(|error| invalid_feed(error.to_string()))?;
4582    if request_hash != expected_request_hash {
4583        return Err(invalid_feed(
4584            "self-custody request hash differs from the requested mutation",
4585        ));
4586    }
4587    let actor = STANDARD
4588        .decode(actor_b64)
4589        .map_err(|_| invalid_feed("self-custody actor claim is not base64"))?;
4590    let actor_value: Value = serde_json::from_slice(&actor)
4591        .map_err(|_| invalid_feed("self-custody actor claim is not JSON"))?;
4592    if crate::linkmd_v2::canonical_bytes(&actor_value)
4593        .map_err(|error| invalid_feed(error.to_string()))?
4594        != actor
4595    {
4596        return Err(invalid_feed("self-custody actor claim is not canonical"));
4597    }
4598    let actor_object = actor_value
4599        .as_object()
4600        .ok_or_else(|| invalid_feed("self-custody actor claim is not an object"))?;
4601    let actor_claim = actor_object
4602        .get("claim")
4603        .ok_or_else(|| invalid_feed("self-custody actor claim body is missing"))?;
4604    let actor_public_key = actor_object
4605        .get("public_key")
4606        .and_then(Value::as_str)
4607        .ok_or_else(|| invalid_feed("self-custody actor signer is missing"))?;
4608    let actor_fingerprint = actor_object
4609        .get("fingerprint")
4610        .and_then(Value::as_str)
4611        .ok_or_else(|| invalid_feed("self-custody actor fingerprint is missing"))?;
4612    let actor_signature = actor_object
4613        .get("sig")
4614        .and_then(Value::as_str)
4615        .ok_or_else(|| invalid_feed("self-custody actor signature is missing"))?;
4616    let actor_message = crate::linkmd_v2::canonical_bytes(actor_claim)
4617        .map_err(|error| invalid_feed(error.to_string()))?;
4618    let actor_der = verify_v2_spki_signature(actor_public_key, &actor_message, actor_signature)?;
4619    let expected_actor_signer = format!("{actor_fingerprint}:{actor_public_key}");
4620    let expected_actor_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4621    let expected_actor_asset_root = asset_root.clone().map(Value::String).unwrap_or(Value::Null);
4622    let impact = actor_claim
4623        .get("result")
4624        .and_then(|result| result.get("impact"))
4625        .and_then(Value::as_object);
4626    let impact_fields = [
4627        "creates",
4628        "updates",
4629        "deletes",
4630        "withdrawals",
4631        "renames",
4632        "restores",
4633        "asset_changes",
4634        "public_expansions",
4635        "executable_activations",
4636    ];
4637    let impact_is_valid = impact.is_some_and(|impact| {
4638        impact.len() == impact_fields.len() + 1
4639            && impact.get("v").and_then(Value::as_u64) == Some(1)
4640            && impact_fields
4641                .iter()
4642                .all(|field| impact.get(*field).and_then(Value::as_u64).is_some())
4643    });
4644    if format!("{:x}", Sha256::digest(&actor_der)) != actor_fingerprint
4645        || head
4646            .trust
4647            .hub_signer
4648            .as_ref()
4649            .is_some_and(|known| known != &expected_actor_signer)
4650        || actor_claim.get("mutation_id").and_then(Value::as_str) != Some(mutation_id)
4651        || actor_claim.get("request_hash").and_then(Value::as_str) != Some(request_hash.as_str())
4652        || actor_claim
4653            .get("candidate")
4654            .and_then(|candidate| candidate.get("changes_sha256"))
4655            .and_then(Value::as_str)
4656            != Some(changes_hash.as_str())
4657        || actor_claim
4658            .get("candidate")
4659            .and_then(|candidate| candidate.get("state_root"))
4660            != Some(&expected_actor_root)
4661        || actor_claim
4662            .get("candidate")
4663            .and_then(|candidate| candidate.get("asset_root"))
4664            != Some(&expected_actor_asset_root)
4665        || actor_claim
4666            .get("candidate")
4667            .and_then(|candidate| candidate.get("control_revision"))
4668            .and_then(Value::as_str)
4669            != Some(head.control_revision.as_str())
4670        || !impact_is_valid
4671    {
4672        return Err(invalid_feed(
4673            "self-custody actor claim does not bind the verified authority",
4674        ));
4675    }
4676    let actor_hash = crate::linkmd_v2::domain_hash_bytes("v2/actor-claim", &actor)
4677        .map_err(|error| invalid_feed(error.to_string()))?;
4678    let signing = STANDARD
4679        .decode(signing_b64)
4680        .map_err(|_| invalid_feed("self-custody signing bytes are not base64"))?;
4681    let signing_value: Value = serde_json::from_slice(&signing)
4682        .map_err(|_| invalid_feed("self-custody signing bytes are not JSON"))?;
4683    if crate::linkmd_v2::canonical_bytes(&signing_value)
4684        .map_err(|error| invalid_feed(error.to_string()))?
4685        != signing
4686    {
4687        return Err(invalid_feed("self-custody signing bytes are not canonical"));
4688    }
4689    let pointer = head.pointer.as_ref();
4690    let expected_materializer = pointer
4691        .map(|value| value.materializer.as_str())
4692        .unwrap_or("dbmd-projection-v1");
4693    let expected_parent_commit = request_body
4694        .get("base")
4695        .and_then(|base| base.get("commit_hash"))
4696        .cloned()
4697        .unwrap_or(Value::Null);
4698    let expected_parent_root = request_body
4699        .get("base")
4700        .and_then(|base| base.get("content_root"))
4701        .cloned()
4702        .unwrap_or(Value::Null);
4703    let expected_state_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4704    let expected_parent_asset_root = request_body
4705        .get("base")
4706        .and_then(|base| base.get("asset_root"))
4707        .cloned()
4708        .unwrap_or(Value::Null);
4709    let expected_asset_root = asset_root.map(Value::String).unwrap_or(Value::Null);
4710    let expected_prev_entry = pointer
4711        .map(|value| Value::String(value.feed_hash.clone()))
4712        .unwrap_or(Value::Null);
4713    let expected_signer_epoch = u64::try_from(head.identity.previous.len())
4714        .map_err(|_| invalid_feed("brain identity history is too large"))?
4715        + 1;
4716    if signing_value.get("v").and_then(Value::as_u64) != Some(2)
4717        || signing_value.get("seq").and_then(Value::as_u64) != Some(current_seq + 1)
4718        || signing_value.get("signer_epoch").and_then(Value::as_u64) != Some(expected_signer_epoch)
4719        || signing_value.get("brain").and_then(Value::as_str) != Some(key.multikey.as_str())
4720        || signing_value.get("public_key").and_then(Value::as_str)
4721            != Some(key.public_key_spki.as_str())
4722        || signing_value.get("parent_commit") != Some(&expected_parent_commit)
4723        || signing_value.get("parent_root") != Some(&expected_parent_root)
4724        || signing_value.get("state_root") != Some(&expected_state_root)
4725        || signing_value.get("parent_asset_root") != Some(&expected_parent_asset_root)
4726        || signing_value.get("asset_root") != Some(&expected_asset_root)
4727        || signing_value.get("materializer").and_then(Value::as_str) != Some(expected_materializer)
4728        || signing_value.get("changes_sha256").and_then(Value::as_str)
4729            != Some(changes_hash.as_str())
4730        || signing_value.get("actor_ref").and_then(Value::as_str) != Some(actor_hash.as_str())
4731        || signing_value
4732            .get("control_revision")
4733            .and_then(Value::as_str)
4734            != Some(head.control_revision.as_str())
4735        || signing_value.get("prev_entry_hash") != Some(&expected_prev_entry)
4736        || signing_value.get("v1_bridge") != Some(&Value::Null)
4737        || signing_value.get("op").and_then(Value::as_str) != Some("changeset")
4738    {
4739        return Err(invalid_feed(
4740            "self-custody signing bytes do not bind the verified candidate",
4741        ));
4742    }
4743    let pair = agent_keypair(&key.pkcs8)?;
4744    let signature = URL_SAFE_NO_PAD.encode(pair.sign(&signing).as_ref());
4745    Ok((challenge_id.to_string(), signature, expected_actor_signer))
4746}
4747
4748fn v2_baseline_name(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<String> {
4749    let origin = normalized_origin(&cfg.hub)?;
4750    let absolute = if checkout.is_absolute() {
4751        checkout.to_path_buf()
4752    } else {
4753        std::env::current_dir()?.join(checkout)
4754    };
4755    Ok(format!(
4756        "sync-{}.json",
4757        content_sha256(format!("{origin}\0{brain}\0{}", absolute.display()).as_bytes())
4758    ))
4759}
4760
4761fn v2_checkout_id(existing: Option<&str>) -> LinkResult<String> {
4762    if let Some(value) = existing {
4763        if !is_sha256(value) {
4764            return Err(invalid_feed("v2 checkout pseudonym is invalid"));
4765        }
4766        return Ok(value.to_string());
4767    }
4768    use ring::rand::SecureRandom as _;
4769    let mut random = [0_u8; 32];
4770    ring::rand::SystemRandom::new()
4771        .fill(&mut random)
4772        .map_err(|_| invalid_feed("could not generate a checkout pseudonym"))?;
4773    Ok(random.iter().map(|byte| format!("{byte:02x}")).collect())
4774}
4775
4776#[cfg(any(unix, windows))]
4777fn lock_v2_sync_operation(cfg: &HubConfig, brain: &str) -> LinkResult<TrustLock> {
4778    let directory = open_trust_dir(cfg)?;
4779    let origin = normalized_origin(&cfg.hub)?;
4780    let name = format!(
4781        "operation-{}.lock",
4782        content_sha256(format!("{origin}\0{brain}").as_bytes())
4783    );
4784    lock_trust_name(&directory, &name)
4785}
4786
4787#[cfg(not(any(unix, windows)))]
4788fn lock_v2_sync_operation(_cfg: &HubConfig, _brain: &str) -> LinkResult<()> {
4789    Err(LinkError::UnsupportedPlatform {
4790        operation: "serialized link.md v2 sync",
4791    })
4792}
4793
4794fn same_v2_head(left: &V2VerifiedHead, right: &V2VerifiedHead) -> bool {
4795    left.brain_id == right.brain_id
4796        && left.view_kind == right.view_kind
4797        && left.view_revision == right.view_revision
4798        && left.control_revision == right.control_revision
4799        && match (&left.pointer, &right.pointer) {
4800            (None, None) => true,
4801            (Some(left), Some(right)) => {
4802                left.seq == right.seq
4803                    && left.commit_hash == right.commit_hash
4804                    && left.content_root == right.content_root
4805                    && left.asset_root == right.asset_root
4806                    && left.feed_hash == right.feed_hash
4807            }
4808            _ => false,
4809        }
4810}
4811
4812/// A private sync baseline is an already verified materialization of the
4813/// remote manifest. It may replace another paginated manifest download only
4814/// when a freshly verified signed head names the exact same content, assets,
4815/// view, and authority revision. Any remote or permission movement falls back
4816/// to proof-bearing hub reads.
4817fn v2_baseline_matches_head(head: &V2VerifiedHead, baseline: &V2SyncBaseline) -> bool {
4818    let pointer = head.pointer.as_ref();
4819    baseline.head_seq == Some(pointer.map_or(0, |value| value.seq))
4820        && baseline.commit_hash.as_deref() == pointer.map(|value| value.commit_hash.as_str())
4821        && baseline.content_root.as_deref()
4822            == pointer.and_then(|value| value.content_root.as_deref())
4823        && baseline.asset_root.as_deref() == pointer.and_then(|value| value.asset_root.as_deref())
4824        && baseline.view_kind.as_deref() == Some(head.view_kind.as_str())
4825        && baseline.view_revision.as_deref() == Some(head.view_revision.as_str())
4826        && baseline.control_revision.as_deref() == Some(head.control_revision.as_str())
4827}
4828
4829fn scoped_projection_bytes(brain: &str) -> Vec<u8> {
4830    format!(
4831        "---\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"
4832    )
4833    .into_bytes()
4834}
4835
4836fn scoped_projection_sha256(brain: &str) -> String {
4837    content_sha256(&scoped_projection_bytes(brain))
4838}
4839
4840#[derive(Deserialize)]
4841struct LocalScopedViewMarker {
4842    v: u8,
4843    kind: String,
4844    authoritative: bool,
4845    brain: String,
4846    projection_sha256: String,
4847}
4848
4849/// True only when this store carries the exact generated marker for a local
4850/// link.md scoped view. This is presentation context, never authorization;
4851/// the hub remains the only authority for reads and writes.
4852pub fn has_verified_local_scoped_view(store: &Store) -> bool {
4853    let marker = store
4854        .read_bounded(Path::new(".dbmd/view.json"), 64 * 1024)
4855        .ok()
4856        .and_then(|bytes| serde_json::from_slice::<LocalScopedViewMarker>(&bytes).ok());
4857    let Some(marker) = marker else {
4858        return false;
4859    };
4860    if marker.v != 1
4861        || marker.kind != "link.md-scoped-view"
4862        || marker.authoritative
4863        || !crate::ulid::is_ulid(&marker.brain)
4864        || marker.projection_sha256 != scoped_projection_sha256(&marker.brain)
4865    {
4866        return false;
4867    }
4868    store
4869        .read_bounded(Path::new("DB.md"), crate::parser::MAX_DBMD_FILE_BYTES)
4870        .is_ok_and(|bytes| content_sha256(&bytes) == marker.projection_sha256)
4871}
4872
4873fn scoped_view_metadata(head: &V2VerifiedHead, files: usize) -> LinkResult<Vec<u8>> {
4874    let mut bytes = serde_json::to_vec_pretty(&json!({
4875        "v": 1,
4876        "kind": "link.md-scoped-view",
4877        "authoritative": false,
4878        "brain": head.brain_id,
4879        "view_revision": head.view_revision,
4880        "head_seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
4881        "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
4882        "content_root": head.pointer.as_ref().and_then(|pointer| pointer.content_root.as_ref()),
4883        "visible_files": files,
4884        "projection_sha256": scoped_projection_sha256(&head.brain_id),
4885    }))
4886    .map_err(|_| invalid_feed("could not serialize scoped view metadata"))?;
4887    bytes.push(b'\n');
4888    Ok(bytes)
4889}
4890
4891fn refresh_scoped_view_marker(
4892    store: &Store,
4893    head: &V2VerifiedHead,
4894    files: usize,
4895) -> LinkResult<()> {
4896    if head.view_kind == "scoped" {
4897        store.write_atomic(
4898            Path::new(".dbmd/view.json"),
4899            &scoped_view_metadata(head, files)?,
4900        )?;
4901    }
4902    Ok(())
4903}
4904
4905fn ensure_v2_view_compatible(
4906    head: &V2VerifiedHead,
4907    baseline: Option<&V2SyncBaseline>,
4908) -> LinkResult<()> {
4909    let Some(baseline) = baseline else {
4910        return Ok(());
4911    };
4912    match (
4913        baseline.view_kind.as_deref(),
4914        baseline.view_revision.as_deref(),
4915    ) {
4916        (None, None) if head.view_kind == "full" => Ok(()),
4917        (Some(kind), Some(revision))
4918            if kind == head.view_kind && revision == head.view_revision =>
4919        {
4920            Ok(())
4921        }
4922        _ => Err(LinkError::ScopedViewChanged),
4923    }
4924}
4925
4926fn ensure_established_v2_checkout_opened(
4927    head: &V2VerifiedHead,
4928    baseline: Option<&V2SyncBaseline>,
4929    opened: bool,
4930) -> LinkResult<()> {
4931    if baseline.is_none() || opened {
4932        return Ok(());
4933    }
4934    if head.view_kind == "scoped" {
4935        return Err(LinkError::ScopedProjectionModified);
4936    }
4937    Err(LinkError::InvalidPack {
4938        message: "the established v2 checkout is no longer a valid db.md store; repair its DB.md before syncing".to_string(),
4939    })
4940}
4941
4942fn remove_scoped_projection(
4943    head: &V2VerifiedHead,
4944    baseline: Option<&V2SyncBaseline>,
4945    view: &mut V2LocalView,
4946) -> LinkResult<()> {
4947    if head.view_kind != "scoped" {
4948        return Ok(());
4949    }
4950    let expected = scoped_projection_sha256(&head.brain_id);
4951    if baseline
4952        .and_then(|state| state.projection_sha256.as_deref())
4953        .is_some_and(|pinned| pinned != expected)
4954    {
4955        return Err(LinkError::ScopedViewChanged);
4956    }
4957    if view.riding.get("DB.md").map(|(sha256, _)| sha256.as_str()) != Some(expected.as_str()) {
4958        return Err(LinkError::ScopedProjectionModified);
4959    }
4960    view.riding.remove("DB.md");
4961    view.eligibility.remove("DB.md");
4962    Ok(())
4963}
4964
4965fn local_view_for_v2_push(
4966    store: &Store,
4967    head: &V2VerifiedHead,
4968    baseline: Option<&V2SyncBaseline>,
4969    carried: Option<V2LocalView>,
4970) -> LinkResult<V2LocalView> {
4971    match carried {
4972        // The pull half already verified and removed the generated scoped
4973        // projection before constructing its in-memory handoff. Requiring it
4974        // a second time would turn every scoped bidirectional write into a
4975        // false tamper refusal.
4976        Some(view) => Ok(view),
4977        None => {
4978            let mut view = v2_local_files(store)?;
4979            remove_scoped_projection(head, baseline, &mut view)?;
4980            Ok(view)
4981        }
4982    }
4983}
4984
4985fn files_for_v2_view(
4986    head: &V2VerifiedHead,
4987    mut files: std::collections::BTreeMap<String, V2BaselineFile>,
4988) -> std::collections::BTreeMap<String, V2BaselineFile> {
4989    if head.view_kind == "scoped" {
4990        // Even when a grant explicitly includes canonical DB.md, a partial
4991        // checkout uses a generated marker. Canonical validation remains a
4992        // server-side operation over the complete brain.
4993        files.remove("DB.md");
4994    }
4995    files
4996}
4997
4998fn parse_v2_baseline(cfg: &HubConfig, brain: &str, bytes: &[u8]) -> LinkResult<V2SyncBaseline> {
4999    let baseline: V2SyncBaseline =
5000        serde_json::from_slice(bytes).map_err(|_| invalid_feed("v2 sync baseline is corrupt"))?;
5001    if baseline.v != 2
5002        || baseline.origin != normalized_origin(&cfg.hub)?
5003        || baseline.brain != brain
5004        || baseline
5005            .commit_hash
5006            .as_deref()
5007            .is_some_and(|hash| !is_sha256(hash))
5008        || baseline
5009            .content_root
5010            .as_deref()
5011            .is_some_and(|hash| !is_sha256(hash))
5012        || baseline
5013            .asset_root
5014            .as_deref()
5015            .is_some_and(|hash| !is_sha256(hash))
5016        || baseline
5017            .local_policy_digest
5018            .as_deref()
5019            .is_some_and(|hash| !is_sha256(hash))
5020        || baseline
5021            .view_kind
5022            .as_deref()
5023            .is_some_and(|kind| !matches!(kind, "full" | "scoped"))
5024        || baseline
5025            .view_revision
5026            .as_deref()
5027            .is_some_and(|hash| !is_sha256(hash))
5028        || baseline
5029            .control_revision
5030            .as_deref()
5031            .is_some_and(|hash| !is_sha256(hash))
5032        || baseline
5033            .projection_sha256
5034            .as_deref()
5035            .is_some_and(|hash| !is_sha256(hash))
5036        || (baseline.view_kind.as_deref() == Some("scoped")
5037            && (baseline.view_revision.is_none() || baseline.projection_sha256.is_none()))
5038        || baseline.files.len() > MAX_PUSH_FILES
5039        || baseline.assets.len() > MAX_PUSH_FILES
5040        || baseline.local_eligibility.len() > MAX_PUSH_FILES
5041        || baseline.remote_copy_remains.len() > MAX_PUSH_FILES
5042        || baseline.files.iter().any(|(path, file)| {
5043            crate::linkmd_v2::normalize_path(path).is_err()
5044                || !is_sha256(&file.sha256)
5045                || file.bytes > MAX_STORE_BYTES
5046        })
5047        || baseline.assets.iter().any(|(path, asset)| {
5048            crate::linkmd_v2::normalize_path(path).is_err()
5049                || !is_sha256(&asset.blob_sha256)
5050                || !is_sha256(&asset.leaf_hash)
5051                || asset.bytes > MAX_ASSET_BYTES
5052                || !matches!(asset.disposition.as_str(), "hosted" | "withheld")
5053                || asset.wrappers.is_empty()
5054                || asset
5055                    .wrappers
5056                    .iter()
5057                    .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
5058        })
5059        || baseline
5060            .local_eligibility
5061            .keys()
5062            .chain(baseline.remote_copy_remains.keys())
5063            .any(|path| crate::linkmd_v2::normalize_path(path).is_err())
5064        || baseline
5065            .remote_copy_remains
5066            .values()
5067            .any(|hash| !is_sha256(hash))
5068        || baseline
5069            .checkout_id
5070            .as_deref()
5071            .is_some_and(|checkout_id| !is_sha256(checkout_id))
5072    {
5073        return Err(invalid_feed("v2 sync baseline failed validation"));
5074    }
5075    Ok(baseline)
5076}
5077
5078#[cfg(unix)]
5079fn load_v2_baseline(
5080    cfg: &HubConfig,
5081    brain: &str,
5082    checkout: &Path,
5083) -> LinkResult<Option<V2SyncBaseline>> {
5084    use std::os::fd::{AsRawFd as _, FromRawFd as _};
5085    let directory = open_trust_dir(cfg)?;
5086    let name_string = v2_baseline_name(cfg, brain, checkout)?;
5087    let _lock = lock_trust_name(&directory, &name_string)?;
5088    let name = c_name(name_string.as_bytes(), &name_string)?;
5089    let fd = unsafe {
5090        libc::openat(
5091            directory.as_raw_fd(),
5092            name.as_ptr(),
5093            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5094        )
5095    };
5096    if fd < 0 {
5097        let error = std::io::Error::last_os_error();
5098        return if error.kind() == std::io::ErrorKind::NotFound {
5099            Ok(None)
5100        } else {
5101            Err(LinkError::UnsafePath { path: name_string })
5102        };
5103    }
5104    let file = unsafe { std::fs::File::from_raw_fd(fd) };
5105    let mut bytes = Vec::new();
5106    file.take(MAX_FEED_RESPONSE_BYTES + 1)
5107        .read_to_end(&mut bytes)?;
5108    if bytes.len() as u64 > MAX_FEED_RESPONSE_BYTES {
5109        return Err(invalid_feed("v2 sync baseline is oversized"));
5110    }
5111    Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?))
5112}
5113
5114#[cfg(windows)]
5115fn load_v2_baseline(
5116    cfg: &HubConfig,
5117    brain: &str,
5118    checkout: &Path,
5119) -> LinkResult<Option<V2SyncBaseline>> {
5120    let directory = open_trust_dir(cfg)?;
5121    let name = v2_baseline_name(cfg, brain, checkout)?;
5122    let _lock = lock_trust_name(&directory, &name)?;
5123    let mut reader = crate::fsx::BoundedDirReader::from_root(&directory)?;
5124    match reader.read(Path::new(&name), MAX_FEED_RESPONSE_BYTES) {
5125        Ok(bytes) => Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?)),
5126        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
5127        Err(_) => Err(LinkError::UnsafePath { path: name }),
5128    }
5129}
5130
5131#[cfg(not(any(unix, windows)))]
5132fn load_v2_baseline(
5133    _cfg: &HubConfig,
5134    _brain: &str,
5135    _checkout: &Path,
5136) -> LinkResult<Option<V2SyncBaseline>> {
5137    Err(LinkError::UnsupportedPlatform {
5138        operation: "verified link.md v2 baseline",
5139    })
5140}
5141
5142#[cfg(unix)]
5143fn save_v2_baseline(
5144    cfg: &HubConfig,
5145    brain: &str,
5146    checkout: &Path,
5147    baseline: &V2SyncBaseline,
5148) -> LinkResult<()> {
5149    use std::os::fd::{AsRawFd as _, FromRawFd as _};
5150    let directory = open_trust_dir(cfg)?;
5151    let name_string = v2_baseline_name(cfg, brain, checkout)?;
5152    let _lock = lock_trust_name(&directory, &name_string)?;
5153    let name = c_name(name_string.as_bytes(), &name_string)?;
5154    let mut bytes = serde_json::to_vec(baseline)
5155        .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5156    bytes.push(b'\n');
5157    let temp_string = format!(
5158        ".{name_string}.tmp.{}-{}",
5159        std::process::id(),
5160        std::time::SystemTime::now()
5161            .duration_since(std::time::UNIX_EPOCH)
5162            .unwrap_or_default()
5163            .as_nanos()
5164    );
5165    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
5166    let fd = unsafe {
5167        libc::openat(
5168            directory.as_raw_fd(),
5169            temp.as_ptr(),
5170            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5171            0o600,
5172        )
5173    };
5174    if fd < 0 {
5175        return Err(std::io::Error::last_os_error().into());
5176    }
5177    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
5178    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
5179        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5180        return Err(error.into());
5181    }
5182    drop(file);
5183    if unsafe {
5184        libc::renameat(
5185            directory.as_raw_fd(),
5186            temp.as_ptr(),
5187            directory.as_raw_fd(),
5188            name.as_ptr(),
5189        )
5190    } != 0
5191    {
5192        let error = std::io::Error::last_os_error();
5193        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5194        return Err(error.into());
5195    }
5196    directory.sync_all()?;
5197    Ok(())
5198}
5199
5200#[cfg(windows)]
5201fn save_v2_baseline(
5202    cfg: &HubConfig,
5203    brain: &str,
5204    checkout: &Path,
5205    baseline: &V2SyncBaseline,
5206) -> LinkResult<()> {
5207    let directory = open_trust_dir(cfg)?;
5208    let name = v2_baseline_name(cfg, brain, checkout)?;
5209    let _lock = lock_trust_name(&directory, &name)?;
5210    let mut bytes = serde_json::to_vec(baseline)
5211        .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5212    bytes.push(b'\n');
5213    crate::fsx::write_atomic_beneath(&directory, Path::new(&name), &bytes, false, true)?;
5214    Ok(())
5215}
5216
5217#[cfg(not(any(unix, windows)))]
5218fn save_v2_baseline(
5219    _cfg: &HubConfig,
5220    _brain: &str,
5221    _checkout: &Path,
5222    _baseline: &V2SyncBaseline,
5223) -> LinkResult<()> {
5224    Err(LinkError::UnsupportedPlatform {
5225        operation: "verified link.md v2 baseline",
5226    })
5227}
5228
5229fn v2_baseline_from_head(
5230    cfg: &HubConfig,
5231    head: &V2VerifiedHead,
5232    files: std::collections::BTreeMap<String, V2BaselineFile>,
5233    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
5234    local: Option<&V2LocalView>,
5235    checkout_id: Option<&str>,
5236) -> LinkResult<V2SyncBaseline> {
5237    let mut local_eligibility = local
5238        .map(|view| view.eligibility.clone())
5239        .unwrap_or_default();
5240    if let Some(view) = local {
5241        for path in files.keys() {
5242            local_eligibility
5243                .entry(path.clone())
5244                .or_insert_with(|| !view.policy.keeps_home(path));
5245        }
5246    }
5247    let remote_copy_remains = local_eligibility
5248        .iter()
5249        .filter(|(_, riding)| !**riding)
5250        .filter_map(|(path, _)| {
5251            files
5252                .get(path)
5253                .map(|file| (path.clone(), file.sha256.clone()))
5254        })
5255        .collect();
5256    Ok(V2SyncBaseline {
5257        v: 2,
5258        origin: normalized_origin(&cfg.hub)?,
5259        brain: head.brain_id.clone(),
5260        checkout_id: Some(v2_checkout_id(checkout_id)?),
5261        head_seq: Some(head.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
5262        commit_hash: head
5263            .pointer
5264            .as_ref()
5265            .map(|pointer| pointer.commit_hash.clone()),
5266        content_root: head
5267            .pointer
5268            .as_ref()
5269            .and_then(|pointer| pointer.content_root.clone()),
5270        asset_root: head
5271            .pointer
5272            .as_ref()
5273            .and_then(|pointer| pointer.asset_root.clone()),
5274        assets,
5275        view_kind: Some(head.view_kind.clone()),
5276        view_revision: Some(head.view_revision.clone()),
5277        control_revision: Some(head.control_revision.clone()),
5278        projection_sha256: (head.view_kind == "scoped")
5279            .then(|| scoped_projection_sha256(&head.brain_id)),
5280        files,
5281        local_policy_digest: local.map(|view| view.policy.digest.clone()),
5282        local_eligibility,
5283        remote_copy_remains,
5284    })
5285}
5286
5287fn v2_local_files(store: &Store) -> LinkResult<V2LocalView> {
5288    let policy = crate::linkmd_sync_policy::load(store)
5289        .map_err(|message| LinkError::InvalidPack { message })?;
5290    let asset_paths = crate::assets::read_manifest(store)
5291        .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
5292        .into_iter()
5293        .map(|asset| asset.path)
5294        .collect::<std::collections::BTreeSet<_>>();
5295    let mut result = std::collections::BTreeMap::new();
5296    let mut eligibility = std::collections::BTreeMap::new();
5297    let mut riding_links = Vec::<(String, Vec<String>)>::new();
5298    let mut total = 0_u64;
5299    let mut paths = vec![PathBuf::from("DB.md")];
5300    paths.extend(store.walk()?);
5301    for relative in paths {
5302        let path = relative.to_string_lossy().replace('\\', "/");
5303        // v2 catalogs and asset inventory are materialized/signed separately.
5304        if matches!(path.as_str(), "assets.jsonl" | "index.md" | "index.jsonl") {
5305            continue;
5306        }
5307        if asset_paths.contains(&path) {
5308            continue;
5309        }
5310        crate::linkmd_v2::normalize_path(&path).map_err(|error| LinkError::UnsafePath {
5311            path: error.to_string(),
5312        })?;
5313        let riding = !policy.keeps_home(&path);
5314        eligibility.insert(path.clone(), riding);
5315        if !riding {
5316            continue;
5317        }
5318        let remaining = MAX_STORE_BYTES.saturating_sub(total);
5319        let bytes = store.read_bounded(&relative, remaining)?;
5320        total = total
5321            .checked_add(bytes.len() as u64)
5322            .ok_or_else(|| LinkError::PushTooLarge {
5323                detail: "v2 local byte count overflow".to_string(),
5324            })?;
5325        if total > MAX_STORE_BYTES {
5326            return Err(LinkError::PushTooLarge {
5327                detail: format!("{total} uncompressed bytes"),
5328            });
5329        }
5330        if std::str::from_utf8(&bytes).is_err() {
5331            return Err(LinkError::NotUtf8 { path });
5332        }
5333        let text = std::str::from_utf8(&bytes).expect("UTF-8 was checked");
5334        riding_links.push((path.clone(), crate::store::extract_edge_targets(text)));
5335        result.insert(path, (content_sha256(&bytes), bytes.len() as u64));
5336    }
5337    let kept_home = eligibility
5338        .iter()
5339        .filter(|(_, riding)| !**riding)
5340        .map(|(path, _)| path.clone())
5341        .collect::<std::collections::BTreeSet<_>>();
5342    let mut withheld_links = riding_links
5343        .into_iter()
5344        .flat_map(|(source, targets)| {
5345            let kept_home = &kept_home;
5346            let policy = &policy;
5347            targets.into_iter().filter_map(move |target| {
5348                let target = format!("{target}.md");
5349                // A kept-home target need not be a file on THIS machine. An
5350                // export omits kept-home content by construction, so a store
5351                // restored from one links to names it does not carry — and
5352                // without this the owner's own export could never be pushed
5353                // back, which is the opposite of owning your data. The policy
5354                // is the owner's statement about where a path lives, and it
5355                // stands whether or not this checkout holds a copy. It
5356                // discloses nothing either way: the name is already written in
5357                // the content that links to it.
5358                (kept_home.contains(&target) || policy.keeps_home(&target)).then_some(
5359                    V2WithheldLink {
5360                        source: source.clone(),
5361                        target,
5362                    },
5363                )
5364            })
5365        })
5366        .collect::<Vec<_>>();
5367    withheld_links.sort();
5368    withheld_links.dedup();
5369    Ok(V2LocalView {
5370        riding: result,
5371        eligibility,
5372        policy,
5373        withheld_links,
5374    })
5375}
5376
5377#[derive(Debug, Clone, Deserialize)]
5378struct V2DownloadItem {
5379    path: String,
5380    sha256: String,
5381    bytes: u64,
5382    url: String,
5383    method: String,
5384}
5385
5386#[derive(Debug, Deserialize)]
5387struct V2DownloadWindow {
5388    v: u8,
5389    commit: String,
5390    downloads: Vec<V2DownloadItem>,
5391}
5392
5393#[derive(Debug, Deserialize)]
5394struct V2BulkStreamHeader {
5395    v: u8,
5396    path: String,
5397    sha256: String,
5398    bytes: u64,
5399}
5400
5401fn parse_v2_bulk_stream(
5402    bytes: &[u8],
5403    expected: &[(&String, &V2BaselineFile)],
5404) -> LinkResult<Vec<(String, Vec<u8>)>> {
5405    if !bytes.starts_with(V2_BULK_STREAM_MAGIC) {
5406        return Err(invalid_feed("v2 bulk stream has an invalid magic"));
5407    }
5408    let mut cursor = V2_BULK_STREAM_MAGIC.len();
5409    let mut result = Vec::with_capacity(expected.len());
5410    for (expected_path, expected_file) in expected {
5411        let length_bytes = bytes
5412            .get(cursor..cursor + 4)
5413            .ok_or_else(|| invalid_feed("v2 bulk stream ended before a frame header"))?;
5414        cursor += 4;
5415        let header_len = u32::from_be_bytes(length_bytes.try_into().expect("four bytes")) as usize;
5416        if header_len == 0 || header_len > 4 * 1024 {
5417            return Err(invalid_feed("v2 bulk stream has an invalid frame length"));
5418        }
5419        let header_bytes = bytes
5420            .get(cursor..cursor + header_len)
5421            .ok_or_else(|| invalid_feed("v2 bulk stream ended inside a frame header"))?;
5422        cursor += header_len;
5423        let header: V2BulkStreamHeader = serde_json::from_slice(header_bytes)
5424            .map_err(|_| invalid_feed("v2 bulk stream has an invalid frame header"))?;
5425        if header.v != 2
5426            || &header.path != *expected_path
5427            || header.sha256 != expected_file.sha256
5428            || header.bytes != expected_file.bytes
5429            || header.bytes > V2_BULK_STREAM_CONTENT_BYTES
5430        {
5431            return Err(invalid_feed(
5432                "v2 bulk stream frame differs from its proven manifest entry",
5433            ));
5434        }
5435        let body_len = usize::try_from(header.bytes)
5436            .map_err(|_| invalid_feed("v2 bulk stream frame length overflows this platform"))?;
5437        let body = bytes
5438            .get(cursor..cursor + body_len)
5439            .ok_or_else(|| invalid_feed("v2 bulk stream ended inside file bytes"))?;
5440        cursor += body_len;
5441        if content_sha256(body) != header.sha256 {
5442            return Err(invalid_feed(
5443                "v2 bulk stream file differs from its proven manifest entry",
5444            ));
5445        }
5446        result.push((header.path, body.to_vec()));
5447    }
5448    if bytes.get(cursor..cursor + 4) != Some(&[0, 0, 0, 0]) {
5449        return Err(invalid_feed("v2 bulk stream has no exact end marker"));
5450    }
5451    cursor += 4;
5452    if cursor != bytes.len() {
5453        return Err(invalid_feed("v2 bulk stream carries trailing data"));
5454    }
5455    Ok(result)
5456}
5457
5458fn download_v2_bulk_stream(
5459    cfg: &HubConfig,
5460    brain: &str,
5461    pointer: &V2PointerBody,
5462    pending: &[(&String, &V2BaselineFile)],
5463) -> LinkResult<Vec<(String, Vec<u8>)>> {
5464    let claims = pending
5465        .iter()
5466        .map(|(path, file)| {
5467            Ok(json!({
5468                "path": path,
5469                "sha256": file.sha256,
5470                "bytes": file.bytes,
5471                "proof": file.proof.as_ref().ok_or_else(|| {
5472                    invalid_feed("v2 manifest omitted a bulk-stream proof")
5473                })?,
5474            }))
5475        })
5476        .collect::<LinkResult<Vec<_>>>()?;
5477    let raw = request_raw_retryable_read(
5478        cfg,
5479        "POST",
5480        &format!("/api/hub/brains/{brain}/v2/stream"),
5481        Some(&json!({
5482            "commit": pointer.commit_hash,
5483            "files": claims,
5484        })),
5485        Auth::Required,
5486        V2_BULK_STREAM_RESPONSE_BYTES,
5487    )?;
5488    let body = ensure_raw_ok(raw, "download v2 bulk stream")?;
5489    parse_v2_bulk_stream(&body, pending)
5490}
5491
5492fn request_capped_retryable_read(
5493    cfg: &HubConfig,
5494    method: &str,
5495    path: &str,
5496    body: Option<&Value>,
5497    auth: Auth,
5498    max_response_bytes: u64,
5499) -> LinkResult<HubResponse> {
5500    let raw = request_raw_retryable_read(cfg, method, path, body, auth, max_response_bytes)?;
5501    let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
5502    Ok(HubResponse {
5503        status: raw.status,
5504        body: parsed,
5505    })
5506}
5507
5508fn prepare_v2_downloads(
5509    cfg: &HubConfig,
5510    brain: &str,
5511    pointer: &V2PointerBody,
5512    pending: &[(&String, &V2BaselineFile)],
5513) -> LinkResult<Vec<V2DownloadItem>> {
5514    let mut result = Vec::with_capacity(pending.len());
5515    for chunk in pending.chunks(128) {
5516        let claims = chunk
5517            .iter()
5518            .map(|(path, file)| {
5519                Ok(json!({
5520                    "path": path,
5521                    "sha256": file.sha256,
5522                    "bytes": file.bytes,
5523                    "proof": file.proof.as_ref().ok_or_else(|| {
5524                        invalid_feed("v2 manifest omitted a download proof")
5525                    })?,
5526                }))
5527            })
5528            .collect::<LinkResult<Vec<_>>>()?;
5529        let value = ensure_ok(
5530            request_capped_retryable_read(
5531                cfg,
5532                "POST",
5533                &format!("/api/hub/brains/{brain}/v2/downloads"),
5534                Some(&json!({
5535                    "commit": pointer.commit_hash,
5536                    "files": claims,
5537                })),
5538                Auth::Required,
5539                MAX_FEED_RESPONSE_BYTES,
5540            )?,
5541            "prepare v2 blob downloads",
5542        )?;
5543        let window: V2DownloadWindow = serde_json::from_value(value)
5544            .map_err(|_| invalid_feed("v2 download window has an invalid shape"))?;
5545        if window.v != 2
5546            || window.commit != pointer.commit_hash
5547            || window.downloads.len() != chunk.len()
5548        {
5549            return Err(invalid_feed(
5550                "v2 download window is not bound to the requested files",
5551            ));
5552        }
5553        let mut by_path = window
5554            .downloads
5555            .into_iter()
5556            .map(|item| (item.path.clone(), item))
5557            .collect::<std::collections::BTreeMap<_, _>>();
5558        if by_path.len() != chunk.len() {
5559            return Err(invalid_feed("v2 download window repeats a path"));
5560        }
5561        for (path, file) in chunk {
5562            let item = by_path
5563                .remove(*path)
5564                .ok_or_else(|| invalid_feed("v2 download window omitted a path"))?;
5565            if item.method != "GET"
5566                || item.sha256 != file.sha256
5567                || item.bytes != file.bytes
5568                || item.url.is_empty()
5569            {
5570                return Err(invalid_feed(
5571                    "v2 download capability differs from its proven file",
5572                ));
5573            }
5574            result.push(item);
5575        }
5576    }
5577    Ok(result)
5578}
5579
5580fn prepare_v2_asset_downloads(
5581    cfg: &HubConfig,
5582    brain: &str,
5583    pointer: &V2PointerBody,
5584    pending: &[(&String, &V2BaselineAsset)],
5585) -> LinkResult<Vec<V2DownloadItem>> {
5586    let mut result = Vec::with_capacity(pending.len());
5587    for chunk in pending.chunks(128) {
5588        let claims = chunk
5589            .iter()
5590            .map(|(path, asset)| {
5591                json!({
5592                    "path": path,
5593                    "sha256": asset.blob_sha256,
5594                    "bytes": asset.bytes,
5595                    "leaf_hash": asset.leaf_hash,
5596                })
5597            })
5598            .collect::<Vec<_>>();
5599        let value = ensure_ok(
5600            request_capped_retryable_read(
5601                cfg,
5602                "POST",
5603                &format!("/api/hub/brains/{brain}/v2/assets/downloads"),
5604                Some(&json!({
5605                    "commit": pointer.commit_hash,
5606                    "assets": claims,
5607                })),
5608                Auth::Required,
5609                MAX_FEED_RESPONSE_BYTES,
5610            )?,
5611            "prepare v2 asset downloads",
5612        )?;
5613        let window: V2DownloadWindow = serde_json::from_value(value)
5614            .map_err(|_| invalid_feed("v2 asset download window has an invalid shape"))?;
5615        if window.v != 2
5616            || window.commit != pointer.commit_hash
5617            || window.downloads.len() != chunk.len()
5618        {
5619            return Err(invalid_feed(
5620                "v2 asset download window is not bound to the requested assets",
5621            ));
5622        }
5623        let mut by_path = window
5624            .downloads
5625            .into_iter()
5626            .map(|item| (item.path.clone(), item))
5627            .collect::<std::collections::BTreeMap<_, _>>();
5628        if by_path.len() != chunk.len() {
5629            return Err(invalid_feed("v2 asset download window repeats a path"));
5630        }
5631        for (path, asset) in chunk {
5632            let item = by_path
5633                .remove(*path)
5634                .ok_or_else(|| invalid_feed("v2 asset download window omitted a path"))?;
5635            if item.method != "GET"
5636                || item.sha256 != asset.blob_sha256
5637                || item.bytes != asset.bytes
5638                || item.url.is_empty()
5639            {
5640                return Err(invalid_feed(
5641                    "v2 asset download capability differs from its signed leaf",
5642                ));
5643            }
5644            result.push(item);
5645        }
5646    }
5647    Ok(result)
5648}
5649
5650#[cfg(any(unix, windows))]
5651fn stage_v2_asset_download_window(
5652    cfg: &HubConfig,
5653    brain: &str,
5654    pointer: &V2PointerBody,
5655    cache_dir: &Path,
5656    pending: &[(&String, &V2BaselineAsset)],
5657) -> LinkResult<Vec<V2StagedFile>> {
5658    if pending.is_empty() {
5659        return Ok(Vec::new());
5660    }
5661    if pending.len() > V2_DOWNLOAD_CAPABILITY_FILES {
5662        return Err(invalid_feed("v2 asset capability window is oversized"));
5663    }
5664
5665    let mut last_error = None;
5666    for retry_delay in V2_DOWNLOAD_CAPABILITY_BACKOFF_MS
5667        .iter()
5668        .copied()
5669        .map(Some)
5670        .chain(std::iter::once(None))
5671    {
5672        // This POST rechecks the exact head and current path authority before
5673        // issuing fresh, immutable GET capabilities. Successful prior workers
5674        // are already durable in the content-addressed cache, so retrying the
5675        // complete small window neither redownloads them nor widens access.
5676        let downloads = prepare_v2_asset_downloads(cfg, brain, pointer, pending)?;
5677        let mut unique = std::collections::BTreeMap::<String, V2DownloadItem>::new();
5678        for item in downloads {
5679            match unique.get(&item.sha256) {
5680                Some(prior) if prior.bytes != item.bytes => {
5681                    return Err(invalid_feed(
5682                        "one v2 asset hash has conflicting byte lengths",
5683                    ));
5684                }
5685                Some(_) => {}
5686                None => {
5687                    unique.insert(item.sha256.clone(), item);
5688                }
5689            }
5690        }
5691        let downloads = unique.into_values().collect::<Vec<_>>();
5692        let next = std::sync::atomic::AtomicUsize::new(0);
5693        let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
5694        let mut results = std::iter::repeat_with(|| None)
5695            .take(downloads.len())
5696            .collect::<Vec<Option<LinkResult<PathBuf>>>>();
5697        std::thread::scope(|scope| {
5698            let (sender, receiver) = std::sync::mpsc::channel();
5699            for _ in 0..worker_count {
5700                let sender = sender.clone();
5701                let downloads = &downloads;
5702                let next = &next;
5703                scope.spawn(move || loop {
5704                    let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5705                    let Some(item) = downloads.get(index) else {
5706                        break;
5707                    };
5708                    let result = download_presigned_to_cache(
5709                        cfg,
5710                        &item.url,
5711                        cache_dir,
5712                        &item.sha256,
5713                        item.bytes,
5714                    );
5715                    if sender.send((index, result)).is_err() {
5716                        break;
5717                    }
5718                });
5719            }
5720            drop(sender);
5721            for (index, result) in receiver {
5722                results[index] = Some(result);
5723            }
5724        });
5725
5726        let mut failed = None;
5727        for result in results {
5728            match result {
5729                Some(Ok(_)) => {}
5730                Some(Err(error)) if failed.is_none() => failed = Some(error),
5731                Some(Err(_)) => {}
5732                None if failed.is_none() => {
5733                    failed = Some(LinkError::Transport {
5734                        hub: cfg.hub.clone(),
5735                        message: "a bounded v2 asset worker stopped before reporting its result"
5736                            .to_string(),
5737                    });
5738                }
5739                None => {}
5740            }
5741        }
5742        if let Some(error) = failed {
5743            last_error = Some(error);
5744            if let Some(milliseconds) = retry_delay {
5745                std::thread::sleep(std::time::Duration::from_millis(milliseconds));
5746                continue;
5747            }
5748            break;
5749        }
5750
5751        return pending
5752            .iter()
5753            .map(|(path, asset)| {
5754                let source = cache_dir.join(&asset.blob_sha256);
5755                if !cached_blob_is_exact(&source, &asset.blob_sha256, asset.bytes)? {
5756                    return Err(invalid_feed(
5757                        "v2 asset download cache omitted a proven blob",
5758                    ));
5759                }
5760                Ok(V2StagedFile {
5761                    path: (*path).clone(),
5762                    source,
5763                    sha256: asset.blob_sha256.clone(),
5764                    bytes: asset.bytes,
5765                })
5766            })
5767            .collect();
5768    }
5769    Err(last_error
5770        .unwrap_or_else(|| invalid_feed("v2 asset capability window made no download attempt")))
5771}
5772
5773fn download_v2_blob(cfg: &HubConfig, item: &V2DownloadItem) -> LinkResult<Vec<u8>> {
5774    let bytes = get_presigned(cfg, &item.url)?;
5775    if bytes.len() as u64 != item.bytes || content_sha256(&bytes) != item.sha256 {
5776        return Err(invalid_feed("v2 blob differs from its proven path entry"));
5777    }
5778    Ok(bytes)
5779}
5780
5781#[derive(Debug, Clone)]
5782struct V2StagedFile {
5783    path: String,
5784    source: PathBuf,
5785    sha256: String,
5786    bytes: u64,
5787}
5788
5789#[cfg(unix)]
5790fn v2_download_cache_dir(
5791    cfg: &HubConfig,
5792    brain: &str,
5793    pointer: &V2PointerBody,
5794) -> LinkResult<PathBuf> {
5795    v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
5796}
5797
5798#[cfg(unix)]
5799fn v2_download_cache_dir_for(
5800    cfg: &HubConfig,
5801    brain: &str,
5802    transaction: &str,
5803) -> LinkResult<PathBuf> {
5804    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5805        return Err(invalid_feed("v2 download cache address is invalid"));
5806    }
5807    let path = cfg
5808        .state_dir
5809        .join("downloads")
5810        .join(brain)
5811        .join(transaction);
5812    let directory = open_or_create_dir_nofollow(&path)?;
5813    use std::os::fd::AsRawFd as _;
5814    if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
5815        return Err(std::io::Error::last_os_error().into());
5816    }
5817    directory.sync_all()?;
5818    Ok(path)
5819}
5820
5821#[cfg(windows)]
5822fn v2_download_cache_dir(
5823    cfg: &HubConfig,
5824    brain: &str,
5825    pointer: &V2PointerBody,
5826) -> LinkResult<PathBuf> {
5827    v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
5828}
5829
5830#[cfg(windows)]
5831fn v2_download_cache_dir_for(
5832    cfg: &HubConfig,
5833    brain: &str,
5834    transaction: &str,
5835) -> LinkResult<PathBuf> {
5836    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5837        return Err(invalid_feed("v2 download cache address is invalid"));
5838    }
5839    let path = cfg
5840        .state_dir
5841        .join("downloads")
5842        .join(brain)
5843        .join(transaction);
5844    crate::fsx::write_atomic(&path.join(".directory"), b"v2 download cache\n")?;
5845    crate::fsx::open_directory_nofollow(&path)?;
5846    Ok(path)
5847}
5848
5849#[cfg(unix)]
5850fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
5851    use std::os::fd::AsRawFd as _;
5852    let parent = cfg.state_dir.join("downloads").join(brain);
5853    let Ok(directory) = open_existing_dir_nofollow(&parent) else {
5854        return;
5855    };
5856    let Ok(name) = c_name(transaction.as_bytes(), transaction) else {
5857        return;
5858    };
5859    let _ = remove_tree_at(directory.as_raw_fd(), &name, &parent.display().to_string());
5860    let _ = directory.sync_all();
5861}
5862
5863#[cfg(windows)]
5864fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
5865    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5866        return;
5867    }
5868    let parent = cfg.state_dir.join("downloads").join(brain);
5869    let Ok(root) = crate::fsx::open_directory_nofollow(&parent) else {
5870        return;
5871    };
5872    let _ = crate::fsx::remove_tree_beneath(&root, Path::new(transaction));
5873}
5874
5875#[cfg(not(any(unix, windows)))]
5876fn cleanup_v2_download_cache(_cfg: &HubConfig, _brain: &str, _transaction: &str) {}
5877
5878#[cfg(not(any(unix, windows)))]
5879fn v2_download_cache_dir_for(
5880    _cfg: &HubConfig,
5881    _brain: &str,
5882    _transaction: &str,
5883) -> LinkResult<PathBuf> {
5884    Err(LinkError::UnsupportedPlatform {
5885        operation: "resumable v2 download staging",
5886    })
5887}
5888
5889#[cfg(any(unix, windows))]
5890fn cached_blob_is_exact(path: &Path, sha256: &str, bytes: u64) -> LinkResult<bool> {
5891    let file = match crate::fsx::open_regular_nofollow(path) {
5892        Ok(file) => file,
5893        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
5894        Err(error) => return Err(error.into()),
5895    };
5896    if file.metadata()?.len() != bytes {
5897        return Ok(false);
5898    }
5899    Ok(content_sha256_reader(file)? == sha256)
5900}
5901
5902#[cfg(any(unix, windows))]
5903fn cache_v2_blob_bytes(
5904    cache_dir: &Path,
5905    sha256: &str,
5906    expected_bytes: u64,
5907    bytes: &[u8],
5908) -> LinkResult<PathBuf> {
5909    if bytes.len() as u64 != expected_bytes || content_sha256(bytes) != sha256 {
5910        return Err(invalid_feed("v2 cached blob differs from its declaration"));
5911    }
5912    let path = cache_dir.join(sha256);
5913    if !cached_blob_is_exact(&path, sha256, expected_bytes)? {
5914        crate::fsx::write_atomic(&path, bytes)?;
5915    }
5916    Ok(path)
5917}
5918
5919#[cfg(not(any(unix, windows)))]
5920fn cache_v2_blob_bytes(
5921    _cache_dir: &Path,
5922    _sha256: &str,
5923    _expected_bytes: u64,
5924    _bytes: &[u8],
5925) -> LinkResult<PathBuf> {
5926    Err(LinkError::UnsupportedPlatform {
5927        operation: "resumable v2 download staging",
5928    })
5929}
5930
5931#[cfg(unix)]
5932fn download_presigned_to_cache(
5933    cfg: &HubConfig,
5934    url: &str,
5935    cache_dir: &Path,
5936    sha256: &str,
5937    expected_bytes: u64,
5938) -> LinkResult<PathBuf> {
5939    use std::os::fd::{AsRawFd as _, FromRawFd as _};
5940
5941    let target = cache_dir.join(sha256);
5942    if cached_blob_is_exact(&target, sha256, expected_bytes)? {
5943        return Ok(target);
5944    }
5945    let directory = open_existing_dir_nofollow(cache_dir)?;
5946    let mut nonce = [0_u8; 16];
5947    ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut nonce)
5948        .map_err(|_| invalid_feed("could not mint a download cache name"))?;
5949    let temp_string = format!(".download-{}", URL_SAFE_NO_PAD.encode(nonce));
5950    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
5951    let fd = unsafe {
5952        libc::openat(
5953            directory.as_raw_fd(),
5954            temp.as_ptr(),
5955            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5956            0o600,
5957        )
5958    };
5959    if fd < 0 {
5960        return Err(std::io::Error::last_os_error().into());
5961    }
5962    let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
5963    let response = match presigned_agent(cfg, url)?.get(url).call() {
5964        Ok(response) => response,
5965        Err(ureq::Error::Status(_, response)) => {
5966            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5967            return Err(LinkError::Http {
5968                what: "v2 direct download",
5969                status: response.status(),
5970                message: "object store rejected the download".to_string(),
5971                code: None,
5972                details: None,
5973            });
5974        }
5975        Err(ureq::Error::Transport(error)) => {
5976            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5977            return Err(LinkError::Transport {
5978                hub: cfg.hub.clone(),
5979                message: error.to_string(),
5980            });
5981        }
5982    };
5983    let mut reader = response
5984        .into_reader()
5985        .take(expected_bytes.saturating_add(1));
5986    let mut digest = Sha256::new();
5987    let mut total = 0_u64;
5988    let mut buffer = [0_u8; 64 * 1024];
5989    // Reading the socket and writing the file are different failures wearing
5990    // the same io::Error: a stalled download is the peer's problem and a full
5991    // disk is this machine's, and an operator who is told only "timed out"
5992    // cannot tell which one to act on.
5993    let write_result = (|| -> LinkResult<()> {
5994        loop {
5995            let read = reader
5996                .read(&mut buffer)
5997                .map_err(|error| LinkError::Transport {
5998                    hub: cfg.hub.clone(),
5999                    message: error.to_string(),
6000                })?;
6001            if read == 0 {
6002                break;
6003            }
6004            total = total.saturating_add(read as u64);
6005            digest.update(&buffer[..read]);
6006            output.write_all(&buffer[..read])?;
6007        }
6008        output.sync_all().map_err(LinkError::from)
6009    })();
6010    if let Err(error) = write_result {
6011        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6012        return Err(error);
6013    }
6014    drop(output);
6015    if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
6016        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6017        return Err(invalid_feed(
6018            "v2 direct download failed integrity verification",
6019        ));
6020    }
6021    let target_name = c_name(sha256.as_bytes(), sha256)?;
6022    // Replace only a previously inspected cache leaf. Cache bytes are private
6023    // and content-addressed; final install re-verifies them again.
6024    if unsafe {
6025        libc::renameat(
6026            directory.as_raw_fd(),
6027            temp.as_ptr(),
6028            directory.as_raw_fd(),
6029            target_name.as_ptr(),
6030        )
6031    } != 0
6032    {
6033        let error = std::io::Error::last_os_error();
6034        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6035        return Err(error.into());
6036    }
6037    directory.sync_all()?;
6038    Ok(target)
6039}
6040
6041#[cfg(windows)]
6042fn download_presigned_to_cache(
6043    cfg: &HubConfig,
6044    url: &str,
6045    cache_dir: &Path,
6046    sha256: &str,
6047    expected_bytes: u64,
6048) -> LinkResult<PathBuf> {
6049    use std::fs::OpenOptions;
6050
6051    let target = cache_dir.join(sha256);
6052    if cached_blob_is_exact(&target, sha256, expected_bytes)? {
6053        return Ok(target);
6054    }
6055    // Keep the checked directory handle alive while the private cache path is
6056    // used. Windows opens omit FILE_SHARE_DELETE, so neither this directory nor
6057    // an ancestor can be swapped after validation.
6058    let _directory = crate::fsx::open_directory_nofollow(cache_dir)?;
6059    let temp = cache_dir.join(format!(".download-{}", crate::ulid::mint()));
6060    let mut output = OpenOptions::new()
6061        .write(true)
6062        .create_new(true)
6063        .open(&temp)?;
6064    let response = match presigned_agent(cfg, url)?.get(url).call() {
6065        Ok(response) => response,
6066        Err(ureq::Error::Status(_, response)) => {
6067            let _ = std::fs::remove_file(&temp);
6068            return Err(LinkError::Http {
6069                what: "v2 direct download",
6070                status: response.status(),
6071                message: "object store rejected the download".to_string(),
6072                code: None,
6073                details: None,
6074            });
6075        }
6076        Err(ureq::Error::Transport(error)) => {
6077            let _ = std::fs::remove_file(&temp);
6078            return Err(LinkError::Transport {
6079                hub: cfg.hub.clone(),
6080                message: error.to_string(),
6081            });
6082        }
6083    };
6084    let mut reader = response
6085        .into_reader()
6086        .take(expected_bytes.saturating_add(1));
6087    let mut digest = Sha256::new();
6088    let mut total = 0_u64;
6089    let mut buffer = [0_u8; 64 * 1024];
6090    // Same split as the cache-fill path: a stalled socket is not a disk error.
6091    let copied = (|| -> LinkResult<()> {
6092        loop {
6093            let read = reader
6094                .read(&mut buffer)
6095                .map_err(|error| LinkError::Transport {
6096                    hub: cfg.hub.clone(),
6097                    message: error.to_string(),
6098                })?;
6099            if read == 0 {
6100                break;
6101            }
6102            total = total.saturating_add(read as u64);
6103            digest.update(&buffer[..read]);
6104            output.write_all(&buffer[..read])?;
6105        }
6106        output.sync_all()?;
6107        Ok(())
6108    })();
6109    if let Err(error) = copied {
6110        let _ = std::fs::remove_file(&temp);
6111        return Err(error);
6112    }
6113    drop(output);
6114    if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
6115        let _ = std::fs::remove_file(&temp);
6116        return Err(invalid_feed(
6117            "v2 direct download failed integrity verification",
6118        ));
6119    }
6120    if target.exists() {
6121        std::fs::remove_file(&target)?;
6122    }
6123    if let Err(error) = std::fs::rename(&temp, &target) {
6124        let _ = std::fs::remove_file(&temp);
6125        return Err(error.into());
6126    }
6127    Ok(target)
6128}
6129
6130#[cfg(not(any(unix, windows)))]
6131fn download_presigned_to_cache(
6132    _cfg: &HubConfig,
6133    _url: &str,
6134    _cache_dir: &Path,
6135    _sha256: &str,
6136    _expected_bytes: u64,
6137) -> LinkResult<PathBuf> {
6138    Err(LinkError::UnsupportedPlatform {
6139        operation: "resumable v2 download staging",
6140    })
6141}
6142
6143fn download_v2_blobs(
6144    cfg: &HubConfig,
6145    brain: &str,
6146    pointer: &V2PointerBody,
6147    pending: Vec<(&String, &V2BaselineFile)>,
6148) -> LinkResult<Vec<(String, Vec<u8>)>> {
6149    if pending.is_empty() {
6150        return Ok(Vec::new());
6151    }
6152    let expected_order = pending
6153        .iter()
6154        .map(|(path, _)| (*path).clone())
6155        .collect::<Vec<_>>();
6156    let mut streamed = std::collections::BTreeMap::new();
6157    let mut direct = Vec::new();
6158    let mut window = Vec::new();
6159    let mut window_bytes = 0_u64;
6160    let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
6161                 window_bytes: &mut u64,
6162                 streamed: &mut std::collections::BTreeMap<String, Vec<u8>>|
6163     -> LinkResult<()> {
6164        if window.is_empty() {
6165            return Ok(());
6166        }
6167        for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, window)? {
6168            if streamed.insert(path, bytes).is_some() {
6169                return Err(invalid_feed("v2 bulk streams repeated a path"));
6170            }
6171        }
6172        window.clear();
6173        *window_bytes = 0;
6174        Ok(())
6175    };
6176    for &(path, file) in &pending {
6177        if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
6178            flush(&mut window, &mut window_bytes, &mut streamed)?;
6179            direct.push((path, file));
6180            continue;
6181        }
6182        if window.len() == V2_BULK_STREAM_FILES
6183            || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
6184        {
6185            flush(&mut window, &mut window_bytes, &mut streamed)?;
6186        }
6187        window.push((path, file));
6188        window_bytes += file.bytes;
6189    }
6190    flush(&mut window, &mut window_bytes, &mut streamed)?;
6191
6192    let downloads = prepare_v2_downloads(cfg, brain, pointer, &direct)?;
6193    let next = std::sync::atomic::AtomicUsize::new(0);
6194    let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
6195    let mut results = std::iter::repeat_with(|| None)
6196        .take(downloads.len())
6197        .collect::<Vec<Option<LinkResult<(String, Vec<u8>)>>>>();
6198    std::thread::scope(|scope| {
6199        let (sender, receiver) = std::sync::mpsc::channel();
6200        for _ in 0..worker_count {
6201            let sender = sender.clone();
6202            let downloads = &downloads;
6203            let next = &next;
6204            scope.spawn(move || loop {
6205                let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6206                let Some(item) = downloads.get(index) else {
6207                    break;
6208                };
6209                let result = download_v2_blob(cfg, item).map(|bytes| (item.path.clone(), bytes));
6210                if sender.send((index, result)).is_err() {
6211                    break;
6212                }
6213            });
6214        }
6215        drop(sender);
6216        for (index, result) in receiver {
6217            results[index] = Some(result);
6218        }
6219    });
6220    for result in results.into_iter().map(|result| {
6221        result.ok_or_else(|| LinkError::Transport {
6222            hub: cfg.hub.clone(),
6223            message: "a bounded v2 blob worker stopped before reporting its result".to_string(),
6224        })?
6225    }) {
6226        let (path, bytes) = result?;
6227        if streamed.insert(path, bytes).is_some() {
6228            return Err(invalid_feed("v2 download lanes repeated a path"));
6229        }
6230    }
6231    expected_order
6232        .into_iter()
6233        .map(|path| {
6234            streamed
6235                .remove(&path)
6236                .map(|bytes| (path, bytes))
6237                .ok_or_else(|| invalid_feed("v2 download lanes omitted a proven path"))
6238        })
6239        .collect()
6240}
6241
6242/// Resume an exact-head materialization into the private global cache. Each
6243/// verified frame is durable before the next network window begins, so a
6244/// retry skips it and memory never grows with total changed bytes.
6245#[cfg(any(unix, windows))]
6246fn stage_v2_blobs(
6247    cfg: &HubConfig,
6248    brain: &str,
6249    pointer: &V2PointerBody,
6250    pending: Vec<(&String, &V2BaselineFile)>,
6251) -> LinkResult<Vec<V2StagedFile>> {
6252    let cache_dir = v2_download_cache_dir(cfg, brain, pointer)?;
6253    let mut staged = std::collections::BTreeMap::<String, V2StagedFile>::new();
6254    let mut direct = Vec::new();
6255    let mut window = Vec::new();
6256    let mut window_bytes = 0_u64;
6257    let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
6258                 window_bytes: &mut u64,
6259                 staged: &mut std::collections::BTreeMap<String, V2StagedFile>|
6260     -> LinkResult<()> {
6261        if window.is_empty() {
6262            return Ok(());
6263        }
6264        let missing = window
6265            .iter()
6266            .filter_map(|(path, file)| {
6267                let target = cache_dir.join(&file.sha256);
6268                match cached_blob_is_exact(&target, &file.sha256, file.bytes) {
6269                    Ok(true) => {
6270                        staged.insert(
6271                            (*path).clone(),
6272                            V2StagedFile {
6273                                path: (*path).clone(),
6274                                source: target,
6275                                sha256: file.sha256.clone(),
6276                                bytes: file.bytes,
6277                            },
6278                        );
6279                        None
6280                    }
6281                    Ok(false) => Some(Ok((*path, *file))),
6282                    Err(error) => Some(Err(error)),
6283                }
6284            })
6285            .collect::<LinkResult<Vec<_>>>()?;
6286        if !missing.is_empty() {
6287            for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, &missing)? {
6288                let file = missing
6289                    .iter()
6290                    .find_map(|(expected_path, file)| (*expected_path == &path).then_some(*file))
6291                    .ok_or_else(|| invalid_feed("v2 stream returned an unrequested cache path"))?;
6292                let source = cache_v2_blob_bytes(&cache_dir, &file.sha256, file.bytes, &bytes)?;
6293                staged.insert(
6294                    path.clone(),
6295                    V2StagedFile {
6296                        path,
6297                        source,
6298                        sha256: file.sha256.clone(),
6299                        bytes: file.bytes,
6300                    },
6301                );
6302            }
6303        }
6304        window.clear();
6305        *window_bytes = 0;
6306        Ok(())
6307    };
6308    for &(path, file) in &pending {
6309        if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
6310            flush(&mut window, &mut window_bytes, &mut staged)?;
6311            direct.push((path, file));
6312            continue;
6313        }
6314        if window.len() == V2_BULK_STREAM_FILES
6315            || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
6316        {
6317            flush(&mut window, &mut window_bytes, &mut staged)?;
6318        }
6319        window.push((path, file));
6320        window_bytes += file.bytes;
6321    }
6322    flush(&mut window, &mut window_bytes, &mut staged)?;
6323    for item in prepare_v2_downloads(cfg, brain, pointer, &direct)? {
6324        let source =
6325            download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
6326        staged.insert(
6327            item.path.clone(),
6328            V2StagedFile {
6329                path: item.path,
6330                source,
6331                sha256: item.sha256,
6332                bytes: item.bytes,
6333            },
6334        );
6335    }
6336    pending
6337        .into_iter()
6338        .map(|(path, _)| {
6339            staged
6340                .remove(path)
6341                .ok_or_else(|| invalid_feed("v2 download cache omitted a proven path"))
6342        })
6343        .collect()
6344}
6345
6346#[cfg(not(any(unix, windows)))]
6347fn stage_v2_blobs(
6348    _cfg: &HubConfig,
6349    _brain: &str,
6350    _pointer: &V2PointerBody,
6351    _pending: Vec<(&String, &V2BaselineFile)>,
6352) -> LinkResult<Vec<V2StagedFile>> {
6353    Err(LinkError::UnsupportedPlatform {
6354        operation: "resumable v2 download staging",
6355    })
6356}
6357
6358const V2_CONFLICT_BUNDLE_MAX: usize = 32;
6359const V2_CONFLICT_BUNDLE_TTL_SECS: u64 = 7 * 24 * 60 * 60;
6360const V2_CONFLICT_REMOTE_BYTES_MAX: u64 = 64 * 1024 * 1024;
6361
6362#[derive(Debug, Clone, Deserialize, Serialize)]
6363struct V2ConflictCoordinate {
6364    sha256: Option<String>,
6365    bytes: Option<u64>,
6366    file: Option<String>,
6367}
6368
6369#[derive(Debug, Clone, Deserialize, Serialize)]
6370struct V2ConflictFile {
6371    path: String,
6372    base: V2ConflictCoordinate,
6373    local: V2ConflictCoordinate,
6374    remote: V2ConflictCoordinate,
6375}
6376
6377#[derive(Debug, Clone, Deserialize, Serialize)]
6378struct V2ConflictPlan {
6379    v: u8,
6380    class: String,
6381    bundle: String,
6382    brain: String,
6383    origin: String,
6384    created_unix: u64,
6385    expires_unix: u64,
6386    base_seq: Option<u64>,
6387    base_commit: Option<String>,
6388    remote_seq: u64,
6389    remote_commit: Option<String>,
6390    remote_content_root: Option<String>,
6391    view_kind: String,
6392    view_revision: String,
6393    files: Vec<V2ConflictFile>,
6394}
6395
6396fn v2_take_remote_selection(
6397    files: &[V2ConflictFile],
6398    current: &std::collections::BTreeMap<String, V2BaselineFile>,
6399) -> LinkResult<(
6400    std::collections::BTreeMap<String, V2BaselineFile>,
6401    Vec<String>,
6402)> {
6403    let mut selected = std::collections::BTreeMap::new();
6404    let mut deleted = Vec::new();
6405    for file in files {
6406        match (&file.remote.sha256, file.remote.bytes) {
6407            (Some(sha256), Some(bytes)) => {
6408                let proven = current.get(&file.path).ok_or_else(|| {
6409                    invalid_feed("conflict remote coordinate disappeared from the exact head")
6410                })?;
6411                if proven.sha256 != *sha256 || proven.bytes != bytes {
6412                    return Err(invalid_feed(
6413                        "conflict remote coordinate differs from the exact head",
6414                    ));
6415                }
6416                if selected.insert(file.path.clone(), proven.clone()).is_some() {
6417                    return Err(invalid_feed("conflict plan repeats a remote coordinate"));
6418                }
6419            }
6420            (None, None) => {
6421                if current.contains_key(&file.path) {
6422                    return Err(invalid_feed(
6423                        "conflict remote deletion differs from the exact head",
6424                    ));
6425                }
6426                deleted.push(file.path.clone());
6427            }
6428            _ => return Err(invalid_feed("conflict remote coordinate is incomplete")),
6429        }
6430    }
6431    Ok((selected, deleted))
6432}
6433
6434fn v2_conflict_relative(bundle: &str, suffix: &str) -> PathBuf {
6435    PathBuf::from(".dbmd")
6436        .join("conflicts")
6437        .join(bundle)
6438        .join(suffix)
6439}
6440
6441fn read_historical_conflict_blob(
6442    cfg: &HubConfig,
6443    brain: &str,
6444    baseline: &V2SyncBaseline,
6445    path: &str,
6446    file: &V2BaselineFile,
6447) -> LinkResult<Option<Vec<u8>>> {
6448    let (Some(seq), Some(commit)) = (baseline.head_seq, baseline.commit_hash.as_deref()) else {
6449        return Ok(None);
6450    };
6451    if seq == 0 {
6452        return Ok(None);
6453    }
6454    let encoded_path: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
6455    let endpoint = format!(
6456        "/api/hub/brains/{brain}/v2/history/blob?seq={seq}&commit={commit}&path={encoded_path}&sha256={}",
6457        file.sha256
6458    );
6459    let raw = request_raw(cfg, "GET", &endpoint, None, Auth::Required, file.bytes)?;
6460    if raw.status == 404 || raw.status == 403 {
6461        return Ok(None);
6462    }
6463    let bytes = ensure_raw_ok(raw, "v2 conflict base")?;
6464    if bytes.len() as u64 != file.bytes || content_sha256(&bytes) != file.sha256 {
6465        return Err(invalid_feed(
6466            "v2 conflict base failed integrity verification",
6467        ));
6468    }
6469    Ok(Some(bytes))
6470}
6471
6472/// Persist a readable content conflict without modifying either side. The
6473/// plan is written last, so an interrupted bundle build is never actionable.
6474fn create_v2_conflict_bundle(
6475    cfg: &HubConfig,
6476    store: &Store,
6477    head: &V2VerifiedHead,
6478    baseline: Option<&V2SyncBaseline>,
6479    local: &std::collections::BTreeMap<String, (String, u64)>,
6480    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6481    paths: &[String],
6482) -> LinkResult<(String, Vec<String>)> {
6483    let conflicts_root = Path::new(".dbmd/conflicts");
6484    store.create_dir_all(conflicts_root)?;
6485    let completed = store
6486        .directory_names(conflicts_root)?
6487        .into_iter()
6488        .filter(|name| name.to_str().is_some_and(crate::ulid::is_ulid))
6489        .count();
6490    if completed >= V2_CONFLICT_BUNDLE_MAX {
6491        return Err(LinkError::InvalidPack {
6492            message: format!(
6493                "private conflict cache has {completed} bundles; resolve or prune one before syncing"
6494            ),
6495        });
6496    }
6497
6498    // A conflict bundle is diagnostic state, not a second whole-brain cache.
6499    // Materialize a deterministic prefix whose remote artifacts fit one
6500    // bounded window. Resolving it exposes the next prefix on the next sync.
6501    let mut selected_paths = Vec::new();
6502    let mut selected_remote_bytes = 0_u64;
6503    for path in paths {
6504        let bytes = remote.get(path).map_or(0, |file| file.bytes);
6505        if !selected_paths.is_empty()
6506            && selected_remote_bytes.saturating_add(bytes) > V2_CONFLICT_REMOTE_BYTES_MAX
6507        {
6508            break;
6509        }
6510        selected_remote_bytes = selected_remote_bytes.saturating_add(bytes);
6511        selected_paths.push(path.clone());
6512        if selected_remote_bytes >= V2_CONFLICT_REMOTE_BYTES_MAX {
6513            break;
6514        }
6515    }
6516    if selected_paths.is_empty() {
6517        return Err(invalid_feed("content conflict set is empty"));
6518    }
6519    let bundle = crate::ulid::mint();
6520    let bundle_root = v2_conflict_relative(&bundle, "");
6521    store.create_dir_all(&bundle_root.join("files"))?;
6522    let pointer = head.pointer.as_ref();
6523    let remote_bytes = match pointer {
6524        Some(pointer) => download_v2_blobs(
6525            cfg,
6526            &head.brain_id,
6527            pointer,
6528            selected_paths
6529                .iter()
6530                .filter_map(|path| {
6531                    remote
6532                        .get(path)
6533                        .filter(|file| file.bytes <= V2_CONFLICT_REMOTE_BYTES_MAX)
6534                        .map(|file| (path, file))
6535                })
6536                .collect(),
6537        )?
6538        .into_iter()
6539        .collect::<std::collections::BTreeMap<_, _>>(),
6540        None => std::collections::BTreeMap::new(),
6541    };
6542
6543    let mut files = Vec::with_capacity(selected_paths.len());
6544    for (index, path) in selected_paths.iter().enumerate() {
6545        let base_file = baseline.and_then(|state| state.files.get(path));
6546        let base_bytes = match (baseline, base_file) {
6547            (Some(state), Some(file)) => {
6548                read_historical_conflict_blob(cfg, &head.brain_id, state, path, file)?
6549            }
6550            _ => None,
6551        };
6552        let local_file = local.get(path);
6553        let remote_file = remote.get(path);
6554        let remote_content = remote_bytes.get(path);
6555        let prefix = format!("files/{index:04}");
6556        let base_name = base_bytes.as_ref().map(|_| format!("{prefix}.base"));
6557        let local_name = local_file.as_ref().map(|_| format!("{prefix}.local"));
6558        let remote_name = remote_content.as_ref().map(|_| format!("{prefix}.remote"));
6559        if let (Some(name), Some(bytes)) = (&base_name, &base_bytes) {
6560            store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
6561        }
6562        if let (Some(name), Some((expected_hash, expected_bytes))) = (&local_name, local_file) {
6563            let bytes = store.read_bounded(Path::new(path), *expected_bytes)?;
6564            if bytes.len() as u64 != *expected_bytes || content_sha256(&bytes) != *expected_hash {
6565                return Err(LinkError::InvalidPack {
6566                    message: format!("local conflict path `{path}` changed while bundling"),
6567                });
6568            }
6569            store.write_atomic_new(&v2_conflict_relative(&bundle, name), &bytes)?;
6570        }
6571        if let (Some(name), Some(bytes)) = (&remote_name, remote_content) {
6572            store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
6573        }
6574        files.push(V2ConflictFile {
6575            path: path.clone(),
6576            base: V2ConflictCoordinate {
6577                sha256: base_file.map(|file| file.sha256.clone()),
6578                bytes: base_file.map(|file| file.bytes),
6579                file: base_name,
6580            },
6581            local: V2ConflictCoordinate {
6582                sha256: local_file.map(|(sha256, _)| sha256.clone()),
6583                bytes: local_file.map(|(_, bytes)| *bytes),
6584                file: local_name,
6585            },
6586            remote: V2ConflictCoordinate {
6587                sha256: remote_file.map(|file| file.sha256.clone()),
6588                bytes: remote_file.map(|file| file.bytes),
6589                file: remote_name,
6590            },
6591        });
6592    }
6593    let now = SystemTime::now()
6594        .duration_since(UNIX_EPOCH)
6595        .unwrap_or_default()
6596        .as_secs();
6597    let plan = V2ConflictPlan {
6598        v: 2,
6599        class: "content_resolution_required".to_string(),
6600        bundle: bundle.clone(),
6601        brain: head.brain_id.clone(),
6602        origin: normalized_origin(&cfg.hub)?,
6603        created_unix: now,
6604        expires_unix: now.saturating_add(V2_CONFLICT_BUNDLE_TTL_SECS),
6605        base_seq: baseline.and_then(|state| state.head_seq),
6606        base_commit: baseline.and_then(|state| state.commit_hash.clone()),
6607        remote_seq: pointer.map_or(0, |value| value.seq),
6608        remote_commit: pointer.map(|value| value.commit_hash.clone()),
6609        remote_content_root: pointer.and_then(|value| value.content_root.clone()),
6610        view_kind: head.view_kind.clone(),
6611        view_revision: head.view_revision.clone(),
6612        files,
6613    };
6614    let mut bytes = serde_json::to_vec_pretty(&plan)
6615        .map_err(|_| invalid_feed("could not serialize v2 conflict plan"))?;
6616    bytes.push(b'\n');
6617    store.write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), &bytes)?;
6618    Ok((bundle, selected_paths))
6619}
6620
6621fn v2_sync_pull_with_resolution(
6622    cfg: &HubConfig,
6623    requested_brain: &str,
6624    expected_head: V2VerifiedHead,
6625    out: Option<&Path>,
6626    take_remote: Option<&std::collections::BTreeSet<String>>,
6627) -> LinkResult<V2PulledSnapshot> {
6628    let dest = out
6629        .map(Path::to_path_buf)
6630        .unwrap_or_else(|| PathBuf::from(requested_brain));
6631    let _operation_lock = lock_v2_sync_operation(cfg, &expected_head.brain_id)?;
6632    recover_v2_pull(cfg, &expected_head.brain_id, &dest)?;
6633    let head = v2_verified_head(cfg, requested_brain)?
6634        .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
6635    if take_remote.is_some() && !same_v2_head(&expected_head, &head) {
6636        return Err(LinkError::RemoteAdvancedDuringSync);
6637    }
6638    let baseline = load_v2_baseline(cfg, &head.brain_id, &dest)?;
6639    ensure_v2_view_compatible(&head, baseline.as_ref())?;
6640    let (remote, remote_assets) = match baseline
6641        .as_ref()
6642        .filter(|state| v2_baseline_matches_head(&head, state))
6643    {
6644        Some(state) => (state.files.clone(), state.assets.clone()),
6645        None => (
6646            files_for_v2_view(
6647                &head,
6648                v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6649            ),
6650            v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6651        ),
6652    };
6653    let local_store = Store::open_strict(&dest).ok();
6654    // A baseline proves this path is an established checkout. Never reinterpret
6655    // a damaged/edited store marker as an empty clone destination: doing so
6656    // would let the pull half erase the local evidence before the push half can
6657    // reject it (most importantly for the generated scoped-view DB.md).
6658    ensure_established_v2_checkout_opened(&head, baseline.as_ref(), local_store.is_some())?;
6659    let mut local_view = local_store.as_ref().map(v2_local_files).transpose()?;
6660    if head.view_kind == "scoped" && baseline.is_none() && local_view.is_some() {
6661        return Err(LinkError::ScopedViewChanged);
6662    }
6663    if let Some(view) = local_view.as_mut() {
6664        remove_scoped_projection(&head, baseline.as_ref(), view)?;
6665    }
6666    let empty_local = std::collections::BTreeMap::new();
6667    let local = local_view
6668        .as_ref()
6669        .map_or(&empty_local, |view| &view.riding);
6670    let kept_home = |path: &str| {
6671        local_view
6672            .as_ref()
6673            .is_some_and(|view| view.policy.keeps_home(path))
6674    };
6675    let empty_base = std::collections::BTreeMap::new();
6676    let base = baseline.as_ref().map_or(&empty_base, |state| &state.files);
6677    let empty_base_assets = std::collections::BTreeMap::new();
6678    let base_assets = baseline
6679        .as_ref()
6680        .map_or(&empty_base_assets, |state| &state.assets);
6681    let mut local_assets = local_store
6682        .as_ref()
6683        .map(v2_local_asset_records)
6684        .transpose()?
6685        .unwrap_or_default();
6686    let mut content_merge = merge_v2_pulled_records(
6687        base,
6688        &remote,
6689        local,
6690        |file, _| (file.sha256.clone(), file.bytes),
6691        |file, _| (file.sha256.clone(), file.bytes),
6692        kept_home,
6693    );
6694    if let Some(selected) = take_remote {
6695        for path in selected {
6696            if let Some(position) = content_merge
6697                .conflicts
6698                .iter()
6699                .position(|conflict| conflict == path)
6700            {
6701                content_merge.conflicts.remove(position);
6702                content_merge.accept_remote.insert(path.clone());
6703                match remote.get(path) {
6704                    Some(file) => {
6705                        content_merge
6706                            .records
6707                            .insert(path.clone(), (file.sha256.clone(), file.bytes));
6708                    }
6709                    None => {
6710                        content_merge.records.remove(path);
6711                    }
6712                }
6713            } else if !content_merge.accept_remote.contains(path) {
6714                return Err(LinkError::InvalidPack {
6715                    message: format!(
6716                        "take-remote path `{path}` is no longer at its conflict coordinate"
6717                    ),
6718                });
6719            }
6720        }
6721    }
6722    if !content_merge.conflicts.is_empty() {
6723        let mut conflicts = content_merge.conflicts.clone();
6724        conflicts.truncate(100);
6725        if let Some(store) = local_store.as_ref() {
6726            let (bundle, paths) = create_v2_conflict_bundle(
6727                cfg,
6728                store,
6729                &head,
6730                baseline.as_ref(),
6731                local,
6732                &remote,
6733                &conflicts,
6734            )?;
6735            return Err(LinkError::ConflictBundle { bundle, paths });
6736        }
6737        return Err(LinkError::Conflict { paths: conflicts });
6738    }
6739    let asset_merge = merge_v2_pulled_records(
6740        base_assets,
6741        &remote_assets,
6742        &local_assets,
6743        v2_asset_record,
6744        v2_asset_record,
6745        |_| false,
6746    );
6747    if !asset_merge.conflicts.is_empty() {
6748        let mut conflicts = asset_merge.conflicts.clone();
6749        conflicts.truncate(100);
6750        return Err(LinkError::Conflict { paths: conflicts });
6751    }
6752    let pointer = head.pointer.as_ref();
6753    let cache_transaction = pointer.map_or_else(
6754        || content_sha256(format!("empty\0{}", head.view_revision).as_bytes()),
6755        |value| value.commit_hash.clone(),
6756    );
6757    let cache_dir = v2_download_cache_dir_for(cfg, &head.brain_id, &cache_transaction)?;
6758    let mut changed = match pointer {
6759        Some(pointer) => stage_v2_blobs(
6760            cfg,
6761            &head.brain_id,
6762            pointer,
6763            remote
6764                .iter()
6765                .filter(|(path, file)| {
6766                    content_merge.accept_remote.contains(*path)
6767                        && local.get(*path).map(|value| value.0.as_str())
6768                            != Some(file.sha256.as_str())
6769                })
6770                .collect(),
6771        )?,
6772        None => Vec::new(),
6773    };
6774    let mut deleted = content_merge
6775        .accept_remote
6776        .iter()
6777        .filter(|path| !remote.contains_key(*path) && local.contains_key(*path))
6778        .cloned()
6779        .collect::<Vec<_>>();
6780    if local_assets != asset_merge.records {
6781        if asset_merge.records.is_empty() {
6782            deleted.push("assets.jsonl".to_string());
6783        } else {
6784            let bytes = v2_asset_record_manifest_bytes(&asset_merge.records)?;
6785            let sha256 = content_sha256(&bytes);
6786            let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
6787            changed.push(V2StagedFile {
6788                path: "assets.jsonl".to_string(),
6789                source,
6790                sha256,
6791                bytes: bytes.len() as u64,
6792            });
6793        }
6794    }
6795    if let Some(pointer) = pointer {
6796        let mut pending_assets = Vec::new();
6797        for (path, asset) in &remote_assets {
6798            if asset.disposition != "hosted"
6799                || kept_home(path)
6800                || !asset_merge.accept_remote.contains(path)
6801            {
6802                continue;
6803            }
6804            let already_current = local_store.as_ref().is_some_and(|store| {
6805                matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6806                    && store
6807                        .read_bounded(Path::new(path), asset.bytes)
6808                        .ok()
6809                        .is_some_and(|bytes| {
6810                            bytes.len() as u64 == asset.bytes
6811                                && content_sha256(&bytes) == asset.blob_sha256
6812                        })
6813            });
6814            if !already_current {
6815                pending_assets.push((path, asset));
6816            }
6817        }
6818        let mut window = Vec::new();
6819        let mut window_bytes = 0_u64;
6820        let flush = |window: &mut Vec<(&String, &V2BaselineAsset)>,
6821                     window_bytes: &mut u64,
6822                     changed: &mut Vec<V2StagedFile>|
6823         -> LinkResult<()> {
6824            changed.extend(stage_v2_asset_download_window(
6825                cfg,
6826                &head.brain_id,
6827                pointer,
6828                &cache_dir,
6829                window,
6830            )?);
6831            window.clear();
6832            *window_bytes = 0;
6833            Ok(())
6834        };
6835        for item @ (_, asset) in pending_assets {
6836            if !window.is_empty()
6837                && (window.len() == V2_DOWNLOAD_CAPABILITY_FILES
6838                    || window_bytes.saturating_add(asset.bytes) > V2_DOWNLOAD_CAPABILITY_BYTES)
6839            {
6840                flush(&mut window, &mut window_bytes, &mut changed)?;
6841            }
6842            window.push(item);
6843            window_bytes = window_bytes.saturating_add(asset.bytes);
6844            if window_bytes >= V2_DOWNLOAD_CAPABILITY_BYTES {
6845                flush(&mut window, &mut window_bytes, &mut changed)?;
6846            }
6847        }
6848        flush(&mut window, &mut window_bytes, &mut changed)?;
6849    }
6850    for (path, prior) in base_assets {
6851        if remote_assets.contains_key(path)
6852            || kept_home(path)
6853            || !asset_merge.accept_remote.contains(path)
6854        {
6855            continue;
6856        }
6857        let unchanged = local_store.as_ref().is_some_and(|store| {
6858            matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6859                && store
6860                    .read_bounded(Path::new(path), prior.bytes)
6861                    .ok()
6862                    .is_some_and(|bytes| content_sha256(&bytes) == prior.blob_sha256)
6863        });
6864        if unchanged {
6865            deleted.push(path.clone());
6866        }
6867    }
6868    let extra_local = content_merge
6869        .records
6870        .keys()
6871        .filter(|path| !remote.contains_key(*path))
6872        .cloned()
6873        .collect::<Vec<_>>();
6874    if head.view_kind == "scoped" {
6875        for (path, bytes) in [
6876            ("DB.md".to_string(), scoped_projection_bytes(&head.brain_id)),
6877            (
6878                ".dbmd/view.json".to_string(),
6879                scoped_view_metadata(&head, remote.len())?,
6880            ),
6881        ] {
6882            let sha256 = content_sha256(&bytes);
6883            let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
6884            changed.push(V2StagedFile {
6885                path,
6886                source,
6887                sha256,
6888                bytes: bytes.len() as u64,
6889            });
6890        }
6891    }
6892    let install_changed = !changed.is_empty() || !deleted.is_empty();
6893    install_pulled_delta_sources(&dest, &changed, &deleted, true, baseline.as_ref(), &head)?;
6894    let finalized = (|| -> LinkResult<(bool, V2LocalView, std::collections::BTreeMap<String, crate::AssetRecord>)> {
6895        let installed_store =
6896            Store::open_strict(&dest).map_err(|error| LinkError::InvalidPack {
6897                message: format!("installed v2 checkout is not a valid db.md store: {error}"),
6898            })?;
6899        let installed_local = if install_changed {
6900            let mut scanned = v2_local_files(&installed_store)?;
6901            remove_scoped_projection(&head, baseline.as_ref(), &mut scanned)?;
6902            scanned
6903        } else {
6904            local_view
6905                .take()
6906                .ok_or_else(|| invalid_feed("an unchanged pull has no installed local view"))?
6907        };
6908        if installed_local.riding != content_merge.records {
6909            return Err(LinkError::InvalidPack {
6910                message: "local content changed while installing the v2 pull".to_string(),
6911            });
6912        }
6913        let installed_assets = if install_changed {
6914            v2_local_asset_records(&installed_store)?
6915        } else {
6916            std::mem::take(&mut local_assets)
6917        };
6918        if installed_assets != asset_merge.records {
6919            return Err(LinkError::InvalidPack {
6920                message: "local assets changed while installing the v2 pull".to_string(),
6921            });
6922        }
6923        let local_dirty = !v2_riding_matches_remote(&installed_local.riding, &remote, |path| {
6924            installed_local.policy.keeps_home(path)
6925        })
6926            || !v2_asset_records_match_remote(&installed_assets, &remote_assets);
6927        let final_head = v2_verified_head(cfg, requested_brain)?
6928            .ok_or_else(|| invalid_feed("v2 head disappeared during pull"))?;
6929        if !same_v2_head(&head, &final_head) {
6930            return Err(LinkError::RemoteAdvancedDuringSync);
6931        }
6932        accept_v2_head(cfg, &final_head)?;
6933        save_v2_baseline(
6934            cfg,
6935            &head.brain_id,
6936            &dest,
6937            &v2_baseline_from_head(
6938                cfg,
6939                &head,
6940                remote.clone(),
6941                remote_assets.clone(),
6942                Some(&installed_local),
6943                baseline
6944                    .as_ref()
6945                    .and_then(|current| current.checkout_id.as_deref()),
6946            )?,
6947        )?;
6948        complete_v2_pull(&dest)?;
6949        Ok((local_dirty, installed_local, installed_assets))
6950    })();
6951    let (local_dirty, installed_local, installed_assets) = match finalized {
6952        Ok(value) => value,
6953        Err(error) => {
6954            if let Err(recovery) = recover_v2_pull(cfg, &head.brain_id, &dest) {
6955                return Err(LinkError::InvalidPack {
6956                    message: format!("{error}; durable pull recovery also failed: {recovery}"),
6957                });
6958            }
6959            return Err(error);
6960        }
6961    };
6962    cleanup_v2_download_cache(cfg, &head.brain_id, &cache_transaction);
6963    let report = PullReport {
6964        brain: head.brain_id.clone(),
6965        slug: requested_brain.to_string(),
6966        head_seq: pointer.map_or(0, |value| value.seq),
6967        files: remote.len() + remote_assets.len(),
6968        dest: dest.to_string_lossy().into_owned(),
6969        extra_local,
6970        sync_status: if local_dirty {
6971            "local_dirty_after_install".to_string()
6972        } else {
6973            "synced".to_string()
6974        },
6975    };
6976    Ok(V2PulledSnapshot {
6977        report,
6978        head,
6979        files: remote,
6980        assets: remote_assets,
6981        local: installed_local,
6982        local_assets: installed_assets,
6983    })
6984}
6985
6986fn v2_sync_pull(
6987    cfg: &HubConfig,
6988    requested_brain: &str,
6989    head: V2VerifiedHead,
6990    out: Option<&Path>,
6991) -> LinkResult<PullReport> {
6992    Ok(v2_sync_pull_with_resolution(cfg, requested_brain, head, out, None)?.report)
6993}
6994
6995fn v2_expected(remote: Option<&V2BaselineFile>) -> Value {
6996    match remote {
6997        Some(file) => json!({ "kind": "blob", "hash": file.sha256 }),
6998        None => json!({ "kind": "absent" }),
6999    }
7000}
7001
7002fn v2_asset_expected(remote: Option<&V2BaselineAsset>) -> Value {
7003    match remote {
7004        Some(asset) => json!({ "kind": "asset", "hash": asset.leaf_hash }),
7005        None => json!({ "kind": "absent" }),
7006    }
7007}
7008
7009fn v2_content_withdrawal_operation(
7010    store: &Store,
7011    local_view: &V2LocalView,
7012    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7013    path: &str,
7014    reason: &str,
7015) -> LinkResult<Value> {
7016    if (!(path.starts_with("records/") || path.starts_with("sources/")) || !path.ends_with(".md"))
7017        || path == "DB.md"
7018    {
7019        return Err(LinkError::InvalidPack {
7020            message: format!("content withdrawal path `{path}` is not a record or source"),
7021        });
7022    }
7023    if !local_view.policy.keeps_home(path)
7024        || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
7025    {
7026        return Err(LinkError::InvalidPack {
7027            message: format!(
7028                "withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
7029            ),
7030        });
7031    }
7032    let current = remote.get(path).ok_or_else(|| LinkError::InvalidPack {
7033        message: format!("withdrawal path `{path}` has no readable hosted coordinate"),
7034    })?;
7035    verify_v2_upload_source(store, path, &current.sha256, current.bytes)?;
7036    Ok(json!({
7037        "op": "withdraw_from_hosting",
7038        "path": path,
7039        "expected": { "kind": "blob", "hash": current.sha256 },
7040        "reason": reason,
7041    }))
7042}
7043
7044fn v2_asset_withdrawal_operation(
7045    store: &Store,
7046    local_view: &V2LocalView,
7047    path: &str,
7048    local: &crate::AssetRecord,
7049    current: &V2BaselineAsset,
7050    reason: &str,
7051) -> LinkResult<Value> {
7052    if !local_view.policy.keeps_home(path)
7053        || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
7054    {
7055        return Err(LinkError::InvalidPack {
7056            message: format!(
7057                "asset withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
7058            ),
7059        });
7060    }
7061    verify_v2_upload_source(store, path, &local.sha256, local.bytes)?;
7062    if current.disposition != "hosted" || v2_asset_record(current, path) != *local {
7063        return Err(LinkError::InvalidPack {
7064            message: format!(
7065                "asset withdrawal path `{path}` must exactly match its currently hosted signed leaf"
7066            ),
7067        });
7068    }
7069    Ok(json!({
7070        "op": "asset_withdraw",
7071        "path": path,
7072        "expected": v2_asset_expected(Some(current)),
7073        "reason": reason,
7074    }))
7075}
7076
7077// A source is immutable, but its exact bytes may move from an intake namespace
7078// to an archive namespace under the distinct `promote_source` capability. A
7079// plain filesystem move appears to sync as one absent old path plus one new
7080// path. Preserve that intent as a protocol rename only when the hash has one
7081// unambiguous source delete and one unambiguous absent-destination source put.
7082// Ambiguous duplicate bytes stay as put/delete and fail closed at the hub.
7083fn infer_exact_source_promotions(operations: Vec<Value>) -> Vec<Value> {
7084    let mut deletes = std::collections::BTreeMap::<String, Vec<(usize, String)>>::new();
7085    let mut puts = std::collections::BTreeMap::<String, Vec<(usize, String, u64)>>::new();
7086    for (index, operation) in operations.iter().enumerate() {
7087        match operation.get("op").and_then(Value::as_str) {
7088            Some("delete") => {
7089                let Some(path) = operation.get("path").and_then(Value::as_str) else {
7090                    continue;
7091                };
7092                let Some(hash) = operation
7093                    .get("expected")
7094                    .and_then(|value| value.get("hash"))
7095                    .and_then(Value::as_str)
7096                else {
7097                    continue;
7098                };
7099                if path.starts_with("sources/") {
7100                    deletes
7101                        .entry(hash.to_string())
7102                        .or_default()
7103                        .push((index, path.to_string()));
7104                }
7105            }
7106            Some("put") => {
7107                let Some(path) = operation.get("path").and_then(Value::as_str) else {
7108                    continue;
7109                };
7110                let Some(hash) = operation.get("blob").and_then(Value::as_str) else {
7111                    continue;
7112                };
7113                let Some(bytes) = operation.get("bytes").and_then(Value::as_u64) else {
7114                    continue;
7115                };
7116                let destination_absent = operation
7117                    .get("expected")
7118                    .and_then(|value| value.get("kind"))
7119                    .and_then(Value::as_str)
7120                    == Some("absent");
7121                if path.starts_with("sources/") && destination_absent {
7122                    puts.entry(hash.to_string()).or_default().push((
7123                        index,
7124                        path.to_string(),
7125                        bytes,
7126                    ));
7127                }
7128            }
7129            _ => {}
7130        }
7131    }
7132    let mut rename_at = std::collections::BTreeMap::<usize, Value>::new();
7133    let mut consumed_puts = std::collections::BTreeSet::<usize>::new();
7134    for (hash, source) in deletes {
7135        let Some(destination) = puts.get(&hash) else {
7136            continue;
7137        };
7138        if source.len() != 1 || destination.len() != 1 {
7139            continue;
7140        }
7141        let (delete_index, from) = &source[0];
7142        let (put_index, to, bytes) = &destination[0];
7143        if from == to {
7144            continue;
7145        }
7146        rename_at.insert(
7147            *delete_index,
7148            json!({
7149                "op": "rename",
7150                "from": from,
7151                "to": to,
7152                "expected_from": { "kind": "blob", "hash": hash },
7153                "expected_to": { "kind": "absent" },
7154                "blob": hash,
7155                "bytes": bytes,
7156            }),
7157        );
7158        consumed_puts.insert(*put_index);
7159    }
7160    operations
7161        .into_iter()
7162        .enumerate()
7163        .filter_map(|(index, operation)| {
7164            if let Some(rename) = rename_at.remove(&index) {
7165                Some(rename)
7166            } else if consumed_puts.contains(&index) {
7167                None
7168            } else {
7169                Some(operation)
7170            }
7171        })
7172        .collect()
7173}
7174
7175fn v2_asset_value(record: &crate::AssetRecord, disposition: &str) -> Value {
7176    json!({
7177        "blob_sha256": record.sha256,
7178        "bytes": record.bytes,
7179        "media_type": record.media_type,
7180        "wrappers": record.wrappers,
7181        "required": record.required,
7182        "disposition": disposition,
7183    })
7184}
7185
7186/// Apply the exact operation subset generated by this client to an already
7187/// verified manifest. This avoids downloading the same 50k-path listing again
7188/// after an unrebased commit; a rebased response still re-reads the manifest.
7189fn apply_generated_v2_operations(
7190    operations: &[Value],
7191    local_assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
7192    candidate: &mut std::collections::BTreeMap<String, V2BaselineFile>,
7193    candidate_assets: &mut std::collections::BTreeMap<String, V2BaselineAsset>,
7194) -> LinkResult<bool> {
7195    let mut asset_changed = false;
7196    for operation in operations {
7197        match operation.get("op").and_then(Value::as_str) {
7198            Some("put") => {
7199                let path = operation
7200                    .get("path")
7201                    .and_then(Value::as_str)
7202                    .ok_or_else(|| invalid_feed("v2 put has no path"))?;
7203                let sha256 = operation
7204                    .get("blob")
7205                    .and_then(Value::as_str)
7206                    .ok_or_else(|| invalid_feed("v2 put has no blob"))?;
7207                let bytes = operation
7208                    .get("bytes")
7209                    .and_then(Value::as_u64)
7210                    .ok_or_else(|| invalid_feed("v2 put has no byte count"))?;
7211                candidate.insert(
7212                    path.to_string(),
7213                    V2BaselineFile {
7214                        sha256: sha256.to_string(),
7215                        bytes,
7216                        proof: None,
7217                    },
7218                );
7219            }
7220            Some("rename") => {
7221                let from = operation
7222                    .get("from")
7223                    .and_then(Value::as_str)
7224                    .ok_or_else(|| invalid_feed("v2 rename has no source path"))?;
7225                let to = operation
7226                    .get("to")
7227                    .and_then(Value::as_str)
7228                    .ok_or_else(|| invalid_feed("v2 rename has no destination path"))?;
7229                let sha256 = operation
7230                    .get("blob")
7231                    .and_then(Value::as_str)
7232                    .ok_or_else(|| invalid_feed("v2 rename has no blob"))?;
7233                let bytes = operation
7234                    .get("bytes")
7235                    .and_then(Value::as_u64)
7236                    .ok_or_else(|| invalid_feed("v2 rename has no byte count"))?;
7237                let expected_from = operation
7238                    .get("expected_from")
7239                    .and_then(|expected| expected.get("hash"))
7240                    .and_then(Value::as_str);
7241                let expected_to_absent = operation
7242                    .get("expected_to")
7243                    .and_then(|expected| expected.get("kind"))
7244                    .and_then(Value::as_str)
7245                    == Some("absent");
7246                if from == to
7247                    || !from.starts_with("sources/")
7248                    || !to.starts_with("sources/")
7249                    || expected_from != Some(sha256)
7250                    || !expected_to_absent
7251                    || candidate.contains_key(to)
7252                {
7253                    return Err(invalid_feed("generated v2 source rename is malformed"));
7254                }
7255                let source = candidate
7256                    .remove(from)
7257                    .ok_or_else(|| invalid_feed("v2 rename source is absent"))?;
7258                if source.sha256 != sha256 || source.bytes != bytes {
7259                    return Err(invalid_feed(
7260                        "v2 rename source differs from its exact-byte claim",
7261                    ));
7262                }
7263                candidate.insert(
7264                    to.to_string(),
7265                    V2BaselineFile {
7266                        sha256: sha256.to_string(),
7267                        bytes,
7268                        proof: None,
7269                    },
7270                );
7271            }
7272            Some("delete" | "withdraw_from_hosting") => {
7273                let path = operation
7274                    .get("path")
7275                    .and_then(Value::as_str)
7276                    .ok_or_else(|| invalid_feed("v2 delete has no path"))?;
7277                candidate.remove(path);
7278            }
7279            Some("asset_delete") => {
7280                let path = operation
7281                    .get("path")
7282                    .and_then(Value::as_str)
7283                    .ok_or_else(|| invalid_feed("v2 asset delete has no path"))?;
7284                candidate_assets.remove(path);
7285                asset_changed = true;
7286            }
7287            Some("asset_put" | "asset_resume" | "asset_withdraw") => {
7288                let path = operation
7289                    .get("path")
7290                    .and_then(Value::as_str)
7291                    .ok_or_else(|| invalid_feed("v2 asset write has no path"))?;
7292                let record = local_assets
7293                    .get(path)
7294                    .ok_or_else(|| invalid_feed("v2 asset write has no local record"))?;
7295                let disposition =
7296                    if operation.get("op").and_then(Value::as_str) == Some("asset_withdraw") {
7297                        "withheld"
7298                    } else {
7299                        operation
7300                            .get("asset")
7301                            .and_then(|asset| asset.get("disposition"))
7302                            .and_then(Value::as_str)
7303                            .ok_or_else(|| invalid_feed("v2 asset write has no disposition"))?
7304                    };
7305                candidate_assets.insert(
7306                    path.to_string(),
7307                    V2BaselineAsset {
7308                        blob_sha256: record.sha256.clone(),
7309                        bytes: record.bytes,
7310                        media_type: record.media_type.clone(),
7311                        wrappers: record.wrappers.clone(),
7312                        required: record.required,
7313                        disposition: disposition.to_string(),
7314                        // The server assigns the signed leaf address. Callers
7315                        // that need an exact asset manifest re-read it below.
7316                        leaf_hash: String::new(),
7317                    },
7318                );
7319                asset_changed = true;
7320            }
7321            _ => return Err(invalid_feed("dbmd generated an unsupported v2 operation")),
7322        }
7323    }
7324    Ok(asset_changed)
7325}
7326
7327fn v2_riding_matches_remote(
7328    local: &std::collections::BTreeMap<String, (String, u64)>,
7329    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7330    keeps_home: impl Fn(&str) -> bool,
7331) -> bool {
7332    remote.iter().all(|(path, file)| {
7333        keeps_home(path)
7334            || local.get(path).map(|value| value.0.as_str()) == Some(file.sha256.as_str())
7335    }) && local.iter().all(|(path, (hash, _))| {
7336        remote.get(path).map(|file| file.sha256.as_str()) == Some(hash.as_str())
7337    })
7338}
7339
7340fn v2_initial_content_conflicts(
7341    local: &std::collections::BTreeMap<String, (String, u64)>,
7342    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7343    resolving: bool,
7344) -> Vec<String> {
7345    if resolving {
7346        // `sync resolve` already pins the exact remote head/view and rechecks
7347        // every bundled local coordinate before it enters the push planner.
7348        // Treating this as an ordinary baseline-less push would merely mint an
7349        // identical conflict bundle forever. The operation loop below permits
7350        // only those explicit remote replacements plus safe local-only
7351        // additions, and verifies each selected local/remote pair again before
7352        // authoring the commit.
7353        return Vec::new();
7354    }
7355    remote
7356        .iter()
7357        .filter(|(path, file)| {
7358            local.get(*path).map(|value| value.0.as_str()) != Some(file.sha256.as_str())
7359        })
7360        .map(|(path, _)| path.clone())
7361        .collect()
7362}
7363
7364fn v2_resolution_allows_path(
7365    resolution: Option<&std::collections::BTreeMap<String, V2ResolutionOverride>>,
7366    path: &str,
7367    remote_present: bool,
7368) -> bool {
7369    resolution.is_none_or(|allowed| allowed.contains_key(path) || !remote_present)
7370}
7371
7372#[derive(Debug, Clone)]
7373struct V2ResolutionOverride {
7374    expected_remote: Option<String>,
7375    selected_local: Option<String>,
7376}
7377
7378#[derive(Debug, Clone)]
7379struct V2UploadSource {
7380    path: String,
7381    bytes: u64,
7382}
7383
7384struct V2SyncPushOptions<'a> {
7385    resume_local_policy: bool,
7386    bulk_confirmation: Option<&'a V2BulkConfirmation>,
7387    resolution: Option<&'a std::collections::BTreeMap<String, V2ResolutionOverride>>,
7388    pulled: Option<V2PulledSnapshot>,
7389    withdrawal_paths: &'a [String],
7390    withdrawal_reason: Option<&'a str>,
7391}
7392
7393fn verify_v2_upload_source(
7394    store: &Store,
7395    path: &str,
7396    sha256: &str,
7397    expected_bytes: u64,
7398) -> LinkResult<()> {
7399    let file = store.open_regular(Path::new(path))?;
7400    if file.metadata()?.len() != expected_bytes || content_sha256_reader(file)? != sha256 {
7401        return Err(LinkError::InvalidPack {
7402            message: format!("local path `{path}` changed during sync planning"),
7403        });
7404    }
7405    Ok(())
7406}
7407
7408/// One reserved object waiting to be staged, held until its whole batch can be
7409/// uploaded together.
7410struct V2PendingUpload<'a> {
7411    url: String,
7412    headers: Value,
7413    sha256: String,
7414    source: &'a V2UploadSource,
7415}
7416
7417/// How many objects a migration stages at once. Bounded so a first push does
7418/// not behave like a burst against the object store, while still being far
7419/// faster than one-at-a-time.
7420/// Workers staging one batch. With a shared pooled connection the cost of an
7421/// object is the PUT itself rather than a handshake, so a wider pool converts
7422/// directly into throughput on a migration.
7423const V2_UPLOAD_CONCURRENCY: usize = 16;
7424
7425/// Stage a reserved batch, uploading up to `V2_UPLOAD_CONCURRENCY` objects at a
7426/// time and verifying each against the store after it lands. The first failure
7427/// is returned and the remaining work is abandoned.
7428fn upload_v2_batch_concurrently(
7429    cfg: &HubConfig,
7430    store: &Store,
7431    pending: &[V2PendingUpload<'_>],
7432) -> LinkResult<()> {
7433    if pending.is_empty() {
7434        return Ok(());
7435    }
7436    let urls = pending
7437        .iter()
7438        .map(|task| task.url.as_str())
7439        .collect::<Vec<_>>();
7440    let shared = shared_staging_agent(cfg, &urls);
7441    if pending.len() == 1 {
7442        let task = &pending[0];
7443        put_presigned_source(
7444            cfg,
7445            &task.url,
7446            &task.headers,
7447            store,
7448            task.source,
7449            shared.as_ref(),
7450        )?;
7451        return verify_v2_upload_source(store, &task.source.path, &task.sha256, task.source.bytes);
7452    }
7453    let next = std::sync::atomic::AtomicUsize::new(0);
7454    let failure: std::sync::Mutex<Option<LinkError>> = std::sync::Mutex::new(None);
7455    let workers = V2_UPLOAD_CONCURRENCY.min(pending.len());
7456    std::thread::scope(|scope| {
7457        for _ in 0..workers {
7458            scope.spawn(|| loop {
7459                if failure.lock().map(|guard| guard.is_some()).unwrap_or(true) {
7460                    return;
7461                }
7462                let index = next.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
7463                let Some(task) = pending.get(index) else {
7464                    return;
7465                };
7466                let outcome = put_presigned_source(
7467                    cfg,
7468                    &task.url,
7469                    &task.headers,
7470                    store,
7471                    task.source,
7472                    shared.as_ref(),
7473                )
7474                .and_then(|()| {
7475                    verify_v2_upload_source(
7476                        store,
7477                        &task.source.path,
7478                        &task.sha256,
7479                        task.source.bytes,
7480                    )
7481                });
7482                if let Err(error) = outcome {
7483                    if let Ok(mut guard) = failure.lock() {
7484                        guard.get_or_insert(error);
7485                    }
7486                    return;
7487                }
7488            });
7489        }
7490    });
7491    match failure.into_inner() {
7492        Ok(Some(error)) => Err(error),
7493        Ok(None) => Ok(()),
7494        Err(_) => Err(invalid_feed("v2 upload worker state was poisoned")),
7495    }
7496}
7497
7498fn put_presigned_source(
7499    cfg: &HubConfig,
7500    raw: &str,
7501    headers: &Value,
7502    store: &Store,
7503    source: &V2UploadSource,
7504    shared: Option<&ureq::Agent>,
7505) -> LinkResult<()> {
7506    put_presigned_source_with_budget(
7507        cfg,
7508        raw,
7509        headers,
7510        store,
7511        source,
7512        shared,
7513        std::time::Duration::from_secs(UPLOAD_TOTAL_TIMEOUT_SECS),
7514    )
7515}
7516
7517fn put_presigned_source_with_budget(
7518    cfg: &HubConfig,
7519    raw: &str,
7520    headers: &Value,
7521    store: &Store,
7522    source: &V2UploadSource,
7523    shared: Option<&ureq::Agent>,
7524    total_budget: std::time::Duration,
7525) -> LinkResult<()> {
7526    // The URL's own safety checks run either way; only the connection pool is
7527    // shared, so a batch never trades a check for a warm socket.
7528    let owned = match shared {
7529        Some(_) => {
7530            checked_presigned_url(cfg, raw)?;
7531            None
7532        }
7533        None => Some(presigned_agent(cfg, raw)?),
7534    };
7535    let http = shared.unwrap_or_else(|| owned.as_ref().expect("an agent for this upload"));
7536    let deadline = std::time::Instant::now()
7537        .checked_add(total_budget)
7538        .ok_or_else(upload_deadline_error)?;
7539    let mut attempt = 0;
7540    let result = loop {
7541        let file = store.open_regular(Path::new(&source.path))?;
7542        if file.metadata()?.len() != source.bytes {
7543            return Err(LinkError::InvalidPack {
7544                message: format!("local path `{}` changed before upload", source.path),
7545            });
7546        }
7547        // The reservation already states content-length, lowercased. The HTTP
7548        // client de-duplicates headers by exact name, not case, so setting
7549        // `Content-Length` here as well leaves both on the wire and an edge
7550        // rejects the request as malformed. Supply it only when absent.
7551        let mut req = http.put(raw).timeout(upload_attempt_timeout(deadline)?);
7552        let mut has_content_length = false;
7553        if let Some(map) = headers.as_object() {
7554            for (name, value) in map {
7555                if let Some(value) = value.as_str() {
7556                    has_content_length |= name.eq_ignore_ascii_case("content-length");
7557                    req = req.set(name, value);
7558                }
7559            }
7560        }
7561        if !has_content_length {
7562            req = req.set("Content-Length", &source.bytes.to_string());
7563        }
7564        match req.send(file) {
7565            // Every transport failure is retryable here, not only the
7566            // pre-request kinds: staging an object is idempotent (fixed content
7567            // address, exact length and checksum, write-once precondition), the
7568            // file is reopened on each attempt, and a migration holds
7569            // connections long enough that a mid-request drop is ordinary.
7570            Err(ureq::Error::Transport(_)) if wait_for_upload_retry(deadline, attempt) => {
7571                attempt += 1;
7572            }
7573            // A migration stages tens of thousands of immutable objects, and an
7574            // edge or object store may refuse a single one under sustained
7575            // load. The upload is content-addressed and precondition-guarded,
7576            // so replaying it is safe; failing the whole push on one refusal is
7577            // not. 412 already means "someone else stored these exact bytes".
7578            Err(ureq::Error::Status(status, _))
7579                if status != 412
7580                    && is_retryable_upload_status(status)
7581                    && wait_for_upload_retry(deadline, attempt) =>
7582            {
7583                attempt += 1;
7584            }
7585            result => break result,
7586        }
7587    };
7588    match result {
7589        Ok(response) if (200..300).contains(&response.status()) => {
7590            drain_presigned_response(response);
7591            Ok(())
7592        }
7593        Ok(response) => {
7594            // The object store states its own reason (checksum mismatch,
7595            // precondition, expiry). Carrying a bounded excerpt turns an
7596            // unactionable refusal into one an operator can diagnose; the
7597            // coordinate is the store path, never file content.
7598            let status = response.status();
7599            let detail = response
7600                .into_string()
7601                .ok()
7602                .map(|body| body.chars().take(400).collect::<String>())
7603                .filter(|body| !body.trim().is_empty());
7604            Err(LinkError::Http {
7605                what: "v2 changed-byte upload",
7606                status,
7607                message: match detail {
7608                    Some(body) => format!(
7609                        "object store rejected the upload of `{}`: {}",
7610                        source.path,
7611                        body.replace('\n', " ")
7612                    ),
7613                    None => format!("object store rejected the upload of `{}`", source.path),
7614                },
7615                code: None,
7616                details: None,
7617            })
7618        }
7619        Err(error) => match error {
7620            ureq::Error::Status(412, _) => Ok(()),
7621            ureq::Error::Status(_, response) => {
7622                let status = response.status();
7623                let detail = response
7624                    .into_string()
7625                    .ok()
7626                    .map(|body| body.chars().take(400).collect::<String>())
7627                    .filter(|body| !body.trim().is_empty());
7628                Err(LinkError::Http {
7629                    what: "v2 changed-byte upload",
7630                    status,
7631                    message: match detail {
7632                        Some(body) => format!(
7633                            "object store rejected the upload of `{}`: {}",
7634                            source.path,
7635                            body.replace('\n', " ")
7636                        ),
7637                        None => {
7638                            format!("object store rejected the upload of `{}`", source.path)
7639                        }
7640                    },
7641                    code: None,
7642                    details: None,
7643                })
7644            }
7645            ureq::Error::Transport(error) => Err(object_store_transport_error(error)),
7646        },
7647    }
7648}
7649
7650/// The request as it is hashed and signed. A staged push carries its operations
7651/// by address rather than inline, so the signed view restores them: the request
7652/// hash covers the change itself, never the transport that carried it.
7653fn v2_signed_request_view(body: &Value, operations: &[Value]) -> Value {
7654    if body.get("operations").is_some() {
7655        return body.clone();
7656    }
7657    let mut value = body.clone();
7658    if let Some(map) = value.as_object_mut() {
7659        map.remove("staged_change");
7660        map.insert("operations".to_string(), Value::Array(operations.to_vec()));
7661    }
7662    value
7663}
7664
7665/// Open one staging window, waiting out a busy hub rather than abandoning the
7666/// push. The call reserves capacity and mints URLs; it commits nothing, so
7667/// asking again after a refusal costs only the wait.
7668fn reserve_upload_window(
7669    cfg: &HubConfig,
7670    path: &str,
7671    body: &Value,
7672    what: &'static str,
7673) -> LinkResult<Value> {
7674    let mut attempt = 0;
7675    loop {
7676        let pause = |attempt: usize| {
7677            std::thread::sleep(std::time::Duration::from_millis(
7678                RESERVATION_BACKOFF_MS[attempt.min(RESERVATION_BACKOFF_MS.len() - 1)],
7679            ));
7680        };
7681        match request(cfg, "POST", path, Some(body), Auth::Required) {
7682            // A migration opens dozens of windows over tens of minutes, so one
7683            // response that never arrives is ordinary rather than final. The
7684            // call reserves capacity and mints URLs; it commits nothing, so a
7685            // dropped or timed-out attempt is worth asking again.
7686            Err(LinkError::Transport { .. }) if attempt + 1 < RESERVATION_ATTEMPTS => {
7687                pause(attempt);
7688                attempt += 1;
7689            }
7690            Err(error) => return Err(error),
7691            Ok(response) => {
7692                if is_retryable_hub_status(response.status) && attempt + 1 < RESERVATION_ATTEMPTS {
7693                    pause(attempt);
7694                    attempt += 1;
7695                    continue;
7696                }
7697                return ensure_ok(response, what);
7698            }
7699        }
7700    }
7701}
7702
7703/// The exact bytes of a staged change. The hub refuses a manifest carrying any
7704/// other key, so this is the whole contract: operations, and the blob
7705/// references they need, and nothing else.
7706fn v2_change_manifest(operations: &[Value], blobs: Value) -> LinkResult<Vec<u8>> {
7707    let bytes = serde_json::to_vec(&json!({ "operations": operations, "blobs": blobs }))
7708        .map_err(|_| invalid_feed("could not serialize the v2 change manifest"))?;
7709    if bytes.len() > MAX_STAGED_CHANGE_BYTES {
7710        return Err(LinkError::PushTooLarge {
7711            detail: "v2 change metadata exceeds the staged-change ceiling".to_string(),
7712        });
7713    }
7714    Ok(bytes)
7715}
7716
7717/// State one change's operations and blob references through the same
7718/// content-addressed lane its bytes already ride.
7719///
7720/// A first push of a real store names one operation and one blob reference per
7721/// file, which alone outgrows the bounded commit request. Staging that metadata
7722/// and addressing it by hash keeps the push a single atomic commit: the hub
7723/// verifies the address, splices the manifest in, and validates exactly what it
7724/// would have validated inline. The alternative — many partial commits — would
7725/// make a failed migration leave a half-populated brain readable.
7726fn stage_v2_change(
7727    cfg: &HubConfig,
7728    requested_brain: &str,
7729    operations: &[Value],
7730    blobs: Value,
7731) -> LinkResult<Value> {
7732    let bytes = v2_change_manifest(operations, blobs)?;
7733    let sha256 = content_sha256(&bytes);
7734    let reserved = reserve_upload_window(
7735        cfg,
7736        &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
7737        &json!({
7738            "blobs": [{
7739                "sha256": sha256,
7740                "bytes": bytes.len(),
7741                "kind": "staged_change",
7742            }],
7743        }),
7744        "stage the v2 change",
7745    )?;
7746    let items = reserved
7747        .get("uploads")
7748        .and_then(Value::as_array)
7749        .ok_or_else(|| invalid_feed("v2 change staging response has no items"))?;
7750    let [item] = items.as_slice() else {
7751        return Err(invalid_feed(
7752            "v2 change staging response changed the requested set",
7753        ));
7754    };
7755    let reservation_id = item
7756        .get("reservation_id")
7757        .and_then(Value::as_str)
7758        .ok_or_else(|| invalid_feed("v2 change staging has no opaque id"))?;
7759    if item.get("sha256").and_then(Value::as_str) != Some(sha256.as_str())
7760        || item.get("bytes").and_then(Value::as_u64) != Some(bytes.len() as u64)
7761        || !crate::ulid::is_ulid(reservation_id)
7762    {
7763        return Err(invalid_feed("v2 change staging item is inconsistent"));
7764    }
7765    match item.get("status").and_then(Value::as_str) {
7766        Some("upload") => put_presigned(
7767            cfg,
7768            item.get("url")
7769                .and_then(Value::as_str)
7770                .ok_or_else(|| invalid_feed("v2 change staging has no URL"))?,
7771            item.get("headers").unwrap_or(&Value::Null),
7772            &bytes,
7773        )?,
7774        Some("already_present") => {}
7775        _ => return Err(invalid_feed("v2 change staging has an unknown status")),
7776    }
7777    Ok(json!({
7778        "sha256": sha256,
7779        "bytes": bytes.len(),
7780        "reservation_id": reservation_id,
7781    }))
7782}
7783
7784/// Move a commit request's operations and blob references off the wire body
7785/// when they no longer fit in it. Idempotent by construction: the mutation ID
7786/// is derived from the change, never from how the change was transported.
7787fn stage_oversized_v2_change(
7788    cfg: &HubConfig,
7789    requested_brain: &str,
7790    operations: &[Value],
7791    body: &mut Value,
7792) -> LinkResult<()> {
7793    if body.to_string().len() <= MAX_PUSH_BYTES {
7794        return Ok(());
7795    }
7796    let staged = stage_v2_change(
7797        cfg,
7798        requested_brain,
7799        operations,
7800        body.get("blobs")
7801            .cloned()
7802            .unwrap_or(Value::Array(Vec::new())),
7803    )?;
7804    let map = body
7805        .as_object_mut()
7806        .ok_or_else(|| invalid_feed("v2 commit request is not an object"))?;
7807    map.remove("operations");
7808    map.remove("blobs");
7809    map.insert("staged_change".to_string(), staged);
7810    Ok(())
7811}
7812
7813fn v2_sync_push(
7814    cfg: &HubConfig,
7815    requested_brain: &str,
7816    store: &Store,
7817    head: V2VerifiedHead,
7818    options: V2SyncPushOptions<'_>,
7819) -> LinkResult<Value> {
7820    let V2SyncPushOptions {
7821        resume_local_policy,
7822        bulk_confirmation,
7823        resolution,
7824        pulled,
7825        withdrawal_paths,
7826        withdrawal_reason,
7827    } = options;
7828    let _operation_lock = lock_v2_sync_operation(cfg, &head.brain_id)?;
7829    let head = v2_verified_head(cfg, requested_brain)?
7830        .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
7831    let baseline = load_v2_baseline(cfg, &head.brain_id, &store.root)?;
7832    ensure_v2_view_compatible(&head, baseline.as_ref())?;
7833    let (mut remote, mut remote_assets, carried_local, carried_local_assets) =
7834        match pulled.filter(|snapshot| same_v2_head(&snapshot.head, &head)) {
7835            Some(snapshot) => (
7836                snapshot.files,
7837                snapshot.assets,
7838                Some(snapshot.local),
7839                Some(snapshot.local_assets),
7840            ),
7841            None => match baseline
7842                .as_ref()
7843                .filter(|state| v2_baseline_matches_head(&head, state))
7844            {
7845                Some(state) => (state.files.clone(), state.assets.clone(), None, None),
7846                None => (
7847                    files_for_v2_view(
7848                        &head,
7849                        v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7850                    ),
7851                    v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7852                    None,
7853                    None,
7854                ),
7855            },
7856        };
7857    if head.view_kind == "scoped" && baseline.is_none() {
7858        return Err(LinkError::ScopedViewChanged);
7859    }
7860    let local_view = local_view_for_v2_push(store, &head, baseline.as_ref(), carried_local)?;
7861    let local = &local_view.riding;
7862    let local_assets = match carried_local_assets {
7863        Some(assets) => assets,
7864        None => v2_local_asset_records(store)?,
7865    };
7866    if withdrawal_paths.len() > MAX_PUSH_FILES {
7867        return Err(LinkError::PushTooLarge {
7868            detail: "too many explicit withdrawal paths".to_string(),
7869        });
7870    }
7871    let withdrawal_reason = if withdrawal_paths.is_empty() {
7872        None
7873    } else {
7874        let reason = withdrawal_reason
7875            .map(str::trim)
7876            .filter(|reason| !reason.is_empty() && reason.len() <= 1000)
7877            .ok_or_else(|| LinkError::InvalidPack {
7878                message: "explicit withdrawal requires a non-empty --withdraw-reason of at most 1000 bytes".to_string(),
7879            })?;
7880        Some(reason)
7881    };
7882    let mut withdrawals = withdrawal_paths
7883        .iter()
7884        .map(|path| {
7885            crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
7886                path: error.to_string(),
7887            })
7888        })
7889        .collect::<LinkResult<Vec<_>>>()?;
7890    withdrawals.sort();
7891    withdrawals.dedup();
7892    if withdrawals.len() != withdrawal_paths.len() {
7893        return Err(LinkError::InvalidPack {
7894            message: "explicit withdrawal paths must be unique".to_string(),
7895        });
7896    }
7897    let withdrawal_set = withdrawals.iter().cloned().collect::<BTreeSet<_>>();
7898    let mut consumed_withdrawals = BTreeSet::new();
7899    if let Some(previous) = baseline.as_ref() {
7900        if previous.local_policy_digest.as_deref() != Some(&local_view.policy.digest)
7901            && !resume_local_policy
7902        {
7903            let mut newly_eligible = previous
7904                .local_eligibility
7905                .iter()
7906                .filter(|(path, riding)| !**riding && !local_view.policy.keeps_home(path))
7907                .map(|(path, _)| path.clone())
7908                .collect::<Vec<_>>();
7909            if !newly_eligible.is_empty() {
7910                newly_eligible.truncate(100);
7911                return Err(LinkError::LocalPolicyTransition {
7912                    paths: newly_eligible,
7913                });
7914            }
7915        }
7916    }
7917    let base = match baseline.as_ref() {
7918        Some(state) => &state.files,
7919        None if remote.is_empty() => &remote,
7920        None => {
7921            let mut conflicts = v2_initial_content_conflicts(local, &remote, resolution.is_some());
7922            if !conflicts.is_empty() {
7923                conflicts.truncate(100);
7924                let (bundle, paths) =
7925                    create_v2_conflict_bundle(cfg, store, &head, None, local, &remote, &conflicts)?;
7926                return Err(LinkError::ConflictBundle { bundle, paths });
7927            }
7928            &remote
7929        }
7930    };
7931    let all_paths = base
7932        .keys()
7933        .chain(remote.keys())
7934        .chain(local.keys())
7935        .cloned()
7936        .collect::<std::collections::BTreeSet<_>>();
7937    let mut conflicts = Vec::new();
7938    let mut operations = Vec::new();
7939    let mut upload_sources = std::collections::BTreeMap::<String, V2UploadSource>::new();
7940    for path in all_paths {
7941        let base_hash = base.get(&path).map(|file| file.sha256.as_str());
7942        let remote_file = remote.get(&path);
7943        let remote_hash = remote_file.map(|file| file.sha256.as_str());
7944        let local_file = local.get(&path);
7945        let local_hash = local_file.map(|file| file.0.as_str());
7946        if local_hash == base_hash {
7947            continue;
7948        }
7949        if !v2_resolution_allows_path(resolution, &path, remote_file.is_some()) {
7950            continue;
7951        }
7952        if local_view.policy.keeps_home(&path) {
7953            // Kept-home is a local transfer exclusion, never an implicit
7954            // delete of the company's already-hosted coordinate.
7955            continue;
7956        }
7957        if remote_hash != base_hash && local_hash != remote_hash {
7958            let explicitly_resolved = resolution
7959                .and_then(|allowed| allowed.get(&path))
7960                .is_some_and(|selected| {
7961                    selected.expected_remote.as_deref() == remote_hash
7962                        && selected.selected_local.as_deref() == local_hash
7963                });
7964            if !explicitly_resolved {
7965                conflicts.push(path);
7966                continue;
7967            }
7968        }
7969        match local_file {
7970            Some((sha256, byte_count)) => {
7971                verify_v2_upload_source(store, &path, sha256, *byte_count)?;
7972                operations.push(json!({
7973                    "op": "put",
7974                    "path": path,
7975                    "expected": v2_expected(remote_file),
7976                    "blob": sha256,
7977                    "bytes": byte_count,
7978                }));
7979                upload_sources
7980                    .entry(sha256.clone())
7981                    .or_insert_with(|| V2UploadSource {
7982                        path: path.clone(),
7983                        bytes: *byte_count,
7984                    });
7985            }
7986            None => {
7987                let Some(current) = remote_file else {
7988                    continue;
7989                };
7990                operations.push(json!({
7991                    "op": "delete",
7992                    "path": path,
7993                    "expected": { "kind": "blob", "hash": current.sha256 },
7994                }));
7995            }
7996        }
7997    }
7998    operations = infer_exact_source_promotions(operations);
7999    for path in &withdrawals {
8000        if local_assets.contains_key(path) {
8001            continue;
8002        }
8003        operations.push(v2_content_withdrawal_operation(
8004            store,
8005            &local_view,
8006            &remote,
8007            path,
8008            withdrawal_reason.expect("non-empty withdrawal set has a reason"),
8009        )?);
8010        consumed_withdrawals.insert(path.clone());
8011    }
8012    if !conflicts.is_empty() {
8013        conflicts.truncate(100);
8014        let (bundle, paths) = create_v2_conflict_bundle(
8015            cfg,
8016            store,
8017            &head,
8018            baseline.as_ref(),
8019            local,
8020            &remote,
8021            &conflicts,
8022        )?;
8023        return Err(LinkError::ConflictBundle { bundle, paths });
8024    }
8025    let base_assets = match baseline.as_ref() {
8026        Some(state) => &state.assets,
8027        None if remote_assets.is_empty() => &remote_assets,
8028        None => {
8029            let mismatched = remote_assets.iter().any(|(path, remote)| {
8030                local_assets.get(path) != Some(&v2_asset_record(remote, path))
8031            }) || local_assets.len() != remote_assets.len();
8032            if mismatched {
8033                return Err(LinkError::Conflict {
8034                    paths: vec!["assets.jsonl".to_string()],
8035                });
8036            }
8037            &remote_assets
8038        }
8039    };
8040    let asset_paths = base_assets
8041        .keys()
8042        .chain(remote_assets.keys())
8043        .chain(local_assets.keys())
8044        .cloned()
8045        .collect::<std::collections::BTreeSet<_>>();
8046    let mut asset_policy_transitions = Vec::new();
8047    for path in asset_paths {
8048        let base_record = base_assets
8049            .get(&path)
8050            .map(|asset| v2_asset_record(asset, &path));
8051        let remote = remote_assets.get(&path);
8052        let remote_record = remote.map(|asset| v2_asset_record(asset, &path));
8053        let local_record = local_assets.get(&path);
8054        if withdrawal_set.contains(&path) {
8055            let record = local_record.ok_or_else(|| LinkError::InvalidPack {
8056                message: format!("asset withdrawal path `{path}` is absent from assets.jsonl"),
8057            })?;
8058            let current = remote.ok_or_else(|| LinkError::InvalidPack {
8059                message: format!(
8060                    "asset withdrawal path `{path}` has no readable hosted coordinate"
8061                ),
8062            })?;
8063            operations.push(v2_asset_withdrawal_operation(
8064                store,
8065                &local_view,
8066                &path,
8067                record,
8068                current,
8069                withdrawal_reason.expect("non-empty withdrawal set has a reason"),
8070            )?);
8071            consumed_withdrawals.insert(path.clone());
8072            continue;
8073        }
8074        let mut raw_present = false;
8075        let mut disposition = "withheld";
8076        let mut resumes_hosting = false;
8077        if let Some(record) = local_record {
8078            crate::linkmd_v2::normalize_path(&record.path)
8079                .map_err(|error| invalid_feed(error.to_string()))?;
8080            let kept_home = local_view.policy.keeps_home(&path);
8081            raw_present = matches!(store.regular_file_exists(Path::new(&path)), Ok(true));
8082            disposition = if kept_home || !raw_present {
8083                "withheld"
8084            } else {
8085                "hosted"
8086            };
8087            if !raw_present && record.required && !kept_home {
8088                return Err(LinkError::InvalidPack {
8089                    message: format!("required asset {path} is missing"),
8090                });
8091            }
8092            resumes_hosting = v2_asset_resumes_hosting(remote, &path, record, disposition);
8093        }
8094        if local_record == base_record.as_ref() && !resumes_hosting {
8095            continue;
8096        }
8097        if remote_record != base_record && local_record != remote_record.as_ref() {
8098            conflicts.push(path);
8099            continue;
8100        }
8101        let Some(record) = local_record else {
8102            if let Some(remote) = remote {
8103                operations.push(json!({
8104                    "op": "asset_delete",
8105                    "path": path,
8106                    "expected": v2_asset_expected(Some(remote)),
8107                }));
8108            }
8109            continue;
8110        };
8111        let raw = if raw_present {
8112            verify_v2_upload_source(store, &path, &record.sha256, record.bytes)?;
8113            Some(())
8114        } else {
8115            None
8116        };
8117        let op = if resumes_hosting {
8118            if !resume_local_policy {
8119                asset_policy_transitions.push(path);
8120                continue;
8121            }
8122            "asset_resume"
8123        } else {
8124            "asset_put"
8125        };
8126        operations.push(json!({
8127            "op": op,
8128            "path": path,
8129            "expected": v2_asset_expected(remote),
8130            "asset": v2_asset_value(record, disposition),
8131        }));
8132        if disposition == "hosted" {
8133            raw.expect("hosted asset was checked present");
8134            upload_sources
8135                .entry(record.sha256.clone())
8136                .or_insert_with(|| V2UploadSource {
8137                    path: path.clone(),
8138                    bytes: record.bytes,
8139                });
8140        }
8141    }
8142    if consumed_withdrawals != withdrawal_set {
8143        let missing = withdrawal_set
8144            .difference(&consumed_withdrawals)
8145            .next()
8146            .expect("different withdrawal sets have one member");
8147        return Err(LinkError::InvalidPack {
8148            message: format!(
8149                "withdrawal path `{missing}` is not a readable content or asset coordinate"
8150            ),
8151        });
8152    }
8153    if !conflicts.is_empty() {
8154        conflicts.truncate(100);
8155        return Err(LinkError::Conflict { paths: conflicts });
8156    }
8157    if !asset_policy_transitions.is_empty() {
8158        asset_policy_transitions.truncate(100);
8159        return Err(LinkError::LocalPolicyTransition {
8160            paths: asset_policy_transitions,
8161        });
8162    }
8163    let touched_sources = operations
8164        .iter()
8165        .filter_map(
8166            |operation| match operation.get("op").and_then(Value::as_str) {
8167                Some("put" | "restore") => operation.get("path").and_then(Value::as_str),
8168                Some("rename") => operation.get("to").and_then(Value::as_str),
8169                _ => None,
8170            },
8171        )
8172        .collect::<std::collections::BTreeSet<_>>();
8173    let withheld_links = local_view
8174        .withheld_links
8175        .iter()
8176        .filter(|link| touched_sources.contains(link.source.as_str()))
8177        .collect::<Vec<_>>();
8178    let checkout_pseudonym = v2_checkout_id(
8179        baseline
8180            .as_ref()
8181            .and_then(|current| current.checkout_id.as_deref()),
8182    )?;
8183    let checkout_id = if withheld_links.is_empty() {
8184        None
8185    } else {
8186        Some(checkout_pseudonym.clone())
8187    };
8188    if operations.is_empty() {
8189        let final_head = v2_verified_head(cfg, requested_brain)?
8190            .ok_or_else(|| invalid_feed("v2 head disappeared during sync"))?;
8191        if !same_v2_head(&head, &final_head) {
8192            return Err(LinkError::RemoteAdvancedDuringSync);
8193        }
8194        let mut final_local = v2_local_files(store)?;
8195        remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
8196        let final_assets = v2_local_asset_records(store)?;
8197        let local_changed = final_local.riding != local_view.riding || final_assets != local_assets;
8198        let remote_ahead = !v2_riding_matches_remote(&final_local.riding, &remote, |path| {
8199            final_local.policy.keeps_home(path)
8200        }) || !v2_asset_records_match_remote(&final_assets, &remote_assets);
8201        let next = v2_baseline_from_head(
8202            cfg,
8203            &head,
8204            remote,
8205            remote_assets,
8206            Some(&final_local),
8207            Some(&checkout_pseudonym),
8208        )?;
8209        let split_count = next.remote_copy_remains.len();
8210        accept_v2_head(cfg, &final_head)?;
8211        if !local_changed && !remote_ahead {
8212            refresh_scoped_view_marker(store, &head, next.files.len())?;
8213            save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
8214        }
8215        return Ok(json!({
8216            "v": 2,
8217            "outcome": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "no_change" },
8218            "sync_status": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "synced" },
8219            "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
8220            "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
8221            "local_policy": {
8222                "remote_copy_remains": split_count,
8223            },
8224        }));
8225    }
8226    let includes_contract = operations
8227        .iter()
8228        .any(|operation| operation.get("path").and_then(Value::as_str) == Some("DB.md"));
8229    let rebase = if head.pointer.is_none() || includes_contract {
8230        "strict"
8231    } else {
8232        "disjoint"
8233    };
8234    let base_value = head.pointer.as_ref().map(|pointer| {
8235        json!({
8236            "seq": pointer.seq,
8237            "commit_hash": pointer.commit_hash,
8238            "content_root": pointer.content_root,
8239            "asset_root": pointer.asset_root,
8240        })
8241    });
8242    // The mutation coordinate is stable for the exact observed base and local
8243    // operation set. A lost response can therefore be retried without a
8244    // duplicate commit/proposal even across process restarts.
8245    let entropy = format!(
8246        "{}\0{}\0{}\0{}\0{}\0{}",
8247        normalized_origin(&cfg.hub)?,
8248        head.brain_id,
8249        serde_json::to_string(&base_value).unwrap_or_default(),
8250        serde_json::to_string(&operations).unwrap_or_default(),
8251        serde_json::to_string(&withheld_links).unwrap_or_default(),
8252        checkout_id.as_deref().unwrap_or("")
8253    );
8254    let mutation_id = format!("dbmd-{}", content_sha256(entropy.as_bytes()));
8255    let changed_bytes = upload_sources.values().try_fold(0_u64, |total, source| {
8256        total
8257            .checked_add(source.bytes)
8258            .ok_or_else(|| LinkError::PushTooLarge {
8259                detail: "v2 changed-byte total overflow".to_string(),
8260            })
8261    })?;
8262    let inline = changed_bytes <= 3 * 1024 * 1024;
8263    let inline_blobs = if inline {
8264        upload_sources
8265            .iter()
8266            .map(|(sha256, source)| {
8267                let bytes = store.read_bounded(Path::new(&source.path), source.bytes)?;
8268                if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != *sha256 {
8269                    return Err(LinkError::InvalidPack {
8270                        message: format!("local path `{}` changed before upload", source.path),
8271                    });
8272                }
8273                Ok(json!({
8274                    "sha256": sha256,
8275                    "bytes": source.bytes,
8276                    "content_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
8277                }))
8278            })
8279            .collect::<LinkResult<Vec<_>>>()?
8280    } else {
8281        Vec::new()
8282    };
8283    let mut body = json!({
8284        "mutation_id": mutation_id,
8285        "base": base_value,
8286        "rebase": rebase,
8287        "reason": "dbmd sync",
8288        "operations": operations,
8289        "blobs": inline_blobs,
8290    });
8291    if !withheld_links.is_empty() {
8292        body["withheld_links"] = serde_json::to_value(&withheld_links)
8293            .map_err(|_| invalid_feed("could not serialize withheld-link observations"))?;
8294        body["checkout_id"] =
8295            Value::String(checkout_id.expect("non-empty withheld links have a checkout pseudonym"));
8296    }
8297    if let Some(confirmation) = bulk_confirmation {
8298        if !crate::ulid::is_ulid(&confirmation.id) || !is_sha256(&confirmation.digest) {
8299            return Err(LinkError::InvalidPack {
8300                message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
8301                    .to_string(),
8302            });
8303        }
8304        // A preview is bound to the exact observed head. Confirmation can
8305        // therefore never use disjoint rebase semantics, even when the normal
8306        // small-mutation path could.
8307        body["rebase"] = Value::String("strict".to_string());
8308        body["bulk_preview_id"] = Value::String(confirmation.id.clone());
8309        body["bulk_preview_digest"] = Value::String(confirmation.digest.clone());
8310    }
8311    if !inline || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
8312        let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
8313        for operation in &operations {
8314            let Some(kind) = operation.get("op").and_then(Value::as_str) else {
8315                return Err(invalid_feed("v2 upload operation has no kind"));
8316            };
8317            let hash = match kind {
8318                "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
8319                "asset_put" | "asset_resume" => operation
8320                    .get("asset")
8321                    .and_then(|asset| asset.get("blob_sha256"))
8322                    .and_then(Value::as_str),
8323                _ => None,
8324            };
8325            let Some(hash) = hash else { continue };
8326            let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
8327            if kind == "rename" {
8328                for field in ["from", "to"] {
8329                    coordinates.insert(
8330                        operation
8331                            .get(field)
8332                            .and_then(Value::as_str)
8333                            .ok_or_else(|| invalid_feed("v2 rename upload has no coordinate"))?
8334                            .to_string(),
8335                    );
8336                }
8337            } else {
8338                let path = operation
8339                    .get("path")
8340                    .and_then(Value::as_str)
8341                    .ok_or_else(|| invalid_feed("v2 upload has no coordinate"))?;
8342                coordinates.insert(if kind.starts_with("asset_") {
8343                    format!("assets/{path}")
8344                } else {
8345                    path.to_string()
8346                });
8347            }
8348        }
8349        let declarations = upload_sources
8350            .iter()
8351            .map(|(sha256, source)| {
8352                json!({
8353                    "sha256": sha256,
8354                    "bytes": source.bytes,
8355                    "coordinates": coordinates_by_hash
8356                        .get(sha256)
8357                        .into_iter()
8358                        .flatten()
8359                        .collect::<Vec<_>>(),
8360                })
8361            })
8362            .collect::<Vec<_>>();
8363        let mut references = Vec::with_capacity(upload_sources.len());
8364        let mut seen = std::collections::BTreeSet::new();
8365        let mut reserved_count = 0usize;
8366        let mut pending_uploads: Vec<V2PendingUpload<'_>> = Vec::new();
8367        // A reservation's presigned URL is short-lived, so each batch's bytes
8368        // ride before the next batch is reserved. Reserving every blob first
8369        // and uploading afterwards expires the earliest URLs on a real store.
8370        for batch in batch_upload_declarations(declarations) {
8371            let batch_len = batch.len();
8372            let reserved = reserve_upload_window(
8373                cfg,
8374                &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
8375                &json!({ "blobs": batch }),
8376                "prepare v2 changed-byte uploads",
8377            )?;
8378            let items = reserved
8379                .get("uploads")
8380                .and_then(Value::as_array)
8381                .ok_or_else(|| invalid_feed("v2 upload reservation response has no items"))?;
8382            if items.len() != batch_len {
8383                return Err(invalid_feed(
8384                    "v2 upload reservation response changed the requested set",
8385                ));
8386            }
8387            reserved_count += items.len();
8388            for item in items {
8389                let sha256 = item
8390                    .get("sha256")
8391                    .and_then(Value::as_str)
8392                    .ok_or_else(|| invalid_feed("v2 upload reservation has no hash"))?;
8393                let source = upload_sources
8394                    .get(sha256)
8395                    .ok_or_else(|| invalid_feed("v2 upload reservation introduced a blob"))?;
8396                let declared_bytes = item
8397                    .get("bytes")
8398                    .and_then(Value::as_u64)
8399                    .ok_or_else(|| invalid_feed("v2 upload reservation has no byte length"))?;
8400                let reservation_id = item
8401                    .get("reservation_id")
8402                    .and_then(Value::as_str)
8403                    .ok_or_else(|| invalid_feed("v2 upload reservation has no opaque id"))?;
8404                let expected_coordinates = coordinates_by_hash.get(sha256).ok_or_else(|| {
8405                    invalid_feed("v2 upload reservation has no coordinate binding")
8406                })?;
8407                let returned_coordinates = item
8408                    .get("coordinates")
8409                    .and_then(Value::as_array)
8410                    .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinates"))?;
8411                if declared_bytes != source.bytes
8412                    || !crate::ulid::is_ulid(reservation_id)
8413                    || !seen.insert(sha256.to_string())
8414                    || returned_coordinates.len() != expected_coordinates.len()
8415                    || returned_coordinates
8416                        .iter()
8417                        .zip(expected_coordinates)
8418                        .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
8419                {
8420                    return Err(invalid_feed("v2 upload reservation item is inconsistent"));
8421                }
8422                match item.get("status").and_then(Value::as_str) {
8423                    Some("upload") => {
8424                        let url = item
8425                            .get("url")
8426                            .and_then(Value::as_str)
8427                            .ok_or_else(|| invalid_feed("v2 upload reservation has no URL"))?;
8428                        pending_uploads.push(V2PendingUpload {
8429                            url: url.to_string(),
8430                            headers: item.get("headers").cloned().unwrap_or(Value::Null),
8431                            sha256: sha256.to_string(),
8432                            source,
8433                        });
8434                    }
8435                    Some("already_present") => {}
8436                    _ => return Err(invalid_feed("v2 upload reservation has an unknown status")),
8437                }
8438                references.push(json!({
8439                    "sha256": sha256,
8440                    "bytes": source.bytes,
8441                    "reservation_id": reservation_id,
8442                }));
8443            }
8444            // Stage this batch's bytes concurrently. Sequential PUTs move a real
8445            // store at a few objects a second, which both takes hours and pushes
8446            // the batch past its reservation's upload window; the object store
8447            // is content-addressed and precondition-guarded, so parallel writes
8448            // stay safe.
8449            upload_v2_batch_concurrently(cfg, store, &pending_uploads)?;
8450            pending_uploads.clear();
8451        }
8452        if reserved_count != upload_sources.len() {
8453            return Err(invalid_feed(
8454                "v2 upload reservation response changed the requested set",
8455            ));
8456        }
8457        body["blobs"] = Value::Array(references);
8458    }
8459    stage_oversized_v2_change(cfg, requested_brain, &operations, &mut body)?;
8460    let path = format!("/api/hub/brains/{requested_brain}/v2/commits");
8461    let mut candidate_hub_signer: Option<String> = None;
8462    let mut response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
8463    let bulk_preview_required = !(200..300).contains(&response.status)
8464        && response.body.as_ref().is_some_and(|value| {
8465            value.get("code").and_then(Value::as_str) == Some("bulk_preview_required")
8466                || value
8467                    .get("details")
8468                    .and_then(|details| details.get("code"))
8469                    .and_then(Value::as_str)
8470                    == Some("bulk_preview_required")
8471        });
8472    if bulk_preview_required && bulk_confirmation.is_none() {
8473        body["rebase"] = Value::String("strict".to_string());
8474        body["preview_only"] = Value::Bool(true);
8475        let preview = ensure_ok(
8476            request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
8477            "v2 bulk preview",
8478        )?;
8479        let preview_code = preview.get("code").and_then(Value::as_str);
8480        let required = preview.get("required").and_then(Value::as_bool);
8481        if preview.get("v").and_then(Value::as_u64) != Some(2)
8482            || preview.get("mutation_id").and_then(Value::as_str) != Some(mutation_id.as_str())
8483            || !matches!(
8484                preview_code,
8485                Some("bulk_preview_created" | "bulk_preview_not_required")
8486            )
8487            || required.is_none()
8488        {
8489            return Err(invalid_feed(
8490                "bulk preview response is not bound to the requested mutation",
8491            ));
8492        }
8493        if required == Some(true) {
8494            let preview_id = preview.get("bulk_preview_id").and_then(Value::as_str);
8495            let preview_digest = preview.get("bulk_preview_digest").and_then(Value::as_str);
8496            if preview_code != Some("bulk_preview_created")
8497                || preview_id.is_none_or(|value| !crate::ulid::is_ulid(value))
8498                || preview_digest.is_none_or(|value| !is_sha256(value))
8499                || preview.get("expires_at").and_then(Value::as_str).is_none()
8500                || !preview.get("impact").is_some_and(Value::is_object)
8501            {
8502                return Err(invalid_feed("bulk preview receipt is malformed"));
8503            }
8504            return Err(LinkError::BulkPreviewRequired { preview });
8505        }
8506        if preview_code != Some("bulk_preview_not_required") {
8507            return Err(invalid_feed("bulk preview requirement is inconsistent"));
8508        }
8509        // The rolling activity window can expire between the refusal and
8510        // preview. Retry once, still pinned strictly to the observed head.
8511        body.as_object_mut()
8512            .expect("v2 commit request is an object")
8513            .remove("preview_only");
8514        response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
8515    }
8516    let mut result = ensure_ok(response, "v2 sync push")?;
8517    if result.get("code").and_then(Value::as_str) == Some("proposal_queued") {
8518        if let Some(object) = result.as_object_mut() {
8519            object.insert(
8520                "sync_status".to_string(),
8521                Value::String("proposal_pending".to_string()),
8522            );
8523        }
8524        return Ok(result);
8525    }
8526    if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
8527        let request_id = result
8528            .get("request_id")
8529            .and_then(Value::as_str)
8530            .ok_or_else(|| invalid_feed("self-custody response has no request id"))?
8531            .to_string();
8532        let challenge = result
8533            .get("signing_challenge")
8534            .ok_or_else(|| invalid_feed("self-custody response has no signing challenge"))?;
8535        let mut expected_candidate = remote.clone();
8536        let mut expected_candidate_assets = remote_assets.clone();
8537        apply_generated_v2_operations(
8538            &operations,
8539            &local_assets,
8540            &mut expected_candidate,
8541            &mut expected_candidate_assets,
8542        )?;
8543        let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
8544            cfg,
8545            &head,
8546            &expected_candidate,
8547            &expected_candidate_assets,
8548            &mutation_id,
8549            &v2_signed_request_view(&body, &operations),
8550            challenge,
8551        )?;
8552        body["signing_challenge_id"] = Value::String(challenge_id);
8553        body["signature_base64url"] = Value::String(signature);
8554        candidate_hub_signer = Some(actor_signer);
8555        result = ensure_ok(
8556            request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
8557            "v2 self-custody commit",
8558        )?;
8559    }
8560    let refreshed = v2_verified_head(cfg, requested_brain)?
8561        .ok_or_else(|| invalid_feed("v2 head disappeared after commit"))?;
8562    if candidate_hub_signer
8563        .as_ref()
8564        .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
8565    {
8566        return Err(invalid_feed(
8567            "self-custody actor signer differs from the committed hub pointer signer",
8568        ));
8569    }
8570    let accepted_hash = result.get("commit_hash").and_then(Value::as_str);
8571    if refreshed
8572        .pointer
8573        .as_ref()
8574        .map(|pointer| pointer.commit_hash.as_str())
8575        != accepted_hash
8576    {
8577        return Err(LinkError::RemoteAdvancedDuringSync);
8578    }
8579    ensure_v2_view_compatible(&refreshed, baseline.as_ref())?;
8580    let rebased = result
8581        .get("rebased")
8582        .and_then(Value::as_bool)
8583        .ok_or_else(|| invalid_feed("v2 commit receipt has no rebase result"))?;
8584    let (refreshed_files, refreshed_assets) = if rebased {
8585        (
8586            files_for_v2_view(
8587                &refreshed,
8588                v2_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
8589            ),
8590            v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
8591        )
8592    } else {
8593        let asset_changed = apply_generated_v2_operations(
8594            &operations,
8595            &local_assets,
8596            &mut remote,
8597            &mut remote_assets,
8598        )?;
8599        let assets = if asset_changed {
8600            // Asset leaf hashes are server-generated signed coordinates; only
8601            // asset-changing commits need this usually tiny post-commit read.
8602            v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?
8603        } else {
8604            remote_assets
8605        };
8606        (remote, assets)
8607    };
8608    let mut final_local = v2_local_files(store)?;
8609    remove_scoped_projection(&refreshed, baseline.as_ref(), &mut final_local)?;
8610    let final_assets = v2_local_asset_records(store)?;
8611    let local_dirty = final_local.riding != local_view.riding
8612        || !v2_riding_matches_remote(&final_local.riding, &refreshed_files, |path| {
8613            final_local.policy.keeps_home(path)
8614        })
8615        || final_assets != local_assets
8616        || !v2_asset_records_match_remote(&final_assets, &refreshed_assets);
8617    let next = v2_baseline_from_head(
8618        cfg,
8619        &refreshed,
8620        refreshed_files,
8621        refreshed_assets,
8622        Some(&final_local),
8623        Some(&checkout_pseudonym),
8624    )?;
8625    let split_count = next.remote_copy_remains.len();
8626    accept_v2_head(cfg, &refreshed)?;
8627    if !local_dirty {
8628        refresh_scoped_view_marker(store, &refreshed, next.files.len())?;
8629        save_v2_baseline(cfg, &refreshed.brain_id, &store.root, &next)?;
8630    }
8631    if let Some(object) = result.as_object_mut() {
8632        object.insert(
8633            "local_policy".to_string(),
8634            json!({ "remote_copy_remains": split_count }),
8635        );
8636        object.insert(
8637            "sync_status".to_string(),
8638            Value::String(if local_dirty {
8639                "remote_committed_local_dirty".to_string()
8640            } else {
8641                "synced".to_string()
8642            }),
8643        );
8644    }
8645    Ok(result)
8646}
8647
8648/// Negotiate v2 and send only local changes. A v1 hub retains the existing
8649/// whole-snapshot behavior until its brain advertises the new profile.
8650pub fn sync_push_incremental(cfg: &HubConfig, brain: &str, store: &Store) -> LinkResult<Value> {
8651    sync_push_incremental_with_policy(cfg, brain, store, false)
8652}
8653
8654/// The explicit `.sevralocal` adoption form. Ordinary sync never uploads a
8655/// path that became eligible because local policy was weakened or removed.
8656pub fn sync_push_incremental_with_policy(
8657    cfg: &HubConfig,
8658    brain: &str,
8659    store: &Store,
8660    resume_local_policy: bool,
8661) -> LinkResult<Value> {
8662    sync_push_incremental_with_options(cfg, brain, store, resume_local_policy, None)
8663}
8664
8665/// Incremental push with both local-policy adoption and an optional exact
8666/// permissioned bulk-preview confirmation.
8667pub fn sync_push_incremental_with_options(
8668    cfg: &HubConfig,
8669    brain: &str,
8670    store: &Store,
8671    resume_local_policy: bool,
8672    bulk_confirmation: Option<&V2BulkConfirmation>,
8673) -> LinkResult<Value> {
8674    sync_push_incremental_with_controls(
8675        cfg,
8676        brain,
8677        store,
8678        resume_local_policy,
8679        bulk_confirmation,
8680        &[],
8681        None,
8682    )
8683}
8684
8685/// Incremental push with explicit permissioned current-host withdrawal.
8686pub fn sync_push_incremental_with_controls(
8687    cfg: &HubConfig,
8688    brain: &str,
8689    store: &Store,
8690    resume_local_policy: bool,
8691    bulk_confirmation: Option<&V2BulkConfirmation>,
8692    withdrawal_paths: &[String],
8693    withdrawal_reason: Option<&str>,
8694) -> LinkResult<Value> {
8695    require_safe_ref(brain)?;
8696    if let Some(head) = v2_verified_head(cfg, brain)? {
8697        return v2_sync_push(
8698            cfg,
8699            brain,
8700            store,
8701            head,
8702            V2SyncPushOptions {
8703                resume_local_policy,
8704                bulk_confirmation,
8705                resolution: None,
8706                pulled: None,
8707                withdrawal_paths,
8708                withdrawal_reason,
8709            },
8710        );
8711    }
8712    if !withdrawal_paths.is_empty() {
8713        return Err(LinkError::InvalidPack {
8714            message: "explicit withdrawal requires a link.md v2 brain".to_string(),
8715        });
8716    }
8717    legacy_sync_push_incremental(cfg, brain, store, resume_local_policy, bulk_confirmation)
8718}
8719
8720/// Whether this exact hub/brain/path coordinate has an accepted v2 sync
8721/// baseline. CLI dispatch uses this to keep checkout identity sticky even when
8722/// a damaged `DB.md` makes strict store-open fail.
8723pub fn has_v2_sync_baseline(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<bool> {
8724    require_safe_ref(brain)?;
8725    Ok(load_v2_baseline(cfg, brain, checkout)?.is_some())
8726}
8727
8728#[cfg(windows)]
8729fn legacy_sync_push_incremental(
8730    _cfg: &HubConfig,
8731    _brain: &str,
8732    _store: &Store,
8733    _resume_local_policy: bool,
8734    _bulk_confirmation: Option<&V2BulkConfirmation>,
8735) -> LinkResult<Value> {
8736    Err(LinkError::UnsupportedPlatform {
8737        operation: "legacy v1 whole-snapshot push on Windows; upgrade the brain to link.md v2",
8738    })
8739}
8740
8741#[cfg(not(windows))]
8742fn legacy_sync_push_incremental(
8743    cfg: &HubConfig,
8744    brain: &str,
8745    store: &Store,
8746    resume_local_policy: bool,
8747    bulk_confirmation: Option<&V2BulkConfirmation>,
8748) -> LinkResult<Value> {
8749    if resume_local_policy || bulk_confirmation.is_some() {
8750        return Err(LinkError::InvalidPack {
8751            message: "v2 sync options require a link.md v2 brain".to_string(),
8752        });
8753    }
8754    let files = collect_push_files(store)?;
8755    sync_push(cfg, brain, &files)
8756}
8757
8758/// One explicit resolution for a previously preserved private conflict bundle.
8759#[derive(Debug, Clone)]
8760pub enum V2ConflictChoice {
8761    KeepLocal,
8762    TakeRemote,
8763    From(PathBuf),
8764}
8765
8766fn load_v2_conflict_plan(store: &Store, bundle: &str) -> LinkResult<V2ConflictPlan> {
8767    if !crate::ulid::is_ulid(bundle) {
8768        return Err(LinkError::InvalidPack {
8769            message: "conflict bundle must be a lowercase ULID".to_string(),
8770        });
8771    }
8772    let bytes = store.read_bounded(&v2_conflict_relative(bundle, "plan.json"), 1024 * 1024)?;
8773    let plan: V2ConflictPlan = serde_json::from_slice(&bytes)
8774        .map_err(|_| invalid_feed("private conflict plan is corrupt"))?;
8775    if plan.v != 2
8776        || plan.class != "content_resolution_required"
8777        || plan.bundle != bundle
8778        || !crate::ulid::is_ulid(&plan.brain)
8779        || plan.files.is_empty()
8780        || plan.files.len() > 100
8781        || plan.files.iter().any(|file| {
8782            crate::linkmd_v2::normalize_path(&file.path).is_err()
8783                || [&file.base, &file.local, &file.remote]
8784                    .into_iter()
8785                    .any(|coordinate| {
8786                        coordinate
8787                            .sha256
8788                            .as_deref()
8789                            .is_some_and(|hash| !is_sha256(hash))
8790                            || coordinate.file.as_deref().is_some_and(|name| {
8791                                name.starts_with('/')
8792                                    || name
8793                                        .split('/')
8794                                        .any(|part| part.is_empty() || part == "." || part == "..")
8795                            })
8796                    })
8797        })
8798    {
8799        return Err(invalid_feed("private conflict plan failed validation"));
8800    }
8801    Ok(plan)
8802}
8803
8804/// Inspect or prune private conflict-control state. Ordinary pruning removes
8805/// only expired completed bundles and interrupted bundles with no `plan.json`;
8806/// corrupt completed plans fail closed. `all` is an explicit local discard of
8807/// every well-addressed bundle and never changes hosted brain data.
8808pub fn sync_conflicts(checkout: &Path, prune: bool, all: bool) -> LinkResult<Value> {
8809    require_hardened_filesystem("private conflict maintenance")?;
8810    if all && !prune {
8811        return Err(LinkError::InvalidPack {
8812            message: "discarding all conflict bundles requires prune=true".to_string(),
8813        });
8814    }
8815    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8816        message: format!("conflict checkout is not a valid db.md store: {error}"),
8817    })?;
8818    let _transaction = store.transaction()?;
8819    let root = Path::new(".dbmd/conflicts");
8820    let names = match store.directory_names(root) {
8821        Ok(names) => names,
8822        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
8823        Err(error) => return Err(error.into()),
8824    };
8825    let now = SystemTime::now()
8826        .duration_since(UNIX_EPOCH)
8827        .unwrap_or_default()
8828        .as_secs();
8829    let mut bundles = Vec::new();
8830    let mut pruned = 0_u64;
8831    for name in names {
8832        let Some(bundle) = name.to_str().filter(|value| crate::ulid::is_ulid(value)) else {
8833            continue;
8834        };
8835        let plan_path = v2_conflict_relative(bundle, "plan.json");
8836        let plan_exists = store.regular_file_exists(&plan_path)?;
8837        let expired = if plan_exists {
8838            match load_v2_conflict_plan(&store, bundle) {
8839                Ok(plan) => plan.expires_unix < now,
8840                Err(error) if all => {
8841                    let _ = error;
8842                    true
8843                }
8844                Err(error) => return Err(error),
8845            }
8846        } else {
8847            true
8848        };
8849        if prune && (all || expired) {
8850            store.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
8851            pruned += 1;
8852            continue;
8853        }
8854        bundles.push(json!({
8855            "bundle": bundle,
8856            "complete": plan_exists,
8857            "expired": expired,
8858        }));
8859    }
8860    Ok(json!({
8861        "v": 2,
8862        "class": "private_conflict_state",
8863        "bundles": bundles.len(),
8864        "pruned": pruned,
8865        "items": bundles,
8866    }))
8867}
8868
8869/// Resolve one exact private conflict plan. Drift never lowers a precondition:
8870/// the remote head/view and every original local coordinate are rechecked
8871/// before either an explicit local install or a fresh normal commit.
8872pub fn sync_resolve_conflict(
8873    cfg: &HubConfig,
8874    checkout: &Path,
8875    bundle: &str,
8876    choice: V2ConflictChoice,
8877    bulk_confirmation: Option<&V2BulkConfirmation>,
8878) -> LinkResult<Value> {
8879    require_hardened_filesystem("conflict resolution")?;
8880    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8881        message: format!("conflict checkout is not a valid db.md store: {error}"),
8882    })?;
8883    let plan = load_v2_conflict_plan(&store, bundle)?;
8884    if plan.origin != normalized_origin(&cfg.hub)? {
8885        return Err(invalid_feed(
8886            "conflict bundle belongs to another hub origin",
8887        ));
8888    }
8889    let now = SystemTime::now()
8890        .duration_since(UNIX_EPOCH)
8891        .unwrap_or_default()
8892        .as_secs();
8893    if now > plan.expires_unix {
8894        return Err(LinkError::InvalidPack {
8895            message: "conflict bundle expired; rerun sync to obtain current coordinates"
8896                .to_string(),
8897        });
8898    }
8899    let head = v2_verified_head(cfg, &plan.brain)?
8900        .ok_or_else(|| invalid_feed("conflict brain no longer advertises v2"))?;
8901    let pointer = head.pointer.as_ref();
8902    if pointer.map_or(0, |value| value.seq) != plan.remote_seq
8903        || pointer.map(|value| value.commit_hash.as_str()) != plan.remote_commit.as_deref()
8904        || pointer.and_then(|value| value.content_root.as_deref())
8905            != plan.remote_content_root.as_deref()
8906        || head.view_kind != plan.view_kind
8907        || head.view_revision != plan.view_revision
8908    {
8909        return Err(LinkError::RemoteAdvancedDuringSync);
8910    }
8911
8912    // A resolution never overwrites a post-bundle local edit.
8913    for file in &plan.files {
8914        let actual = match store.regular_file_exists(Path::new(&file.path))? {
8915            true => Some(content_sha256(&store.read_bounded(
8916                Path::new(&file.path),
8917                file.local.bytes.unwrap_or(MAX_STORE_BYTES),
8918            )?)),
8919            false => None,
8920        };
8921        if actual.as_deref() != file.local.sha256.as_deref() {
8922            return Err(LinkError::InvalidPack {
8923                message: format!(
8924                    "local conflict path `{}` changed after the bundle was created",
8925                    file.path
8926                ),
8927            });
8928        }
8929    }
8930
8931    let from_source = match &choice {
8932        V2ConflictChoice::From(source) => Some(source.clone()),
8933        _ => None,
8934    };
8935    let result = match choice {
8936        V2ConflictChoice::TakeRemote => {
8937            if bulk_confirmation.is_some() {
8938                return Err(LinkError::InvalidPack {
8939                    message: "bulk confirmation applies to keep-local/from commits, not a local take-remote install".to_string(),
8940                });
8941            }
8942            // Re-read the exact current manifest so every selected remote byte
8943            // retains its signed inclusion proof. The private bundle is useful
8944            // for inspection, but is never promoted into live state as trust.
8945            let current_remote =
8946                files_for_v2_view(&head, v2_manifest(cfg, &plan.brain, head.pointer.as_ref())?);
8947            let _ = v2_take_remote_selection(&plan.files, &current_remote)?;
8948            let selected = plan
8949                .files
8950                .iter()
8951                .map(|file| file.path.clone())
8952                .collect::<std::collections::BTreeSet<_>>();
8953            serde_json::to_value(
8954                v2_sync_pull_with_resolution(
8955                    cfg,
8956                    &plan.brain,
8957                    head,
8958                    Some(checkout),
8959                    Some(&selected),
8960                )?
8961                .report,
8962            )
8963            .map_err(|_| invalid_feed("could not serialize conflict pull receipt"))?
8964        }
8965        V2ConflictChoice::KeepLocal | V2ConflictChoice::From(_) => {
8966            if let Some(source) = from_source.as_ref() {
8967                if plan.files.len() != 1 {
8968                    return Err(LinkError::InvalidPack {
8969                        message: "--from requires a bundle with exactly one conflict".to_string(),
8970                    });
8971                }
8972                let candidate = crate::fsx::read_bounded_nofollow(source, MAX_STORE_BYTES)?;
8973                if std::str::from_utf8(&candidate).is_err() {
8974                    return Err(LinkError::NotUtf8 {
8975                        path: source.display().to_string(),
8976                    });
8977                }
8978                store.write_atomic(Path::new(&plan.files[0].path), &candidate)?;
8979            }
8980            let refreshed_store =
8981                Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8982                    message: format!("resolved checkout is not a valid db.md store: {error}"),
8983                })?;
8984            let mut overrides = std::collections::BTreeMap::new();
8985            for file in &plan.files {
8986                let selected_local = match refreshed_store
8987                    .regular_file_exists(Path::new(&file.path))?
8988                {
8989                    true => Some(content_sha256(
8990                        &refreshed_store.read_bounded(Path::new(&file.path), MAX_STORE_BYTES)?,
8991                    )),
8992                    false => None,
8993                };
8994                overrides.insert(
8995                    file.path.clone(),
8996                    V2ResolutionOverride {
8997                        expected_remote: file.remote.sha256.clone(),
8998                        selected_local,
8999                    },
9000                );
9001            }
9002            v2_sync_push(
9003                cfg,
9004                &plan.brain,
9005                &refreshed_store,
9006                head,
9007                V2SyncPushOptions {
9008                    resume_local_policy: true,
9009                    bulk_confirmation,
9010                    resolution: Some(&overrides),
9011                    pulled: None,
9012                    withdrawal_paths: &[],
9013                    withdrawal_reason: None,
9014                },
9015            )?
9016        }
9017    };
9018
9019    if result.get("code").and_then(Value::as_str) != Some("proposal_queued") {
9020        let installed = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9021            message: format!("resolved checkout is not a valid db.md store: {error}"),
9022        })?;
9023        installed.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
9024    }
9025    Ok(json!({
9026        "v": 2,
9027        "class": "auto_converged",
9028        "bundle": bundle,
9029        "receipt": result,
9030    }))
9031}
9032
9033/// Converge one established permissioned-v2 checkout in both directions.
9034///
9035/// The pull half first performs the ordinary three-way conflict check and
9036/// crash-safe install against the accepted baseline. The push half then opens
9037/// that exact installed checkout, takes its local transaction guard, computes
9038/// a fresh three-way delta, and submits it with mandatory preconditions. A
9039/// remote movement between halves is therefore a normal rebase/conflict input,
9040/// never a blind overwrite. Legacy v1 remains explicit pull-only/push-only so
9041/// its whole-pack replacement semantics are never presented as granular
9042/// convergence.
9043pub fn sync_converge(
9044    cfg: &HubConfig,
9045    brain: &str,
9046    checkout: &Path,
9047    resume_local_policy: bool,
9048) -> LinkResult<Value> {
9049    sync_converge_with_options(cfg, brain, checkout, resume_local_policy, None)
9050}
9051
9052/// Bidirectional convergence with optional exact bulk-preview confirmation.
9053pub fn sync_converge_with_options(
9054    cfg: &HubConfig,
9055    brain: &str,
9056    checkout: &Path,
9057    resume_local_policy: bool,
9058    bulk_confirmation: Option<&V2BulkConfirmation>,
9059) -> LinkResult<Value> {
9060    sync_converge_with_controls(
9061        cfg,
9062        brain,
9063        checkout,
9064        resume_local_policy,
9065        bulk_confirmation,
9066        &[],
9067        None,
9068    )
9069}
9070
9071/// Bidirectional convergence with optional exact withdrawal intents.
9072pub fn sync_converge_with_controls(
9073    cfg: &HubConfig,
9074    brain: &str,
9075    checkout: &Path,
9076    resume_local_policy: bool,
9077    bulk_confirmation: Option<&V2BulkConfirmation>,
9078    withdrawal_paths: &[String],
9079    withdrawal_reason: Option<&str>,
9080) -> LinkResult<Value> {
9081    require_hardened_filesystem("bidirectional sync")?;
9082    require_safe_ref(brain)?;
9083    let head = v2_verified_head(cfg, brain)?.ok_or_else(|| LinkError::InvalidPack {
9084        message:
9085            "bidirectional sync requires link.md v2; use --pull-only or --push-only for a legacy brain"
9086                .to_string(),
9087    })?;
9088    let pulled = v2_sync_pull_with_resolution(cfg, brain, head, Some(checkout), None)?;
9089    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9090        message: format!("installed v2 checkout is not a valid db.md store: {error}"),
9091    })?;
9092    let _transaction = store.transaction()?;
9093    let pulled_report = pulled.report.clone();
9094    let pulled_head = pulled.head.clone();
9095    let mut result = v2_sync_push(
9096        cfg,
9097        brain,
9098        &store,
9099        pulled_head,
9100        V2SyncPushOptions {
9101            resume_local_policy,
9102            bulk_confirmation,
9103            resolution: None,
9104            pulled: Some(pulled),
9105            withdrawal_paths,
9106            withdrawal_reason,
9107        },
9108    )?;
9109    if let Some(object) = result.as_object_mut() {
9110        object.insert("pulled_files".to_string(), json!(pulled_report.files));
9111        object.insert("checkout".to_string(), Value::String(pulled_report.dest));
9112        object.insert(
9113            "mode".to_string(),
9114            Value::String("bidirectional".to_string()),
9115        );
9116    }
9117    Ok(result)
9118}
9119
9120/// Pull the granted slice of `brain` to `out` (default: `./<slug>`). Every
9121/// exported path is safety-gated before it touches disk; files are written
9122/// atomically; nothing local is ever deleted (locals the export lacks are
9123/// *reported* in `extra_local` instead). Returns the report; rebuilding the
9124/// local index catalog afterwards is the caller's (cheap, optional) step.
9125pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
9126    require_hardened_filesystem("sync pull")?;
9127    require_safe_ref(brain)?;
9128    if let Some(head) = v2_verified_head(cfg, brain)? {
9129        return v2_sync_pull(cfg, brain, head, out);
9130    }
9131    legacy_sync_pull(cfg, brain, out)
9132}
9133
9134#[cfg(windows)]
9135fn legacy_sync_pull(_cfg: &HubConfig, _brain: &str, _out: Option<&Path>) -> LinkResult<PullReport> {
9136    Err(LinkError::UnsupportedPlatform {
9137        operation: "legacy v1 whole-snapshot pull on Windows; upgrade the brain to link.md v2",
9138    })
9139}
9140
9141#[cfg(not(windows))]
9142fn legacy_sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
9143    let remote = verified_remote_head(cfg, brain, false)?;
9144    if !remote.head.verified {
9145        return Err(invalid_feed(
9146            "the grant exposes head movement but no signed snapshot; refusing an unverifiable pull",
9147        ));
9148    }
9149    let snapshot_hash = remote.head.feed_hash.as_deref().unwrap_or("none");
9150    let path = format!(
9151        "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={snapshot_hash}",
9152        remote.head.seq
9153    );
9154    let body = ensure_ok(
9155        request(cfg, "GET", &path, None, Auth::Required)?,
9156        "sync pull",
9157    )?;
9158    if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
9159        || body.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
9160    {
9161        return Err(invalid_feed(
9162            "export response is not bound to the verified snapshot token",
9163        ));
9164    }
9165
9166    let remote_slug = body
9167        .get("slug")
9168        .and_then(Value::as_str)
9169        .filter(|slug| is_safe_slug(slug));
9170    let slug = remote_slug
9171        .or_else(|| is_safe_slug(brain).then_some(brain))
9172        .unwrap_or("brain")
9173        .to_string();
9174    let brain_id = body
9175        .get("brain")
9176        .and_then(Value::as_str)
9177        .unwrap_or(&remote.head.brain)
9178        .to_string();
9179    if brain_id != remote.head.brain {
9180        return Err(invalid_feed(
9181            "export response names a different brain than the verified head",
9182        ));
9183    }
9184    let head_seq = remote.head.seq;
9185    let dest: PathBuf = match out {
9186        Some(p) => p.to_path_buf(),
9187        None => PathBuf::from(&slug),
9188    };
9189    let entries = if head_seq == 0 {
9190        let files = body
9191            .get("files")
9192            .and_then(Value::as_array)
9193            .ok_or_else(|| invalid_feed("empty snapshot export did not carry an empty manifest"))?;
9194        if !files.is_empty() || body.get("url").is_some() {
9195            return Err(invalid_feed(
9196                "empty signed feed cannot authorize non-empty exported content",
9197            ));
9198        }
9199        Vec::new()
9200    } else {
9201        let signed_head = remote
9202            .head_entry
9203            .as_ref()
9204            .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
9205        let expected = &signed_head.entry.pack_sha256;
9206        if !is_sha256(expected) {
9207            return Err(invalid_feed(
9208                "signed head carries an invalid snapshot pack digest",
9209            ));
9210        }
9211        if let Some(url) = body.get("url").and_then(Value::as_str) {
9212            if body.get("sha256").and_then(Value::as_str) != Some(expected.as_str()) {
9213                return Err(invalid_feed(
9214                    "export pack digest does not match the signed head entry",
9215                ));
9216            }
9217            let bytes = get_presigned(cfg, url)?;
9218            let actual = format!("{:x}", Sha256::digest(&bytes));
9219            if actual != *expected {
9220                return Err(LinkError::InvalidPack {
9221                    message: "downloaded pack does not match the signed snapshot digest"
9222                        .to_string(),
9223                });
9224            }
9225            let entries = parse_store_pack(bytes)?;
9226            if signed_head.entry.kind == "push" {
9227                verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
9228            }
9229            entries
9230        } else {
9231            if signed_head.entry.kind != "push" {
9232                return Err(invalid_feed(
9233                    "delta snapshots must export the exact signed pack",
9234                ));
9235            }
9236            let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
9237                invalid_feed("verified snapshot export carried neither a pack nor files")
9238            })?;
9239            let mut entries = Vec::with_capacity(files.len());
9240            for file in files {
9241                let path = file
9242                    .get("path")
9243                    .and_then(Value::as_str)
9244                    .ok_or_else(|| invalid_feed("exported file path is not a string"))?;
9245                let content = file
9246                    .get("content")
9247                    .and_then(Value::as_str)
9248                    .ok_or_else(|| invalid_feed("exported inline content is not UTF-8 text"))?;
9249                entries.push((path.to_string(), content.as_bytes().to_vec()));
9250            }
9251            verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
9252            entries
9253        }
9254    };
9255
9256    // Gate the complete manifest before the first filesystem mutation.
9257    let mut seen = std::collections::HashSet::new();
9258    for (path, _) in &entries {
9259        if !safe_store_rel_path(path) {
9260            return Err(LinkError::UnsafePath { path: path.clone() });
9261        }
9262        if !seen.insert(path) {
9263            return Err(LinkError::InvalidPack {
9264                message: format!("duplicate path `{path}`"),
9265            });
9266        }
9267    }
9268    // Compute divergence against the still-live tree. The staged clone keeps
9269    // these extra locals byte-for-byte while overlaying the signed snapshot.
9270    let pulled: std::collections::BTreeSet<&str> =
9271        entries.iter().map(|(p, _)| p.as_str()).collect();
9272    let mut extra_local = Vec::new();
9273    if let Ok(store) = Store::open(&dest) {
9274        if let Ok(walked) = store.walk() {
9275            for rel in walked {
9276                let rel_str = rel.to_string_lossy().replace('\\', "/");
9277                if !pulled.contains(rel_str.as_str()) {
9278                    extra_local.push(rel_str);
9279                }
9280            }
9281        }
9282    }
9283    #[cfg(unix)]
9284    install_pulled_snapshot(&dest, &entries)?;
9285
9286    Ok(PullReport {
9287        brain: brain_id,
9288        slug,
9289        head_seq,
9290        files: entries.len(),
9291        dest: dest.to_string_lossy().into_owned(),
9292        extra_local,
9293        sync_status: "synced".to_string(),
9294    })
9295}
9296
9297#[cfg(unix)]
9298fn c_name(bytes: &[u8], display: &str) -> LinkResult<std::ffi::CString> {
9299    std::ffi::CString::new(bytes).map_err(|_| LinkError::UnsafePath {
9300        path: display.to_string(),
9301    })
9302}
9303
9304#[cfg(unix)]
9305fn open_dir_at(
9306    parent: std::os::fd::RawFd,
9307    name: &std::ffi::CStr,
9308    display: &str,
9309) -> LinkResult<std::fs::File> {
9310    use std::os::fd::FromRawFd as _;
9311    let fd = unsafe {
9312        libc::openat(
9313            parent,
9314            name.as_ptr(),
9315            libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9316        )
9317    };
9318    if fd < 0 {
9319        return Err(LinkError::UnsafePath {
9320            path: display.to_string(),
9321        });
9322    }
9323    Ok(unsafe { std::fs::File::from_raw_fd(fd) })
9324}
9325
9326/// Open (and, where absent, create) a directory path without following a
9327/// symlink in any component. The returned directory capability remains bound
9328/// to the opened inode even if an attacker renames or replaces an ancestor.
9329#[cfg(unix)]
9330fn open_dir_path_nofollow(path: &Path, create: bool) -> LinkResult<std::fs::File> {
9331    use std::os::fd::AsRawFd as _;
9332
9333    // macOS exposes a few system-owned compatibility symlinks at the root.
9334    // Normalize only those fixed OS aliases; never canonicalize an arbitrary
9335    // caller-controlled ancestor.
9336    #[cfg(target_os = "macos")]
9337    let normalized = [("/var", "/private/var"), ("/tmp", "/private/tmp")]
9338        .into_iter()
9339        .find_map(|(alias, real)| {
9340            path.strip_prefix(alias)
9341                .ok()
9342                .map(|rest| Path::new(real).join(rest))
9343        })
9344        .unwrap_or_else(|| path.to_path_buf());
9345    #[cfg(not(target_os = "macos"))]
9346    let normalized = path.to_path_buf();
9347
9348    let start = if normalized.is_absolute() {
9349        std::fs::File::open("/")?
9350    } else {
9351        std::fs::File::open(".")?
9352    };
9353    let mut directory = start;
9354    for component in normalized.components() {
9355        use std::path::Component;
9356        let name = match component {
9357            Component::RootDir | Component::CurDir => continue,
9358            Component::Normal(name) => name,
9359            Component::ParentDir | Component::Prefix(_) => {
9360                return Err(LinkError::UnsafePath {
9361                    path: path.display().to_string(),
9362                });
9363            }
9364        };
9365        use std::os::unix::ffi::OsStrExt as _;
9366        let name = c_name(name.as_bytes(), &path.display().to_string())?;
9367        if create {
9368            let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9369            if made != 0 {
9370                let error = std::io::Error::last_os_error();
9371                if error.raw_os_error() != Some(libc::EEXIST) {
9372                    return Err(error.into());
9373                }
9374            }
9375        }
9376        directory = open_dir_at(directory.as_raw_fd(), &name, &path.display().to_string())?;
9377    }
9378    Ok(directory)
9379}
9380
9381#[cfg(unix)]
9382fn open_or_create_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
9383    open_dir_path_nofollow(path, true)
9384}
9385
9386#[cfg(unix)]
9387fn open_existing_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
9388    open_dir_path_nofollow(path, false)
9389}
9390
9391#[cfg(unix)]
9392fn entry_is_dir_at(parent: std::os::fd::RawFd, name: &std::ffi::CStr) -> LinkResult<Option<bool>> {
9393    let mut stat: libc::stat = unsafe { std::mem::zeroed() };
9394    let result =
9395        unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) };
9396    if result == 0 {
9397        return Ok(Some((stat.st_mode & libc::S_IFMT) == libc::S_IFDIR));
9398    }
9399    let error = std::io::Error::last_os_error();
9400    if error.kind() == std::io::ErrorKind::NotFound {
9401        Ok(None)
9402    } else {
9403        Err(error.into())
9404    }
9405}
9406
9407#[cfg(unix)]
9408fn create_dir_exclusive_at(
9409    parent: std::os::fd::RawFd,
9410    name: &std::ffi::CStr,
9411    display: &str,
9412) -> LinkResult<std::fs::File> {
9413    let made = unsafe { libc::mkdirat(parent, name.as_ptr(), 0o700) };
9414    if made != 0 {
9415        return Err(LinkError::UnsafePath {
9416            path: display.to_string(),
9417        });
9418    }
9419    open_dir_at(parent, name, display)
9420}
9421
9422#[cfg(unix)]
9423fn directory_entry_names(directory: &std::fs::File) -> LinkResult<Vec<std::ffi::CString>> {
9424    use std::os::fd::AsRawFd as _;
9425
9426    let duplicate = unsafe { libc::dup(directory.as_raw_fd()) };
9427    if duplicate < 0 {
9428        return Err(std::io::Error::last_os_error().into());
9429    }
9430    let stream = unsafe { libc::fdopendir(duplicate) };
9431    if stream.is_null() {
9432        let error = std::io::Error::last_os_error();
9433        unsafe {
9434            libc::close(duplicate);
9435        }
9436        return Err(error.into());
9437    }
9438    let mut names = Vec::new();
9439    loop {
9440        let entry = unsafe { libc::readdir(stream) };
9441        if entry.is_null() {
9442            break;
9443        }
9444        let raw = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) };
9445        if raw.to_bytes() != b"." && raw.to_bytes() != b".." {
9446            names.push(raw.to_owned());
9447        }
9448    }
9449    if unsafe { libc::closedir(stream) } != 0 {
9450        return Err(std::io::Error::last_os_error().into());
9451    }
9452    Ok(names)
9453}
9454
9455/// Remove an entry tree relative to a held directory capability. Symlinks are
9456/// unlinked, never traversed, even when an old mirror contains hostile names.
9457#[cfg(unix)]
9458fn remove_tree_at(
9459    parent: std::os::fd::RawFd,
9460    name: &std::ffi::CStr,
9461    display: &str,
9462) -> LinkResult<()> {
9463    use std::os::fd::AsRawFd as _;
9464
9465    match entry_is_dir_at(parent, name)? {
9466        None => return Ok(()),
9467        Some(false) => {
9468            if unsafe { libc::unlinkat(parent, name.as_ptr(), 0) } != 0 {
9469                return Err(std::io::Error::last_os_error().into());
9470            }
9471        }
9472        Some(true) => {
9473            let directory = open_dir_at(parent, name, display)?;
9474            for child in directory_entry_names(&directory)? {
9475                let child_display =
9476                    format!("{display}/{}", String::from_utf8_lossy(child.to_bytes()));
9477                remove_tree_at(directory.as_raw_fd(), &child, &child_display)?;
9478            }
9479            drop(directory);
9480            if unsafe { libc::unlinkat(parent, name.as_ptr(), libc::AT_REMOVEDIR) } != 0 {
9481                return Err(std::io::Error::last_os_error().into());
9482            }
9483        }
9484    }
9485    Ok(())
9486}
9487
9488/// Clone a live destination into a private sibling stage without following
9489/// any symlink. Regular files are streamed between held descriptors; symlinks
9490/// are reproduced as links, never opened. Special files are refused.
9491#[cfg(unix)]
9492fn clone_tree_contents(
9493    source: &std::fs::File,
9494    destination: &std::fs::File,
9495    display: &str,
9496) -> LinkResult<()> {
9497    use std::os::fd::{AsRawFd as _, FromRawFd as _};
9498
9499    for name in directory_entry_names(source)? {
9500        let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
9501        let mut stat: libc::stat = unsafe { std::mem::zeroed() };
9502        if unsafe {
9503            libc::fstatat(
9504                source.as_raw_fd(),
9505                name.as_ptr(),
9506                &mut stat,
9507                libc::AT_SYMLINK_NOFOLLOW,
9508            )
9509        } != 0
9510        {
9511            return Err(std::io::Error::last_os_error().into());
9512        }
9513        match stat.st_mode & libc::S_IFMT {
9514            libc::S_IFDIR => {
9515                if unsafe {
9516                    libc::mkdirat(destination.as_raw_fd(), name.as_ptr(), stat.st_mode & 0o777)
9517                } != 0
9518                {
9519                    return Err(std::io::Error::last_os_error().into());
9520                }
9521                let source_child = open_dir_at(source.as_raw_fd(), &name, &child_display)?;
9522                let destination_child =
9523                    open_dir_at(destination.as_raw_fd(), &name, &child_display)?;
9524                clone_tree_contents(&source_child, &destination_child, &child_display)?;
9525                destination_child.sync_all()?;
9526            }
9527            libc::S_IFREG => {
9528                let source_fd = unsafe {
9529                    libc::openat(
9530                        source.as_raw_fd(),
9531                        name.as_ptr(),
9532                        libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9533                    )
9534                };
9535                if source_fd < 0 {
9536                    return Err(std::io::Error::last_os_error().into());
9537                }
9538                let destination_fd = unsafe {
9539                    libc::openat(
9540                        destination.as_raw_fd(),
9541                        name.as_ptr(),
9542                        libc::O_WRONLY
9543                            | libc::O_CREAT
9544                            | libc::O_EXCL
9545                            | libc::O_CLOEXEC
9546                            | libc::O_NOFOLLOW,
9547                        (stat.st_mode & 0o777) as libc::c_uint,
9548                    )
9549                };
9550                if destination_fd < 0 {
9551                    unsafe {
9552                        libc::close(source_fd);
9553                    }
9554                    return Err(std::io::Error::last_os_error().into());
9555                }
9556                let mut input = unsafe { std::fs::File::from_raw_fd(source_fd) };
9557                let mut output = unsafe { std::fs::File::from_raw_fd(destination_fd) };
9558                std::io::copy(&mut input, &mut output)?;
9559                output.sync_all()?;
9560            }
9561            libc::S_IFLNK => {
9562                let mut target = vec![0_u8; 4097];
9563                let length = unsafe {
9564                    libc::readlinkat(
9565                        source.as_raw_fd(),
9566                        name.as_ptr(),
9567                        target.as_mut_ptr().cast(),
9568                        target.len(),
9569                    )
9570                };
9571                if length < 0 || length as usize >= target.len() {
9572                    return Err(LinkError::UnsafePath {
9573                        path: child_display,
9574                    });
9575                }
9576                target.truncate(length as usize);
9577                let target = c_name(&target, &child_display)?;
9578                if unsafe {
9579                    libc::symlinkat(target.as_ptr(), destination.as_raw_fd(), name.as_ptr())
9580                } != 0
9581                {
9582                    return Err(std::io::Error::last_os_error().into());
9583                }
9584            }
9585            _ => {
9586                return Err(LinkError::UnsafePath {
9587                    path: child_display,
9588                });
9589            }
9590        }
9591    }
9592    destination.sync_all()?;
9593    Ok(())
9594}
9595
9596#[cfg(target_os = "linux")]
9597fn install_stage_at(
9598    parent: std::os::fd::RawFd,
9599    stage: &std::ffi::CStr,
9600    dest: &std::ffi::CStr,
9601    dest_exists: bool,
9602) -> LinkResult<()> {
9603    let flags = if dest_exists {
9604        libc::RENAME_EXCHANGE
9605    } else {
9606        libc::RENAME_NOREPLACE
9607    };
9608    // `libc::renameat2` is not exported for the musl targets we ship. Invoke
9609    // the kernel ABI directly, as `fsx::renameat_noreplace` does, so the same
9610    // atomic exchange/no-replace boundary compiles for glibc and musl.
9611    let result = unsafe {
9612        libc::syscall(
9613            libc::SYS_renameat2,
9614            parent,
9615            stage.as_ptr(),
9616            parent,
9617            dest.as_ptr(),
9618            flags,
9619        )
9620    };
9621    if result == 0 {
9622        Ok(())
9623    } else {
9624        Err(std::io::Error::last_os_error().into())
9625    }
9626}
9627
9628#[cfg(target_os = "macos")]
9629fn install_stage_at(
9630    parent: std::os::fd::RawFd,
9631    stage: &std::ffi::CStr,
9632    dest: &std::ffi::CStr,
9633    dest_exists: bool,
9634) -> LinkResult<()> {
9635    let flags = if dest_exists {
9636        libc::RENAME_SWAP
9637    } else {
9638        libc::RENAME_EXCL
9639    };
9640    let result =
9641        unsafe { libc::renameatx_np(parent, stage.as_ptr(), parent, dest.as_ptr(), flags) };
9642    if result == 0 {
9643        Ok(())
9644    } else {
9645        Err(std::io::Error::last_os_error().into())
9646    }
9647}
9648
9649#[cfg(unix)]
9650fn write_pull_entries_beneath_dir(
9651    root: &std::fs::File,
9652    entries: &[(String, Vec<u8>)],
9653) -> LinkResult<()> {
9654    use std::os::fd::{AsRawFd as _, FromRawFd as _};
9655
9656    for (path, content) in entries {
9657        let components: Vec<&str> = path.split('/').collect();
9658        let (leaf, parents) = components
9659            .split_last()
9660            .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9661        let mut directory = root.try_clone()?;
9662        for component in parents {
9663            let name = c_name(component.as_bytes(), path)?;
9664            let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9665            if made != 0 {
9666                let error = std::io::Error::last_os_error();
9667                if error.raw_os_error() != Some(libc::EEXIST) {
9668                    return Err(error.into());
9669                }
9670            }
9671            directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9672        }
9673
9674        let leaf_name = c_name(leaf.as_bytes(), path)?;
9675        let mut existing: libc::stat = unsafe { std::mem::zeroed() };
9676        let inspected = unsafe {
9677            libc::fstatat(
9678                directory.as_raw_fd(),
9679                leaf_name.as_ptr(),
9680                &mut existing,
9681                libc::AT_SYMLINK_NOFOLLOW,
9682            )
9683        };
9684        if inspected == 0 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK {
9685            return Err(LinkError::UnsafePath { path: path.clone() });
9686        }
9687
9688        let nonce = std::time::SystemTime::now()
9689            .duration_since(std::time::UNIX_EPOCH)
9690            .unwrap_or_default()
9691            .as_nanos();
9692        let temp_name = format!(
9693            ".dbmd-pull-{}-{nonce}-{}",
9694            std::process::id(),
9695            content_sha256(format!("{path}\0{}", content.len()).as_bytes())
9696        );
9697        let temp = c_name(temp_name.as_bytes(), path)?;
9698        let fd = unsafe {
9699            libc::openat(
9700                directory.as_raw_fd(),
9701                temp.as_ptr(),
9702                libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9703                0o600,
9704            )
9705        };
9706        if fd < 0 {
9707            return Err(std::io::Error::last_os_error().into());
9708        }
9709        let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
9710        if let Err(error) = file.write_all(content).and_then(|_| file.sync_all()) {
9711            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9712            return Err(error.into());
9713        }
9714        drop(file);
9715        let renamed = unsafe {
9716            libc::renameat(
9717                directory.as_raw_fd(),
9718                temp.as_ptr(),
9719                directory.as_raw_fd(),
9720                leaf_name.as_ptr(),
9721            )
9722        };
9723        if renamed != 0 {
9724            let error = std::io::Error::last_os_error();
9725            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9726            return Err(error.into());
9727        }
9728        directory.sync_all()?;
9729    }
9730    root.sync_all()?;
9731    Ok(())
9732}
9733
9734#[cfg(unix)]
9735fn write_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
9736    use std::os::fd::{AsRawFd as _, FromRawFd as _};
9737
9738    let path = &entry.path;
9739    let components: Vec<&str> = path.split('/').collect();
9740    let (leaf, parents) = components
9741        .split_last()
9742        .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9743    let mut directory = root.try_clone()?;
9744    for component in parents {
9745        let name = c_name(component.as_bytes(), path)?;
9746        let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9747        if made != 0 {
9748            let error = std::io::Error::last_os_error();
9749            if error.raw_os_error() != Some(libc::EEXIST) {
9750                return Err(error.into());
9751            }
9752        }
9753        directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9754    }
9755    let leaf_name = c_name(leaf.as_bytes(), path)?;
9756    let mut existing: libc::stat = unsafe { std::mem::zeroed() };
9757    if unsafe {
9758        libc::fstatat(
9759            directory.as_raw_fd(),
9760            leaf_name.as_ptr(),
9761            &mut existing,
9762            libc::AT_SYMLINK_NOFOLLOW,
9763        )
9764    } == 0
9765        && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK
9766    {
9767        return Err(LinkError::UnsafePath { path: path.clone() });
9768    }
9769    let nonce = SystemTime::now()
9770        .duration_since(UNIX_EPOCH)
9771        .unwrap_or_default()
9772        .as_nanos();
9773    let temp_name = format!(
9774        ".dbmd-pull-{}-{nonce}-{}",
9775        std::process::id(),
9776        content_sha256(path.as_bytes())
9777    );
9778    let temp = c_name(temp_name.as_bytes(), path)?;
9779    let fd = unsafe {
9780        libc::openat(
9781            directory.as_raw_fd(),
9782            temp.as_ptr(),
9783            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9784            0o600,
9785        )
9786    };
9787    if fd < 0 {
9788        return Err(std::io::Error::last_os_error().into());
9789    }
9790    let mut input = crate::fsx::open_regular_nofollow(&entry.source)?;
9791    let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
9792    let mut digest = Sha256::new();
9793    let mut total = 0_u64;
9794    let mut buffer = [0_u8; 64 * 1024];
9795    let copied = (|| -> std::io::Result<()> {
9796        loop {
9797            let read = input.read(&mut buffer)?;
9798            if read == 0 {
9799                break;
9800            }
9801            total = total.saturating_add(read as u64);
9802            if total > entry.bytes {
9803                return Err(std::io::Error::new(
9804                    std::io::ErrorKind::InvalidData,
9805                    "staged sync source grew beyond its verified length",
9806                ));
9807            }
9808            digest.update(&buffer[..read]);
9809            output.write_all(&buffer[..read])?;
9810        }
9811        Ok(())
9812    })();
9813    if let Err(error) = copied {
9814        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9815        return Err(error.into());
9816    }
9817    drop(output);
9818    if total != entry.bytes || format!("{:x}", digest.finalize()) != entry.sha256 {
9819        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9820        return Err(invalid_feed(
9821            "private staged sync source failed final integrity verification",
9822        ));
9823    }
9824    if unsafe {
9825        libc::renameat(
9826            directory.as_raw_fd(),
9827            temp.as_ptr(),
9828            directory.as_raw_fd(),
9829            leaf_name.as_ptr(),
9830        )
9831    } != 0
9832    {
9833        let error = std::io::Error::last_os_error();
9834        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9835        return Err(error.into());
9836    }
9837    Ok(())
9838}
9839
9840#[cfg(unix)]
9841fn sync_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
9842    use std::os::fd::{AsRawFd as _, FromRawFd as _};
9843
9844    let path = &entry.path;
9845    let components: Vec<&str> = path.split('/').collect();
9846    let (leaf, parents) = components
9847        .split_last()
9848        .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9849    let mut directory = root.try_clone()?;
9850    for component in parents {
9851        directory = open_dir_at(
9852            directory.as_raw_fd(),
9853            &c_name(component.as_bytes(), path)?,
9854            path,
9855        )?;
9856    }
9857    let leaf = c_name(leaf.as_bytes(), path)?;
9858    let fd = unsafe {
9859        libc::openat(
9860            directory.as_raw_fd(),
9861            leaf.as_ptr(),
9862            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9863        )
9864    };
9865    if fd < 0 {
9866        return Err(std::io::Error::last_os_error().into());
9867    }
9868    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
9869    if file.metadata()?.len() != entry.bytes || content_sha256_reader(&mut file)? != entry.sha256 {
9870        return Err(invalid_feed(
9871            "private pull stage changed before its durability barrier",
9872        ));
9873    }
9874    file.sync_all()?;
9875    Ok(())
9876}
9877
9878#[cfg(unix)]
9879fn run_pull_source_workers(
9880    root: &std::fs::File,
9881    entries: &[V2StagedFile],
9882    operation: fn(&std::fs::File, &V2StagedFile) -> LinkResult<()>,
9883) -> LinkResult<()> {
9884    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
9885
9886    let next = AtomicUsize::new(0);
9887    let failed = AtomicBool::new(false);
9888    let worker_count = entries.len().min(V2_PULL_INSTALL_WORKERS);
9889    let mut first_error = None;
9890    std::thread::scope(|scope| {
9891        let (sender, receiver) = std::sync::mpsc::channel();
9892        for _ in 0..worker_count {
9893            let sender = sender.clone();
9894            let next = &next;
9895            let failed = &failed;
9896            scope.spawn(move || {
9897                while !failed.load(Ordering::Acquire) {
9898                    let index = next.fetch_add(1, Ordering::Relaxed);
9899                    let Some(entry) = entries.get(index) else {
9900                        break;
9901                    };
9902                    let result = operation(root, entry);
9903                    if result.is_err() {
9904                        failed.store(true, Ordering::Release);
9905                    }
9906                    if sender.send(result).is_err() {
9907                        break;
9908                    }
9909                }
9910            });
9911        }
9912        drop(sender);
9913        for result in receiver {
9914            if let Err(error) = result {
9915                if first_error.is_none() {
9916                    first_error = Some(error);
9917                }
9918            }
9919        }
9920    });
9921    if let Some(error) = first_error {
9922        return Err(error);
9923    }
9924    if next.load(Ordering::Relaxed) < entries.len() {
9925        return Err(invalid_feed(
9926            "a bounded pull worker stopped before reporting every file",
9927        ));
9928    }
9929    Ok(())
9930}
9931
9932#[cfg(unix)]
9933fn sync_pull_directory_tree(root: &std::fs::File, display: &str) -> LinkResult<()> {
9934    use std::os::fd::AsRawFd as _;
9935
9936    for name in directory_entry_names(root)? {
9937        if entry_is_dir_at(root.as_raw_fd(), &name)? == Some(true) {
9938            let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
9939            let child = open_dir_at(root.as_raw_fd(), &name, &child_display)?;
9940            sync_pull_directory_tree(&child, &child_display)?;
9941        }
9942    }
9943    root.sync_all()?;
9944    Ok(())
9945}
9946
9947#[cfg(unix)]
9948fn write_pull_sources_beneath_dir(
9949    root: &std::fs::File,
9950    entries: &[V2StagedFile],
9951) -> LinkResult<()> {
9952    // The stage is still private and unreachable as the checkout here. Write
9953    // and verify every leaf first so the filesystem can coalesce dirty data,
9954    // then re-open/re-hash/fsync leaves through the held root capability. Only
9955    // after every file barrier succeeds do we flush each directory once. This
9956    // preserves the same crash durability as file-fsync + directory-fsync per
9957    // path without serializing 50,000 redundant directory barriers.
9958    run_pull_source_workers(root, entries, write_pull_source_beneath_dir)?;
9959    run_pull_source_workers(root, entries, sync_pull_source_beneath_dir)?;
9960    sync_pull_directory_tree(root, "v2 pull stage")
9961}
9962
9963#[cfg(unix)]
9964fn remove_pull_paths_beneath_dir(root: &std::fs::File, paths: &[String]) -> LinkResult<()> {
9965    use std::os::fd::AsRawFd as _;
9966    for path in paths {
9967        if !safe_store_rel_path(path) {
9968            return Err(LinkError::UnsafePath { path: path.clone() });
9969        }
9970        let components = path.split('/').collect::<Vec<_>>();
9971        let Some((leaf, parents)) = components.split_last() else {
9972            return Err(LinkError::UnsafePath { path: path.clone() });
9973        };
9974        let mut directory = root.try_clone()?;
9975        let mut missing = false;
9976        for component in parents {
9977            let name = c_name(component.as_bytes(), path)?;
9978            match entry_is_dir_at(directory.as_raw_fd(), &name)? {
9979                None => {
9980                    missing = true;
9981                    break;
9982                }
9983                Some(false) => return Err(LinkError::UnsafePath { path: path.clone() }),
9984                Some(true) => {
9985                    directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9986                }
9987            }
9988        }
9989        if missing {
9990            continue;
9991        }
9992        let leaf = c_name(leaf.as_bytes(), path)?;
9993        match entry_is_dir_at(directory.as_raw_fd(), &leaf)? {
9994            None => {}
9995            Some(true) => return Err(LinkError::UnsafePath { path: path.clone() }),
9996            Some(false) => {
9997                if unsafe { libc::unlinkat(directory.as_raw_fd(), leaf.as_ptr(), 0) } != 0 {
9998                    return Err(std::io::Error::last_os_error().into());
9999                }
10000                directory.sync_all()?;
10001            }
10002        }
10003    }
10004    Ok(())
10005}
10006
10007#[cfg(unix)]
10008fn install_pulled_delta(
10009    dest: &Path,
10010    entries: &[(String, Vec<u8>)],
10011    deleted: &[String],
10012    rebuild_indexes: bool,
10013) -> LinkResult<()> {
10014    use ring::rand::SecureRandom as _;
10015    use std::os::fd::AsRawFd as _;
10016    use std::os::unix::ffi::OsStrExt as _;
10017
10018    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10019    let name = dest
10020        .file_name()
10021        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
10022        .ok_or_else(|| LinkError::UnsafePath {
10023            path: dest.display().to_string(),
10024        })?;
10025    let parent_dir = open_or_create_dir_nofollow(parent)?;
10026    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
10027    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
10028        None => false,
10029        Some(true) => true,
10030        Some(false) => {
10031            return Err(LinkError::UnsafePath {
10032                path: dest.display().to_string(),
10033            });
10034        }
10035    };
10036
10037    let mut nonce = [0_u8; 16];
10038    ring::rand::SystemRandom::new()
10039        .fill(&mut nonce)
10040        .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
10041    let stage_label = format!(
10042        ".{}.dbmd-pull-stage-{}",
10043        name.to_string_lossy(),
10044        URL_SAFE_NO_PAD.encode(nonce)
10045    );
10046    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
10047    let stage_dir = create_dir_exclusive_at(
10048        parent_dir.as_raw_fd(),
10049        &stage_name,
10050        &dest.display().to_string(),
10051    )?;
10052
10053    let prepared = (|| -> LinkResult<()> {
10054        if dest_exists {
10055            let live = open_dir_at(
10056                parent_dir.as_raw_fd(),
10057                &dest_name,
10058                &dest.display().to_string(),
10059            )?;
10060            clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
10061        }
10062        remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
10063        write_pull_entries_beneath_dir(&stage_dir, entries)?;
10064        if rebuild_indexes {
10065            let stage_store =
10066                Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
10067                    .map_err(|error| LinkError::InvalidPack {
10068                        message: format!("v2 staging tree is not a valid db.md store: {error}"),
10069                    })?;
10070            crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
10071                LinkError::InvalidPack {
10072                    message: format!("could not materialize v2 local catalogs: {error}"),
10073                }
10074            })?;
10075        }
10076        stage_dir.sync_all()?;
10077        Ok(())
10078    })();
10079    if let Err(error) = prepared {
10080        let _ = remove_tree_at(
10081            parent_dir.as_raw_fd(),
10082            &stage_name,
10083            &dest.display().to_string(),
10084        );
10085        return Err(error);
10086    }
10087
10088    if let Err(error) =
10089        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
10090    {
10091        let _ = remove_tree_at(
10092            parent_dir.as_raw_fd(),
10093            &stage_name,
10094            &dest.display().to_string(),
10095        );
10096        return Err(error);
10097    }
10098    parent_dir.sync_all()?;
10099    if dest_exists {
10100        // Commit already happened atomically. Cleanup is best-effort so a
10101        // failure cannot be reported as a failed pull after the live tree
10102        // changed; a crash may leave only this private old-tree sibling.
10103        let _ = remove_tree_at(
10104            parent_dir.as_raw_fd(),
10105            &stage_name,
10106            &dest.display().to_string(),
10107        );
10108        let _ = parent_dir.sync_all();
10109    }
10110    Ok(())
10111}
10112
10113#[cfg(unix)]
10114fn install_pulled_delta_sources(
10115    dest: &Path,
10116    entries: &[V2StagedFile],
10117    deleted: &[String],
10118    rebuild_indexes: bool,
10119    _previous: Option<&V2SyncBaseline>,
10120    _next: &V2VerifiedHead,
10121) -> LinkResult<()> {
10122    use ring::rand::SecureRandom as _;
10123    use std::os::fd::AsRawFd as _;
10124    use std::os::unix::ffi::OsStrExt as _;
10125
10126    // An established checkout is updated only at the changed coordinates
10127    // through the durable pull journal shared with Windows. Initial clones
10128    // still use a private complete stage and one atomic directory install.
10129    if let Ok(store) = Store::open_strict(dest) {
10130        return install_established_v2_delta(
10131            store,
10132            entries,
10133            deleted,
10134            rebuild_indexes,
10135            _previous,
10136            _next,
10137        );
10138    }
10139
10140    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10141    let name = dest
10142        .file_name()
10143        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
10144        .ok_or_else(|| LinkError::UnsafePath {
10145            path: dest.display().to_string(),
10146        })?;
10147    let parent_dir = open_or_create_dir_nofollow(parent)?;
10148    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
10149    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
10150        None => false,
10151        Some(true) => true,
10152        Some(false) => {
10153            return Err(LinkError::UnsafePath {
10154                path: dest.display().to_string(),
10155            })
10156        }
10157    };
10158    let mut nonce = [0_u8; 16];
10159    ring::rand::SystemRandom::new()
10160        .fill(&mut nonce)
10161        .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
10162    let stage_label = format!(
10163        ".{}.dbmd-pull-stage-{}",
10164        name.to_string_lossy(),
10165        URL_SAFE_NO_PAD.encode(nonce)
10166    );
10167    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
10168    let stage_dir = create_dir_exclusive_at(
10169        parent_dir.as_raw_fd(),
10170        &stage_name,
10171        &dest.display().to_string(),
10172    )?;
10173    let prepared = (|| -> LinkResult<()> {
10174        if dest_exists {
10175            let live = open_dir_at(
10176                parent_dir.as_raw_fd(),
10177                &dest_name,
10178                &dest.display().to_string(),
10179            )?;
10180            clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
10181        }
10182        remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
10183        write_pull_sources_beneath_dir(&stage_dir, entries)?;
10184        if rebuild_indexes {
10185            let stage_store =
10186                Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
10187                    .map_err(|error| LinkError::InvalidPack {
10188                        message: format!("v2 staging tree is not a valid db.md store: {error}"),
10189                    })?;
10190            crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
10191                LinkError::InvalidPack {
10192                    message: format!("could not materialize v2 local catalogs: {error}"),
10193                }
10194            })?;
10195        }
10196        stage_dir.sync_all()?;
10197        Ok(())
10198    })();
10199    if let Err(error) = prepared {
10200        let _ = remove_tree_at(
10201            parent_dir.as_raw_fd(),
10202            &stage_name,
10203            &dest.display().to_string(),
10204        );
10205        return Err(error);
10206    }
10207    if let Err(error) =
10208        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
10209    {
10210        let _ = remove_tree_at(
10211            parent_dir.as_raw_fd(),
10212            &stage_name,
10213            &dest.display().to_string(),
10214        );
10215        return Err(error);
10216    }
10217    parent_dir.sync_all()?;
10218    if dest_exists {
10219        let _ = remove_tree_at(
10220            parent_dir.as_raw_fd(),
10221            &stage_name,
10222            &dest.display().to_string(),
10223        );
10224        let _ = parent_dir.sync_all();
10225    }
10226    Ok(())
10227}
10228
10229#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
10230struct V2PullCoordinate {
10231    head_seq: Option<u64>,
10232    commit_hash: Option<String>,
10233    view_kind: Option<String>,
10234    view_revision: Option<String>,
10235}
10236
10237#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
10238struct V2PullFileCoordinate {
10239    sha256: String,
10240    bytes: u64,
10241}
10242
10243#[derive(Debug, Clone, Deserialize, Serialize)]
10244struct V2PullJournalEntry {
10245    path: String,
10246    old: Option<V2PullFileCoordinate>,
10247    new: Option<V2PullFileCoordinate>,
10248    backup: Option<String>,
10249}
10250
10251#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
10252#[serde(rename_all = "snake_case")]
10253enum V2PullPhase {
10254    Preparing,
10255    Ready,
10256}
10257
10258#[derive(Debug, Clone, Deserialize, Serialize)]
10259struct V2PullJournal {
10260    v: u8,
10261    phase: V2PullPhase,
10262    brain: String,
10263    previous: V2PullCoordinate,
10264    next: V2PullCoordinate,
10265    backup_dir: String,
10266    entries: Vec<V2PullJournalEntry>,
10267}
10268
10269const V2_PULL_JOURNAL: &str = ".dbmd/pull-journal.json";
10270
10271fn v2_pull_baseline_coordinate(baseline: Option<&V2SyncBaseline>) -> V2PullCoordinate {
10272    V2PullCoordinate {
10273        head_seq: baseline.and_then(|value| value.head_seq),
10274        commit_hash: baseline.and_then(|value| value.commit_hash.clone()),
10275        view_kind: baseline.and_then(|value| value.view_kind.clone()),
10276        view_revision: baseline.and_then(|value| value.view_revision.clone()),
10277    }
10278}
10279
10280fn v2_pull_head_coordinate(head: &V2VerifiedHead) -> V2PullCoordinate {
10281    V2PullCoordinate {
10282        head_seq: head.pointer.as_ref().map(|value| value.seq),
10283        commit_hash: head.pointer.as_ref().map(|value| value.commit_hash.clone()),
10284        view_kind: Some(head.view_kind.clone()),
10285        view_revision: Some(head.view_revision.clone()),
10286    }
10287}
10288
10289fn v2_pull_file_coordinate(
10290    store: &Store,
10291    path: &str,
10292    limit: u64,
10293) -> LinkResult<Option<V2PullFileCoordinate>> {
10294    let file = match store.open_regular(Path::new(path)) {
10295        Ok(file) => file,
10296        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10297        Err(error) => return Err(error.into()),
10298    };
10299    let bytes = file.metadata()?.len();
10300    if bytes > limit || bytes > MAX_STORE_BYTES {
10301        return Err(invalid_feed(
10302            "pull transaction file exceeds its declared bound",
10303        ));
10304    }
10305    Ok(Some(V2PullFileCoordinate {
10306        sha256: content_sha256_reader(file)?,
10307        bytes,
10308    }))
10309}
10310
10311fn v2_pull_journal_bytes(journal: &V2PullJournal) -> LinkResult<Vec<u8>> {
10312    let mut bytes = serde_json::to_vec_pretty(journal)
10313        .map_err(|_| invalid_feed("could not serialize v2 pull journal"))?;
10314    bytes.push(b'\n');
10315    Ok(bytes)
10316}
10317
10318fn validate_v2_pull_journal(journal: &V2PullJournal) -> LinkResult<()> {
10319    let backup_prefix = ".dbmd/pull-backup-";
10320    let suffix = journal
10321        .backup_dir
10322        .strip_prefix(backup_prefix)
10323        .ok_or_else(|| invalid_feed("v2 pull journal backup address is invalid"))?;
10324    let mut paths = std::collections::BTreeSet::new();
10325    if journal.v != 1
10326        || !crate::ulid::is_ulid(&journal.brain)
10327        || !crate::ulid::is_ulid(suffix)
10328        || journal.entries.is_empty()
10329        || journal.entries.len() > MAX_PUSH_FILES + 4
10330        || journal.previous == journal.next
10331    {
10332        return Err(invalid_feed("v2 pull journal failed validation"));
10333    }
10334    for (index, entry) in journal.entries.iter().enumerate() {
10335        if !safe_store_rel_path(&entry.path)
10336            || entry.path == V2_PULL_JOURNAL
10337            || entry.path.starts_with(backup_prefix)
10338            || !paths.insert(entry.path.clone())
10339            || (entry.old.is_none() && entry.new.is_none())
10340            || entry
10341                .old
10342                .iter()
10343                .chain(entry.new.iter())
10344                .any(|value| !is_sha256(&value.sha256) || value.bytes > MAX_STORE_BYTES)
10345            || entry.backup.as_deref()
10346                != entry
10347                    .old
10348                    .as_ref()
10349                    .map(|_| format!("{index:08x}"))
10350                    .as_deref()
10351        {
10352            return Err(invalid_feed("v2 pull journal entry failed validation"));
10353        }
10354    }
10355    Ok(())
10356}
10357
10358fn load_v2_pull_journal(store: &Store) -> LinkResult<Option<V2PullJournal>> {
10359    #[cfg(unix)]
10360    {
10361        use std::os::unix::fs::PermissionsExt as _;
10362        match store.regular_metadata(Path::new(V2_PULL_JOURNAL)) {
10363            Ok(metadata) if metadata.permissions().mode() & 0o077 != 0 => {
10364                return Err(invalid_feed(
10365                    "v2 pull journal is accessible to group/other; set mode 0600",
10366                ));
10367            }
10368            Ok(_) => {}
10369            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10370            Err(error) => return Err(error.into()),
10371        }
10372    }
10373    let bytes = match store.read_bounded(Path::new(V2_PULL_JOURNAL), 64 * 1024 * 1024) {
10374        Ok(bytes) => bytes,
10375        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10376        Err(error) => return Err(error.into()),
10377    };
10378    let journal: V2PullJournal =
10379        serde_json::from_slice(&bytes).map_err(|_| invalid_feed("v2 pull journal is corrupt"))?;
10380    validate_v2_pull_journal(&journal)?;
10381    Ok(Some(journal))
10382}
10383
10384fn cleanup_v2_pull_journal(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
10385    // The journal is the only recovery authority. Remove it first only after
10386    // rollback or baseline commit is durable; an orphan private backup is safe
10387    // and can be pruned, while a journal without its backups is not recoverable.
10388    match store.remove_file(Path::new(V2_PULL_JOURNAL)) {
10389        Ok(()) => {}
10390        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
10391        Err(error) => return Err(error.into()),
10392    }
10393    match store.remove_private_tree(Path::new(&journal.backup_dir)) {
10394        Ok(()) => Ok(()),
10395        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
10396        Err(error) => Err(error.into()),
10397    }
10398}
10399
10400fn prune_orphan_v2_pull_backups(store: &Store) -> LinkResult<()> {
10401    let names = match store.directory_names(Path::new(".dbmd")) {
10402        Ok(names) => names,
10403        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
10404        Err(error) => return Err(error.into()),
10405    };
10406    for name in names {
10407        let Some(name) = name.to_str() else {
10408            continue;
10409        };
10410        let Some(suffix) = name.strip_prefix("pull-backup-") else {
10411            continue;
10412        };
10413        if crate::ulid::is_ulid(suffix) {
10414            store.remove_private_tree(&Path::new(".dbmd").join(name))?;
10415        }
10416    }
10417    Ok(())
10418}
10419
10420fn rollback_v2_pull(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
10421    // Validate every live coordinate and every backup before the first restore.
10422    for entry in &journal.entries {
10423        let limit = entry
10424            .old
10425            .as_ref()
10426            .into_iter()
10427            .chain(entry.new.iter())
10428            .map(|value| value.bytes)
10429            .max()
10430            .unwrap_or(0);
10431        let current = v2_pull_file_coordinate(store, &entry.path, limit)?;
10432        if current != entry.old && current != entry.new {
10433            return Err(LinkError::InvalidPack {
10434                message: format!(
10435                    "cannot recover interrupted pull because `{}` changed afterward",
10436                    entry.path
10437                ),
10438            });
10439        }
10440        if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
10441            let path = Path::new(&journal.backup_dir).join(backup);
10442            let file = store.open_regular(&path)?;
10443            if file.metadata()?.len() != old.bytes || content_sha256_reader(file)? != old.sha256 {
10444                return Err(invalid_feed("v2 pull recovery backup failed verification"));
10445            }
10446        }
10447    }
10448    for entry in journal.entries.iter().rev() {
10449        match (&entry.old, &entry.backup) {
10450            (Some(old), Some(backup)) => {
10451                let bytes =
10452                    store.read_bounded(&Path::new(&journal.backup_dir).join(backup), old.bytes)?;
10453                store.write_atomic(Path::new(&entry.path), &bytes)?;
10454            }
10455            (None, None) if store.regular_file_exists(Path::new(&entry.path))? => {
10456                store.remove_file(Path::new(&entry.path))?;
10457            }
10458            (None, None) => {}
10459            _ => return Err(invalid_feed("v2 pull recovery entry is inconsistent")),
10460        }
10461    }
10462    crate::index::Index::rebuild_all(store).map_err(|error| LinkError::InvalidPack {
10463        message: format!("could not rebuild catalogs after pull recovery: {error}"),
10464    })?;
10465    cleanup_v2_pull_journal(store, journal)
10466}
10467
10468fn recover_v2_pull(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<()> {
10469    let Ok(store) = Store::open_strict(dest) else {
10470        return Ok(());
10471    };
10472    if let Some(journal) = load_v2_pull_journal(&store)? {
10473        if journal.brain != brain {
10474            return Err(invalid_feed("v2 pull journal belongs to another brain"));
10475        }
10476        if journal.phase == V2PullPhase::Preparing {
10477            cleanup_v2_pull_journal(&store, &journal)?;
10478        } else {
10479            let baseline = load_v2_baseline(cfg, brain, dest)?;
10480            let current = v2_pull_baseline_coordinate(baseline.as_ref());
10481            if current == journal.next {
10482                cleanup_v2_pull_journal(&store, &journal)?;
10483            } else {
10484                if current != journal.previous {
10485                    return Err(invalid_feed(
10486                        "cannot recover interrupted pull because its baseline changed afterward",
10487                    ));
10488                }
10489                rollback_v2_pull(&store, &journal)?;
10490            }
10491        }
10492    }
10493    // Cleanup removes the journal first after the state is durably old or new.
10494    // A hard kill in the tiny interval before deleting its backup directory can
10495    // therefore leave only an inert, private orphan. The per-brain operation
10496    // lock is held by the caller, so no live pull can own one here.
10497    prune_orphan_v2_pull_backups(&store)
10498}
10499
10500fn complete_v2_pull(dest: &Path) -> LinkResult<()> {
10501    let store = Store::open_strict(dest).map_err(|error| LinkError::InvalidPack {
10502        message: format!("installed v2 checkout is not a valid db.md store: {error}"),
10503    })?;
10504    if let Some(journal) = load_v2_pull_journal(&store)? {
10505        cleanup_v2_pull_journal(&store, &journal)?;
10506    }
10507    Ok(())
10508}
10509
10510#[cfg(windows)]
10511fn install_windows_initial_sources(
10512    dest: &Path,
10513    entries: &[V2StagedFile],
10514    rebuild_indexes: bool,
10515) -> LinkResult<()> {
10516    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10517    let name = dest.file_name().ok_or_else(|| LinkError::UnsafePath {
10518        path: dest.display().to_string(),
10519    })?;
10520    let parent_capability = crate::fsx::open_or_create_directory_nofollow(parent)?;
10521    if crate::fsx::directory_exists_beneath(&parent_capability, Path::new(name))? {
10522        return Err(LinkError::UnsafePath {
10523            path: dest.display().to_string(),
10524        });
10525    }
10526    let stage_name = format!(
10527        ".{}.dbmd-pull-stage-{}",
10528        name.to_string_lossy(),
10529        crate::ulid::mint()
10530    );
10531    let stage_path = parent.join(&stage_name);
10532    let stage_capability =
10533        crate::fsx::open_directory_beneath(&parent_capability, Path::new(&stage_name), true)?;
10534    let stage = Store::from_root_and_config(&stage_path, crate::Config::default())?;
10535    let prepared = (|| -> LinkResult<()> {
10536        for entry in entries {
10537            let bytes = crate::fsx::read_bounded_nofollow(&entry.source, entry.bytes)?;
10538            if bytes.len() as u64 != entry.bytes || content_sha256(&bytes) != entry.sha256 {
10539                return Err(invalid_feed(
10540                    "private staged sync source failed final integrity verification",
10541                ));
10542            }
10543            stage.write_atomic(Path::new(&entry.path), &bytes)?;
10544        }
10545        let strict = Store::from_held_root_strict(&stage_path, stage_capability.try_clone()?)
10546            .map_err(|error| LinkError::InvalidPack {
10547                message: format!("v2 staging tree is not a valid db.md store: {error}"),
10548            })?;
10549        if rebuild_indexes {
10550            crate::index::Index::rebuild_all(&strict).map_err(|error| LinkError::InvalidPack {
10551                message: format!("could not materialize v2 local catalogs: {error}"),
10552            })?;
10553        }
10554        Ok(())
10555    })();
10556    if let Err(error) = prepared {
10557        let _ = crate::fsx::remove_tree_beneath(&parent_capability, Path::new(&stage_name));
10558        return Err(error);
10559    }
10560    crate::fsx::rename_directory_beneath(
10561        &parent_capability,
10562        Path::new(&stage_name),
10563        Path::new(name),
10564    )?;
10565    Ok(())
10566}
10567
10568fn install_established_v2_delta(
10569    store: Store,
10570    entries: &[V2StagedFile],
10571    deleted: &[String],
10572    rebuild_indexes: bool,
10573    previous: Option<&V2SyncBaseline>,
10574    next: &V2VerifiedHead,
10575) -> LinkResult<()> {
10576    if load_v2_pull_journal(&store)?.is_some() {
10577        return Err(invalid_feed(
10578            "an interrupted pull must be recovered before installing",
10579        ));
10580    }
10581    let mut sources = std::collections::BTreeMap::new();
10582    for entry in entries {
10583        if sources.insert(entry.path.clone(), entry).is_some() || deleted.contains(&entry.path) {
10584            return Err(invalid_feed("pull mutation repeats a path"));
10585        }
10586    }
10587    let mut paths = sources.keys().cloned().collect::<Vec<_>>();
10588    paths.extend(deleted.iter().cloned());
10589    paths.sort();
10590    paths.dedup();
10591    if paths.is_empty() {
10592        return Ok(());
10593    }
10594    let backup_dir = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
10595    let mut journal = V2PullJournal {
10596        v: 1,
10597        phase: V2PullPhase::Preparing,
10598        brain: next.brain_id.clone(),
10599        previous: v2_pull_baseline_coordinate(previous),
10600        next: v2_pull_head_coordinate(next),
10601        backup_dir: backup_dir.clone(),
10602        entries: Vec::with_capacity(paths.len()),
10603    };
10604    for path in &paths {
10605        let old = v2_pull_file_coordinate(&store, path, MAX_STORE_BYTES)?;
10606        let new = sources.get(path).map(|entry| V2PullFileCoordinate {
10607            sha256: entry.sha256.clone(),
10608            bytes: entry.bytes,
10609        });
10610        if old == new {
10611            continue;
10612        }
10613        let index = journal.entries.len();
10614        journal.entries.push(V2PullJournalEntry {
10615            path: path.clone(),
10616            backup: old.as_ref().map(|_| format!("{index:08x}")),
10617            old,
10618            new,
10619        });
10620    }
10621    if journal.entries.is_empty() {
10622        return Ok(());
10623    }
10624    let backup_bytes = journal.entries.iter().try_fold(0_u64, |total, entry| {
10625        entry
10626            .old
10627            .as_ref()
10628            .map_or(Some(total), |old| total.checked_add(old.bytes))
10629    });
10630    if backup_bytes.is_none_or(|bytes| bytes > MAX_STORE_BYTES) {
10631        return Err(LinkError::InvalidPack {
10632            message: "pull recovery preimages exceed the 512 MB transaction limit".to_string(),
10633        });
10634    }
10635    validate_v2_pull_journal(&journal)?;
10636    store.write_private_atomic_new(
10637        Path::new(V2_PULL_JOURNAL),
10638        &v2_pull_journal_bytes(&journal)?,
10639    )?;
10640    let prepared = (|| -> LinkResult<()> {
10641        store.create_private_dir_all(Path::new(&backup_dir))?;
10642        for entry in &journal.entries {
10643            if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
10644                let bytes = store.read_bounded(Path::new(&entry.path), old.bytes)?;
10645                if content_sha256(&bytes) != old.sha256 {
10646                    return Err(invalid_feed("live pull source changed during backup"));
10647                }
10648                store.write_private_atomic_new(&Path::new(&backup_dir).join(backup), &bytes)?;
10649            }
10650        }
10651        journal.phase = V2PullPhase::Ready;
10652        store.write_private_atomic(
10653            Path::new(V2_PULL_JOURNAL),
10654            &v2_pull_journal_bytes(&journal)?,
10655        )?;
10656        Ok(())
10657    })();
10658    if let Err(error) = prepared {
10659        let cleanup = cleanup_v2_pull_journal(&store, &journal);
10660        return match cleanup {
10661            Ok(()) => Err(error),
10662            Err(cleanup) => Err(LinkError::InvalidPack {
10663                message: format!("{error}; recovery metadata cleanup also failed: {cleanup}"),
10664            }),
10665        };
10666    }
10667    let installed = (|| -> LinkResult<()> {
10668        for entry in &journal.entries {
10669            if v2_pull_file_coordinate(&store, &entry.path, MAX_STORE_BYTES)? != entry.old {
10670                return Err(LinkError::InvalidPack {
10671                    message: format!("local path `{}` changed during pull", entry.path),
10672                });
10673            }
10674            if let Some(source) = sources.get(&entry.path) {
10675                let bytes = crate::fsx::read_bounded_nofollow(&source.source, source.bytes)?;
10676                if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != source.sha256 {
10677                    return Err(invalid_feed(
10678                        "private staged sync source failed final integrity verification",
10679                    ));
10680                }
10681                store.write_atomic(Path::new(&entry.path), &bytes)?;
10682            } else if entry.old.is_some() {
10683                store.remove_file(Path::new(&entry.path))?;
10684            }
10685        }
10686        if rebuild_indexes {
10687            crate::index::Index::rebuild_all(&store).map_err(|error| LinkError::InvalidPack {
10688                message: format!("could not materialize v2 local catalogs: {error}"),
10689            })?;
10690        }
10691        Ok(())
10692    })();
10693    if let Err(error) = installed {
10694        return match rollback_v2_pull(&store, &journal) {
10695            Ok(()) => Err(error),
10696            Err(rollback) => Err(LinkError::InvalidPack {
10697                message: format!("{error}; durable pull rollback also failed: {rollback}"),
10698            }),
10699        };
10700    }
10701    Ok(())
10702}
10703
10704#[cfg(windows)]
10705fn install_pulled_delta_sources(
10706    dest: &Path,
10707    entries: &[V2StagedFile],
10708    deleted: &[String],
10709    rebuild_indexes: bool,
10710    previous: Option<&V2SyncBaseline>,
10711    next: &V2VerifiedHead,
10712) -> LinkResult<()> {
10713    match Store::open_strict(dest) {
10714        Ok(store) => {
10715            install_established_v2_delta(store, entries, deleted, rebuild_indexes, previous, next)
10716        }
10717        Err(_) => install_windows_initial_sources(dest, entries, rebuild_indexes),
10718    }
10719}
10720
10721#[cfg(not(any(unix, windows)))]
10722fn install_pulled_delta_sources(
10723    _dest: &Path,
10724    _entries: &[V2StagedFile],
10725    _deleted: &[String],
10726    _rebuild_indexes: bool,
10727    _previous: Option<&V2SyncBaseline>,
10728    _next: &V2VerifiedHead,
10729) -> LinkResult<()> {
10730    Err(LinkError::UnsupportedPlatform {
10731        operation: "atomic v2 pull install",
10732    })
10733}
10734
10735#[cfg(unix)]
10736fn install_pulled_snapshot(dest: &Path, entries: &[(String, Vec<u8>)]) -> LinkResult<()> {
10737    install_pulled_delta(dest, entries, &[], false)
10738}
10739
10740#[cfg(not(windows))]
10741fn is_safe_slug(slug: &str) -> bool {
10742    !slug.is_empty()
10743        && slug.len() <= 63
10744        && !slug.starts_with('-')
10745        && !slug.ends_with('-')
10746        && slug
10747            .bytes()
10748            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
10749}
10750
10751fn le_u16(bytes: &[u8], at: usize) -> Option<u16> {
10752    Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
10753}
10754
10755fn le_u32(bytes: &[u8], at: usize) -> Option<u32> {
10756    Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
10757}
10758
10759fn le_u64(bytes: &[u8], at: usize) -> Option<u64> {
10760    Some(u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?))
10761}
10762
10763fn preflight_zip_central_directory(
10764    bytes: &[u8],
10765    offset: usize,
10766    size: usize,
10767    count: u64,
10768) -> LinkResult<()> {
10769    const CENTRAL_ENTRY_SIG: &[u8; 4] = b"PK\x01\x02";
10770    let end = offset
10771        .checked_add(size)
10772        .filter(|end| *end <= bytes.len())
10773        .ok_or_else(|| LinkError::InvalidPack {
10774            message: "ZIP central directory is out of bounds".to_string(),
10775        })?;
10776    let mut cursor = offset;
10777    for _ in 0..count {
10778        if bytes.get(cursor..cursor + 4) != Some(CENTRAL_ENTRY_SIG.as_slice()) {
10779            return Err(LinkError::InvalidPack {
10780                message: "ZIP central directory entry count is inconsistent".to_string(),
10781            });
10782        }
10783        if le_u16(bytes, cursor + 34) != Some(0) {
10784            return Err(LinkError::InvalidPack {
10785                message: "multi-disk ZIP archives are not supported".to_string(),
10786            });
10787        }
10788        let variable = [28, 30, 32].into_iter().try_fold(0usize, |total, at| {
10789            total.checked_add(le_u16(bytes, cursor + at)? as usize)
10790        });
10791        cursor = cursor
10792            .checked_add(46)
10793            .and_then(|fixed| fixed.checked_add(variable?))
10794            .filter(|cursor| *cursor <= end)
10795            .ok_or_else(|| LinkError::InvalidPack {
10796                message: "ZIP central directory entry is truncated".to_string(),
10797            })?;
10798    }
10799    if cursor != end {
10800        return Err(LinkError::InvalidPack {
10801            message: "ZIP central directory size is inconsistent".to_string(),
10802        });
10803    }
10804    Ok(())
10805}
10806
10807/// Read only the bounded ZIP trailer before `ZipArchive::new` allocates one
10808/// metadata object per central-directory entry. Supports ordinary EOCD and the
10809/// Zip64 locator/record emitted for >65,535-entry archives.
10810fn preflight_zip_entry_count(bytes: &[u8], max_entries: usize) -> LinkResult<()> {
10811    const EOCD_SIG: &[u8; 4] = b"PK\x05\x06";
10812    const ZIP64_LOCATOR_SIG: &[u8; 4] = b"PK\x06\x07";
10813    const ZIP64_EOCD_SIG: &[u8; 4] = b"PK\x06\x06";
10814    let search_start = bytes.len().saturating_sub(22 + u16::MAX as usize);
10815    let eocd = bytes[search_start..]
10816        .windows(4)
10817        .rposition(|window| window == EOCD_SIG)
10818        .map(|offset| search_start + offset)
10819        .ok_or_else(|| LinkError::InvalidPack {
10820            message: "ZIP has no end-of-central-directory record".to_string(),
10821        })?;
10822    let invalid_end = || LinkError::InvalidPack {
10823        message: "ZIP has an invalid end-of-central-directory structure".to_string(),
10824    };
10825    let comment_len = le_u16(bytes, eocd + 20).ok_or_else(invalid_end)? as usize;
10826    if eocd
10827        .checked_add(22)
10828        .and_then(|end| end.checked_add(comment_len))
10829        != Some(bytes.len())
10830    {
10831        // Do not fall back to an earlier signature. ZipArchive does, and a
10832        // fake low-count EOCD appended after a real Zip64 entry-count bomb
10833        // would otherwise bypass this allocation preflight.
10834        return Err(invalid_end());
10835    }
10836    let disk = le_u16(bytes, eocd + 4);
10837    let central_disk = le_u16(bytes, eocd + 6);
10838    if disk != Some(0) || central_disk != Some(0) {
10839        return Err(LinkError::InvalidPack {
10840            message: "multi-disk ZIP archives are not supported".to_string(),
10841        });
10842    }
10843    let entries_on_disk = le_u16(bytes, eocd + 8).ok_or_else(invalid_end)?;
10844    let ordinary = le_u16(bytes, eocd + 10).ok_or_else(invalid_end)?;
10845    if entries_on_disk != ordinary {
10846        return Err(LinkError::InvalidPack {
10847            message: "multi-disk ZIP archives are not supported".to_string(),
10848        });
10849    }
10850    let zip64_locator = eocd
10851        .checked_sub(20)
10852        .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_LOCATOR_SIG.as_slice()));
10853    let (count, central_offset, central_size) = if ordinary != u16::MAX || zip64_locator.is_none() {
10854        let central_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)? as usize;
10855        let central_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)? as usize;
10856        if central_offset
10857            .checked_add(central_size)
10858            .filter(|end| *end == eocd)
10859            .is_none()
10860        {
10861            return Err(invalid_end());
10862        }
10863        (ordinary as u64, central_offset, central_size)
10864    } else {
10865        let Some(locator) = zip64_locator else {
10866            return Err(invalid_end());
10867        };
10868        if le_u32(bytes, locator + 4) != Some(0) || le_u32(bytes, locator + 16) != Some(1) {
10869            return Err(LinkError::InvalidPack {
10870                message: "multi-disk ZIP64 archives are not supported".to_string(),
10871            });
10872        }
10873        let record = le_u64(bytes, locator + 8)
10874            .and_then(|offset| usize::try_from(offset).ok())
10875            .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_EOCD_SIG.as_slice()))
10876            .ok_or_else(|| LinkError::InvalidPack {
10877                message: "ZIP64 archive has an invalid end record".to_string(),
10878            })?;
10879        let record_size = le_u64(bytes, record + 4)
10880            .and_then(|size| usize::try_from(size).ok())
10881            .filter(|size| *size >= 44)
10882            .ok_or_else(invalid_end)?;
10883        if record
10884            .checked_add(12)
10885            .and_then(|end| end.checked_add(record_size))
10886            != Some(locator)
10887            || le_u32(bytes, record + 16) != Some(0)
10888            || le_u32(bytes, record + 20) != Some(0)
10889        {
10890            return Err(invalid_end());
10891        }
10892        let zip64_on_disk = le_u64(bytes, record + 24).ok_or_else(invalid_end)?;
10893        let zip64_total = le_u64(bytes, record + 32).ok_or_else(invalid_end)?;
10894        let central_size = le_u64(bytes, record + 40)
10895            .and_then(|size| usize::try_from(size).ok())
10896            .ok_or_else(invalid_end)?;
10897        let central_offset = le_u64(bytes, record + 48)
10898            .and_then(|offset| usize::try_from(offset).ok())
10899            .ok_or_else(invalid_end)?;
10900        if zip64_on_disk != zip64_total
10901            || central_offset
10902                .checked_add(central_size)
10903                .filter(|end| *end == record)
10904                .is_none()
10905        {
10906            return Err(invalid_end());
10907        }
10908        let legacy_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)?;
10909        let legacy_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)?;
10910        if (legacy_size != u32::MAX && legacy_size as usize != central_size)
10911            || (legacy_offset != u32::MAX && legacy_offset as usize != central_offset)
10912        {
10913            return Err(invalid_end());
10914        }
10915        (zip64_total, central_offset, central_size)
10916    };
10917    if count == 0 || count > max_entries as u64 {
10918        return Err(LinkError::InvalidPack {
10919            message: format!("invalid file count {count}"),
10920        });
10921    }
10922    preflight_zip_central_directory(bytes, central_offset, central_size, count)?;
10923    Ok(())
10924}
10925
10926fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
10927    preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)?;
10928    let mut archive =
10929        zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
10930            message: format!("ZIP parse failed: {err}"),
10931        })?;
10932    if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
10933        return Err(LinkError::InvalidPack {
10934            message: format!("invalid file count {}", archive.len()),
10935        });
10936    }
10937    let mut total = 0u64;
10938    let mut seen = std::collections::HashSet::new();
10939    let mut entries = Vec::with_capacity(archive.len());
10940    for index in 0..archive.len() {
10941        let mut file = archive
10942            .by_index(index)
10943            .map_err(|err| LinkError::InvalidPack {
10944                message: format!("ZIP entry failed: {err}"),
10945            })?;
10946        if file.is_dir() {
10947            continue;
10948        }
10949        let path = file.name().to_string();
10950        if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
10951            return Err(LinkError::UnsafePath { path });
10952        }
10953        if file
10954            .unix_mode()
10955            .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
10956        {
10957            return Err(LinkError::InvalidPack {
10958                message: format!("non-file entry `{path}`"),
10959            });
10960        }
10961        if !seen.insert(path.clone()) {
10962            return Err(LinkError::InvalidPack {
10963                message: format!("duplicate path `{path}`"),
10964            });
10965        }
10966        let remaining = MAX_STORE_BYTES.saturating_sub(total);
10967        if file.size() > remaining {
10968            return Err(LinkError::InvalidPack {
10969                message: "expanded content exceeds the 512 MB limit".to_string(),
10970            });
10971        }
10972        let mut content = Vec::new();
10973        (&mut file)
10974            .take(remaining + 1)
10975            .read_to_end(&mut content)
10976            .map_err(|err| LinkError::InvalidPack {
10977                message: format!("could not decompress `{path}`: {err}"),
10978            })?;
10979        if content.len() as u64 > remaining {
10980            return Err(LinkError::InvalidPack {
10981                message: "expanded content exceeds the 512 MB limit".to_string(),
10982            });
10983        }
10984        if content.len() as u64 != file.size() {
10985            return Err(LinkError::InvalidPack {
10986                message: format!("length mismatch for `{path}`"),
10987            });
10988        }
10989        total += content.len() as u64;
10990        entries.push((path, content));
10991    }
10992    if entries.is_empty() {
10993        return Err(LinkError::InvalidPack {
10994            message: "pack contains no files".to_string(),
10995        });
10996    }
10997    Ok(entries)
10998}
10999
11000fn verify_snapshot_manifest(entries: &[(String, Vec<u8>)], signed: &[FeedFile]) -> LinkResult<()> {
11001    let mut expected = std::collections::BTreeMap::new();
11002    for file in signed {
11003        if !safe_store_rel_path(&file.path) {
11004            return Err(LinkError::UnsafePath {
11005                path: file.path.clone(),
11006            });
11007        }
11008        if !is_sha256(&file.sha256)
11009            || expected
11010                .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
11011                .is_some()
11012        {
11013            return Err(invalid_feed(
11014                "signed snapshot manifest contains an invalid or duplicate file",
11015            ));
11016        }
11017    }
11018    if expected.len() != entries.len() {
11019        return Err(invalid_feed(
11020            "downloaded pack file set differs from the signed snapshot manifest",
11021        ));
11022    }
11023    for (path, bytes) in entries {
11024        let Some((sha256, declared_bytes)) = expected.get(path.as_str()) else {
11025            return Err(invalid_feed(format!(
11026                "downloaded pack contains unsigned path `{path}`"
11027            )));
11028        };
11029        if *declared_bytes != bytes.len() as u64
11030            || *sha256 != format!("{:x}", Sha256::digest(bytes))
11031        {
11032            return Err(invalid_feed(format!(
11033                "downloaded file `{path}` differs from its signed manifest"
11034            )));
11035        }
11036    }
11037    Ok(())
11038}
11039
11040/// Collect the files a push sends: the store's owned text — `DB.md`,
11041/// `assets.jsonl` when present, and every content `.md` under `records/` and
11042/// `sources/` (the store walk, which already excludes hidden dirs like
11043/// `.dbmd/`, the `log/` archive, and derived `index.*` catalogs; the hub
11044/// derives its own index, and local history stays local). Returns
11045/// `(store-relative path, content)` pairs, path-sorted.
11046pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
11047    require_hardened_filesystem("sync push")?;
11048    preflight_push_ownership(store)?;
11049    let mut out: Vec<(String, String)> = Vec::new();
11050    let mut total = 0u64;
11051
11052    let mut read_text = |rel: &str| -> LinkResult<String> {
11053        let bytes = store.read_bounded(Path::new(rel), MAX_STORE_BYTES.saturating_sub(total))?;
11054        total = total
11055            .checked_add(bytes.len() as u64)
11056            .ok_or_else(|| LinkError::PushTooLarge {
11057                detail: "uncompressed byte count overflow".to_string(),
11058            })?;
11059        if total > MAX_STORE_BYTES {
11060            return Err(LinkError::PushTooLarge {
11061                detail: format!("{total} uncompressed bytes"),
11062            });
11063        }
11064        String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
11065            path: rel.to_string(),
11066        })
11067    };
11068
11069    out.push(("DB.md".to_string(), read_text("DB.md")?));
11070    if store
11071        .regular_file_exists(Path::new("assets.jsonl"))
11072        .unwrap_or(false)
11073    {
11074        out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
11075    }
11076
11077    for rel in store.walk()? {
11078        let rel_str = rel.to_string_lossy().replace('\\', "/");
11079        if !safe_store_rel_path(&rel_str) {
11080            // A locally-legal name outside the hub's portable charset cannot
11081            // travel this wire; refusing beats silently dropping it.
11082            return Err(LinkError::UnsafePath { path: rel_str });
11083        }
11084        let content = read_text(&rel_str)?;
11085        out.push((rel_str, content));
11086    }
11087
11088    out.sort_by(|a, b| a.0.cmp(&b.0));
11089    Ok(out)
11090}
11091
11092/// Refuse to build a destructive whole-store snapshot from an ambiguous local
11093/// tree. Ordinary read-only walks safely prune foreign paths, but a push that
11094/// silently omitted them could delete the hosted copies of those paths.
11095fn preflight_push_ownership(store: &Store) -> LinkResult<()> {
11096    if let Some(nested) = store.nested_store_roots()?.into_iter().next() {
11097        return Err(LinkError::from(std::io::Error::new(
11098            std::io::ErrorKind::PermissionDenied,
11099            format!("cannot push: nested db.md store at {}", nested.display()),
11100        )));
11101    }
11102
11103    if let Some(symlink) = store.unowned_symlinks()?.into_iter().next() {
11104        return Err(LinkError::from(std::io::Error::new(
11105            std::io::ErrorKind::PermissionDenied,
11106            format!(
11107                "cannot push: {} is a symlink outside the store ownership model",
11108                symlink.display()
11109            ),
11110        )));
11111    }
11112    Ok(())
11113}
11114
11115/// Push `files` to `brain` as a whole-store snapshot — the hub's push
11116/// semantics: the hosted copy becomes exactly this set (pull first if the
11117/// hosted side may have records the local copy lacks). Client-side caps
11118/// mirror the hub's JSON-path limits so an oversized push fails before the
11119/// upload.
11120pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
11121    require_safe_ref(brain)?;
11122    let remote = verified_remote_head(cfg, brain, false)?;
11123    if files.len() > MAX_PUSH_FILES {
11124        return Err(LinkError::PushTooLarge {
11125            detail: format!("{} files", files.len()),
11126        });
11127    }
11128    let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
11129    if raw_total > MAX_STORE_BYTES {
11130        return Err(LinkError::PushTooLarge {
11131            detail: format!("{raw_total} uncompressed bytes"),
11132        });
11133    }
11134
11135    // Self-custody (a brain key is configured): the JSON fast path is
11136    // hub-signed by construction, so every push goes through the pack flow
11137    // with a locally signed entry — the hub verifies and can never sign.
11138    if cfg.brain_key.is_none() {
11139        let body = json!({
11140            "files": files
11141                .iter()
11142                .map(|(p, c)| json!({ "path": p, "content": c }))
11143                .collect::<Vec<_>>(),
11144        });
11145        if body.to_string().len() <= MAX_PUSH_BYTES {
11146            let path = format!("/api/hub/brains/{brain}/push");
11147            let pushed = ensure_ok(
11148                request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11149                "sync push",
11150            )?;
11151            return Ok(pushed);
11152        }
11153    }
11154
11155    let pack = build_store_pack(files)?;
11156    if pack.len() as u64 > MAX_PACK_BYTES {
11157        return Err(LinkError::PushTooLarge {
11158            detail: format!("{} pack bytes", pack.len()),
11159        });
11160    }
11161    let sha256 = format!("{:x}", Sha256::digest(&pack));
11162    let mut meta = json!({ "sha256": sha256, "bytes": pack.len() });
11163    if let Some(key) = &cfg.brain_key {
11164        if !remote.head.verified {
11165            return Err(invalid_feed(
11166                "self-custody push requires a fully verified, unscoped feed head",
11167            ));
11168        }
11169        let identity = remote
11170            .identity
11171            .as_ref()
11172            .ok_or_else(|| invalid_feed("verified brain has no current identity"))?;
11173        let current_multikey = format!("ed25519:{}", identity.fingerprint);
11174        if key.multikey != current_multikey || key.public_key_spki != identity.public_key_spki {
11175            return Err(invalid_feed(
11176                "configured brain key is not the verified current brain identity",
11177            ));
11178        }
11179        // The verified state pins seq + prev; a concurrent writer surfaces as
11180        // the hub's 422 on commit (re-run to retry against the new head).
11181        let next_seq = remote
11182            .head
11183            .seq
11184            .checked_add(1)
11185            .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
11186        let mut manifest: Vec<WireFeedFile> = files
11187            .iter()
11188            .map(|(path, content)| WireFeedFile {
11189                path: path.clone(),
11190                sha256: format!("{:x}", Sha256::digest(content.as_bytes())),
11191                bytes: content.len() as u64,
11192            })
11193            .collect();
11194        manifest.sort_by(|a, b| a.path.cmp(&b.path));
11195        let ts = crate::now()
11196            .with_timezone(&chrono::Utc)
11197            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
11198            .to_string();
11199        let entry = self_custody_entry(
11200            key,
11201            next_seq,
11202            ts,
11203            &sha256,
11204            &manifest,
11205            remote.head.feed_hash.as_deref(),
11206        )?;
11207        meta["entry"] = Value::String(entry);
11208    }
11209    let presigned = ensure_ok(
11210        request(
11211            cfg,
11212            "POST",
11213            &format!("/api/hub/brains/{brain}/packs/presign"),
11214            Some(&meta),
11215            Auth::Required,
11216        )?,
11217        "prepare pack upload",
11218    )?;
11219    let url = presigned
11220        .get("url")
11221        .and_then(Value::as_str)
11222        .ok_or_else(|| LinkError::InvalidPack {
11223            message: "the hub returned no upload URL".to_string(),
11224        })?;
11225    put_presigned(
11226        cfg,
11227        url,
11228        presigned.get("headers").unwrap_or(&Value::Null),
11229        &pack,
11230    )?;
11231    let committed = ensure_ok(
11232        request(
11233            cfg,
11234            "POST",
11235            &format!("/api/hub/brains/{brain}/packs/commit"),
11236            Some(&meta),
11237            Auth::Required,
11238        )?,
11239        "commit pack",
11240    )?;
11241    Ok(committed)
11242}
11243
11244fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
11245    const LOCAL_HEADER: u32 = 0x0403_4b50;
11246    const CENTRAL_HEADER: u32 = 0x0201_4b50;
11247    const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
11248    const VERSION_20: u16 = 20;
11249    const MADE_BY_UNIX_20: u16 = (3 << 8) | VERSION_20;
11250    const UTF8_FLAG: u16 = 1 << 11;
11251    const STORED: u16 = 0;
11252    const DOS_TIME_MIDNIGHT: u16 = 0;
11253    const DOS_DATE_1980_01_01: u16 = (1 << 5) | 1;
11254    const UNIX_REGULAR_0600: u32 = 0o100600 << 16;
11255
11256    struct CentralEntry<'a> {
11257        name: &'a [u8],
11258        crc32: u32,
11259        size: u32,
11260        local_offset: u32,
11261    }
11262
11263    fn push_u16(out: &mut Vec<u8>, value: u16) {
11264        out.extend_from_slice(&value.to_le_bytes());
11265    }
11266
11267    fn push_u32(out: &mut Vec<u8>, value: u32) {
11268        out.extend_from_slice(&value.to_le_bytes());
11269    }
11270
11271    if files.is_empty() {
11272        return Err(LinkError::InvalidPack {
11273            message: "cannot create an empty snapshot pack".to_string(),
11274        });
11275    }
11276    if files.len() > u16::MAX as usize {
11277        return Err(LinkError::PushTooLarge {
11278            detail: format!(
11279                "{} files (canonical ZIP32 packs cap at {})",
11280                files.len(),
11281                u16::MAX
11282            ),
11283        });
11284    }
11285
11286    let mut sorted: Vec<_> = files.iter().collect();
11287    sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
11288    let mut previous: Option<&str> = None;
11289    for (path, content) in &sorted {
11290        if !safe_store_rel_path(path) {
11291            return Err(LinkError::UnsafePath {
11292                path: (*path).clone(),
11293            });
11294        }
11295        if previous == Some(path.as_str()) {
11296            return Err(LinkError::InvalidPack {
11297                message: format!("duplicate path `{path}`"),
11298            });
11299        }
11300        previous = Some(path.as_str());
11301        u32::try_from(content.len()).map_err(|_| LinkError::PushTooLarge {
11302            detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
11303        })?;
11304    }
11305
11306    let mut out = Vec::new();
11307    let mut central = Vec::with_capacity(sorted.len());
11308    for (path, content) in sorted {
11309        let name = path.as_bytes();
11310        let name_len = u16::try_from(name.len()).map_err(|_| LinkError::InvalidPack {
11311            message: format!("ZIP entry name is too long: `{path}`"),
11312        })?;
11313        let bytes = content.as_bytes();
11314        let size = u32::try_from(bytes.len()).map_err(|_| LinkError::PushTooLarge {
11315            detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
11316        })?;
11317        let local_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
11318            detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
11319        })?;
11320        let crc32 = crc32fast::hash(bytes);
11321
11322        // Canonical local header: sizes and CRC are known up front. Bit 3 is
11323        // deliberately clear, so there is no trailing data descriptor.
11324        push_u32(&mut out, LOCAL_HEADER);
11325        push_u16(&mut out, VERSION_20);
11326        push_u16(&mut out, UTF8_FLAG);
11327        push_u16(&mut out, STORED);
11328        push_u16(&mut out, DOS_TIME_MIDNIGHT);
11329        push_u16(&mut out, DOS_DATE_1980_01_01);
11330        push_u32(&mut out, crc32);
11331        push_u32(&mut out, size);
11332        push_u32(&mut out, size);
11333        push_u16(&mut out, name_len);
11334        push_u16(&mut out, 0); // no local extra data
11335        out.extend_from_slice(name);
11336        out.extend_from_slice(bytes);
11337
11338        central.push(CentralEntry {
11339            name,
11340            crc32,
11341            size,
11342            local_offset,
11343        });
11344    }
11345
11346    let central_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
11347        detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
11348    })?;
11349    for entry in &central {
11350        push_u32(&mut out, CENTRAL_HEADER);
11351        push_u16(&mut out, MADE_BY_UNIX_20);
11352        push_u16(&mut out, VERSION_20);
11353        push_u16(&mut out, UTF8_FLAG);
11354        push_u16(&mut out, STORED);
11355        push_u16(&mut out, DOS_TIME_MIDNIGHT);
11356        push_u16(&mut out, DOS_DATE_1980_01_01);
11357        push_u32(&mut out, entry.crc32);
11358        push_u32(&mut out, entry.size);
11359        push_u32(&mut out, entry.size);
11360        push_u16(&mut out, entry.name.len() as u16);
11361        push_u16(&mut out, 0); // no central extra data
11362        push_u16(&mut out, 0); // no file comment
11363        push_u16(&mut out, 0); // disk number
11364        push_u16(&mut out, 0); // internal attributes
11365        push_u32(&mut out, UNIX_REGULAR_0600);
11366        push_u32(&mut out, entry.local_offset);
11367        out.extend_from_slice(entry.name);
11368    }
11369    let central_size = u32::try_from(out.len())
11370        .ok()
11371        .and_then(|end| end.checked_sub(central_offset))
11372        .ok_or_else(|| LinkError::PushTooLarge {
11373            detail: "canonical ZIP32 central directory exceeds its limit".to_string(),
11374        })?;
11375    let entry_count = central.len() as u16;
11376
11377    push_u32(&mut out, END_OF_CENTRAL_DIRECTORY);
11378    push_u16(&mut out, 0); // this disk
11379    push_u16(&mut out, 0); // central directory disk
11380    push_u16(&mut out, entry_count);
11381    push_u16(&mut out, entry_count);
11382    push_u32(&mut out, central_size);
11383    push_u32(&mut out, central_offset);
11384    push_u16(&mut out, 0); // no archive comment
11385
11386    if out.len() > u32::MAX as usize {
11387        return Err(LinkError::PushTooLarge {
11388            detail: "canonical ZIP32 pack exceeds 4 GiB".to_string(),
11389        });
11390    }
11391    Ok(out)
11392}
11393
11394// ─────────────────────────────────────────────────────────────────────────────
11395// grant — issue / list / revoke capabilities (owner-side)
11396// ─────────────────────────────────────────────────────────────────────────────
11397
11398/// The two capabilities a v0 hub enforces.
11399#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11400pub enum Capability {
11401    /// Read the granted slice.
11402    Read,
11403    /// Read and push (whole-store; a path-scoped grant is read-only).
11404    Write,
11405}
11406
11407impl Capability {
11408    /// The wire form.
11409    pub fn as_str(self) -> &'static str {
11410        match self {
11411            Capability::Read => "read",
11412            Capability::Write => "write",
11413        }
11414    }
11415}
11416
11417/// Issue (or refresh) a grant on `brain` to `grantee` — a hub principal named
11418/// by email in v0 (the protocol's near-term simplification; key-named
11419/// grantees arrive with the signing layer). `scope` is a store-path prefix
11420/// (the hub's enforcement unit); `until` an ISO 8601 expiry, absent = until
11421/// revoked.
11422pub fn grant_issue(
11423    cfg: &HubConfig,
11424    brain: &str,
11425    grantee: &str,
11426    can: Capability,
11427    scope: Option<&str>,
11428    until: Option<&str>,
11429) -> LinkResult<Value> {
11430    require_safe_ref(brain)?;
11431    // Grantee shape decides the axis: a base64url Ed25519 SPKI is a bare
11432    // multikey holder (link.md §6 cross-party keys — no hub account; the
11433    // printed `publicKeySpki` from `dbmd key generate`); anything else is a
11434    // hub principal named by email.
11435    let is_key_grantee = URL_SAFE_NO_PAD
11436        .decode(grantee)
11437        .map(|der| der.len() == 44 && der.starts_with(&ED25519_SPKI_PREFIX))
11438        .unwrap_or(false);
11439    if let Some(head) = v2_verified_head(cfg, brain)? {
11440        if is_key_grantee {
11441            let scope = scope.unwrap_or("");
11442            let preset = match can {
11443                Capability::Read => "viewer",
11444                Capability::Write => "editor",
11445            };
11446            let entropy = format!(
11447                "{}\0{}\0{}\0{}\0{}\0{}\0{}",
11448                normalized_origin(&cfg.hub)?,
11449                head.brain_id,
11450                head.control_revision,
11451                grantee,
11452                preset,
11453                scope,
11454                until.unwrap_or("")
11455            );
11456            let mut body = json!({
11457                "context": "external",
11458                "expected_control_revision": head.control_revision,
11459                "mutation_id": format!("dbmd-grant-{}", content_sha256(entropy.as_bytes())),
11460                "preset": preset,
11461                "principal_kind": "key",
11462                "public_key": grantee,
11463                "scope": scope,
11464                "scope_kind": "prefix",
11465            });
11466            if let Some(value) = until {
11467                body["expires_at"] = json!(value);
11468            }
11469            let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
11470            let response = ensure_ok(
11471                request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11472                "v2 grant issue",
11473            )?;
11474            let expected_fingerprint = identity_fingerprint(grantee)?;
11475            if response.get("v").and_then(Value::as_u64) != Some(2)
11476                || response
11477                    .get("id")
11478                    .and_then(Value::as_str)
11479                    .is_none_or(|id| !crate::ulid::is_ulid(id))
11480                || response.get("principal_kind").and_then(Value::as_str) != Some("key")
11481                || response.get("principal_id").and_then(Value::as_str)
11482                    != Some(expected_fingerprint.as_str())
11483                || response
11484                    .get("control_revision")
11485                    .and_then(Value::as_str)
11486                    .is_none_or(|value| !is_sha256(value))
11487            {
11488                return Err(invalid_feed(
11489                    "v2 grant issue response is not authority-bound",
11490                ));
11491            }
11492            return Ok(response);
11493        }
11494        // Account invitations remain a product workflow. The compatibility
11495        // endpoint resolves email/account identity, then commits a native v2
11496        // grant with the same control-plane recovery boundary. The v2 head
11497        // above is already the authenticated authority checkpoint; never
11498        // fall through to the retired v1 feed verifier for this request.
11499        let mut body = json!({ "email": grantee, "capability": can.as_str() });
11500        if let Some(value) = scope {
11501            body["scopePrefix"] = json!(value);
11502        }
11503        if let Some(value) = until {
11504            body["expiresAt"] = json!(value);
11505        }
11506        let path = format!("/api/hub/brains/{}/grants", head.brain_id);
11507        return ensure_ok(
11508            request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11509            "account grant issue",
11510        );
11511    }
11512    let _ = verified_remote_head(cfg, brain, false)?;
11513    let mut body = if is_key_grantee {
11514        json!({ "keySpki": grantee, "capability": can.as_str() })
11515    } else {
11516        json!({ "email": grantee, "capability": can.as_str() })
11517    };
11518    if let Some(s) = scope {
11519        body["scopePrefix"] = json!(s);
11520    }
11521    if let Some(u) = until {
11522        body["expiresAt"] = json!(u);
11523    }
11524    let path = format!("/api/hub/brains/{brain}/grants");
11525    ensure_ok(
11526        request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11527        "grant issue",
11528    )
11529}
11530
11531/// List the active grants (and pending invites) on `brain`. Owner-side.
11532pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
11533    require_safe_ref(brain)?;
11534    if let Some(head) = v2_verified_head(cfg, brain)? {
11535        let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
11536        let response = ensure_ok(
11537            request(cfg, "GET", &path, None, Auth::Required)?,
11538            "v2 grant list",
11539        )?;
11540        if response.get("v").and_then(Value::as_u64) != Some(2)
11541            || response.get("control_revision").and_then(Value::as_str)
11542                != Some(head.control_revision.as_str())
11543            || !response.get("grants").is_some_and(Value::is_array)
11544        {
11545            return Err(invalid_feed(
11546                "v2 grant list is not bound to the verified authority",
11547            ));
11548        }
11549        return Ok(response);
11550    }
11551    let _ = verified_remote_head(cfg, brain, false)?;
11552    let path = format!("/api/hub/brains/{brain}/grants");
11553    ensure_ok(
11554        request(cfg, "GET", &path, None, Auth::Required)?,
11555        "grant list",
11556    )
11557}
11558
11559/// Revoke a grant (or cancel a pending invite) by id. Owner-side; revocation
11560/// is soft on the hub (the audit trail survives).
11561pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
11562    require_safe_ref(brain)?;
11563    require_safe_grant_id(grant_id)?;
11564    if let Some(head) = v2_verified_head(cfg, brain)? {
11565        let entropy = format!(
11566            "{}\0{}\0{}\0{}",
11567            normalized_origin(&cfg.hub)?,
11568            head.brain_id,
11569            head.control_revision,
11570            grant_id
11571        );
11572        let body = json!({
11573            "expected_control_revision": head.control_revision,
11574            "mutation_id": format!("dbmd-revoke-{}", content_sha256(entropy.as_bytes())),
11575        });
11576        let path = format!("/api/hub/brains/{}/v2/grants/{grant_id}", head.brain_id);
11577        let response = ensure_ok(
11578            request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
11579            "v2 grant revoke",
11580        )?;
11581        if response.get("v").and_then(Value::as_u64) != Some(2)
11582            || response.get("id").and_then(Value::as_str) != Some(grant_id)
11583            || response.get("revoked").and_then(Value::as_bool) != Some(true)
11584            || response
11585                .get("control_revision")
11586                .and_then(Value::as_str)
11587                .is_none_or(|value| !is_sha256(value))
11588        {
11589            return Err(invalid_feed(
11590                "v2 grant revocation response is not authority-bound",
11591            ));
11592        }
11593        return Ok(response);
11594    }
11595    let _ = verified_remote_head(cfg, brain, false)?;
11596    let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
11597    ensure_ok(
11598        request(cfg, "DELETE", &path, None, Auth::Required)?,
11599        "grant revoke",
11600    )
11601}
11602
11603// ─────────────────────────────────────────────────────────────────────────────
11604// permissioned v2 proposals — inspect, accept, reject
11605// ─────────────────────────────────────────────────────────────────────────────
11606
11607#[derive(Debug)]
11608struct VerifiedV2Proposal {
11609    value: Value,
11610    changes: Value,
11611    blobs: Vec<(String, u64, String)>,
11612}
11613
11614fn require_proposal_id(id: &str) -> LinkResult<()> {
11615    if crate::ulid::is_ulid(id) {
11616        Ok(())
11617    } else {
11618        Err(invalid_feed("proposal id is not a lowercase ULID"))
11619    }
11620}
11621
11622fn verified_v2_proposal(
11623    cfg: &HubConfig,
11624    head: &V2VerifiedHead,
11625    proposal_id: &str,
11626) -> LinkResult<VerifiedV2Proposal> {
11627    require_proposal_id(proposal_id)?;
11628    if head.view_kind != "full" {
11629        return Err(invalid_feed(
11630            "proposal review requires a full readable view",
11631        ));
11632    }
11633    let path = format!(
11634        "/api/hub/brains/{}/v2/proposals/{proposal_id}",
11635        head.brain_id
11636    );
11637    let value = ensure_ok(
11638        request_capped(
11639            cfg,
11640            "GET",
11641            &path,
11642            None,
11643            Auth::Required,
11644            MAX_FEED_RESPONSE_BYTES,
11645        )?,
11646        "v2 proposal",
11647    )?;
11648    verify_v2_proposal_value(head, proposal_id, value)
11649}
11650
11651fn verify_v2_proposal_value(
11652    head: &V2VerifiedHead,
11653    proposal_id: &str,
11654    value: Value,
11655) -> LinkResult<VerifiedV2Proposal> {
11656    if value.get("v").and_then(Value::as_u64) != Some(2) {
11657        return Err(invalid_feed("proposal response has an invalid version"));
11658    }
11659    let proposal = value
11660        .get("proposal")
11661        .and_then(Value::as_object)
11662        .ok_or_else(|| invalid_feed("proposal response has no proposal"))?;
11663    if proposal.get("id").and_then(Value::as_str) != Some(proposal_id) {
11664        return Err(invalid_feed("proposal response changed its id"));
11665    }
11666    let payload_hash = proposal
11667        .get("payload_sha256")
11668        .and_then(Value::as_str)
11669        .filter(|hash| is_sha256(hash))
11670        .ok_or_else(|| invalid_feed("proposal has no payload address"))?;
11671    let clear_hash = proposal
11672        .get("clear_sha256")
11673        .and_then(Value::as_str)
11674        .filter(|hash| is_sha256(hash))
11675        .ok_or_else(|| invalid_feed("proposal has no clear payload digest"))?;
11676    let submission_hash = proposal
11677        .get("submission_claim_sha256")
11678        .and_then(Value::as_str)
11679        .filter(|hash| is_sha256(hash))
11680        .ok_or_else(|| invalid_feed("proposal has no submission claim address"))?;
11681    let submission = STANDARD
11682        .decode(
11683            proposal
11684                .get("submission_claim_base64")
11685                .and_then(Value::as_str)
11686                .ok_or_else(|| invalid_feed("proposal has no submission claim"))?,
11687        )
11688        .map_err(|_| invalid_feed("proposal submission claim is not base64"))?;
11689    let submission_value: Value = serde_json::from_slice(&submission)
11690        .map_err(|_| invalid_feed("proposal submission claim is not JSON"))?;
11691    if crate::linkmd_v2::canonical_bytes(&submission_value)
11692        .map_err(|error| invalid_feed(error.to_string()))?
11693        != submission
11694        || crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &submission)
11695            .map_err(|error| invalid_feed(error.to_string()))?
11696            != submission_hash
11697    {
11698        return Err(invalid_feed(
11699            "proposal submission claim is not canonical or addressed",
11700        ));
11701    }
11702    let envelope = submission_value
11703        .as_object()
11704        .ok_or_else(|| invalid_feed("proposal submission claim is not an object"))?;
11705    let claim = envelope
11706        .get("claim")
11707        .ok_or_else(|| invalid_feed("proposal submission claim body is missing"))?;
11708    let claim_object = claim
11709        .as_object()
11710        .ok_or_else(|| invalid_feed("proposal submission claim body is not an object"))?;
11711    let actor_root = claim_object
11712        .get("actor_root")
11713        .and_then(Value::as_object)
11714        .ok_or_else(|| invalid_feed("proposal submission actor root is missing"))?;
11715    let public_key = envelope
11716        .get("public_key")
11717        .and_then(Value::as_str)
11718        .ok_or_else(|| invalid_feed("proposal submission signer is missing"))?;
11719    let fingerprint = envelope
11720        .get("fingerprint")
11721        .and_then(Value::as_str)
11722        .ok_or_else(|| invalid_feed("proposal submission fingerprint is missing"))?;
11723    let signature = envelope
11724        .get("sig")
11725        .and_then(Value::as_str)
11726        .ok_or_else(|| invalid_feed("proposal submission signature is missing"))?;
11727    let claim_bytes = crate::linkmd_v2::canonical_bytes(claim)
11728        .map_err(|error| invalid_feed(error.to_string()))?;
11729    let der = verify_v2_spki_signature(public_key, &claim_bytes, signature)?;
11730    let signer = format!("{fingerprint}:{public_key}");
11731    let actor_class = actor_root.get("actor_class").and_then(Value::as_str);
11732    let grants = actor_root.get("grants").and_then(Value::as_array);
11733    let grants_are_canonical = grants.is_some_and(|items| {
11734        let mut prior: Option<&str> = None;
11735        items.iter().all(|item| {
11736            let Some(grant) = item.as_str() else {
11737                return false;
11738            };
11739            if !crate::ulid::is_ulid(grant) || prior.is_some_and(|value| value >= grant) {
11740                return false;
11741            }
11742            prior = Some(grant);
11743            true
11744        })
11745    });
11746    let optional_actor_field = |name: &str| {
11747        actor_root.get(name).is_some_and(|value| {
11748            value.is_null() || value.as_str().is_some_and(|text| !text.is_empty())
11749        })
11750    };
11751    let submitted_at = claim_object.get("submitted_at").and_then(Value::as_str);
11752    if claim_object.get("v").and_then(Value::as_u64) != Some(2)
11753        || format!("{:x}", Sha256::digest(&der)) != fingerprint
11754        || head
11755            .trust
11756            .hub_signer
11757            .as_ref()
11758            .is_some_and(|known| known != &signer)
11759        || !matches!(
11760            actor_class,
11761            Some(
11762                "user"
11763                    | "owned_agent"
11764                    | "foreign_key"
11765                    | "curation"
11766                    | "inbox"
11767                    | "restore"
11768                    | "migration"
11769                    | "operator_recovery"
11770            )
11771        )
11772        || actor_root
11773            .get("principal")
11774            .and_then(Value::as_str)
11775            .is_none_or(|value| value.is_empty())
11776        || actor_root
11777            .get("credential")
11778            .and_then(Value::as_str)
11779            .is_none_or(|value| value.is_empty())
11780        || !optional_actor_field("organization")
11781        || !optional_actor_field("role")
11782        || !grants_are_canonical
11783        || claim.get("brain").and_then(Value::as_str) != Some(head.brain_id.as_str())
11784        || claim.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
11785        || !claim_object
11786            .get("mutation_id")
11787            .and_then(Value::as_str)
11788            .is_some_and(|value| {
11789                !value.is_empty()
11790                    && value.len() <= 128
11791                    && value.chars().enumerate().all(|(index, char)| {
11792                        char.is_ascii_alphanumeric()
11793                            || (index > 0 && matches!(char, '.' | '_' | ':' | '-'))
11794                    })
11795            })
11796        || claim.get("payload_sha256").and_then(Value::as_str) != Some(payload_hash)
11797        || claim.get("clear_sha256").and_then(Value::as_str) != Some(clear_hash)
11798        || !claim_object
11799            .get("control_revision")
11800            .and_then(Value::as_str)
11801            .is_some_and(is_sha256)
11802        || submitted_at.is_none_or(|value| {
11803            chrono::DateTime::parse_from_rfc3339(value).is_err()
11804                || proposal.get("submitted_at").and_then(Value::as_str) != Some(value)
11805        })
11806        || !proposal
11807            .get("state")
11808            .and_then(Value::as_str)
11809            .is_some_and(|value| matches!(value, "pending" | "accepted" | "rejected" | "expired"))
11810        || proposal
11811            .get("expires_at")
11812            .and_then(Value::as_str)
11813            .is_none_or(|value| chrono::DateTime::parse_from_rfc3339(value).is_err())
11814        || proposal
11815            .get("proposer")
11816            .and_then(Value::as_object)
11817            .and_then(|value| value.get("class"))
11818            .and_then(Value::as_str)
11819            != actor_class
11820    {
11821        return Err(invalid_feed(
11822            "proposal submission claim does not bind the verified proposal",
11823        ));
11824    }
11825    let changes_b64 = proposal
11826        .get("changes_base64")
11827        .and_then(Value::as_str)
11828        .ok_or_else(|| invalid_feed("proposal has no changeset"))?;
11829    let changes_bytes = STANDARD
11830        .decode(changes_b64)
11831        .map_err(|_| invalid_feed("proposal changeset is not base64"))?;
11832    let changes: Value = serde_json::from_slice(&changes_bytes)
11833        .map_err(|_| invalid_feed("proposal changeset is not JSON"))?;
11834    if crate::linkmd_v2::canonical_bytes(&changes)
11835        .map_err(|error| invalid_feed(error.to_string()))?
11836        != changes_bytes
11837        || changes.get("v").and_then(Value::as_u64) != Some(2)
11838        || !changes.get("operations").is_some_and(Value::is_array)
11839    {
11840        return Err(invalid_feed("proposal changeset is not canonical v2"));
11841    }
11842    let blob_values = proposal
11843        .get("blobs")
11844        .and_then(Value::as_array)
11845        .ok_or_else(|| invalid_feed("proposal has no blob declarations"))?;
11846    let mut blobs = Vec::with_capacity(blob_values.len());
11847    let mut descriptor_blobs = Vec::with_capacity(blob_values.len());
11848    let mut prior_hash: Option<String> = None;
11849    for item in blob_values {
11850        let hash = item
11851            .get("sha256")
11852            .and_then(Value::as_str)
11853            .filter(|hash| is_sha256(hash))
11854            .ok_or_else(|| invalid_feed("proposal blob has no address"))?;
11855        let bytes = item
11856            .get("bytes")
11857            .and_then(Value::as_u64)
11858            .filter(|bytes| *bytes <= MAX_STORE_BYTES)
11859            .ok_or_else(|| invalid_feed("proposal blob has an invalid size"))?;
11860        if prior_hash.as_deref().is_some_and(|prior| prior >= hash) {
11861            return Err(invalid_feed(
11862                "proposal blob declarations are not unique and sorted",
11863            ));
11864        }
11865        prior_hash = Some(hash.to_string());
11866        let endpoint = item
11867            .get("endpoint")
11868            .and_then(Value::as_str)
11869            .ok_or_else(|| invalid_feed("proposal blob has no endpoint"))?;
11870        let expected_endpoint = format!(
11871            "/api/hub/brains/{}/v2/proposals/{proposal_id}/blob?sha256={hash}",
11872            head.brain_id
11873        );
11874        if endpoint != expected_endpoint {
11875            return Err(invalid_feed("proposal blob endpoint is not origin-bound"));
11876        }
11877        descriptor_blobs.push(json!({ "bytes": bytes, "sha256": hash }));
11878        blobs.push((hash.to_string(), bytes, endpoint.to_string()));
11879    }
11880    let descriptor = json!({
11881        "base": proposal.get("base").cloned().unwrap_or(Value::Null),
11882        "blobs": descriptor_blobs,
11883        "changes_base64": changes_b64,
11884        "rebase": proposal.get("rebase").cloned().unwrap_or(Value::Null),
11885        "v": 2,
11886    });
11887    let descriptor_bytes = crate::linkmd_v2::canonical_bytes(&descriptor)
11888        .map_err(|error| invalid_feed(error.to_string()))?;
11889    if content_sha256(&descriptor_bytes) != clear_hash {
11890        return Err(invalid_feed(
11891            "proposal clear payload differs from its signed submission claim",
11892        ));
11893    }
11894    Ok(VerifiedV2Proposal {
11895        value,
11896        changes,
11897        blobs,
11898    })
11899}
11900
11901pub fn proposal_list(
11902    cfg: &HubConfig,
11903    brain: &str,
11904    state: &str,
11905    after: Option<&str>,
11906    limit: usize,
11907) -> LinkResult<Value> {
11908    require_safe_ref(brain)?;
11909    if !matches!(state, "pending" | "accepted" | "rejected" | "expired") {
11910        return Err(invalid_feed("proposal state is invalid"));
11911    }
11912    if after.is_some_and(|value| !crate::ulid::is_ulid(value)) {
11913        return Err(invalid_feed("proposal cursor is invalid"));
11914    }
11915    let head = v2_verified_head(cfg, brain)?
11916        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11917    let path = format!(
11918        "/api/hub/brains/{}/v2/proposals?state={state}&limit={}{}",
11919        head.brain_id,
11920        limit.clamp(1, 100),
11921        after.map_or_else(String::new, |value| format!("&after={value}"))
11922    );
11923    ensure_ok(
11924        request_capped(
11925            cfg,
11926            "GET",
11927            &path,
11928            None,
11929            Auth::Required,
11930            MAX_FEED_RESPONSE_BYTES,
11931        )?,
11932        "v2 proposal list",
11933    )
11934}
11935
11936pub fn proposal_show(cfg: &HubConfig, brain: &str, proposal_id: &str) -> LinkResult<Value> {
11937    require_safe_ref(brain)?;
11938    let head = v2_verified_head(cfg, brain)?
11939        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11940    Ok(verified_v2_proposal(cfg, &head, proposal_id)?.value)
11941}
11942
11943pub fn proposal_reject(
11944    cfg: &HubConfig,
11945    brain: &str,
11946    proposal_id: &str,
11947    mutation_id: &str,
11948    reason: &str,
11949) -> LinkResult<Value> {
11950    require_safe_ref(brain)?;
11951    require_proposal_id(proposal_id)?;
11952    let head = v2_verified_head(cfg, brain)?
11953        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11954    let _ = verified_v2_proposal(cfg, &head, proposal_id)?;
11955    let body = json!({
11956        "mutation_id": mutation_id,
11957        "control_revision": head.control_revision,
11958        "reason": reason,
11959    });
11960    let path = format!(
11961        "/api/hub/brains/{}/v2/proposals/{proposal_id}",
11962        head.brain_id
11963    );
11964    ensure_ok(
11965        request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
11966        "v2 proposal rejection",
11967    )
11968}
11969
11970pub fn proposal_accept_exact(
11971    cfg: &HubConfig,
11972    brain: &str,
11973    proposal_id: &str,
11974    mutation_id: &str,
11975    reason: &str,
11976) -> LinkResult<Value> {
11977    require_safe_ref(brain)?;
11978    require_proposal_id(proposal_id)?;
11979    let head = v2_verified_head(cfg, brain)?
11980        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11981    let proposal = verified_v2_proposal(cfg, &head, proposal_id)?;
11982    let operations = proposal
11983        .changes
11984        .get("operations")
11985        .and_then(Value::as_array)
11986        .cloned()
11987        .ok_or_else(|| invalid_feed("proposal changeset has no operations"))?;
11988    if operations.is_empty() || operations.len() > MAX_PUSH_FILES {
11989        return Err(invalid_feed("proposal operation count is invalid"));
11990    }
11991    let mut downloaded = std::collections::BTreeMap::new();
11992    for (hash, bytes, endpoint) in &proposal.blobs {
11993        let body = ensure_raw_ok(
11994            request_raw(cfg, "GET", endpoint, None, Auth::Required, *bytes)?,
11995            "v2 proposal blob",
11996        )?;
11997        if body.len() as u64 != *bytes || content_sha256(&body) != *hash {
11998            return Err(invalid_feed("proposal blob does not match its declaration"));
11999        }
12000        downloaded.insert(hash.clone(), body);
12001    }
12002    let remote = files_for_v2_view(
12003        &head,
12004        v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
12005    );
12006    let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
12007    let mut expected_candidate = remote.clone();
12008    let mut expected_candidate_assets = remote_assets;
12009    for operation in &operations {
12010        let op = operation
12011            .get("op")
12012            .and_then(Value::as_str)
12013            .ok_or_else(|| invalid_feed("proposal operation has no kind"))?;
12014        match op {
12015            "put" | "restore" => {
12016                let path = operation
12017                    .get("path")
12018                    .and_then(Value::as_str)
12019                    .ok_or_else(|| invalid_feed("proposal write has no path"))?;
12020                crate::linkmd_v2::normalize_path(path)
12021                    .map_err(|error| invalid_feed(error.to_string()))?;
12022                let hash = operation
12023                    .get("blob")
12024                    .and_then(Value::as_str)
12025                    .filter(|hash| is_sha256(hash))
12026                    .ok_or_else(|| invalid_feed("proposal write has no blob"))?;
12027                let bytes = operation
12028                    .get("bytes")
12029                    .and_then(Value::as_u64)
12030                    .ok_or_else(|| invalid_feed("proposal write has no size"))?;
12031                expected_candidate.insert(
12032                    path.to_string(),
12033                    V2BaselineFile {
12034                        sha256: hash.to_string(),
12035                        bytes,
12036                        proof: None,
12037                    },
12038                );
12039            }
12040            "delete" | "withdraw_from_hosting" => {
12041                let path = operation
12042                    .get("path")
12043                    .and_then(Value::as_str)
12044                    .ok_or_else(|| invalid_feed("proposal removal has no path"))?;
12045                crate::linkmd_v2::normalize_path(path)
12046                    .map_err(|error| invalid_feed(error.to_string()))?;
12047                expected_candidate.remove(path);
12048            }
12049            "rename" => {
12050                let from = operation
12051                    .get("from")
12052                    .and_then(Value::as_str)
12053                    .ok_or_else(|| invalid_feed("proposal rename has no source"))?;
12054                let to = operation
12055                    .get("to")
12056                    .and_then(Value::as_str)
12057                    .ok_or_else(|| invalid_feed("proposal rename has no destination"))?;
12058                crate::linkmd_v2::normalize_path(from)
12059                    .and_then(|_| crate::linkmd_v2::normalize_path(to))
12060                    .map_err(|error| invalid_feed(error.to_string()))?;
12061                let hash = operation
12062                    .get("blob")
12063                    .and_then(Value::as_str)
12064                    .filter(|hash| is_sha256(hash))
12065                    .ok_or_else(|| invalid_feed("proposal rename has no blob"))?;
12066                let bytes = operation
12067                    .get("bytes")
12068                    .and_then(Value::as_u64)
12069                    .ok_or_else(|| invalid_feed("proposal rename has no size"))?;
12070                expected_candidate.remove(from);
12071                expected_candidate.insert(
12072                    to.to_string(),
12073                    V2BaselineFile {
12074                        sha256: hash.to_string(),
12075                        bytes,
12076                        proof: None,
12077                    },
12078                );
12079            }
12080            "asset_delete" => {
12081                let path = operation
12082                    .get("path")
12083                    .and_then(Value::as_str)
12084                    .ok_or_else(|| invalid_feed("proposal asset delete has no path"))?;
12085                expected_candidate_assets.remove(path);
12086            }
12087            "asset_withdraw" => {
12088                let path = operation
12089                    .get("path")
12090                    .and_then(Value::as_str)
12091                    .ok_or_else(|| invalid_feed("proposal asset withdrawal has no path"))?;
12092                let asset = expected_candidate_assets
12093                    .get_mut(path)
12094                    .ok_or_else(|| invalid_feed("proposal withdraws an unknown asset"))?;
12095                asset.disposition = "withheld".to_string();
12096                asset.leaf_hash.clear();
12097            }
12098            "asset_put" | "asset_resume" => {
12099                let path = operation
12100                    .get("path")
12101                    .and_then(Value::as_str)
12102                    .ok_or_else(|| invalid_feed("proposal asset write has no path"))?;
12103                let asset = operation
12104                    .get("asset")
12105                    .and_then(Value::as_object)
12106                    .ok_or_else(|| invalid_feed("proposal asset write has no value"))?;
12107                let blob_sha256 = asset
12108                    .get("blob_sha256")
12109                    .and_then(Value::as_str)
12110                    .filter(|hash| is_sha256(hash))
12111                    .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
12112                let bytes = asset
12113                    .get("bytes")
12114                    .and_then(Value::as_u64)
12115                    .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
12116                let media_type = asset
12117                    .get("media_type")
12118                    .and_then(Value::as_str)
12119                    .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
12120                let wrappers = asset
12121                    .get("wrappers")
12122                    .and_then(Value::as_array)
12123                    .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
12124                    .iter()
12125                    .map(|wrapper| {
12126                        wrapper
12127                            .as_str()
12128                            .map(str::to_string)
12129                            .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
12130                    })
12131                    .collect::<LinkResult<Vec<_>>>()?;
12132                let required = asset
12133                    .get("required")
12134                    .and_then(Value::as_bool)
12135                    .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
12136                let disposition = asset
12137                    .get("disposition")
12138                    .and_then(Value::as_str)
12139                    .filter(|value| matches!(*value, "hosted" | "withheld"))
12140                    .ok_or_else(|| invalid_feed("proposal asset disposition is invalid"))?;
12141                expected_candidate_assets.insert(
12142                    path.to_string(),
12143                    V2BaselineAsset {
12144                        blob_sha256: blob_sha256.to_string(),
12145                        bytes,
12146                        media_type: media_type.to_string(),
12147                        wrappers,
12148                        required,
12149                        disposition: disposition.to_string(),
12150                        leaf_hash: String::new(),
12151                    },
12152                );
12153            }
12154            _ => return Err(invalid_feed("proposal operation kind is unsupported")),
12155        }
12156    }
12157    let base = head.pointer.as_ref().map(|pointer| {
12158        json!({
12159            "seq": pointer.seq,
12160            "commit_hash": pointer.commit_hash,
12161            "content_root": pointer.content_root,
12162            "asset_root": pointer.asset_root,
12163        })
12164    });
12165    let mut body = json!({
12166        "mutation_id": mutation_id,
12167        "base": base,
12168        "rebase": "strict",
12169        "reason": reason,
12170        "operations": operations,
12171        "blobs": downloaded
12172            .iter()
12173            .map(|(sha256, bytes)| json!({
12174                "sha256": sha256,
12175                "bytes": bytes.len(),
12176                "content_base64": STANDARD.encode(bytes),
12177            }))
12178            .collect::<Vec<_>>(),
12179        "proposal_id": proposal_id,
12180        "proposal_mode": "exact",
12181    });
12182    let changed_bytes = downloaded.values().try_fold(0_usize, |total, bytes| {
12183        total
12184            .checked_add(bytes.len())
12185            .ok_or_else(|| LinkError::PushTooLarge {
12186                detail: "proposal changed-byte total overflow".to_string(),
12187            })
12188    })?;
12189    if changed_bytes > 3 * 1024 * 1024 || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
12190        let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
12191        for operation in &operations {
12192            let Some(kind) = operation.get("op").and_then(Value::as_str) else {
12193                return Err(invalid_feed("proposal upload operation has no kind"));
12194            };
12195            let hash = match kind {
12196                "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
12197                "asset_put" | "asset_resume" => operation
12198                    .get("asset")
12199                    .and_then(|asset| asset.get("blob_sha256"))
12200                    .and_then(Value::as_str),
12201                _ => None,
12202            };
12203            let Some(hash) = hash else { continue };
12204            let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
12205            if kind == "rename" {
12206                for field in ["from", "to"] {
12207                    coordinates.insert(
12208                        operation
12209                            .get(field)
12210                            .and_then(Value::as_str)
12211                            .ok_or_else(|| invalid_feed("proposal rename has no coordinate"))?
12212                            .to_string(),
12213                    );
12214                }
12215            } else {
12216                let path = operation
12217                    .get("path")
12218                    .and_then(Value::as_str)
12219                    .ok_or_else(|| invalid_feed("proposal upload has no coordinate"))?;
12220                coordinates.insert(if kind.starts_with("asset_") {
12221                    format!("assets/{path}")
12222                } else {
12223                    path.to_string()
12224                });
12225            }
12226        }
12227        let declarations = downloaded
12228            .iter()
12229            .map(|(sha256, bytes)| {
12230                json!({
12231                    "sha256": sha256,
12232                    "bytes": bytes.len(),
12233                    "coordinates": coordinates_by_hash
12234                        .get(sha256)
12235                        .into_iter()
12236                        .flatten()
12237                        .collect::<Vec<_>>(),
12238                })
12239            })
12240            .collect::<Vec<_>>();
12241        let mut items: Vec<Value> = Vec::with_capacity(downloaded.len());
12242        for batch in batch_upload_declarations(declarations) {
12243            let reserved = reserve_upload_window(
12244                cfg,
12245                &format!("/api/hub/brains/{}/v2/uploads", head.brain_id),
12246                &json!({ "blobs": batch }),
12247                "prepare proposal blob transport",
12248            )?;
12249            let reserved_items = reserved
12250                .get("uploads")
12251                .and_then(Value::as_array)
12252                .ok_or_else(|| invalid_feed("proposal upload reservation has no items"))?;
12253            items.extend(reserved_items.iter().cloned());
12254        }
12255        if items.len() != downloaded.len() {
12256            return Err(invalid_feed("proposal upload reservation changed the set"));
12257        }
12258        let mut references = Vec::with_capacity(items.len());
12259        for item in items {
12260            let hash = item
12261                .get("sha256")
12262                .and_then(Value::as_str)
12263                .ok_or_else(|| invalid_feed("proposal upload reservation has no hash"))?;
12264            let bytes = downloaded
12265                .get(hash)
12266                .ok_or_else(|| invalid_feed("proposal upload reservation introduced a blob"))?;
12267            let reservation_id = item
12268                .get("reservation_id")
12269                .and_then(Value::as_str)
12270                .filter(|id| crate::ulid::is_ulid(id))
12271                .ok_or_else(|| invalid_feed("proposal upload reservation has no id"))?;
12272            let expected_coordinates = coordinates_by_hash
12273                .get(hash)
12274                .ok_or_else(|| invalid_feed("proposal upload has no coordinate binding"))?;
12275            let returned_coordinates = item
12276                .get("coordinates")
12277                .and_then(Value::as_array)
12278                .ok_or_else(|| invalid_feed("proposal upload reservation has no coordinates"))?;
12279            if returned_coordinates.len() != expected_coordinates.len()
12280                || returned_coordinates
12281                    .iter()
12282                    .zip(expected_coordinates)
12283                    .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
12284            {
12285                return Err(invalid_feed(
12286                    "proposal upload reservation changed its coordinates",
12287                ));
12288            }
12289            match item.get("status").and_then(Value::as_str) {
12290                Some("upload") => put_presigned(
12291                    cfg,
12292                    item.get("url")
12293                        .and_then(Value::as_str)
12294                        .ok_or_else(|| invalid_feed("proposal upload has no URL"))?,
12295                    item.get("headers").unwrap_or(&Value::Null),
12296                    bytes,
12297                )?,
12298                Some("already_present") => {}
12299                _ => return Err(invalid_feed("proposal upload status is invalid")),
12300            }
12301            references.push(json!({
12302                "sha256": hash,
12303                "bytes": bytes.len(),
12304                "reservation_id": reservation_id,
12305            }));
12306        }
12307        body["blobs"] = Value::Array(references);
12308    }
12309    // A proposal can be queued through the staged lane, so acceptance has to be
12310    // able to restate it. Capping only this side would make a large proposal
12311    // permanently unacceptable.
12312    stage_oversized_v2_change(cfg, &head.brain_id, &operations, &mut body)?;
12313    let path = format!("/api/hub/brains/{}/v2/commits", head.brain_id);
12314    let mut result = ensure_ok(
12315        request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
12316        "exact proposal acceptance",
12317    )?;
12318    let mut candidate_hub_signer = None;
12319    if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
12320        let request_id = result
12321            .get("request_id")
12322            .and_then(Value::as_str)
12323            .ok_or_else(|| invalid_feed("proposal acceptance has no request id"))?
12324            .to_string();
12325        let challenge = result
12326            .get("signing_challenge")
12327            .ok_or_else(|| invalid_feed("proposal acceptance has no signing challenge"))?;
12328        let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
12329            cfg,
12330            &head,
12331            &expected_candidate,
12332            &expected_candidate_assets,
12333            mutation_id,
12334            &v2_signed_request_view(&body, &operations),
12335            challenge,
12336        )?;
12337        body["signing_challenge_id"] = Value::String(challenge_id);
12338        body["signature_base64url"] = Value::String(signature);
12339        candidate_hub_signer = Some(actor_signer);
12340        result = ensure_ok(
12341            request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
12342            "signed exact proposal acceptance",
12343        )?;
12344    }
12345    let refreshed = v2_verified_head(cfg, brain)?
12346        .ok_or_else(|| invalid_feed("v2 head disappeared after proposal acceptance"))?;
12347    if candidate_hub_signer
12348        .as_ref()
12349        .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
12350        || refreshed
12351            .pointer
12352            .as_ref()
12353            .map(|pointer| pointer.commit_hash.as_str())
12354            != result.get("commit_hash").and_then(Value::as_str)
12355        || result.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
12356        || result.get("proposal_state").and_then(Value::as_str) != Some("accepted")
12357    {
12358        return Err(LinkError::RemoteAdvancedDuringSync);
12359    }
12360    accept_v2_head(cfg, &refreshed)?;
12361    Ok(result)
12362}
12363
12364// ─────────────────────────────────────────────────────────────────────────────
12365// propose — write without trust: evidence into the owner's inbox
12366// ─────────────────────────────────────────────────────────────────────────────
12367
12368/// Submit `body` to the published site `handle`, addressed to its app page
12369/// `app` (a page that declares the `write-inbox` capability). Deliberately
12370/// unauthenticated — this is the cross-party door; the submission lands as
12371/// *evidence* in the owner's `sources/inbox/`, never as truth, and the
12372/// owner's curator accepts or rejects it. Returns the hub's `{id, path}`
12373/// receipt.
12374pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
12375    require_valid_handle(handle)?;
12376    if body.len() as u64 > MAX_PROPOSE_BYTES {
12377        return Err(LinkError::ProposeTooLarge {
12378            bytes: body.len() as u64,
12379        });
12380    }
12381    let payload = json!({ "app": app, "body": body });
12382    // A ULID-shaped target is a bare brain address (link.md §7.4's
12383    // generalization): the brain inbox door, open on public brains, where a
12384    // configured credential earns a bigger actor-class budget. Anything else
12385    // is a published-site handle: that door is unauthenticated by design.
12386    let (path, auth) = if crate::ulid::is_ulid(handle) {
12387        (format!("/api/hub/brains/{handle}/inbox"), Auth::Optional)
12388    } else {
12389        (format!("/api/hub/sites/{handle}/inbox"), Auth::None)
12390    };
12391    ensure_ok(
12392        request(cfg, "POST", &path, Some(&payload), auth)?,
12393        "propose",
12394    )
12395}
12396
12397// ─────────────────────────────────────────────────────────────────────────────
12398// subscribe — follow feed-head movement
12399// ─────────────────────────────────────────────────────────────────────────────
12400
12401/// One observation of a brain's feed head.
12402#[derive(Debug, serde::Serialize)]
12403pub struct Head {
12404    /// The brain id.
12405    pub brain: String,
12406    /// The hub's durable feed cursor — advances on every accepted write.
12407    pub seq: u64,
12408    /// The hub's `updatedAt` for the brain, when present.
12409    #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
12410    pub updated_at: Option<String>,
12411    /// SHA-256 of the exact signed head entry.
12412    #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
12413    pub feed_hash: Option<String>,
12414    /// Whether the head entry's content hash, identity, and Ed25519 signature
12415    /// were verified locally. Path-scoped grants get head movement only.
12416    pub verified: bool,
12417}
12418
12419struct BoundedVecVisitor<T, const MAX: usize> {
12420    label: &'static str,
12421    marker: std::marker::PhantomData<T>,
12422}
12423
12424impl<'de, T, const MAX: usize> serde::de::Visitor<'de> for BoundedVecVisitor<T, MAX>
12425where
12426    T: Deserialize<'de>,
12427{
12428    type Value = Vec<T>;
12429
12430    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
12431        write!(formatter, "at most {MAX} {}", self.label)
12432    }
12433
12434    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
12435    where
12436        A: serde::de::SeqAccess<'de>,
12437    {
12438        if sequence.size_hint().is_some_and(|size| size > MAX) {
12439            return Err(serde::de::Error::custom(format!(
12440                "{} exceeds the {MAX}-item limit",
12441                self.label
12442            )));
12443        }
12444        let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX));
12445        while let Some(value) = sequence.next_element()? {
12446            if values.len() == MAX {
12447                return Err(serde::de::Error::custom(format!(
12448                    "{} exceeds the {MAX}-item limit",
12449                    self.label
12450                )));
12451            }
12452            values.push(value);
12453        }
12454        Ok(values)
12455    }
12456}
12457
12458fn deserialize_bounded_vec<'de, D, T, const MAX: usize>(
12459    deserializer: D,
12460    label: &'static str,
12461) -> Result<Vec<T>, D::Error>
12462where
12463    D: serde::Deserializer<'de>,
12464    T: Deserialize<'de>,
12465{
12466    deserializer.deserialize_seq(BoundedVecVisitor::<T, MAX> {
12467        label,
12468        marker: std::marker::PhantomData,
12469    })
12470}
12471
12472fn deserialize_feed_files<'de, D>(deserializer: D) -> Result<Vec<FeedFile>, D::Error>
12473where
12474    D: serde::Deserializer<'de>,
12475{
12476    deserialize_bounded_vec::<D, FeedFile, MAX_PUSH_FILES>(deserializer, "feed files")
12477}
12478
12479fn deserialize_removed_paths<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
12480where
12481    D: serde::Deserializer<'de>,
12482{
12483    deserialize_bounded_vec::<D, String, MAX_PUSH_FILES>(deserializer, "removed paths")
12484}
12485
12486fn deserialize_previous_identities<'de, D>(
12487    deserializer: D,
12488) -> Result<Vec<PreviousIdentity>, D::Error>
12489where
12490    D: serde::Deserializer<'de>,
12491{
12492    deserialize_bounded_vec::<D, PreviousIdentity, MAX_IDENTITY_ROTATIONS>(
12493        deserializer,
12494        "previous identities",
12495    )
12496}
12497
12498fn deserialize_rotations<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
12499where
12500    D: serde::Deserializer<'de>,
12501{
12502    deserialize_bounded_vec::<D, String, MAX_IDENTITY_ROTATIONS>(
12503        deserializer,
12504        "rotation statements",
12505    )
12506}
12507
12508fn deserialize_feed_items<'de, D>(deserializer: D) -> Result<Vec<FeedItem>, D::Error>
12509where
12510    D: serde::Deserializer<'de>,
12511{
12512    deserialize_bounded_vec::<D, FeedItem, FEED_PAGE_LIMIT>(deserializer, "feed entries")
12513}
12514
12515#[derive(Debug, Clone, Deserialize, Serialize)]
12516struct FeedFile {
12517    path: String,
12518    sha256: String,
12519    bytes: u64,
12520}
12521
12522#[cfg(test)]
12523#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12524enum V1DisclosureError {
12525    DuplicateFile,
12526    DuplicateRemoved,
12527    PushManifestMismatch,
12528    EditMissingChange,
12529    EditFalseFile,
12530    RemovedMismatch,
12531}
12532
12533/// Validate wire-profile-v1 `files`/`removed` disclosure semantics against
12534/// the exact previous and resulting pack manifests. `edit` accepts the
12535/// historical superset form while still requiring every actual changed path.
12536#[cfg(test)]
12537fn verify_v1_manifest_disclosure(
12538    kind: &str,
12539    previous: &[FeedFile],
12540    resulting: &[FeedFile],
12541    files: &[FeedFile],
12542    removed: &[String],
12543) -> Result<(), V1DisclosureError> {
12544    fn as_map(
12545        files: &[FeedFile],
12546    ) -> Result<std::collections::BTreeMap<&str, (&str, u64)>, V1DisclosureError> {
12547        let mut result = std::collections::BTreeMap::new();
12548        for file in files {
12549            if result
12550                .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
12551                .is_some()
12552            {
12553                return Err(V1DisclosureError::DuplicateFile);
12554            }
12555        }
12556        Ok(result)
12557    }
12558    let previous = as_map(previous)?;
12559    let resulting = as_map(resulting)?;
12560    let disclosed = as_map(files)?;
12561    let removed_set: std::collections::BTreeSet<&str> =
12562        removed.iter().map(String::as_str).collect();
12563    if removed_set.len() != removed.len() {
12564        return Err(V1DisclosureError::DuplicateRemoved);
12565    }
12566    let expected_removed: std::collections::BTreeSet<&str> = previous
12567        .keys()
12568        .copied()
12569        .filter(|path| !resulting.contains_key(path))
12570        .collect();
12571    if removed_set != expected_removed {
12572        return Err(V1DisclosureError::RemovedMismatch);
12573    }
12574    if kind == "push" {
12575        return if disclosed == resulting {
12576            Ok(())
12577        } else {
12578            Err(V1DisclosureError::PushManifestMismatch)
12579        };
12580    }
12581    if kind != "edit" {
12582        return Err(V1DisclosureError::EditFalseFile);
12583    }
12584    if disclosed
12585        .iter()
12586        .any(|(path, value)| resulting.get(path) != Some(value))
12587    {
12588        return Err(V1DisclosureError::EditFalseFile);
12589    }
12590    for (path, value) in &resulting {
12591        if previous.get(path) != Some(value) && !disclosed.contains_key(path) {
12592            return Err(V1DisclosureError::EditMissingChange);
12593        }
12594    }
12595    Ok(())
12596}
12597
12598#[derive(Debug, Clone, Deserialize, Serialize)]
12599struct FeedEntry {
12600    v: u8,
12601    seq: u64,
12602    ts: String,
12603    brain: String,
12604    public_key: String,
12605    kind: String,
12606    op: String,
12607    pack_sha256: String,
12608    #[serde(deserialize_with = "deserialize_feed_files")]
12609    files: Vec<FeedFile>,
12610    #[serde(deserialize_with = "deserialize_removed_paths")]
12611    removed: Vec<String>,
12612    prev_entry_hash: Option<String>,
12613    sig: String,
12614}
12615
12616#[derive(Serialize)]
12617struct UnsignedFeedEntry<'a> {
12618    v: u8,
12619    seq: u64,
12620    ts: &'a str,
12621    brain: &'a str,
12622    public_key: &'a str,
12623    kind: &'a str,
12624    op: &'a str,
12625    pack_sha256: &'a str,
12626    files: &'a [FeedFile],
12627    removed: &'a [String],
12628    prev_entry_hash: &'a Option<String>,
12629}
12630
12631#[derive(Debug, Clone, Deserialize, Serialize)]
12632struct FeedItem {
12633    hash: String,
12634    entry: FeedEntry,
12635}
12636
12637#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
12638struct FeedIdentity {
12639    fingerprint: String,
12640    #[serde(rename = "publicKeySpki")]
12641    public_key_spki: String,
12642    /// Rotation history (link.md §9.1): identities this brain previously
12643    /// signed as. Entries verify against current OR previous — rotation
12644    /// never invalidates history.
12645    #[serde(default, deserialize_with = "deserialize_previous_identities")]
12646    previous: Vec<PreviousIdentity>,
12647    /// Exact normative rotation statements, oldest first. A list of previous
12648    /// public keys without these old-key signatures is not a trust chain.
12649    #[serde(default, deserialize_with = "deserialize_rotations")]
12650    rotations: Vec<String>,
12651}
12652
12653#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
12654struct PreviousIdentity {
12655    fingerprint: String,
12656    #[serde(rename = "publicKeySpki")]
12657    public_key_spki: String,
12658}
12659
12660#[derive(Debug, Deserialize)]
12661struct FeedResponse {
12662    #[serde(rename = "headSeq")]
12663    head_seq: u64,
12664    #[serde(rename = "feedHash")]
12665    feed_hash: Option<String>,
12666    identity: Option<FeedIdentity>,
12667    #[serde(deserialize_with = "deserialize_feed_items")]
12668    entries: Vec<FeedItem>,
12669    #[serde(rename = "scopeLimited")]
12670    scope_limited: bool,
12671}
12672
12673#[derive(Debug, Deserialize, Serialize)]
12674#[serde(deny_unknown_fields)]
12675struct RotationStatement {
12676    v: u8,
12677    op: String,
12678    brain: String,
12679    public_key: String,
12680    new_brain: String,
12681    new_public_key: String,
12682    prior_head_seq: u64,
12683    prior_feed_hash: Option<String>,
12684    ts: String,
12685    sig: String,
12686}
12687
12688#[derive(Debug, Clone, Deserialize, Serialize)]
12689struct TrustState {
12690    v: u8,
12691    origin: String,
12692    /// The exact caller-visible ref whose resolution this checkpoint pins.
12693    /// Slugs/handles are mutable names at the hub; once observed, they may not
12694    /// silently resolve to a different canonical brain.
12695    #[serde(default)]
12696    requested: String,
12697    /// Canonical hub brain id returned for `requested`.
12698    brain: String,
12699    /// Federation home origin when this ref was learned from a registry.
12700    /// Once observed, the registry cannot silently relocate the same handle.
12701    #[serde(default, skip_serializing_if = "Option::is_none")]
12702    home: Option<String>,
12703    anchor: String,
12704    current: String,
12705    #[serde(rename = "headSeq")]
12706    head_seq: u64,
12707    #[serde(rename = "feedHash")]
12708    feed_hash: Option<String>,
12709    /// Exact accepted old-key-signed rotation statements, oldest first. v2
12710    /// checkpoints require this vector to be an immutable prefix of every
12711    /// subsequently served identity chain.
12712    #[serde(default)]
12713    rotations: Vec<String>,
12714    /// Hub pointer-signing identity pinned on first v2 observation. This is
12715    /// separate from the brain key, which signs commits.
12716    #[serde(default, skip_serializing_if = "Option::is_none")]
12717    hub_signer: Option<String>,
12718    /// Explicit transport profile. Older accepted non-empty v2 checkpoints
12719    /// are recognized by their pinned hub signer during one-way migration.
12720    #[serde(default, skip_serializing_if = "Option::is_none")]
12721    protocol_profile: Option<String>,
12722}
12723
12724fn accepted_as_v2(state: &TrustState) -> bool {
12725    state.protocol_profile.as_deref() == Some("link-v2") || state.hub_signer.is_some()
12726}
12727
12728fn has_accepted_v2_ref(cfg: &HubConfig, requested: &str) -> LinkResult<bool> {
12729    let directory = open_trust_dir(cfg)?;
12730    if load_trust_in(cfg, &directory, requested)?.is_some_and(|state| accepted_as_v2(&state)) {
12731        return Ok(true);
12732    }
12733    let Some(alias) = load_alias_in(cfg, &directory, requested)? else {
12734        return Ok(false);
12735    };
12736    Ok(load_trust_in(cfg, &directory, &alias.brain)?.is_some_and(|state| accepted_as_v2(&state)))
12737}
12738
12739#[derive(Debug, Clone, Deserialize, Serialize)]
12740struct AliasBinding {
12741    v: u8,
12742    origin: String,
12743    requested: String,
12744    brain: String,
12745    #[serde(default, skip_serializing_if = "Option::is_none")]
12746    home: Option<String>,
12747}
12748
12749struct VerifiedRemote {
12750    head: Head,
12751    identity: Option<FeedIdentity>,
12752    head_entry: Option<FeedItem>,
12753    /// Present when the caller requested and verified the complete chain.
12754    entries: Vec<FeedItem>,
12755    anchor: Option<String>,
12756}
12757
12758fn invalid_feed(message: impl Into<String>) -> LinkError {
12759    LinkError::InvalidFeed {
12760        message: message.into(),
12761    }
12762}
12763
12764fn is_sha256(value: &str) -> bool {
12765    value.len() == 64
12766        && value
12767            .bytes()
12768            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
12769}
12770
12771fn identity_fingerprint(public_key_spki: &str) -> LinkResult<String> {
12772    let der = URL_SAFE_NO_PAD
12773        .decode(public_key_spki)
12774        .map_err(|_| invalid_feed("identity public key is not base64url"))?;
12775    if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
12776        return Err(invalid_feed(
12777            "identity public key is not a valid Ed25519 SPKI",
12778        ));
12779    }
12780    Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(&der)))
12781}
12782
12783/// Verify the hub's current identity and every old-key-signed rotation that
12784/// leads from the original TOFU anchor to it. `previous` alone is metadata,
12785/// never authority.
12786fn verify_identity_chain(
12787    identity: &FeedIdentity,
12788    pinned: Option<&TrustState>,
12789) -> LinkResult<String> {
12790    if identity.previous.len() > MAX_IDENTITY_ROTATIONS
12791        || identity.rotations.len() > MAX_IDENTITY_ROTATIONS
12792    {
12793        return Err(invalid_feed(
12794            "identity rotation history exceeds the client cap",
12795        ));
12796    }
12797    if identity.fingerprint != identity_fingerprint(&identity.public_key_spki)? {
12798        return Err(invalid_feed(
12799            "current identity fingerprint does not match its public key",
12800        ));
12801    }
12802    for previous in &identity.previous {
12803        if previous.fingerprint != identity_fingerprint(&previous.public_key_spki)? {
12804            return Err(invalid_feed(
12805                "previous identity fingerprint does not match its public key",
12806            ));
12807        }
12808    }
12809    if identity.rotations.len() != identity.previous.len() {
12810        return Err(invalid_feed(
12811            "identity history is missing an old-key-signed rotation statement",
12812        ));
12813    }
12814
12815    // The HTTP identity lists prior identities newest first. Rotation
12816    // statements are chronological, so verification walks the reversed list
12817    // and ends at the current identity.
12818    let mut chain: Vec<(&str, &str)> = identity
12819        .previous
12820        .iter()
12821        .rev()
12822        .map(|p| (p.fingerprint.as_str(), p.public_key_spki.as_str()))
12823        .collect();
12824    chain.push((&identity.fingerprint, &identity.public_key_spki));
12825
12826    for (index, raw) in identity.rotations.iter().enumerate() {
12827        let statement: RotationStatement = serde_json::from_str(raw)
12828            .map_err(|_| invalid_feed("rotation statement did not parse exactly"))?;
12829        let (old_fingerprint, old_spki) = chain[index];
12830        let (new_fingerprint, new_spki) = chain[index + 1];
12831        if statement.v != 1
12832            || statement.op != "rotate"
12833            || statement.brain != format!("ed25519:{old_fingerprint}")
12834            || statement.public_key != old_spki
12835            || statement.new_brain != format!("ed25519:{new_fingerprint}")
12836            || statement.new_public_key != new_spki
12837            || (statement.prior_head_seq == 0 && statement.prior_feed_hash.is_some())
12838            || (statement.prior_head_seq > 0
12839                && statement
12840                    .prior_feed_hash
12841                    .as_deref()
12842                    .is_none_or(|hash| !is_sha256(hash)))
12843        {
12844            return Err(invalid_feed(
12845                "rotation statement does not connect adjacent identities",
12846            ));
12847        }
12848        let unsigned = serde_json::to_string(&UnsignedRotation {
12849            v: statement.v,
12850            op: &statement.op,
12851            brain: &statement.brain,
12852            public_key: &statement.public_key,
12853            new_brain: &statement.new_brain,
12854            new_public_key: &statement.new_public_key,
12855            prior_head_seq: statement.prior_head_seq,
12856            prior_feed_hash: statement.prior_feed_hash.as_deref(),
12857            ts: statement.ts.clone(),
12858        })
12859        .map_err(|_| invalid_feed("could not canonicalize rotation statement"))?;
12860        let exact = format!(
12861            "{},\"sig\":\"{}\"}}",
12862            &unsigned[..unsigned.len() - 1],
12863            statement.sig
12864        );
12865        if exact != *raw {
12866            return Err(invalid_feed(
12867                "rotation statement is not in normative serialization",
12868            ));
12869        }
12870        let der = URL_SAFE_NO_PAD
12871            .decode(old_spki)
12872            .map_err(|_| invalid_feed("rotation public key is not base64url"))?;
12873        let signature = URL_SAFE_NO_PAD
12874            .decode(&statement.sig)
12875            .map_err(|_| invalid_feed("rotation signature is not base64url"))?;
12876        UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
12877            .verify(unsigned.as_bytes(), &signature)
12878            .map_err(|_| invalid_feed("rotation signature verification failed"))?;
12879        if index > 0 {
12880            let prior: RotationStatement = serde_json::from_str(&identity.rotations[index - 1])
12881                .map_err(|_| invalid_feed("prior rotation statement did not parse"))?;
12882            if statement.prior_head_seq < prior.prior_head_seq {
12883                return Err(invalid_feed("rotation feed boundaries move backward"));
12884            }
12885        }
12886    }
12887
12888    let anchor = format!("ed25519:{}", chain[0].0);
12889    let current = format!("ed25519:{}", identity.fingerprint);
12890    if let Some(pin) = pinned {
12891        if pin.anchor != anchor {
12892            return Err(invalid_feed(
12893                "served identity chain does not descend from the pinned anchor",
12894            ));
12895        }
12896        if !chain
12897            .iter()
12898            .any(|(fingerprint, _)| pin.current == format!("ed25519:{fingerprint}"))
12899        {
12900            return Err(invalid_feed(
12901                "served identity chain forked away from the last pinned identity",
12902            ));
12903        }
12904        if pin.current == current && pin.anchor != current && identity.rotations.is_empty() {
12905            return Err(invalid_feed("served identity discarded its rotation chain"));
12906        }
12907        if pin.v >= 2
12908            && (identity.rotations.len() < pin.rotations.len()
12909                || identity.rotations[..pin.rotations.len()] != pin.rotations)
12910        {
12911            return Err(invalid_feed(
12912                "served identity rewrote the locally accepted rotation history",
12913            ));
12914        }
12915    }
12916    Ok(anchor)
12917}
12918
12919fn verify_rotation_feed_boundaries(
12920    identity: &FeedIdentity,
12921    pinned: Option<&TrustState>,
12922    observed: &[FeedItem],
12923    advertised_seq: u64,
12924) -> LinkResult<()> {
12925    let mut chain: Vec<String> = identity
12926        .previous
12927        .iter()
12928        .rev()
12929        .map(|previous| format!("ed25519:{}", previous.fingerprint))
12930        .collect();
12931    chain.push(format!("ed25519:{}", identity.fingerprint));
12932    let pinned_index = pinned.and_then(|pin| chain.iter().position(|key| key == &pin.current));
12933
12934    for (index, raw) in identity.rotations.iter().enumerate() {
12935        let rotation: RotationStatement = serde_json::from_str(raw)
12936            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
12937        if rotation.prior_head_seq > advertised_seq {
12938            return Err(invalid_feed(
12939                "rotation claims a feed boundary beyond the advertised head",
12940            ));
12941        }
12942        if let (Some(pin), Some(pin_index)) = (pinned, pinned_index) {
12943            if index >= pin_index && rotation.prior_head_seq < pin.head_seq {
12944                return Err(invalid_feed(
12945                    "newly disclosed rotation predates the local feed checkpoint",
12946                ));
12947            }
12948        }
12949        let actual = if rotation.prior_head_seq == 0 {
12950            None
12951        } else if pinned.is_some_and(|pin| pin.head_seq == rotation.prior_head_seq) {
12952            pinned.and_then(|pin| pin.feed_hash.as_deref())
12953        } else {
12954            observed
12955                .iter()
12956                .find(|item| item.entry.seq == rotation.prior_head_seq)
12957                .map(|item| item.hash.as_str())
12958        };
12959        if let Some(actual) = actual {
12960            if rotation.prior_feed_hash.as_deref() != Some(actual) {
12961                return Err(invalid_feed(
12962                    "rotation statement does not commit the verified feed boundary",
12963                ));
12964            }
12965        } else if rotation.prior_head_seq == 0 {
12966            // The empty-feed boundary is represented by (0, null); there is
12967            // no entry hash to find in `observed`.
12968        } else if pinned.is_some_and(|pin| {
12969            pinned_index.is_some_and(|pin_index| index >= pin_index)
12970                || rotation.prior_head_seq >= pin.head_seq
12971        }) {
12972            return Err(invalid_feed(
12973                "rotation feed boundary was not present in the verified chain",
12974            ));
12975        }
12976    }
12977    Ok(())
12978}
12979
12980/// Once a client has checkpointed identity K, identities retired before K may
12981/// verify old history but can never regain authority over a later sequence.
12982/// This is also the safe migration rule for legacy v1 checkpoints that did not
12983/// persist the exact accepted rotation statements.
12984fn reject_retired_signer_after_checkpoint(
12985    identity: &FeedIdentity,
12986    pinned: Option<&TrustState>,
12987    item: &FeedItem,
12988) -> LinkResult<()> {
12989    let Some(pin) = pinned else {
12990        return Ok(());
12991    };
12992    if item.entry.seq <= pin.head_seq {
12993        return Ok(());
12994    }
12995    let mut chain: Vec<String> = identity
12996        .previous
12997        .iter()
12998        .rev()
12999        .map(|previous| format!("ed25519:{}", previous.fingerprint))
13000        .collect();
13001    chain.push(format!("ed25519:{}", identity.fingerprint));
13002    let pinned_index = chain
13003        .iter()
13004        .position(|key| key == &pin.current)
13005        .ok_or_else(|| invalid_feed("pinned identity is absent from the served chain"))?;
13006    let signer_index = chain
13007        .iter()
13008        .position(|key| key == &item.entry.brain)
13009        .ok_or_else(|| invalid_feed("feed signer is absent from the served identity chain"))?;
13010    if signer_index < pinned_index {
13011        return Err(invalid_feed(
13012            "a retired identity attempted to sign after the local checkpoint",
13013        ));
13014    }
13015    Ok(())
13016}
13017
13018fn trust_file_name(cfg: &HubConfig, brain: &str) -> LinkResult<String> {
13019    let origin = normalized_origin(&cfg.hub)?;
13020    let key = format!(
13021        "{:x}",
13022        Sha256::digest(format!("{origin}\0{brain}").as_bytes())
13023    );
13024    Ok(format!("{key}.json"))
13025}
13026
13027fn alias_file_name(cfg: &HubConfig, alias: &str) -> LinkResult<String> {
13028    let origin = normalized_origin(&cfg.hub)?;
13029    let key = format!(
13030        "{:x}",
13031        Sha256::digest(format!("{origin}\0alias\0{alias}").as_bytes())
13032    );
13033    Ok(format!("alias-{key}.json"))
13034}
13035
13036#[cfg(any(unix, windows))]
13037struct TrustLock {
13038    _file: std::fs::File,
13039}
13040
13041#[cfg(unix)]
13042fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
13043    use std::os::fd::{AsRawFd as _, FromRawFd as _};
13044
13045    let lock_string = format!(".{state_name}.lock");
13046    let lock_name = c_name(lock_string.as_bytes(), &lock_string)?;
13047    let fd = unsafe {
13048        libc::openat(
13049            directory.as_raw_fd(),
13050            lock_name.as_ptr(),
13051            libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13052            0o600,
13053        )
13054    };
13055    if fd < 0 {
13056        return Err(std::io::Error::last_os_error().into());
13057    }
13058    let file = unsafe { std::fs::File::from_raw_fd(fd) };
13059    if !file.metadata()?.is_file() {
13060        return Err(LinkError::UnsafePath { path: lock_string });
13061    }
13062    if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
13063        return Err(std::io::Error::last_os_error().into());
13064    }
13065    Ok(TrustLock { _file: file })
13066}
13067
13068#[cfg(windows)]
13069fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
13070    let lock_name = format!(".{state_name}.lock");
13071    let file = crate::fsx::lock_exclusive_beneath(directory, Path::new(&lock_name))?;
13072    Ok(TrustLock { _file: file })
13073}
13074
13075#[cfg(any(unix, windows))]
13076fn lock_trust_many(
13077    cfg: &HubConfig,
13078    directory: &std::fs::File,
13079    refs: &[&str],
13080) -> LinkResult<Vec<TrustLock>> {
13081    let mut names = refs
13082        .iter()
13083        .map(|reference| trust_file_name(cfg, reference))
13084        .collect::<LinkResult<Vec<_>>>()?;
13085    names.sort();
13086    names.dedup();
13087    names
13088        .iter()
13089        .map(|name| lock_trust_name(directory, name))
13090        .collect()
13091}
13092
13093#[cfg(not(any(unix, windows)))]
13094fn lock_trust_many(
13095    _cfg: &HubConfig,
13096    _directory: &TrustDirectory,
13097    _refs: &[&str],
13098) -> LinkResult<Vec<()>> {
13099    Err(LinkError::UnsupportedPlatform {
13100        operation: "verified link.md state",
13101    })
13102}
13103
13104#[cfg(any(unix, windows))]
13105type TrustDirectory = std::fs::File;
13106
13107#[cfg(not(any(unix, windows)))]
13108struct TrustDirectory;
13109
13110#[cfg(unix)]
13111fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
13112    use std::os::fd::AsRawFd as _;
13113
13114    let directory = open_or_create_dir_nofollow(&cfg.state_dir.join("trust"))?;
13115    if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
13116        return Err(std::io::Error::last_os_error().into());
13117    }
13118    directory.sync_all()?;
13119    Ok(directory)
13120}
13121
13122#[cfg(windows)]
13123fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
13124    let marker = cfg.state_dir.join("trust").join(".directory");
13125    crate::fsx::write_atomic(&marker, b"link.md trust directory\n")?;
13126    Ok(crate::fsx::open_directory_nofollow(
13127        marker.parent().expect("trust marker has a parent"),
13128    )?)
13129}
13130
13131#[cfg(not(any(unix, windows)))]
13132fn open_trust_dir(_cfg: &HubConfig) -> LinkResult<TrustDirectory> {
13133    Err(LinkError::UnsupportedPlatform {
13134        operation: "verified link.md state",
13135    })
13136}
13137
13138#[cfg(unix)]
13139fn load_trust_in(
13140    cfg: &HubConfig,
13141    directory: &TrustDirectory,
13142    requested: &str,
13143) -> LinkResult<Option<TrustState>> {
13144    use std::os::fd::{AsRawFd as _, FromRawFd as _};
13145
13146    let name_string = trust_file_name(cfg, requested)?;
13147    let name = c_name(name_string.as_bytes(), &name_string)?;
13148    let fd = unsafe {
13149        libc::openat(
13150            directory.as_raw_fd(),
13151            name.as_ptr(),
13152            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13153        )
13154    };
13155    if fd < 0 {
13156        let error = std::io::Error::last_os_error();
13157        if error.kind() == std::io::ErrorKind::NotFound {
13158            return Ok(None);
13159        }
13160        return Err(LinkError::UnsafePath { path: name_string });
13161    }
13162    let file = unsafe { std::fs::File::from_raw_fd(fd) };
13163    if !file.metadata()?.is_file() {
13164        return Err(LinkError::UnsafePath { path: name_string });
13165    }
13166    let mut bytes = Vec::new();
13167    file.take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
13168    if bytes.len() > 1024 * 1024 {
13169        return Err(invalid_feed("local identity/feed checkpoint is oversized"));
13170    }
13171    let mut state: TrustState = serde_json::from_slice(&bytes)
13172        .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
13173    if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
13174        return Err(invalid_feed(
13175            "local identity/feed checkpoint does not match this hub and brain",
13176        ));
13177    }
13178    if state.v == 1 {
13179        // Legacy files were keyed by canonical brain id. They can migrate only
13180        // when the caller used that exact id; old slug lookups had no durable
13181        // alias binding and therefore cannot be guessed safely.
13182        if state.brain != requested {
13183            return Err(invalid_feed(
13184                "legacy checkpoint is not bound to the requested brain id",
13185            ));
13186        }
13187        state.requested = requested.to_string();
13188    } else if state.requested != requested {
13189        return Err(invalid_feed(
13190            "local identity/feed checkpoint is bound to a different requested ref",
13191        ));
13192    }
13193    Ok(Some(state))
13194}
13195
13196#[cfg(windows)]
13197fn load_trust_in(
13198    cfg: &HubConfig,
13199    directory: &TrustDirectory,
13200    requested: &str,
13201) -> LinkResult<Option<TrustState>> {
13202    let name = trust_file_name(cfg, requested)?;
13203    let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
13204    let bytes = match reader.read(Path::new(&name), 1024 * 1024) {
13205        Ok(bytes) => bytes,
13206        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
13207        Err(_) => return Err(LinkError::UnsafePath { path: name }),
13208    };
13209    let mut state: TrustState = serde_json::from_slice(&bytes)
13210        .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
13211    if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
13212        return Err(invalid_feed(
13213            "local identity/feed checkpoint does not match this hub and brain",
13214        ));
13215    }
13216    if state.v == 1 {
13217        if state.brain != requested {
13218            return Err(invalid_feed(
13219                "legacy checkpoint is not bound to the requested brain id",
13220            ));
13221        }
13222        state.requested = requested.to_string();
13223    } else if state.requested != requested {
13224        return Err(invalid_feed(
13225            "local identity/feed checkpoint is bound to a different requested ref",
13226        ));
13227    }
13228    Ok(Some(state))
13229}
13230
13231#[cfg(not(any(unix, windows)))]
13232fn load_trust_in(
13233    _cfg: &HubConfig,
13234    _directory: &TrustDirectory,
13235    _brain: &str,
13236) -> LinkResult<Option<TrustState>> {
13237    Err(LinkError::UnsupportedPlatform {
13238        operation: "verified link.md state",
13239    })
13240}
13241
13242#[cfg(all(test, any(unix, windows)))]
13243fn load_trust(cfg: &HubConfig, requested: &str) -> LinkResult<Option<TrustState>> {
13244    let directory = open_trust_dir(cfg)?;
13245    load_trust_in(cfg, &directory, requested)
13246}
13247
13248#[cfg(unix)]
13249fn save_trust_in(
13250    cfg: &HubConfig,
13251    directory: &TrustDirectory,
13252    state: &TrustState,
13253) -> LinkResult<()> {
13254    use std::os::fd::{AsRawFd as _, FromRawFd as _};
13255
13256    let name_string = trust_file_name(cfg, &state.requested)?;
13257    let name = c_name(name_string.as_bytes(), &name_string)?;
13258    let mut bytes = serde_json::to_vec(state)
13259        .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
13260    bytes.push(b'\n');
13261
13262    let nonce = std::time::SystemTime::now()
13263        .duration_since(std::time::UNIX_EPOCH)
13264        .unwrap_or_default()
13265        .as_nanos();
13266    let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
13267    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
13268    let fd = unsafe {
13269        libc::openat(
13270            directory.as_raw_fd(),
13271            temp.as_ptr(),
13272            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13273            0o600,
13274        )
13275    };
13276    if fd < 0 {
13277        return Err(std::io::Error::last_os_error().into());
13278    }
13279    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
13280    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
13281        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13282        return Err(error.into());
13283    }
13284    drop(file);
13285    if unsafe {
13286        libc::renameat(
13287            directory.as_raw_fd(),
13288            temp.as_ptr(),
13289            directory.as_raw_fd(),
13290            name.as_ptr(),
13291        )
13292    } != 0
13293    {
13294        let error = std::io::Error::last_os_error();
13295        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13296        return Err(error.into());
13297    }
13298    directory.sync_all()?;
13299    Ok(())
13300}
13301
13302#[cfg(windows)]
13303fn save_trust_in(
13304    cfg: &HubConfig,
13305    directory: &TrustDirectory,
13306    state: &TrustState,
13307) -> LinkResult<()> {
13308    let name = trust_file_name(cfg, &state.requested)?;
13309    let mut bytes = serde_json::to_vec(state)
13310        .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
13311    bytes.push(b'\n');
13312    crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
13313    Ok(())
13314}
13315
13316#[cfg(not(any(unix, windows)))]
13317fn save_trust_in(
13318    _cfg: &HubConfig,
13319    _directory: &TrustDirectory,
13320    _state: &TrustState,
13321) -> LinkResult<()> {
13322    Err(LinkError::UnsupportedPlatform {
13323        operation: "verified link.md state",
13324    })
13325}
13326
13327#[cfg(unix)]
13328fn load_alias_in(
13329    cfg: &HubConfig,
13330    directory: &TrustDirectory,
13331    requested: &str,
13332) -> LinkResult<Option<AliasBinding>> {
13333    use std::os::fd::{AsRawFd as _, FromRawFd as _};
13334
13335    let name_string = alias_file_name(cfg, requested)?;
13336    let name = c_name(name_string.as_bytes(), &name_string)?;
13337    let fd = unsafe {
13338        libc::openat(
13339            directory.as_raw_fd(),
13340            name.as_ptr(),
13341            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13342        )
13343    };
13344    if fd < 0 {
13345        let error = std::io::Error::last_os_error();
13346        if error.kind() == std::io::ErrorKind::NotFound {
13347            return Ok(None);
13348        }
13349        return Err(LinkError::UnsafePath { path: name_string });
13350    }
13351    let file = unsafe { std::fs::File::from_raw_fd(fd) };
13352    if !file.metadata()?.is_file() {
13353        return Err(LinkError::UnsafePath { path: name_string });
13354    }
13355    let mut bytes = Vec::new();
13356    file.take(64 * 1024 + 1).read_to_end(&mut bytes)?;
13357    if bytes.len() > 64 * 1024 {
13358        return Err(invalid_feed("local alias binding is oversized"));
13359    }
13360    let alias: AliasBinding = serde_json::from_slice(&bytes)
13361        .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
13362    if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
13363    {
13364        return Err(invalid_feed(
13365            "local alias binding does not match this hub and requested ref",
13366        ));
13367    }
13368    Ok(Some(alias))
13369}
13370
13371#[cfg(windows)]
13372fn load_alias_in(
13373    cfg: &HubConfig,
13374    directory: &TrustDirectory,
13375    requested: &str,
13376) -> LinkResult<Option<AliasBinding>> {
13377    let name = alias_file_name(cfg, requested)?;
13378    let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
13379    let bytes = match reader.read(Path::new(&name), 64 * 1024) {
13380        Ok(bytes) => bytes,
13381        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
13382        Err(_) => return Err(LinkError::UnsafePath { path: name }),
13383    };
13384    let alias: AliasBinding = serde_json::from_slice(&bytes)
13385        .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
13386    if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
13387    {
13388        return Err(invalid_feed(
13389            "local alias binding does not match this hub and requested ref",
13390        ));
13391    }
13392    Ok(Some(alias))
13393}
13394
13395#[cfg(not(any(unix, windows)))]
13396fn load_alias_in(
13397    _cfg: &HubConfig,
13398    _directory: &TrustDirectory,
13399    _requested: &str,
13400) -> LinkResult<Option<AliasBinding>> {
13401    Err(LinkError::UnsupportedPlatform {
13402        operation: "verified link.md state",
13403    })
13404}
13405
13406#[cfg(unix)]
13407fn save_alias_in(
13408    cfg: &HubConfig,
13409    directory: &TrustDirectory,
13410    alias: &AliasBinding,
13411) -> LinkResult<()> {
13412    use std::os::fd::{AsRawFd as _, FromRawFd as _};
13413
13414    let name_string = alias_file_name(cfg, &alias.requested)?;
13415    let name = c_name(name_string.as_bytes(), &name_string)?;
13416    let mut bytes = serde_json::to_vec(alias)
13417        .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
13418    bytes.push(b'\n');
13419    let nonce = std::time::SystemTime::now()
13420        .duration_since(std::time::UNIX_EPOCH)
13421        .unwrap_or_default()
13422        .as_nanos();
13423    let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
13424    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
13425    let fd = unsafe {
13426        libc::openat(
13427            directory.as_raw_fd(),
13428            temp.as_ptr(),
13429            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13430            0o600,
13431        )
13432    };
13433    if fd < 0 {
13434        return Err(std::io::Error::last_os_error().into());
13435    }
13436    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
13437    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
13438        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13439        return Err(error.into());
13440    }
13441    drop(file);
13442    if unsafe {
13443        libc::renameat(
13444            directory.as_raw_fd(),
13445            temp.as_ptr(),
13446            directory.as_raw_fd(),
13447            name.as_ptr(),
13448        )
13449    } != 0
13450    {
13451        let error = std::io::Error::last_os_error();
13452        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13453        return Err(error.into());
13454    }
13455    directory.sync_all()?;
13456    Ok(())
13457}
13458
13459#[cfg(windows)]
13460fn save_alias_in(
13461    cfg: &HubConfig,
13462    directory: &TrustDirectory,
13463    alias: &AliasBinding,
13464) -> LinkResult<()> {
13465    let name = alias_file_name(cfg, &alias.requested)?;
13466    let mut bytes = serde_json::to_vec(alias)
13467        .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
13468    bytes.push(b'\n');
13469    crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
13470    Ok(())
13471}
13472
13473#[cfg(not(any(unix, windows)))]
13474fn save_alias_in(
13475    _cfg: &HubConfig,
13476    _directory: &TrustDirectory,
13477    _alias: &AliasBinding,
13478) -> LinkResult<()> {
13479    Err(LinkError::UnsupportedPlatform {
13480        operation: "verified link.md state",
13481    })
13482}
13483
13484/// Load the canonical checkpoint shared by every spelling of one brain and
13485/// the separate alias binding. This also performs the one-way migration from
13486/// pre-v3 checkpoints that stored a full trust state under the alias itself.
13487/// Callers hold both alias and canonical locks before entering.
13488fn load_canonical_pin(
13489    cfg: &HubConfig,
13490    directory: &TrustDirectory,
13491    requested: &str,
13492    resolved_brain: &str,
13493) -> LinkResult<(Option<TrustState>, Option<AliasBinding>)> {
13494    let mut canonical = load_trust_in(cfg, directory, resolved_brain)?;
13495    if requested == resolved_brain {
13496        return Ok((canonical, None));
13497    }
13498
13499    let mut alias = load_alias_in(cfg, directory, requested)?;
13500    if let Some(binding) = &alias {
13501        if binding.brain != resolved_brain {
13502            return Err(LinkError::AliasRebindRequired {
13503                alias: requested.to_string(),
13504                from: binding.brain.clone(),
13505                to: resolved_brain.to_string(),
13506            });
13507        }
13508        return Ok((canonical, alias));
13509    }
13510
13511    // A v2 build stored the complete checkpoint under the requested slug.
13512    // Promote it to the canonical ULID key before creating the lightweight
13513    // alias binding. Never silently merge two independently advanced states.
13514    if let Some(legacy) = load_trust_in(cfg, directory, requested)? {
13515        if legacy.brain != resolved_brain {
13516            return Err(invalid_feed(
13517                "legacy alias checkpoint names a different canonical brain",
13518            ));
13519        }
13520        if let Some(existing) = &canonical {
13521            if existing.brain != legacy.brain
13522                || existing.anchor != legacy.anchor
13523                || existing.current != legacy.current
13524                || existing.head_seq != legacy.head_seq
13525                || existing.feed_hash != legacy.feed_hash
13526                || existing.rotations != legacy.rotations
13527            {
13528                return Err(invalid_feed(
13529                    "legacy alias checkpoint conflicts with the canonical checkpoint",
13530                ));
13531            }
13532        } else {
13533            let mut promoted = legacy.clone();
13534            promoted.requested = resolved_brain.to_string();
13535            promoted.home = None;
13536            save_trust_in(cfg, directory, &promoted)?;
13537            canonical = Some(promoted);
13538        }
13539        alias = Some(AliasBinding {
13540            v: 1,
13541            origin: normalized_origin(&cfg.hub)?,
13542            requested: requested.to_string(),
13543            brain: resolved_brain.to_string(),
13544            home: legacy.home,
13545        });
13546        save_alias_in(cfg, directory, alias.as_ref().expect("alias just created"))?;
13547    }
13548    Ok((canonical, alias))
13549}
13550
13551/// Explicitly replace one mutable alias binding after the operator reviews the
13552/// exact deleted/re-created brain ids. Canonical checkpoints are never removed
13553/// or rewritten, so old history remains pinned and the new brain establishes
13554/// its own independently verified trust chain.
13555pub fn rebind_v2_alias(cfg: &HubConfig, alias: &str, from: &str, to: &str) -> LinkResult<Value> {
13556    require_hardened_filesystem("verified alias rebind")?;
13557    require_safe_ref(alias)?;
13558    require_safe_ref(from)?;
13559    require_safe_ref(to)?;
13560    if crate::ulid::is_ulid(alias)
13561        || !crate::ulid::is_ulid(from)
13562        || !crate::ulid::is_ulid(to)
13563        || from == to
13564    {
13565        return Err(LinkError::InvalidPack {
13566            message:
13567                "alias rebind requires one non-ULID alias and two different exact canonical ULIDs"
13568                    .to_string(),
13569        });
13570    }
13571
13572    let verified = v2_verified_head(cfg, to)?.ok_or_else(|| LinkError::InvalidPack {
13573        message: "the proposed replacement is not a readable link.md v2 brain".to_string(),
13574    })?;
13575    accept_v2_head(cfg, &verified)?;
13576
13577    let alias_response = ensure_ok(
13578        request(
13579            cfg,
13580            "GET",
13581            &format!("/api/hub/brains/{alias}/v2/head"),
13582            None,
13583            Auth::Required,
13584        )?,
13585        "resolve alias for explicit rebind",
13586    )?;
13587    let resolved: V2HeadResponse = serde_json::from_value(alias_response)
13588        .map_err(|_| invalid_feed("alias rebind response has an invalid shape"))?;
13589    if resolved.v != 2 || resolved.brain_id != to {
13590        return Err(LinkError::RemoteAdvancedDuringSync);
13591    }
13592
13593    let directory = open_trust_dir(cfg)?;
13594    let _locks = lock_trust_many(cfg, &directory, &[alias, from, to])?;
13595    let binding = load_alias_in(cfg, &directory, alias)?.ok_or_else(|| LinkError::InvalidPack {
13596        message: "the requested alias has no existing local binding to replace".to_string(),
13597    })?;
13598    if binding.brain != from {
13599        return Err(LinkError::AliasRebindRequired {
13600            alias: alias.to_string(),
13601            from: binding.brain,
13602            to: to.to_string(),
13603        });
13604    }
13605    save_alias_in(
13606        cfg,
13607        &directory,
13608        &AliasBinding {
13609            v: 1,
13610            origin: normalized_origin(&cfg.hub)?,
13611            requested: alias.to_string(),
13612            brain: to.to_string(),
13613            home: binding.home,
13614        },
13615    )?;
13616    Ok(json!({
13617        "v": 2,
13618        "alias": alias,
13619        "from": from,
13620        "to": to,
13621        "outcome": "alias_rebound",
13622    }))
13623}
13624
13625fn save_canonical_pin_and_alias(
13626    cfg: &HubConfig,
13627    directory: &TrustDirectory,
13628    requested: &str,
13629    resolved_brain: &str,
13630    mut state: TrustState,
13631    existing_alias: Option<&AliasBinding>,
13632) -> LinkResult<()> {
13633    state.requested = resolved_brain.to_string();
13634    state.brain = resolved_brain.to_string();
13635    state.home = None;
13636    save_trust_in(cfg, directory, &state)?;
13637    if requested != resolved_brain {
13638        save_alias_in(
13639            cfg,
13640            directory,
13641            &AliasBinding {
13642                v: 1,
13643                origin: normalized_origin(&cfg.hub)?,
13644                requested: requested.to_string(),
13645                brain: resolved_brain.to_string(),
13646                home: existing_alias.and_then(|alias| alias.home.clone()),
13647            },
13648        )?;
13649    }
13650    Ok(())
13651}
13652
13653fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
13654    const ED25519_SPKI_PREFIX: &[u8] = &[
13655        0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
13656    ];
13657    let entry = &item.entry;
13658    let public_der = URL_SAFE_NO_PAD
13659        .decode(&entry.public_key)
13660        .map_err(|_| invalid_feed("public key is not base64url"))?;
13661    if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
13662        || !public_der.starts_with(ED25519_SPKI_PREFIX)
13663    {
13664        return Err(invalid_feed("entry public key is not a valid Ed25519 SPKI"));
13665    }
13666    let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
13667    if entry.brain != format!("ed25519:{fingerprint}") {
13668        return Err(invalid_feed(
13669            "brain fingerprint does not match its public key",
13670        ));
13671    }
13672    // Verify the complete rotation authority before using any previous key.
13673    let _ = verify_identity_chain(identity, None)?;
13674    let mut chain: Vec<(&str, &str)> = identity
13675        .previous
13676        .iter()
13677        .rev()
13678        .map(|previous| {
13679            (
13680                previous.fingerprint.as_str(),
13681                previous.public_key_spki.as_str(),
13682            )
13683        })
13684        .collect();
13685    chain.push((&identity.fingerprint, &identity.public_key_spki));
13686    let signer_index = chain.iter().position(|(known_fingerprint, spki)| {
13687        *known_fingerprint == fingerprint && *spki == entry.public_key
13688    });
13689    let Some(signer_index) = signer_index else {
13690        return Err(invalid_feed(
13691            "entry signer is not this brain's identity (current or rotated-from)",
13692        ));
13693    };
13694    let lower_boundary = if signer_index == 0 {
13695        None
13696    } else {
13697        let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
13698            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13699        Some(prior.prior_head_seq)
13700    };
13701    let upper_boundary = if signer_index == identity.rotations.len() {
13702        None
13703    } else {
13704        let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
13705            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13706        Some(next.prior_head_seq)
13707    };
13708    if lower_boundary.is_some_and(|boundary| entry.seq <= boundary)
13709        || upper_boundary.is_some_and(|boundary| entry.seq > boundary)
13710    {
13711        return Err(invalid_feed(
13712            "entry signer is outside its authenticated rotation epoch",
13713        ));
13714    }
13715    let unsigned = UnsignedFeedEntry {
13716        v: entry.v,
13717        seq: entry.seq,
13718        ts: &entry.ts,
13719        brain: &entry.brain,
13720        public_key: &entry.public_key,
13721        kind: &entry.kind,
13722        op: &entry.op,
13723        pack_sha256: &entry.pack_sha256,
13724        files: &entry.files,
13725        removed: &entry.removed,
13726        prev_entry_hash: &entry.prev_entry_hash,
13727    };
13728    let message =
13729        serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
13730    let signature = URL_SAFE_NO_PAD
13731        .decode(&entry.sig)
13732        .map_err(|_| invalid_feed("signature is not base64url"))?;
13733    UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
13734        .verify(&message, &signature)
13735        .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
13736
13737    let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
13738    exact.push(b'\n');
13739    let actual_hash = format!("{:x}", Sha256::digest(&exact));
13740    if actual_hash != item.hash {
13741        return Err(invalid_feed("entry SHA-256 does not match"));
13742    }
13743    Ok(())
13744}
13745
13746// ─────────────────────────────────────────────────────────────────────────────
13747// key rotation — link.md §9.1: the new key, signed by the old one
13748// ─────────────────────────────────────────────────────────────────────────────
13749
13750/// The unsigned rotation statement in its normative field order.
13751#[derive(Serialize)]
13752struct UnsignedRotation<'a> {
13753    v: u8,
13754    op: &'a str,
13755    brain: &'a str,
13756    public_key: &'a str,
13757    new_brain: &'a str,
13758    new_public_key: &'a str,
13759    prior_head_seq: u64,
13760    prior_feed_hash: Option<&'a str>,
13761    ts: String,
13762}
13763
13764/// Durable intent for an in-flight key rotation. The hub's recovery contract
13765/// identifies an ambiguous retry by the exact statement bytes, so the
13766/// statement cannot be reconstructed from the key and feed boundary later:
13767/// its timestamp and signature would differ.
13768#[derive(Debug, Deserialize, Serialize)]
13769#[serde(deny_unknown_fields)]
13770struct RotationJournal {
13771    v: u8,
13772    origin: String,
13773    brain: String,
13774    old_brain: String,
13775    new_brain: String,
13776    prior_head_seq: u64,
13777    prior_feed_hash: Option<String>,
13778    statement: String,
13779}
13780
13781fn rotation_journal_path(key_path: &Path) -> PathBuf {
13782    let mut path = key_path.as_os_str().to_os_string();
13783    path.push(".rotation.json");
13784    PathBuf::from(path)
13785}
13786
13787fn read_rotation_journal(path: &Path) -> LinkResult<RotationJournal> {
13788    #[cfg(unix)]
13789    let file = {
13790        use std::os::fd::{AsRawFd as _, FromRawFd as _};
13791        use std::os::unix::ffi::OsStrExt as _;
13792        let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
13793            .map_err(|error| {
13794                bad_agent_key(&format!("cannot open the rotation journal parent: {error}"))
13795            })?;
13796        let leaf_name = path
13797            .file_name()
13798            .ok_or_else(|| bad_agent_key("the rotation journal path has no file name"))?;
13799        let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
13800        let fd = unsafe {
13801            libc::openat(
13802                parent.as_raw_fd(),
13803                leaf.as_ptr(),
13804                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13805            )
13806        };
13807        if fd < 0 {
13808            return Err(bad_agent_key(
13809                "the rotation journal must be an existing regular file without symlink ancestors",
13810            ));
13811        }
13812        unsafe { std::fs::File::from_raw_fd(fd) }
13813    };
13814    #[cfg(not(unix))]
13815    let file = std::fs::File::open(path)
13816        .map_err(|error| bad_agent_key(&format!("cannot read the rotation journal: {error}")))?;
13817    let metadata = file
13818        .metadata()
13819        .map_err(|error| bad_agent_key(&format!("cannot inspect the rotation journal: {error}")))?;
13820    if !metadata.is_file() || metadata.len() > MAX_REGISTRY_CARD_BYTES {
13821        return Err(bad_agent_key(
13822            "the rotation journal must be a bounded regular file",
13823        ));
13824    }
13825    #[cfg(unix)]
13826    {
13827        use std::os::unix::fs::PermissionsExt as _;
13828        if metadata.permissions().mode() & 0o077 != 0 {
13829            return Err(bad_agent_key(
13830                "the rotation journal is accessible to group/other; set mode 0600",
13831            ));
13832        }
13833    }
13834    serde_json::from_reader(file)
13835        .map_err(|_| bad_agent_key("the rotation journal is not valid exact JSON"))
13836}
13837
13838fn remove_rotation_journal(path: &Path) {
13839    #[cfg(unix)]
13840    {
13841        use std::os::fd::AsRawFd as _;
13842        use std::os::unix::ffi::OsStrExt as _;
13843        let Ok(parent) =
13844            open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
13845        else {
13846            return;
13847        };
13848        let Some(leaf_name) = path.file_name() else {
13849            return;
13850        };
13851        let Ok(leaf) = c_name(leaf_name.as_bytes(), &path.display().to_string()) else {
13852            return;
13853        };
13854        if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), 0) } == 0 {
13855            let _ = parent.sync_all();
13856        }
13857    }
13858    #[cfg(not(unix))]
13859    {
13860        let _ = std::fs::remove_file(path);
13861    }
13862}
13863
13864fn validate_rotation_journal(
13865    journal: &RotationJournal,
13866    cfg: &HubConfig,
13867    canonical_brain: &str,
13868    old_key: &AgentSigningKey,
13869    new_key: &AgentSigningKey,
13870    head: &Head,
13871) -> LinkResult<()> {
13872    if journal.v != 1
13873        || journal.origin != normalized_origin(&cfg.hub)?
13874        || journal.brain != canonical_brain
13875        || journal.old_brain != old_key.multikey
13876        || journal.new_brain != new_key.multikey
13877        || journal.prior_head_seq != head.seq
13878        || journal.prior_feed_hash != head.feed_hash
13879    {
13880        return Err(invalid_feed(
13881            "rotation journal does not match the verified key and feed boundary",
13882        ));
13883    }
13884    let statement: RotationStatement = serde_json::from_str(&journal.statement)
13885        .map_err(|_| invalid_feed("rotation journal statement did not parse exactly"))?;
13886    if statement.prior_head_seq != journal.prior_head_seq
13887        || statement.prior_feed_hash != journal.prior_feed_hash
13888        || statement.brain != old_key.multikey
13889        || statement.public_key != old_key.public_key_spki
13890        || statement.new_brain != new_key.multikey
13891        || statement.new_public_key != new_key.public_key_spki
13892    {
13893        return Err(invalid_feed(
13894            "rotation journal statement does not match its durable intent",
13895        ));
13896    }
13897    let identity = FeedIdentity {
13898        fingerprint: new_key.multikey.trim_start_matches("ed25519:").to_string(),
13899        public_key_spki: new_key.public_key_spki.clone(),
13900        previous: vec![PreviousIdentity {
13901            fingerprint: old_key.multikey.trim_start_matches("ed25519:").to_string(),
13902            public_key_spki: old_key.public_key_spki.clone(),
13903        }],
13904        rotations: vec![journal.statement.clone()],
13905    };
13906    verify_identity_chain(&identity, None)?;
13907    Ok(())
13908}
13909
13910/// What `dbmd key rotate` returns.
13911#[derive(Debug, Serialize)]
13912pub struct RotationReport {
13913    /// The brain id rotated.
13914    pub brain: String,
13915    /// The NEW identity the hub now serves.
13916    pub multikey: String,
13917    /// Where the new PKCS#8 secret landed (0600).
13918    #[serde(rename = "keyFile")]
13919    pub key_file: String,
13920    /// Prior identities (newest first) the feed still verifies against.
13921    pub previous: Vec<String>,
13922}
13923
13924/// Rotate a self-custodied brain's key: mint a fresh keypair, build the
13925/// §9.1 statement — the new key plus exact prior feed boundary, signed by the
13926/// OLD key in normative serialization — and send it to the hub. The new secret
13927/// is durably created at 0600 before the POST; an existing output is reused for
13928/// idempotent retry/reconciliation. The old key is left untouched.
13929pub fn rotate_brain_key(
13930    cfg: &HubConfig,
13931    brain: &str,
13932    old_key: &AgentSigningKey,
13933    out: &Path,
13934) -> LinkResult<RotationReport> {
13935    require_hardened_filesystem("key rotation")?;
13936    require_safe_ref(brain)?;
13937    // The new private key must be durable *before* the hub can accept its
13938    // public half. An existing file is the retry/reconciliation path after an
13939    // ambiguous network failure: reuse it, never generate another identity.
13940    let new_key = if out.exists() {
13941        load_signing_key(out)?
13942    } else {
13943        let rng = ring::rand::SystemRandom::new();
13944        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
13945            .map_err(|_| bad_agent_key("key generation failed"))?;
13946        let pair = agent_keypair(pkcs8.as_ref())?;
13947        let (public_key_spki, multikey) = public_identity_for(&pair);
13948        write_secret_new(
13949            out,
13950            format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
13951        )?;
13952        AgentSigningKey {
13953            pkcs8: pkcs8.as_ref().to_vec(),
13954            multikey,
13955            public_key_spki,
13956        }
13957    };
13958    let new_spki = new_key.public_key_spki.clone();
13959    let new_multikey = new_key.multikey.clone();
13960    let journal_path = rotation_journal_path(out);
13961    let before_v2 = v2_verified_head(cfg, brain)?;
13962    let (canonical_brain, served_identity, observed_head, v2_profile) =
13963        if let Some(head) = before_v2 {
13964            let observed = Head {
13965                brain: head.brain_id.clone(),
13966                seq: head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
13967                updated_at: head
13968                    .pointer
13969                    .as_ref()
13970                    .map(|pointer| pointer.signed_at.clone()),
13971                feed_hash: head
13972                    .pointer
13973                    .as_ref()
13974                    .map(|pointer| pointer.feed_hash.clone()),
13975                verified: true,
13976            };
13977            let identity = v2_identity(&head.identity);
13978            let canonical = head.brain_id.clone();
13979            accept_v2_head(cfg, &head)?;
13980            (canonical, identity, observed, true)
13981        } else {
13982            let remote = verified_remote_head(cfg, brain, false)?;
13983            let identity = remote
13984                .identity
13985                .clone()
13986                .ok_or_else(|| invalid_feed("cannot rotate a brain with no signed identity"))?;
13987            (remote.head.brain.clone(), identity, remote.head, false)
13988        };
13989    let served_multikey = format!("ed25519:{}", served_identity.fingerprint);
13990    let already_rotated = served_multikey == new_multikey;
13991    // A completed invocation removes the journal only after the server has
13992    // acknowledged its recovery boundary. If the verified identity advanced
13993    // but the journal remains, the prior response was ambiguous: replay the
13994    // exact signed statement so a v2 hub can repair/confirm its checkpoint.
13995    if already_rotated && !journal_path.exists() {
13996        remove_rotation_journal(&journal_path);
13997        return Ok(RotationReport {
13998            brain: brain.to_string(),
13999            multikey: new_multikey,
14000            key_file: out.display().to_string(),
14001            previous: served_identity
14002                .previous
14003                .iter()
14004                .map(|identity| format!("ed25519:{}", identity.fingerprint))
14005                .collect(),
14006        });
14007    }
14008    if !already_rotated && served_multikey != old_key.multikey {
14009        return Err(invalid_feed(
14010            "the supplied old key is not the brain's verified current identity",
14011        ));
14012    }
14013
14014    let journal = if journal_path.exists() {
14015        read_rotation_journal(&journal_path)?
14016    } else {
14017        let ts = crate::now()
14018            .with_timezone(&chrono::Utc)
14019            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
14020            .to_string();
14021        let unsigned = serde_json::to_string(&UnsignedRotation {
14022            v: 1,
14023            op: "rotate",
14024            brain: &old_key.multikey,
14025            public_key: &old_key.public_key_spki,
14026            new_brain: &new_multikey,
14027            new_public_key: &new_spki,
14028            prior_head_seq: observed_head.seq,
14029            prior_feed_hash: observed_head.feed_hash.as_deref(),
14030            ts,
14031        })
14032        .expect("serialize rotation");
14033        let old_pair = agent_keypair(&old_key.pkcs8)?;
14034        let sig = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
14035        let statement = format!("{},\"sig\":\"{}\"}}", &unsigned[..unsigned.len() - 1], sig);
14036        let journal = RotationJournal {
14037            v: 1,
14038            origin: normalized_origin(&cfg.hub)?,
14039            brain: canonical_brain.clone(),
14040            old_brain: old_key.multikey.clone(),
14041            new_brain: new_multikey.clone(),
14042            prior_head_seq: observed_head.seq,
14043            prior_feed_hash: observed_head.feed_hash.clone(),
14044            statement,
14045        };
14046        let mut exact = serde_json::to_vec(&journal)
14047            .map_err(|_| invalid_feed("could not serialize rotation journal"))?;
14048        exact.push(b'\n');
14049        if write_secret_new(&journal_path, &exact).is_err() {
14050            // A concurrent retry may have won the O_EXCL race. Only an exact,
14051            // fully validated journal is allowed to recover that race.
14052            read_rotation_journal(&journal_path)?
14053        } else {
14054            journal
14055        }
14056    };
14057    validate_rotation_journal(
14058        &journal,
14059        cfg,
14060        &canonical_brain,
14061        old_key,
14062        &new_key,
14063        &observed_head,
14064    )?;
14065
14066    let body = json!({ "statement": journal.statement });
14067    let path = format!("/api/hub/brains/{brain}/rotate");
14068    let attempted = request(cfg, "POST", &path, Some(&body), Auth::Required);
14069    let attempted_failure = match attempted {
14070        Ok(response) if (200..300).contains(&response.status) => None,
14071        Ok(response) => Some(ensure_ok(response, "key rotate").unwrap_err()),
14072        Err(error) => Some(error),
14073    };
14074
14075    // A 2xx body is not authority, and a failed response may have followed a
14076    // committed mutation. In both cases success means the normal verifier sees
14077    // an append-only rotation chain ending at the durable new key.
14078    let identity = if v2_profile {
14079        match v2_verified_head(cfg, brain) {
14080            Ok(Some(after)) => {
14081                let identity = v2_identity(&after.identity);
14082                accept_v2_head(cfg, &after)?;
14083                identity
14084            }
14085            Ok(None) => {
14086                return Err(attempted_failure.unwrap_or_else(|| {
14087                    invalid_feed("rotated v2 brain no longer serves a v2 head")
14088                }));
14089            }
14090            Err(error) => return Err(attempted_failure.unwrap_or(error)),
14091        }
14092    } else {
14093        match verified_remote_head(cfg, brain, false) {
14094            Ok(after) => after
14095                .identity
14096                .ok_or_else(|| invalid_feed("rotated brain has no verified identity"))?,
14097            Err(error) => return Err(attempted_failure.unwrap_or(error)),
14098        }
14099    };
14100    if format!("ed25519:{}", identity.fingerprint) != new_multikey
14101        || identity.public_key_spki != new_spki
14102    {
14103        return Err(attempted_failure.unwrap_or_else(|| {
14104            invalid_feed("hub acknowledged rotation without committing the verified new identity")
14105        }));
14106    }
14107    if v2_profile {
14108        if let Some(error) = attempted_failure {
14109            // Seeing the new public identity is insufficient: v2 rotation is
14110            // successful only after the endpoint has forced and acknowledged
14111            // the recovery checkpoint that contains it. Keep the journal for
14112            // an exact retry after any non-2xx or transport ambiguity.
14113            return Err(error);
14114        }
14115    }
14116    let previous = identity
14117        .previous
14118        .iter()
14119        .map(|prior| format!("ed25519:{}", prior.fingerprint))
14120        .collect();
14121    remove_rotation_journal(&journal_path);
14122
14123    Ok(RotationReport {
14124        brain: brain.to_string(),
14125        multikey: new_multikey,
14126        key_file: out.display().to_string(),
14127        previous,
14128    })
14129}
14130
14131// ─────────────────────────────────────────────────────────────────────────────
14132// mirror — verified replication: the whole feed + files, re-servable
14133// ─────────────────────────────────────────────────────────────────────────────
14134
14135/// What `dbmd mirror` materialized.
14136#[derive(Debug, Serialize)]
14137pub struct MirrorReport {
14138    /// The brain id.
14139    pub brain: String,
14140    /// The mirrored feed head.
14141    #[serde(rename = "headSeq")]
14142    pub head_seq: u64,
14143    /// The head entry hash (the feed's advertised converged state).
14144    #[serde(rename = "feedHash")]
14145    pub feed_hash: Option<String>,
14146    /// Signed feed entries verified and stored.
14147    pub entries: u64,
14148    /// The brain's multikey, pinned in `.dbmd/config` (TOFU).
14149    pub pinned: String,
14150    /// Store files materialized by the pull.
14151    pub files: usize,
14152}
14153
14154/// The mirror state directory, relative to the mirror root.
14155pub const MIRROR_REL_DIR: &str = ".dbmd/mirror";
14156
14157/// Fully re-verified mirror material suitable for a read-only re-server.
14158#[derive(Debug)]
14159pub struct VerifiedMirrorMaterial {
14160    pub brain: String,
14161    pub head_seq: u64,
14162    pub feed_hash: Option<String>,
14163    pub identity: serde_json::Value,
14164    /// Sequence, exact normative entry JSON without newline, and entry hash.
14165    pub entries: Vec<(u64, String, String)>,
14166    pub pack_sha256: Option<String>,
14167}
14168
14169#[derive(Deserialize)]
14170#[serde(deny_unknown_fields)]
14171struct StoredMirrorHead {
14172    brain: String,
14173    #[serde(rename = "headSeq")]
14174    head_seq: u64,
14175    #[serde(rename = "feedHash")]
14176    feed_hash: Option<String>,
14177}
14178
14179/// Re-verify mirror metadata, every signature/hash/rotation boundary, and the
14180/// exact snapshot pack before `dbmd serve` exposes any bytes.
14181pub fn verify_mirror_material(
14182    head_bytes: &[u8],
14183    identity_bytes: &[u8],
14184    feed_bytes: &[Vec<u8>],
14185    snapshot_pack: Option<&[u8]>,
14186    expected_anchor: &str,
14187) -> LinkResult<VerifiedMirrorMaterial> {
14188    let snapshot_hash = snapshot_pack
14189        .filter(|pack| !pack.is_empty())
14190        .map(content_sha256);
14191    verify_mirror_material_with_pack_hash(
14192        head_bytes,
14193        identity_bytes,
14194        feed_bytes,
14195        snapshot_hash.as_deref(),
14196        expected_anchor,
14197    )
14198}
14199
14200/// Re-verify mirror metadata against a snapshot digest computed from a held
14201/// no-follow file capability. This lets `dbmd serve` authenticate and retain a
14202/// large pack without ever buffering the pack in process memory.
14203pub fn verify_mirror_material_with_pack_hash(
14204    head_bytes: &[u8],
14205    identity_bytes: &[u8],
14206    feed_bytes: &[Vec<u8>],
14207    snapshot_pack_sha256: Option<&str>,
14208    expected_anchor: &str,
14209) -> LinkResult<VerifiedMirrorMaterial> {
14210    let head: StoredMirrorHead = serde_json::from_slice(head_bytes)
14211        .map_err(|_| invalid_feed("stored mirror head did not parse exactly"))?;
14212    require_safe_ref(&head.brain)?;
14213    if head.head_seq > MAX_FEED_REPLAY_ENTRIES || feed_bytes.len() as u64 != head.head_seq {
14214        return Err(invalid_feed(
14215            "stored mirror feed count does not match its bounded head sequence",
14216        ));
14217    }
14218    let aggregate = feed_bytes
14219        .iter()
14220        .try_fold(0u64, |total, bytes| total.checked_add(bytes.len() as u64))
14221        .ok_or_else(|| invalid_feed("stored mirror feed size overflow"))?;
14222    if aggregate > MAX_FEED_REPLAY_BYTES {
14223        return Err(invalid_feed(
14224            "stored mirror feed metadata exceeds the aggregate limit",
14225        ));
14226    }
14227    let identity: FeedIdentity = serde_json::from_slice(identity_bytes)
14228        .map_err(|_| invalid_feed("stored mirror identity did not parse exactly"))?;
14229    let anchor = verify_identity_chain(&identity, None)?;
14230    if anchor != expected_anchor {
14231        return Err(invalid_feed(
14232            "stored mirror identity does not descend from the explicitly trusted anchor",
14233        ));
14234    }
14235
14236    let mut entries = Vec::with_capacity(feed_bytes.len());
14237    let mut items = Vec::with_capacity(feed_bytes.len());
14238    let mut previous_hash = None;
14239    let mut pack_sha256 = None;
14240    for (index, bytes) in feed_bytes.iter().enumerate() {
14241        let exact = bytes
14242            .strip_suffix(b"\n")
14243            .ok_or_else(|| invalid_feed("stored feed entry lacks its exact trailing newline"))?;
14244        if exact.ends_with(b"\n") {
14245            return Err(invalid_feed("stored feed entry has extra trailing bytes"));
14246        }
14247        let entry: FeedEntry = serde_json::from_slice(exact)
14248            .map_err(|_| invalid_feed("stored feed entry did not parse exactly"))?;
14249        let expected_seq = index as u64 + 1;
14250        if entry.seq != expected_seq || entry.prev_entry_hash != previous_hash {
14251            return Err(invalid_feed(
14252                "stored mirror feed is not contiguous and hash-chained",
14253            ));
14254        }
14255        let canonical = serde_json::to_vec(&entry)
14256            .map_err(|_| invalid_feed("could not canonicalize stored feed entry"))?;
14257        if canonical != exact {
14258            return Err(invalid_feed(
14259                "stored feed entry is not in normative serialization",
14260            ));
14261        }
14262        let hash = content_sha256(bytes);
14263        let item = FeedItem {
14264            hash: hash.clone(),
14265            entry,
14266        };
14267        verify_feed_item(&item, &identity)?;
14268        previous_hash = Some(hash.clone());
14269        if expected_seq == head.head_seq {
14270            pack_sha256 = Some(item.entry.pack_sha256.clone());
14271        }
14272        entries.push((
14273            expected_seq,
14274            std::str::from_utf8(exact)
14275                .map_err(|_| invalid_feed("stored feed entry is not UTF-8"))?
14276                .to_string(),
14277            hash,
14278        ));
14279        items.push(item);
14280    }
14281    if previous_hash != head.feed_hash {
14282        return Err(invalid_feed(
14283            "stored mirror feed does not converge on its advertised head",
14284        ));
14285    }
14286    verify_rotation_feed_boundaries(&identity, None, &items, head.head_seq)?;
14287    match (head.head_seq, snapshot_pack_sha256, pack_sha256.as_deref()) {
14288        (0, None, None) => {}
14289        (_, Some(actual), Some(expected)) if actual == expected => {}
14290        _ => {
14291            return Err(LinkError::InvalidPack {
14292                message: "stored snapshot pack does not match the signed head digest".to_string(),
14293            });
14294        }
14295    }
14296    let identity_value = serde_json::to_value(&identity)
14297        .map_err(|_| invalid_feed("could not serialize verified mirror identity"))?;
14298    Ok(VerifiedMirrorMaterial {
14299        brain: head.brain,
14300        head_seq: head.head_seq,
14301        feed_hash: head.feed_hash,
14302        identity: identity_value,
14303        entries,
14304        pack_sha256,
14305    })
14306}
14307
14308/// SHA-256 hex of one feed entry's stored bytes (`exact JSON + "\n"`) — the
14309/// entry hash every consumer recomputes (SPEC §5.3).
14310pub fn feed_entry_hash(exact_sans_newline: &str) -> String {
14311    format!(
14312        "{:x}",
14313        Sha256::digest(format!("{exact_sans_newline}\n").as_bytes())
14314    )
14315}
14316
14317/// SHA-256 hex for content a signed manifest names. Exposed for the thin
14318/// `dbmd serve` adapter; cryptographic verification remains centralized here.
14319pub fn content_sha256(bytes: &[u8]) -> String {
14320    format!("{:x}", Sha256::digest(bytes))
14321}
14322
14323/// SHA-256 a stream with a fixed-size working buffer.
14324pub fn content_sha256_reader(mut reader: impl Read) -> std::io::Result<String> {
14325    let mut digest = Sha256::new();
14326    let mut buffer = [0u8; 64 * 1024];
14327    loop {
14328        let read = reader.read(&mut buffer)?;
14329        if read == 0 {
14330            break;
14331        }
14332        digest.update(&buffer[..read]);
14333    }
14334    Ok(format!("{:x}", digest.finalize()))
14335}
14336
14337/// Replicate a brain with full verification (link.md §5.4 over the WHOLE
14338/// chain, not just the head): every entry's signature, hash, sequence
14339/// contiguity, prev-hash linkage, rotation chain, and exact signed pack are
14340/// checked in a sibling staging directory. Only then is the old mirror swapped
14341/// out through an atomic directory exchange. Every stage, install, and cleanup
14342/// operation is relative to one held no-follow parent-directory capability, so
14343/// renaming an ancestor cannot redirect any write or deletion.
14344#[cfg_attr(windows, allow(unreachable_code, unused_variables))]
14345pub fn mirror(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<MirrorReport> {
14346    require_hardened_filesystem("mirror")?;
14347    require_safe_ref(brain)?;
14348    #[cfg(windows)]
14349    {
14350        let _ = (cfg, dest);
14351        return Err(LinkError::UnsupportedPlatform {
14352            operation: "atomic whole-mirror replacement on Windows",
14353        });
14354    }
14355    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
14356    let name = dest
14357        .file_name()
14358        .and_then(|name| name.to_str())
14359        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
14360        .ok_or_else(|| LinkError::UnsafePath {
14361            path: dest.display().to_string(),
14362        })?;
14363    #[cfg(unix)]
14364    let parent_dir = open_or_create_dir_nofollow(parent)?;
14365    #[cfg(unix)]
14366    use std::os::fd::AsRawFd as _;
14367    #[cfg(unix)]
14368    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
14369    #[cfg(unix)]
14370    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
14371        None => false,
14372        Some(true) => true,
14373        Some(false) => {
14374            return Err(LinkError::UnsafePath {
14375                path: dest.display().to_string(),
14376            });
14377        }
14378    };
14379
14380    // A fixed backup was used by pre-hardening builds. Never interpret or
14381    // delete an attacker-planted entry at that name; require manual recovery.
14382    #[cfg(unix)]
14383    let legacy_backup_name = c_name(
14384        format!(".{name}.dbmd-backup").as_bytes(),
14385        &dest.display().to_string(),
14386    )?;
14387    #[cfg(unix)]
14388    if entry_is_dir_at(parent_dir.as_raw_fd(), &legacy_backup_name)?.is_some() {
14389        return Err(LinkError::UnsafePath {
14390            path: parent
14391                .join(format!(".{name}.dbmd-backup"))
14392                .display()
14393                .to_string(),
14394        });
14395    }
14396
14397    let nonce = std::time::SystemTime::now()
14398        .duration_since(std::time::UNIX_EPOCH)
14399        .unwrap_or_default()
14400        .as_nanos();
14401    let stage_label = format!(".{name}.dbmd-stage-{}-{nonce}", std::process::id());
14402    #[cfg(unix)]
14403    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
14404    #[cfg(unix)]
14405    let stage_dir = create_dir_exclusive_at(
14406        parent_dir.as_raw_fd(),
14407        &stage_name,
14408        &dest.display().to_string(),
14409    )?;
14410
14411    let assembled = (|| -> LinkResult<MirrorReport> {
14412        let remote = verified_remote_head(cfg, brain, true)?;
14413        let brain_id = remote.head.brain.clone();
14414        let identity = remote
14415            .identity
14416            .as_ref()
14417            .ok_or_else(|| invalid_feed("brain has no signed identity"))?;
14418        let anchor = remote
14419            .anchor
14420            .clone()
14421            .ok_or_else(|| invalid_feed("brain has no verified identity anchor"))?;
14422        let pack = download_verified_snapshot_pack(cfg, &brain_id, &remote)?;
14423        let snapshot_entries = parse_store_pack(pack.clone())?;
14424        let snapshot_count = snapshot_entries.len();
14425        let mut staged_entries = snapshot_entries;
14426        staged_entries.push((format!("{MIRROR_REL_DIR}/snapshot.pack"), pack));
14427        for item in &remote.entries {
14428            let mut exact = serde_json::to_vec(&item.entry)
14429                .map_err(|_| invalid_feed("could not serialize signed feed entry"))?;
14430            exact.push(b'\n');
14431            if format!("{:x}", Sha256::digest(&exact)) != item.hash {
14432                return Err(invalid_feed(
14433                    "serialized mirror entry differs from its verified hash",
14434                ));
14435            }
14436            staged_entries.push((
14437                format!("{MIRROR_REL_DIR}/feed/{}.json", item.entry.seq),
14438                exact,
14439            ));
14440        }
14441        let mut identity_bytes = serde_json::to_vec(identity)
14442            .map_err(|_| invalid_feed("could not serialize verified identity chain"))?;
14443        identity_bytes.push(b'\n');
14444        staged_entries.push((format!("{MIRROR_REL_DIR}/identity.json"), identity_bytes));
14445        let mut head_bytes = serde_json::to_vec(&json!({
14446            "brain": brain_id,
14447            "headSeq": remote.head.seq,
14448            "feedHash": remote.head.feed_hash,
14449        }))
14450        .map_err(|_| invalid_feed("could not serialize verified mirror head"))?;
14451        head_bytes.push(b'\n');
14452        staged_entries.push((format!("{MIRROR_REL_DIR}/head.json"), head_bytes));
14453        staged_entries.push((
14454            CONFIG_REL_PATH.to_string(),
14455            format!("hub = {}\npin = {anchor}\n", cfg.hub).into_bytes(),
14456        ));
14457        #[cfg(unix)]
14458        write_pull_entries_beneath_dir(&stage_dir, &staged_entries)?;
14459
14460        Ok(MirrorReport {
14461            brain: brain_id,
14462            head_seq: remote.head.seq,
14463            feed_hash: remote.head.feed_hash,
14464            entries: remote.entries.len() as u64,
14465            pinned: anchor,
14466            files: snapshot_count,
14467        })
14468    })();
14469
14470    let report = match assembled {
14471        Ok(report) => report,
14472        Err(error) => {
14473            #[cfg(unix)]
14474            let _ = remove_tree_at(
14475                parent_dir.as_raw_fd(),
14476                &stage_name,
14477                &dest.display().to_string(),
14478            );
14479            return Err(error);
14480        }
14481    };
14482
14483    #[cfg(unix)]
14484    if let Err(error) =
14485        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
14486    {
14487        let _ = remove_tree_at(
14488            parent_dir.as_raw_fd(),
14489            &stage_name,
14490            &dest.display().to_string(),
14491        );
14492        return Err(error);
14493    }
14494    // An exchange leaves the old mirror at the unique stage name. Cleanup is
14495    // capability-relative and never follows symlinks in hostile old content.
14496    #[cfg(unix)]
14497    if dest_exists {
14498        remove_tree_at(
14499            parent_dir.as_raw_fd(),
14500            &stage_name,
14501            &dest.display().to_string(),
14502        )?;
14503    }
14504    #[cfg(unix)]
14505    parent_dir.sync_all()?;
14506    Ok(report)
14507}
14508
14509fn verified_remote_head(
14510    cfg: &HubConfig,
14511    brain: &str,
14512    require_full_chain: bool,
14513) -> LinkResult<VerifiedRemote> {
14514    require_hardened_filesystem("verified link.md state")?;
14515    require_safe_ref(brain)?;
14516    // Refuse an unsafe state root before sending credentials or consulting an
14517    // untrusted card, and retain this exact directory inode for the complete
14518    // checkpoint transaction.
14519    let trust_directory = open_trust_dir(cfg)?;
14520    let path = format!("/api/hub/brains/{brain}");
14521    let body = ensure_ok(
14522        request(cfg, "GET", &path, None, Auth::Required)?,
14523        "subscribe",
14524    )?;
14525    let resolved_brain = body
14526        .get("id")
14527        .and_then(Value::as_str)
14528        .filter(|id| crate::ulid::is_ulid(id))
14529        .ok_or_else(|| invalid_feed("brain card has no canonical ULID id"))?
14530        .to_string();
14531    if crate::ulid::is_ulid(brain) && resolved_brain != brain {
14532        return Err(invalid_feed(
14533            "brain card id differs from the explicitly requested brain id",
14534        ));
14535    }
14536    // The card supplies the canonical ULID. Lock alias + canonical keys in
14537    // deterministic filename order, then hold both through verify + save.
14538    // Concurrent aliases for the same brain therefore converge on one
14539    // checkpoint instead of establishing independent TOFU universes.
14540    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &resolved_brain])?;
14541    let (pinned, alias_binding) =
14542        load_canonical_pin(cfg, &trust_directory, brain, &resolved_brain)?;
14543    let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
14544    let advertised_hash = body
14545        .get("feedHash")
14546        .and_then(Value::as_str)
14547        .map(str::to_string);
14548    let updated_at = body
14549        .get("updatedAt")
14550        .and_then(Value::as_str)
14551        .map(str::to_string);
14552    if let Some(pin) = &pinned {
14553        if seq < pin.head_seq {
14554            return Err(invalid_feed(format!(
14555                "feed rollback: hub advertised sequence {seq}, local checkpoint is {}",
14556                pin.head_seq
14557            )));
14558        }
14559        if seq == pin.head_seq && advertised_hash != pin.feed_hash {
14560            return Err(invalid_feed(
14561                "feed equivocation: the checkpoint sequence now has a different hash",
14562            ));
14563        }
14564    }
14565    if seq == 0 {
14566        if advertised_hash.is_some() {
14567            return Err(invalid_feed("an empty feed advertised a head hash"));
14568        }
14569        let identity: FeedIdentity = serde_json::from_value(
14570            body.get("identity")
14571                .cloned()
14572                .ok_or_else(|| invalid_feed("empty brain card has no signed identity"))?,
14573        )
14574        .map_err(|_| invalid_feed("empty brain card has an invalid identity"))?;
14575        let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
14576        // A valid old-key-signed rotation is still inconsistent if it commits
14577        // to history that the same card now claims never existed. Run the
14578        // identical feed-boundary proof used by non-empty heads before this
14579        // identity can become a durable TOFU checkpoint.
14580        verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], seq)?;
14581        save_canonical_pin_and_alias(
14582            cfg,
14583            &trust_directory,
14584            brain,
14585            &resolved_brain,
14586            TrustState {
14587                v: 2,
14588                origin: normalized_origin(&cfg.hub)?,
14589                requested: resolved_brain.clone(),
14590                brain: resolved_brain.clone(),
14591                home: None,
14592                anchor: anchor.clone(),
14593                current: format!("ed25519:{}", identity.fingerprint),
14594                head_seq: 0,
14595                feed_hash: None,
14596                rotations: identity.rotations.clone(),
14597                hub_signer: None,
14598                protocol_profile: None,
14599            },
14600            alias_binding.as_ref(),
14601        )?;
14602        return Ok(VerifiedRemote {
14603            head: Head {
14604                brain: resolved_brain,
14605                seq,
14606                updated_at,
14607                feed_hash: None,
14608                verified: true,
14609            },
14610            identity: Some(identity),
14611            head_entry: None,
14612            entries: Vec::new(),
14613            anchor: Some(anchor),
14614        });
14615    }
14616    if advertised_hash.as_ref().is_none_or(|hash| !is_sha256(hash)) {
14617        return Err(invalid_feed(
14618            "non-empty feed did not advertise a valid SHA-256 head",
14619        ));
14620    }
14621
14622    // On first contact the signed head itself is the TOFU checkpoint. A full
14623    // history replay cannot add authority before an anchor exists; mirrors
14624    // still request the complete chain because they promise an archival copy.
14625    let replay_head_only = !require_full_chain
14626        && pinned
14627            .as_ref()
14628            .is_none_or(|checkpoint| checkpoint.head_seq == seq);
14629    let mut after = if replay_head_only {
14630        seq - 1
14631    } else if require_full_chain || pinned.is_none() {
14632        0
14633    } else {
14634        pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq)
14635    };
14636    let mut expected_seq = after + 1;
14637    let mut previous_hash = if require_full_chain || pinned.is_none() || replay_head_only {
14638        None
14639    } else {
14640        pinned
14641            .as_ref()
14642            .and_then(|checkpoint| checkpoint.feed_hash.clone())
14643    };
14644    let mut identity: Option<FeedIdentity> = None;
14645    let mut anchor: Option<String> = None;
14646    let mut head_entry: Option<FeedItem> = None;
14647    let mut all_entries = Vec::new();
14648    let mut observed_entries = Vec::new();
14649    let replay_count = seq
14650        .checked_sub(after)
14651        .ok_or_else(|| invalid_feed("feed replay range moved backward"))?;
14652    if replay_count > MAX_FEED_REPLAY_ENTRIES {
14653        return Err(invalid_feed(format!(
14654            "feed replay requires {replay_count} entries, over the client cap"
14655        )));
14656    }
14657    let mut replay_bytes = 0u64;
14658
14659    loop {
14660        let feed_bytes = ensure_raw_ok(
14661            request_raw(
14662                cfg,
14663                "GET",
14664                &format!("/api/hub/brains/{brain}/feed?after={after}&limit=100"),
14665                None,
14666                Auth::Required,
14667                MAX_FEED_RESPONSE_BYTES,
14668            )?,
14669            "subscribe feed",
14670        )?;
14671        let feed: FeedResponse = serde_json::from_slice(&feed_bytes)
14672            .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
14673        if feed.head_seq != seq || feed.feed_hash != advertised_hash {
14674            return Err(invalid_feed("brain card and feed head disagree"));
14675        }
14676        if feed.entries.len() > FEED_PAGE_LIMIT {
14677            return Err(invalid_feed("feed page exceeds the requested entry limit"));
14678        }
14679        if feed.scope_limited {
14680            if require_full_chain {
14681                return Err(invalid_feed(
14682                    "path-scoped grants cannot verify a full snapshot chain",
14683                ));
14684            }
14685            return Ok(VerifiedRemote {
14686                head: Head {
14687                    brain: resolved_brain,
14688                    seq,
14689                    updated_at,
14690                    feed_hash: advertised_hash,
14691                    verified: false,
14692                },
14693                identity: None,
14694                head_entry: None,
14695                entries: Vec::new(),
14696                anchor: None,
14697            });
14698        }
14699        let page_identity = feed
14700            .identity
14701            .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
14702        let page_anchor = verify_identity_chain(&page_identity, pinned.as_ref())?;
14703        if identity
14704            .as_ref()
14705            .is_some_and(|existing| existing != &page_identity)
14706        {
14707            return Err(invalid_feed("identity changed while reading the feed"));
14708        }
14709        if anchor
14710            .as_ref()
14711            .is_some_and(|existing| existing != &page_anchor)
14712        {
14713            return Err(invalid_feed(
14714                "identity anchor changed while reading the feed",
14715            ));
14716        }
14717        identity = Some(page_identity.clone());
14718        if anchor.is_none() {
14719            anchor = Some(page_anchor);
14720        }
14721        if feed.entries.is_empty() {
14722            return Err(invalid_feed("feed page was empty before the signed head"));
14723        }
14724
14725        for item in feed.entries {
14726            if item.entry.seq != expected_seq {
14727                return Err(invalid_feed(format!(
14728                    "expected entry {expected_seq}, feed served {}",
14729                    item.entry.seq
14730                )));
14731            }
14732            if item.entry.seq > seq {
14733                return Err(invalid_feed("feed advanced past the card snapshot"));
14734            }
14735            if !replay_head_only && item.entry.prev_entry_hash != previous_hash {
14736                return Err(invalid_feed(format!(
14737                    "entry {} does not chain to the local checkpoint",
14738                    item.entry.seq
14739                )));
14740            }
14741            verify_feed_item(&item, &page_identity)?;
14742            reject_retired_signer_after_checkpoint(&page_identity, pinned.as_ref(), &item)?;
14743            replay_bytes = replay_bytes.saturating_add(
14744                serde_json::to_vec(&item)
14745                    .map_err(|_| invalid_feed("could not size feed entry"))?
14746                    .len() as u64,
14747            );
14748            if replay_bytes > MAX_FEED_REPLAY_BYTES {
14749                return Err(invalid_feed("feed replay metadata exceeds the client cap"));
14750            }
14751            previous_hash = Some(item.hash.clone());
14752            after = item.entry.seq;
14753            expected_seq = expected_seq
14754                .checked_add(1)
14755                .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
14756            if require_full_chain {
14757                all_entries.push(item.clone());
14758            }
14759            observed_entries.push(item.clone());
14760            head_entry = Some(item);
14761        }
14762        if after == seq {
14763            break;
14764        }
14765    }
14766
14767    if head_entry.as_ref().map(|item| &item.hash) != advertised_hash.as_ref() {
14768        return Err(invalid_feed(
14769            "verified chain does not converge on the advertised head",
14770        ));
14771    }
14772    let identity = identity.ok_or_else(|| invalid_feed("feed has no brain identity"))?;
14773    let anchor = anchor.ok_or_else(|| invalid_feed("feed has no identity anchor"))?;
14774    verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &observed_entries, seq)?;
14775    save_canonical_pin_and_alias(
14776        cfg,
14777        &trust_directory,
14778        brain,
14779        &resolved_brain,
14780        TrustState {
14781            v: 2,
14782            origin: normalized_origin(&cfg.hub)?,
14783            requested: resolved_brain.clone(),
14784            brain: resolved_brain.clone(),
14785            home: None,
14786            anchor: anchor.clone(),
14787            current: format!("ed25519:{}", identity.fingerprint),
14788            head_seq: seq,
14789            feed_hash: advertised_hash.clone(),
14790            rotations: identity.rotations.clone(),
14791            hub_signer: pinned.as_ref().and_then(|state| state.hub_signer.clone()),
14792            protocol_profile: pinned
14793                .as_ref()
14794                .and_then(|state| state.protocol_profile.clone()),
14795        },
14796        alias_binding.as_ref(),
14797    )?;
14798    Ok(VerifiedRemote {
14799        head: Head {
14800            brain: resolved_brain,
14801            seq,
14802            updated_at,
14803            feed_hash: advertised_hash,
14804            verified: true,
14805        },
14806        identity: Some(identity),
14807        head_entry,
14808        entries: all_entries,
14809        anchor: Some(anchor),
14810    })
14811}
14812
14813/// Read and locally verify the brain's current signed feed head. Identity
14814/// rotation is accepted only through an old-key-signed chain rooted at the
14815/// local TOFU anchor; sequence and hash checkpoints reject rollback and
14816/// equivocation across invocations.
14817pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
14818    if let Some(verified) = v2_verified_head(cfg, brain)? {
14819        let observation = Head {
14820            brain: verified.brain_id.clone(),
14821            seq: verified.pointer.as_ref().map_or(0, |pointer| pointer.seq),
14822            updated_at: verified
14823                .pointer
14824                .as_ref()
14825                .map(|pointer| pointer.signed_at.clone()),
14826            feed_hash: verified
14827                .pointer
14828                .as_ref()
14829                .map(|pointer| pointer.feed_hash.clone()),
14830            verified: true,
14831        };
14832        accept_v2_head(cfg, &verified)?;
14833        return Ok(observation);
14834    }
14835    Ok(verified_remote_head(cfg, brain, false)?.head)
14836}
14837
14838#[cfg(test)]
14839mod tests {
14840    use super::*;
14841
14842    const TEST_BRAIN_ID: &str = "01j5qc3v9k4ym8rwbn2tqe6f7d";
14843
14844    fn upload_declaration(index: usize, coordinate_len: usize) -> Value {
14845        json!({
14846            "sha256": "a".repeat(64),
14847            "bytes": 10,
14848            "coordinates": [format!("records/notes/{}-{}.md", "n".repeat(coordinate_len), index)],
14849        })
14850    }
14851
14852    #[test]
14853    fn upload_reservations_batch_by_count_and_by_size() {
14854        // A real store's first push declares one blob per changed file, which
14855        // does not fit in one request. Every batch has to stay inside both
14856        // bounds, and concatenating them has to reproduce the original order.
14857        let declarations: Vec<Value> = (0..5_000).map(|i| upload_declaration(i, 8)).collect();
14858        let batches = batch_upload_declarations(declarations.clone());
14859
14860        assert!(batches.len() > 1, "5,000 blobs must not ride one request");
14861        for batch in &batches {
14862            assert!(batch.len() <= MAX_UPLOAD_RESERVATION_BLOBS);
14863            let bytes = serde_json::to_string(&json!({ "blobs": batch }))
14864                .expect("batch serializes")
14865                .len();
14866            assert!(
14867                bytes <= MAX_UPLOAD_RESERVATION_BYTES + 1_024,
14868                "batch body {bytes} exceeds the reservation budget"
14869            );
14870        }
14871        let flattened: Vec<Value> = batches.into_iter().flatten().collect();
14872        assert_eq!(
14873            flattened, declarations,
14874            "batching must preserve the set and order"
14875        );
14876    }
14877
14878    #[test]
14879    fn only_load_shaped_hub_answers_are_worth_asking_again() {
14880        // A migration opens dozens of staging windows, so a busy hub's 429 or
14881        // 5xx must not end the push — while a refusal that states something
14882        // about the request itself has to surface immediately rather than
14883        // being repeated six times behind a minute of waiting.
14884        for status in [408, 429, 500, 502, 503, 504] {
14885            assert!(is_retryable_hub_status(status), "{status} is load-shaped");
14886        }
14887        for status in [400, 401, 403, 404, 409, 413, 422] {
14888            assert!(
14889                !is_retryable_hub_status(status),
14890                "{status} states something about the request"
14891            );
14892        }
14893        // The waits must reach past a whole limiter window to be worth taking.
14894        let total: u64 = RESERVATION_BACKOFF_MS.iter().sum();
14895        assert!(total >= 60_000, "backoff totals only {total}ms");
14896    }
14897
14898    #[test]
14899    fn a_batch_shares_a_connection_only_within_one_authority() {
14900        // Sharing a pooled agent is what makes a migration stage in minutes,
14901        // but it must never widen where bytes can go: a batch that spans two
14902        // authorities, or names an unsafe URL, falls back to per-object agents
14903        // rather than reusing a connection pinned to somewhere else.
14904        let cfg = HubConfig {
14905            hub: "https://www.sevrahq.com".to_string(),
14906            key: Some("k".to_string()),
14907            agent_key: None,
14908            brain_key: None,
14909            state_dir: PathBuf::from("."),
14910            store_selected: false,
14911        };
14912        assert!(shared_staging_agent(&cfg, &[]).is_none());
14913        assert!(
14914            shared_staging_agent(
14915                &cfg,
14916                &[
14917                    "https://one.example.com/a?sig=1",
14918                    "https://two.example.com/b?sig=2",
14919                ]
14920            )
14921            .is_none(),
14922            "two authorities must not share a pinned pool"
14923        );
14924        assert!(
14925            shared_staging_agent(&cfg, &["http://one.example.com/a"]).is_none(),
14926            "an unsafe object-store URL must not produce an agent"
14927        );
14928        assert!(
14929            shared_staging_agent(&cfg, &["https://user:pw@one.example.com/a"]).is_none(),
14930            "credentials in the URL must not produce an agent"
14931        );
14932    }
14933
14934    #[test]
14935    fn a_staged_change_states_only_operations_and_blobs() {
14936        // The hub refuses a manifest with any other key, so the two sides have
14937        // to agree exactly. A drifting extra field would fail a whole migration
14938        // at the commit, after every byte had already been staged.
14939        let operations = vec![json!({
14940            "op": "put",
14941            "path": "records/a.md",
14942            "blob": "a".repeat(64),
14943            "bytes": 3,
14944        })];
14945        let blobs = json!([{ "sha256": "a".repeat(64), "bytes": 3, "reservation_id": "01" }]);
14946        let bytes = v2_change_manifest(&operations, blobs.clone()).expect("manifest");
14947        let parsed: Value = serde_json::from_slice(&bytes).expect("manifest is JSON");
14948        let keys: Vec<&str> = parsed
14949            .as_object()
14950            .expect("manifest is an object")
14951            .keys()
14952            .map(String::as_str)
14953            .collect();
14954        assert_eq!(keys, ["blobs", "operations"]);
14955        assert_eq!(parsed["operations"], Value::Array(operations));
14956        assert_eq!(parsed["blobs"], blobs);
14957    }
14958
14959    #[test]
14960    fn a_staged_push_signs_the_change_not_the_transport() {
14961        // Self-custody signs a request hash the hub recomputes from the parsed
14962        // change. A staged body no longer states its operations, so the signed
14963        // view has to restore them or every large self-custodied push would
14964        // sign a different request than the one the hub validated.
14965        let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
14966        let staged = json!({
14967            "mutation_id": "dbmd-1",
14968            "rebase": "strict",
14969            "staged_change": { "sha256": "a".repeat(64), "bytes": 9, "reservation_id": "01" },
14970        });
14971        let view = v2_signed_request_view(&staged, &operations);
14972        assert_eq!(view["operations"], Value::Array(operations.clone()));
14973        assert!(view.get("staged_change").is_none());
14974        assert_eq!(view["mutation_id"], staged["mutation_id"]);
14975
14976        let inline = json!({ "mutation_id": "dbmd-1", "operations": operations });
14977        assert_eq!(v2_signed_request_view(&inline, &[]), inline);
14978    }
14979
14980    #[test]
14981    fn a_change_past_the_staging_ceiling_is_refused_before_transport() {
14982        let huge = vec![Value::String("a".repeat(MAX_STAGED_CHANGE_BYTES + 1))];
14983        let error = v2_change_manifest(&huge, Value::Array(Vec::new()))
14984            .expect_err("an oversized change must not be staged");
14985        assert!(
14986            matches!(error, LinkError::PushTooLarge { .. }),
14987            "expected a size refusal, got {error:?}"
14988        );
14989    }
14990
14991    #[test]
14992    fn a_push_that_fits_the_request_is_left_inline() {
14993        // The staged lane costs a reservation and a round trip, so it must
14994        // engage only when the body genuinely does not fit. The hub here is a
14995        // closed port: reaching for it at all would fail this test.
14996        let cfg = HubConfig {
14997            hub: "http://127.0.0.1:9".to_string(),
14998            key: Some("k".to_string()),
14999            agent_key: None,
15000            brain_key: None,
15001            state_dir: PathBuf::from("."),
15002            store_selected: false,
15003        };
15004        let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
15005        let mut body = json!({
15006            "mutation_id": "dbmd-1",
15007            "operations": operations,
15008            "blobs": [],
15009        });
15010        stage_oversized_v2_change(&cfg, "brain", &operations, &mut body).expect("no staging");
15011        assert!(body.get("staged_change").is_none());
15012        assert_eq!(body["operations"], Value::Array(operations));
15013    }
15014
15015    #[test]
15016    fn wide_coordinate_sets_shrink_batches_below_the_count_bound() {
15017        // Byte budget, not just count. One hash can carry many coordinates
15018        // (the same bytes reachable at several paths), so a batch sized only by
15019        // count would blow the body budget on a deduplicated store.
15020        let declarations: Vec<Value> = (0..2_000)
15021            .map(|index| {
15022                json!({
15023                    "sha256": "a".repeat(64),
15024                    "bytes": 10,
15025                    "coordinates": (0..24)
15026                        .map(|slot| format!(
15027                            "records/workflowy-nodes/2019/02/a-fairly-long-node-title-{index}-{slot}-abcd1234.md"
15028                        ))
15029                        .collect::<Vec<_>>(),
15030                })
15031            })
15032            .collect();
15033        let batches = batch_upload_declarations(declarations);
15034        assert!(
15035            batches[0].len() < MAX_UPLOAD_RESERVATION_BLOBS,
15036            "wide coordinate sets must bound the batch by size"
15037        );
15038        for batch in &batches {
15039            let bytes = serde_json::to_string(&json!({ "blobs": batch }))
15040                .expect("batch serializes")
15041                .len();
15042            assert!(bytes <= MAX_UPLOAD_RESERVATION_BYTES + 2_048);
15043        }
15044    }
15045
15046    #[test]
15047    fn a_small_push_still_rides_exactly_one_request() {
15048        let declarations: Vec<Value> = (0..3).map(|i| upload_declaration(i, 8)).collect();
15049        assert_eq!(batch_upload_declarations(declarations).len(), 1);
15050        assert!(batch_upload_declarations(Vec::new()).is_empty());
15051    }
15052
15053    #[test]
15054    fn exact_source_move_becomes_one_provenance_preserving_rename() {
15055        let hash = "a".repeat(64);
15056        let operations = vec![
15057            json!({
15058                "op": "put",
15059                "path": "sources/curated/item.md",
15060                "expected": { "kind": "absent" },
15061                "blob": hash,
15062                "bytes": 19,
15063            }),
15064            json!({
15065                "op": "delete",
15066                "path": "sources/inbox/item.md",
15067                "expected": { "kind": "blob", "hash": hash },
15068            }),
15069        ];
15070
15071        assert_eq!(
15072            infer_exact_source_promotions(operations),
15073            vec![json!({
15074                "op": "rename",
15075                "from": "sources/inbox/item.md",
15076                "to": "sources/curated/item.md",
15077                "expected_from": { "kind": "blob", "hash": hash },
15078                "expected_to": { "kind": "absent" },
15079                "blob": hash,
15080                "bytes": 19,
15081            })]
15082        );
15083    }
15084
15085    #[test]
15086    fn duplicate_source_bytes_never_guess_which_evidence_was_promoted() {
15087        let hash = "b".repeat(64);
15088        let operations = vec![
15089            json!({
15090                "op": "delete",
15091                "path": "sources/inbox/a.md",
15092                "expected": { "kind": "blob", "hash": hash },
15093            }),
15094            json!({
15095                "op": "delete",
15096                "path": "sources/inbox/b.md",
15097                "expected": { "kind": "blob", "hash": hash },
15098            }),
15099            json!({
15100                "op": "put",
15101                "path": "sources/curated/item.md",
15102                "expected": { "kind": "absent" },
15103                "blob": hash,
15104                "bytes": 19,
15105            }),
15106        ];
15107
15108        assert_eq!(
15109            infer_exact_source_promotions(operations.clone()),
15110            operations,
15111            "an ambiguous filesystem diff must reach the hub unchanged and fail closed"
15112        );
15113    }
15114
15115    #[test]
15116    fn accepted_source_promotion_advances_the_local_baseline_exactly() {
15117        let hash = "c".repeat(64);
15118        let mut candidate = std::collections::BTreeMap::from([(
15119            "sources/inbox/item.md".to_string(),
15120            V2BaselineFile {
15121                sha256: hash.clone(),
15122                bytes: 19,
15123                proof: None,
15124            },
15125        )]);
15126        let mut candidate_assets = std::collections::BTreeMap::new();
15127        let operations = vec![
15128            json!({
15129                "op": "rename",
15130                "from": "sources/inbox/item.md",
15131                "to": "sources/curated/item.md",
15132                "expected_from": { "kind": "blob", "hash": hash },
15133                "expected_to": { "kind": "absent" },
15134                "blob": hash,
15135                "bytes": 19,
15136            }),
15137            json!({
15138                "op": "put",
15139                "path": "records/rsvps/item.md",
15140                "expected": { "kind": "absent" },
15141                "blob": "d".repeat(64),
15142                "bytes": 23,
15143            }),
15144        ];
15145
15146        assert!(!apply_generated_v2_operations(
15147            &operations,
15148            &std::collections::BTreeMap::new(),
15149            &mut candidate,
15150            &mut candidate_assets,
15151        )
15152        .unwrap());
15153        assert!(!candidate.contains_key("sources/inbox/item.md"));
15154        assert_eq!(
15155            candidate
15156                .get("sources/curated/item.md")
15157                .map(|file| (&file.sha256, file.bytes)),
15158            Some((&hash, 19))
15159        );
15160        assert_eq!(
15161            candidate
15162                .get("records/rsvps/item.md")
15163                .map(|file| (file.sha256.as_str(), file.bytes)),
15164            Some((
15165                "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
15166                23
15167            ))
15168        );
15169    }
15170
15171    fn merge_fixture(
15172        base: Option<&str>,
15173        remote: Option<&str>,
15174        local: Option<&str>,
15175        keep_local: bool,
15176    ) -> V2PulledMerge<String> {
15177        let map = |value: Option<&str>| {
15178            value
15179                .map(|value| [("records/a.md".to_string(), value.to_string())])
15180                .into_iter()
15181                .flatten()
15182                .collect::<std::collections::BTreeMap<_, _>>()
15183        };
15184        merge_v2_pulled_records(
15185            &map(base),
15186            &map(remote),
15187            &map(local),
15188            |value, _| value.clone(),
15189            |value, _| value.clone(),
15190            |_| keep_local,
15191        )
15192    }
15193
15194    #[test]
15195    fn v2_pull_three_way_merge_never_discards_a_local_only_change() {
15196        let path = "records/a.md".to_string();
15197
15198        let local_add = merge_fixture(None, None, Some("local"), false);
15199        assert_eq!(
15200            local_add.records.get(&path).map(String::as_str),
15201            Some("local")
15202        );
15203        assert!(local_add.accept_remote.is_empty());
15204        assert!(local_add.conflicts.is_empty());
15205
15206        let local_edit = merge_fixture(Some("old"), Some("old"), Some("local"), false);
15207        assert_eq!(
15208            local_edit.records.get(&path).map(String::as_str),
15209            Some("local")
15210        );
15211        assert!(local_edit.accept_remote.is_empty());
15212        assert!(local_edit.conflicts.is_empty());
15213
15214        let local_delete = merge_fixture(Some("old"), Some("old"), None, false);
15215        assert!(!local_delete.records.contains_key(&path));
15216        assert!(local_delete.accept_remote.is_empty());
15217        assert!(local_delete.conflicts.is_empty());
15218
15219        let remote_edit = merge_fixture(Some("old"), Some("remote"), Some("old"), false);
15220        assert_eq!(
15221            remote_edit.records.get(&path).map(String::as_str),
15222            Some("remote")
15223        );
15224        assert!(remote_edit.accept_remote.contains(&path));
15225        assert!(remote_edit.conflicts.is_empty());
15226
15227        let remote_delete = merge_fixture(Some("old"), None, Some("old"), false);
15228        assert!(!remote_delete.records.contains_key(&path));
15229        assert!(remote_delete.accept_remote.contains(&path));
15230        assert!(remote_delete.conflicts.is_empty());
15231
15232        let same_edit = merge_fixture(Some("old"), Some("same"), Some("same"), false);
15233        assert_eq!(
15234            same_edit.records.get(&path).map(String::as_str),
15235            Some("same")
15236        );
15237        assert!(same_edit.accept_remote.contains(&path));
15238        assert!(same_edit.conflicts.is_empty());
15239
15240        let conflict = merge_fixture(Some("old"), Some("remote"), Some("local"), false);
15241        assert_eq!(conflict.conflicts, vec![path.clone()]);
15242        assert_eq!(
15243            conflict.records.get(&path).map(String::as_str),
15244            Some("local")
15245        );
15246        assert!(conflict.accept_remote.is_empty());
15247
15248        let kept_home = merge_fixture(Some("old"), Some("remote"), Some("local"), true);
15249        assert_eq!(
15250            kept_home.records.get(&path).map(String::as_str),
15251            Some("local")
15252        );
15253        assert!(kept_home.accept_remote.is_empty());
15254        assert!(kept_home.conflicts.is_empty());
15255    }
15256
15257    #[test]
15258    fn v2_asset_hosting_resume_is_an_explicit_local_policy_transition() {
15259        let path = "sources/report.pdf";
15260        let record = crate::AssetRecord {
15261            path: path.to_string(),
15262            sha256: "a".repeat(64),
15263            bytes: 42,
15264            media_type: "application/pdf".to_string(),
15265            wrappers: vec!["gzip".to_string()],
15266            required: true,
15267        };
15268        let mut remote = V2BaselineAsset {
15269            blob_sha256: record.sha256.clone(),
15270            bytes: record.bytes,
15271            media_type: record.media_type.clone(),
15272            wrappers: record.wrappers.clone(),
15273            required: record.required,
15274            disposition: "withheld".to_string(),
15275            leaf_hash: "b".repeat(64),
15276        };
15277
15278        assert!(v2_asset_resumes_hosting(
15279            Some(&remote),
15280            path,
15281            &record,
15282            "hosted"
15283        ));
15284        assert!(!v2_asset_resumes_hosting(
15285            Some(&remote),
15286            path,
15287            &record,
15288            "withheld"
15289        ));
15290
15291        remote.disposition = "hosted".to_string();
15292        assert!(!v2_asset_resumes_hosting(
15293            Some(&remote),
15294            path,
15295            &record,
15296            "hosted"
15297        ));
15298
15299        remote.disposition = "withheld".to_string();
15300        remote.blob_sha256 = "c".repeat(64);
15301        assert!(!v2_asset_resumes_hosting(
15302            Some(&remote),
15303            path,
15304            &record,
15305            "hosted"
15306        ));
15307        assert!(!v2_asset_resumes_hosting(None, path, &record, "hosted"));
15308    }
15309
15310    #[test]
15311    fn take_remote_resolution_rebinds_to_the_exact_current_manifest() {
15312        let path = "records/team/alpha.md".to_string();
15313        let deleted_path = "records/team/deleted.md".to_string();
15314        let coordinate = |sha256: Option<String>, bytes: Option<u64>| V2ConflictCoordinate {
15315            sha256,
15316            bytes,
15317            file: None,
15318        };
15319        let files = vec![
15320            V2ConflictFile {
15321                path: path.clone(),
15322                base: coordinate(None, None),
15323                local: coordinate(Some("b".repeat(64)), Some(7)),
15324                remote: coordinate(Some("a".repeat(64)), Some(5)),
15325            },
15326            V2ConflictFile {
15327                path: deleted_path.clone(),
15328                base: coordinate(Some("c".repeat(64)), Some(9)),
15329                local: coordinate(Some("d".repeat(64)), Some(11)),
15330                remote: coordinate(None, None),
15331            },
15332        ];
15333        let proven = V2BaselineFile {
15334            sha256: "a".repeat(64),
15335            bytes: 5,
15336            proof: None,
15337        };
15338        let current = [(path.clone(), proven.clone())]
15339            .into_iter()
15340            .collect::<std::collections::BTreeMap<_, _>>();
15341
15342        let (selected, deleted) = v2_take_remote_selection(&files, &current).unwrap();
15343        assert_eq!(selected.get(&path).unwrap().sha256, proven.sha256);
15344        assert_eq!(deleted, vec![deleted_path.clone()]);
15345
15346        let changed = [(
15347            path.clone(),
15348            V2BaselineFile {
15349                sha256: "e".repeat(64),
15350                bytes: 5,
15351                proof: None,
15352            },
15353        )]
15354        .into_iter()
15355        .collect::<std::collections::BTreeMap<_, _>>();
15356        assert!(v2_take_remote_selection(&files, &changed).is_err());
15357
15358        let resurrected = [
15359            (path, proven),
15360            (
15361                deleted_path,
15362                V2BaselineFile {
15363                    sha256: "f".repeat(64),
15364                    bytes: 13,
15365                    proof: None,
15366                },
15367            ),
15368        ]
15369        .into_iter()
15370        .collect::<std::collections::BTreeMap<_, _>>();
15371        assert!(v2_take_remote_selection(&files, &resurrected).is_err());
15372    }
15373
15374    #[cfg(target_os = "linux")]
15375    #[test]
15376    fn linux_snapshot_install_is_atomic_for_create_and_exchange() {
15377        use std::os::fd::AsRawFd as _;
15378
15379        let sandbox = tempfile::TempDir::new().unwrap();
15380        let parent = std::fs::File::open(sandbox.path()).unwrap();
15381        let stage = std::ffi::CString::new("stage").unwrap();
15382        let destination = std::ffi::CString::new("brain").unwrap();
15383
15384        std::fs::create_dir(sandbox.path().join("stage")).unwrap();
15385        std::fs::write(sandbox.path().join("stage/value"), b"created").unwrap();
15386        install_stage_at(
15387            parent.as_raw_fd(),
15388            stage.as_c_str(),
15389            destination.as_c_str(),
15390            false,
15391        )
15392        .unwrap();
15393        assert!(!sandbox.path().join("stage").exists());
15394        assert_eq!(
15395            std::fs::read(sandbox.path().join("brain/value")).unwrap(),
15396            b"created"
15397        );
15398
15399        std::fs::create_dir(sandbox.path().join("stage")).unwrap();
15400        std::fs::write(sandbox.path().join("stage/value"), b"replacement").unwrap();
15401        install_stage_at(
15402            parent.as_raw_fd(),
15403            stage.as_c_str(),
15404            destination.as_c_str(),
15405            true,
15406        )
15407        .unwrap();
15408        assert_eq!(
15409            std::fs::read(sandbox.path().join("brain/value")).unwrap(),
15410            b"replacement"
15411        );
15412        assert_eq!(
15413            std::fs::read(sandbox.path().join("stage/value")).unwrap(),
15414            b"created",
15415            "RENAME_EXCHANGE must leave the predecessor at the unique stage name"
15416        );
15417    }
15418
15419    struct SignedRemoteFixture {
15420        card: String,
15421        feed: String,
15422        key: AgentSigningKey,
15423        identity: FeedIdentity,
15424    }
15425
15426    fn signed_remote_fixture() -> SignedRemoteFixture {
15427        let rng = ring::rand::SystemRandom::new();
15428        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15429        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15430        let (public_key, multikey) = public_identity_for(&pair);
15431        let identity = FeedIdentity {
15432            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
15433            public_key_spki: public_key.clone(),
15434            previous: Vec::new(),
15435            rotations: Vec::new(),
15436        };
15437        let mut entry = FeedEntry {
15438            v: 1,
15439            seq: 1,
15440            ts: "2026-07-30T12:00:00.000Z".to_string(),
15441            brain: multikey.clone(),
15442            public_key: public_key.clone(),
15443            kind: "push".to_string(),
15444            op: "snapshot".to_string(),
15445            pack_sha256: "a".repeat(64),
15446            files: Vec::new(),
15447            removed: Vec::new(),
15448            prev_entry_hash: None,
15449            sig: String::new(),
15450        };
15451        let unsigned = UnsignedFeedEntry {
15452            v: entry.v,
15453            seq: entry.seq,
15454            ts: &entry.ts,
15455            brain: &entry.brain,
15456            public_key: &entry.public_key,
15457            kind: &entry.kind,
15458            op: &entry.op,
15459            pack_sha256: &entry.pack_sha256,
15460            files: &entry.files,
15461            removed: &entry.removed,
15462            prev_entry_hash: &entry.prev_entry_hash,
15463        };
15464        entry.sig =
15465            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
15466        let mut exact = serde_json::to_vec(&entry).unwrap();
15467        exact.push(b'\n');
15468        let hash = content_sha256(&exact);
15469        let card = json!({
15470            "id": TEST_BRAIN_ID,
15471            "headSeq": 1,
15472            "feedHash": hash,
15473            "identity": identity.clone(),
15474        })
15475        .to_string();
15476        let feed = json!({
15477            "headSeq": 1,
15478            "feedHash": hash,
15479            "identity": identity.clone(),
15480            "entries": [{"hash": hash, "entry": entry}],
15481            "scopeLimited": false,
15482        })
15483        .to_string();
15484        SignedRemoteFixture {
15485            card,
15486            feed,
15487            key: AgentSigningKey {
15488                pkcs8: pkcs8.as_ref().to_vec(),
15489                multikey,
15490                public_key_spki: public_key,
15491            },
15492            identity,
15493        }
15494    }
15495
15496    #[test]
15497    fn v1_edit_disclosure_accepts_minimal_and_superset_forms() {
15498        let file = |path: &str, byte: char| FeedFile {
15499            path: path.to_string(),
15500            sha256: byte.to_string().repeat(64),
15501            bytes: 1,
15502        };
15503        let a0 = file("records/a.md", 'a');
15504        let a1 = file("records/a.md", 'b');
15505        let stable = file("records/stable.md", 'c');
15506        let added = file("records/added.md", 'd');
15507        let removed_file = file("records/removed.md", 'e');
15508        let previous = vec![a0, stable.clone(), removed_file.clone()];
15509        let resulting = vec![a1.clone(), stable.clone(), added.clone()];
15510        let removed = vec![removed_file.path.clone()];
15511
15512        assert_eq!(
15513            verify_v1_manifest_disclosure(
15514                "edit",
15515                &previous,
15516                &resulting,
15517                &[a1.clone(), added.clone()],
15518                &removed,
15519            ),
15520            Ok(())
15521        );
15522        assert_eq!(
15523            verify_v1_manifest_disclosure(
15524                "edit",
15525                &previous,
15526                &resulting,
15527                &[stable.clone(), added.clone(), a1.clone()],
15528                &removed,
15529            ),
15530            Ok(())
15531        );
15532        assert_eq!(
15533            verify_v1_manifest_disclosure(
15534                "edit",
15535                &previous,
15536                &resulting,
15537                std::slice::from_ref(&added),
15538                &removed,
15539            ),
15540            Err(V1DisclosureError::EditMissingChange)
15541        );
15542        assert_eq!(
15543            verify_v1_manifest_disclosure(
15544                "edit",
15545                &previous,
15546                &resulting,
15547                &[file("records/a.md", 'f'), added.clone()],
15548                &removed,
15549            ),
15550            Err(V1DisclosureError::EditFalseFile)
15551        );
15552        assert_eq!(
15553            verify_v1_manifest_disclosure(
15554                "edit",
15555                &previous,
15556                &resulting,
15557                &[a1.clone(), added.clone()],
15558                &[],
15559            ),
15560            Err(V1DisclosureError::RemovedMismatch)
15561        );
15562        assert_eq!(
15563            verify_v1_manifest_disclosure(
15564                "push",
15565                &previous,
15566                &resulting,
15567                &[added.clone(), stable, a1],
15568                &removed,
15569            ),
15570            Ok(())
15571        );
15572        assert_eq!(
15573            verify_v1_manifest_disclosure("push", &previous, &resulting, &[added], &removed,),
15574            Err(V1DisclosureError::PushManifestMismatch)
15575        );
15576    }
15577
15578    #[test]
15579    fn wire_sequences_are_rejected_at_their_endpoint_count_limits() {
15580        let fixture = signed_remote_fixture();
15581        let feed: Value = serde_json::from_str(&fixture.feed).unwrap();
15582        let item = feed["entries"][0].to_string();
15583        let oversized_page = format!(
15584            "{{\"headSeq\":1,\"feedHash\":null,\"identity\":null,\"entries\":[{}],\"scopeLimited\":false}}",
15585            std::iter::repeat_n(item.as_str(), FEED_PAGE_LIMIT + 1)
15586                .collect::<Vec<_>>()
15587                .join(",")
15588        );
15589        assert!(serde_json::from_str::<FeedResponse>(&oversized_page).is_err());
15590
15591        let oversized_identity = format!(
15592            "{{\"fingerprint\":\"fp\",\"publicKeySpki\":\"spki\",\"previous\":[],\"rotations\":[{}]}}",
15593            std::iter::repeat_n("\"rotation\"", MAX_IDENTITY_ROTATIONS + 1)
15594                .collect::<Vec<_>>()
15595                .join(",")
15596        );
15597        assert!(serde_json::from_str::<FeedIdentity>(&oversized_identity).is_err());
15598
15599        let file = r#"{"path":"a","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bytes":1}"#;
15600        let oversized_entry = format!(
15601            "{{\"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\"}}",
15602            "a".repeat(64),
15603            std::iter::repeat_n(file, MAX_PUSH_FILES + 1)
15604                .collect::<Vec<_>>()
15605                .join(",")
15606        );
15607        assert!(serde_json::from_str::<FeedEntry>(&oversized_entry).is_err());
15608    }
15609
15610    #[test]
15611    fn bulk_confirmation_parser_accepts_only_the_exact_wire_shape() {
15612        let id = "01arz3ndektsv4rrffq69g5fav";
15613        let digest = "a".repeat(64);
15614        assert_eq!(
15615            V2BulkConfirmation::parse(&format!("{id}:{digest}")).unwrap(),
15616            V2BulkConfirmation {
15617                id: id.to_string(),
15618                digest,
15619            }
15620        );
15621        for invalid in [
15622            "",
15623            "01arz3ndektsv4rrffq69g5fav",
15624            "01ARZ3NDEKTSV4RRFFQ69G5FAV:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
15625            "01arz3ndektsv4rrffq69g5fav:ABCDEF",
15626            "01arz3ndektsv4rrffq69g5fav:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
15627        ] {
15628            assert!(matches!(
15629                V2BulkConfirmation::parse(invalid),
15630                Err(LinkError::InvalidPack { .. })
15631            ));
15632        }
15633    }
15634
15635    fn scripted_json_hub(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle<()>) {
15636        use std::io::{BufRead as _, BufReader, Read as _, Write as _};
15637        use std::net::TcpListener;
15638
15639        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15640        let url = format!("http://{}", listener.local_addr().unwrap());
15641        let handle = std::thread::spawn(move || {
15642            for (status, body) in responses {
15643                let (stream, _) = listener.accept().unwrap();
15644                let mut reader = BufReader::new(stream);
15645                let mut line = String::new();
15646                reader.read_line(&mut line).unwrap();
15647                let mut content_length = 0usize;
15648                loop {
15649                    line.clear();
15650                    reader.read_line(&mut line).unwrap();
15651                    if line == "\r\n" || line == "\n" || line.is_empty() {
15652                        break;
15653                    }
15654                    if let Some((name, value)) = line.split_once(':') {
15655                        if name.eq_ignore_ascii_case("content-length") {
15656                            content_length = value.trim().parse().unwrap();
15657                        }
15658                    }
15659                }
15660                let mut request_body = vec![0_u8; content_length];
15661                reader.read_exact(&mut request_body).unwrap();
15662                let response = format!(
15663                    "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
15664                    body.len()
15665                );
15666                reader.get_mut().write_all(response.as_bytes()).unwrap();
15667            }
15668        });
15669        (url, handle)
15670    }
15671
15672    fn routed_json_hub(
15673        requests: usize,
15674        mut respond: impl FnMut(&str) -> (u16, String) + Send + 'static,
15675    ) -> (String, std::thread::JoinHandle<()>) {
15676        use std::io::{BufRead as _, BufReader, Read as _, Write as _};
15677        use std::net::TcpListener;
15678
15679        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15680        let url = format!("http://{}", listener.local_addr().unwrap());
15681        let handle = std::thread::spawn(move || {
15682            for _ in 0..requests {
15683                let (stream, _) = listener.accept().unwrap();
15684                let mut reader = BufReader::new(stream);
15685                let mut line = String::new();
15686                reader.read_line(&mut line).unwrap();
15687                let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
15688                let mut content_length = 0usize;
15689                loop {
15690                    line.clear();
15691                    reader.read_line(&mut line).unwrap();
15692                    if line == "\r\n" || line == "\n" || line.is_empty() {
15693                        break;
15694                    }
15695                    if let Some((name, value)) = line.split_once(':') {
15696                        if name.eq_ignore_ascii_case("content-length") {
15697                            content_length = value.trim().parse().unwrap();
15698                        }
15699                    }
15700                }
15701                let mut request_body = vec![0_u8; content_length];
15702                reader.read_exact(&mut request_body).unwrap();
15703                let (status, body) = respond(&path);
15704                let response = format!(
15705                    "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
15706                    body.len()
15707                );
15708                reader.get_mut().write_all(response.as_bytes()).unwrap();
15709            }
15710        });
15711        (url, handle)
15712    }
15713
15714    fn test_hub_config(hub: String, state_dir: PathBuf) -> HubConfig {
15715        HubConfig {
15716            hub,
15717            key: Some("test-key".to_string()),
15718            agent_key: None,
15719            brain_key: None,
15720            state_dir,
15721            store_selected: false,
15722        }
15723    }
15724
15725    #[cfg(any(unix, windows))]
15726    #[test]
15727    fn an_expired_asset_capability_is_refreshed_without_losing_cached_progress() {
15728        use std::sync::{Arc, Mutex};
15729
15730        let bytes = b"immutable asset bytes".to_vec();
15731        let sha256 = content_sha256(&bytes);
15732        let commit_hash = "c".repeat(64);
15733        let base_url = Arc::new(Mutex::new(String::new()));
15734        let server_base = Arc::clone(&base_url);
15735        let object_attempt = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15736        let server_attempt = Arc::clone(&object_attempt);
15737        let response_bytes = bytes.clone();
15738        let response_sha = sha256.clone();
15739        let response_commit = commit_hash.clone();
15740        let (hub, server) = routed_json_hub(4, move |path| {
15741            if path.contains("/v2/assets/downloads") {
15742                let attempt = server_attempt.load(std::sync::atomic::Ordering::SeqCst) + 1;
15743                let url = format!("{}/asset/{attempt}", server_base.lock().unwrap());
15744                return (
15745                    200,
15746                    json!({
15747                        "v": 2,
15748                        "commit": response_commit,
15749                        "downloads": [{
15750                            "path": "assets/proof.bin",
15751                            "sha256": response_sha,
15752                            "bytes": response_bytes.len(),
15753                            "url": url,
15754                            "method": "GET"
15755                        }]
15756                    })
15757                    .to_string(),
15758                );
15759            }
15760            let attempt = server_attempt.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15761            if attempt == 0 {
15762                (403, "{}".to_string())
15763            } else {
15764                (200, String::from_utf8(response_bytes.clone()).unwrap())
15765            }
15766        });
15767        *base_url.lock().unwrap() = hub.clone();
15768
15769        let temp = tempfile::tempdir().unwrap();
15770        let cache = temp.path().join("cache");
15771        std::fs::create_dir(&cache).unwrap();
15772        let cfg = test_hub_config(hub, temp.path().to_path_buf());
15773        let pointer = V2PointerBody {
15774            v: 2,
15775            brain: TEST_BRAIN_ID.to_string(),
15776            seq: 1,
15777            commit_hash,
15778            feed_hash: "f".repeat(64),
15779            content_root: Some("a".repeat(64)),
15780            asset_root: Some("b".repeat(64)),
15781            materializer: "m".repeat(64),
15782            signer_epoch: 1,
15783            control_revision: "d".repeat(64),
15784            backup_preparation: "ready".to_string(),
15785            prior_pointer_hash: None,
15786            signed_at: "2026-08-23T00:00:00Z".to_string(),
15787        };
15788        let path = "assets/proof.bin".to_string();
15789        let asset = V2BaselineAsset {
15790            blob_sha256: sha256.clone(),
15791            bytes: bytes.len() as u64,
15792            media_type: "application/octet-stream".to_string(),
15793            wrappers: Vec::new(),
15794            required: true,
15795            disposition: "hosted".to_string(),
15796            leaf_hash: "e".repeat(64),
15797        };
15798
15799        let staged = stage_v2_asset_download_window(
15800            &cfg,
15801            TEST_BRAIN_ID,
15802            &pointer,
15803            &cache,
15804            &[(&path, &asset)],
15805        )
15806        .expect("a fresh authority-checked capability recovers an expired one");
15807        assert_eq!(staged.len(), 1);
15808        assert_eq!(staged[0].path, path);
15809        assert_eq!(std::fs::read(cache.join(sha256)).unwrap(), bytes);
15810        assert_eq!(object_attempt.load(std::sync::atomic::Ordering::SeqCst), 2);
15811        server.join().unwrap();
15812    }
15813
15814    #[cfg(any(unix, windows))]
15815    #[test]
15816    fn asset_capability_windows_cannot_exceed_the_worker_bound() {
15817        let temp = tempfile::tempdir().unwrap();
15818        let cfg = test_hub_config("http://127.0.0.1:1".to_string(), temp.path().to_path_buf());
15819        let pointer = V2PointerBody {
15820            v: 2,
15821            brain: TEST_BRAIN_ID.to_string(),
15822            seq: 1,
15823            commit_hash: "c".repeat(64),
15824            feed_hash: "f".repeat(64),
15825            content_root: Some("a".repeat(64)),
15826            asset_root: Some("b".repeat(64)),
15827            materializer: "m".repeat(64),
15828            signer_epoch: 1,
15829            control_revision: "d".repeat(64),
15830            backup_preparation: "ready".to_string(),
15831            prior_pointer_hash: None,
15832            signed_at: "2026-08-23T00:00:00Z".to_string(),
15833        };
15834        let paths = (0..=V2_DOWNLOAD_CAPABILITY_FILES)
15835            .map(|index| format!("assets/{index}.bin"))
15836            .collect::<Vec<_>>();
15837        let assets = paths
15838            .iter()
15839            .map(|_| V2BaselineAsset {
15840                blob_sha256: "a".repeat(64),
15841                bytes: 1,
15842                media_type: "application/octet-stream".to_string(),
15843                wrappers: Vec::new(),
15844                required: true,
15845                disposition: "hosted".to_string(),
15846                leaf_hash: "b".repeat(64),
15847            })
15848            .collect::<Vec<_>>();
15849        let pending = paths.iter().zip(&assets).collect::<Vec<_>>();
15850
15851        let error =
15852            stage_v2_asset_download_window(&cfg, TEST_BRAIN_ID, &pointer, temp.path(), &pending)
15853                .expect_err("an oversized window must fail before any network request");
15854        assert!(matches!(error, LinkError::InvalidFeed { .. }));
15855    }
15856
15857    #[test]
15858    fn linkmd_sig_v2_is_bound_to_the_exact_hub_origin() {
15859        use ring::signature::KeyPair as _;
15860
15861        let rng = ring::rand::SystemRandom::new();
15862        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15863        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15864        let (spki, multikey) = public_identity_for(&pair);
15865        let key = AgentSigningKey {
15866            pkcs8: pkcs8.as_ref().to_vec(),
15867            multikey,
15868            public_key_spki: spki,
15869        };
15870        let header = linkmd_sig_header(
15871            &key,
15872            "https://hub-a.example",
15873            "post",
15874            "/api/hub/brains/brain/push?mode=exact",
15875            Some("{\"ok\":true}"),
15876        )
15877        .unwrap();
15878        assert!(header.starts_with("LinkMD-Sig v2,"));
15879        let ts = header
15880            .split(",ts=")
15881            .nth(1)
15882            .unwrap()
15883            .split(',')
15884            .next()
15885            .unwrap();
15886        let signature = URL_SAFE_NO_PAD
15887            .decode(header.rsplit(",sig=").next().unwrap())
15888            .unwrap();
15889        let body_hash = format!("{:x}", Sha256::digest(b"{\"ok\":true}"));
15890        let accepted = format!(
15891            "v2\nhttps://hub-a.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
15892        );
15893        let replayed = format!(
15894            "v2\nhttps://hub-b.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
15895        );
15896        let public = pair.public_key().as_ref();
15897        assert!(UnparsedPublicKey::new(&ED25519, public)
15898            .verify(accepted.as_bytes(), &signature)
15899            .is_ok());
15900        assert!(
15901            UnparsedPublicKey::new(&ED25519, public)
15902                .verify(replayed.as_bytes(), &signature)
15903                .is_err(),
15904            "a proof captured at hub A must not authenticate at hub B"
15905        );
15906    }
15907
15908    #[test]
15909    fn explicit_brain_id_rejects_a_substituted_card_before_feed_trust() {
15910        let other = "01j5qc3v9k4ym8rwbn2tqe6f7e";
15911        let card = json!({
15912            "id": other,
15913            "headSeq": 0,
15914            "identity": signed_remote_fixture().identity,
15915        })
15916        .to_string();
15917        let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
15918        let state = tempfile::tempdir().unwrap();
15919        let cfg = test_hub_config(hub, state.path().to_path_buf());
15920        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15921        assert!(
15922            error.contains("differs from the explicitly requested"),
15923            "{error}"
15924        );
15925        server.join().unwrap();
15926    }
15927
15928    #[test]
15929    fn empty_brain_identity_is_pinned_and_cannot_be_substituted_later() {
15930        let first = signed_remote_fixture().identity;
15931        let second = signed_remote_fixture().identity;
15932        let card = |identity: FeedIdentity| {
15933            json!({
15934                "id": TEST_BRAIN_ID,
15935                "headSeq": 0,
15936                "identity": identity,
15937            })
15938            .to_string()
15939        };
15940        let (hub, server) = scripted_json_hub(vec![
15941            (404, "{}".to_string()),
15942            (200, card(first)),
15943            (404, "{}".to_string()),
15944            (200, card(second)),
15945        ]);
15946        let state = tempfile::tempdir().unwrap();
15947        let cfg = test_hub_config(hub, state.path().to_path_buf());
15948        assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
15949        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15950        assert!(
15951            error.contains("pinned anchor") || error.contains("forked away"),
15952            "{error}"
15953        );
15954        server.join().unwrap();
15955    }
15956
15957    #[test]
15958    fn empty_head_rejects_a_rotation_that_commits_to_hidden_history() {
15959        let old = signed_remote_fixture();
15960        let new = signed_remote_fixture();
15961        let old_pair = ring::signature::Ed25519KeyPair::from_pkcs8(&old.key.pkcs8).unwrap();
15962        let unsigned = serde_json::to_string(&UnsignedRotation {
15963            v: 1,
15964            op: "rotate",
15965            brain: &old.key.multikey,
15966            public_key: &old.key.public_key_spki,
15967            new_brain: &new.key.multikey,
15968            new_public_key: &new.key.public_key_spki,
15969            prior_head_seq: 1,
15970            prior_feed_hash: Some(&"a".repeat(64)),
15971            ts: "2026-07-30T12:00:00.000Z".to_string(),
15972        })
15973        .unwrap();
15974        let signature = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
15975        let rotation = format!(
15976            "{},\"sig\":\"{}\"}}",
15977            &unsigned[..unsigned.len() - 1],
15978            signature
15979        );
15980        let identity = FeedIdentity {
15981            fingerprint: new.key.multikey.trim_start_matches("ed25519:").to_string(),
15982            public_key_spki: new.key.public_key_spki,
15983            previous: vec![PreviousIdentity {
15984                fingerprint: old.key.multikey.trim_start_matches("ed25519:").to_string(),
15985                public_key_spki: old.key.public_key_spki,
15986            }],
15987            rotations: vec![rotation],
15988        };
15989        let card = json!({
15990            "id": TEST_BRAIN_ID,
15991            "headSeq": 0,
15992            "feedHash": null,
15993            "identity": identity,
15994        })
15995        .to_string();
15996        let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
15997        let state = tempfile::tempdir().unwrap();
15998        let cfg = test_hub_config(hub, state.path().to_path_buf());
15999        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
16000        assert!(
16001            error.contains("rotation claims a feed boundary beyond the advertised head"),
16002            "{error}"
16003        );
16004        assert!(
16005            load_trust(&cfg, TEST_BRAIN_ID).unwrap().is_none(),
16006            "an inconsistent empty-head identity must not become the TOFU checkpoint"
16007        );
16008        server.join().unwrap();
16009    }
16010
16011    #[test]
16012    fn trust_checkpoint_rejects_a_later_fork() {
16013        let fixture = signed_remote_fixture();
16014        let mut fork: Value = serde_json::from_str(&fixture.card).unwrap();
16015        fork["feedHash"] = Value::String("b".repeat(64));
16016        let (hub, server) = scripted_json_hub(vec![
16017            (404, "{}".to_string()),
16018            (200, fixture.card),
16019            (200, fixture.feed),
16020            (404, "{}".to_string()),
16021            (200, fork.to_string()),
16022        ]);
16023        let state = tempfile::tempdir().unwrap();
16024        let cfg = test_hub_config(hub, state.path().to_path_buf());
16025        assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
16026        assert!(head(&cfg, TEST_BRAIN_ID).is_err());
16027        server.join().unwrap();
16028    }
16029
16030    #[test]
16031    fn alias_and_canonical_id_share_one_identity_checkpoint() {
16032        let trusted = signed_remote_fixture();
16033        let attacker = signed_remote_fixture();
16034        let (hub, server) = scripted_json_hub(vec![
16035            (404, "{}".to_string()),
16036            (200, trusted.card),
16037            (200, trusted.feed),
16038            (404, "{}".to_string()),
16039            (200, attacker.card),
16040        ]);
16041        let state = tempfile::tempdir().unwrap();
16042        let cfg = test_hub_config(hub, state.path().to_path_buf());
16043        assert!(head(&cfg, "trusted-slug").unwrap().verified);
16044        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
16045        assert!(
16046            error.contains("equivocation")
16047                || error.contains("pinned")
16048                || error.contains("identity"),
16049            "{error}"
16050        );
16051        server.join().unwrap();
16052    }
16053
16054    #[test]
16055    fn moved_alias_requires_exact_explicit_rebind_without_rewriting_history() {
16056        let state = tempfile::tempdir().unwrap();
16057        let cfg = test_hub_config(
16058            "https://hub.example".to_string(),
16059            state.path().to_path_buf(),
16060        );
16061        let directory = open_trust_dir(&cfg).unwrap();
16062        let old = TEST_BRAIN_ID;
16063        let new = "01j5qc3v9k4ym8rwbn2tqe6f7e";
16064        save_alias_in(
16065            &cfg,
16066            &directory,
16067            &AliasBinding {
16068                v: 1,
16069                origin: normalized_origin(&cfg.hub).unwrap(),
16070                requested: "company-brain".to_string(),
16071                brain: old.to_string(),
16072                home: Some("company-brain".to_string()),
16073            },
16074        )
16075        .unwrap();
16076
16077        let error = load_canonical_pin(&cfg, &directory, "company-brain", new).unwrap_err();
16078        assert!(matches!(
16079            error,
16080            LinkError::AliasRebindRequired {
16081                alias,
16082                from,
16083                to
16084            } if alias == "company-brain" && from == old && to == new
16085        ));
16086        let unchanged = load_alias_in(&cfg, &directory, "company-brain")
16087            .unwrap()
16088            .unwrap();
16089        assert_eq!(unchanged.brain, old);
16090        assert_eq!(unchanged.home.as_deref(), Some("company-brain"));
16091    }
16092
16093    #[test]
16094    fn concurrent_aliases_cannot_establish_conflicting_tofu_pins() {
16095        let alpha = signed_remote_fixture();
16096        let beta = signed_remote_fixture();
16097        let alpha_card = alpha.card.clone();
16098        let alpha_feed = alpha.feed.clone();
16099        let beta_card = beta.card.clone();
16100        let beta_feed = beta.feed.clone();
16101        let (hub, server) = routed_json_hub(5, move |path| {
16102            if path.ends_with("/v2/head") {
16103                (404, "{}".to_string())
16104            } else if path.contains("/alpha/feed?") {
16105                (200, alpha_feed.clone())
16106            } else if path.contains("/beta/feed?") {
16107                (200, beta_feed.clone())
16108            } else if path.ends_with("/alpha") {
16109                (200, alpha_card.clone())
16110            } else if path.ends_with("/beta") {
16111                (200, beta_card.clone())
16112            } else {
16113                (500, r#"{"error":"unexpected path"}"#.to_string())
16114            }
16115        });
16116        let state = tempfile::tempdir().unwrap();
16117        let cfg = test_hub_config(hub, state.path().to_path_buf());
16118        let alpha_cfg = cfg.clone();
16119        let beta_cfg = cfg;
16120        let first = std::thread::spawn(move || head(&alpha_cfg, "alpha"));
16121        let second = std::thread::spawn(move || head(&beta_cfg, "beta"));
16122        let results = [first.join().unwrap(), second.join().unwrap()];
16123        assert_eq!(
16124            results.iter().filter(|result| result.is_ok()).count(),
16125            1,
16126            "only one alias identity may establish canonical TOFU: {results:?}"
16127        );
16128        assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
16129        server.join().unwrap();
16130    }
16131
16132    #[cfg(unix)]
16133    #[test]
16134    fn trust_transaction_survives_an_ancestor_swap_without_writing_outside() {
16135        use std::os::unix::fs::symlink;
16136
16137        let fixture = signed_remote_fixture();
16138        let card = json!({
16139            "id": TEST_BRAIN_ID,
16140            "headSeq": 0,
16141            "feedHash": Value::Null,
16142            "identity": fixture.identity,
16143        })
16144        .to_string();
16145        let work = tempfile::tempdir().unwrap();
16146        let outside = tempfile::tempdir().unwrap();
16147        let state = work.path().join("state");
16148        let moved = work.path().join("state-held");
16149        let swap_state = state.clone();
16150        let swap_moved = moved.clone();
16151        let outside_path = outside.path().to_path_buf();
16152        let (hub, server) = routed_json_hub(1, move |_| {
16153            // The client has already opened state/trust before this response.
16154            std::fs::rename(&swap_state, &swap_moved).unwrap();
16155            symlink(&outside_path, &swap_state).unwrap();
16156            (200, card.clone())
16157        });
16158        let cfg = test_hub_config(hub, state);
16159
16160        let verified = verified_remote_head(&cfg, TEST_BRAIN_ID, false).unwrap();
16161        assert_eq!(verified.head.seq, 0);
16162        assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
16163        assert!(std::fs::read_dir(moved.join("trust"))
16164            .unwrap()
16165            .flatten()
16166            .any(|entry| entry.path().extension().is_some_and(|ext| ext == "json")));
16167        server.join().unwrap();
16168    }
16169
16170    #[test]
16171    fn self_custody_push_refuses_an_unrelated_key_before_requesting_an_upload() {
16172        let remote = signed_remote_fixture();
16173        let unrelated = signed_remote_fixture().key;
16174        let (hub, server) = scripted_json_hub(vec![(200, remote.card), (200, remote.feed)]);
16175        let state = tempfile::tempdir().unwrap();
16176        let mut cfg = test_hub_config(hub, state.path().to_path_buf());
16177        cfg.brain_key = Some(unrelated);
16178        let error = sync_push(
16179            &cfg,
16180            TEST_BRAIN_ID,
16181            &[("DB.md".to_string(), "signed local content".to_string())],
16182        )
16183        .unwrap_err()
16184        .to_string();
16185        assert!(
16186            error.contains("not the verified current brain identity"),
16187            "{error}"
16188        );
16189        server.join().unwrap();
16190    }
16191
16192    #[test]
16193    fn rotation_ignores_a_forged_2xx_body_and_requires_verified_postcondition() {
16194        let remote = signed_remote_fixture();
16195        let new = signed_remote_fixture().key;
16196        let state = tempfile::tempdir().unwrap();
16197        let new_file = state.path().join("new.key");
16198        std::fs::write(
16199            &new_file,
16200            format!("{}\n", URL_SAFE_NO_PAD.encode(&new.pkcs8)),
16201        )
16202        .unwrap();
16203        #[cfg(unix)]
16204        {
16205            use std::os::unix::fs::PermissionsExt as _;
16206            std::fs::set_permissions(&new_file, std::fs::Permissions::from_mode(0o600)).unwrap();
16207        }
16208        let forged = json!({
16209            "brain": TEST_BRAIN_ID,
16210            "identity": {
16211                "fingerprint": new.multikey.trim_start_matches("ed25519:"),
16212                "publicKeySpki": new.public_key_spki,
16213            }
16214        })
16215        .to_string();
16216        let (hub, server) = scripted_json_hub(vec![
16217            (404, "{}".to_string()),
16218            (200, remote.card.clone()),
16219            (200, remote.feed.clone()),
16220            (200, forged),
16221            (200, remote.card),
16222            (200, remote.feed),
16223        ]);
16224        let cfg = test_hub_config(hub, state.path().to_path_buf());
16225        let error = rotate_brain_key(&cfg, TEST_BRAIN_ID, &remote.key, &new_file)
16226            .unwrap_err()
16227            .to_string();
16228        assert!(
16229            error.contains("without committing the verified new identity"),
16230            "{error}"
16231        );
16232        server.join().unwrap();
16233    }
16234
16235    #[test]
16236    fn resolve_record_is_derived_from_signed_pack_not_a_query_response() {
16237        let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
16238        let raw = format!(
16239            "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
16240        );
16241        let pack = build_store_pack(&[
16242            (
16243                "DB.md".to_string(),
16244                "---\ntype: db-md\nscope: company\nowner: Test\n---\n".to_string(),
16245            ),
16246            ("records/clients/truth.md".to_string(), raw.clone()),
16247        ])
16248        .unwrap();
16249        let by_id = resolve_from_verified_pack(
16250            "01j5qc3v9k4ym8rwbn2tqe6f7d",
16251            &AddressTarget::Id(record_id.to_string()),
16252            pack.clone(),
16253        )
16254        .unwrap();
16255        assert_eq!(by_id["document"]["summary"], "Signed truth");
16256        assert_eq!(by_id["document"]["body"], "# Signed truth\n");
16257        assert_eq!(
16258            by_id["document"]["contentSha"],
16259            content_sha256(raw.as_bytes())
16260        );
16261
16262        let by_path = resolve_from_verified_pack(
16263            "01j5qc3v9k4ym8rwbn2tqe6f7d",
16264            &AddressTarget::Path("records/clients/truth.md".to_string()),
16265            pack,
16266        )
16267        .unwrap();
16268        assert_eq!(by_path["document"]["id"], record_id);
16269        assert_eq!(by_path["document"]["path"], "records/clients/truth.md");
16270
16271        let wrong_id = resolve_from_verified_record_bytes(
16272            TEST_BRAIN_ID,
16273            &AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7f".to_string()),
16274            "records/clients/truth.md".to_string(),
16275            raw.as_bytes().to_vec(),
16276        )
16277        .unwrap_err()
16278        .to_string();
16279        assert!(wrong_id.contains("id differs"), "{wrong_id}");
16280
16281        let wrong_path = resolve_from_verified_record_bytes(
16282            TEST_BRAIN_ID,
16283            &AddressTarget::Path("records/clients/other.md".to_string()),
16284            "records/clients/truth.md".to_string(),
16285            raw.into_bytes(),
16286        )
16287        .unwrap_err()
16288        .to_string();
16289        assert!(wrong_path.contains("path differs"), "{wrong_path}");
16290    }
16291
16292    #[test]
16293    fn v2_record_locators_return_one_proof_bound_to_the_signed_content_root() {
16294        let path = "records/clients/truth.md";
16295        let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
16296        let raw = format!(
16297            "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
16298        );
16299        let sha256 = content_sha256(raw.as_bytes());
16300        let mut nonce = 0_u128;
16301        let tree = crate::linkmd_v2::build_content_tree(
16302            &[crate::linkmd_v2::ContentFile {
16303                path: path.to_string(),
16304                blob_hash: sha256.clone(),
16305                bytes: raw.len() as u64,
16306            }],
16307            None,
16308            &mut || {
16309                nonce += 1;
16310                format!("{nonce:032x}")
16311            },
16312        )
16313        .unwrap();
16314        let root = tree.root.clone().unwrap();
16315        let mut directory_root = root.clone();
16316        let mut proof = Vec::new();
16317        for component in path.split('/') {
16318            let inclusion =
16319                crate::linkmd_v2::create_proof(&directory_root, component, &tree.nodes).unwrap();
16320            let child = match &inclusion {
16321                crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry.child_hash.clone(),
16322                crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
16323                    panic!("fixture path must have an inclusion proof")
16324                }
16325            };
16326            proof.push(json!({
16327                "directory_root": directory_root,
16328                "component": component,
16329                "proof": inclusion,
16330            }));
16331            directory_root = child;
16332        }
16333        let commit_hash = "c".repeat(64);
16334        let pointer = V2PointerBody {
16335            v: 2,
16336            brain: TEST_BRAIN_ID.to_string(),
16337            seq: 1,
16338            commit_hash: commit_hash.clone(),
16339            feed_hash: "f".repeat(64),
16340            content_root: Some(root.clone()),
16341            asset_root: None,
16342            materializer: "dbmd-projection-v1".to_string(),
16343            signer_epoch: 1,
16344            control_revision: "d".repeat(64),
16345            backup_preparation: "e".repeat(64),
16346            prior_pointer_hash: None,
16347            signed_at: "2026-08-21T12:00:00.000Z".to_string(),
16348        };
16349        let manifest = json!({
16350            "v": 2,
16351            "commit": commit_hash,
16352            "content_root": root,
16353            "files": [{
16354                "path": path,
16355                "sha256": sha256,
16356                "bytes": raw.len(),
16357                "proof": proof,
16358            }],
16359            "next_cursor": Value::Null,
16360        })
16361        .to_string();
16362
16363        let path_manifest = manifest.clone();
16364        let (hub, server) = routed_json_hub(1, move |request| {
16365            assert_eq!(
16366                request,
16367                format!(
16368                    "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Ftruth.md",
16369                    "c".repeat(64)
16370                )
16371            );
16372            (200, path_manifest.clone())
16373        });
16374        let state = tempfile::tempdir().unwrap();
16375        let cfg = test_hub_config(hub, state.path().to_path_buf());
16376        let by_path = v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, path)
16377            .unwrap()
16378            .unwrap();
16379        assert_eq!(by_path.sha256, content_sha256(raw.as_bytes()));
16380        assert!(by_path.proof.is_some());
16381        server.join().unwrap();
16382
16383        let (hub, server) = routed_json_hub(1, move |request| {
16384            assert_eq!(
16385                request,
16386                format!(
16387                    "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Fmissing.md",
16388                    "c".repeat(64)
16389                )
16390            );
16391            (404, r#"{"error":"File not found"}"#.to_string())
16392        });
16393        let state = tempfile::tempdir().unwrap();
16394        let cfg = test_hub_config(hub, state.path().to_path_buf());
16395        assert!(
16396            v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, "records/clients/missing.md")
16397                .unwrap()
16398                .is_none()
16399        );
16400        server.join().unwrap();
16401
16402        let id_manifest = manifest;
16403        let (hub, server) = routed_json_hub(1, move |request| {
16404            assert_eq!(
16405                request,
16406                format!(
16407                    "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&id={record_id}",
16408                    "c".repeat(64)
16409                )
16410            );
16411            (200, id_manifest.clone())
16412        });
16413        let state = tempfile::tempdir().unwrap();
16414        let cfg = test_hub_config(hub, state.path().to_path_buf());
16415        let (located_path, by_id) =
16416            v2_manifest_file_by_id(&cfg, TEST_BRAIN_ID, &pointer, record_id).unwrap();
16417        assert_eq!(located_path, path);
16418        assert_eq!(by_id.sha256, content_sha256(raw.as_bytes()));
16419        server.join().unwrap();
16420    }
16421
16422    #[test]
16423    fn canonical_store_pack_matches_the_cross_language_zip32_golden() {
16424        let unsorted = vec![
16425            ("records/a.md".to_string(), "alpha\n".to_string()),
16426            ("DB.md".to_string(), "# db\n".to_string()),
16427        ];
16428        let sorted = vec![
16429            ("DB.md".to_string(), "# db\n".to_string()),
16430            ("records/a.md".to_string(), "alpha\n".to_string()),
16431        ];
16432        let pack = build_store_pack(&unsorted).unwrap();
16433
16434        // This digest is shared with the hub implementation. It locks every
16435        // byte of the wire profile: raw UTF-8 order, STORED payloads, fixed DOS
16436        // epoch, explicit CRC/sizes, Unix regular-0600 attributes, and the
16437        // absence of descriptors/extras/comments/ZIP64.
16438        assert_eq!(pack.len(), 219);
16439        assert_eq!(
16440            content_sha256(&pack),
16441            "972fb2045becaa21588baaf4b349e62a430687fa2c21167b53f4ca0efa6c9408"
16442        );
16443        assert_eq!(pack, build_store_pack(&sorted).unwrap());
16444        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x07\x08"));
16445        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x06"));
16446        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x07"));
16447
16448        assert_eq!(
16449            parse_store_pack(pack).unwrap(),
16450            vec![
16451                ("DB.md".to_string(), b"# db\n".to_vec()),
16452                ("records/a.md".to_string(), b"alpha\n".to_vec()),
16453            ]
16454        );
16455    }
16456
16457    #[test]
16458    fn canonical_store_pack_validates_every_path_before_writing() {
16459        let duplicate = vec![
16460            ("DB.md".to_string(), "first".to_string()),
16461            ("DB.md".to_string(), "second".to_string()),
16462        ];
16463        assert!(build_store_pack(&duplicate)
16464            .unwrap_err()
16465            .to_string()
16466            .contains("duplicate path"));
16467        assert!(matches!(
16468            build_store_pack(&[("../escape.md".to_string(), "no".to_string())]),
16469            Err(LinkError::UnsafePath { .. })
16470        ));
16471    }
16472
16473    #[test]
16474    fn zip64_preflight_rejects_an_entry_count_bomb_before_zip_parsing() {
16475        const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
16476        // One byte stands in for the central directory. The trailer itself is
16477        // structurally valid; the count is the sole reason for refusal.
16478        let mut bytes = vec![0_u8];
16479        let zip64_offset = bytes.len() as u64;
16480        bytes.extend_from_slice(b"PK\x06\x06");
16481        bytes.extend_from_slice(&44_u64.to_le_bytes());
16482        bytes.extend_from_slice(&[0_u8; 12]);
16483        bytes.extend_from_slice(&COUNT.to_le_bytes());
16484        bytes.extend_from_slice(&COUNT.to_le_bytes());
16485        bytes.extend_from_slice(&1_u64.to_le_bytes());
16486        bytes.extend_from_slice(&0_u64.to_le_bytes());
16487        bytes.extend_from_slice(b"PK\x06\x07");
16488        bytes.extend_from_slice(&0_u32.to_le_bytes());
16489        bytes.extend_from_slice(&zip64_offset.to_le_bytes());
16490        bytes.extend_from_slice(&1_u32.to_le_bytes());
16491        bytes.extend_from_slice(b"PK\x05\x06");
16492        bytes.extend_from_slice(&0_u16.to_le_bytes());
16493        bytes.extend_from_slice(&0_u16.to_le_bytes());
16494        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16495        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16496        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16497        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16498        bytes.extend_from_slice(&0_u16.to_le_bytes());
16499
16500        let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
16501            .unwrap_err()
16502            .to_string();
16503        assert!(error.contains("invalid file count"), "{error}");
16504    }
16505
16506    #[test]
16507    fn zip_preflight_rejects_a_fake_trailing_eocd_before_zip_parsing() {
16508        const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
16509        let mut bytes = vec![0_u8];
16510        let zip64_offset = bytes.len() as u64;
16511        bytes.extend_from_slice(b"PK\x06\x06");
16512        bytes.extend_from_slice(&44_u64.to_le_bytes());
16513        bytes.extend_from_slice(&[0_u8; 12]);
16514        bytes.extend_from_slice(&COUNT.to_le_bytes());
16515        bytes.extend_from_slice(&COUNT.to_le_bytes());
16516        bytes.extend_from_slice(&1_u64.to_le_bytes());
16517        bytes.extend_from_slice(&0_u64.to_le_bytes());
16518        bytes.extend_from_slice(b"PK\x06\x07");
16519        bytes.extend_from_slice(&0_u32.to_le_bytes());
16520        bytes.extend_from_slice(&zip64_offset.to_le_bytes());
16521        bytes.extend_from_slice(&1_u32.to_le_bytes());
16522        bytes.extend_from_slice(b"PK\x05\x06");
16523        bytes.extend_from_slice(&0_u16.to_le_bytes());
16524        bytes.extend_from_slice(&0_u16.to_le_bytes());
16525        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16526        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16527        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16528        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16529        bytes.extend_from_slice(&0_u16.to_le_bytes());
16530        // The old parser trusted this last low-count signature, while
16531        // ZipArchive fell back to the real Zip64 directory and allocated for
16532        // COUNT entries. Its central-directory offsets are deliberately fake.
16533        let fake_eocd = bytes.len() as u32;
16534        bytes.extend_from_slice(b"PK\x05\x06");
16535        bytes.extend_from_slice(&0_u16.to_le_bytes());
16536        bytes.extend_from_slice(&0_u16.to_le_bytes());
16537        bytes.extend_from_slice(&1_u16.to_le_bytes());
16538        bytes.extend_from_slice(&1_u16.to_le_bytes());
16539        bytes.extend_from_slice(&0_u32.to_le_bytes());
16540        bytes.extend_from_slice(&fake_eocd.to_le_bytes());
16541        bytes.extend_from_slice(&0_u16.to_le_bytes());
16542
16543        let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
16544            .unwrap_err()
16545            .to_string();
16546        assert!(error.contains("central directory"), "{error}");
16547    }
16548
16549    #[test]
16550    fn strict_http_status_handling_rejects_redirects_without_panicking() {
16551        let error = ensure_ok(
16552            HubResponse {
16553                status: 302,
16554                body: Some(json!({"redirect": "/elsewhere"})),
16555            },
16556            "mutation",
16557        )
16558        .unwrap_err();
16559        assert!(matches!(error, LinkError::Http { status: 302, .. }));
16560
16561        let error = ensure_raw_ok(
16562            RawHubResponse {
16563                status: 302,
16564                body: br#"{"redirect":"/elsewhere"}"#.to_vec(),
16565            },
16566            "feed",
16567        )
16568        .unwrap_err();
16569        assert!(matches!(error, LinkError::Http { status: 302, .. }));
16570    }
16571
16572    #[cfg(unix)]
16573    #[test]
16574    fn collect_push_files_refuses_external_symlink_and_nested_store() {
16575        use std::os::unix::fs::symlink;
16576
16577        let root = tempfile::tempdir().unwrap();
16578        std::fs::write(
16579            root.path().join("DB.md"),
16580            "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
16581        )
16582        .unwrap();
16583        std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
16584
16585        let external = tempfile::tempdir().unwrap();
16586        let secret = external.path().join("secret.md");
16587        std::fs::write(&secret, "TOP SECRET").unwrap();
16588        symlink(&secret, root.path().join("records/notes/secret.md")).unwrap();
16589
16590        let store = Store::open_strict(root.path()).unwrap();
16591        let err = collect_push_files(&store).unwrap_err().to_string();
16592        assert!(err.contains("cannot push"), "{err}");
16593        assert!(
16594            !err.contains("TOP SECRET"),
16595            "external bytes must never leak"
16596        );
16597
16598        std::fs::remove_file(root.path().join("records/notes/secret.md")).unwrap();
16599        let nested = root.path().join("records/nested");
16600        std::fs::create_dir_all(&nested).unwrap();
16601        std::fs::write(
16602            nested.join("DB.md"),
16603            "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
16604        )
16605        .unwrap();
16606        let err = collect_push_files(&store).unwrap_err().to_string();
16607        assert!(err.contains("nested db.md store"), "{err}");
16608    }
16609
16610    #[cfg(unix)]
16611    #[test]
16612    fn remote_push_uses_opened_root_after_path_replacement() {
16613        use std::os::unix::fs::symlink;
16614
16615        let sandbox = tempfile::tempdir().unwrap();
16616        let root = sandbox.path().join("store");
16617        std::fs::create_dir_all(root.join("records/notes")).unwrap();
16618        std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
16619        std::fs::write(
16620            root.join("records/notes/owned.md"),
16621            "---\ntype: note\nsummary: owned\n---\nowned upload\n",
16622        )
16623        .unwrap();
16624        let store = Store::open_strict(&root).unwrap();
16625        let detached = sandbox.path().join("detached");
16626        std::fs::rename(&root, &detached).unwrap();
16627
16628        let replacement = sandbox.path().join("replacement");
16629        std::fs::create_dir_all(replacement.join("records/notes")).unwrap();
16630        std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
16631        std::fs::write(
16632            replacement.join("records/notes/secret.md"),
16633            "---\ntype: note\nsummary: secret\n---\nreplacement sentinel\n",
16634        )
16635        .unwrap();
16636        symlink(&replacement, &root).unwrap();
16637
16638        let files = collect_push_files(&store).unwrap();
16639        let wire_text = files
16640            .iter()
16641            .map(|(path, content)| format!("{path}\n{content}"))
16642            .collect::<Vec<_>>()
16643            .join("\n");
16644        assert!(wire_text.contains("owned upload"));
16645        assert!(!wire_text.contains("replacement sentinel"));
16646        assert!(!wire_text.contains("records/notes/secret.md"));
16647
16648        let remote = signed_remote_fixture();
16649        let (hub, server) = scripted_json_hub(vec![
16650            (200, remote.card),
16651            (200, remote.feed),
16652            (200, json!({"ok": true}).to_string()),
16653        ]);
16654        let state = tempfile::tempdir().unwrap();
16655        let cfg = test_hub_config(hub, state.path().to_path_buf());
16656        let pushed = sync_push(&cfg, TEST_BRAIN_ID, &files).unwrap();
16657        assert_eq!(pushed, json!({"ok": true}));
16658        server.join().unwrap();
16659    }
16660
16661    #[test]
16662    fn signed_feed_item_verifies_identity_hash_and_signature() {
16663        use ring::rand::SystemRandom;
16664        use ring::signature::{Ed25519KeyPair, KeyPair};
16665
16666        const PREFIX: &[u8] = &[
16667            0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
16668        ];
16669        let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
16670        let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16671        let mut spki = PREFIX.to_vec();
16672        spki.extend_from_slice(pair.public_key().as_ref());
16673        let public_key = URL_SAFE_NO_PAD.encode(&spki);
16674        let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
16675        let mut entry = FeedEntry {
16676            v: 1,
16677            seq: 1,
16678            ts: "2026-07-14T00:00:00.000Z".to_string(),
16679            brain: format!("ed25519:{fingerprint}"),
16680            public_key: public_key.clone(),
16681            kind: "push".to_string(),
16682            op: "snapshot".to_string(),
16683            pack_sha256: "a".repeat(64),
16684            files: vec![FeedFile {
16685                path: "DB.md".to_string(),
16686                sha256: "b".repeat(64),
16687                bytes: 3,
16688            }],
16689            removed: vec![],
16690            prev_entry_hash: None,
16691            sig: String::new(),
16692        };
16693        let unsigned = UnsignedFeedEntry {
16694            v: entry.v,
16695            seq: entry.seq,
16696            ts: &entry.ts,
16697            brain: &entry.brain,
16698            public_key: &entry.public_key,
16699            kind: &entry.kind,
16700            op: &entry.op,
16701            pack_sha256: &entry.pack_sha256,
16702            files: &entry.files,
16703            removed: &entry.removed,
16704            prev_entry_hash: &entry.prev_entry_hash,
16705        };
16706        entry.sig =
16707            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
16708        let mut exact = serde_json::to_vec(&entry).unwrap();
16709        exact.push(b'\n');
16710        let item = FeedItem {
16711            hash: format!("{:x}", Sha256::digest(&exact)),
16712            entry,
16713        };
16714        let identity = FeedIdentity {
16715            fingerprint,
16716            public_key_spki: public_key,
16717            previous: Vec::new(),
16718            rotations: Vec::new(),
16719        };
16720        assert!(verify_feed_item(&item, &identity).is_ok());
16721        let mut tampered = item;
16722        tampered.entry.pack_sha256 = "c".repeat(64);
16723        assert!(verify_feed_item(&tampered, &identity).is_err());
16724    }
16725
16726    #[test]
16727    fn v2_commit_requires_exact_fields_and_a_valid_genesis_bridge() {
16728        let rng = ring::rand::SystemRandom::new();
16729        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16730        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16731        let (spki, multikey) = public_identity_for(&pair);
16732        let identity = V2HeadIdentity {
16733            custody: "self".to_string(),
16734            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
16735            public_key_spki: spki.clone(),
16736            previous: Vec::new(),
16737            rotations: Vec::new(),
16738        };
16739        let unsigned = json!({
16740            "actor_ref": "a".repeat(64),
16741            "asset_root": Value::Null,
16742            "brain": multikey,
16743            "changes_sha256": "b".repeat(64),
16744            "control_revision": "c".repeat(64),
16745            "materializer": "dbmd-projection-v1",
16746            "op": "changeset",
16747            "parent_asset_root": Value::Null,
16748            "parent_commit": Value::Null,
16749            "parent_root": Value::Null,
16750            "prev_entry_hash": Value::Null,
16751            "public_key": spki,
16752            "seq": 1,
16753            "signer_epoch": 1,
16754            "state_root": "d".repeat(64),
16755            "ts": "2026-08-19T12:00:00.000Z",
16756            "v": 2,
16757            "v1_bridge": {
16758                "feed_hash": "e".repeat(64),
16759                "head_seq": 7,
16760                "pack_sha256": "f".repeat(64),
16761            },
16762        });
16763        let sign_value = |value: Value| {
16764            let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
16765            let mut object = value.as_object().unwrap().clone();
16766            object.insert(
16767                "sig".to_string(),
16768                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16769            );
16770            crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
16771        };
16772        assert!(verified_v2_commit_object(&sign_value(unsigned.clone()), &identity).is_ok());
16773
16774        let mut extra = unsigned.clone();
16775        extra
16776            .as_object_mut()
16777            .unwrap()
16778            .insert("future".to_string(), Value::Bool(true));
16779        assert!(verified_v2_commit_object(&sign_value(extra), &identity).is_err());
16780
16781        let mut missing = unsigned.clone();
16782        missing.as_object_mut().unwrap().remove("v1_bridge");
16783        assert!(verified_v2_commit_object(&sign_value(missing), &identity).is_err());
16784
16785        let mut invalid_bridge = unsigned;
16786        invalid_bridge.as_object_mut().unwrap().insert(
16787            "v1_bridge".to_string(),
16788            json!({"feed_hash": "e".repeat(64), "head_seq": 0, "pack_sha256": "f".repeat(64)}),
16789        );
16790        assert!(verified_v2_commit_object(&sign_value(invalid_bridge), &identity).is_err());
16791    }
16792
16793    #[test]
16794    fn shared_v2_commit_bridge_vector_matches_the_typescript_signer() {
16795        let vector: Value = serde_json::from_str(include_str!(
16796            "../tests/vectors/linkmd-v2-commit-bridge.json"
16797        ))
16798        .unwrap();
16799        let identity_value = vector.get("identity").unwrap();
16800        let identity = V2HeadIdentity {
16801            custody: "self".to_string(),
16802            fingerprint: identity_value
16803                .get("fingerprint")
16804                .and_then(Value::as_str)
16805                .unwrap()
16806                .to_string(),
16807            public_key_spki: identity_value
16808                .get("public_key_spki")
16809                .and_then(Value::as_str)
16810                .unwrap()
16811                .to_string(),
16812            previous: Vec::new(),
16813            rotations: Vec::new(),
16814        };
16815        let private = URL_SAFE_NO_PAD
16816            .decode(
16817                identity_value
16818                    .get("private_key_pkcs8")
16819                    .and_then(Value::as_str)
16820                    .unwrap(),
16821            )
16822            .unwrap();
16823        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&private)
16824            .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(&private))
16825            .unwrap();
16826        let base = vector.get("body").unwrap().as_object().unwrap();
16827
16828        for item in vector.get("valid").unwrap().as_array().unwrap() {
16829            let mut body = base.clone();
16830            body.insert(
16831                "v1_bridge".to_string(),
16832                item.get("v1_bridge").unwrap().clone(),
16833            );
16834            body.insert(
16835                "sig".to_string(),
16836                item.get("signature_base64url").unwrap().clone(),
16837            );
16838            let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
16839            assert!(verified_v2_commit_object(&signed, &identity).is_ok());
16840            assert_eq!(
16841                crate::linkmd_v2::domain_hash_bytes("v2/commit", &signed).unwrap(),
16842                item.get("commit_hash").and_then(Value::as_str).unwrap()
16843            );
16844            assert_eq!(
16845                format!("{:x}", Sha256::digest(&signed)),
16846                item.get("feed_hash").and_then(Value::as_str).unwrap()
16847            );
16848        }
16849
16850        for item in vector.get("invalid").unwrap().as_array().unwrap() {
16851            let mut body = base.clone();
16852            if let Some(remove) = item.get("remove").and_then(Value::as_array) {
16853                for field in remove {
16854                    body.remove(field.as_str().unwrap());
16855                }
16856            }
16857            if let Some(set) = item.get("set").and_then(Value::as_object) {
16858                for (field, value) in set {
16859                    body.insert(field.clone(), value.clone());
16860                }
16861            }
16862            let message = crate::linkmd_v2::canonical_bytes(&Value::Object(body.clone())).unwrap();
16863            body.insert(
16864                "sig".to_string(),
16865                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16866            );
16867            let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
16868            assert!(
16869                verified_v2_commit_object(&signed, &identity).is_err(),
16870                "accepted invalid shared vector {}",
16871                item.get("reason").and_then(Value::as_str).unwrap()
16872            );
16873        }
16874    }
16875
16876    #[test]
16877    fn shared_v2_kept_home_changeset_vector_matches_the_typescript_hub() {
16878        let vector: Value = serde_json::from_str(include_str!(
16879            "../tests/vectors/linkmd-v2-changeset-withheld.json"
16880        ))
16881        .unwrap();
16882        assert_eq!(
16883            vector.get("profile").and_then(Value::as_str),
16884            Some("link.md-v2-changeset-withheld")
16885        );
16886        let canonical =
16887            crate::linkmd_v2::canonical_bytes(vector.get("changeset").unwrap()).unwrap();
16888        let expected = STANDARD
16889            .decode(
16890                vector
16891                    .get("canonical_base64")
16892                    .and_then(Value::as_str)
16893                    .unwrap(),
16894            )
16895            .unwrap();
16896        assert_eq!(canonical, expected);
16897        assert_eq!(
16898            crate::linkmd_v2::domain_hash_bytes("v2/changeset", &canonical).unwrap(),
16899            vector.get("domain_hash").and_then(Value::as_str).unwrap()
16900        );
16901    }
16902
16903    #[test]
16904    fn v2_profile_transition_reverifies_the_exact_signed_v1_bridge() {
16905        let remote = signed_remote_fixture();
16906        let legacy: FeedResponse = serde_json::from_str(&remote.feed).unwrap();
16907        let legacy_item = legacy.entries.first().unwrap();
16908        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&remote.key.pkcs8).unwrap();
16909        let body = json!({
16910            "actor_ref": "a".repeat(64),
16911            "asset_root": Value::Null,
16912            "brain": remote.key.multikey,
16913            "changes_sha256": "b".repeat(64),
16914            "control_revision": "c".repeat(64),
16915            "materializer": "dbmd-projection-v1",
16916            "op": "changeset",
16917            "parent_asset_root": Value::Null,
16918            "parent_commit": Value::Null,
16919            "parent_root": Value::Null,
16920            "prev_entry_hash": Value::Null,
16921            "public_key": remote.key.public_key_spki,
16922            "seq": 1,
16923            "signer_epoch": 1,
16924            "state_root": "d".repeat(64),
16925            "ts": "2026-08-19T12:00:00.000Z",
16926            "v": 2,
16927            "v1_bridge": {
16928                "feed_hash": legacy_item.hash,
16929                "head_seq": legacy_item.entry.seq,
16930                "pack_sha256": legacy_item.entry.pack_sha256,
16931            },
16932        });
16933        let message = crate::linkmd_v2::canonical_bytes(&body).unwrap();
16934        let mut signed = body.as_object().unwrap().clone();
16935        signed.insert(
16936            "sig".to_string(),
16937            Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16938        );
16939        let raw = crate::linkmd_v2::canonical_bytes(&Value::Object(signed)).unwrap();
16940        let commit_hash = crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw).unwrap();
16941        let feed_hash = content_sha256(&raw);
16942        let pointer = V2PointerBody {
16943            v: 2,
16944            brain: TEST_BRAIN_ID.to_string(),
16945            seq: 1,
16946            commit_hash: commit_hash.clone(),
16947            feed_hash: feed_hash.clone(),
16948            content_root: Some("d".repeat(64)),
16949            asset_root: None,
16950            materializer: "dbmd-projection-v1".to_string(),
16951            signer_epoch: 1,
16952            control_revision: "c".repeat(64),
16953            backup_preparation: "e".repeat(64),
16954            prior_pointer_hash: None,
16955            signed_at: "2026-08-19T12:00:00.000Z".to_string(),
16956        };
16957        let v2_page = json!({
16958            "v": 2,
16959            "head_seq": 1,
16960            "head_commit_hash": commit_hash,
16961            "head_feed_hash": feed_hash,
16962            "entries": [{
16963                "seq": 1,
16964                "commit_hash": pointer.commit_hash,
16965                "feed_hash": pointer.feed_hash,
16966                "bytes_base64": STANDARD.encode(&raw),
16967            }],
16968            "next_after": 1,
16969            "complete": true,
16970        })
16971        .to_string();
16972        let identity = V2HeadIdentity {
16973            custody: "self".to_string(),
16974            fingerprint: remote.identity.fingerprint.clone(),
16975            public_key_spki: remote.identity.public_key_spki.clone(),
16976            previous: Vec::new(),
16977            rotations: Vec::new(),
16978        };
16979        let checkpoint = TrustState {
16980            v: 2,
16981            origin: "unused".to_string(),
16982            requested: TEST_BRAIN_ID.to_string(),
16983            brain: TEST_BRAIN_ID.to_string(),
16984            home: None,
16985            anchor: remote.key.multikey.clone(),
16986            current: remote.key.multikey,
16987            head_seq: legacy_item.entry.seq,
16988            feed_hash: Some(legacy_item.hash.clone()),
16989            rotations: Vec::new(),
16990            hub_signer: None,
16991            protocol_profile: None,
16992        };
16993        let (hub, server) = scripted_json_hub(vec![(200, v2_page), (200, remote.feed)]);
16994        let state = tempfile::tempdir().unwrap();
16995        let cfg = test_hub_config(hub, state.path().to_path_buf());
16996        verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &checkpoint).unwrap();
16997        server.join().unwrap();
16998
16999        let mut wrong = checkpoint;
17000        wrong.feed_hash = Some("0".repeat(64));
17001        let (hub, server) = scripted_json_hub(vec![(
17002            200,
17003            json!({
17004                "v": 2,
17005                "head_seq": 1,
17006                "head_commit_hash": pointer.commit_hash,
17007                "head_feed_hash": pointer.feed_hash,
17008                "entries": [{
17009                    "seq": 1,
17010                    "commit_hash": pointer.commit_hash,
17011                    "feed_hash": pointer.feed_hash,
17012                    "bytes_base64": STANDARD.encode(&raw),
17013                }],
17014                "next_after": 1,
17015                "complete": true,
17016            })
17017            .to_string(),
17018        )]);
17019        let state = tempfile::tempdir().unwrap();
17020        let cfg = test_hub_config(hub, state.path().to_path_buf());
17021        assert!(verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &wrong,).is_err());
17022        server.join().unwrap();
17023    }
17024
17025    #[test]
17026    fn v2_commit_signer_epoch_follows_the_authenticated_rotation_boundary() {
17027        let rng = ring::rand::SystemRandom::new();
17028        let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17029        let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
17030        let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17031        let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
17032        let (old_spki, old_multikey) = public_identity_for(&old);
17033        let (new_spki, new_multikey) = public_identity_for(&new);
17034        let rotation_unsigned = serde_json::to_string(&UnsignedRotation {
17035            v: 1,
17036            op: "rotate",
17037            brain: &old_multikey,
17038            public_key: &old_spki,
17039            new_brain: &new_multikey,
17040            new_public_key: &new_spki,
17041            prior_head_seq: 1,
17042            prior_feed_hash: Some(&"9".repeat(64)),
17043            ts: "2026-08-19T12:01:00.000Z".to_string(),
17044        })
17045        .unwrap();
17046        let rotation_sig = URL_SAFE_NO_PAD.encode(old.sign(rotation_unsigned.as_bytes()).as_ref());
17047        let rotation = format!(
17048            "{},\"sig\":\"{}\"}}",
17049            &rotation_unsigned[..rotation_unsigned.len() - 1],
17050            rotation_sig
17051        );
17052        let identity = V2HeadIdentity {
17053            custody: "self".to_string(),
17054            fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
17055            public_key_spki: new_spki.clone(),
17056            previous: vec![V2PreviousIdentity {
17057                fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
17058                public_key_spki: old_spki.clone(),
17059            }],
17060            rotations: vec![rotation],
17061        };
17062        let commit = |seq: u64,
17063                      epoch: u64,
17064                      multikey: &str,
17065                      spki: &str,
17066                      pair: &ring::signature::Ed25519KeyPair| {
17067            let value = json!({
17068                "actor_ref": "a".repeat(64),
17069                "asset_root": Value::Null,
17070                "brain": multikey,
17071                "changes_sha256": "b".repeat(64),
17072                "control_revision": "c".repeat(64),
17073                "materializer": "dbmd-projection-v1",
17074                "op": "changeset",
17075                "parent_asset_root": Value::Null,
17076                "parent_commit": if seq == 1 { Value::Null } else { Value::String("d".repeat(64)) },
17077                "parent_root": if seq == 1 { Value::Null } else { Value::String("e".repeat(64)) },
17078                "prev_entry_hash": if seq == 1 { Value::Null } else { Value::String("f".repeat(64)) },
17079                "public_key": spki,
17080                "seq": seq,
17081                "signer_epoch": epoch,
17082                "state_root": "1".repeat(64),
17083                "ts": "2026-08-19T12:00:00.000Z",
17084                "v": 2,
17085                "v1_bridge": Value::Null,
17086            });
17087            let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
17088            let mut object = value.as_object().unwrap().clone();
17089            object.insert(
17090                "sig".to_string(),
17091                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
17092            );
17093            crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
17094        };
17095
17096        assert!(verified_v2_commit_object(
17097            &commit(1, 1, &old_multikey, &old_spki, &old),
17098            &identity,
17099        )
17100        .is_ok());
17101        assert!(verified_v2_commit_object(
17102            &commit(2, 2, &new_multikey, &new_spki, &new),
17103            &identity,
17104        )
17105        .is_ok());
17106        assert!(verified_v2_commit_object(
17107            &commit(2, 1, &old_multikey, &old_spki, &old),
17108            &identity,
17109        )
17110        .is_err());
17111        assert!(verified_v2_commit_object(
17112            &commit(1, 2, &new_multikey, &new_spki, &new),
17113            &identity,
17114        )
17115        .is_err());
17116    }
17117
17118    #[test]
17119    fn a_self_custody_entry_verifies_like_any_hub_entry() {
17120        let rng = ring::rand::SystemRandom::new();
17121        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17122        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
17123        let (spki, multikey) = public_identity_for(&pair);
17124        let key = AgentSigningKey {
17125            pkcs8: pkcs8.as_ref().to_vec(),
17126            multikey: multikey.clone(),
17127            public_key_spki: spki.clone(),
17128        };
17129        let files = vec![WireFeedFile {
17130            path: "DB.md".to_string(),
17131            sha256: "a".repeat(64),
17132            bytes: 3,
17133        }];
17134        let raw = self_custody_entry(
17135            &key,
17136            1,
17137            "2026-07-23T12:00:00.000Z".to_string(),
17138            &"c".repeat(64),
17139            &files,
17140            None,
17141        )
17142        .unwrap();
17143        // The exact client serialization parses as a feed entry and passes the
17144        // SAME verifier every subscribe read runs — the self-custody path
17145        // produces first-class wire-profile-v1 entries.
17146        let entry: FeedEntry = serde_json::from_str(&raw).unwrap();
17147        let hash = format!("{:x}", Sha256::digest(format!("{raw}\n").as_bytes()));
17148        let item = FeedItem { hash, entry };
17149        let identity = FeedIdentity {
17150            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
17151            public_key_spki: spki,
17152            previous: Vec::new(),
17153            rotations: Vec::new(),
17154        };
17155        assert!(verify_feed_item(&item, &identity).is_ok());
17156    }
17157
17158    #[test]
17159    fn identity_rotation_requires_old_key_signature_and_preserves_the_pin() {
17160        let rng = ring::rand::SystemRandom::new();
17161        let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17162        let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
17163        let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17164        let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
17165        let (old_spki, old_multikey) = public_identity_for(&old);
17166        let (new_spki, new_multikey) = public_identity_for(&new);
17167        let unsigned = serde_json::to_string(&UnsignedRotation {
17168            v: 1,
17169            op: "rotate",
17170            brain: &old_multikey,
17171            public_key: &old_spki,
17172            new_brain: &new_multikey,
17173            new_public_key: &new_spki,
17174            prior_head_seq: 1,
17175            prior_feed_hash: Some(&"a".repeat(64)),
17176            ts: "2026-07-30T12:00:00.000Z".to_string(),
17177        })
17178        .unwrap();
17179        let signature = URL_SAFE_NO_PAD.encode(old.sign(unsigned.as_bytes()).as_ref());
17180        let rotation = format!(
17181            "{},\"sig\":\"{}\"}}",
17182            &unsigned[..unsigned.len() - 1],
17183            signature
17184        );
17185        let identity = FeedIdentity {
17186            fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
17187            public_key_spki: new_spki,
17188            previous: vec![PreviousIdentity {
17189                fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
17190                public_key_spki: old_spki,
17191            }],
17192            rotations: vec![rotation],
17193        };
17194        let pin = TrustState {
17195            v: 2,
17196            origin: "https://hub.example".to_string(),
17197            requested: "brain".to_string(),
17198            brain: "brain".to_string(),
17199            home: None,
17200            anchor: old_multikey.clone(),
17201            current: old_multikey.clone(),
17202            head_seq: 1,
17203            feed_hash: Some("a".repeat(64)),
17204            rotations: Vec::new(),
17205            hub_signer: None,
17206            protocol_profile: None,
17207        };
17208        assert_eq!(
17209            verify_identity_chain(&identity, Some(&pin)).unwrap(),
17210            old_multikey
17211        );
17212        let mut accepted = pin.clone();
17213        accepted.current = new_multikey.clone();
17214        accepted.rotations = identity.rotations.clone();
17215        let alternate_unsigned = serde_json::to_string(&UnsignedRotation {
17216            v: 1,
17217            op: "rotate",
17218            brain: &old_multikey,
17219            public_key: &identity.previous[0].public_key_spki,
17220            new_brain: &new_multikey,
17221            new_public_key: &identity.public_key_spki,
17222            prior_head_seq: 1,
17223            prior_feed_hash: Some(&"a".repeat(64)),
17224            ts: "2026-07-30T12:00:01.000Z".to_string(),
17225        })
17226        .unwrap();
17227        let alternate_signature =
17228            URL_SAFE_NO_PAD.encode(old.sign(alternate_unsigned.as_bytes()).as_ref());
17229        let mut rewritten = identity.clone();
17230        rewritten.rotations[0] = format!(
17231            "{},\"sig\":\"{}\"}}",
17232            &alternate_unsigned[..alternate_unsigned.len() - 1],
17233            alternate_signature
17234        );
17235        assert!(
17236            verify_identity_chain(&rewritten, Some(&accepted)).is_err(),
17237            "an alternate valid statement must not rewrite accepted history"
17238        );
17239
17240        let mut stale_entry = FeedEntry {
17241            v: 1,
17242            seq: 2,
17243            ts: "2026-07-30T12:01:00.000Z".to_string(),
17244            brain: pin.current.clone(),
17245            public_key: identity.previous[0].public_key_spki.clone(),
17246            kind: "push".to_string(),
17247            op: "snapshot".to_string(),
17248            pack_sha256: "b".repeat(64),
17249            files: Vec::new(),
17250            removed: Vec::new(),
17251            prev_entry_hash: pin.feed_hash.clone(),
17252            sig: String::new(),
17253        };
17254        let stale_unsigned = UnsignedFeedEntry {
17255            v: stale_entry.v,
17256            seq: stale_entry.seq,
17257            ts: &stale_entry.ts,
17258            brain: &stale_entry.brain,
17259            public_key: &stale_entry.public_key,
17260            kind: &stale_entry.kind,
17261            op: &stale_entry.op,
17262            pack_sha256: &stale_entry.pack_sha256,
17263            files: &stale_entry.files,
17264            removed: &stale_entry.removed,
17265            prev_entry_hash: &stale_entry.prev_entry_hash,
17266        };
17267        stale_entry.sig = URL_SAFE_NO_PAD.encode(
17268            old.sign(&serde_json::to_vec(&stale_unsigned).unwrap())
17269                .as_ref(),
17270        );
17271        let mut stale_exact = serde_json::to_vec(&stale_entry).unwrap();
17272        stale_exact.push(b'\n');
17273        let stale_item = FeedItem {
17274            hash: content_sha256(&stale_exact),
17275            entry: stale_entry,
17276        };
17277        assert!(
17278            reject_retired_signer_after_checkpoint(&identity, Some(&accepted), &stale_item)
17279                .is_err(),
17280            "a key retired before the checkpoint must never append after it"
17281        );
17282        assert!(
17283            verify_feed_item(&stale_item, &identity).is_err(),
17284            "an old key must never append after its signed rotation boundary"
17285        );
17286
17287        let mut missing = identity.clone();
17288        missing.rotations.clear();
17289        assert!(verify_identity_chain(&missing, Some(&pin)).is_err());
17290
17291        let mut tampered = identity;
17292        tampered.rotations[0] = tampered.rotations[0].replace("\"sig\":\"", "\"sig\":\"A");
17293        assert!(verify_identity_chain(&tampered, Some(&pin)).is_err());
17294    }
17295
17296    #[cfg(unix)]
17297    #[test]
17298    fn key_creation_refuses_a_planted_symlink_without_touching_its_target() {
17299        use std::os::unix::fs::symlink;
17300
17301        let dir = tempfile::tempdir().unwrap();
17302        let target = dir.path().join("valuable.txt");
17303        let planted = dir.path().join("agent.key");
17304        std::fs::write(&target, "do not overwrite").unwrap();
17305        symlink(&target, &planted).unwrap();
17306
17307        assert!(matches!(
17308            generate_agent_key(&planted),
17309            Err(LinkError::BadAgentKey { .. })
17310        ));
17311        assert_eq!(std::fs::read_to_string(target).unwrap(), "do not overwrite");
17312    }
17313
17314    #[cfg(unix)]
17315    #[test]
17316    fn key_creation_refuses_a_symlinked_parent_without_writing_through_it() {
17317        use std::os::unix::fs::symlink;
17318
17319        let root = tempfile::tempdir().unwrap();
17320        let outside = tempfile::tempdir().unwrap();
17321        symlink(outside.path(), root.path().join("redirect")).unwrap();
17322
17323        assert!(generate_agent_key(&root.path().join("redirect/agent.key")).is_err());
17324        assert!(!outside.path().join("agent.key").exists());
17325    }
17326
17327    // ── Address parsing ─────────────────────────────────────────────────────
17328
17329    #[test]
17330    fn address_bare_brain_with_and_without_sigil() {
17331        for raw in ["@acme-ops", "acme-ops"] {
17332            let a = Address::parse(raw).expect(raw);
17333            assert_eq!(a.brain, "acme-ops");
17334            assert_eq!(a.target, None);
17335        }
17336    }
17337
17338    #[test]
17339    fn address_ulid_target_parses_as_id() {
17340        let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
17341        assert_eq!(a.brain, "acme");
17342        assert_eq!(
17343            a.target,
17344            Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
17345        );
17346    }
17347
17348    #[test]
17349    fn address_md_path_target_parses_as_path() {
17350        let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
17351        assert_eq!(
17352            a.target,
17353            Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
17354        );
17355    }
17356
17357    #[test]
17358    fn address_rejects_malformed_forms() {
17359        for raw in [
17360            "",
17361            "@",
17362            "@/x",
17363            "@acme/",
17364            "@acme/../etc/passwd",
17365            "@acme/records/.hidden.md",
17366            "@ACME",             // uppercase is not a hub ref shape
17367            "@acme/notes/x.txt", // target is neither ULID nor .md path
17368            "@a b",              // whitespace
17369        ] {
17370            assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
17371        }
17372    }
17373
17374    // ── Path safety ─────────────────────────────────────────────────────────
17375
17376    #[test]
17377    fn safe_paths_accept_store_shapes_and_reject_escapes() {
17378        for ok in [
17379            "DB.md",
17380            "assets.jsonl",
17381            "records/clients/lumio.md",
17382            "sources/emails/2026/07/x.md",
17383        ] {
17384            assert!(safe_store_rel_path(ok), "should accept {ok:?}");
17385        }
17386        for bad in [
17387            "",
17388            "/etc/passwd",
17389            "../up.md",
17390            "records/../../up.md",
17391            "records//x.md",
17392            ".dbmd/config",
17393            "records/.hidden/x.md",
17394            "records/a b.md",
17395            "records\\win.md",
17396        ] {
17397            assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
17398        }
17399    }
17400
17401    #[cfg(unix)]
17402    #[test]
17403    fn opened_destination_capability_survives_an_ancestor_path_swap() {
17404        use std::os::unix::fs::symlink;
17405
17406        let work = tempfile::tempdir().unwrap();
17407        let outside = tempfile::tempdir().unwrap();
17408        let original = work.path().join("destination");
17409        let moved = work.path().join("destination-moved");
17410        let directory = open_or_create_dir_nofollow(&original).unwrap();
17411
17412        std::fs::rename(&original, &moved).unwrap();
17413        symlink(outside.path(), &original).unwrap();
17414        write_pull_entries_beneath_dir(
17415            &directory,
17416            &[("records/note.md".to_string(), b"held inode".to_vec())],
17417        )
17418        .unwrap();
17419
17420        assert_eq!(
17421            std::fs::read(moved.join("records/note.md")).unwrap(),
17422            b"held inode"
17423        );
17424        assert!(!outside.path().join("records/note.md").exists());
17425    }
17426
17427    // ── Config resolution (flag + file precedence; env is covered by the CLI
17428    //    integration tests, where a child process isolates it) ───────────────
17429
17430    #[test]
17431    fn hub_config_flag_beats_file_and_requires_some_source() {
17432        let dir = tempfile::tempdir().unwrap();
17433        std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
17434        std::fs::write(
17435            dir.path().join(CONFIG_REL_PATH),
17436            "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
17437        )
17438        .unwrap();
17439
17440        let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
17441        assert_eq!(from_flag.hub, "https://flag.example.com");
17442
17443        let from_file = hub_config(None, dir.path()).unwrap();
17444        assert_eq!(from_file.hub, "https://file.example.com");
17445
17446        let none = hub_config(None, tempfile::tempdir().unwrap().path());
17447        assert!(matches!(none, Err(LinkError::NoHub)));
17448    }
17449
17450    #[test]
17451    fn https_guard_allows_loopback_only_for_plain_http() {
17452        assert!(assert_safe_hub("https://hub.example.com").is_ok());
17453        assert!(assert_safe_hub("http://localhost:3000").is_ok());
17454        assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
17455        assert!(assert_safe_hub("http://[::1]:3000").is_ok());
17456        assert!(matches!(
17457            assert_safe_hub("http://hub.example.com"),
17458            Err(LinkError::UnsafeHub { .. })
17459        ));
17460        assert!(matches!(
17461            assert_safe_hub("hub.example.com"),
17462            Err(LinkError::UnsafeHub { .. })
17463        ));
17464        assert!(matches!(
17465            assert_safe_hub("http://localhost:80@127.0.0.1:1"),
17466            Err(LinkError::UnsafeHub { .. })
17467        ));
17468        assert!(matches!(
17469            assert_safe_hub("https://hub.example.com@attacker.example"),
17470            Err(LinkError::UnsafeHub { .. })
17471        ));
17472        assert!(matches!(
17473            assert_safe_hub("https://hub.example.com/base"),
17474            Err(LinkError::UnsafeHub { .. })
17475        ));
17476    }
17477
17478    #[test]
17479    fn registry_ssrf_classifier_rejects_local_private_and_documentation_ranges() {
17480        for blocked in [
17481            "127.0.0.1",
17482            "10.0.0.1",
17483            "100.64.0.1",
17484            "169.254.169.254",
17485            "172.16.0.1",
17486            "192.168.0.1",
17487            "192.88.99.1",
17488            "198.18.0.1",
17489            "203.0.113.1",
17490            "::1",
17491            "fe80::1",
17492            "fd00::1",
17493            "2001:db8::1",
17494            "2001:1::1",
17495            "2002:7f00:1::",
17496            "3fff::1",
17497        ] {
17498            assert!(
17499                !is_public_registry_ip(blocked.parse().unwrap()),
17500                "must block {blocked}"
17501            );
17502        }
17503        assert!(is_public_registry_ip("1.1.1.1".parse().unwrap()));
17504        assert!(is_public_registry_ip(
17505            "2606:4700:4700::1111".parse().unwrap()
17506        ));
17507        assert!(is_public_registry_ip("3fff:1000::1".parse().unwrap()));
17508    }
17509
17510    #[test]
17511    fn registry_dns_answer_is_pinned_and_cannot_rebind_or_change_authority() {
17512        use ureq::Resolver as _;
17513
17514        let pinned: std::net::SocketAddr = "1.1.1.1:443".parse().unwrap();
17515        let resolver = PinnedRegistryResolver {
17516            netloc: "home.example:443".to_string(),
17517            addresses: vec![pinned],
17518        };
17519        assert_eq!(resolver.resolve("home.example:443").unwrap(), vec![pinned]);
17520        assert!(resolver.resolve("127.0.0.1:443").is_err());
17521        assert_eq!(
17522            resolver.resolve("home.example:443").unwrap(),
17523            vec![pinned],
17524            "subsequent connects reuse the validated answer instead of DNS"
17525        );
17526    }
17527
17528    #[test]
17529    fn production_object_urls_and_store_selected_hubs_cannot_reach_private_ips() {
17530        let cfg = HubConfig {
17531            hub: "https://hub.example".to_string(),
17532            key: None,
17533            agent_key: None,
17534            brain_key: None,
17535            state_dir: tempfile::tempdir().unwrap().keep(),
17536            store_selected: false,
17537        };
17538        assert!(
17539            presigned_agent(&cfg, "https://127.0.0.1/private").is_err(),
17540            "a production hub must not turn its presigned URL into an SSRF primitive"
17541        );
17542
17543        let store_selected = HubConfig {
17544            hub: "https://127.0.0.1".to_string(),
17545            store_selected: true,
17546            ..cfg
17547        };
17548        assert!(
17549            hub_agent(&store_selected).is_err(),
17550            "bytes in a cloned store must not select a private-network hub"
17551        );
17552    }
17553
17554    #[test]
17555    fn presigned_download_ceiling_cannot_overflow_at_u64_max() {
17556        assert_eq!(
17557            one_past_bounded_limit(MAX_PACK_BYTES),
17558            Some(MAX_PACK_BYTES + 1),
17559            "the presigned reader consumes exactly one refusal byte beyond its fixed pack cap"
17560        );
17561        assert_eq!(
17562            presigned_download_read_limit(),
17563            MAX_PACK_BYTES + 1,
17564            "the presigned reader is capped by the client constant, not a hub response"
17565        );
17566        assert_eq!(
17567            one_past_bounded_limit(u64::MAX),
17568            None,
17569            "the former attacker-controlled u64::MAX + 1 shape must fail without overflow"
17570        );
17571    }
17572
17573    #[test]
17574    fn https_guard_matches_the_scheme_case_insensitively() {
17575        // RFC 3986 schemes are case-insensitive: an uppercase-scheme HTTPS
17576        // hub is still HTTPS, never a misleading non-HTTPS refusal.
17577        assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
17578        assert!(assert_safe_hub("Https://hub.example.com").is_ok());
17579        // And an uppercase plain-HTTP hub is still refused outside loopback.
17580        assert!(matches!(
17581            assert_safe_hub("HTTP://hub.example.com"),
17582            Err(LinkError::UnsafeHub { .. })
17583        ));
17584    }
17585
17586    #[test]
17587    fn clean_key_refuses_paste_artifacts_without_echoing() {
17588        assert_eq!(clean_key("  vc_account_abc  ").unwrap(), "vc_account_abc");
17589        for bad in ["vc account", "vc\naccount", "ключ", ""] {
17590            let err = clean_key(bad).unwrap_err();
17591            assert!(matches!(err, LinkError::BadKey));
17592            assert!(
17593                !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
17594                "error must not echo the key"
17595            );
17596        }
17597    }
17598
17599    // ── Verb entry gates: refs must never reshape the request path ──────────
17600
17601    /// A config whose hub passes the loopback guard but is never listened on:
17602    /// every refusal below must come from the entry gate BEFORE a request
17603    /// exists — a dial on this dead port would surface `Transport` instead.
17604    fn dead_hub() -> HubConfig {
17605        HubConfig {
17606            hub: "http://127.0.0.1:9".to_string(),
17607            key: Some("k".to_string()),
17608            agent_key: None,
17609            brain_key: None,
17610            state_dir: PathBuf::from("."),
17611            store_selected: false,
17612        }
17613    }
17614
17615    #[test]
17616    fn request_retries_a_connection_failure_before_sending() {
17617        use std::io::{Read as _, Write as _};
17618        use std::net::TcpListener;
17619        use std::thread;
17620        use std::time::Duration;
17621
17622        let probe = TcpListener::bind("127.0.0.1:0").unwrap();
17623        let address = probe.local_addr().unwrap();
17624        drop(probe);
17625        let server = thread::spawn(move || {
17626            thread::sleep(Duration::from_millis(40));
17627            let listener = TcpListener::bind(address).unwrap();
17628            let (mut stream, _) = listener.accept().unwrap();
17629            let mut request_bytes = [0_u8; 1024];
17630            let _ = stream.read(&mut request_bytes).unwrap();
17631            stream
17632                .write_all(
17633                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
17634                )
17635                .unwrap();
17636        });
17637        let cfg = HubConfig {
17638            hub: format!("http://{address}"),
17639            key: None,
17640            agent_key: None,
17641            brain_key: None,
17642            state_dir: tempfile::tempdir().unwrap().keep(),
17643            store_selected: false,
17644        };
17645
17646        let response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
17647        assert_eq!(response.status, 200);
17648        assert_eq!(response.body, Some(json!({ "ok": true })));
17649        server.join().unwrap();
17650    }
17651
17652    #[test]
17653    fn a_commit_goes_back_for_a_receipt_it_lost() {
17654        use std::io::{Read as _, Write as _};
17655        use std::net::TcpListener;
17656        use std::thread;
17657
17658        // The mutation coordinate is derived from the observed base and the
17659        // exact operation set so a repeat is the SAME effect. A commit that
17660        // lands durably and then loses its connection must therefore be asked
17661        // after, not reported as a failure — which is what happened to a
17662        // 32,338-file store that had already committed.
17663        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17664        let address = listener.local_addr().unwrap();
17665        let server = thread::spawn(move || {
17666            // First attempt: promise a body, hang up mid-way.
17667            let (mut first, _) = listener.accept().unwrap();
17668            let mut bytes = [0_u8; 4096];
17669            let _ = first.read(&mut bytes).unwrap();
17670            first
17671                .write_all(
17672                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 90\r\nConnection: close\r\n\r\n{\"v\":2",
17673                )
17674                .unwrap();
17675            drop(first);
17676            // Second attempt: the receipt for the commit that already landed.
17677            let (mut second, _) = listener.accept().unwrap();
17678            let _ = second.read(&mut bytes).unwrap();
17679            second
17680                .write_all(
17681                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 29\r\nConnection: close\r\n\r\n{\"v\":2,\"outcome\":\"converged\"}",
17682                )
17683                .unwrap();
17684        });
17685        let cfg = HubConfig {
17686            hub: format!("http://{address}"),
17687            key: Some("k".to_string()),
17688            agent_key: None,
17689            brain_key: None,
17690            state_dir: tempfile::tempdir().unwrap().keep(),
17691            store_selected: false,
17692        };
17693
17694        let response = request_patient(
17695            &cfg,
17696            "POST",
17697            "/api/hub/brains/b/v2/commits",
17698            Some(&json!({ "mutation_id": "dbmd-1" })),
17699            Auth::Required,
17700        )
17701        .expect("the receipt is collected on the second ask");
17702        assert_eq!(response.status, 200);
17703        assert_eq!(
17704            response
17705                .body
17706                .as_ref()
17707                .and_then(|value| value.get("outcome"))
17708                .and_then(Value::as_str),
17709            Some("converged"),
17710            "an already-applied mutation answers with its receipt"
17711        );
17712        server.join().unwrap();
17713    }
17714
17715    #[test]
17716    fn a_mutation_body_that_dies_mid_stream_is_a_transport_failure() {
17717        use std::io::{Read as _, Write as _};
17718        use std::net::TcpListener;
17719        use std::thread;
17720
17721        // A connection that fails while the body is arriving is the same class
17722        // of problem as one that fails before it arrives at all. Typed as a
17723        // bare io error it matched no retry path and named no peer, and one
17724        // stalled body ended a whole migration reporting only "timed out
17725        // reading response".
17726        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17727        let address = listener.local_addr().unwrap();
17728        let server = thread::spawn(move || {
17729            let (mut stream, _) = listener.accept().unwrap();
17730            let mut request_bytes = [0_u8; 1024];
17731            let _ = stream.read(&mut request_bytes).unwrap();
17732            // Promise a hundred bytes, send ten, hang up.
17733            stream
17734                .write_all(
17735                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
17736                )
17737                .unwrap();
17738        });
17739        let cfg = HubConfig {
17740            hub: format!("http://{address}"),
17741            key: None,
17742            agent_key: None,
17743            brain_key: None,
17744            state_dir: tempfile::tempdir().unwrap().keep(),
17745            store_selected: false,
17746        };
17747
17748        let error = request(&cfg, "POST", "/truncated", None, Auth::None)
17749            .expect_err("a truncated body must not read as success");
17750        match error {
17751            LinkError::Transport { hub, .. } => {
17752                assert!(hub.contains(&address.to_string()), "names its peer: {hub}");
17753            }
17754            other => panic!("expected a transport failure, got {other:?}"),
17755        }
17756        server.join().unwrap();
17757    }
17758
17759    #[test]
17760    fn a_safe_get_retries_when_its_body_dies_mid_stream() {
17761        use std::io::{Read as _, Write as _};
17762        use std::net::{TcpListener, TcpStream};
17763        use std::thread;
17764
17765        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17766        let address = listener.local_addr().unwrap();
17767        let server = thread::spawn(move || {
17768            let read_request = |stream: &mut TcpStream| {
17769                let mut request = Vec::new();
17770                let mut bytes = [0_u8; 1024];
17771                loop {
17772                    let read = stream.read(&mut bytes).unwrap();
17773                    if read == 0 {
17774                        break;
17775                    }
17776                    request.extend_from_slice(&bytes[..read]);
17777                    let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n")
17778                    else {
17779                        continue;
17780                    };
17781                    let headers = String::from_utf8_lossy(&request[..header_end]);
17782                    let content_length = headers
17783                        .lines()
17784                        .find_map(|line| {
17785                            let (name, value) = line.split_once(':')?;
17786                            name.eq_ignore_ascii_case("content-length")
17787                                .then(|| value.trim().parse::<usize>().ok())
17788                                .flatten()
17789                        })
17790                        .unwrap_or(0);
17791                    if request.len() >= header_end + 4 + content_length {
17792                        break;
17793                    }
17794                }
17795            };
17796            let (mut first, _) = listener.accept().unwrap();
17797            read_request(&mut first);
17798            first
17799                .write_all(
17800                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
17801                )
17802                .unwrap();
17803            drop(first);
17804
17805            let (mut second, _) = listener.accept().unwrap();
17806            read_request(&mut second);
17807            second
17808                .write_all(
17809                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
17810                )
17811                .unwrap();
17812        });
17813        let cfg = HubConfig {
17814            hub: format!("http://{address}"),
17815            key: None,
17816            agent_key: None,
17817            brain_key: None,
17818            state_dir: tempfile::tempdir().unwrap().keep(),
17819            store_selected: false,
17820        };
17821
17822        let response = request(&cfg, "GET", "/retry-body", None, Auth::None)
17823            .expect("a safe read retries the interrupted body");
17824        assert_eq!(response.status, 200);
17825        assert_eq!(response.body, Some(json!({ "ok": true })));
17826        server.join().unwrap();
17827    }
17828
17829    #[test]
17830    fn an_explicit_read_only_post_retries_when_its_body_dies_mid_stream() {
17831        use std::io::{Read as _, Write as _};
17832        use std::net::{TcpListener, TcpStream};
17833        use std::thread;
17834
17835        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17836        let address = listener.local_addr().unwrap();
17837        let server = thread::spawn(move || {
17838            let read_request = |stream: &mut TcpStream| {
17839                let mut request = Vec::new();
17840                let mut bytes = [0_u8; 1024];
17841                loop {
17842                    let read = stream.read(&mut bytes).unwrap();
17843                    if read == 0 {
17844                        break;
17845                    }
17846                    request.extend_from_slice(&bytes[..read]);
17847                    let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n")
17848                    else {
17849                        continue;
17850                    };
17851                    let headers = String::from_utf8_lossy(&request[..header_end]);
17852                    let content_length = headers
17853                        .lines()
17854                        .find_map(|line| {
17855                            let (name, value) = line.split_once(':')?;
17856                            name.eq_ignore_ascii_case("content-length")
17857                                .then(|| value.trim().parse::<usize>().ok())
17858                                .flatten()
17859                        })
17860                        .unwrap_or(0);
17861                    if request.len() >= header_end + 4 + content_length {
17862                        break;
17863                    }
17864                }
17865            };
17866            let (mut first, _) = listener.accept().unwrap();
17867            read_request(&mut first);
17868            first
17869                .write_all(
17870                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
17871                )
17872                .unwrap();
17873            drop(first);
17874
17875            let (mut second, _) = listener.accept().unwrap();
17876            read_request(&mut second);
17877            second
17878                .write_all(
17879                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
17880                )
17881                .unwrap();
17882        });
17883        let cfg = HubConfig {
17884            hub: format!("http://{address}"),
17885            key: None,
17886            agent_key: None,
17887            brain_key: None,
17888            state_dir: tempfile::tempdir().unwrap().keep(),
17889            store_selected: false,
17890        };
17891
17892        let response = request_raw_retryable_read(
17893            &cfg,
17894            "POST",
17895            "/v2/stream",
17896            Some(&json!({ "files": ["proof"] })),
17897            Auth::None,
17898            1_024,
17899        )
17900        .expect("an explicitly safe POST retries the interrupted body");
17901        assert_eq!(response.status, 200);
17902        assert_eq!(
17903            serde_json::from_slice::<Value>(&response.body).unwrap(),
17904            json!({ "ok": true })
17905        );
17906        server.join().unwrap();
17907    }
17908
17909    #[test]
17910    fn object_store_transport_errors_never_render_presigned_urls() {
17911        use std::net::TcpListener;
17912
17913        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17914        let address = listener.local_addr().unwrap();
17915        drop(listener);
17916        let signature = "do-not-render-this-presigned-signature";
17917        let raw =
17918            format!("http://{address}/blob?X-Amz-Credential=temporary&X-Amz-Signature={signature}");
17919        let error = ureq::get(&raw)
17920            .timeout(std::time::Duration::from_millis(250))
17921            .call()
17922            .expect_err("the closed local port must fail");
17923        let ureq::Error::Transport(transport) = error else {
17924            panic!("expected a transport failure");
17925        };
17926
17927        let rendered = object_store_transport_error(transport).to_string();
17928        assert!(rendered.contains("the object store"));
17929        assert!(rendered.contains("network error"));
17930        assert!(!rendered.contains(&raw));
17931        assert!(!rendered.contains(signature));
17932        assert!(!rendered.contains("X-Amz-"));
17933    }
17934
17935    #[test]
17936    fn endpoint_cap_refuses_a_body_before_json_parsing() {
17937        let (hub, server) = scripted_json_hub(vec![(200, "x".repeat(2_048))]);
17938        let cfg = HubConfig {
17939            hub,
17940            key: None,
17941            agent_key: None,
17942            brain_key: None,
17943            state_dir: tempfile::tempdir().unwrap().keep(),
17944            store_selected: false,
17945        };
17946
17947        assert!(matches!(
17948            request_capped(&cfg, "GET", "/bounded", None, Auth::None, 1_024),
17949            Err(LinkError::ResponseTooLarge { .. })
17950        ));
17951        server.join().unwrap();
17952    }
17953
17954    #[test]
17955    fn overall_deadline_stops_a_dribbled_response_body() {
17956        use std::io::{Read as _, Write as _};
17957        use std::net::TcpListener;
17958        use std::time::{Duration, Instant};
17959
17960        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17961        let url = format!("http://{}/dribble", listener.local_addr().unwrap());
17962        let server = std::thread::spawn(move || {
17963            let (mut stream, _) = listener.accept().unwrap();
17964            let mut request = [0_u8; 1024];
17965            let _ = stream.read(&mut request);
17966            stream
17967                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 32\r\nConnection: close\r\n\r\n")
17968                .unwrap();
17969            for byte in [b'x'; 32] {
17970                if stream.write_all(&[byte]).is_err() {
17971                    break;
17972                }
17973                std::thread::sleep(Duration::from_millis(40));
17974            }
17975        });
17976        let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
17977        let started = Instant::now();
17978        let response = http.get(&url).call().unwrap();
17979        let mut body = Vec::new();
17980        let error = response
17981            .into_reader()
17982            .read_to_end(&mut body)
17983            .expect_err("per-read progress must not reset the overall deadline");
17984        assert!(
17985            started.elapsed() < Duration::from_millis(700),
17986            "dribbled body exceeded the wall-clock budget: {error}"
17987        );
17988        server.join().unwrap();
17989    }
17990
17991    #[test]
17992    fn overall_deadline_stops_a_stalled_upload() {
17993        use std::net::TcpListener;
17994        use std::time::{Duration, Instant};
17995
17996        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17997        let url = format!("http://{}/upload", listener.local_addr().unwrap());
17998        let server = std::thread::spawn(move || {
17999            let (_stream, _) = listener.accept().unwrap();
18000            // Never consume the request body. Once the kernel send buffer fills,
18001            // the client must leave on its absolute write deadline.
18002            std::thread::sleep(Duration::from_millis(600));
18003        });
18004        let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
18005        let body = vec![0x5a; 32 * 1024 * 1024];
18006        let started = Instant::now();
18007        let error = http
18008            .put(&url)
18009            .send_bytes(&body)
18010            .expect_err("stalled request-body writes must time out");
18011        assert!(
18012            started.elapsed() < Duration::from_millis(700),
18013            "stalled upload exceeded the wall-clock budget: {error}"
18014        );
18015        server.join().unwrap();
18016    }
18017
18018    #[test]
18019    fn presigned_source_retries_share_one_upload_deadline() {
18020        use std::net::TcpListener;
18021        use std::time::{Duration, Instant};
18022
18023        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18024        let address = listener.local_addr().unwrap();
18025        let signature = "do-not-render-this-stalled-upload-signature";
18026        let url = format!(
18027            "http://{address}/upload?X-Amz-Credential=temporary&X-Amz-Signature={signature}"
18028        );
18029        let server = std::thread::spawn(move || {
18030            let (_stream, _) = listener.accept().unwrap();
18031            // Keep the socket open without consuming the request. The first
18032            // attempt must spend the complete shared budget; retries may not
18033            // each restart that budget.
18034            std::thread::sleep(Duration::from_millis(600));
18035        });
18036
18037        let directory = tempfile::tempdir().unwrap();
18038        std::fs::write(directory.path().join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
18039        std::fs::create_dir(directory.path().join("records")).unwrap();
18040        let relative = "records/stalled.bin";
18041        let bytes = vec![0x5a; 32 * 1024 * 1024];
18042        std::fs::write(directory.path().join(relative), &bytes).unwrap();
18043        let store = Store::open_strict(directory.path()).unwrap();
18044        let cfg = HubConfig {
18045            hub: format!("http://{address}"),
18046            key: None,
18047            agent_key: None,
18048            brain_key: None,
18049            state_dir: tempfile::tempdir().unwrap().keep(),
18050            store_selected: false,
18051        };
18052        let source = V2UploadSource {
18053            path: relative.to_string(),
18054            bytes: bytes.len() as u64,
18055        };
18056
18057        let started = Instant::now();
18058        let error = put_presigned_source_with_budget(
18059            &cfg,
18060            &url,
18061            &json!({ "content-length": source.bytes.to_string() }),
18062            &store,
18063            &source,
18064            None,
18065            Duration::from_millis(150),
18066        )
18067        .expect_err("a black-holed upload must leave at its shared deadline");
18068        assert!(
18069            started.elapsed() < Duration::from_millis(700),
18070            "presigned retries exceeded their shared budget: {error}"
18071        );
18072        let rendered = error.to_string();
18073        assert!(rendered.contains("the object store"));
18074        assert!(!rendered.contains(&url));
18075        assert!(!rendered.contains(signature));
18076        server.join().unwrap();
18077    }
18078
18079    #[test]
18080    fn verb_entry_gates_accept_the_hub_ref_shapes() {
18081        for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
18082            assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
18083            assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
18084            assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
18085        }
18086    }
18087
18088    #[test]
18089    fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
18090        let cfg = dead_hub();
18091        for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
18092            assert!(
18093                matches!(
18094                    sync_pull(&cfg, bad, None),
18095                    Err(LinkError::BadAddress { .. })
18096                ),
18097                "sync_pull must refuse {bad:?}"
18098            );
18099            assert!(
18100                matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
18101                "sync_push must refuse {bad:?}"
18102            );
18103            assert!(
18104                matches!(
18105                    grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
18106                    Err(LinkError::BadAddress { .. })
18107                ),
18108                "grant_issue must refuse {bad:?}"
18109            );
18110            assert!(
18111                matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
18112                "grant_list must refuse {bad:?}"
18113            );
18114            assert!(
18115                matches!(
18116                    grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
18117                    Err(LinkError::BadAddress { .. })
18118                ),
18119                "grant_revoke must refuse brain {bad:?}"
18120            );
18121            assert!(
18122                matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
18123                "head must refuse {bad:?}"
18124            );
18125        }
18126    }
18127
18128    #[test]
18129    fn grant_revoke_refuses_url_reshaping_grant_ids() {
18130        let cfg = dead_hub();
18131        for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
18132            assert!(
18133                matches!(
18134                    grant_revoke(&cfg, "acme", bad),
18135                    Err(LinkError::BadGrantId { .. })
18136                ),
18137                "grant_revoke must refuse grant id {bad:?}"
18138            );
18139        }
18140    }
18141
18142    #[test]
18143    fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
18144        let cfg = dead_hub();
18145        for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
18146            assert!(
18147                matches!(
18148                    propose(&cfg, bad, "intake", "hi"),
18149                    Err(LinkError::BadAddress { .. })
18150                ),
18151                "propose must refuse handle {bad:?}"
18152            );
18153        }
18154        let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
18155        assert!(matches!(
18156            propose(&cfg, "acme-site", "intake", &oversize),
18157            Err(LinkError::ProposeTooLarge { .. })
18158        ));
18159        // A clean handle + in-cap body passes both gates: the failure is now
18160        // the (dead) wire, proving the gates refuse shape, not the verb.
18161        assert!(matches!(
18162            propose(&cfg, "acme-site", "intake", "hi"),
18163            Err(LinkError::Transport { .. })
18164        ));
18165    }
18166
18167    #[test]
18168    fn resolve_refuses_a_hand_built_unsafe_address() {
18169        let cfg = dead_hub();
18170        for brain in ["../up", "a/b", "a?x", "a#f"] {
18171            let addr = Address {
18172                brain: brain.to_string(),
18173                target: None,
18174            };
18175            assert!(
18176                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
18177                "resolve must refuse brain {brain:?}"
18178            );
18179        }
18180        for target in [
18181            AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
18182            AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), // not the minted shape
18183            AddressTarget::Path("../up.md".to_string()),
18184            AddressTarget::Path("records/x.md#frag".to_string()),
18185        ] {
18186            let addr = Address {
18187                brain: "acme".to_string(),
18188                target: Some(target.clone()),
18189            };
18190            assert!(
18191                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
18192                "resolve must refuse target {target:?}"
18193            );
18194        }
18195    }
18196
18197    #[test]
18198    fn v2_final_barrier_refuses_to_advance_a_remote_ahead_checkout() {
18199        let mut local = std::collections::BTreeMap::new();
18200        local.insert("records/a.md".to_string(), ("a".repeat(64), 0));
18201        local.insert("records/b.md".to_string(), ("b".repeat(64), 0));
18202        let mut remote = std::collections::BTreeMap::new();
18203        remote.insert(
18204            "records/a.md".to_string(),
18205            V2BaselineFile {
18206                sha256: "c".repeat(64),
18207                bytes: 1,
18208                proof: None,
18209            },
18210        );
18211        remote.insert(
18212            "records/b.md".to_string(),
18213            V2BaselineFile {
18214                sha256: "b".repeat(64),
18215                bytes: 1,
18216                proof: None,
18217            },
18218        );
18219        assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
18220    }
18221
18222    #[test]
18223    fn v2_final_barrier_ignores_only_explicit_kept_home_paths() {
18224        let local = std::collections::BTreeMap::new();
18225        let mut remote = std::collections::BTreeMap::new();
18226        remote.insert(
18227            "private/local.md".to_string(),
18228            V2BaselineFile {
18229                sha256: "d".repeat(64),
18230                bytes: 1,
18231                proof: None,
18232            },
18233        );
18234        assert!(v2_riding_matches_remote(&local, &remote, |path| path == "private/local.md"));
18235        assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
18236    }
18237
18238    fn scoped_test_head(revision: &str) -> V2VerifiedHead {
18239        V2VerifiedHead {
18240            requested: TEST_BRAIN_ID.to_string(),
18241            brain_id: TEST_BRAIN_ID.to_string(),
18242            view_kind: "scoped".to_string(),
18243            view_revision: revision.to_string(),
18244            control_revision: revision.to_string(),
18245            identity: V2HeadIdentity {
18246                custody: "hub".to_string(),
18247                fingerprint: "test".to_string(),
18248                public_key_spki: "test".to_string(),
18249                previous: Vec::new(),
18250                rotations: Vec::new(),
18251            },
18252            pointer: None,
18253            trust: TrustState {
18254                v: 2,
18255                origin: "https://hub.example".to_string(),
18256                requested: TEST_BRAIN_ID.to_string(),
18257                brain: TEST_BRAIN_ID.to_string(),
18258                home: None,
18259                anchor: "ed25519:test".to_string(),
18260                current: "ed25519:test".to_string(),
18261                head_seq: 0,
18262                feed_hash: None,
18263                rotations: Vec::new(),
18264                hub_signer: None,
18265                protocol_profile: Some("link-v2".to_string()),
18266            },
18267            alias: None,
18268        }
18269    }
18270
18271    #[test]
18272    fn accepted_v2_checkpoint_never_downgrades_after_profile_migration() {
18273        let mut trust = scoped_test_head(&"a".repeat(64)).trust;
18274        assert!(accepted_as_v2(&trust));
18275
18276        trust.protocol_profile = None;
18277        trust.hub_signer = Some("ed25519:hub".to_string());
18278        assert!(accepted_as_v2(&trust));
18279
18280        trust.hub_signer = None;
18281        assert!(!accepted_as_v2(&trust));
18282    }
18283
18284    fn scoped_test_baseline(revision: &str) -> V2SyncBaseline {
18285        V2SyncBaseline {
18286            v: 2,
18287            origin: "https://hub.example".to_string(),
18288            brain: TEST_BRAIN_ID.to_string(),
18289            checkout_id: Some("c".repeat(64)),
18290            head_seq: Some(0),
18291            commit_hash: None,
18292            content_root: None,
18293            asset_root: None,
18294            assets: std::collections::BTreeMap::new(),
18295            view_kind: Some("scoped".to_string()),
18296            view_revision: Some(revision.to_string()),
18297            control_revision: Some(revision.to_string()),
18298            projection_sha256: Some(scoped_projection_sha256(TEST_BRAIN_ID)),
18299            files: std::collections::BTreeMap::new(),
18300            local_policy_digest: None,
18301            local_eligibility: std::collections::BTreeMap::new(),
18302            remote_copy_remains: std::collections::BTreeMap::new(),
18303        }
18304    }
18305
18306    #[test]
18307    fn v2_sync_baseline_keeps_asset_and_markdown_size_bounds_distinct() {
18308        let cfg = test_hub_config(
18309            "https://hub.example".to_string(),
18310            tempfile::tempdir().unwrap().keep(),
18311        );
18312        let mut baseline = scoped_test_baseline(&"a".repeat(64));
18313        baseline.assets.insert(
18314            "assets/archive.bin".to_string(),
18315            V2BaselineAsset {
18316                blob_sha256: "b".repeat(64),
18317                bytes: MAX_STORE_BYTES + 1,
18318                media_type: "application/octet-stream".to_string(),
18319                wrappers: vec!["records/archive.md".to_string()],
18320                required: true,
18321                disposition: "hosted".to_string(),
18322                leaf_hash: "c".repeat(64),
18323            },
18324        );
18325
18326        let accepted = serde_json::to_vec(&baseline).unwrap();
18327        assert!(parse_v2_baseline(&cfg, TEST_BRAIN_ID, &accepted).is_ok());
18328
18329        baseline.assets.get_mut("assets/archive.bin").unwrap().bytes = MAX_ASSET_BYTES + 1;
18330        let refused = serde_json::to_vec(&baseline).unwrap();
18331        assert!(matches!(
18332            parse_v2_baseline(&cfg, TEST_BRAIN_ID, &refused),
18333            Err(LinkError::InvalidFeed { .. })
18334        ));
18335    }
18336
18337    #[test]
18338    fn scoped_projection_is_a_valid_local_store_marker_but_never_rides() {
18339        let directory = tempfile::tempdir().unwrap();
18340        std::fs::write(
18341            directory.path().join("DB.md"),
18342            scoped_projection_bytes(TEST_BRAIN_ID),
18343        )
18344        .unwrap();
18345        let store = Store::open_strict(directory.path()).unwrap();
18346        let head = scoped_test_head(&"a".repeat(64));
18347        let baseline = scoped_test_baseline(&"a".repeat(64));
18348        let mut view = v2_local_files(&store).unwrap();
18349        remove_scoped_projection(&head, Some(&baseline), &mut view).unwrap();
18350        assert!(!view.riding.contains_key("DB.md"));
18351        assert!(!view.eligibility.contains_key("DB.md"));
18352    }
18353
18354    #[test]
18355    fn scoped_push_accepts_a_pull_verified_handoff_without_double_checking_projection() {
18356        let directory = tempfile::tempdir().unwrap();
18357        std::fs::write(
18358            directory.path().join("DB.md"),
18359            scoped_projection_bytes(TEST_BRAIN_ID),
18360        )
18361        .unwrap();
18362        let store = Store::open_strict(directory.path()).unwrap();
18363        let head = scoped_test_head(&"a".repeat(64));
18364        let baseline = scoped_test_baseline(&"a".repeat(64));
18365
18366        let mut carried = v2_local_files(&store).unwrap();
18367        remove_scoped_projection(&head, Some(&baseline), &mut carried).unwrap();
18368        let handed_off =
18369            local_view_for_v2_push(&store, &head, Some(&baseline), Some(carried)).unwrap();
18370        assert!(!handed_off.riding.contains_key("DB.md"));
18371
18372        let freshly_scanned = local_view_for_v2_push(&store, &head, Some(&baseline), None).unwrap();
18373        assert!(!freshly_scanned.riding.contains_key("DB.md"));
18374
18375        std::fs::write(
18376            directory.path().join("DB.md"),
18377            b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
18378        )
18379        .unwrap();
18380        let tampered = Store::open_strict(directory.path()).unwrap();
18381        assert!(matches!(
18382            local_view_for_v2_push(&tampered, &head, Some(&baseline), None),
18383            Err(LinkError::ScopedProjectionModified)
18384        ));
18385    }
18386
18387    #[test]
18388    fn v2_local_view_reports_only_exact_linked_kept_home_markdown() {
18389        let directory = tempfile::tempdir().unwrap();
18390        std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
18391        std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
18392        std::fs::write(
18393            directory.path().join("DB.md"),
18394            b"---\nname: Kept home test\n---\n",
18395        )
18396        .unwrap();
18397        std::fs::write(
18398            directory.path().join("records/notes/a.md"),
18399            b"---\ntype: note\n---\nSee [[sources/private/secret]].\n",
18400        )
18401        .unwrap();
18402        std::fs::write(
18403            directory.path().join("sources/private/secret.md"),
18404            b"---\ntype: note\n---\nlocal only\n",
18405        )
18406        .unwrap();
18407        std::fs::write(
18408            directory.path().join("sources/private/unlinked.md"),
18409            b"---\ntype: note\n---\nnot disclosed\n",
18410        )
18411        .unwrap();
18412        std::fs::write(
18413            directory.path().join(".sevralocal"),
18414            b"sources/private/**\n",
18415        )
18416        .unwrap();
18417
18418        let store = Store::open_strict(directory.path()).unwrap();
18419        let view = v2_local_files(&store).unwrap();
18420        assert!(!view.riding.contains_key("sources/private/secret.md"));
18421        assert_eq!(
18422            view.withheld_links,
18423            vec![V2WithheldLink {
18424                source: "records/notes/a.md".to_string(),
18425                target: "sources/private/secret.md".to_string(),
18426            }]
18427        );
18428    }
18429
18430    #[test]
18431    fn a_kept_home_target_is_withheld_even_when_this_machine_lacks_it() {
18432        // An export omits kept-home content by construction, so a store
18433        // restored from one links to names it does not carry. Without this the
18434        // owner's own export could never be pushed back — the links would read
18435        // as broken and the commit would be refused.
18436        let directory = tempfile::tempdir().unwrap();
18437        std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
18438        std::fs::write(
18439            directory.path().join("DB.md"),
18440            b"---\nname: Restored export\n---\n",
18441        )
18442        .unwrap();
18443        std::fs::write(
18444            directory.path().join("records/notes/a.md"),
18445            b"---\ntype: note\n---\nSee [[sources/private/absent]].\n",
18446        )
18447        .unwrap();
18448        std::fs::write(
18449            directory.path().join(".sevralocal"),
18450            b"sources/private/**\n",
18451        )
18452        .unwrap();
18453
18454        let store = Store::open_strict(directory.path()).unwrap();
18455        let view = v2_local_files(&store).unwrap();
18456        assert_eq!(
18457            view.withheld_links,
18458            vec![V2WithheldLink {
18459                source: "records/notes/a.md".to_string(),
18460                target: "sources/private/absent.md".to_string(),
18461            }]
18462        );
18463        // A dangling link the owner never claimed is still dangling.
18464        std::fs::write(
18465            directory.path().join("records/notes/b.md"),
18466            b"---\ntype: note\n---\nSee [[records/notes/nowhere]].\n",
18467        )
18468        .unwrap();
18469        let store = Store::open_strict(directory.path()).unwrap();
18470        let view = v2_local_files(&store).unwrap();
18471        assert!(
18472            !view
18473                .withheld_links
18474                .iter()
18475                .any(|link| link.target == "records/notes/nowhere.md"),
18476            "an unclaimed dangling target must not be declared withheld"
18477        );
18478    }
18479
18480    #[test]
18481    fn explicit_content_withdrawal_requires_exact_current_kept_home_bytes() {
18482        let directory = tempfile::tempdir().unwrap();
18483        std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
18484        std::fs::write(
18485            directory.path().join("DB.md"),
18486            b"---\nname: Withdrawal test\n---\n",
18487        )
18488        .unwrap();
18489        let source = b"---\ntype: note\n---\nlocal evidence\n";
18490        std::fs::write(directory.path().join("sources/private/evidence.md"), source).unwrap();
18491        std::fs::write(
18492            directory.path().join(".sevralocal"),
18493            b"sources/private/**\n",
18494        )
18495        .unwrap();
18496        let store = Store::open_strict(directory.path()).unwrap();
18497        let view = v2_local_files(&store).unwrap();
18498        let mut remote = std::collections::BTreeMap::new();
18499        remote.insert(
18500            "sources/private/evidence.md".to_string(),
18501            V2BaselineFile {
18502                sha256: content_sha256(source),
18503                bytes: source.len() as u64,
18504                proof: None,
18505            },
18506        );
18507        assert_eq!(
18508            v2_content_withdrawal_operation(
18509                &store,
18510                &view,
18511                &remote,
18512                "sources/private/evidence.md",
18513                "approved retention change",
18514            )
18515            .unwrap(),
18516            json!({
18517                "op": "withdraw_from_hosting",
18518                "path": "sources/private/evidence.md",
18519                "expected": { "kind": "blob", "hash": content_sha256(source) },
18520                "reason": "approved retention change",
18521            })
18522        );
18523
18524        std::fs::write(
18525            directory.path().join("sources/private/evidence.md"),
18526            b"changed after review",
18527        )
18528        .unwrap();
18529        assert!(matches!(
18530            v2_content_withdrawal_operation(
18531                &store,
18532                &view,
18533                &remote,
18534                "sources/private/evidence.md",
18535                "approved retention change",
18536            ),
18537            Err(LinkError::InvalidPack { .. })
18538        ));
18539    }
18540
18541    #[test]
18542    fn explicit_asset_withdrawal_requires_the_exact_hosted_leaf_and_local_bytes() {
18543        let directory = tempfile::tempdir().unwrap();
18544        std::fs::create_dir_all(directory.path().join("sources/files")).unwrap();
18545        std::fs::write(
18546            directory.path().join("DB.md"),
18547            b"---\nname: Asset withdrawal test\n---\n",
18548        )
18549        .unwrap();
18550        let bytes = b"private binary";
18551        std::fs::write(directory.path().join("sources/files/private.pdf"), bytes).unwrap();
18552        std::fs::write(
18553            directory.path().join(".sevralocal"),
18554            b"sources/files/private.pdf\n",
18555        )
18556        .unwrap();
18557        let store = Store::open_strict(directory.path()).unwrap();
18558        let view = v2_local_files(&store).unwrap();
18559        let local = crate::AssetRecord {
18560            path: "sources/files/private.pdf".to_string(),
18561            sha256: content_sha256(bytes),
18562            bytes: bytes.len() as u64,
18563            media_type: "application/pdf".to_string(),
18564            wrappers: vec!["sources/files/private.md".to_string()],
18565            required: true,
18566        };
18567        let current = V2BaselineAsset {
18568            blob_sha256: local.sha256.clone(),
18569            bytes: local.bytes,
18570            media_type: local.media_type.clone(),
18571            wrappers: local.wrappers.clone(),
18572            required: local.required,
18573            disposition: "hosted".to_string(),
18574            leaf_hash: "d".repeat(64),
18575        };
18576        assert_eq!(
18577            v2_asset_withdrawal_operation(
18578                &store,
18579                &view,
18580                &local.path,
18581                &local,
18582                &current,
18583                "approved retention change",
18584            )
18585            .unwrap(),
18586            json!({
18587                "op": "asset_withdraw",
18588                "path": local.path,
18589                "expected": { "kind": "asset", "hash": "d".repeat(64) },
18590                "reason": "approved retention change",
18591            })
18592        );
18593
18594        let mut mismatched = current.clone();
18595        mismatched.required = false;
18596        assert!(matches!(
18597            v2_asset_withdrawal_operation(
18598                &store,
18599                &view,
18600                &local.path,
18601                &local,
18602                &mismatched,
18603                "approved retention change",
18604            ),
18605            Err(LinkError::InvalidPack { .. })
18606        ));
18607    }
18608
18609    #[test]
18610    fn checkout_pseudonym_is_random_then_stable_from_private_baseline() {
18611        let first = v2_checkout_id(None).unwrap();
18612        assert_eq!(first, v2_checkout_id(Some(&first)).unwrap());
18613        assert_ne!(first, v2_checkout_id(None).unwrap());
18614        assert!(is_sha256(&first));
18615    }
18616
18617    #[test]
18618    fn scoped_projection_edit_and_scope_transition_fail_closed() {
18619        let directory = tempfile::tempdir().unwrap();
18620        std::fs::write(
18621            directory.path().join("DB.md"),
18622            b"---\ntype: db-md\nscope: company\nowner: someone\n---\n",
18623        )
18624        .unwrap();
18625        let store = Store::open_strict(directory.path()).unwrap();
18626        let head = scoped_test_head(&"a".repeat(64));
18627        let baseline = scoped_test_baseline(&"a".repeat(64));
18628        let mut view = v2_local_files(&store).unwrap();
18629        assert!(matches!(
18630            remove_scoped_projection(&head, Some(&baseline), &mut view),
18631            Err(LinkError::ScopedProjectionModified)
18632        ));
18633
18634        let changed = scoped_test_head(&"b".repeat(64));
18635        assert!(matches!(
18636            ensure_v2_view_compatible(&changed, Some(&baseline)),
18637            Err(LinkError::ScopedViewChanged)
18638        ));
18639
18640        let mut same_view_new_control = head.clone();
18641        same_view_new_control.control_revision = "c".repeat(64);
18642        assert!(ensure_v2_view_compatible(&same_view_new_control, Some(&baseline)).is_ok());
18643        assert!(!same_v2_head(&head, &same_view_new_control));
18644    }
18645
18646    #[test]
18647    fn verified_baseline_reuse_requires_exact_content_assets_view_and_authority() {
18648        let mut head = scoped_test_head(&"a".repeat(64));
18649        head.control_revision = "b".repeat(64);
18650        head.pointer = Some(V2PointerBody {
18651            v: 2,
18652            brain: TEST_BRAIN_ID.to_string(),
18653            seq: 7,
18654            commit_hash: "c".repeat(64),
18655            feed_hash: "d".repeat(64),
18656            content_root: Some("e".repeat(64)),
18657            asset_root: Some("f".repeat(64)),
18658            materializer: "dbmd-projection-v1".to_string(),
18659            signer_epoch: 1,
18660            control_revision: head.control_revision.clone(),
18661            backup_preparation: "0".repeat(64),
18662            prior_pointer_hash: Some("1".repeat(64)),
18663            signed_at: "2026-08-24T00:00:00.000Z".to_string(),
18664        });
18665        let mut baseline = scoped_test_baseline(&head.view_revision);
18666        baseline.head_seq = Some(7);
18667        baseline.commit_hash = Some("c".repeat(64));
18668        baseline.content_root = Some("e".repeat(64));
18669        baseline.asset_root = Some("f".repeat(64));
18670        baseline.control_revision = Some(head.control_revision.clone());
18671        assert!(v2_baseline_matches_head(&head, &baseline));
18672
18673        let mut changed = baseline.clone();
18674        changed.head_seq = Some(8);
18675        assert!(!v2_baseline_matches_head(&head, &changed));
18676        let mut changed = baseline.clone();
18677        changed.commit_hash = Some("2".repeat(64));
18678        assert!(!v2_baseline_matches_head(&head, &changed));
18679        let mut changed = baseline.clone();
18680        changed.content_root = Some("3".repeat(64));
18681        assert!(!v2_baseline_matches_head(&head, &changed));
18682        let mut changed = baseline.clone();
18683        changed.asset_root = Some("4".repeat(64));
18684        assert!(!v2_baseline_matches_head(&head, &changed));
18685        let mut changed = baseline.clone();
18686        changed.view_revision = Some("5".repeat(64));
18687        assert!(!v2_baseline_matches_head(&head, &changed));
18688        let mut changed = baseline.clone();
18689        changed.control_revision = Some("6".repeat(64));
18690        assert!(!v2_baseline_matches_head(&head, &changed));
18691
18692        let mut changed_head = head.clone();
18693        changed_head.view_kind = "full".to_string();
18694        assert!(!v2_baseline_matches_head(&changed_head, &baseline));
18695    }
18696
18697    #[test]
18698    fn legacy_baseline_without_authority_revision_refreshes_before_reuse() {
18699        let sandbox = tempfile::tempdir().unwrap();
18700        let cfg = test_hub_config(
18701            "https://hub.example".to_string(),
18702            sandbox.path().to_path_buf(),
18703        );
18704        let head = scoped_test_head(&"a".repeat(64));
18705        let baseline = scoped_test_baseline(&head.view_revision);
18706        let mut encoded = serde_json::to_value(&baseline).unwrap();
18707        encoded.as_object_mut().unwrap().remove("control_revision");
18708        let parsed =
18709            parse_v2_baseline(&cfg, TEST_BRAIN_ID, &serde_json::to_vec(&encoded).unwrap()).unwrap();
18710        assert!(parsed.control_revision.is_none());
18711        assert!(!v2_baseline_matches_head(&head, &parsed));
18712    }
18713
18714    #[test]
18715    fn established_checkout_never_becomes_an_empty_clone_when_db_md_is_invalid() {
18716        let scoped = scoped_test_head(&"a".repeat(64));
18717        let scoped_baseline = scoped_test_baseline(&"a".repeat(64));
18718        assert!(matches!(
18719            ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), false),
18720            Err(LinkError::ScopedProjectionModified)
18721        ));
18722
18723        let mut full = scoped.clone();
18724        full.view_kind = "full".to_string();
18725        let mut full_baseline = scoped_baseline.clone();
18726        full_baseline.view_kind = Some("full".to_string());
18727        full_baseline.projection_sha256 = None;
18728        assert!(matches!(
18729            ensure_established_v2_checkout_opened(&full, Some(&full_baseline), false),
18730            Err(LinkError::InvalidPack { .. })
18731        ));
18732
18733        assert!(ensure_established_v2_checkout_opened(&scoped, None, false).is_ok());
18734        assert!(
18735            ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), true).is_ok()
18736        );
18737    }
18738
18739    #[test]
18740    fn scoped_view_metadata_is_explicitly_non_authoritative() {
18741        let head = scoped_test_head(&"a".repeat(64));
18742        let value: Value =
18743            serde_json::from_slice(&scoped_view_metadata(&head, 7).unwrap()).unwrap();
18744        assert_eq!(value["kind"], "link.md-scoped-view");
18745        assert_eq!(value["authoritative"], false);
18746        assert_eq!(value["visible_files"], 7);
18747        assert_eq!(value["brain"], TEST_BRAIN_ID);
18748    }
18749
18750    #[test]
18751    fn local_scoped_marker_requires_the_exact_generated_projection() {
18752        let directory = tempfile::tempdir().unwrap();
18753        std::fs::create_dir(directory.path().join(".dbmd")).unwrap();
18754        std::fs::write(
18755            directory.path().join("DB.md"),
18756            scoped_projection_bytes(TEST_BRAIN_ID),
18757        )
18758        .unwrap();
18759        let head = scoped_test_head(&"a".repeat(64));
18760        std::fs::write(
18761            directory.path().join(".dbmd/view.json"),
18762            scoped_view_metadata(&head, 0).unwrap(),
18763        )
18764        .unwrap();
18765        let store = Store::open_strict(directory.path()).unwrap();
18766        assert!(has_verified_local_scoped_view(&store));
18767
18768        std::fs::write(
18769            directory.path().join("DB.md"),
18770            b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
18771        )
18772        .unwrap();
18773        let altered = Store::open_strict(directory.path()).unwrap();
18774        assert!(!has_verified_local_scoped_view(&altered));
18775    }
18776
18777    fn signed_proposal_fixture() -> (V2VerifiedHead, String, Value) {
18778        use ring::signature::KeyPair as _;
18779
18780        let proposal_id = "01k2r7bm9w5x6e8nq3tjhv4cya".to_string();
18781        let rng = ring::rand::SystemRandom::new();
18782        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18783        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
18784        let public_der = [ED25519_SPKI_PREFIX.as_slice(), pair.public_key().as_ref()].concat();
18785        let public_key = URL_SAFE_NO_PAD.encode(&public_der);
18786        let fingerprint = format!("{:x}", Sha256::digest(&public_der));
18787        let blob = b"new";
18788        let blob_hash = content_sha256(blob);
18789        let changes = json!({
18790            "mutation_id": "sync:proposal-fixture",
18791            "operations": [{
18792                "blob": blob_hash,
18793                "bytes": blob.len(),
18794                "expected": null,
18795                "op": "put",
18796                "path": "records/new.md",
18797            }],
18798            "reason": "fixture",
18799            "v": 2,
18800        });
18801        let changes_bytes = crate::linkmd_v2::canonical_bytes(&changes).unwrap();
18802        let changes_base64 = STANDARD.encode(&changes_bytes);
18803        let descriptor = json!({
18804            "base": null,
18805            "blobs": [{ "bytes": blob.len(), "sha256": blob_hash }],
18806            "changes_base64": changes_base64,
18807            "rebase": "strict",
18808            "v": 2,
18809        });
18810        let clear_hash = content_sha256(&crate::linkmd_v2::canonical_bytes(&descriptor).unwrap());
18811        let payload_hash = "b".repeat(64);
18812        let submitted_at = "2026-08-19T12:00:00.000Z";
18813        let claim = json!({
18814            "actor_root": {
18815                "actor_class": "foreign_key",
18816                "credential": "ed25519:fixture",
18817                "grants": ["01k2r7bm9w5x6e8nq3tjhv4cyb"],
18818                "organization": "01k2r7bm9w5x6e8nq3tjhv4cyc",
18819                "principal": "key:fixture",
18820                "role": null,
18821            },
18822            "brain": TEST_BRAIN_ID,
18823            "clear_sha256": clear_hash,
18824            "control_revision": "c".repeat(64),
18825            "mutation_id": "sync:proposal-fixture",
18826            "payload_sha256": payload_hash,
18827            "proposal_id": proposal_id,
18828            "submitted_at": submitted_at,
18829            "v": 2,
18830        });
18831        let claim_bytes = crate::linkmd_v2::canonical_bytes(&claim).unwrap();
18832        let envelope = json!({
18833            "claim": claim,
18834            "fingerprint": fingerprint,
18835            "public_key": public_key,
18836            "sig": URL_SAFE_NO_PAD.encode(pair.sign(&claim_bytes).as_ref()),
18837        });
18838        let envelope_bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
18839        let submission_hash =
18840            crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &envelope_bytes).unwrap();
18841        let mut head = scoped_test_head(&"c".repeat(64));
18842        head.view_kind = "full".to_string();
18843        head.trust.hub_signer = Some(format!("{fingerprint}:{public_key}"));
18844        let value = json!({
18845            "proposal": {
18846                "base": null,
18847                "blobs": [{
18848                    "bytes": blob.len(),
18849                    "endpoint": format!(
18850                        "/api/hub/brains/{TEST_BRAIN_ID}/v2/proposals/{proposal_id}/blob?sha256={blob_hash}"
18851                    ),
18852                    "sha256": blob_hash,
18853                }],
18854                "changes_base64": changes_base64,
18855                "clear_sha256": clear_hash,
18856                "expires_at": "2026-08-26T12:00:00.000Z",
18857                "id": proposal_id,
18858                "payload_sha256": payload_hash,
18859                "proposer": { "class": "foreign_key" },
18860                "rebase": "strict",
18861                "state": "pending",
18862                "submission_claim_base64": STANDARD.encode(envelope_bytes),
18863                "submission_claim_sha256": submission_hash,
18864                "submitted_at": submitted_at,
18865            },
18866            "v": 2,
18867        });
18868        (head, proposal_id, value)
18869    }
18870
18871    #[test]
18872    fn v2_proposal_verifier_accepts_exact_signed_payload() {
18873        let (head, proposal_id, value) = signed_proposal_fixture();
18874        let verified = verify_v2_proposal_value(&head, &proposal_id, value).unwrap();
18875        assert_eq!(verified.blobs.len(), 1);
18876        assert_eq!(verified.changes["operations"][0]["path"], "records/new.md");
18877    }
18878
18879    #[test]
18880    fn v2_proposal_verifier_refuses_payload_endpoint_and_signature_tampering() {
18881        let (head, proposal_id, value) = signed_proposal_fixture();
18882
18883        let mut changed = value.clone();
18884        changed["proposal"]["changes_base64"] = Value::String(STANDARD.encode(b"{}"));
18885        assert!(verify_v2_proposal_value(&head, &proposal_id, changed).is_err());
18886
18887        let mut redirected = value.clone();
18888        redirected["proposal"]["blobs"][0]["endpoint"] =
18889            Value::String("https://attacker.example/blob".to_string());
18890        assert!(verify_v2_proposal_value(&head, &proposal_id, redirected).is_err());
18891
18892        let mut forged = value;
18893        let encoded = forged["proposal"]["submission_claim_base64"]
18894            .as_str()
18895            .unwrap();
18896        let mut envelope: Value =
18897            serde_json::from_slice(&STANDARD.decode(encoded).unwrap()).unwrap();
18898        envelope["sig"] = Value::String(URL_SAFE_NO_PAD.encode([0_u8; 64]));
18899        let bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
18900        forged["proposal"]["submission_claim_base64"] = Value::String(STANDARD.encode(&bytes));
18901        forged["proposal"]["submission_claim_sha256"] = Value::String(
18902            crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &bytes).unwrap(),
18903        );
18904        assert!(verify_v2_proposal_value(&head, &proposal_id, forged).is_err());
18905    }
18906
18907    #[cfg(unix)]
18908    #[test]
18909    fn v2_atomic_install_materializes_only_local_catalogs_before_swap() {
18910        let sandbox = tempfile::tempdir().unwrap();
18911        let destination = sandbox.path().join("brain");
18912        let entries = vec![
18913            (
18914                "DB.md".to_string(),
18915                scoped_projection_bytes(TEST_BRAIN_ID),
18916            ),
18917            (
18918                "records/contacts/a.md".to_string(),
18919                b"---\ntype: contact\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Visible contact\n---\n\n# A\n"
18920                    .to_vec(),
18921            ),
18922        ];
18923        install_pulled_delta(&destination, &entries, &[], true).unwrap();
18924        assert!(destination.join("index.md").is_file());
18925        assert!(destination.join("records/index.md").is_file());
18926        assert!(destination.join("records/contacts/index.md").is_file());
18927        assert!(destination.join("records/contacts/index.jsonl").is_file());
18928    }
18929
18930    #[cfg(unix)]
18931    #[test]
18932    fn v2_bounded_parallel_source_install_is_exact_and_atomic() {
18933        let sandbox = tempfile::tempdir().unwrap();
18934        let destination = sandbox.path().join("brain");
18935        let cache = sandbox.path().join("cache");
18936        std::fs::create_dir(&cache).unwrap();
18937        let db = scoped_projection_bytes(TEST_BRAIN_ID);
18938        let shared = b"---\ntype: note\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Shared\n---\n\n# Shared\n";
18939        let db_source = cache.join("db");
18940        let shared_source = cache.join("shared");
18941        crate::fsx::write_atomic(&db_source, &db).unwrap();
18942        crate::fsx::write_atomic(&shared_source, shared).unwrap();
18943        let mut entries = vec![V2StagedFile {
18944            path: "DB.md".to_string(),
18945            source: db_source,
18946            sha256: content_sha256(&db),
18947            bytes: db.len() as u64,
18948        }];
18949        for index in 0..512 {
18950            entries.push(V2StagedFile {
18951                path: format!("records/items/{index:05}.md"),
18952                source: shared_source.clone(),
18953                sha256: content_sha256(shared),
18954                bytes: shared.len() as u64,
18955            });
18956        }
18957        install_pulled_delta_sources(
18958            &destination,
18959            &entries,
18960            &[],
18961            false,
18962            None,
18963            &scoped_test_head(&"c".repeat(64)),
18964        )
18965        .unwrap();
18966        assert_eq!(std::fs::read(destination.join("DB.md")).unwrap(), db);
18967        for index in 0..512 {
18968            assert_eq!(
18969                std::fs::read(destination.join(format!("records/items/{index:05}.md"))).unwrap(),
18970                shared
18971            );
18972        }
18973        assert!(
18974            std::fs::read_dir(sandbox.path())
18975                .unwrap()
18976                .all(|entry| !entry
18977                    .unwrap()
18978                    .file_name()
18979                    .to_string_lossy()
18980                    .contains("pull-stage")),
18981            "the private stage must be atomically installed or removed"
18982        );
18983    }
18984
18985    #[cfg(unix)]
18986    #[test]
18987    fn v2_established_pull_touches_only_delta_and_rolls_back_exactly() {
18988        use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
18989
18990        let sandbox = tempfile::tempdir().unwrap();
18991        let root = sandbox.path().join("brain");
18992        std::fs::create_dir_all(root.join("records/items")).unwrap();
18993        let db = scoped_projection_bytes(TEST_BRAIN_ID);
18994        let old = b"---\ntype: note\n---\n\nold\n";
18995        let new = b"---\ntype: note\n---\n\nnew\n";
18996        let removed = b"---\ntype: note\n---\n\nremove me\n";
18997        std::fs::write(root.join("DB.md"), &db).unwrap();
18998        std::fs::write(root.join("records/items/change.md"), old).unwrap();
18999        std::fs::write(root.join("records/items/delete.md"), removed).unwrap();
19000        for index in 0..512 {
19001            std::fs::write(
19002                root.join(format!("records/items/untouched-{index:04}.md")),
19003                old,
19004            )
19005            .unwrap();
19006        }
19007        let untouched = root.join("records/items/untouched-0256.md");
19008        let untouched_inode = std::fs::metadata(&untouched).unwrap().ino();
19009        let source = sandbox.path().join("changed-source");
19010        crate::fsx::write_atomic(&source, new).unwrap();
19011        let same_source = sandbox.path().join("unchanged-source");
19012        crate::fsx::write_atomic(&same_source, old).unwrap();
19013        let same_entry = V2StagedFile {
19014            path: "records/items/change.md".to_string(),
19015            source: same_source,
19016            sha256: content_sha256(old),
19017            bytes: old.len() as u64,
19018        };
19019        let entry = V2StagedFile {
19020            path: "records/items/change.md".to_string(),
19021            source,
19022            sha256: content_sha256(new),
19023            bytes: new.len() as u64,
19024        };
19025        let head = scoped_test_head(&"c".repeat(64));
19026
19027        // A no-op established pull neither clones the checkout nor arms a
19028        // journal. Inode identity is a direct regression guard against the
19029        // former brain-wide private-copy implementation.
19030        install_established_v2_delta(
19031            Store::open_strict(&root).unwrap(),
19032            &[same_entry],
19033            &["records/items/already-absent.md".to_string()],
19034            true,
19035            None,
19036            &head,
19037        )
19038        .unwrap();
19039        assert_eq!(
19040            std::fs::metadata(&untouched).unwrap().ino(),
19041            untouched_inode
19042        );
19043        assert!(!root.join(V2_PULL_JOURNAL).exists());
19044
19045        install_established_v2_delta(
19046            Store::open_strict(&root).unwrap(),
19047            &[entry],
19048            &["records/items/delete.md".to_string()],
19049            false,
19050            None,
19051            &head,
19052        )
19053        .unwrap();
19054        assert_eq!(
19055            std::fs::read(root.join("records/items/change.md")).unwrap(),
19056            new
19057        );
19058        assert!(!root.join("records/items/delete.md").exists());
19059        assert_eq!(
19060            std::fs::metadata(&untouched).unwrap().ino(),
19061            untouched_inode
19062        );
19063        assert!(root.join(V2_PULL_JOURNAL).is_file());
19064        assert_eq!(
19065            std::fs::metadata(root.join(V2_PULL_JOURNAL))
19066                .unwrap()
19067                .permissions()
19068                .mode()
19069                & 0o777,
19070            0o600
19071        );
19072        let journal = load_v2_pull_journal(&Store::open_strict(&root).unwrap())
19073            .unwrap()
19074            .unwrap();
19075        assert_eq!(
19076            std::fs::metadata(root.join(&journal.backup_dir))
19077                .unwrap()
19078                .permissions()
19079                .mode()
19080                & 0o777,
19081            0o700
19082        );
19083        for entry in &journal.entries {
19084            if let Some(backup) = &entry.backup {
19085                assert_eq!(
19086                    std::fs::metadata(root.join(&journal.backup_dir).join(backup))
19087                        .unwrap()
19088                        .permissions()
19089                        .mode()
19090                        & 0o777,
19091                    0o600
19092                );
19093            }
19094        }
19095
19096        let cfg = test_hub_config(
19097            "https://example.test".to_string(),
19098            sandbox.path().join("state"),
19099        );
19100        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
19101        assert_eq!(
19102            std::fs::read(root.join("records/items/change.md")).unwrap(),
19103            old
19104        );
19105        assert_eq!(
19106            std::fs::read(root.join("records/items/delete.md")).unwrap(),
19107            removed
19108        );
19109        assert_eq!(
19110            std::fs::metadata(&untouched).unwrap().ino(),
19111            untouched_inode
19112        );
19113        assert!(!root.join(V2_PULL_JOURNAL).exists());
19114    }
19115
19116    #[test]
19117    fn v2_bulk_stream_is_exact_ordered_and_tamper_evident() {
19118        let body = b"bounded bytes";
19119        let path = "records/example.md".to_string();
19120        let file = V2BaselineFile {
19121            sha256: content_sha256(body),
19122            bytes: body.len() as u64,
19123            proof: None,
19124        };
19125        let header = serde_json::to_vec(&json!({
19126            "bytes": body.len(),
19127            "path": path,
19128            "sha256": file.sha256,
19129            "v": 2,
19130        }))
19131        .unwrap();
19132        let mut stream = V2_BULK_STREAM_MAGIC.to_vec();
19133        stream.extend_from_slice(&(header.len() as u32).to_be_bytes());
19134        stream.extend_from_slice(&header);
19135        stream.extend_from_slice(body);
19136        stream.extend_from_slice(&0_u32.to_be_bytes());
19137        let parsed = parse_v2_bulk_stream(&stream, &[(&path, &file)]).unwrap();
19138        assert_eq!(parsed, vec![(path.clone(), body.to_vec())]);
19139
19140        let mut tampered = stream.clone();
19141        let body_offset = V2_BULK_STREAM_MAGIC.len() + 4 + header.len();
19142        tampered[body_offset] ^= 1;
19143        assert!(parse_v2_bulk_stream(&tampered, &[(&path, &file)]).is_err());
19144
19145        let mut trailing = stream;
19146        trailing.push(0);
19147        assert!(parse_v2_bulk_stream(&trailing, &[(&path, &file)]).is_err());
19148    }
19149
19150    #[test]
19151    fn first_checkout_resolution_does_not_recreate_the_same_conflict() {
19152        let path = "records/value.md".to_string();
19153        let mut local = std::collections::BTreeMap::new();
19154        local.insert(path.clone(), (content_sha256(b"local"), 5));
19155        let mut remote = std::collections::BTreeMap::new();
19156        remote.insert(
19157            path.clone(),
19158            V2BaselineFile {
19159                sha256: content_sha256(b"remote"),
19160                bytes: 6,
19161                proof: None,
19162            },
19163        );
19164
19165        assert_eq!(
19166            v2_initial_content_conflicts(&local, &remote, false),
19167            vec![path]
19168        );
19169        assert!(v2_initial_content_conflicts(&local, &remote, true).is_empty());
19170
19171        let mut resolution = std::collections::BTreeMap::new();
19172        resolution.insert(
19173            "records/value.md".to_string(),
19174            V2ResolutionOverride {
19175                expected_remote: Some(content_sha256(b"remote")),
19176                selected_local: Some(content_sha256(b"local")),
19177            },
19178        );
19179        assert!(v2_resolution_allows_path(
19180            Some(&resolution),
19181            "records/value.md",
19182            true
19183        ));
19184        assert!(v2_resolution_allows_path(
19185            Some(&resolution),
19186            "records/new-target.md",
19187            false
19188        ));
19189        assert!(!v2_resolution_allows_path(
19190            Some(&resolution),
19191            "records/unreviewed-remote.md",
19192            true
19193        ));
19194    }
19195
19196    #[test]
19197    fn conflict_cache_prunes_only_expired_or_incomplete_state_by_default() {
19198        let sandbox = tempfile::TempDir::new().unwrap();
19199        let root = sandbox.path().join("brain");
19200        std::fs::create_dir_all(&root).unwrap();
19201        std::fs::write(
19202            root.join("DB.md"),
19203            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19204        )
19205        .unwrap();
19206        let store = Store::open_strict(&root).unwrap();
19207        let incomplete = crate::ulid::mint();
19208        store
19209            .create_dir_all(&v2_conflict_relative(&incomplete, "files"))
19210            .unwrap();
19211        let expired = crate::ulid::mint();
19212        store
19213            .create_dir_all(&v2_conflict_relative(&expired, "files"))
19214            .unwrap();
19215        let plan = V2ConflictPlan {
19216            v: 2,
19217            class: "content_resolution_required".to_string(),
19218            bundle: expired.clone(),
19219            brain: TEST_BRAIN_ID.to_string(),
19220            origin: "https://example.test".to_string(),
19221            created_unix: 0,
19222            expires_unix: 0,
19223            base_seq: None,
19224            base_commit: None,
19225            remote_seq: 0,
19226            remote_commit: None,
19227            remote_content_root: None,
19228            view_kind: "full".to_string(),
19229            view_revision: "a".repeat(64),
19230            files: vec![V2ConflictFile {
19231                path: "records/value.md".to_string(),
19232                base: V2ConflictCoordinate {
19233                    sha256: None,
19234                    bytes: None,
19235                    file: None,
19236                },
19237                local: V2ConflictCoordinate {
19238                    sha256: None,
19239                    bytes: None,
19240                    file: None,
19241                },
19242                remote: V2ConflictCoordinate {
19243                    sha256: None,
19244                    bytes: None,
19245                    file: None,
19246                },
19247            }],
19248        };
19249        let mut bytes = serde_json::to_vec(&plan).unwrap();
19250        bytes.push(b'\n');
19251        store
19252            .write_atomic_new(&v2_conflict_relative(&expired, "plan.json"), &bytes)
19253            .unwrap();
19254
19255        let listed = sync_conflicts(&root, false, false).unwrap();
19256        assert_eq!(listed["bundles"], 2);
19257        assert_eq!(listed["pruned"], 0);
19258        let pruned = sync_conflicts(&root, true, false).unwrap();
19259        assert_eq!(pruned["bundles"], 0);
19260        assert_eq!(pruned["pruned"], 2);
19261        assert!(!root.join(".dbmd/conflicts").join(incomplete).exists());
19262        assert!(!root.join(".dbmd/conflicts").join(expired).exists());
19263    }
19264
19265    #[test]
19266    fn corrupt_completed_conflict_state_requires_explicit_discard_all() {
19267        let sandbox = tempfile::TempDir::new().unwrap();
19268        let root = sandbox.path().join("brain");
19269        std::fs::create_dir_all(&root).unwrap();
19270        std::fs::write(
19271            root.join("DB.md"),
19272            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19273        )
19274        .unwrap();
19275        let store = Store::open_strict(&root).unwrap();
19276        let bundle = crate::ulid::mint();
19277        store
19278            .create_dir_all(&v2_conflict_relative(&bundle, "files"))
19279            .unwrap();
19280        store
19281            .write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), b"not-json\n")
19282            .unwrap();
19283
19284        assert!(sync_conflicts(&root, true, false).is_err());
19285        assert!(sync_conflicts(&root, false, true).is_err());
19286        let pruned = sync_conflicts(&root, true, true).unwrap();
19287        assert_eq!(pruned["pruned"], 1);
19288        assert!(!root.join(".dbmd/conflicts").join(bundle).exists());
19289    }
19290
19291    #[test]
19292    fn ready_pull_journal_rolls_back_exact_preimages() {
19293        let sandbox = tempfile::TempDir::new().unwrap();
19294        let root = sandbox.path().join("brain");
19295        std::fs::create_dir_all(root.join("records")).unwrap();
19296        std::fs::write(
19297            root.join("DB.md"),
19298            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19299        )
19300        .unwrap();
19301        let path = "records/value.md";
19302        let old = b"---\ntype: note\n---\n\nold\n";
19303        let new = b"---\ntype: note\n---\n\nnew\n";
19304        std::fs::write(root.join(path), old).unwrap();
19305        let store = Store::open_strict(&root).unwrap();
19306        let bundle = crate::ulid::mint();
19307        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
19308        store
19309            .create_private_dir_all(Path::new(&backup_dir))
19310            .unwrap();
19311        store
19312            .write_private_atomic_new(&Path::new(&backup_dir).join("00000000"), old)
19313            .unwrap();
19314        let journal = V2PullJournal {
19315            v: 1,
19316            phase: V2PullPhase::Ready,
19317            brain: TEST_BRAIN_ID.to_string(),
19318            previous: V2PullCoordinate {
19319                head_seq: None,
19320                commit_hash: None,
19321                view_kind: None,
19322                view_revision: None,
19323            },
19324            next: V2PullCoordinate {
19325                head_seq: Some(2),
19326                commit_hash: Some("c".repeat(64)),
19327                view_kind: Some("full".to_string()),
19328                view_revision: Some("d".repeat(64)),
19329            },
19330            backup_dir: backup_dir.clone(),
19331            entries: vec![V2PullJournalEntry {
19332                path: path.to_string(),
19333                old: Some(V2PullFileCoordinate {
19334                    sha256: content_sha256(old),
19335                    bytes: old.len() as u64,
19336                }),
19337                new: Some(V2PullFileCoordinate {
19338                    sha256: content_sha256(new),
19339                    bytes: new.len() as u64,
19340                }),
19341                backup: Some("00000000".to_string()),
19342            }],
19343        };
19344        validate_v2_pull_journal(&journal).unwrap();
19345        store
19346            .write_private_atomic_new(
19347                Path::new(V2_PULL_JOURNAL),
19348                &v2_pull_journal_bytes(&journal).unwrap(),
19349            )
19350            .unwrap();
19351        store.write_atomic(Path::new(path), new).unwrap();
19352
19353        let cfg = test_hub_config(
19354            "https://example.test".to_string(),
19355            sandbox.path().join("state"),
19356        );
19357        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
19358        assert_eq!(std::fs::read(root.join(path)).unwrap(), old);
19359        assert!(!root.join(V2_PULL_JOURNAL).exists());
19360        assert!(!root.join(backup_dir).exists());
19361    }
19362
19363    #[test]
19364    fn preparing_pull_journal_discards_only_private_staging() {
19365        let sandbox = tempfile::TempDir::new().unwrap();
19366        let root = sandbox.path().join("brain");
19367        std::fs::create_dir_all(root.join(".dbmd")).unwrap();
19368        std::fs::write(
19369            root.join("DB.md"),
19370            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19371        )
19372        .unwrap();
19373        let store = Store::open_strict(&root).unwrap();
19374        let bundle = crate::ulid::mint();
19375        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
19376        store
19377            .create_private_dir_all(Path::new(&backup_dir))
19378            .unwrap();
19379        let journal = V2PullJournal {
19380            v: 1,
19381            phase: V2PullPhase::Preparing,
19382            brain: TEST_BRAIN_ID.to_string(),
19383            previous: V2PullCoordinate {
19384                head_seq: None,
19385                commit_hash: None,
19386                view_kind: None,
19387                view_revision: None,
19388            },
19389            next: V2PullCoordinate {
19390                head_seq: Some(1),
19391                commit_hash: Some("a".repeat(64)),
19392                view_kind: Some("full".to_string()),
19393                view_revision: Some("b".repeat(64)),
19394            },
19395            backup_dir: backup_dir.clone(),
19396            entries: vec![V2PullJournalEntry {
19397                path: "records/new.md".to_string(),
19398                old: None,
19399                new: Some(V2PullFileCoordinate {
19400                    sha256: "c".repeat(64),
19401                    bytes: 1,
19402                }),
19403                backup: None,
19404            }],
19405        };
19406        store
19407            .write_private_atomic_new(
19408                Path::new(V2_PULL_JOURNAL),
19409                &v2_pull_journal_bytes(&journal).unwrap(),
19410            )
19411            .unwrap();
19412        let cfg = test_hub_config(
19413            "https://example.test".to_string(),
19414            sandbox.path().join("state"),
19415        );
19416
19417        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
19418
19419        assert!(root.join("DB.md").is_file());
19420        assert!(!root.join(V2_PULL_JOURNAL).exists());
19421        assert!(!root.join(backup_dir).exists());
19422    }
19423
19424    #[test]
19425    fn committed_baseline_keeps_installed_bytes_and_prunes_orphans() {
19426        let sandbox = tempfile::TempDir::new().unwrap();
19427        let root = sandbox.path().join("brain");
19428        std::fs::create_dir_all(root.join("records")).unwrap();
19429        std::fs::write(
19430            root.join("DB.md"),
19431            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19432        )
19433        .unwrap();
19434        let new = b"---\ntype: note\n---\n\nnew\n";
19435        std::fs::write(root.join("records/value.md"), new).unwrap();
19436        let store = Store::open_strict(&root).unwrap();
19437        let bundle = crate::ulid::mint();
19438        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
19439        store
19440            .create_private_dir_all(Path::new(&backup_dir))
19441            .unwrap();
19442        let orphan = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
19443        store.create_private_dir_all(Path::new(&orphan)).unwrap();
19444        let next = V2PullCoordinate {
19445            head_seq: Some(2),
19446            commit_hash: Some("c".repeat(64)),
19447            view_kind: Some("full".to_string()),
19448            view_revision: Some("d".repeat(64)),
19449        };
19450        let journal = V2PullJournal {
19451            v: 1,
19452            phase: V2PullPhase::Ready,
19453            brain: TEST_BRAIN_ID.to_string(),
19454            previous: V2PullCoordinate {
19455                head_seq: Some(1),
19456                commit_hash: Some("a".repeat(64)),
19457                view_kind: Some("full".to_string()),
19458                view_revision: Some("b".repeat(64)),
19459            },
19460            next: next.clone(),
19461            backup_dir: backup_dir.clone(),
19462            entries: vec![V2PullJournalEntry {
19463                path: "records/value.md".to_string(),
19464                old: Some(V2PullFileCoordinate {
19465                    sha256: "e".repeat(64),
19466                    bytes: new.len() as u64,
19467                }),
19468                new: Some(V2PullFileCoordinate {
19469                    sha256: content_sha256(new),
19470                    bytes: new.len() as u64,
19471                }),
19472                backup: Some("00000000".to_string()),
19473            }],
19474        };
19475        store
19476            .write_private_atomic_new(
19477                Path::new(V2_PULL_JOURNAL),
19478                &v2_pull_journal_bytes(&journal).unwrap(),
19479            )
19480            .unwrap();
19481        let cfg = test_hub_config(
19482            "https://example.test".to_string(),
19483            sandbox.path().join("state"),
19484        );
19485        save_v2_baseline(
19486            &cfg,
19487            TEST_BRAIN_ID,
19488            &root,
19489            &V2SyncBaseline {
19490                v: 2,
19491                origin: "https://example.test".to_string(),
19492                brain: TEST_BRAIN_ID.to_string(),
19493                checkout_id: Some("c".repeat(64)),
19494                head_seq: next.head_seq,
19495                commit_hash: next.commit_hash.clone(),
19496                content_root: Some("f".repeat(64)),
19497                asset_root: None,
19498                assets: Default::default(),
19499                view_kind: next.view_kind.clone(),
19500                view_revision: next.view_revision.clone(),
19501                control_revision: Some("d".repeat(64)),
19502                projection_sha256: None,
19503                files: Default::default(),
19504                local_policy_digest: None,
19505                local_eligibility: Default::default(),
19506                remote_copy_remains: Default::default(),
19507            },
19508        )
19509        .unwrap();
19510
19511        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
19512
19513        assert_eq!(std::fs::read(root.join("records/value.md")).unwrap(), new);
19514        assert!(!root.join(V2_PULL_JOURNAL).exists());
19515        assert!(!root.join(backup_dir).exists());
19516        assert!(!root.join(orphan).exists());
19517    }
19518}